[
  {
    "path": ".devcontainer/Dockerfile",
    "content": "# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster\nARG VARIANT=20\nFROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base\n\n# Setup the node environment\nENV NODE_ENV=development\n\n# Install additional OS packages.\nRUN apt-get update && \\\n  DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \\\n    curl tzdata ffmpeg && \\\n  rm -rf /var/lib/apt/lists/*\n"
  },
  {
    "path": ".devcontainer/dev.js",
    "content": "// Using port 3333 is important when running the client web app separately\nconst Path = require('path')\nmodule.exports.config = {\n  Port: 3333,\n  ConfigPath: Path.resolve('config'),\n  MetadataPath: Path.resolve('metadata'),\n  FFmpegPath: '/usr/bin/ffmpeg',\n  FFProbePath: '/usr/bin/ffprobe',\n  SkipBinariesCheck: false\n}\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node\n{\n\t\"name\": \"Audiobookshelf\",\n\t\"build\": {\n\t\t\"dockerfile\": \"Dockerfile\",\n\t\t// Update 'VARIANT' to pick a Node version: 18, 16, 14.\n\t\t// Append -bullseye or -buster to pin to an OS version.\n\t\t// Use -bullseye variants on local arm64/Apple Silicon.\n\t\t\"args\": {\n\t\t\t\"VARIANT\": \"20\"\n\t\t}\n\t},\n\t\"mounts\": [\n\t\t\"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume\",\n\t\t\"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume\"\n\t],\n\t// Features to add to the dev container. More info: https://containers.dev/features.\n\t// \"features\": {},\n\t// Use 'forwardPorts' to make a list of ports inside the container available locally.\n\t\"forwardPorts\": [\n\t\t3000,\n\t\t3333\n\t],\n\t// Use 'postCreateCommand' to run commands after the container is created.\n\t\"postCreateCommand\": \"sh .devcontainer/post-create.sh\",\n\t// Configure tool-specific properties.\n\t\"customizations\": {\n\t\t// Configure properties specific to VS Code.\n\t\t\"vscode\": {\n\t\t\t// Add the IDs of extensions you want installed when the container is created.\n\t\t\t\"extensions\": [\n\t\t\t\t\"dbaeumer.vscode-eslint\",\n\t\t\t\t\"octref.vetur\"\n\t\t\t]\n\t\t}\n\t}\n\t// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.\n\t// \"remoteUser\": \"root\"\n}"
  },
  {
    "path": ".devcontainer/post-create.sh",
    "content": "#!/bin/sh\n\n# Mark the working directory as safe for use with git\ngit config --global --add safe.directory $PWD\n\n# If there is no dev.js file, create it\nif [ ! -f dev.js ]; then\n  cp .devcontainer/dev.js .\nfi\n\n# Update permissions for node_modules folders\n# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume\nif [ -d node_modules ]; then\n  sudo chown $(id -u):$(id -g) node_modules\nfi\n\nif [ -d client/node_modules ]; then\n  sudo chown $(id -u):$(id -g) client/node_modules\nfi\n\n# Install packages for the server\nif [ -f package.json ]; then\n    npm ci\nfi\n\n# Install packages and build the client\nif [ -f client/package.json ]; then\n    (cd client; npm ci; npm run generate)\nfi\n"
  },
  {
    "path": ".dockerignore",
    "content": ".env\nnode_modules\nnpm-debug.log\n.git\n.gitignore\n/config\n/audiobooks\n/audiobooks2\n/media/\n/metadata\ndev.js\ntest/\n/client/.nuxt/\n/client/dist/\n/dist/\n/deploy/"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\ncharset = utf-8\ninsert_final_newline = true\ntrim_trailing_whitespace = true"
  },
  {
    "path": ".gitattributes",
    "content": "# Set the default behavior, in case people don't have core.autocrlf set.\n* text=auto\n\n# Declare files that will always have CRLF line endings on checkout.\n.devcontainer/post-create.sh text eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug.yaml",
    "content": "name: 🐞 Bug Report\ndescription: File a bug/issue and help us improve Audiobookshelf\ntitle: '[Bug]: '\nlabels: ['bug', 'triage']\nbody:\n  - type: markdown\n    attributes:\n      value: 'Thank you for filing a bug report! 🐛'\n  - type: markdown\n    attributes:\n      value: 'Please first search for your issue and check the [docs](https://audiobookshelf.org/docs).'\n  - type: markdown\n    attributes:\n      value: 'Report issues with the mobile app [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose).'\n  - type: markdown\n    attributes:\n      value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: What happened?\n      placeholder: Tell us what you see!\n    validations:\n      required: true\n  - type: textarea\n    id: what-was-expected\n    attributes:\n      label: What did you expect to happen?\n      placeholder: Tell us what you expected to see! Be as descriptive as you can and include screenshots if applicable.\n    validations:\n      required: true\n  - type: textarea\n    id: steps-to-reproduce\n    attributes:\n      label: Steps to reproduce the issue\n      value: '1. '\n    validations:\n      required: true\n  - type: markdown\n    attributes:\n      value: '## Install Environment'\n  - type: input\n    id: version\n    attributes:\n      label: Audiobookshelf version\n      description: Do not put 'Latest version', please put the actual version here\n      placeholder: 'e.g. v1.6.60'\n    validations:\n      required: true\n  - type: dropdown\n    id: install\n    attributes:\n      label: How are you running audiobookshelf?\n      options:\n        - Docker\n        - Debian/PPA\n        - Windows Tray App\n        - Built from source\n        - Other (list in \"Additional Notes\" box)\n    validations:\n      required: true\n  - type: dropdown\n    id: server-os\n    attributes:\n      label: What OS is your Audiobookshelf server hosted from?\n      options:\n        - Windows\n        - macOS\n        - Linux\n        - Other (list in \"Additional Notes\" box)\n    validations:\n      required: true\n  - type: dropdown\n    id: desktop-browsers\n    attributes:\n      label: If the issue is being seen in the UI, what browsers are you seeing the problem on?\n      options:\n        - Chrome\n        - Firefox\n        - Safari\n        - Edge\n        - Firefox for Android\n        - Chrome for Android\n        - Safari on iOS\n        - Other (list in \"Additional Notes\" box)\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs\n      description: Please include any relevant logs here. This field is automatically formatted into code, so you do not need to include any backticks.\n      placeholder: Paste logs here\n      render: shell\n  - type: textarea\n    id: additional-notes\n    attributes:\n      label: Additional Notes\n      description: Anything else you want to add?\n      placeholder: 'e.g. I have tried X, Y, and Z.'\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Discord\n    url: https://discord.gg/HQgCbd6E75\n    about: Ask questions, get help troubleshooting, and join the Abs community here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature.yml",
    "content": "name: 🚀 Feature Request\ndescription: Request a feature/enhancement\ntitle: '[Enhancement]: '\nlabels: ['enhancement']\nbody:\n  - type: markdown\n    attributes:\n      value: '#### *Mobile app features should be [requested here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)*.'\n  - type: markdown\n    attributes:\n      value: '## Web/Server Feature Request Description'\n  - type: markdown\n    attributes:\n      value: 'Please first search in both issues & discussions for your enhancement.'\n  - type: dropdown\n    id: enhancment-type\n    attributes:\n      label: Type of Enhancement\n      options:\n        - Server Backend\n        - Web Interface/Frontend\n        - Documentation\n  - type: textarea\n    id: describe\n    attributes:\n      label: Describe the Feature/Enhancement\n      description: Please help us understand what you want.\n      placeholder: What is your vision?\n    validations:\n      required: true\n  - type: textarea\n    id: the-why\n    attributes:\n      label: Why would this be helpful?\n      description: Please help us understand why this would enhance your experience.\n      placeholder: Explain the \"why\" or \"use case\".\n    validations:\n      required: true\n  - type: textarea\n    id: image\n    attributes:\n      label: Future Implementation (Screenshot)\n      description: Please help us visualize by including a doodle or screenshot.\n      placeholder: How could this look?\n    validations:\n      required: true\n  - type: markdown\n    attributes:\n      value: '## Web/Server Current Implementation'\n  - type: input\n    id: version\n    attributes:\n      label: Audiobookshelf Server Version\n      description: Do not put 'Latest version', please put your current version number here\n      placeholder: 'e.g. v1.6.60'\n    validations:\n      required: true\n  - type: textarea\n    id: current-image\n    attributes:\n      label: Current Implementation (Screenshot)\n      description: What page were you looking at when you thought of this enhancement?\n      placeholder: If an image is not applicable, please explain why.\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!--\nFor Work In Progress Pull Requests, please use the Draft PR feature,\nsee https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.\n\nIf you do not follow this template, the PR may be closed without review.\n\nPlease ensure all checks pass.\nIf you are a new contributor, the workflows will need to be manually approved before they run.\n-->\n\n## Brief summary\n\n<!-- Please provide a brief summary of what your PR attempts to achieve. -->\n\n## Which issue is fixed?\n\n<!-- Which issue number does this PR fix? Ex: \"Fixes #1234\" -->\n\n## In-depth Description\n\n<!--\nDescribe your solution in more depth.\nHow does it work? Why is this the best solution?\nDoes it solve a problem that affects multiple users or is this an edge case for your setup?\n-->\n\n## How have you tested this?\n\n<!-- Please describe in detail with reproducible steps how you tested your changes. -->\n\n## Screenshots\n\n<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->\n"
  },
  {
    "path": ".github/workflows/apply_comments.yaml",
    "content": "name: Add issue comments by label\non:\n  issues:\n    types:\n      - labeled\njobs:\n  help-wanted:\n    if: github.event.label.name == 'help wanted'\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n    steps:\n      - name: Help wanted comment\n        run: gh issue comment \"$NUMBER\" --body \"$BODY\"\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          GH_REPO: ${{ github.repository }}\n          NUMBER: ${{ github.event.issue.number }}\n          BODY: >\n            This issue is not able to be completed due to limited bandwidth or access to the required test hardware.\n            \n            This issue is available for anyone to work on.\n\n\n  config-issue:\n    if: github.event.label.name == 'config-issue'\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n    steps:\n      - name: Config issue comment\n        run: gh issue close \"$NUMBER\" --reason \"not planned\" --comment \"$BODY\"\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          GH_REPO: ${{ github.repository }}\n          NUMBER: ${{ github.event.issue.number }}\n          BODY: >\n            After reviewing this issue, this appears to be a problem with your setup and not Audiobookshelf. This issue is being closed to keep the issue tracker focused on Audiobookshelf itself. Please reach out on the Audiobookshelf Discord for community support.\n            \n            Some common search terms to help you find the solution to your problem:\n              - Reverse proxy\n              - Enabling websockets\n              - SSL (https vs http)\n              - Configuring a static IP\n              - `localhost` versus IP address\n              - hairpin NAT\n              - VPN\n              - firewall ports\n              - public versus private network\n              - bridge versus host mode\n              - Docker networking\n              - DNS (such as EAI_AGAIN errors)\n            \n            After you have followed these steps, please post the solution or steps you followed to fix the problem to help others in the future, or show that it is a problem with Audiobookshelf so we can reopen the issue.\n\n"
  },
  {
    "path": ".github/workflows/close-issues-on-release.yml",
    "content": "name: Close fixed issues on release.\non:\n  release:\n    types: [published]\n\npermissions:\n  contents: read\n  issues: write\n\njobs:\n  comment:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Close issues marked as fixed upon a release.\n        uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5\n        with:\n          label: 'awaiting release'\n          removeLabel: true\n          applyToAll: true\n          message: Fixed in [${releaseTag}](${releaseUrl}).\n"
  },
  {
    "path": ".github/workflows/close_blank_issues.yaml",
    "content": "name: Close Issues not using a template\n\non:\n  issues:\n    types:\n      - opened\n\npermissions:\n  issues: write\n\njobs:\n  close_issue:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Check issue headings\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issueBody = context.payload.issue.body || \"\";\n\n            // Match Markdown headings (e.g., # Heading, ## Heading)\n            const headingRegex = /^(#{1,6})\\s.+/gm;\n            const headings = [...issueBody.matchAll(headingRegex)];\n\n            if (headings.length < 3) {\n              // Post a comment\n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: context.payload.issue.number,\n                body: \"Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!\"\n              });\n\n              // Close the issue\n              await github.rest.issues.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: context.payload.issue.number,\n                state: \"closed\"\n              });\n            }\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: 'CodeQL'\n\non:\n  push:\n    branches: ['master']\n    # Only build when files in these directories have been changed\n    paths:\n      - client/**\n      - server/**\n      - test/**\n      - index.js\n      - package.json\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: ['master']\n    # Only build when files in these directories have been changed\n    paths:\n      - client/**\n      - server/**\n      - test/**\n      - index.js\n      - package.json\n  schedule:\n    - cron: '16 5 * * 4'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: ['javascript']\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Use only 'java' to analyze code written in Java, Kotlin or both\n        # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both\n        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\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          # 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      # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).\n      # If this step fails, then you should remove it and run the build manually (see below)\n      - name: Autobuild\n        uses: github/codeql-action/autobuild@v3\n\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\n      #   If the Autobuild fails above, remove it and uncomment the following three lines.\n      #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n      # - run: |\n      #     echo \"Run, Build Application using script\"\n      #     ./location_of_script_within_repo/buildscript.sh\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v3\n        with:\n          category: '/language:${{matrix.language}}'\n"
  },
  {
    "path": ".github/workflows/component-tests.yml",
    "content": "name: Run Component Tests\n\non:\n  workflow_dispatch:\n    inputs:\n      ref:\n        description: 'Branch/Tag/SHA to test'\n        required: true\n  pull_request:\n    paths:\n      - 'client/**'\n      - '.github/workflows/component-tests.yml'\n  push:\n    paths:\n      - 'client/**'\n      - '.github/workflows/component-tests.yml'\n\njobs:\n  run-component-tests:\n    name: Run Component Tests\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout (push/pull request)\n        uses: actions/checkout@v4\n        if: github.event_name != 'workflow_dispatch'\n\n      - name: Checkout (workflow_dispatch)\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ inputs.ref }}\n        if: github.event_name == 'workflow_dispatch'\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: |\n          cd client\n          npm ci\n\n      - name: Run tests\n        run: |\n          cd client\n          npm test\n"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "---\nname: Build and Push Docker Image\n\non:\n  # Allows you to run workflow manually from Actions tab\n  workflow_dispatch:\n    inputs:\n      tags:\n        description: 'Docker Tag'\n        required: true\n        default: 'latest'\n  push:\n    branches: [main, master]\n    tags:\n      - 'v*.*.*'\n    # Only build when files in these directories have been changed\n    paths:\n      - client/**\n      - server/**\n      - index.js\n      - package.json\n\njobs:\n  build:\n    if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }}\n    runs-on: ubuntu-24.04\n\n    steps:\n      - name: Check out\n        uses: actions/checkout@v4\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf\n          tags: |\n            type=edge,branch=master\n            type=semver,pattern={{version}}\n\n      - name: Setup QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Cache Docker layers\n        uses: actions/cache@v4\n        with:\n          path: /tmp/.buildx-cache\n          key: ${{ runner.os }}-buildx-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-buildx-\n\n      - name: Login to Dockerhub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n\n      - name: Login to ghcr\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GHCR_PASSWORD }}\n\n      - name: Build image\n        uses: docker/build-push-action@v6\n        with:\n          tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: true\n          cache-from: type=local,src=/tmp/.buildx-cache\n          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max\n\n      - name: Move cache\n        run: |\n          rm -rf /tmp/.buildx-cache\n          mv /tmp/.buildx-cache-new /tmp/.buildx-cache\n"
  },
  {
    "path": ".github/workflows/i18n-integration.yml",
    "content": "name: Verify all i18n files are alphabetized\n\non:\n  pull_request:\n    paths:\n      - client/strings/** # Should only check if any strings changed\n  push:\n    paths:\n      - client/strings/** # Should only check if any strings changed\n\njobs:\n  update_translations:\n    runs-on: ubuntu-latest\n    steps:\n      # Check out the repository\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      # Set up node to run the javascript\n      - name: Set up node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      # The only argument is the `directory`, which is where the i18n files are\n      # stored.\n      - name: Run Update JSON Files action\n        uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0\n        with:\n          directory: 'client/strings/' # Adjust the directory path as needed\n"
  },
  {
    "path": ".github/workflows/integration-test.yml",
    "content": "name: Integration Test\n\non:\n  pull_request:\n  push:\n    branches-ignore:\n      - 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests\n    # Only build when files in these directories have been changed\n    paths:\n      - client/**\n      - server/**\n      - test/**\n      - index.js\n      - package.json\n\njobs:\n  build:\n    name: build and test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: setup node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: install pkg (using yao-pkg fork for targeting node20)\n        run: npm install -g @yao-pkg/pkg\n\n      - name: get client dependencies\n        working-directory: client\n        run: npm ci\n\n      - name: build client\n        working-directory: client\n        run: npm run generate\n\n      - name: get server dependencies\n        run: npm ci --only=production\n\n      - name: build binary\n        run: pkg -t node20-linux-x64 -o audiobookshelf .\n\n      - name: run audiobookshelf\n        run: |\n          ./audiobookshelf &\n          sleep 5\n\n      - name: test if server is available\n        run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf\n"
  },
  {
    "path": ".github/workflows/lint-openapi.yml",
    "content": "name: API linting\n\n# Run on pull requests or pushes when there is a change to any OpenAPI files in docs/\non:\n  pull_request:\n  push:\n    paths:\n      - 'docs/**'\n\n# This action only needs read permissions\npermissions:\n  contents: read\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      # Check out the repository\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      # Set up node to run the javascript\n      - name: Set up node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      # Install Redocly CLI\n      - name: Install Redocly CLI\n        run: npm install -g @redocly/cli@latest\n\n      # Perform linting for exploded spec\n      - name: Run linting for exploded spec\n        run: redocly lint docs/root.yaml --format=github-actions\n\n      # Perform linting for bundled spec\n      - name: Run linting for bundled spec\n        run: redocly lint docs/openapi.json --format=github-actions\n"
  },
  {
    "path": ".github/workflows/notify-abs-windows.yml",
    "content": "name: Dispatch an abs-windows event\n\non:\n  release:\n    types: [published]\n  workflow_dispatch:\n\njobs:\n  abs-windows-dispatch:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Send a remote repository dispatch event\n        uses: peter-evans/repository-dispatch@v3\n        with:\n          token: ${{ secrets.ABS_WINDOWS_PAT }}\n          repository: mikiher/audiobookshelf-windows\n          event-type: build-windows\n"
  },
  {
    "path": ".github/workflows/unit-tests.yml",
    "content": "name: Run Unit Tests\n\non:\n  workflow_dispatch:\n    inputs:\n      ref:\n        description: 'Branch/Tag/SHA to test'\n        required: true\n  pull_request:\n  push:\n\njobs:\n  run-unit-tests:\n    name: Run Unit Tests\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout (push/pull request)\n        uses: actions/checkout@v4\n        if: github.event_name != 'workflow_dispatch'\n\n      - name: Checkout (workflow_dispatch)\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ inputs.ref }}\n        if: github.event_name == 'workflow_dispatch'\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests\n        run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": ".env\n/dev.js\n**/node_modules/\n/config/\n/audiobooks/\n/audiobooks2/\n/podcasts/\n/media/\n/metadata/\n/plugins/\n/client/.nuxt/\n/client/dist/\n/dist/\n/deploy/\n/coverage/\n/.nyc_output/\n/ffmpeg*\n/ffprobe*\n/unicode*\n/libnusqlite3*\n\nsw.*\n.DS_STORE\n.idea/*\ntailwind.compiled.css\ntailwind.config.js\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"printWidth\": 400,\n  \"proseWrap\": \"never\",\n  \"trailingComma\": \"none\",\n  \"overrides\": [\n    {\n      \"files\": [\"*.html\"],\n      \"options\": {\n        \"singleQuote\": false,\n        \"wrapAttributes\": false,\n        \"sortAttributes\": false\n      }\n    }\n  ]\n}"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"EditorConfig.EditorConfig\",\n    \"esbenp.prettier-vscode\",\n    \"octref.vetur\"\n  ]\n}"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Debug server\",\n      \"runtimeExecutable\": \"npm\",\n      \"args\": [\n        \"run\",\n        \"dev\"\n      ],\n      \"skipFiles\": [\n        \"<node_internals>/**\"\n      ]\n    },\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Debug client (nuxt)\",\n      \"runtimeExecutable\": \"npm\",\n      \"args\": [\n        \"run\",\n        \"dev\"\n      ],\n      \"cwd\": \"${workspaceFolder}/client\",\n      \"skipFiles\": [\n        \"${workspaceFolder}/<node_internals>/**\"\n      ]\n    }\n  ],\n  \"compounds\": [\n    {\n      \"name\": \"Debug server and client (nuxt)\",\n      \"configurations\": [\n        \"Debug server\",\n        \"Debug client (nuxt)\"\n      ]\n    }\n  ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"vetur.format.defaultFormatterOptions\": {\n    \"prettier\": {\n      \"semi\": false,\n      \"singleQuote\": true,\n      \"printWidth\": 400,\n      \"proseWrap\": \"never\",\n      \"trailingComma\": \"none\"\n    },\n    \"prettyhtml\": {\n      \"printWidth\": 400,\n      \"singleQuote\": false,\n      \"wrapAttributes\": false,\n      \"sortAttributes\": false\n    }\n  },\n  \"editor.formatOnSave\": true,\n  \"editor.detectIndentation\": true,\n  \"editor.tabSize\": 2,\n  \"javascript.format.semicolons\": \"remove\",\n  \"[javascript][json][jsonc]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  },\n  \"[vue]\": {\n    \"editor.defaultFormatter\": \"octref.vetur\"\n  }\n}"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n\t\"version\": \"2.0.0\",\n\t\"tasks\": [\n\t\t{\n\t\t\t\"path\": \"client\",\n\t\t\t\"type\": \"npm\",\n\t\t\t\"script\": \"generate\",\n\t\t\t\"detail\": \"nuxt generate\",\n\t\t\t\"label\": \"Build client\",\n\t\t\t\"group\": {\n\t\t\t\t\"kind\": \"build\",\n\t\t\t\t\"isDefault\": true\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"dependsOn\": [\n\t\t\t\t\"Build client\"\n\t\t\t],\n\t\t\t\"type\": \"npm\",\n\t\t\t\"script\": \"dev\",\n\t\t\t\"detail\": \"nodemon --watch server index.js\",\n\t\t\t\"label\": \"Run server\",\n\t\t\t\"group\": {\n\t\t\t\t\"kind\": \"test\",\n\t\t\t\t\"isDefault\": true\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"path\": \"client\",\n\t\t\t\"type\": \"npm\",\n\t\t\t\"script\": \"dev\",\n\t\t\t\"detail\": \"nuxt\",\n\t\t\t\"label\": \"Run Live-reload client\",\n\t\t\t\"group\": {\n\t\t\t\t\"kind\": \"test\",\n\t\t\t\t\"isDefault\": false\n\t\t\t}\n\t\t}\n\t]\n}"
  },
  {
    "path": "Dockerfile",
    "content": "ARG NUSQLITE3_DIR=\"/usr/local/lib/nusqlite3\"\nARG NUSQLITE3_PATH=\"${NUSQLITE3_DIR}/libnusqlite3.so\"\n\n### STAGE 0: Build client ###\nFROM node:20-alpine AS build-client\n\nWORKDIR /client\nCOPY /client /client\nRUN npm ci && npm cache clean --force\nRUN npm run generate\n\n### STAGE 1: Build server ###\nFROM node:20-alpine AS build-server\n\nARG NUSQLITE3_DIR\nARG TARGETPLATFORM\n\nENV NODE_ENV=production\n\nRUN apk add --no-cache --update \\\n  curl \\\n  make \\\n  python3 \\\n  g++ \\\n  unzip\n\nWORKDIR /server\nCOPY index.js package* /server\nCOPY /server /server/server\n\nRUN case \"$TARGETPLATFORM\" in \\\n  \"linux/amd64\") \\\n  curl -L -o /tmp/library.zip \"https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip\" ;; \\\n  \"linux/arm64\") \\\n  curl -L -o /tmp/library.zip \"https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-arm64.zip\" ;; \\\n  *) echo \"Unsupported platform: $TARGETPLATFORM\" && exit 1 ;; \\\n  esac && \\\n  unzip /tmp/library.zip -d $NUSQLITE3_DIR && \\\n  rm /tmp/library.zip\n\nRUN npm ci --only=production\n\n### STAGE 2: Create minimal runtime image ###\nFROM node:20-alpine\n\nARG NUSQLITE3_DIR\nARG NUSQLITE3_PATH\n\n# Install only runtime dependencies\nRUN apk add --no-cache --update \\\n  tzdata \\\n  ffmpeg \\\n  tini\n\nWORKDIR /app\n\n# Copy compiled frontend and server from build stages\nCOPY --from=build-client /client/dist /app/client/dist\nCOPY --from=build-server /server /app\nCOPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}\n\nEXPOSE 80\n\nENV PORT=80\nENV NODE_ENV=production\nENV CONFIG_PATH=\"/config\"\nENV METADATA_PATH=\"/metadata\"\nENV SOURCE=\"docker\"\nENV NUSQLITE3_DIR=${NUSQLITE3_DIR}\nENV NUSQLITE3_PATH=${NUSQLITE3_PATH}\n\nENTRYPOINT [\"tini\", \"--\"]\nCMD [\"node\", \"index.js\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 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 General Public License is a free, copyleft license for\nsoftware and other kinds of works.\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,\nthe GNU General Public License is 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.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\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  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\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 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. Use with the GNU Affero General Public License.\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 Affero 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 special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe 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 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 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 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    Audiobookshelf  Self-hosted audiobook server\n    Copyright (C) 2021  advplyr\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU 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 General Public License for more details.\n\n    You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    Audiobookshelf  Copyright (C) 2021  advplyr\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\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 GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>."
  },
  {
    "path": "build/debian/DEBIAN/control",
    "content": "Package: audiobookshelf\nVersion: 1.6.41\nSection: base\nPriority: optional\nArchitecture: amd64\nDepends:\nMaintainer: advplyr\nDescription: Self-hosted audiobook server for managing and playing audiobooks\n"
  },
  {
    "path": "build/debian/DEBIAN/postinst",
    "content": "#!/bin/bash\nset -e\nset -o pipefail\n\nABS_LOG_DIR=\"/var/log/audiobookshelf\"\n\ndeclare -r init_type='auto'\ndeclare -ri no_rebuild='0'\n\nstart_service () {\n  : \"${1:?'Service name was not defined'}\"\n  declare -r service_name=\"$1\"\n\n  if hash systemctl 2> /dev/null; then\n    if [[ \"$init_type\" == 'auto' || \"$init_type\" == 'systemd' ]]; then\n      {\n        systemctl enable \"$service_name.service\" && \\\n        systemctl start \"$service_name.service\"\n      } || echo \"$service_name could not be registered or started\"\n    fi\n  elif hash service 2> /dev/null; then\n    if [[ \"$init_type\" == 'auto' || \"$init_type\" == 'upstart' || \"$init_type\" == 'sysv' ]]; then\n      service \"$service_name\" start || echo \"$service_name could not be registered or started\"\n    fi\n  elif hash start 2> /dev/null; then\n    if [[ \"$init_type\" == 'auto' || \"$init_type\" == 'upstart' ]]; then\n      start \"$service_name\" || echo \"$service_name could not be registered or started\"\n    fi\n  elif hash update-rc.d 2> /dev/null; then\n      if [[ \"$init_type\" == 'auto' || \"$init_type\" == 'sysv' ]]; then\n      {\n        update-rc.d \"$service_name\" defaults && \\\n        \"/etc/init.d/$service_name\" start\n      } || echo \"$service_name could not be registered or started\"\n    fi\n  else\n    echo 'Your system does not appear to use systemd, Upstart, or System V, so the service could not be started'\n  fi\n}\n\n# Create log directory if not there and set ownership\nif [ ! -d \"$ABS_LOG_DIR\" ]; then\n  mkdir -p \"$ABS_LOG_DIR\"\n  chown -R 'audiobookshelf:audiobookshelf' \"$ABS_LOG_DIR\"\nfi\n\nstart_service 'audiobookshelf'\n"
  },
  {
    "path": "build/debian/DEBIAN/preinst",
    "content": "#!/bin/bash\nset -e\nset -o pipefail\n\nDEFAULT_DATA_DIR=\"/usr/share/audiobookshelf\"\nCONFIG_PATH=\"/etc/default/audiobookshelf\"\nDEFAULT_PORT=13378\nDEFAULT_HOST=\"0.0.0.0\"\n\nadd_user() {\n  : \"${1:?'User was not defined'}\"\n  declare -r user=\"$1\"\n  declare -r uid=\"$2\"\n\n  if [ -z \"$uid\" ]; then\n    declare -r uid_flags=\"\"\n  else\n    declare -r uid_flags=\"--uid $uid\"\n  fi\n\n  declare -r group=\"${3:-$user}\"\n  declare -r descr=\"${4:-No description}\"\n  declare -r shell=\"${5:-/bin/false}\"\n\n  if ! getent passwd \"$user\" 2>&1 >/dev/null; then\n    echo \"Creating system user: $user in $group with $descr and shell $shell\"\n    useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c \"$descr\" $user\n  fi\n}\n\nadd_group() {\n  : \"${1:?'Group was not defined'}\"\n  declare -r group=\"$1\"\n  declare -r gid=\"$2\"\n\n  if [ -z \"$gid\" ]; then\n    declare -r gid_flags=\"\"\n  else\n    declare -r gid_flags=\"--gid $gid\"\n  fi\n\n  if ! getent group \"$group\" 2>&1 >/dev/null; then\n    echo \"Creating system group: $group\"\n    groupadd $gid_flags --system $group\n  fi\n}\n\nsetup_config() {\n  if [ -f \"$CONFIG_PATH\" ]; then\n    echo \"Existing config found.\"\n    cat $CONFIG_PATH\n\n  else\n\n    if [ ! -d \"$DEFAULT_DATA_DIR\" ]; then\n      # Create directory and set permissions\n      echo \"Creating default data dir at $DEFAULT_DATA_DIR\"\n      mkdir \"$DEFAULT_DATA_DIR\"\n      chown -R 'audiobookshelf:audiobookshelf' \"$DEFAULT_DATA_DIR\"\n    fi\n\n    echo \"Creating default config.\"\n\n    config_text=\"METADATA_PATH=$DEFAULT_DATA_DIR/metadata\nCONFIG_PATH=$DEFAULT_DATA_DIR/config\nPORT=$DEFAULT_PORT\nHOST=$DEFAULT_HOST\"\n\n    echo \"$config_text\"\n\n    echo \"$config_text\" > /etc/default/audiobookshelf;\n\n    echo \"Config created\"\n  fi\n}\n\nadd_group 'audiobookshelf' ''\n\nadd_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'\n\nsetup_config\n"
  },
  {
    "path": "build/debian/DEBIAN/prerm",
    "content": "#!/bin/bash\nset -e\nset -o pipefail\n\ndeclare -r init_type='auto'\ndeclare -r service_name='audiobookshelf'\n\nif [[ \"$init_type\" == 'auto' || \"$init_type\" == 'systemd' || \"$init_type\" == 'upstart' || \"$init_type\" == 'sysv' ]]; then\n  if hash systemctl 2> /dev/null; then\n    systemctl disable \"$service_name.service\" && \\\n    systemctl stop \"$service_name.service\" || \\\n    echo \"$service_name wasn't even running!\"\n  elif hash service 2> /dev/null; then\n    service \"$service_name\" stop || echo \"$service_name wasn't even running!\"\n  elif hash stop 2> /dev/null; then\n    stop \"$service_name\" || echo \"$service_name wasn't even running!\"\n  elif hash update-rc.d 2> /dev/null; then\n    {\n      update-rc.d \"$service_name\" remove && \\\n      \"/etc/init.d/$service_name\" stop\n    } || \"$service_name wasn't even running!\"\n  else\n    echo \"Your system does not appear to use upstart, systemd or sysv, so $service_name could not be stopped\"\n    echo 'Unless these systems were removed since install, no processes have been left running'\n  fi\nfi"
  },
  {
    "path": "build/debian/etc/default/.gitkeep",
    "content": ""
  },
  {
    "path": "build/debian/lib/systemd/system/audiobookshelf.service",
    "content": "[Unit]\nDescription=Self-hosted audiobook server for managing and playing audiobooks\nRequires=network.target\n\n[Service]\nType=simple\nEnvironmentFile=/etc/default/audiobookshelf\nWorkingDirectory=/usr/share/audiobookshelf\nExecStart=/usr/share/audiobookshelf/audiobookshelf\nExecReload=/bin/kill -HUP $MAINPID\nRestart=always\nUser=audiobookshelf\nGroup=audiobookshelf\n\n[Install]\nWantedBy=multi-user.target"
  },
  {
    "path": "build/debian/usr/lib/.gitkeep",
    "content": ""
  },
  {
    "path": "build/debian/usr/share/.gitkeep",
    "content": ""
  },
  {
    "path": "build/linuxpackager",
    "content": "#!/bin/bash\nset -e\nset -o pipefail\n\nSCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n\ncd \"$SCRIPT_DIR/..\"\n\n# Get package version without double quotes\nVERSION=\"$( eval echo $( jq '.version' package.json) )\"\nDESCRIPTION=\"$( eval echo $( jq '.description' package.json) )\"\nOUTPUT_FILE=\"audiobookshelf_${VERSION}_amd64.deb\"\n\necho \">>> Building Client\"\necho \"--------------------\"\n\ncd client\nrm -rf node_modules\nnpm ci --unsafe-perm=true --allow-root\nnpm run generate\ncd ..\n\necho \">>> Building Server\"\necho \"--------------------\"\n\nrm -rf node_modules\nnpm ci --unsafe-perm=true --allow-root\n\necho \">>> Packaging\"\necho \"--------------------\"\n\n# Create debian control file\n\nmkdir -p dist\nrm -rf dist/debian\ncp -R build/debian dist/debian\nchmod -R 775 dist/debian\n\ncontrolfile=\"Package: audiobookshelf\nVersion: $VERSION\nSection: base\nPriority: optional\nArchitecture: amd64\nDepends:\nMaintainer: advplyr\nDescription: $DESCRIPTION\"\n\necho \"$controlfile\" > dist/debian/DEBIAN/control;\n\n# Package debian\npkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .\n\nfakeroot dpkg-deb -Zxz --build dist/debian\n\nmv dist/debian.deb \"dist/$OUTPUT_FILE\"\n\necho \"Finished! Filename: $OUTPUT_FILE\"\n"
  },
  {
    "path": "client/assets/absicons.css",
    "content": "@font-face {\n  font-family: 'absicons';\n  src: url('~static/fonts/absicons/absicons.eot?2jfq33');\n  src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),\n    url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),\n    url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),\n    url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');\n  font-weight: normal;\n  font-style: normal;\n  font-display: block;\n}\n\n.abs-icons {\n  /* use !important to prevent issues with browser extensions that change fonts */\n  font-family: 'absicons' !important;\n  speak: never;\n  font-style: normal;\n  font-weight: normal;\n  font-variant: normal;\n  text-transform: none;\n  line-height: 1;\n\n  /* Better Font Rendering =========== */\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n.icon-books-1:before {\n  content: \"\\e905\";\n}\n\n.icon-microphone-1:before {\n  content: \"\\e902\";\n}\n\n.icon-radio:before {\n  content: \"\\e903\";\n}\n\n.icon-podcast:before {\n  content: \"\\e904\";\n}\n\n.icon-audiobookshelf:before {\n  content: \"\\e900\";\n}\n\n.icon-database:before {\n  content: \"\\e906\";\n}\n\n.icon-microphone-2:before {\n  content: \"\\e901\";\n}\n\n.icon-headphones:before {\n  content: \"\\e910\";\n}\n\n.icon-music:before {\n  content: \"\\e911\";\n}\n\n.icon-video:before {\n  content: \"\\e914\";\n}\n\n.icon-microphone-3:before {\n  content: \"\\e91e\";\n}\n\n.icon-book-1:before {\n  content: \"\\e91f\";\n}\n\n.icon-books-2:before {\n  content: \"\\e920\";\n}\n\n.icon-file-picture:before {\n  content: \"\\e927\";\n}\n\n.icon-database-1:before {\n  content: \"\\e964\";\n}\n\n.icon-rocket:before {\n  content: \"\\e9a5\";\n}\n\n.icon-power:before {\n  content: \"\\e9b5\";\n}\n\n.icon-star:before {\n  content: \"\\e9d9\";\n}\n\n.icon-heart:before {\n  content: \"\\e9da\";\n}\n\n.icon-rss:before {\n  content: \"\\ea9b\";\n}"
  },
  {
    "path": "client/assets/app.css",
    "content": "@import './fonts.css';\n@import './transitions.css';\n@import './draggable.css';\n@import './defaultStyles.css';\n@import './absicons.css';\n\n:root {\n  --bookshelf-texture-img: url(~static/textures/wood_default.jpg);\n  --bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);\n}\n\n.page {\n  width: 100%;\n  height: calc(100% - 64px);\n  max-height: calc(100% - 64px);\n}\n\n.page.streaming {\n  height: calc(100% - 64px - 165px);\n  max-height: calc(100% - 64px - 165px);\n}\n\n#bookshelf {\n  height: calc(100% - 40px);\n  background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);\n\n  /* For Firefox */\n  scrollbar-width: thin;\n  scrollbar-color: #855620 rgba(0, 0, 0, 0);\n}\n\n.bookshelf-row {\n  width: calc(100vw - (100vw - 100%));\n}\n\n@media (max-width: 768px) {\n  #bookshelf {\n    height: calc(100% - 80px);\n  }\n\n  .bookshelf-row {\n    width: 100vw;\n  }\n}\n\n#page-wrapper {\n  background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);\n}\n\n/* width */\n::-webkit-scrollbar {\n  width: 8px;\n}\n\n::-webkit-scrollbar:horizontal {\n  height: 8px;\n}\n\n/* Track */\n::-webkit-scrollbar-track {\n  background-color: rgba(0, 0, 0, 0);\n}\n\n/* Handle */\n::-webkit-scrollbar-thumb {\n  background: #855620;\n  border-radius: 4px;\n}\n\n/* Handle on hover */\n::-webkit-scrollbar-thumb:hover {\n  background: #704922;\n}\n\n.no-scroll::-webkit-scrollbar {\n  display: none;\n  opacity: 0;\n}\n\n.no-scroll {\n  -ms-overflow-style: none;\n  /* IE and Edge */\n  scrollbar-width: none;\n  /* Firefox */\n}\n\n/* Chrome, Safari, Edge, Opera */\n.no-spinner::-webkit-outer-spin-button,\n.no-spinner::-webkit-inner-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\n/* Firefox */\ninput[type='number'] {\n  -moz-appearance: textfield;\n}\n\n.tracksTable {\n  border-collapse: collapse;\n  width: 100%;\n  border: 1px solid #474747;\n}\n\n.tracksTable tr:nth-child(even) {\n  background-color: #2e2e2e;\n}\n\n.tracksTable tr {\n  background-color: #373838;\n}\n\n.tracksTable tr:hover:not(:has(th)) {\n  background-color: #474747;\n}\n\n.tracksTable td {\n  padding: 4px 8px;\n}\n\n.tracksTable th {\n  padding: 4px 8px;\n  font-size: 0.75rem;\n}\n\n.arrow-down {\n  width: 0;\n  height: 0;\n  border-left: 6px solid transparent;\n  border-right: 6px solid transparent;\n  border-top: 6px solid white;\n}\n\n.arrow-down-small {\n  width: 0;\n  height: 0;\n  border-left: 4px solid transparent;\n  border-right: 4px solid transparent;\n  border-top: 4px solid currentColor;\n}\n\n.triangle-right {\n  width: 0;\n  height: 0;\n  border-left: 8px solid transparent;\n  border-bottom: 8px solid transparent;\n  border-top: 8px solid rgb(34, 127, 35);\n  border-right: 8px solid rgb(34, 127, 35);\n}\n\n.icon-text {\n  font-size: 1.1rem;\n}\n\n.box-shadow-md {\n  box-shadow: 2px 8px 6px #111111aa;\n}\n\n.box-shadow-sm-up {\n  box-shadow: 0px -5px 8px #11111122;\n}\n\n.box-shadow-md-up {\n  box-shadow: 0px -8px 8px #11111144;\n}\n\n.box-shadow-lg-up {\n  box-shadow: 0px -12px 8px #111111ee;\n}\n\n.box-shadow-xl {\n  box-shadow: 2px 14px 8px #111111aa;\n}\n\n.box-shadow-book {\n  box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;\n}\n\n.box-shadow-progressbar {\n  box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);\n}\n\n.shadow-height {\n  height: calc(100% - 4px);\n}\n\n.box-shadow-book3d {\n  box-shadow: 4px 1px 8px #11111166, 1px -4px 8px #11111166;\n}\n\n.box-shadow-side {\n  box-shadow: 5px 0px 5px #11111166;\n}\n\n/*\nBookshelf Label\n*/\n.categoryPlacard {\n  letter-spacing: 1px;\n}\n\n.shinyBlack {\n  background-color: #2d3436;\n  background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);\n  border-color: rgba(255, 244, 182, 0.6);\n  border-style: solid;\n  color: #fce3a6;\n}\n\n.cover-bg {\n  width: calc(100% + 40px);\n  height: calc(100% + 40px);\n  top: -20px;\n  left: -20px;\n  background-size: 100% 100%;\n  background-position: center;\n  opacity: 1;\n  filter: blur(20px);\n}\n\n/* Padding for toastification toasts in the top right to not cover appbar/toolbar */\n.app-bar-and-toolbar .Vue-Toastification__container.top-right {\n  padding-top: 104px;\n}\n\n.app-bar .Vue-Toastification__container.top-right {\n  padding-top: 64px;\n}\n\n.no-bars .Vue-Toastification__container.top-right {\n  padding-top: 8px;\n}\n\n.abs-btn::before {\n  content: '';\n  position: absolute;\n  border-radius: 6px;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(255, 255, 255, 0);\n  transition: all 0.1s ease-in-out;\n}\n\n.abs-btn:hover:not(:disabled)::before {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n\n.abs-btn:disabled::before {\n  background-color: rgba(0, 0, 0, 0.2);\n}\n"
  },
  {
    "path": "client/assets/defaultStyles.css",
    "content": "/*\n\n  This is for setting regular html styles for places where embedding HTML will be\n  like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.\n\n*/\n\n.default-style p {\n  display: block;\n  margin-block-start: 1em;\n  margin-block-end: 1em;\n  margin-inline-start: 0px;\n  margin-inline-end: 0px;\n}\n\n.default-style a {\n  text-decoration: none;\n  color: #5985ff;\n}\n\n.default-style ul {\n  display: block;\n  list-style: circle;\n  list-style-type: disc;\n  margin-block-start: 1em;\n  margin-block-end: 1em;\n  margin-inline-start: 0px;\n  margin-inline-end: 0px;\n  padding-inline-start: 40px;\n}\n\n.default-style ol {\n  display: block;\n  list-style: decimal;\n  list-style-type: decimal;\n  margin-block-start: 1em;\n  margin-block-end: 1em;\n  margin-inline-start: 0px;\n  margin-inline-end: 0px;\n  padding-inline-start: 40px;\n}\n\n.default-style li {\n  display: list-item;\n  text-align: -webkit-match-parent;\n}\n\n.default-style li::marker {\n  unicode-bidi: isolate;\n  font-variant-numeric: tabular-nums;\n  text-transform: none;\n  text-indent: 0px !important;\n  text-align: start !important;\n  text-align-last: start !important;\n}\n\n.default-style.less-spacing p {\n  margin-block-start: 0;\n}\n\n.default-style.less-spacing ul {\n  margin-block-start: 0;\n}\n\n.default-style.less-spacing ol {\n  margin-block-start: 0;\n}\n\n"
  },
  {
    "path": "client/assets/draggable.css",
    "content": ".flip-list-move {\n  transition: transform 0.5s;\n}\n\n.no-move {\n  transition: transform 0s;\n}\n\n.ghost {\n  opacity: 0.5;\n  background-color: rgba(255, 255, 255, 0.25);\n}\n\n.list-group {\n  min-height: 30px;\n}\n\n.drag-handle {\n  cursor: n-resize;\n}\n\n.list-group-item:not(.exclude) {\n  cursor: n-resize;\n}\n\n.list-group-item.exclude {\n  cursor: not-allowed;\n}\n\n.list-group-item:not(.ghost):not(.exclude):hover {\n  background-color: rgba(0, 0, 0, 0.1);\n}\n\n.list-group-item:nth-child(even):not(.ghost):not(.exclude) {\n  background-color: rgba(0, 0, 0, 0.25);\n}\n\n.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {\n  background-color: rgba(0, 0, 0, 0.1);\n}\n\n.list-group-item.exclude:not(.ghost) {\n  background-color: rgba(255, 0, 0, 0.25);\n}\n\n.list-group-item.exclude:not(.ghost):hover {\n  background-color: rgba(223, 0, 0, 0.25);\n}"
  },
  {
    "path": "client/assets/ebooks/basic.js",
    "content": "/*\nCalibres stylesheet\n*/\n\nexport default `\n@charset \"UTF-8\";\n\n/*\n  Calibre styles\n*/\n.arabic {\n    display: block;\n    list-style-type: decimal;\n    margin-bottom: 1em;\n    margin-right: 0;\n    margin-top: 1em;\n    text-align: justify\n    }\n.attribution {\n    display: block;\n    font-size: 1em;\n    line-height: 1.2;\n    text-align: left;\n    margin: 0.3em 0\n    }\n.big {\n    font-size: 1.375em;\n    line-height: 1.2\n    }\n.big1 {\n    font-size: 1em\n    }\n.block {\n    display: block;\n    text-align: justify;\n    margin: 1em 1em 2em\n    }\n.block1 {\n    display: block;\n    text-align: justify;\n    margin: 1em 4em\n    }\n.block2 {\n    display: block;\n    text-align: justify;\n    margin: 1em 1em 1em 2em\n    }\n.bullet {\n    display: block;\n    list-style-type: disc;\n    margin-bottom: 1em;\n    margin-right: 0;\n    margin-top: 1em;\n    text-align: disc\n    }\n.calibre {\n    background-color: #000007;\n    display: block;\n    font-family: Charis, \"Times New Roman\", Verdana, Arial;\n    font-size: 1.125em;\n    line-height: 1.2;\n    padding-left: 0;\n    padding-right: 0;\n    text-align: center;\n    margin: 0 5pt\n    }\n.calibre1 {\n    display: block\n    }\n.calibre2 {\n    height: auto;\n    width: auto\n    }\n.calibre3:not(strong) {\n    display: block;\n    font-family: Charis, \"Times New Roman\", Verdana, Arial;\n    font-size: 1.125em;\n    line-height: 1.2;\n    padding-left: 0;\n    padding-right: 0;\n    margin: 0 5pt\n    }\n.calibre4 {\n    font-weight: bold\n    }\n.calibre5 {\n    font-style: italic\n    }\n.calibre6 {\n    background-color: #FFF;\n    display: block;\n    font-family: Charis, \"Times New Roman\", Verdana, Arial;\n    font-size: 1.125em;\n    line-height: 1.2;\n    padding-left: 0;\n    padding-right: 0;\n    text-align: center;\n    margin: 0 5pt\n    }\n.calibre7 {\n    display: list-item\n    }\n.calibre8 {\n    font-size: 1em;\n    line-height: 1.2;\n    vertical-align: super\n    }\n.calibre9 {\n    border-collapse: separate;\n    border-spacing: 2px;\n    display: table;\n    margin-bottom: 0;\n    margin-top: 0;\n    text-indent: 0\n    }\n.calibre10 {\n    display: table-row;\n    vertical-align: middle\n    }\n.calibre11 {\n    display: table-cell;\n    text-align: right;\n    vertical-align: inherit;\n    padding: 1px\n    }\n.calibre12 {\n    display: table-cell;\n    text-align: left;\n    vertical-align: inherit;\n    padding: 1px\n    }\n.calibre13 {\n    height: 1em;\n    width: auto\n    }\n.calibre14 {\n    font-size: 0.88889em;\n    line-height: 1.2;\n    vertical-align: super\n    }\n.calibre15 {\n    font-size: 1em;\n    line-height: 1.2;\n    vertical-align: sub\n    }\n.calibre16 {\n    display: block;\n    list-style-type: decimal;\n    margin-bottom: 1em;\n    margin-right: 0;\n    margin-top: 1em\n    }\n.calibre17 {\n    display: block;\n    font-size: 1.125em;\n    font-weight: bold;\n    line-height: 1.2;\n    margin: 0.83em 0\n    }\n.center {\n    display: block;\n    text-align: center;\n    margin: 1em 0\n    }\n.center1 {\n    display: block;\n    font-size: 1em;\n    font-weight: bold;\n    line-height: 1.2;\n    text-align: center;\n    margin: -2em 0 3em\n    }\n.center2 {\n    display: block;\n    font-size: 1em;\n    font-weight: bold;\n    line-height: 1.2;\n    text-align: center;\n    margin: 2em 0 1em\n    }\n.center3 {\n    display: block;\n    text-align: center;\n    margin: -1em 0 1em\n    }\n.center4 {\n    display: block;\n    text-align: center;\n    text-indent: 3%;\n    margin: 1em 0\n    }\n.chapter {\n    display: block;\n    font-size: 1.125em;\n    font-weight: bold;\n    line-height: 2em;\n    text-align: center;\n    margin: 2em 0 1em\n    }\n.chapter1 {\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    margin-left: 0.5em;\n    margin-right: 0.5em;\n    margin-top: 2em\n    }\n.chapter2 {\n    display: block;\n    font-size: 1.125em;\n    font-weight: bold;\n    line-height: 2em;\n    text-align: center;\n    margin: 2em 0 3em\n    }\n.copyright {\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    margin-top: 4em;\n    text-align: center\n    }\n.dedication {\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    margin-top: 4em\n    }\n.dropcaps {\n    float: left;\n    font-size: 3.4375rem;\n    line-height: 50px;\n    margin-right: 0.09em;\n    margin-top: -0.05em;\n    padding-top: 1px\n    }\n.dropcaps1 {\n    float: left;\n    font-size: 3.4375rem;\n    line-height: 50px;\n    margin-right: 0.09em;\n    margin-top: 0.15em;\n    padding-top: 1px\n    }\n.extract {\n    display: block;\n    text-align: justify;\n    margin: 2em 0 0.3em\n    }\n.extract1 {\n    display: block;\n    text-align: justify;\n    text-indent: 3%;\n    margin: 2em 0 0.3em\n    }\n.extract2 {\n    display: block;\n    text-align: justify;\n    margin: 1em 0 0.3em\n    }\n.footnote {\n    border-bottom-style: solid;\n    border-bottom-width: 0;\n    border-left-style: solid;\n    border-left-width: 0;\n    border-right-style: solid;\n    border-right-width: 0;\n    border-top-style: solid;\n    border-top-width: 1px;\n    display: block;\n    font-size: 1em;\n    line-height: 1.2;\n    margin-top: 2 em\n    }\n.footnote1 {\n    display: block;\n    text-align: justify;\n    margin: 0.3em 0 0.3em 2\n    }\n.footnote2 {\n    border-bottom-style: solid;\n    border-bottom-width: 0;\n    border-left-style: solid;\n    border-left-width: 0;\n    border-right-style: solid;\n    border-right-width: 0;\n    border-top-style: solid;\n    border-top-width: 1px;\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    margin-top: 2 em\n    }\n.hanging {\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    text-align: left;\n    text-indent: -1em;\n    margin: 0.5em 0 0.3em 1em\n    }\n.hanging1 {\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    text-align: left;\n    text-indent: -1em;\n    margin: 0.5em 0 0.3em 1.5em\n    }\n.hanging2 {\n    display: block;\n    font-size: 1em;\n    line-height: 1.2;\n    text-indent: -1em;\n    margin: 0.5em 0 0.3em 1em\n    }\n.hanging3 {\n    display: block;\n    font-size: 1em;\n    line-height: 1.2;\n    text-align: left;\n    text-indent: 1em;\n    margin: 0.1em 0 0.3em 1em\n    }\n.hanging4 {\n    display: block;\n    font-size: 1em;\n    line-height: 1.2;\n    text-align: left;\n    text-indent: 0.1em;\n    margin: 0.1em 0 0.3em 1em\n    }\na.hlink {\n    text-decoration: none\n    }\n.indent {\n    display: block;\n    text-align: justify;\n    text-indent: 1em;\n    margin: 0.3em 0\n    }\n.line {\n    border-top: currentColor solid 1px;\n    border-bottom: currentColor solid 1px\n    }\n.loweralpha {\n    display: block;\n    list-style-type: lower-alpha;\n    margin-bottom: 1em;\n    margin-right: 0;\n    margin-top: 1em;\n    text-align: justify\n    }\n.none {\n    display: block;\n    list-style-type: none;\n    margin-bottom: 1em;\n    margin-right: 0;\n    margin-top: 1em;\n    text-align: justify\n    }\n.none1 {\n    display: block;\n    list-style-type: none;\n    margin-bottom: 0;\n    margin-right: 0;\n    margin-top: 0;\n    text-align: justify\n    }\n.nonindent {\n    display: block;\n    text-align: justify;\n    margin: 0.3em 0\n    }\n.nonindent1 {\n    display: block;\n    font-size: 1.125em;\n    line-height: 1.2;\n    text-indent: -1em;\n    margin: 0.5em 0 0.3em 0.1em\n    }\n.nonindent2 {\n    display: block;\n    font-size: 1.125em;\n    line-height: 1.2;\n    text-indent: -1em;\n    margin: 0.5em 0 0.3em -0.5em\n    }\n.nonindent3 {\n    display: block;\n    text-align: justify;\n    text-indent: 3%;\n    margin: 0.3em 0\n    }\n.part {\n    display: block;\n    font-size: 1em;\n    font-weight: bold;\n    line-height: 2em;\n    text-align: center;\n    margin: 4em 0 1em\n    }\n.preface {\n    display: block;\n    font-size: 0.88889em;\n    line-height: 1.2;\n    margin-left: 2em;\n    margin-right: 2em;\n    text-align: justify\n    }\n.pubhlink {\n    color: green;\n    text-decoration: none\n    }\n.right {\n    display: block;\n    text-align: right;\n    margin: 0.3em 0\n    }\n.section {\n    display: block;\n    font-size: 1.125em;\n    font-weight: bold;\n    line-height: 1.2;\n    text-align: center;\n    margin: 2em 0 0.5em\n    }\n.section1 {\n    display: block;\n    font-size: 1.125em;\n    font-weight: bold;\n    line-height: 1.2;\n    text-align: left;\n    margin: 2em 0 0.3em\n    }\n.section2 {\n    display: block;\n    font-size: 1em;\n    font-weight: bold;\n    line-height: 1.2;\n    text-align: left;\n    margin: 2em 0 0.3em 1em\n    }\n.small {\n    font-size: 0.66667em\n    }\n.small1 {\n    font-size: 0.75em\n    }\n.subchapter {\n    display: block;\n    font-size: 1.125em;\n    font-weight: bold;\n    line-height: 1.2;\n    margin: 1em 0\n    }\n.textbox {\n    background-color: #E4E4E4;\n    display: block;\n    line-height: 1.5em;\n    margin-bottom: 2em;\n    margin-top: 2em;\n    text-align: justify;\n    border-top: currentColor double 2px;\n    border-bottom: currentColor double 2px\n    }\n.textbox1 {\n    display: block;\n    text-align: justify;\n    margin: 0.3em 0.5em 0.3em 0.8em\n    }\n.textbox2 {\n    display: block;\n    text-align: justify;\n    text-indent: 1em;\n    margin: 0.3em 0.5em\n    }\n.textbox3 {\n    display: block;\n    text-align: justify;\n    text-indent: 3%;\n    margin: 0.3em 0.5em 0.3em 0.8em\n    }\n.titlepage {\n    display: block;\n    margin-left: -0.4em;\n    margin-top: 1.2em\n    }\n.toc {\n    display: block;\n    font-size: 1em;\n    line-height: 1.2;\n    text-align: center\n    }\n.toc1 {\n    display: block;\n    font-size: 1em;\n    font-weight: bold;\n    line-height: 1.2;\n    text-align: center;\n    margin: 0.67em 0 3em\n    }\n.underline {\n    text-decoration: underline\n    }\n`"
  },
  {
    "path": "client/assets/ebooks/htmlParser.js",
    "content": "/*\nThis is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src\n*/\n\nexport const isTitle = (\n  line,\n  isContainDI = false,\n  isContainChapter = false,\n  isContainCHAPTER = false\n) => {\n  return (\n    line.length < 30 &&\n    line.indexOf(\"[\") === -1 &&\n    line.indexOf(\"(\") === -1 &&\n    (line.startsWith(\"CHAPTER\") ||\n      line.startsWith(\"Chapter\") ||\n      line.startsWith(\"序章\") ||\n      line.startsWith(\"前言\") ||\n      line.startsWith(\"声明\") ||\n      line.startsWith(\"聲明\") ||\n      line.startsWith(\"写在前面的话\") ||\n      line.startsWith(\"后记\") ||\n      line.startsWith(\"楔子\") ||\n      line.startsWith(\"后序\") ||\n      line.startsWith(\"寫在前面的話\") ||\n      line.startsWith(\"後記\") ||\n      line.startsWith(\"後序\") ||\n      /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(\n        line\n      ) ||\n      (line.startsWith(\"第\") && startWithDI(line)) ||\n      (line.startsWith(\"卷\") && startWithJUAN(line)) ||\n      startWithRomanNum(line) ||\n      (!isContainDI &&\n        !isContainChapter &&\n        !isContainCHAPTER &&\n        line.indexOf(\"第\") > -1 &&\n        (line[line.indexOf(\"第\") - 1] === \" \" ||\n          line[line.indexOf(\"第\") - 1] === \"　\" ||\n          line[line.indexOf(\"第\") - 1] === \"、\" ||\n          line[line.indexOf(\"第\") - 1] === \"：\" ||\n          line[line.indexOf(\"第\") - 1] === \":\") &&\n        startWithDI(line.substr(line.indexOf(\"第\")))) ||\n      (!isContainDI &&\n        !isContainChapter &&\n        !isContainCHAPTER &&\n        line.indexOf(\" \") &&\n        startWithNumAndSpace(line)) ||\n      (!isContainDI &&\n        !isContainChapter &&\n        !isContainCHAPTER &&\n        line.indexOf(\"　\") &&\n        startWithNumAndSpace(line)) ||\n      (!isContainDI &&\n        !isContainChapter &&\n        !isContainCHAPTER &&\n        line.indexOf(\"、\") &&\n        startWithNumAndPause(line)) ||\n      (!isContainDI &&\n        !isContainChapter &&\n        !isContainCHAPTER &&\n        line.indexOf(\"：\") &&\n        startWithNumAndColon(line)) ||\n      (!isContainDI &&\n        !isContainChapter &&\n        !isContainCHAPTER &&\n        line.indexOf(\":\") &&\n        startWithNumAndColon(line)))\n  );\n};\nconst startWithDI = (line) => {\n  let keywords = [\n    \"章\",\n    \"节\",\n    \"回\",\n    \"節\",\n    \"卷\",\n    \"部\",\n    \"輯\",\n    \"辑\",\n    \"話\",\n    \"集\",\n    \"话\",\n    \"篇\",\n  ];\n  let flag = false;\n  for (let i = 0; i < keywords.length; i++) {\n    if (\n      (line.indexOf(keywords[i]) > -1 &&\n        (line[line.indexOf(keywords[i]) + 1] === \" \" ||\n          line[line.indexOf(keywords[i]) + 1] === \"　\" ||\n          line[line.indexOf(keywords[i]) + 1] === \"、\" ||\n          line[line.indexOf(keywords[i]) + 1] === \"：\" ||\n          line[line.indexOf(keywords[i]) + 1] === \":\")) ||\n      !line[line.indexOf(keywords[i]) + 1]\n    ) {\n      if (\n        /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n          line.substring(1, line.indexOf(keywords[i])).trim()\n        ) ||\n        /^\\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim())\n      ) {\n        flag = true;\n      }\n      if (flag) break;\n    }\n  }\n  return flag;\n};\nconst startWithJUAN = (line) => {\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(1, line.indexOf(\" \"))\n    ) ||\n    /^\\d+$/.test(line.substring(1, line.indexOf(\" \")))\n  )\n    return true;\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(1, line.indexOf(\"　\"))\n    ) ||\n    /^\\d+$/.test(line.substring(1, line.indexOf(\"　\")))\n  )\n    return true;\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(1)\n    ) ||\n    /^\\d+$/.test(line.substring(1))\n  )\n    return true;\n  return false;\n};\nconst startWithRomanNum = (line) => {\n  if (\n    /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(\n      line.substring(0, line.indexOf(\" \"))\n    )\n  )\n    return true;\n  if (\n    /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(\n      line.substring(0, line.indexOf(\".\"))\n    )\n  )\n    return true;\n  if (\n    /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(\n      line.trim()\n    )\n  )\n    return true;\n  return false;\n};\nconst startWithNumAndSpace = (line) => {\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(0, line.indexOf(\" \"))\n    )\n  )\n    return true;\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(0, line.indexOf(\"　\"))\n    )\n  )\n    return true;\n\n  if (/^\\d+$/.test(line.substring(0, line.indexOf(\" \")))) return true;\n  if (/^\\d+$/.test(line.substring(0, line.indexOf(\"　\")))) return true;\n  return false;\n};\nconst startWithNumAndColon = (line) => {\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(0, line.indexOf(\":\"))\n    )\n  )\n    return true;\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(0, line.indexOf(\"：\"))\n    )\n  )\n    return true;\n\n  if (/^\\d+$/.test(line.substring(0, line.indexOf(\":\")))) return true;\n  if (/^\\d+$/.test(line.substring(0, line.indexOf(\"：\")))) return true;\n  return false;\n};\nconst startWithNumAndPause = (line) => {\n  if (\n    /^[\\u4e00\\u4e8c\\u4e09\\u56db\\u4e94\\u516d\\u4e03\\u516b\\u4e5d\\u5341\\u767e\\u5343\\u4e07\\u842c]+$/.test(\n      line.substring(0, line.indexOf(\"、\"))\n    )\n  )\n    return true;\n\n  if (/^\\d+$/.test(line.substring(0, line.indexOf(\"、\")))) return true;\n  return false;\n};\n\n\nclass HtmlParser {\n  bookDoc;\n  contentList;\n  contentTitleList;\n  constructor(bookDoc) {\n    this.bookDoc = bookDoc;\n    this.contentList = [];\n    this.contentTitleList = [];\n    this.getContent(bookDoc);\n  }\n  getContent(bookDoc) {\n    this.contentList = Array.from(\n      bookDoc.querySelectorAll(\"h1,h2,h3,h4,h5,b,font\")\n    ).filter((item, index) => {\n      return isTitle(item.innerText.trim());\n    });\n\n    for (let i = 0; i < this.contentList.length; i++) {\n      let random = Math.floor(Math.random() * 900000) + 100000;\n      this.contentTitleList.push({\n        label: this.contentList[i].innerText,\n        id: \"title\" + random,\n        href: \"#title\" + random,\n        subitems: [],\n      });\n    }\n    for (let i = 0; i < this.contentList.length; i++) {\n      this.contentList[i].id = this.contentTitleList[i].id;\n    }\n  }\n  getAnchoredDoc() {\n    return this.bookDoc;\n  }\n  getContentList() {\n    return this.contentTitleList.filter((item, index) => {\n      if (index > 0) {\n        return item.label !== this.contentTitleList[index - 1].label;\n      } else {\n        return true;\n      }\n    });\n  }\n}\n\nexport default HtmlParser;\n"
  },
  {
    "path": "client/assets/ebooks/mobi.js",
    "content": "/*\nThis is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src\n*/\n\nfunction ab2str(buf) {\n  if (buf instanceof ArrayBuffer) {\n    buf = new Uint8Array(buf);\n  }\n  return new TextDecoder(\"utf-8\").decode(buf);\n}\n\nvar domParser = new DOMParser();\n\nclass Buffer {\n  capacity;\n  fragment_list;\n  imageArray;\n  cur_fragment;\n  constructor(capacity) {\n    this.capacity = capacity;\n    this.fragment_list = [];\n    this.imageArray = [];\n    this.cur_fragment = new Fragment(capacity);\n    this.fragment_list.push(this.cur_fragment);\n  }\n  write(byte) {\n    var result = this.cur_fragment.write(byte);\n    if (!result) {\n      this.cur_fragment = new Fragment(this.capacity);\n      this.fragment_list.push(this.cur_fragment);\n      this.cur_fragment.write(byte);\n    }\n  }\n  get(idx) {\n    var fi = 0;\n    while (fi < this.fragment_list.length) {\n      var frag = this.fragment_list[fi];\n      if (idx < frag.size) {\n        return frag.get(idx);\n      }\n      idx -= frag.size;\n      fi += 1;\n    }\n    return null;\n  }\n  size() {\n    var s = 0;\n    for (var i = 0; i < this.fragment_list.length; i++) {\n      s += this.fragment_list[i].size;\n    }\n    return s;\n  }\n  shrink() {\n    var total_buffer = new Uint8Array(this.size());\n    var offset = 0;\n    for (var i = 0; i < this.fragment_list.length; i++) {\n      var frag = this.fragment_list[i];\n      if (frag.full()) {\n        total_buffer.set(frag.buffer, offset);\n      } else {\n        total_buffer.set(frag.buffer.slice(0, frag.size), offset);\n      }\n      offset += frag.size;\n    }\n    return total_buffer;\n  }\n}\n\nvar copagesne_uint8array = function (buffers) {\n  var total_size = 0;\n  for (let i = 0; i < buffers.length; i++) {\n    var buffer = buffers[i];\n    total_size += buffer.length;\n  }\n  var total_buffer = new Uint8Array(total_size);\n  var offset = 0;\n  for (let i = 0; i < buffers.length; i++) {\n    buffer = buffers[i];\n    total_buffer.set(buffer, offset);\n    offset += buffer.length;\n  }\n  return total_buffer;\n};\n\nclass Fragment {\n  buffer;\n  capacity;\n  size;\n  constructor(capacity) {\n    this.buffer = new Uint8Array(capacity);\n    this.capacity = capacity;\n    this.size = 0;\n  }\n\n  write(byte) {\n    if (this.size >= this.capacity) {\n      return false;\n    }\n    this.buffer[this.size] = byte;\n    this.size += 1;\n    return true;\n  }\n  full() {\n    return this.size === this.capacity;\n  }\n  get(idx) {\n    return this.buffer[idx];\n  }\n}\n\nvar uncompression_lz77 = function (data) {\n  var length = data.length;\n  var offset = 0; // Current offset into data\n  var buffer = new Buffer(data.length);\n\n  while (offset < length) {\n    var char = data[offset];\n    offset += 1;\n\n    if (char === 0) {\n      buffer.write(char);\n    } else if (char <= 8) {\n      for (var i = offset; i < offset + char; i++) {\n        buffer.write(data[i]);\n      }\n      offset += char;\n    } else if (char <= 0x7f) {\n      buffer.write(char);\n    } else if (char <= 0xbf) {\n      var next = data[offset];\n      offset += 1;\n      var distance = (((char << 8) | next) >> 3) & 0x7ff;\n      var lz_length = (next & 0x7) + 3;\n\n      var buffer_size = buffer.size();\n      for (let i = 0; i < lz_length; i++) {\n        buffer.write(buffer.get(buffer_size - distance));\n        buffer_size += 1;\n      }\n    } else {\n      buffer.write(32);\n      buffer.write(char ^ 0x80);\n    }\n  }\n  return buffer;\n};\n\nclass MobiFile {\n  view;\n  buffer;\n  offset;\n  header;\n  palm_header;\n  mobi_header;\n  reclist;\n  constructor(data) {\n    this.view = new DataView(data);\n    this.buffer = this.view.buffer;\n    this.offset = 0;\n    this.header = null;\n  }\n\n  parse() { }\n\n  getUint8() {\n    var v = this.view.getUint8(this.offset);\n    this.offset += 1;\n    return v;\n  }\n\n  getUint16() {\n    var v = this.view.getUint16(this.offset);\n    this.offset += 2;\n    return v;\n  }\n\n  getUint32() {\n    var v = this.view.getUint32(this.offset);\n    this.offset += 4;\n    return v;\n  }\n\n  getStr(size) {\n    var v = ab2str(this.buffer.slice(this.offset, this.offset + size));\n    this.offset += size;\n    return v;\n  }\n\n  skip(size) {\n    this.offset += size;\n  }\n\n  setoffset(_of) {\n    this.offset = _of;\n  }\n\n  get_record_extrasize(data, flags) {\n    var pos = data.length - 1;\n    var extra = 0;\n    for (var i = 15; i > 0; i--) {\n      if (flags & (1 << i)) {\n        var res = this.buffer_get_varlen(data, pos);\n        var size = res[0];\n        var l = res[1];\n        pos = res[2];\n        pos -= size - l;\n        extra += size;\n      }\n    }\n    if (flags & 1) {\n      var a = data[pos];\n      extra += (a & 0x3) + 1;\n    }\n    return extra;\n  }\n\n  // data should be uint8array\n  buffer_get_varlen(data, pos) {\n    var l = 0;\n    var size = 0;\n    var byte_count = 0;\n    var mask = 0x7f;\n    var stop_flag = 0x80;\n    var shift = 0;\n    for (var i = 0; ; i++) {\n      var byte = data[pos];\n      size |= (byte & mask) << shift;\n      shift += 7;\n      l += 1;\n      byte_count += 1;\n      pos -= 1;\n\n      var to_stop = byte & stop_flag;\n      if (byte_count >= 4 || to_stop > 0) {\n        break;\n      }\n    }\n    return [size, l, pos];\n  }\n  // 读出文本内容\n  read_text() {\n    var text_end = this.palm_header.record_count;\n    var buffers = [];\n    for (var i = 1; i <= text_end; i++) {\n      buffers.push(this.read_text_record(i));\n    }\n    var all = copagesne_uint8array(buffers);\n    return ab2str(all);\n  }\n\n  read_text_record(i) {\n    var flags = this.mobi_header.extra_flags;\n    var begin = this.reclist[i].offset;\n    var end = this.reclist[i + 1].offset;\n\n    var data = new Uint8Array(this.buffer.slice(begin, end));\n    var ex = this.get_record_extrasize(data, flags);\n\n    data = new Uint8Array(this.buffer.slice(begin, end - ex));\n    if (this.palm_header.compression === 2) {\n      var buffer = uncompression_lz77(data);\n      return buffer.shrink();\n    } else {\n      return data;\n    }\n  }\n  // 从buffer中读出image\n  read_image(idx) {\n    var first_image_idx = this.mobi_header.first_image_idx;\n    var begin = this.reclist[first_image_idx + idx].offset;\n    var end = this.reclist[first_image_idx + idx + 1].offset;\n    var data = new Uint8Array(this.buffer.slice(begin, end));\n    return new Blob([data.buffer]);\n  }\n\n  load() {\n    this.header = this.load_pdbheader();\n    this.reclist = this.load_reclist();\n    this.load_record0();\n  }\n\n  load_pdbheader() {\n    var header = {};\n    header.name = this.getStr(32);\n    header.attr = this.getUint16();\n    header.version = this.getUint16();\n    header.ctime = this.getUint32();\n    header.mtime = this.getUint32();\n    header.btime = this.getUint32();\n    header.mod_num = this.getUint32();\n    header.appinfo_offset = this.getUint32();\n    header.sortinfo_offset = this.getUint32();\n    header.type = this.getStr(4);\n    header.creator = this.getStr(4);\n    header.uid = this.getUint32();\n    header.next_rec = this.getUint32();\n    header.record_num = this.getUint16();\n    return header;\n  }\n\n  load_reclist() {\n    var reclist = [];\n    for (var i = 0; i < this.header.record_num; i++) {\n      var record = {};\n      record.offset = this.getUint32();\n      // TODO(zz) change\n      record.attr = this.getUint32();\n      reclist.push(record);\n    }\n    return reclist;\n  }\n  load_record0() {\n    this.palm_header = this.load_record0_header();\n    this.mobi_header = this.load_mobi_header();\n  }\n\n  load_record0_header() {\n    var p_header = {};\n    var first_record = this.reclist[0];\n    this.setoffset(first_record.offset);\n\n    p_header.compression = this.getUint16();\n    this.skip(2);\n    p_header.text_length = this.getUint32();\n    p_header.record_count = this.getUint16();\n    p_header.record_size = this.getUint16();\n    p_header.encryption_type = this.getUint16();\n    this.skip(2);\n\n    return p_header;\n  }\n\n  load_mobi_header() {\n    var mobi_header = {};\n\n    var start_offset = this.offset;\n\n    mobi_header.identifier = this.getUint32();\n    mobi_header.header_length = this.getUint32();\n    mobi_header.mobi_type = this.getUint32();\n    mobi_header.text_encoding = this.getUint32();\n    mobi_header.uid = this.getUint32();\n    mobi_header.generator_version = this.getUint32();\n\n    this.skip(40);\n\n    mobi_header.first_nonbook_index = this.getUint32();\n    mobi_header.full_name_offset = this.getUint32();\n    mobi_header.full_name_length = this.getUint32();\n\n    mobi_header.language = this.getUint32();\n    mobi_header.input_language = this.getUint32();\n    mobi_header.output_language = this.getUint32();\n    mobi_header.min_version = this.getUint32();\n    mobi_header.first_image_idx = this.getUint32();\n\n    mobi_header.huff_rec_index = this.getUint32();\n    mobi_header.huff_rec_count = this.getUint32();\n    mobi_header.datp_rec_index = this.getUint32();\n    mobi_header.datp_rec_count = this.getUint32();\n\n    mobi_header.exth_flags = this.getUint32();\n\n    this.skip(36);\n\n    mobi_header.drm_offset = this.getUint32();\n    mobi_header.drm_count = this.getUint32();\n    mobi_header.drm_size = this.getUint32();\n    mobi_header.drm_flags = this.getUint32();\n\n    this.skip(8);\n\n    // TODO (zz) fdst_index\n    this.skip(4);\n\n    this.skip(46);\n\n    mobi_header.extra_flags = this.getUint16();\n\n    this.setoffset(start_offset + mobi_header.header_length);\n\n    return mobi_header;\n  }\n  load_exth_header() {\n    // TODO\n    return {};\n  }\n  extractContent(s) {\n    var span = document.createElement(\"span\");\n    span.innerHTML = s;\n    return span.textContent || span.innerText;\n  }\n  render(isElectron = false) {\n    return new Promise((resolve, reject) => {\n      this.load();\n      var content = this.read_text();\n      var bookDoc = domParser.parseFromString(content, \"text/html\")\n        .documentElement;\n      let lines = Array.from(\n        bookDoc.querySelectorAll(\"p,b,font,h3,h2,h1\")\n      );\n      let parseContent = [];\n      for (let i = 0, len = lines.length; i < len - 1; i++) {\n        lines[i].innerText &&\n          lines[i].innerText !== parseContent[parseContent.length - 1] &&\n          parseContent.push(lines[i].innerText);\n        let imgDoms = lines[i].getElementsByTagName(\"img\");\n        if (imgDoms.length > 0) {\n          for (let i = 0; i < imgDoms.length; i++) {\n            parseContent.push(\"#image\");\n          }\n        }\n      }\n      const handleImage = async () => {\n        var imgDoms = bookDoc.getElementsByTagName(\"img\");\n        parseContent.push(\"~image\");\n        for (let i = 0; i < imgDoms.length; i++) {\n          const src = await this.render_image(imgDoms, i);\n          parseContent.push(\n            src + \" \" + imgDoms[i].width + \" \" + imgDoms[i].height\n          );\n        }\n        if (imgDoms.length > 200 || !isElectron) {\n          resolve(bookDoc);\n        } else {\n          resolve(parseContent.join(\"\\n    \\n\"));\n        }\n      };\n      handleImage();\n    });\n  }\n  render_image = (imgDoms, i) => {\n    return new Promise((resolve, reject) => {\n      var imgDom = imgDoms[i];\n      var idx = +imgDom.getAttribute(\"recindex\");\n      var blob = this.read_image(idx - 1);\n      var imgReader = new FileReader();\n      imgReader.onload = (e) => {\n        imgDom.src = e.target?.result;\n        resolve(e.target?.result);\n      };\n      imgReader.onerror = function (err) {\n        reject(err);\n      };\n      imgReader.readAsDataURL(blob);\n    });\n  };\n}\n\nexport default MobiFile;\n"
  },
  {
    "path": "client/assets/fonts.css",
    "content": "@font-face {\n  font-family: 'Material Symbols Rounded';\n  font-style: normal;\n  font-weight: 400;\n  src: url(~static/fonts/MaterialSymbolsRounded.woff2) format('woff2');\n}\n\n.material-symbols {\n  font-family: 'Material Symbols Rounded';\n  font-weight: normal;\n  font-style: normal;\n  line-height: 1;\n  letter-spacing: normal;\n  text-transform: none;\n  display: inline-block;\n  white-space: nowrap;\n  word-wrap: normal;\n  direction: ltr;\n  -webkit-font-smoothing: antialiased;\n  vertical-align: top;\n}\n\n.material-symbols.fill {\n  font-variation-settings:\n    'FILL' 1\n}\n\n/* cyrillic-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* cyrillic */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* greek-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+1F00-1FFF;\n}\n\n/* greek */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+0370-03FF;\n}\n\n/* vietnamese */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;\n}\n\n/* latin-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* latin */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 300;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* cyrillic-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* cyrillic */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');\n  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* greek-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');\n  unicode-range: U+1F00-1FFF;\n}\n\n/* greek */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');\n  unicode-range: U+0370-03FF;\n}\n\n/* vietnamese */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');\n  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;\n}\n\n/* latin-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');\n  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* latin */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* cyrillic-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* cyrillic */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* greek-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+1F00-1FFF;\n}\n\n/* greek */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+0370-03FF;\n}\n\n/* vietnamese */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;\n}\n\n/* latin-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* latin */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 600;\n  font-display: swap;\n  src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* cyrillic-ext */\n@font-face {\n  font-family: 'Ubuntu Mono';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* cyrillic */\n@font-face {\n  font-family: 'Ubuntu Mono';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');\n  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* greek-ext */\n@font-face {\n  font-family: 'Ubuntu Mono';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');\n  unicode-range: U+1F00-1FFF;\n}\n\n/* greek */\n@font-face {\n  font-family: 'Ubuntu Mono';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');\n  unicode-range: U+0370-03FF;\n}\n\n/* latin-ext */\n@font-face {\n  font-family: 'Ubuntu Mono';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');\n  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* latin */\n@font-face {\n  font-family: 'Ubuntu Mono';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n"
  },
  {
    "path": "client/assets/tailwind.css",
    "content": "@import 'tailwindcss';\n\n/*\n  The default border color has changed to `currentColor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentColor);\n  }\n\n  [role='button'],\n  button {\n    cursor: pointer;\n  }\n}\n\n@theme {\n  --spacing-0\\.5e: 0.125em;\n  --spacing-1e: 0.25em;\n  --spacing-1\\.5e: 0.375em;\n  --spacing-2e: 0.5em;\n  --spacing-2\\.5e: 0.625em;\n  --spacing-3e: 0.75em;\n  --spacing-3\\.5e: 0.875em;\n  --spacing-4e: 1em;\n  --spacing-5e: 1.25em;\n  --spacing-6e: 1.5em;\n  --spacing-7e: 1.75em;\n  --spacing-8e: 2em;\n  --spacing-9e: 2.25em;\n  --spacing-10e: 2.5em;\n  --spacing-11e: 2.75em;\n  --spacing-12e: 3em;\n  --spacing-14e: 3.5em;\n  --spacing-16e: 4em;\n  --spacing-20e: 5em;\n  --spacing-24e: 6em;\n  --spacing-28e: 7em;\n  --spacing-32e: 8em;\n  --spacing-36e: 9em;\n  --spacing-40e: 10em;\n  --spacing-44e: 11em;\n  --spacing-48e: 12em;\n  --spacing-52e: 13em;\n  --spacing-56e: 14em;\n  --spacing-60e: 15em;\n  --spacing-64e: 16em;\n  --spacing-72e: 18em;\n  --spacing-80e: 20em;\n  --spacing-96e: 24em;\n\n  --color-bg: #373838;\n  --color-primary: #232323;\n  --color-accent: #1ad691;\n  --color-error: #ff5252;\n  --color-info: #2196f3;\n  --color-success: #4caf50;\n  --color-warning: #fb8c00;\n  --color-darkgreen: rgb(34, 127, 35);\n  --color-black-50: #bbbbbb;\n  --color-black-100: #666666;\n  --color-black-200: #555555;\n  --color-black-300: #444444;\n  --color-black-400: #333333;\n  --color-black-500: #222222;\n  --color-black-600: #111111;\n  --color-black-700: #101010;\n\n  --font-sans: 'Source Sans Pro';\n  --font-mono: 'Ubuntu Mono';\n\n  --text-xxs: 0.625rem;\n  --text-1\\.5xl: 1.375rem;\n  --text-2\\.5xl: 1.6875rem;\n  --text-4\\.5xl: 2.625rem;\n}\n"
  },
  {
    "path": "client/assets/transitions.css",
    "content": ".slide-enter-active {\n  -moz-transition-duration: 0.1s;\n  -webkit-transition-duration: 0.1s;\n  -o-transition-duration: 0.1s;\n  transition-duration: 0.1s;\n  -moz-transition-timing-function: ease-in;\n  -webkit-transition-timing-function: ease-in;\n  -o-transition-timing-function: ease-in;\n  transition-timing-function: ease-in;\n}\n\n.slide-leave-active {\n  -moz-transition-duration: 0.2s;\n  -webkit-transition-duration: 0.2s;\n  -o-transition-duration: 0.2s;\n  transition-duration: 0.2s;\n  -moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);\n  -webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);\n  -o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);\n  transition-timing-function: cubic-bezier(0, 1, 0.5, 1);\n}\n\n.slide-enter-to,\n.slide-leave {\n  max-height: 600px;\n  overflow: hidden;\n}\n\n.slide-enter,\n.slide-leave-to {\n  overflow: hidden;\n  max-height: 0;\n}\n\n\n\n.menu-enter,\n.menu-leave-active {\n  transform: translateY(-15px);\n}\n\n.menu-enter-active {\n  transition: all 0.2s;\n}\n\n.menu-leave-active {\n  transition: all 0.1s;\n}\n\n.menu-enter,\n.menu-leave-active {\n  opacity: 0;\n}\n\n.menux-enter,\n.menux-leave-active {\n  transform: translateX(15px);\n}\n\n.menux-enter-active {\n  transition: all 0.2s;\n}\n\n.menux-leave-active {\n  transition: all 0.1s;\n}\n\n.menux-enter,\n.menux-leave-active {\n  opacity: 0;\n}\n\n\n.list-complete-item {\n  transition: all 0.8s ease;\n}\n\n.list-complete-enter-from,\n.list-complete-leave-to {\n  opacity: 0;\n  transform: translateY(30px);\n}\n\n.list-complete-leave-active {\n  position: absolute;\n}"
  },
  {
    "path": "client/assets/trix.css",
    "content": "@charset \"UTF-8\";\n\n/*\nTrix 1.3.1\nCopyright © 2020 Basecamp, LLC\nhttp://trix-editor.org/*/\ntrix-editor {\n  border: 1px solid rgb(75, 85, 99);\n  border-radius: 3px;\n  background: rgb(35, 35, 35);\n  margin: 0;\n  padding: 0.4em 0.6em;\n  min-height: 5em;\n  outline: none;\n}\n\ntrix-toolbar * {\n  box-sizing: border-box;\n}\n\ntrix-toolbar .trix-button-row {\n  display: flex;\n  flex-wrap: nowrap;\n  justify-content: space-between;\n  overflow-x: auto;\n}\n\ntrix-toolbar .trix-button-group {\n  display: flex;\n  margin-bottom: 10px;\n  border: 1px solid rgb(75, 85, 99);\n  border-top-color: rgb(75, 85, 99);\n  border-bottom-color: rgb(75, 85, 99);\n  border-radius: 3px;\n}\n\ntrix-toolbar .trix-button-group:not(:first-child) {\n  margin-left: 1.5vw;\n}\n\n@media (max-device-width: 768px) {\n  trix-toolbar .trix-button-group:not(:first-child) {\n    margin-left: 0;\n  }\n}\n\ntrix-toolbar .trix-button-group-spacer {\n  flex-grow: 1;\n}\n\n@media (max-device-width: 768px) {\n  trix-toolbar .trix-button-group-spacer {\n    display: none;\n  }\n}\n\ntrix-toolbar .trix-button {\n  position: relative;\n  float: left;\n  color: rgba(0, 0, 0, 0.6);\n  font-size: 0.75em;\n  font-weight: 600;\n  white-space: nowrap;\n  padding: 0 0.5em;\n  margin: 0;\n  outline: none;\n  border: none;\n  border-radius: 0;\n  background: transparent;\n}\n\ntrix-toolbar .trix-button:not(:first-child) {\n  border-left: 1px solid rgb(75, 85, 99);\n}\n\ntrix-toolbar .trix-button.trix-active {\n  background: #bbb;\n  color: black;\n}\n\ntrix-toolbar .trix-button:not(:disabled) {\n  cursor: pointer;\n  background: rgb(35, 35, 35);\n}\n\ntrix-toolbar .trix-button:disabled {\n  color: rgba(0, 0, 0, 0.25);\n}\n\n@media (max-device-width: 768px) {\n  trix-toolbar .trix-button {\n    letter-spacing: -0.01em;\n    padding: 0 0.3em;\n  }\n}\n\ntrix-toolbar .trix-button--icon {\n  font-size: inherit;\n  width: 2.6em;\n  height: 1.6em;\n  max-width: calc(0.8em + 4vw);\n  text-indent: -9999px;\n}\n\n@media (max-device-width: 768px) {\n  trix-toolbar .trix-button--icon {\n    height: 2em;\n    max-width: calc(0.8em + 3.5vw);\n  }\n}\n\ntrix-toolbar .trix-button--icon::before {\n  display: inline-block;\n  position: absolute;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  opacity: 0.6;\n  content: \"\";\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: contain;\n  filter: invert(100%);\n}\n\n@media (max-device-width: 768px) {\n  trix-toolbar .trix-button--icon::before {\n    right: 6%;\n    left: 6%;\n  }\n}\n\ntrix-toolbar .trix-button--icon.trix-active::before {\n  opacity: 1;\n}\n\ntrix-toolbar .trix-button--icon:disabled::before {\n  opacity: 0.125;\n}\n\ntrix-toolbar .trix-button--icon-attach::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);\n  top: 8%;\n  bottom: 4%;\n}\n\ntrix-toolbar .trix-button--icon-bold::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-italic::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-link::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-strike::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-quote::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-heading-1::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-code::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-bullet-list::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-number-list::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-undo::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-redo::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-decrease-nesting-level::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-button--icon-increase-nesting-level::before {\n  background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);\n}\n\ntrix-toolbar .trix-dialogs {\n  position: relative;\n}\n\ntrix-toolbar .trix-dialog {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  font-size: 0.75em;\n  padding: 15px 10px;\n  background: rgb(48, 48, 48);\n  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);\n  border: 1px solid rgb(112, 112, 112);\n  border-radius: 5px;\n  z-index: 5;\n}\n\ntrix-toolbar .trix-input--dialog {\n  font-size: inherit;\n  font-weight: normal;\n  padding: 0.5em 0.8em;\n  margin: 0 10px 0 0;\n  border-radius: 3px;\n  border: 1px solid #bbb;\n  background-color: rgb(95, 95, 95);\n  box-shadow: none;\n  outline: none;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n}\n\ntrix-toolbar .trix-input--dialog.validate:invalid {\n  box-shadow: #F00 0px 0px 1.5px 1px;\n}\n\ntrix-toolbar .trix-button--dialog {\n  font-size: inherit;\n  padding: 0.5em;\n  border-bottom: none;\n  color: #eee;\n}\n\ntrix-toolbar .trix-dialog--link {\n  max-width: 600px;\n}\n\ntrix-toolbar .trix-dialog__link-fields {\n  display: flex;\n  align-items: baseline;\n}\n\ntrix-toolbar .trix-dialog__link-fields .trix-input {\n  flex: 1;\n}\n\ntrix-toolbar .trix-dialog__link-fields .trix-button-group {\n  flex: 0 0 content;\n  margin: 0;\n}\n\ntrix-editor [data-trix-mutable]:not(.attachment__caption-editor) {\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n}\n\ntrix-editor [data-trix-mutable]::-moz-selection,\ntrix-editor [data-trix-cursor-target]::-moz-selection,\ntrix-editor [data-trix-mutable] ::-moz-selection {\n  background: none;\n}\n\ntrix-editor [data-trix-mutable]::selection,\ntrix-editor [data-trix-cursor-target]::selection,\ntrix-editor [data-trix-mutable] ::selection {\n  background: none;\n}\n\ntrix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {\n  background: highlight;\n}\n\ntrix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {\n  background: highlight;\n}\n\ntrix-editor [data-trix-mutable].attachment.attachment--file {\n  box-shadow: 0 0 0 2px highlight;\n  border-color: transparent;\n}\n\ntrix-editor [data-trix-mutable].attachment img {\n  box-shadow: 0 0 0 2px highlight;\n}\n\ntrix-editor .attachment {\n  position: relative;\n}\n\ntrix-editor .attachment:hover {\n  cursor: default;\n}\n\ntrix-editor .attachment--preview .attachment__caption:hover {\n  cursor: text;\n}\n\ntrix-editor .attachment__progress {\n  position: absolute;\n  z-index: 1;\n  height: 20px;\n  top: calc(50% - 10px);\n  left: 5%;\n  width: 90%;\n  opacity: 0.9;\n  transition: opacity 200ms ease-in;\n}\n\ntrix-editor .attachment__progress[value=\"100\"] {\n  opacity: 0;\n}\n\ntrix-editor .attachment__caption-editor {\n  display: inline-block;\n  width: 100%;\n  margin: 0;\n  padding: 0;\n  font-size: inherit;\n  font-family: inherit;\n  line-height: inherit;\n  color: inherit;\n  text-align: center;\n  vertical-align: top;\n  border: none;\n  outline: none;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n}\n\ntrix-editor .attachment__toolbar {\n  position: absolute;\n  z-index: 1;\n  top: -0.9em;\n  left: 0;\n  width: 100%;\n  text-align: center;\n}\n\ntrix-editor .trix-button-group {\n  display: inline-flex;\n}\n\ntrix-editor .trix-button {\n  position: relative;\n  float: left;\n  color: #666;\n  white-space: nowrap;\n  font-size: 80%;\n  padding: 0 0.8em;\n  margin: 0;\n  outline: none;\n  border: none;\n  border-radius: 0;\n  background: transparent;\n}\n\ntrix-editor .trix-button:not(:first-child) {\n  border-left: 1px solid #ccc;\n}\n\ntrix-editor .trix-button.trix-active {\n  background: #cbeefa;\n}\n\ntrix-editor .trix-button:not(:disabled) {\n  cursor: pointer;\n}\n\ntrix-editor .trix-button--remove {\n  text-indent: -9999px;\n  display: inline-block;\n  padding: 0;\n  outline: none;\n  width: 1.8em;\n  height: 1.8em;\n  line-height: 1.8em;\n  border-radius: 50%;\n  background-color: #fff;\n  border: 2px solid highlight;\n  box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);\n}\n\ntrix-editor .trix-button--remove::before {\n  display: inline-block;\n  position: absolute;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  opacity: 0.7;\n  content: \"\";\n  background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: 90%;\n}\n\ntrix-editor .trix-button--remove:hover {\n  border-color: #333;\n}\n\ntrix-editor .trix-button--remove:hover::before {\n  opacity: 1;\n}\n\ntrix-editor .attachment__metadata-container {\n  position: relative;\n}\n\ntrix-editor .attachment__metadata {\n  position: absolute;\n  left: 50%;\n  top: 2em;\n  transform: translate(-50%, 0);\n  max-width: 90%;\n  padding: 0.1em 0.6em;\n  font-size: 0.8em;\n  color: #fff;\n  background-color: rgba(0, 0, 0, 0.7);\n  border-radius: 3px;\n}\n\ntrix-editor .attachment__metadata .attachment__name {\n  display: inline-block;\n  max-width: 100%;\n  vertical-align: bottom;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\ntrix-editor .attachment__metadata .attachment__size {\n  margin-left: 0.2em;\n  white-space: nowrap;\n}\n\n.trix-content {\n  line-height: inherit;\n}\n\n.trix-content * {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n\n.trix-content p {\n  box-sizing: border-box;\n  margin-top: 0;\n  margin-bottom: 0.5em;\n  padding: 0;\n}\n\n.trix-content h1 {\n  font-size: 1.2em;\n  line-height: 1.2;\n}\n\n.trix-content blockquote {\n  border: 0 solid #ccc;\n  border-left-width: 0.3em;\n  margin-left: 0.3em;\n  padding-left: 0.6em;\n}\n\n.trix-content [dir=rtl] blockquote,\n.trix-content blockquote[dir=rtl] {\n  border-width: 0;\n  border-right-width: 0.3em;\n  margin-right: 0.3em;\n  padding-right: 0.6em;\n}\n\n.trix-content li {\n  margin-left: 1em;\n}\n\n.trix-content [dir=rtl] li {\n  margin-right: 1em;\n}\n\n.trix-content pre {\n  display: inline-block;\n  width: 100%;\n  vertical-align: top;\n  font-family: monospace;\n  font-size: 0.9em;\n  padding: 0.5em;\n  white-space: pre;\n  background-color: #eee;\n  overflow-x: auto;\n}\n\n.trix-content img {\n  max-width: 100%;\n  height: auto;\n}\n\n.trix-content .attachment {\n  display: inline-block;\n  position: relative;\n  max-width: 100%;\n}\n\n.trix-content .attachment a {\n  color: inherit;\n  text-decoration: none;\n}\n\n.trix-content .attachment a:hover,\n.trix-content .attachment a:visited:hover {\n  color: inherit;\n}\n\n.trix-content .attachment__caption {\n  text-align: center;\n}\n\n.trix-content .attachment__caption .attachment__name+.attachment__size::before {\n  content: ' · ';\n}\n\n.trix-content .attachment--preview {\n  width: 100%;\n  text-align: center;\n}\n\n.trix-content .attachment--preview .attachment__caption {\n  color: #666;\n  font-size: 0.9em;\n  line-height: 1.2;\n}\n\n.trix-content .attachment--file {\n  color: #333;\n  line-height: 1;\n  margin: 0 2px 2px 2px;\n  padding: 0.4em 1em;\n  border: 1px solid #bbb;\n  border-radius: 5px;\n}\n\n.trix-content .attachment-gallery {\n  display: flex;\n  flex-wrap: wrap;\n  position: relative;\n}\n\n.trix-content .attachment-gallery .attachment {\n  flex: 1 0 33%;\n  padding: 0 0.5em;\n  max-width: 33%;\n}\n\n.trix-content .attachment-gallery.attachment-gallery--2 .attachment,\n.trix-content .attachment-gallery.attachment-gallery--4 .attachment {\n  flex-basis: 50%;\n  max-width: 50%;\n}\n"
  },
  {
    "path": "client/components/app/Appbar.vue",
    "content": "<template>\n  <div class=\"w-full h-16 bg-primary relative\">\n    <div id=\"appbar\" role=\"toolbar\" aria-label=\"Appbar\" class=\"absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60\">\n      <div class=\"flex h-full items-center\">\n        <nuxt-link to=\"/\">\n          <img src=\"~static/icon.svg\" :alt=\"$strings.ButtonHome\" class=\"w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4\" />\n        </nuxt-link>\n\n        <nuxt-link to=\"/\">\n          <h1 class=\"text-xl mr-6 hidden lg:block hover:underline\">audiobookshelf</h1>\n        </nuxt-link>\n\n        <ui-libraries-dropdown class=\"mr-2\" />\n\n        <controls-global-search v-if=\"currentLibrary\" class=\"mr-1 sm:mr-0\" />\n        <div class=\"grow\" />\n\n        <ui-tooltip v-if=\"isChromecastInitialized && !isHttps\" direction=\"bottom\" text=\"Casting requires a secure connection\" class=\"flex items-center\">\n          <span class=\"material-symbols text-2xl text-warning/50\"> cast </span>\n        </ui-tooltip>\n        <div v-if=\"isChromecastInitialized\" class=\"w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer\">\n          <google-cast-launcher></google-cast-launcher>\n        </div>\n\n        <widgets-notification-widget class=\"hidden md:block\" />\n\n        <nuxt-link v-if=\"currentLibrary\" to=\"/config/stats\" class=\"hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1\">\n          <ui-tooltip :text=\"$strings.HeaderYourStats\" direction=\"bottom\" class=\"flex items-center\">\n            <span class=\"material-symbols text-2xl\" aria-label=\"User Stats\" role=\"button\">&#xe01d;</span>\n          </ui-tooltip>\n        </nuxt-link>\n\n        <nuxt-link v-if=\"userCanUpload && currentLibrary\" to=\"/upload\" class=\"hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1\">\n          <ui-tooltip :text=\"$strings.ButtonUpload\" direction=\"bottom\" class=\"flex items-center\">\n            <span class=\"material-symbols text-2xl\" aria-label=\"Upload Media\" role=\"button\">&#xf09b;</span>\n          </ui-tooltip>\n        </nuxt-link>\n\n        <nuxt-link v-if=\"userIsAdminOrUp\" to=\"/config\" class=\"hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1\">\n          <ui-tooltip :text=\"$strings.HeaderSettings\" direction=\"bottom\" class=\"flex items-center\">\n            <span class=\"material-symbols text-2xl\" aria-label=\"System Settings\" role=\"button\">&#xe8b8;</span>\n          </ui-tooltip>\n        </nuxt-link>\n\n        <nuxt-link to=\"/account\" class=\"relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40\" aria-haspopup=\"listbox\" aria-expanded=\"true\">\n          <span class=\"items-center hidden md:flex\">\n            <span class=\"block truncate\">{{ username }}</span>\n          </span>\n          <span class=\"h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none\">\n            <span class=\"material-symbols text-xl text-gray-100\">&#xe7fd;</span>\n          </span>\n        </nuxt-link>\n      </div>\n      <div v-show=\"numMediaItemsSelected\" class=\"absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center\">\n        <h1 class=\"text-lg md:text-2xl px-4\">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>\n        <div class=\"grow\" />\n        <ui-btn v-if=\"!isPodcastLibrary && selectedMediaItemsArePlayable\" color=\"bg-success\" :padding-x=\"4\" small class=\"flex items-center h-9 mr-2\" @click=\"playSelectedItems\">\n          <span class=\"material-symbols fill text-2xl -ml-2 pr-1 text-white\">play_arrow</span>\n          {{ $strings.ButtonPlay }}\n        </ui-btn>\n        <ui-tooltip v-if=\"isBookLibrary\" :text=\"selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"bottom\">\n          <ui-read-icon-btn :disabled=\"processingBatch\" :is-read=\"selectedIsFinished\" @click=\"toggleBatchRead\" class=\"mx-1.5\" />\n        </ui-tooltip>\n        <ui-tooltip v-if=\"userCanUpdate && isBookLibrary\" :text=\"$strings.LabelAddToCollection\" direction=\"bottom\">\n          <ui-icon-btn :disabled=\"processingBatch\" icon=\"collections_bookmark\" @click=\"batchAddToCollectionClick\" class=\"mx-1.5\" />\n        </ui-tooltip>\n        <template v-if=\"userCanUpdate\">\n          <ui-tooltip :text=\"$strings.LabelEdit\" direction=\"bottom\">\n            <ui-icon-btn :disabled=\"processingBatch\" icon=\"edit\" bg-color=\"bg-warning\" class=\"mx-1.5\" @click=\"batchEditClick\" />\n          </ui-tooltip>\n        </template>\n        <ui-tooltip v-if=\"userCanDelete\" :text=\"$strings.ButtonRemove\" direction=\"bottom\">\n          <ui-icon-btn :disabled=\"processingBatch\" icon=\"delete\" bg-color=\"bg-error\" class=\"mx-1.5\" @click=\"batchDeleteClick\" />\n        </ui-tooltip>\n\n        <ui-context-menu-dropdown v-if=\"contextMenuItems.length && !processingBatch\" :items=\"contextMenuItems\" class=\"ml-1\" @action=\"contextMenuAction\" />\n\n        <ui-tooltip :text=\"$strings.LabelDeselectAll\" direction=\"bottom\" class=\"flex items-center\">\n          <span class=\"material-symbols text-3xl px-4 hover:text-gray-100 cursor-pointer\" :class=\"processingBatch ? 'text-gray-400' : ''\" @click=\"cancelSelectionMode\">close</span>\n        </ui-tooltip>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      totalEntities: 0\n    }\n  },\n  computed: {\n    currentLibrary() {\n      return this.$store.getters['libraries/getCurrentLibrary']\n    },\n    libraryName() {\n      return this.currentLibrary ? this.currentLibrary.name : 'unknown'\n    },\n    libraryMediaType() {\n      return this.currentLibrary ? this.currentLibrary.mediaType : null\n    },\n    isPodcastLibrary() {\n      return this.libraryMediaType === 'podcast'\n    },\n    isBookLibrary() {\n      return this.libraryMediaType === 'book'\n    },\n    isHome() {\n      return this.$route.name === 'library-library'\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    username() {\n      return this.user ? this.user.username : 'err'\n    },\n    numMediaItemsSelected() {\n      return this.selectedMediaItems.length\n    },\n    selectedMediaItems() {\n      return this.$store.state.globals.selectedMediaItems\n    },\n    selectedMediaItemsArePlayable() {\n      return !this.selectedMediaItems.some((i) => !i.hasTracks)\n    },\n    userMediaProgress() {\n      return this.$store.state.user.user.mediaProgress || []\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userCanUpload() {\n      return this.$store.getters['user/getUserCanUpload']\n    },\n    selectedIsFinished() {\n      // Find an item that is not finished, if none then all items finished\n      return !this.selectedMediaItems.find((item) => {\n        const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)\n        return !itemProgress || !itemProgress.isFinished\n      })\n    },\n    processingBatch() {\n      return this.$store.state.processingBatch\n    },\n    isChromecastEnabled() {\n      return this.$store.getters['getServerSetting']('chromecastEnabled')\n    },\n    isChromecastInitialized() {\n      return this.$store.state.globals.isChromecastInitialized\n    },\n    isHttps() {\n      return location.protocol === 'https:' || process.env.NODE_ENV === 'development'\n    },\n    contextMenuItems() {\n      if (!this.userIsAdminOrUp) return []\n\n      const options = [\n        {\n          text: this.$strings.ButtonQuickMatch,\n          action: 'quick-match'\n        }\n      ]\n\n      if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {\n        options.push({\n          text: this.$strings.ButtonQuickEmbedMetadata,\n          action: 'quick-embed'\n        })\n      }\n\n      options.push({\n        text: this.$strings.ButtonReScan,\n        action: 'rescan'\n      })\n\n      // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440\n      // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains\n      if (this.selectedMediaItems.length <= 40) {\n        options.push({\n          text: this.$strings.LabelDownload,\n          action: 'download'\n        })\n      }\n\n      return options\n    }\n  },\n  methods: {\n    requestBatchQuickEmbed() {\n      const payload = {\n        message: this.$strings.MessageConfirmQuickEmbed,\n        allowHtml: true,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$axios\n              .$post(`/api/tools/batch/embed-metadata`, {\n                libraryItemIds: this.selectedMediaItems.map((i) => i.id)\n              })\n              .then(() => {\n                console.log('Audio metadata embed started')\n                this.cancelSelectionMode()\n              })\n              .catch((error) => {\n                console.error('Audio metadata embed failed', error)\n                const errorMsg = error.response.data || 'Failed to embed metadata'\n                this.$toast.error(errorMsg)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    contextMenuAction({ action }) {\n      if (action === 'quick-embed') {\n        this.requestBatchQuickEmbed()\n      } else if (action === 'quick-match') {\n        this.batchAutoMatchClick()\n      } else if (action === 'rescan') {\n        this.batchRescan()\n      } else if (action === 'download') {\n        this.batchDownload()\n      }\n    },\n    async batchRescan() {\n      const payload = {\n        message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$axios\n              .$post(`/api/items/batch/scan`, {\n                libraryItemIds: this.selectedMediaItems.map((i) => i.id)\n              })\n              .then(() => {\n                console.log('Batch Re-Scan started')\n                this.cancelSelectionMode()\n              })\n              .catch((error) => {\n                console.error('Batch Re-Scan failed', error)\n                const errorMsg = error.response.data || 'Failed to batch re-scan'\n                this.$toast.error(errorMsg)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    async batchDownload() {\n      const libraryItemIds = this.selectedMediaItems.map((i) => i.id)\n      console.log('Downloading library items', libraryItemIds)\n      this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`)\n    },\n    async playSelectedItems() {\n      this.$store.commit('setProcessingBatch', true)\n\n      const libraryItemIds = this.selectedMediaItems.map((i) => i.id)\n      const libraryItems = await this.$axios\n        .$post(`/api/items/batch/get`, { libraryItemIds })\n        .then((res) => res.libraryItems)\n        .catch((error) => {\n          const errorMsg = error.response.data || 'Failed to get items'\n          console.error(errorMsg, error)\n          this.$toast.error(errorMsg)\n          return []\n        })\n\n      if (!libraryItems.length) {\n        this.$store.commit('setProcessingBatch', false)\n        return\n      }\n\n      const queueItems = []\n      libraryItems.forEach((item) => {\n        let subtitle = ''\n        if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')\n        queueItems.push({\n          libraryItemId: item.id,\n          libraryId: item.libraryId,\n          episodeId: null,\n          title: item.media.metadata.title,\n          subtitle,\n          caption: '',\n          duration: item.media.duration || null,\n          coverPath: item.media.coverPath || null\n        })\n      })\n\n      this.$eventBus.$emit('play-item', {\n        libraryItemId: queueItems[0].libraryItemId,\n        queueItems\n      })\n      this.$store.commit('setProcessingBatch', false)\n      this.$store.commit('globals/resetSelectedMediaItems', [])\n      this.$eventBus.$emit('bookshelf_clear_selection')\n    },\n    cancelSelectionMode() {\n      if (this.processingBatch) return\n      this.$store.commit('globals/resetSelectedMediaItems', [])\n      this.$eventBus.$emit('bookshelf_clear_selection')\n    },\n    toggleBatchRead() {\n      this.$store.commit('setProcessingBatch', true)\n      const newIsFinished = !this.selectedIsFinished\n      const updateProgressPayloads = this.selectedMediaItems.map((item) => {\n        return {\n          libraryItemId: item.id,\n          isFinished: newIsFinished\n        }\n      })\n      console.log('Progress payloads', updateProgressPayloads)\n      this.$axios\n        .patch(`/api/me/progress/batch/update`, updateProgressPayloads)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastBatchUpdateSuccess)\n          this.$store.commit('setProcessingBatch', false)\n          this.$store.commit('globals/resetSelectedMediaItems', [])\n          this.$eventBus.$emit('bookshelf_clear_selection')\n        })\n        .catch((error) => {\n          this.$toast.error(this.$strings.ToastBatchUpdateFailed)\n          console.error('Failed to batch update read/not read', error)\n          this.$store.commit('setProcessingBatch', false)\n        })\n    },\n    batchDeleteClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]),\n        checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,\n        yesButtonText: this.$strings.ButtonDelete,\n        yesButtonColor: 'error',\n        checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),\n        callback: (confirmed, hardDelete) => {\n          if (confirmed) {\n            localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)\n\n            this.$store.commit('setProcessingBatch', true)\n\n            this.$axios\n              .$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {\n                libraryItemIds: this.selectedMediaItems.map((i) => i.id)\n              })\n              .then(() => {\n                this.$toast.success(this.$strings.ToastBatchDeleteSuccess)\n                this.$store.commit('globals/resetSelectedMediaItems', [])\n                this.$eventBus.$emit('bookshelf_clear_selection')\n              })\n              .catch((error) => {\n                console.error('Batch delete failed', error)\n                this.$toast.error(this.$strings.ToastBatchDeleteFailed)\n              })\n              .finally(() => {\n                this.$store.commit('setProcessingBatch', false)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    batchEditClick() {\n      this.$router.push('/batch')\n    },\n    batchAddToCollectionClick() {\n      this.$store.commit('globals/setShowBatchCollectionsModal', true)\n    },\n    setBookshelfTotalEntities(totalEntities) {\n      this.totalEntities = totalEntities\n    },\n    batchAutoMatchClick() {\n      this.$store.commit('globals/setShowBatchQuickMatchModal', true)\n    }\n  },\n  mounted() {\n    this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)\n  }\n}\n</script>\n\n<style>\n#appbar {\n  box-shadow: 0px 5px 5px #11111155;\n}\n</style>\n"
  },
  {
    "path": "client/components/app/BookShelfCategorized.vue",
    "content": "<template>\n  <div id=\"bookshelf\" ref=\"wrapper\" class=\"w-full max-w-full h-full overflow-y-scroll relative\" :style=\"{ fontSize: sizeMultiplier + 'rem' }\">\n    <!-- Cover size widget -->\n    <widgets-cover-size-widget class=\"fixed right-4 z-50\" :style=\"{ bottom: streamLibraryItem ? '181px' : '16px' }\" />\n\n    <div v-if=\"loaded && !shelves.length && !search\" class=\"w-full flex flex-col items-center justify-center py-12\">\n      <p class=\"text-center text-2xl mb-4 py-4\">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>\n      <div v-if=\"userIsAdminOrUp\" class=\"flex\">\n        <ui-btn to=\"/config\" color=\"bg-primary\" class=\"w-52 mr-2\">{{ $strings.ButtonConfigureScanner }}</ui-btn>\n        <ui-btn color=\"bg-success\" class=\"w-52\" :loading=\"isScanningLibrary || tempIsScanning\" @click=\"scan\">{{ $strings.ButtonScanLibrary }}</ui-btn>\n      </div>\n    </div>\n    <div v-else-if=\"loaded && !shelves.length && search\" class=\"w-full h-40 flex items-center justify-center\">\n      <p class=\"text-center text-xl py-4\">{{ $strings.MessageBookshelfNoResultsForQuery }}</p>\n    </div>\n    <!-- Alternate plain view -->\n    <div v-else-if=\"isAlternativeBookshelfView\" class=\"w-full mb-24e\">\n      <template v-for=\"(shelf, index) in supportedShelves\">\n        <widgets-item-slider :shelf-id=\"shelf.id\" :key=\"index + '.'\" :items=\"shelf.entities\" :continue-listening-shelf=\"shelf.id === 'continue-listening' || shelf.id === 'continue-reading'\" :type=\"shelf.type\" class=\"bookshelf-row pl-8e my-6e\" @selectEntity=\"(payload) => selectEntity(payload, index)\">\n          <h2 class=\"font-semibold text-gray-100\">{{ $strings[shelf.labelStringKey] }}</h2>\n        </widgets-item-slider>\n      </template>\n    </div>\n    <!-- Regular bookshelf view -->\n    <div v-else class=\"w-full\">\n      <template v-for=\"(shelf, index) in supportedShelves\">\n        <app-book-shelf-row :key=\"index\" :index=\"index\" :shelf=\"shelf\" :size-multiplier=\"sizeMultiplier\" :book-cover-width=\"bookCoverWidth\" :book-cover-aspect-ratio=\"coverAspectRatio\" :continue-listening-shelf=\"shelf.id === 'continue-listening' || shelf.id === 'continue-reading'\" @selectEntity=\"(payload) => selectEntity(payload, index)\" />\n      </template>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    search: Boolean,\n    results: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      loaded: false,\n      keywordFilterTimeout: null,\n      scannerParseSubtitle: false,\n      wrapperClientWidth: 0,\n      shelves: [],\n      lastItemIndexSelected: -1,\n      tempIsScanning: false\n    }\n  },\n  computed: {\n    supportedShelves() {\n      return this.shelves.filter((shelf) => ['book', 'podcast', 'episode', 'series', 'authors', 'narrators'].includes(shelf.type))\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    currentLibraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    libraryName() {\n      return this.$store.getters['libraries/getCurrentLibraryName']\n    },\n    isAlternativeBookshelfView() {\n      return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.DETAIL\n    },\n    bookCoverWidth() {\n      var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')\n      if (this.isCoverSquareAspectRatio) return coverSize * 1.6\n      return coverSize\n    },\n    coverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    isCoverSquareAspectRatio() {\n      return this.coverAspectRatio == 1\n    },\n    sizeMultiplier() {\n      return this.$store.getters['user/getSizeMultiplier']\n    },\n    selectedMediaItems() {\n      return this.$store.state.globals.selectedMediaItems || []\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    isScanningLibrary() {\n      return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)\n    }\n  },\n  methods: {\n    selectEntity({ entity, shiftKey }, shelfIndex) {\n      const shelf = this.shelves[shelfIndex]\n      const entityShelfIndex = shelf.entities.findIndex((ent) => ent.id === entity.id)\n      const indexOf = shelf.shelfStartIndex + entityShelfIndex\n\n      const lastLastItemIndexSelected = this.lastItemIndexSelected\n      if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {\n        this.lastItemIndexSelected = indexOf\n      } else {\n        this.lastItemIndexSelected = -1\n      }\n\n      if (shiftKey && lastLastItemIndexSelected >= 0) {\n        let loopStart = indexOf\n        let loopEnd = lastLastItemIndexSelected\n        if (indexOf > lastLastItemIndexSelected) {\n          loopStart = lastLastItemIndexSelected\n          loopEnd = indexOf\n        }\n\n        const flattenedEntitiesArray = []\n        this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))\n\n        let isSelecting = false\n        // If any items in this range is not selected then select all otherwise unselect all\n        for (let i = loopStart; i <= loopEnd; i++) {\n          const thisEntity = flattenedEntitiesArray[i]\n          if (thisEntity) {\n            if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {\n              isSelecting = true\n              break\n            }\n          }\n        }\n        if (isSelecting) this.lastItemIndexSelected = indexOf\n\n        for (let i = loopStart; i <= loopEnd; i++) {\n          const thisEntity = flattenedEntitiesArray[i]\n          if (thisEntity) {\n            const mediaItem = {\n              id: thisEntity.id,\n              mediaType: thisEntity.mediaType,\n              hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)\n            }\n            this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })\n          } else {\n            console.error('Invalid entity index', i)\n          }\n        }\n      } else {\n        const mediaItem = {\n          id: entity.id,\n          mediaType: entity.mediaType,\n          hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)\n        }\n        this.$store.commit('globals/toggleMediaItemSelected', mediaItem)\n      }\n\n      this.$nextTick(() => {\n        this.$eventBus.$emit('item-selected', entity)\n      })\n    },\n    async init() {\n      this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0\n\n      if (this.search) {\n        this.setShelvesFromSearch()\n      } else {\n        await this.fetchCategories()\n      }\n      this.loaded = true\n    },\n    async fetchCategories() {\n      // Sets the limit for the number of items to be displayed based on the viewport width.\n      const viewportWidth = window.innerWidth\n      let limit\n      if (viewportWidth >= 3240) {\n        limit = 15\n      } else if (viewportWidth >= 2880 && viewportWidth < 3240) {\n        limit = 12\n      }\n\n      const limitQuery = limit ? `&limit=${limit}` : ''\n\n      const categories = await this.$axios\n        .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete,share${limitQuery}`)\n        .then((data) => {\n          return data\n        })\n        .catch((error) => {\n          console.error('Failed to fetch categories', error)\n          return []\n        })\n\n      let totalEntityCount = 0\n      for (const shelf of categories) {\n        shelf.shelfStartIndex = totalEntityCount\n        totalEntityCount += shelf.entities.length\n      }\n      this.shelves = categories\n    },\n    async setShelvesFromSearch() {\n      const shelves = []\n      if (this.results.books?.length) {\n        shelves.push({\n          id: 'books',\n          label: 'Books',\n          labelStringKey: 'LabelBooks',\n          type: 'book',\n          entities: this.results.books.map((res) => res.libraryItem)\n        })\n      }\n\n      if (this.results.podcasts?.length) {\n        shelves.push({\n          id: 'podcasts',\n          label: 'Podcasts',\n          labelStringKey: 'LabelPodcasts',\n          type: 'podcast',\n          entities: this.results.podcasts.map((res) => res.libraryItem)\n        })\n      }\n\n      if (this.results.episodes?.length) {\n        shelves.push({\n          id: 'episodes',\n          label: 'Episodes',\n          labelStringKey: 'LabelEpisodes',\n          type: 'episode',\n          entities: this.results.episodes.map((res) => res.libraryItem)\n        })\n      }\n\n      if (this.results.series?.length) {\n        shelves.push({\n          id: 'series',\n          label: 'Series',\n          labelStringKey: 'LabelSeries',\n          type: 'series',\n          entities: this.results.series.map((seriesObj) => {\n            return {\n              ...seriesObj.series,\n              books: seriesObj.books,\n              type: 'series'\n            }\n          })\n        })\n      }\n      if (this.results.tags?.length) {\n        shelves.push({\n          id: 'tags',\n          label: 'Tags',\n          labelStringKey: 'LabelTags',\n          type: 'tags',\n          entities: this.results.tags.map((tagObj) => {\n            return {\n              name: tagObj.name,\n              books: tagObj.books || [],\n              type: 'tags'\n            }\n          })\n        })\n      }\n      if (this.results.authors?.length) {\n        shelves.push({\n          id: 'authors',\n          label: 'Authors',\n          labelStringKey: 'LabelAuthors',\n          type: 'authors',\n          entities: this.results.authors.map((a) => {\n            return {\n              ...a,\n              type: 'author'\n            }\n          })\n        })\n      }\n      if (this.results.narrators?.length) {\n        shelves.push({\n          id: 'narrators',\n          label: 'Narrators',\n          labelStringKey: 'LabelNarrators',\n          type: 'narrators',\n          entities: this.results.narrators.map((n) => {\n            return {\n              ...n,\n              type: 'narrator'\n            }\n          })\n        })\n      }\n      this.shelves = shelves\n    },\n    scan() {\n      this.tempIsScanning = true\n      this.$store\n        .dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })\n        .catch((error) => {\n          console.error('Failed to start scan', error)\n          this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)\n        })\n        .finally(() => {\n          this.tempIsScanning = false\n        })\n    },\n    userUpdated(user) {\n      if (user.id !== this.$store.state.user.user.id) return\n\n      if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {\n        this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)\n      }\n      if (user.mediaProgress.length) {\n        const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)\n        this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')\n        this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')\n      }\n    },\n    libraryItemAdded(libraryItem) {\n      console.log('libraryItem added', libraryItem)\n      // TODO: Check if libraryItem would be on this shelf\n      if (!this.search) {\n        this.fetchCategories()\n      }\n    },\n    libraryItemUpdated(libraryItem) {\n      console.log('libraryItem updated', libraryItem)\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'book' || shelf.type == 'podcast') {\n          shelf.entities = shelf.entities.map((ent) => {\n            if (ent.id === libraryItem.id) {\n              return libraryItem\n            }\n            return ent\n          })\n        } else if (shelf.type === 'series') {\n          shelf.entities.forEach((ent) => {\n            ent.books = ent.books.map((book) => {\n              if (book.id === libraryItem.id) return libraryItem\n              return book\n            })\n          })\n        }\n      })\n    },\n    removeBookFromShelf(libraryItem) {\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'book' || shelf.type == 'podcast') {\n          shelf.entities = shelf.entities.filter((ent) => {\n            return ent.id !== libraryItem.id\n          })\n        } else if (shelf.type === 'series') {\n          shelf.entities.forEach((ent) => {\n            ent.books = ent.books.filter((book) => {\n              return book.id !== libraryItem.id\n            })\n          })\n        }\n      })\n    },\n    libraryItemRemoved(libraryItem) {\n      this.removeBookFromShelf(libraryItem)\n    },\n    libraryItemsAdded(libraryItems) {\n      console.log('libraryItems added', libraryItems)\n\n      // First items added to library\n      const isThisLibrary = libraryItems.some((li) => li.libraryId === this.currentLibraryId)\n      if (!this.shelves.length && !this.search && isThisLibrary) {\n        this.fetchCategories()\n        return\n      }\n\n      const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')\n      if (!recentlyAddedShelf) return\n\n      // Add new library item to the recently added shelf\n      for (const libraryItem of libraryItems) {\n        if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {\n          // Add to front of array\n          recentlyAddedShelf.entities.unshift(libraryItem)\n        }\n      }\n    },\n    libraryItemsUpdated(items) {\n      items.forEach((li) => {\n        this.libraryItemUpdated(li)\n      })\n    },\n    episodeAdded(episodeWithLibraryItem) {\n      const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId\n      if (!this.search && isThisLibrary) {\n        this.fetchCategories()\n      }\n    },\n    removeAllSeriesFromContinueSeries(seriesIds) {\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'book' && shelf.id == 'continue-series') {\n          // Filter out series books from continue series shelf\n          shelf.entities = shelf.entities.filter((ent) => {\n            if (ent.media.metadata.series && seriesIds.includes(ent.media.metadata.series.id)) return false\n            return true\n          })\n        }\n      })\n    },\n    removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {\n      const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)\n      if (continueListeningShelf) {\n        if (continueListeningShelf.type === 'book') {\n          continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {\n            if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id)) return false\n            return true\n          })\n        } else if (continueListeningShelf.type === 'episode') {\n          continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {\n            if (!ent.recentEpisode) return true // Should always have this here\n            if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id && mp.episodeId === ent.recentEpisode.id)) return false\n            return true\n          })\n        }\n      }\n    },\n    authorUpdated(author) {\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'authors') {\n          shelf.entities = shelf.entities.map((ent) => {\n            if (ent.id === author.id) {\n              return {\n                ...ent,\n                ...author\n              }\n            }\n            return ent\n          })\n        }\n      })\n    },\n    authorRemoved(author) {\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'authors') {\n          shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)\n        }\n      })\n    },\n    shareOpen(mediaItemShare) {\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'book') {\n          shelf.entities = shelf.entities.map((ent) => {\n            if (ent.media.id === mediaItemShare.mediaItemId) {\n              return {\n                ...ent,\n                mediaItemShare\n              }\n            }\n            return ent\n          })\n        }\n      })\n    },\n    shareClosed(mediaItemShare) {\n      this.shelves.forEach((shelf) => {\n        if (shelf.type == 'book') {\n          shelf.entities = shelf.entities.map((ent) => {\n            if (ent.media.id === mediaItemShare.mediaItemId) {\n              return {\n                ...ent,\n                mediaItemShare: null\n              }\n            }\n            return ent\n          })\n        }\n      })\n    },\n    initListeners() {\n      if (this.$root.socket) {\n        this.$root.socket.on('user_updated', this.userUpdated)\n        this.$root.socket.on('author_updated', this.authorUpdated)\n        this.$root.socket.on('author_removed', this.authorRemoved)\n        this.$root.socket.on('item_updated', this.libraryItemUpdated)\n        this.$root.socket.on('item_added', this.libraryItemAdded)\n        this.$root.socket.on('item_removed', this.libraryItemRemoved)\n        this.$root.socket.on('items_updated', this.libraryItemsUpdated)\n        this.$root.socket.on('items_added', this.libraryItemsAdded)\n        this.$root.socket.on('episode_added', this.episodeAdded)\n        this.$root.socket.on('share_open', this.shareOpen)\n        this.$root.socket.on('share_closed', this.shareClosed)\n      } else {\n        console.error('Error socket not initialized')\n      }\n    },\n    removeListeners() {\n      if (this.$root.socket) {\n        this.$root.socket.off('user_updated', this.userUpdated)\n        this.$root.socket.off('author_updated', this.authorUpdated)\n        this.$root.socket.off('author_removed', this.authorRemoved)\n        this.$root.socket.off('item_updated', this.libraryItemUpdated)\n        this.$root.socket.off('item_added', this.libraryItemAdded)\n        this.$root.socket.off('item_removed', this.libraryItemRemoved)\n        this.$root.socket.off('items_updated', this.libraryItemsUpdated)\n        this.$root.socket.off('items_added', this.libraryItemsAdded)\n        this.$root.socket.off('episode_added', this.episodeAdded)\n        this.$root.socket.off('share_open', this.shareOpen)\n        this.$root.socket.off('share_closed', this.shareClosed)\n      } else {\n        console.error('Error socket not initialized')\n      }\n    }\n  },\n  mounted() {\n    this.initListeners()\n    this.init()\n  },\n  beforeDestroy() {\n    this.removeListeners()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/app/BookShelfRow.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <div ref=\"shelf\" class=\"w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll no-scroll overflow-y-hidden z-10\" :style=\"{ paddingLeft: paddingLeft + 'em' }\" @scroll=\"scrolled\">\n      <div class=\"w-full h-full pt-6e\">\n        <div v-if=\"shelf.type === 'book' || shelf.type === 'podcast'\" class=\"flex items-center\">\n          <template v-for=\"(entity, index) in shelf.entities\">\n            <cards-lazy-book-card :key=\"entity.id\" :ref=\"`shelf-book-${entity.id}`\" :index=\"index\" :book-mount=\"entity\" :continue-listening-shelf=\"continueListeningShelf\" class=\"relative mx-2e\" @hook:updated=\"updatedBookCard\" @select=\"selectItem\" @edit=\"editItem\" />\n          </template>\n        </div>\n        <div v-if=\"shelf.type === 'episode'\" class=\"flex items-center\">\n          <template v-for=\"(entity, index) in shelf.entities\">\n            <cards-lazy-book-card :key=\"entity.recentEpisode.id\" :ref=\"`shelf-episode-${entity.recentEpisode.id}`\" :index=\"index\" :book-mount=\"entity\" :continue-listening-shelf=\"continueListeningShelf\" class=\"relative mx-2e\" @hook:updated=\"updatedBookCard\" @select=\"selectItem\" @editPodcast=\"editItem\" @edit=\"editEpisode\" />\n          </template>\n        </div>\n        <div v-if=\"shelf.type === 'series'\" class=\"flex items-center\">\n          <template v-for=\"entity in shelf.entities\">\n            <cards-lazy-series-card :key=\"entity.name\" :series-mount=\"entity\" class=\"relative mx-2e\" @hook:updated=\"updatedBookCard\" />\n          </template>\n        </div>\n        <div v-if=\"shelf.type === 'tags'\" class=\"flex items-center\">\n          <template v-for=\"entity in shelf.entities\">\n            <cards-group-card :key=\"entity.name\" :group=\"entity\" class=\"relative mx-2e\" @hook:updated=\"updatedBookCard\" />\n          </template>\n        </div>\n        <div v-if=\"shelf.type === 'authors'\" class=\"flex items-center\">\n          <template v-for=\"entity in shelf.entities\">\n            <cards-author-card :key=\"entity.id\" :authorMount=\"entity\" @hook:updated=\"updatedBookCard\" class=\"mx-2e\" @edit=\"editAuthor\" />\n          </template>\n        </div>\n        <div v-if=\"shelf.type === 'narrators'\" class=\"flex items-center\">\n          <template v-for=\"entity in shelf.entities\">\n            <cards-narrator-card :key=\"entity.name\" :narrator=\"entity\" @hook:updated=\"updatedBookCard\" class=\"mx-2e\" />\n          </template>\n        </div>\n      </div>\n    </div>\n    <div class=\"relative\">\n      <div class=\"relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md\">\n        <div class=\"w-full h-full shinyBlack flex items-center justify-center rounded-xs border\" :style=\"{ padding: `0em 0.5em` }\">\n          <h2 :style=\"{ fontSize: 0.9 + 'em' }\">{{ $strings[shelf.labelStringKey] }}</h2>\n        </div>\n      </div>\n\n      <div class=\"bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20\"></div>\n    </div>\n    <button v-show=\"canScrollLeft && !isScrolling\" :aria-label=\"$strings.ButtonScrollLeft\" class=\"hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40\" @click=\"scrollLeft\">\n      <span class=\"material-symbols text-white\" :style=\"{ fontSize: 3.75 + 'em' }\">chevron_left</span>\n    </button>\n    <button v-show=\"canScrollRight && !isScrolling\" :aria-label=\"$strings.ButtonScrollRight\" class=\"hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40\" @click=\"scrollRight\">\n      <span class=\"material-symbols text-white\" :style=\"{ fontSize: 3.75 + 'em' }\">chevron_right</span>\n    </button>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    index: Number,\n    shelf: {\n      type: Object,\n      default: () => {}\n    },\n    continueListeningShelf: Boolean\n  },\n  data() {\n    return {\n      canScrollRight: false,\n      canScrollLeft: false,\n      isScrolling: false,\n      scrollTimer: null,\n      updateTimer: null\n    }\n  },\n  computed: {\n    sizeMultiplier() {\n      return this.$store.getters['user/getSizeMultiplier']\n    },\n    paddingLeft() {\n      if (window.innerWidth < 768) return 1\n      return 2.5\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    isSelectionMode() {\n      return this.$store.getters['globals/getIsBatchSelectingMediaItems']\n    }\n  },\n  methods: {\n    clearSelectedEntities() {\n      this.updateSelectionMode(false)\n    },\n    editAuthor(author) {\n      this.$store.commit('globals/showEditAuthorModal', author)\n    },\n    editItem(libraryItem, tab = 'details') {\n      var itemIds = this.shelf.entities.map((e) => e.id)\n      this.$store.commit('setBookshelfBookIds', itemIds)\n      this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' })\n    },\n    editEpisode({ libraryItem, episode }) {\n      this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])\n      this.$store.commit('setSelectedLibraryItem', libraryItem)\n      this.$store.commit('globals/setSelectedEpisode', episode)\n      this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)\n    },\n    updateSelectionMode(val) {\n      const selectedMediaItems = this.$store.state.globals.selectedMediaItems\n      if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {\n        this.shelf.entities.forEach((ent) => {\n          var component = this.$refs[`shelf-book-${ent.id}`]\n          if (!component || !component.length) return\n          component = component[0]\n          component.setSelectionMode(val)\n          component.selected = selectedMediaItems.some((i) => i.id === ent.id)\n        })\n      } else if (this.shelf.type === 'episode') {\n        this.shelf.entities.forEach((ent) => {\n          var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]\n          if (!component || !component.length) return\n          component = component[0]\n          component.setSelectionMode(val)\n          component.selected = selectedMediaItems.some((i) => i.id === ent.id)\n        })\n      }\n    },\n    selectItem(payload) {\n      this.$emit('selectEntity', payload)\n    },\n    itemSelectedEvt() {\n      this.updateSelectionMode(this.isSelectionMode)\n    },\n    scrolled() {\n      clearTimeout(this.scrollTimer)\n      this.scrollTimer = setTimeout(() => {\n        this.isScrolling = false\n        this.$nextTick(this.checkCanScroll)\n      }, 50)\n    },\n    scrollLeft() {\n      if (!this.$refs.shelf) {\n        return\n      }\n      this.isScrolling = true\n      this.$refs.shelf.scrollLeft = 0\n    },\n    scrollRight() {\n      if (!this.$refs.shelf) {\n        return\n      }\n      this.isScrolling = true\n      this.$refs.shelf.scrollLeft = 999\n    },\n    updatedBookCard() {\n      clearTimeout(this.updateTimer)\n      this.updateTimer = setTimeout(() => {\n        this.$nextTick(this.checkCanScroll)\n      }, 100)\n    },\n    checkCanScroll() {\n      if (!this.$refs.shelf) {\n        return\n      }\n      var clientWidth = this.$refs.shelf.clientWidth\n      var scrollWidth = this.$refs.shelf.scrollWidth\n      var scrollLeft = this.$refs.shelf.scrollLeft\n      if (scrollWidth > clientWidth) {\n        this.canScrollRight = scrollLeft === 0\n        this.canScrollLeft = scrollLeft > 0\n      } else {\n        this.canScrollRight = false\n        this.canScrollLeft = false\n      }\n    }\n  },\n  mounted() {\n    this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)\n    this.$eventBus.$on('item-selected', this.itemSelectedEvt)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)\n    this.$eventBus.$off('item-selected', this.itemSelectedEvt)\n  }\n}\n</script>\n\n<style>\n.categorizedBookshelfRow {\n  scroll-behavior: smooth;\n  background-image: var(--bookshelf-texture-img);\n  background-repeat: repeat-x;\n}\n\n.bookshelfDividerCategorized {\n  background: rgb(149, 119, 90);\n  background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);\n  box-shadow: 2px 14px 8px #111111aa;\n}\n\n.book-shelf-arrow-right {\n  height: calc(100% - 1.5em);\n  background: rgb(48, 48, 48);\n  background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);\n}\n.book-shelf-arrow-left {\n  height: calc(100% - 1.5em);\n  background: rgb(48, 48, 48);\n  background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);\n}\n</style>\n"
  },
  {
    "path": "client/components/app/BookShelfToolbar.vue",
    "content": "<template>\n  <div class=\"w-full h-20 md:h-10 relative\">\n    <div class=\"flex md:hidden h-10 items-center\">\n      <nuxt-link :to=\"`/library/${currentLibraryId}`\" class=\"grow h-full flex justify-center items-center\" :class=\"isHomePage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p v-if=\"isHomePage || isPodcastLibrary\" class=\"text-sm\">{{ $strings.ButtonHome }}</p>\n        <span v-else class=\"material-symbols text-lg\">home</span>\n      </nuxt-link>\n      <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf`\" class=\"grow h-full flex justify-center items-center\" :class=\"isLibraryPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p v-if=\"isLibraryPage || isPodcastLibrary\" class=\"text-sm\">{{ $strings.ButtonLibrary }}</p>\n        <span v-else class=\"material-symbols text-lg\">import_contacts</span>\n      </nuxt-link>\n      <nuxt-link v-if=\"isPodcastLibrary\" :to=\"`/library/${currentLibraryId}/podcast/latest`\" class=\"grow h-full flex justify-center items-center\" :class=\"isPodcastLatestPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p class=\"text-sm\">{{ $strings.ButtonLatest }}</p>\n      </nuxt-link>\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/bookshelf/series`\" class=\"grow h-full flex justify-center items-center\" :class=\"isSeriesPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p v-if=\"isSeriesPage\" class=\"text-sm\">{{ $strings.ButtonSeries }}</p>\n        <span v-else class=\"material-symbols text-lg\">view_column</span>\n      </nuxt-link>\n      <nuxt-link v-if=\"showPlaylists\" :to=\"`/library/${currentLibraryId}/bookshelf/playlists`\" class=\"grow h-full flex justify-center items-center\" :class=\"isPlaylistsPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p v-if=\"isPlaylistsPage || isPodcastLibrary\" class=\"text-sm\">{{ $strings.ButtonPlaylists }}</p>\n        <span v-else class=\"material-symbols text-lg\">&#xe03d;</span>\n      </nuxt-link>\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/bookshelf/collections`\" class=\"grow h-full flex justify-center items-center\" :class=\"isCollectionsPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p v-if=\"isCollectionsPage\" class=\"text-sm\">{{ $strings.ButtonCollections }}</p>\n        <span v-else class=\"material-symbols text-lg\">&#xe431;</span>\n      </nuxt-link>\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/bookshelf/authors`\" class=\"grow h-full flex justify-center items-center\" :class=\"isAuthorsPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p v-if=\"isAuthorsPage\" class=\"text-sm\">{{ $strings.ButtonAuthors }}</p>\n        <span v-else class=\"material-symbols text-lg\">groups</span>\n      </nuxt-link>\n      <nuxt-link v-if=\"isPodcastLibrary && userIsAdminOrUp\" :to=\"`/library/${currentLibraryId}/podcast/search`\" class=\"grow h-full flex justify-center items-center\" :class=\"isPodcastSearchPage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p class=\"text-sm\">{{ $strings.ButtonAdd }}</p>\n      </nuxt-link>\n      <nuxt-link v-if=\"isPodcastLibrary && userIsAdminOrUp\" :to=\"`/library/${currentLibraryId}/podcast/download-queue`\" class=\"grow h-full flex justify-center items-center\" :class=\"isPodcastDownloadQueuePage ? 'bg-primary/80' : 'bg-primary/40'\">\n        <p class=\"text-sm\">{{ $strings.ButtonDownloadQueue }}</p>\n      </nuxt-link>\n    </div>\n    <div id=\"toolbar\" role=\"toolbar\" aria-label=\"Library Toolbar\" class=\"absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8\">\n      <!-- Series books page -->\n      <template v-if=\"selectedSeries\">\n        <p class=\"pl-2 text-base md:text-lg\">\n          {{ seriesName }}\n        </p>\n        <div class=\"w-6 h-6 rounded-full bg-black/30 flex items-center justify-center ml-3\">\n          <span class=\"font-mono\">{{ $formatNumber(numShowing) }}</span>\n        </div>\n        <div class=\"grow\" />\n\n        <!-- RSS feed -->\n        <ui-tooltip v-if=\"seriesRssFeed\" :text=\"$strings.LabelOpenRSSFeed\" direction=\"top\">\n          <ui-icon-btn icon=\"rss_feed\" class=\"mx-0.5\" :size=\"7\" icon-font-size=\"1.2rem\" bg-color=\"bg-success\" outlined @click=\"showOpenSeriesRSSFeed\" />\n        </ui-tooltip>\n\n        <ui-context-menu-dropdown v-if=\"!isBatchSelecting && seriesContextMenuItems.length\" :items=\"seriesContextMenuItems\" class=\"mx-px\" @action=\"seriesContextMenuAction\" />\n      </template>\n      <!-- library & collections page -->\n      <template v-else-if=\"page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage\">\n        <p class=\"hidden md:block\">{{ $formatNumber(numShowing) }} {{ entityName }}</p>\n\n        <div class=\"grow hidden sm:inline-block\" />\n\n        <!-- library filter select -->\n        <controls-library-filter-select v-if=\"isLibraryPage && !isBatchSelecting\" v-model=\"settings.filterBy\" class=\"w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4\" @change=\"updateFilter\" />\n\n        <!-- library sort select -->\n        <controls-library-sort-select v-if=\"isLibraryPage && !isBatchSelecting\" v-model=\"settings.orderBy\" :descending.sync=\"settings.orderDesc\" class=\"w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4\" @change=\"updateOrder\" />\n\n        <!-- series filter select -->\n        <controls-library-filter-select v-if=\"isSeriesPage && !isBatchSelecting\" v-model=\"settings.seriesFilterBy\" is-series class=\"w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4\" @change=\"updateSeriesFilter\" />\n\n        <!-- series sort select -->\n        <controls-sort-select v-if=\"isSeriesPage && !isBatchSelecting\" v-model=\"settings.seriesSortBy\" :descending.sync=\"settings.seriesSortDesc\" :items=\"seriesSortItems\" class=\"w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4\" @change=\"updateSeriesSort\" />\n\n        <!-- issues page remove all button -->\n        <ui-btn v-if=\"isIssuesFilter && userCanDelete && !isBatchSelecting\" :loading=\"processingIssues\" color=\"bg-error\" small class=\"ml-4\" @click=\"removeAllIssues\">{{ $strings.ButtonRemoveAll }} {{ $formatNumber(numShowing) }} {{ entityName }}</ui-btn>\n\n        <ui-context-menu-dropdown v-if=\"contextMenuItems.length\" :items=\"contextMenuItems\" :menu-width=\"110\" class=\"ml-2\" @action=\"contextMenuAction\" />\n      </template>\n      <!-- search page -->\n      <template v-else-if=\"page === 'search'\">\n        <div class=\"grow\" />\n        <p>{{ $strings.MessageSearchResultsFor }} \"{{ searchQuery }}\"</p>\n        <div class=\"grow\" />\n        <ui-context-menu-dropdown v-if=\"contextMenuItems.length\" :items=\"contextMenuItems\" :menu-width=\"110\" class=\"ml-2\" @action=\"contextMenuAction\" />\n      </template>\n      <!-- authors page -->\n      <template v-else-if=\"isAuthorsPage\">\n        <p class=\"hidden md:block\">{{ $formatNumber(numShowing) }} {{ entityName }}</p>\n\n        <div class=\"grow hidden sm:inline-block\" />\n        <ui-btn v-if=\"userCanUpdate && !isBatchSelecting\" :loading=\"processingAuthors\" color=\"bg-primary\" small @click=\"matchAllAuthors\">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>\n\n        <!-- author sort select -->\n        <controls-sort-select v-model=\"settings.authorSortBy\" :descending.sync=\"settings.authorSortDesc\" :items=\"authorSortItems\" class=\"w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4\" @change=\"updateAuthorSort\" />\n      </template>\n      <!-- home page -->\n      <template v-else-if=\"isHome\">\n        <div class=\"grow\" />\n        <ui-context-menu-dropdown v-if=\"contextMenuItems.length\" :items=\"contextMenuItems\" :menu-width=\"110\" class=\"ml-2\" @action=\"contextMenuAction\" />\n      </template>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    page: String,\n    isHome: Boolean,\n    selectedSeries: {\n      type: Object,\n      default: () => null\n    },\n    searchQuery: String\n  },\n  data() {\n    return {\n      settings: {},\n      hasInit: false,\n      totalEntities: 0,\n      processingSeries: false,\n      processingIssues: false,\n      processingAuthors: false\n    }\n  },\n  computed: {\n    seriesContextMenuItems() {\n      if (!this.selectedSeries) return []\n\n      const items = [\n        {\n          text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,\n          action: 'mark-series-finished'\n        }\n      ]\n\n      if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {\n        items.push({\n          text: this.$strings.LabelOpenRSSFeed,\n          action: 'open-rss-feed'\n        })\n      }\n\n      if (this.isSeriesRemovedFromContinueListening) {\n        items.push({\n          text: this.$strings.LabelReAddSeriesToContinueListening,\n          action: 're-add-to-continue-listening'\n        })\n      }\n\n      this.addSubtitlesMenuItem(items)\n      this.addCollapseSubSeriesMenuItem(items)\n\n      return items\n    },\n    seriesSortItems() {\n      return [\n        {\n          text: this.$strings.LabelName,\n          value: 'name'\n        },\n        {\n          text: this.$strings.LabelNumberOfBooks,\n          value: 'numBooks'\n        },\n        {\n          text: this.$strings.LabelAddedAt,\n          value: 'addedAt'\n        },\n        {\n          text: this.$strings.LabelLastBookAdded,\n          value: 'lastBookAdded'\n        },\n        {\n          text: this.$strings.LabelLastBookUpdated,\n          value: 'lastBookUpdated'\n        },\n        {\n          text: this.$strings.LabelTotalDuration,\n          value: 'totalDuration'\n        },\n        {\n          text: this.$strings.LabelRandomly,\n          value: 'random'\n        }\n      ]\n    },\n    authorSortItems() {\n      return [\n        {\n          text: this.$strings.LabelAuthorFirstLast,\n          value: 'name'\n        },\n        {\n          text: this.$strings.LabelAuthorLastFirst,\n          value: 'lastFirst'\n        },\n        {\n          text: this.$strings.LabelNumberOfBooks,\n          value: 'numBooks'\n        },\n        {\n          text: this.$strings.LabelAddedAt,\n          value: 'addedAt'\n        },\n        {\n          text: this.$strings.LabelUpdatedAt,\n          value: 'updatedAt'\n        }\n      ]\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    libraryProvider() {\n      return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'\n    },\n    currentLibraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isBookLibrary() {\n      return this.currentLibraryMediaType === 'book'\n    },\n    isPodcastLibrary() {\n      return this.currentLibraryMediaType === 'podcast'\n    },\n    isLibraryPage() {\n      return this.page === ''\n    },\n    isSeriesPage() {\n      return this.page === 'series'\n    },\n    isCollectionsPage() {\n      return this.page === 'collections'\n    },\n    isPlaylistsPage() {\n      return this.page === 'playlists'\n    },\n    isHomePage() {\n      return this.$route.name === 'library-library'\n    },\n    isPodcastSearchPage() {\n      return this.$route.name === 'library-library-podcast-search'\n    },\n    isPodcastLatestPage() {\n      return this.$route.name === 'library-library-podcast-latest'\n    },\n    isPodcastDownloadQueuePage() {\n      return this.$route.name === 'library-library-podcast-download-queue'\n    },\n    isAuthorsPage() {\n      return this.page === 'authors'\n    },\n    numShowing() {\n      return this.totalEntities\n    },\n    entityName() {\n      if (this.isPodcastLibrary) return this.$strings.LabelPodcasts\n      if (!this.page) return this.$strings.LabelBooks\n      if (this.isSeriesPage) return this.$strings.LabelSeries\n      if (this.isCollectionsPage) return this.$strings.LabelCollections\n      if (this.isPlaylistsPage) return this.$strings.LabelPlaylists\n      if (this.isAuthorsPage) return this.$strings.LabelAuthors\n      return ''\n    },\n    seriesId() {\n      return this.selectedSeries ? this.selectedSeries.id : null\n    },\n    seriesName() {\n      return this.selectedSeries ? this.selectedSeries.name : null\n    },\n    seriesProgress() {\n      return this.selectedSeries ? this.selectedSeries.progress : null\n    },\n    seriesRssFeed() {\n      return this.selectedSeries ? this.selectedSeries.rssFeed : null\n    },\n    seriesLibraryItemIds() {\n      if (!this.seriesProgress) return []\n      return this.seriesProgress.libraryItemIds || []\n    },\n    isBatchSelecting() {\n      return this.$store.getters['globals/getIsBatchSelectingMediaItems']\n    },\n    isSeriesFinished() {\n      return this.seriesProgress && !!this.seriesProgress.isFinished\n    },\n    isSeriesRemovedFromContinueListening() {\n      if (!this.seriesId) return false\n      return this.$store.getters['user/getIsSeriesRemovedFromContinueListening'](this.seriesId)\n    },\n    filterBy() {\n      return this.$store.getters['user/getUserSetting']('filterBy')\n    },\n    isIssuesFilter() {\n      return this.filterBy === 'issues' && this.$route.query.filter === 'issues'\n    },\n    contextMenuItems() {\n      const items = []\n\n      if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {\n        items.push({\n          text: this.$strings.LabelExportOPML,\n          action: 'export-opml'\n        })\n      }\n\n      this.addSubtitlesMenuItem(items)\n      this.addCollapseSeriesMenuItem(items)\n\n      return items\n    },\n    showPlaylists() {\n      return this.$store.state.libraries.numUserPlaylists > 0\n    }\n  },\n  methods: {\n    addSubtitlesMenuItem(items) {\n      if (this.isBookLibrary && (!this.page || this.page === 'search')) {\n        if (this.settings.showSubtitles) {\n          items.push({\n            text: this.$strings.LabelHideSubtitles,\n            action: 'hide-subtitles'\n          })\n        } else {\n          items.push({\n            text: this.$strings.LabelShowSubtitles,\n            action: 'show-subtitles'\n          })\n        }\n      }\n    },\n    addCollapseSeriesMenuItem(items) {\n      if (this.isLibraryPage && this.isBookLibrary && !this.isBatchSelecting) {\n        if (this.settings.collapseSeries) {\n          items.push({\n            text: this.$strings.LabelExpandSeries,\n            action: 'expand-series'\n          })\n        } else {\n          items.push({\n            text: this.$strings.LabelCollapseSeries,\n            action: 'collapse-series'\n          })\n        }\n      }\n    },\n    addCollapseSubSeriesMenuItem(items) {\n      if (this.selectedSeries && this.isBookLibrary && !this.isBatchSelecting) {\n        if (this.settings.collapseBookSeries) {\n          items.push({\n            text: this.$strings.LabelExpandSubSeries,\n            action: 'expand-sub-series'\n          })\n        } else {\n          items.push({\n            text: this.$strings.LabelCollapseSubSeries,\n            action: 'collapse-sub-series'\n          })\n        }\n      }\n    },\n    handleSubtitlesAction(action) {\n      if (action === 'show-subtitles') {\n        this.settings.showSubtitles = true\n        this.updateShowSubtitles()\n        return true\n      }\n      if (action === 'hide-subtitles') {\n        this.settings.showSubtitles = false\n        this.updateShowSubtitles()\n        return true\n      }\n      return false\n    },\n    handleCollapseSeriesAction(action) {\n      if (action === 'collapse-series') {\n        this.settings.collapseSeries = true\n        this.updateCollapseSeries()\n        return true\n      }\n      if (action === 'expand-series') {\n        this.settings.collapseSeries = false\n        this.updateCollapseSeries()\n        return true\n      }\n      return false\n    },\n    handleCollapseSubSeriesAction(action) {\n      if (action === 'collapse-sub-series') {\n        this.settings.collapseBookSeries = true\n        this.updateCollapseSubSeries()\n        return true\n      }\n      if (action === 'expand-sub-series') {\n        this.settings.collapseBookSeries = false\n        this.updateCollapseSubSeries()\n        return true\n      }\n      return false\n    },\n    contextMenuAction({ action }) {\n      if (action === 'export-opml') {\n        this.exportOPML()\n        return\n      } else if (this.handleSubtitlesAction(action)) {\n        return\n      } else if (this.handleCollapseSeriesAction(action)) {\n        return\n      }\n    },\n    exportOPML() {\n      this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)\n    },\n    seriesContextMenuAction({ action }) {\n      if (action === 'open-rss-feed') {\n        this.showOpenSeriesRSSFeed()\n      } else if (action === 're-add-to-continue-listening') {\n        if (this.processingSeries) {\n          console.warn('Already processing series')\n          return\n        }\n        this.reAddSeriesToContinueListening()\n      } else if (action === 'mark-series-finished') {\n        if (this.processingSeries) {\n          console.warn('Already processing series')\n          return\n        }\n        this.markSeriesFinished()\n      } else if (this.handleSubtitlesAction(action)) {\n        return\n      } else if (this.handleCollapseSubSeriesAction(action)) {\n        return\n      }\n    },\n    showOpenSeriesRSSFeed() {\n      this.$store.commit('globals/setRSSFeedOpenCloseModal', {\n        id: this.selectedSeries.id,\n        name: this.selectedSeries.name,\n        type: 'series',\n        feed: this.selectedSeries.rssFeed\n      })\n    },\n    reAddSeriesToContinueListening() {\n      this.processingSeries = true\n      this.$axios\n        .$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastItemUpdateSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to re-add series to continue listening', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.processingSeries = false\n        })\n    },\n    async fetchAllAuthors() {\n      // fetch all authors from the server, in the order that they are currently displayed\n      const response = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors?sort=${this.settings.authorSortBy}&desc=${this.settings.authorSortDesc}`)\n      return response.authors\n    },\n    async matchAllAuthors() {\n      this.processingAuthors = true\n\n      try {\n        const authors = await this.fetchAllAuthors()\n\n        for (const author of authors) {\n          const payload = {}\n          if (author.asin) payload.asin = author.asin\n          else payload.q = author.name\n\n          payload.region = 'us'\n          if (this.libraryProvider.startsWith('audible.')) {\n            payload.region = this.libraryProvider.split('.').pop() || 'us'\n          }\n\n          this.$eventBus.$emit(`searching-author-${author.id}`, true)\n\n          var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {\n            console.error('Failed', error)\n            return null\n          })\n          if (!response) {\n            console.error(`Author ${author.name} not found`)\n            this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))\n          } else if (response.updated) {\n            if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)\n            else console.log(`Author ${response.author.name} was updated (no image found)`)\n          } else {\n            console.log(`No updates were made for Author ${response.author.name}`)\n          }\n\n          this.$eventBus.$emit(`searching-author-${author.id}`, false)\n        }\n      } catch (error) {\n        console.error('Failed to match all authors', error)\n        this.$toast.error(this.$strings.ToastMatchAllAuthorsFailed)\n      }\n      this.processingAuthors = false\n    },\n    removeAllIssues() {\n      if (confirm(`Are you sure you want to remove all library items with issues?\\n\\nNote: This will not delete any files`)) {\n        this.processingIssues = true\n        this.$axios\n          .$delete(`/api/libraries/${this.currentLibraryId}/issues`)\n          .then(() => {\n            this.$toast.success(this.$strings.ToastRemoveItemsWithIssuesSuccess)\n            this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)\n            this.$store.dispatch('libraries/fetch', this.currentLibraryId)\n          })\n          .catch((error) => {\n            console.error('Failed to remove library items with issues', error)\n            this.$toast.error(this.$strings.ToastRemoveItemsWithIssuesFailed)\n          })\n          .finally(() => {\n            this.processingIssues = false\n          })\n      }\n    },\n    markSeriesFinished() {\n      const newIsFinished = !this.isSeriesFinished\n\n      const payload = {\n        message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.processingSeries = true\n            const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {\n              return {\n                libraryItemId: lid,\n                isFinished: newIsFinished\n              }\n            })\n            console.log('Progress payloads', updateProgressPayloads)\n            this.$axios\n              .patch(`/api/me/progress/batch/update`, updateProgressPayloads)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)\n                this.selectedSeries.progress.isFinished = newIsFinished\n              })\n              .catch((error) => {\n                this.$toast.error(this.$strings.ToastSeriesUpdateFailed)\n                console.error('Failed to batch update read/not read', error)\n              })\n              .finally(() => {\n                this.processingSeries = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    updateOrder() {\n      this.saveSettings()\n    },\n    updateFilter() {\n      this.saveSettings()\n    },\n    updateSeriesSort() {\n      this.saveSettings()\n    },\n    updateSeriesFilter() {\n      this.saveSettings()\n    },\n    updateCollapseSeries() {\n      this.saveSettings()\n    },\n    updateCollapseSubSeries() {\n      this.saveSettings()\n    },\n    updateShowSubtitles() {\n      this.saveSettings()\n    },\n    updateAuthorSort() {\n      this.saveSettings()\n    },\n    saveSettings() {\n      this.$store.dispatch('user/updateUserSettings', this.settings)\n    },\n    init() {\n      this.settings = { ...this.$store.state.user.settings }\n    },\n    settingsUpdated(settings) {\n      for (const key in settings) {\n        this.settings[key] = settings[key]\n      }\n    },\n    setBookshelfTotalEntities(totalEntities) {\n      this.totalEntities = totalEntities\n    },\n    rssFeedOpen(data) {\n      if (data.entityId === this.seriesId) {\n        console.log('RSS Feed Opened', data)\n        this.selectedSeries.rssFeed = data\n      }\n    },\n    rssFeedClosed(data) {\n      if (data.entityId === this.seriesId) {\n        console.log('RSS Feed Closed', data)\n        this.selectedSeries.rssFeed = null\n      }\n    }\n  },\n  mounted() {\n    this.init()\n    this.$eventBus.$on('user-settings', this.settingsUpdated)\n    this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)\n    this.$root.socket.on('rss_feed_open', this.rssFeedOpen)\n    this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('user-settings', this.settingsUpdated)\n    this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)\n    this.$root.socket.off('rss_feed_open', this.rssFeedOpen)\n    this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)\n  }\n}\n</script>\n\n\n<style>\n#toolbar {\n  box-shadow: 0px 8px 6px #111111aa;\n}\n</style>\n"
  },
  {
    "path": "client/components/app/ConfigSideNav.vue",
    "content": "<template>\n  <div role=\"toolbar\" aria-orientation=\"vertical\" aria-label=\"Config Sidebar\">\n    <div role=\"navigation\" aria-label=\"Config Navigation\" class=\"w-44 fixed left-0 top-16 bg-bg/100 md:bg-bg/70 shadow-lg border-r border-white/5 py-3 transform transition-transform mb-12 overflow-y-auto\" :class=\"wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')\" v-click-outside=\"clickOutside\">\n      <div v-show=\"isMobilePortrait\" class=\"flex items-center justify-end pb-2 px-4 mb-1\" @click=\"closeDrawer\">\n        <span class=\"material-symbols text-2xl\">arrow_back</span>\n      </div>\n\n      <nuxt-link v-for=\"route in configRoutes\" :key=\"route.id\" :to=\"route.path\" class=\"w-full px-3 h-12 border-b border-primary/30 flex items-center cursor-pointer relative\" :class=\"routeName === route.id ? 'bg-primary/70' : 'hover:bg-primary/30'\">\n        <p class=\"leading-4\">{{ route.title }}</p>\n        <div v-show=\"routeName === route.iod\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <modals-changelog-view-modal v-model=\"showChangelogModal\" :versionData=\"versionData\" />\n    </div>\n\n    <div class=\"w-44 h-12 px-4 border-t bg-bg border-black/20 fixed left-0 flex flex-col justify-center\" :class=\"wrapperClass\" :style=\"{ bottom: streamLibraryItem ? '160px' : '0px' }\">\n      <div class=\"flex items-center justify-between\">\n        <button type=\"button\" class=\"underline font-mono text-sm\" @click=\"clickChangelog\">v{{ $config.version }}</button>\n\n        <p class=\"text-xs text-gray-300 italic\">{{ Source }}</p>\n      </div>\n      <a v-if=\"hasUpdate\" :href=\"githubTagUrl\" target=\"_blank\" class=\"text-warning text-xs\">Latest: {{ versionData.latestVersion }}</a>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    isOpen: Boolean\n  },\n  data() {\n    return {\n      showChangelogModal: false\n    }\n  },\n  computed: {\n    Source() {\n      return this.$store.state.Source\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    configRoutes() {\n      if (!this.userIsAdminOrUp) {\n        return [\n          {\n            id: 'config-stats',\n            title: this.$strings.HeaderYourStats,\n            path: '/config/stats'\n          }\n        ]\n      }\n      const configRoutes = [\n        {\n          id: 'config',\n          title: this.$strings.HeaderSettings,\n          path: '/config'\n        },\n        {\n          id: 'config-libraries',\n          title: this.$strings.HeaderLibraries,\n          path: '/config/libraries'\n        },\n        {\n          id: 'config-users',\n          title: this.$strings.HeaderUsers,\n          path: '/config/users'\n        },\n        {\n          id: 'config-api-keys',\n          title: this.$strings.HeaderApiKeys,\n          path: '/config/api-keys'\n        },\n        {\n          id: 'config-sessions',\n          title: this.$strings.HeaderListeningSessions,\n          path: '/config/sessions'\n        },\n        {\n          id: 'config-backups',\n          title: this.$strings.HeaderBackups,\n          path: '/config/backups'\n        },\n        {\n          id: 'config-log',\n          title: this.$strings.HeaderLogs,\n          path: '/config/log'\n        },\n        {\n          id: 'config-notifications',\n          title: this.$strings.HeaderNotifications,\n          path: '/config/notifications'\n        },\n        {\n          id: 'config-email',\n          title: this.$strings.HeaderEmail,\n          path: '/config/email'\n        },\n        {\n          id: 'config-item-metadata-utils',\n          title: this.$strings.HeaderItemMetadataUtils,\n          path: '/config/item-metadata-utils'\n        },\n        {\n          id: 'config-rss-feeds',\n          title: this.$strings.HeaderRSSFeeds,\n          path: '/config/rss-feeds'\n        },\n        {\n          id: 'config-authentication',\n          title: this.$strings.HeaderAuthentication,\n          path: '/config/authentication'\n        }\n      ]\n\n      if (this.currentLibraryId) {\n        configRoutes.push({\n          id: 'library-stats',\n          title: this.$strings.HeaderLibraryStats,\n          path: `/library/${this.currentLibraryId}/stats`\n        })\n        configRoutes.push({\n          id: 'config-stats',\n          title: this.$strings.HeaderYourStats,\n          path: '/config/stats'\n        })\n      }\n\n      return configRoutes\n    },\n    wrapperClass() {\n      var classes = []\n      if (this.drawerOpen) classes.push('translate-x-0')\n      else classes.push('-translate-x-44')\n      if (this.isMobilePortrait) classes.push('z-50')\n      else classes.push('z-40')\n      return classes.join(' ')\n    },\n    isMobile() {\n      return this.$store.state.globals.isMobile\n    },\n    isMobileLandscape() {\n      return this.$store.state.globals.isMobileLandscape\n    },\n    isMobilePortrait() {\n      return this.$store.state.globals.isMobilePortrait\n    },\n    drawerOpen() {\n      return !this.isMobilePortrait || this.isOpen\n    },\n    routeName() {\n      return this.$route.name\n    },\n    versionData() {\n      return this.$store.state.versionData || {}\n    },\n    hasUpdate() {\n      return !!this.versionData.hasUpdate\n    },\n    githubTagUrl() {\n      return this.versionData.githubTagUrl\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {\n    clickChangelog() {\n      this.showChangelogModal = true\n    },\n    clickOutside() {\n      if (!this.isOpen) return\n      this.closeDrawer()\n    },\n    closeDrawer() {\n      this.$emit('update:isOpen', false)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/app/LazyBookshelf.vue",
    "content": "<template>\n  <div id=\"bookshelf\" ref=\"bookshelf\" class=\"w-full overflow-y-auto\" :style=\"{ fontSize: sizeMultiplier + 'rem' }\">\n    <template v-for=\"shelf in totalShelves\">\n      <div :key=\"shelf\" :id=\"`shelf-${shelf - 1}`\" class=\"w-full px-4e sm:px-8e relative\" :class=\"{ bookshelfRow: !isAlternativeBookshelfView }\" :style=\"{ height: shelfHeight + 'px' }\">\n        <!-- Card skeletons -->\n        <template v-for=\"entityIndex in entitiesInShelf(shelf)\">\n          <div :key=\"entityIndex\" class=\"w-full h-full absolute rounded-sm z-5 top-0 left-0 bg-primary box-shadow-book\" :style=\"{ transform: entityTransform(entityIndex), width: cardWidth + 'px', height: coverHeight + 'px' }\" />\n        </template>\n        <div v-if=\"!isAlternativeBookshelfView\" class=\"bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e\" />\n      </div>\n    </template>\n\n    <div v-if=\"initialized && !totalShelves && !hasFilter && entityName === 'items'\" class=\"w-full flex flex-col items-center justify-center py-12\">\n      <p class=\"text-center text-2xl mb-4 py-4\">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>\n      <div v-if=\"userIsAdminOrUp\" class=\"flex\">\n        <ui-btn to=\"/config\" color=\"bg-primary\" class=\"w-52 mr-2\">{{ $strings.ButtonConfigureScanner }}</ui-btn>\n        <ui-btn color=\"bg-success\" class=\"w-52\" :loading=\"isScanningLibrary || tempIsScanning\" @click=\"scan\">{{ $strings.ButtonScanLibrary }}</ui-btn>\n      </div>\n    </div>\n    <div v-else-if=\"!totalShelves && initialized\" class=\"w-full py-16\">\n      <p class=\"text-xl text-center\">{{ emptyMessage }}</p>\n      <div v-if=\"entityName === 'collections' || entityName === 'playlists'\" class=\"flex justify-center mt-4\">\n        {{ emptyMessageHelp }}\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/collections\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n      </div>\n      <!-- Clear filter only available on Library bookshelf -->\n      <div v-if=\"entityName === 'items'\" class=\"flex justify-center mt-2\">\n        <ui-btn v-if=\"hasFilter\" color=\"bg-primary\" @click=\"clearFilter\">{{ $strings.ButtonClearFilter }}</ui-btn>\n      </div>\n    </div>\n\n    <widgets-cover-size-widget class=\"fixed right-4 z-50\" :style=\"{ bottom: streamLibraryItem ? '181px' : '16px' }\" />\n  </div>\n</template>\n\n<script>\nimport bookshelfCardsHelpers from '@/mixins/bookshelfCardsHelpers'\n\nexport default {\n  props: {\n    page: String,\n    seriesId: String\n  },\n  mixins: [bookshelfCardsHelpers],\n  data() {\n    return {\n      routeFullPath: null,\n      initialized: false,\n      bookshelfHeight: 0,\n      bookshelfWidth: 0,\n      shelvesPerPage: 0,\n      entitiesPerShelf: 8,\n      currentPage: 0,\n      totalEntities: 0,\n      entities: [],\n      pagesLoaded: {},\n      entityIndexesMounted: [],\n      entityComponentRefs: {},\n      currentBookWidth: 0,\n      isFetchingEntities: false,\n      scrollTimeout: null,\n      booksPerFetch: 0,\n      totalShelves: 0,\n      bookshelfMarginLeft: 0,\n      isSelectionMode: false,\n      currentSFQueryString: null,\n      pendingReset: false,\n      keywordFilter: null,\n      currScrollTop: 0,\n      resizeTimeout: null,\n      mountWindowWidth: 0,\n      lastItemIndexSelected: -1,\n      tempIsScanning: false,\n      cardWidth: 0,\n      cardHeight: 0,\n      coverHeight: 0,\n      resizeObserver: null,\n      lastScrollTop: 0,\n      lastTimestamp: 0,\n      postScrollTimeout: null,\n      currFirstEntityIndex: -1,\n      currLastEntityIndex: -1\n    }\n  },\n  watch: {\n    '$route.query.filter'() {\n      if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {\n        this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })\n      } else if (!this.$route.query.filter && this.filterBy) {\n        this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })\n      }\n    }\n  },\n  computed: {\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    libraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isPodcast() {\n      return this.libraryMediaType === 'podcast'\n    },\n    emptyMessage() {\n      if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries\n      if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections\n      if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists\n      if (this.page === 'authors') return this.$strings.MessageNoAuthors\n      if (this.hasFilter) {\n        if (this.filterName === 'Issues') return this.$strings.MessageNoIssues\n        else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds\n        return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])\n      }\n      return this.$strings.MessageNoResults\n    },\n    emptyMessageHelp() {\n      if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollectionsHelp\n      if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylistsHelp\n      return ''\n    },\n    entityName() {\n      if (!this.page) return 'items'\n      return this.page\n    },\n    seriesSortBy() {\n      return this.$store.getters['user/getUserSetting']('seriesSortBy')\n    },\n    seriesSortDesc() {\n      return this.$store.getters['user/getUserSetting']('seriesSortDesc')\n    },\n    seriesFilterBy() {\n      return this.$store.getters['user/getUserSetting']('seriesFilterBy')\n    },\n    authorSortBy() {\n      return this.$store.getters['user/getUserSetting']('authorSortBy')\n    },\n    authorSortDesc() {\n      return !!this.$store.getters['user/getUserSetting']('authorSortDesc')\n    },\n    orderBy() {\n      return this.$store.getters['user/getUserSetting']('orderBy')\n    },\n    orderDesc() {\n      return this.$store.getters['user/getUserSetting']('orderDesc')\n    },\n    filterBy() {\n      return this.$store.getters['user/getUserSetting']('filterBy')\n    },\n    collapseSeries() {\n      return this.$store.getters['user/getUserSetting']('collapseSeries')\n    },\n    collapseBookSeries() {\n      return this.$store.getters['user/getUserSetting']('collapseBookSeries')\n    },\n    coverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    sortingIgnorePrefix() {\n      return this.$store.getters['getServerSetting']('sortingIgnorePrefix')\n    },\n    isCoverSquareAspectRatio() {\n      return this.coverAspectRatio == 1\n    },\n    bookshelfView() {\n      return this.$store.getters['getBookshelfView']\n    },\n    isAlternativeBookshelfView() {\n      return this.bookshelfView === this.$constants.BookshelfView.DETAIL\n    },\n    hasFilter() {\n      return this.filterBy && this.filterBy !== 'all'\n    },\n    filterName() {\n      if (!this.filterBy) return ''\n      var filter = this.filterBy.split('.')[0]\n      filter = filter.substr(0, 1).toUpperCase() + filter.substr(1)\n      return filter\n    },\n    filterValue() {\n      if (!this.filterBy) return ''\n      if (!this.filterBy.includes('.')) return ''\n      return this.$decode(this.filterBy.split('.')[1])\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    libraryName() {\n      return this.$store.getters['libraries/getCurrentLibraryName']\n    },\n    bookWidth() {\n      return this.cardWidth\n    },\n    shelfPadding() {\n      if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier\n      return 64 * this.sizeMultiplier\n    },\n    totalPadding() {\n      return this.shelfPadding * 2\n    },\n    entityWidth() {\n      return this.cardWidth\n    },\n    shelfPaddingHeight() {\n      return 16\n    },\n    shelfHeight() {\n      const dividerHeight = this.isAlternativeBookshelfView ? 0 : 24 // h-6\n      return this.cardHeight + (this.shelfPaddingHeight + dividerHeight) * this.sizeMultiplier\n    },\n    totalEntityCardWidth() {\n      // Includes margin\n      return this.entityWidth + 24 * this.sizeMultiplier\n    },\n    selectedMediaItems() {\n      return this.$store.state.globals.selectedMediaItems || []\n    },\n    sizeMultiplier() {\n      return this.$store.getters['user/getSizeMultiplier']\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    isScanningLibrary() {\n      return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)\n    }\n  },\n  methods: {\n    clearFilter() {\n      this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })\n    },\n    editEntity(entity, tab = 'details') {\n      if (this.entityName === 'items' || this.entityName === 'series-books') {\n        const bookIds = this.entities.map((e) => e.id)\n        this.$store.commit('setBookshelfBookIds', bookIds)\n        this.$store.commit('showEditModalOnTab', { libraryItem: entity, tab: tab || 'details' })\n      } else if (this.entityName === 'collections') {\n        this.$store.commit('globals/setEditCollection', entity)\n      } else if (this.entityName === 'playlists') {\n        this.$store.commit('globals/setEditPlaylist', entity)\n      } else if (this.entityName === 'authors') {\n        this.$store.commit('globals/showEditAuthorModal', entity)\n      }\n    },\n    clearSelectedEntities() {\n      this.updateBookSelectionMode(false)\n      this.isSelectionMode = false\n    },\n    selectEntity(entity, shiftKey) {\n      if (this.entityName === 'items' || this.entityName === 'series-books') {\n        const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)\n        const lastLastItemIndexSelected = this.lastItemIndexSelected\n        if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {\n          this.lastItemIndexSelected = indexOf\n        } else {\n          this.lastItemIndexSelected = -1\n        }\n\n        if (shiftKey && lastLastItemIndexSelected >= 0) {\n          let loopStart = indexOf\n          let loopEnd = lastLastItemIndexSelected\n          if (indexOf > lastLastItemIndexSelected) {\n            loopStart = lastLastItemIndexSelected\n            loopEnd = indexOf\n          }\n\n          let isSelecting = false\n          // If any items in this range is not selected then select all otherwise unselect all\n          for (let i = loopStart; i <= loopEnd; i++) {\n            const thisEntity = this.entities[i]\n            if (thisEntity && !thisEntity.collapsedSeries) {\n              if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {\n                isSelecting = true\n                break\n              }\n            }\n          }\n          if (isSelecting) this.lastItemIndexSelected = indexOf\n\n          for (let i = loopStart; i <= loopEnd; i++) {\n            const thisEntity = this.entities[i]\n            if (thisEntity.collapsedSeries) {\n              console.warn('Ignoring collapsed series')\n              continue\n            }\n\n            const entityComponentRef = this.entityComponentRefs[i]\n            if (thisEntity && entityComponentRef) {\n              entityComponentRef.selected = isSelecting\n\n              const mediaItem = {\n                id: thisEntity.id,\n                mediaType: thisEntity.mediaType,\n                hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)\n              }\n              this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })\n            } else {\n              console.error('Invalid entity index', i)\n            }\n          }\n        } else {\n          const mediaItem = {\n            id: entity.id,\n            mediaType: entity.mediaType,\n            hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)\n          }\n          this.$store.commit('globals/toggleMediaItemSelected', mediaItem)\n        }\n\n        const newIsSelectionMode = !!this.selectedMediaItems.length\n        if (this.isSelectionMode !== newIsSelectionMode) {\n          this.isSelectionMode = newIsSelectionMode\n          this.updateBookSelectionMode(newIsSelectionMode)\n        }\n      }\n    },\n    updateBookSelectionMode(isSelectionMode) {\n      for (const key in this.entityComponentRefs) {\n        if (this.entityIndexesMounted.includes(Number(key))) {\n          this.entityComponentRefs[key].setSelectionMode(isSelectionMode)\n        }\n      }\n      if (!isSelectionMode) {\n        this.lastItemIndexSelected = -1\n      }\n    },\n    async fetchEntites(page = 0) {\n      const startIndex = page * this.booksPerFetch\n\n      this.isFetchingEntities = true\n\n      if (!this.initialized) {\n        this.currentSFQueryString = this.buildSearchParams()\n      }\n\n      let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName\n      const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''\n      const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share`\n\n      const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {\n        console.error('failed to fetch items', error)\n        return null\n      })\n\n      this.isFetchingEntities = false\n      if (this.pendingReset) {\n        this.pendingReset = false\n        this.resetEntities()\n        return\n      }\n      if (payload) {\n        if (!this.initialized) {\n          this.initialized = true\n          this.totalEntities = payload.total\n          this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)\n          this.entities = new Array(this.totalEntities)\n        }\n\n        for (let i = 0; i < payload.results.length; i++) {\n          const index = i + startIndex\n          this.entities[index] = payload.results[i]\n          if (this.entityComponentRefs[index]) {\n            this.entityComponentRefs[index].setEntity(this.entities[index])\n          }\n        }\n\n        this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)\n      }\n    },\n    loadPage(page) {\n      if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)\n      return this.pagesLoaded[page]\n    },\n    showHideBookPlaceholder(index, show) {\n      var el = document.getElementById(`book-${index}-placeholder`)\n      if (el) el.style.display = show ? 'flex' : 'none'\n    },\n    mountEntities(fromIndex, toIndex) {\n      for (let i = fromIndex; i < toIndex; i++) {\n        if (!this.entityIndexesMounted.includes(i)) {\n          this.cardsHelpers.mountEntityCard(i)\n        }\n      }\n    },\n    getVisibleIndices(scrollTop) {\n      const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)\n      const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)\n      const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf\n      const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)\n      return { firstEntityIndex, lastEntityIndex }\n    },\n    postScroll() {\n      const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)\n      this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {\n        if (_index < firstEntityIndex || _index >= lastEntityIndex) {\n          var el = this.entityComponentRefs[_index]\n          if (el && el.$el) el.$el.remove()\n          return false\n        }\n        return true\n      })\n    },\n    handleScroll(scrollTop) {\n      this.currScrollTop = scrollTop\n      const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(scrollTop)\n      if (firstEntityIndex === this.currFirstEntityIndex && lastEntityIndex === this.currLastEntityIndex) return\n      this.currFirstEntityIndex = firstEntityIndex\n      this.currLastEntityIndex = lastEntityIndex\n\n      clearTimeout(this.postScrollTimeout)\n      const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)\n      const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)\n      Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])\n        .then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))\n        .catch((error) => console.error('Failed to load page', error))\n\n      this.postScrollTimeout = setTimeout(this.postScroll, 500)\n    },\n    async resetEntities(scrollPositionToRestore) {\n      if (this.isFetchingEntities) {\n        this.pendingReset = true\n        return\n      }\n      this.destroyEntityComponents()\n      this.pagesLoaded = {}\n      this.entities = []\n      this.totalShelves = 0\n      this.totalEntities = 0\n      this.currentPage = 0\n      this.isSelectionMode = false\n      this.initialized = false\n\n      this.initSizeData()\n      await this.loadPage(0)\n      var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)\n      this.mountEntities(0, lastBookIndex)\n\n      if (scrollPositionToRestore) {\n        if (window.bookshelf) {\n          window.bookshelf.scrollTop = scrollPositionToRestore\n        }\n      }\n    },\n    async rebuild() {\n      this.initSizeData()\n\n      var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)\n      this.destroyEntityComponents()\n      await this.loadPage(0)\n      if (window.bookshelf) {\n        window.bookshelf.scrollTop = 0\n      }\n      this.mountEntities(0, lastBookIndex)\n    },\n    buildSearchParams() {\n      if (this.page === 'search' || this.page === 'collections') {\n        return ''\n      }\n\n      let searchParams = new URLSearchParams()\n      if (this.page === 'series') {\n        searchParams.set('sort', this.seriesSortBy)\n        searchParams.set('desc', this.seriesSortDesc ? 1 : 0)\n        searchParams.set('filter', this.seriesFilterBy)\n      } else if (this.page === 'series-books') {\n        searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)\n        if (this.collapseBookSeries) {\n          searchParams.set('collapseseries', 1)\n        }\n      } else if (this.page === 'authors') {\n        searchParams.set('sort', this.authorSortBy)\n        searchParams.set('desc', this.authorSortDesc ? 1 : 0)\n      } else {\n        if (this.filterBy && this.filterBy !== 'all') {\n          searchParams.set('filter', this.filterBy)\n        }\n        if (this.orderBy) {\n          searchParams.set('sort', this.orderBy)\n          searchParams.set('desc', this.orderDesc ? 1 : 0)\n        }\n        if (this.collapseSeries && !this.isPodcast) {\n          searchParams.set('collapseseries', 1)\n        }\n      }\n      return searchParams.toString()\n    },\n    checkUpdateSearchParams() {\n      var newSearchParams = this.buildSearchParams()\n      var currentQueryString = window.location.search\n      if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)\n\n      if (newSearchParams === '') {\n        return false\n      }\n      if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {\n        let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams\n        window.history.replaceState({ path: newurl }, '', newurl)\n\n        this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position\n        return true\n      }\n\n      return false\n    },\n    seriesSortUpdated() {\n      var wasUpdated = this.checkUpdateSearchParams()\n      if (wasUpdated) {\n        this.resetEntities()\n      }\n    },\n    async settingsUpdated(settings) {\n      await this.cardsHelpers.setCardSize()\n      const wasUpdated = this.checkUpdateSearchParams()\n      if (wasUpdated) {\n        this.resetEntities()\n      } else if (settings.bookshelfCoverSize !== this.currentBookWidth) {\n        this.rebuild()\n      }\n    },\n    getScrollRate() {\n      const currentTimestamp = Date.now()\n      const timeDelta = currentTimestamp - this.lastTimestamp\n      const scrollDelta = this.currScrollTop - this.lastScrollTop\n      const scrollRate = Math.abs(scrollDelta) / (timeDelta || 1)\n      this.lastScrollTop = this.currScrollTop\n      this.lastTimestamp = currentTimestamp\n      return scrollRate\n    },\n    scroll(e) {\n      if (!e || !e.target) return\n      clearTimeout(this.scrollTimeout)\n      const { scrollTop } = e.target\n      const scrollRate = this.getScrollRate()\n      if (scrollRate > 5) {\n        this.scrollTimeout = setTimeout(() => {\n          this.handleScroll(scrollTop)\n        }, 25)\n        return\n      }\n      this.handleScroll(scrollTop)\n    },\n    libraryItemAdded(libraryItem) {\n      console.log('libraryItem added', libraryItem)\n      // TODO: Check if audiobook would be on this shelf\n      this.resetEntities()\n    },\n    libraryItemUpdated(libraryItem) {\n      console.log('Item updated', libraryItem)\n      if (this.entityName === 'items' || this.entityName === 'series-books') {\n        var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)\n        if (indexOf >= 0) {\n          if (this.entityName === 'items' && this.orderBy === 'media.metadata.title') {\n            const curTitle = this.entities[indexOf].media.metadata?.title\n            const newTitle = libraryItem.media.metadata?.title\n            if (curTitle != newTitle) {\n              console.log('Title changed. Re-sorting...')\n              this.resetEntities(this.currScrollTop)\n              return\n            }\n          }\n          this.entities[indexOf] = libraryItem\n          if (this.entityComponentRefs[indexOf]) {\n            this.entityComponentRefs[indexOf].setEntity(libraryItem)\n          }\n        }\n      }\n    },\n    routeToBookshelfIfLastIssueRemoved() {\n      if (this.totalEntities === 0) {\n        const currentRouteQuery = this.$route.query\n        if (currentRouteQuery?.filter && currentRouteQuery.filter === 'issues') {\n          this.$nextTick(() => {\n            console.log('Last issue removed. Redirecting to library bookshelf')\n            this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)\n            this.$store.dispatch('libraries/fetch', this.currentLibraryId)\n          })\n        }\n      }\n    },\n    libraryItemRemoved(libraryItem) {\n      if (this.entityName === 'items' || this.entityName === 'series-books') {\n        var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)\n        if (indexOf >= 0) {\n          this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)\n          this.totalEntities--\n          this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)\n          this.executeRebuild()\n        }\n      }\n      this.routeToBookshelfIfLastIssueRemoved()\n    },\n    libraryItemsAdded(libraryItems) {\n      console.log('items added', libraryItems)\n      // TODO: Check if audiobook would be on this shelf\n      this.resetEntities()\n    },\n    libraryItemsUpdated(libraryItems) {\n      libraryItems.forEach((ab) => {\n        this.libraryItemUpdated(ab)\n      })\n    },\n    collectionAdded(collection) {\n      if (this.entityName !== 'collections') return\n      console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)\n      this.resetEntities()\n    },\n    collectionUpdated(collection) {\n      if (this.entityName !== 'collections') return\n      console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)\n      var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)\n      if (indexOf >= 0) {\n        this.entities[indexOf] = collection\n        if (this.entityComponentRefs[indexOf]) {\n          this.entityComponentRefs[indexOf].setEntity(collection)\n        }\n      }\n    },\n    collectionRemoved(collection) {\n      if (this.entityName !== 'collections') return\n      console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)\n      var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)\n      if (indexOf >= 0) {\n        this.entities = this.entities.filter((ent) => ent.id !== collection.id)\n        this.totalEntities--\n        this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)\n        this.executeRebuild()\n      }\n    },\n    playlistAdded(playlist) {\n      if (this.entityName !== 'playlists') return\n      console.log(`[LazyBookshelf] playlistAdded ${playlist.id}`, playlist)\n      this.resetEntities()\n    },\n    playlistUpdated(playlist) {\n      if (this.entityName !== 'playlists') return\n      console.log(`[LazyBookshelf] playlistUpdated ${playlist.id}`, playlist)\n      var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)\n      if (indexOf >= 0) {\n        this.entities[indexOf] = playlist\n        if (this.entityComponentRefs[indexOf]) {\n          this.entityComponentRefs[indexOf].setEntity(playlist)\n        }\n      }\n    },\n    playlistRemoved(playlist) {\n      if (this.entityName !== 'playlists') return\n      console.log(`[LazyBookshelf] playlistRemoved ${playlist.id}`, playlist)\n      var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)\n      if (indexOf >= 0) {\n        this.entities = this.entities.filter((ent) => ent.id !== playlist.id)\n        this.totalEntities--\n        this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)\n        this.executeRebuild()\n      }\n    },\n    authorAdded(author) {\n      if (this.entityName !== 'authors') return\n      console.log(`[LazyBookshelf] authorAdded ${author.id}`, author)\n      this.resetEntities()\n    },\n    authorUpdated(author) {\n      if (this.entityName !== 'authors') return\n      console.log(`[LazyBookshelf] authorUpdated ${author.id}`, author)\n      const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)\n      if (indexOf >= 0) {\n        this.entities[indexOf] = author\n        if (this.entityComponentRefs[indexOf]) {\n          this.entityComponentRefs[indexOf].setEntity(author)\n        }\n      }\n    },\n    authorRemoved(author) {\n      if (this.entityName !== 'authors') return\n      console.log(`[LazyBookshelf] authorRemoved ${author.id}`, author)\n      const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)\n      if (indexOf >= 0) {\n        this.entities = this.entities.filter((ent) => ent.id !== author.id)\n        this.totalEntities--\n        this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)\n        this.executeRebuild()\n      }\n    },\n\n    shareOpen(mediaItemShare) {\n      if (this.entityName === 'items' || this.entityName === 'series-books') {\n        var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)\n        if (indexOf >= 0) {\n          if (this.entityComponentRefs[indexOf]) {\n            const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }\n            libraryItem.mediaItemShare = mediaItemShare\n            this.entityComponentRefs[indexOf].setEntity?.(libraryItem)\n          }\n        }\n      }\n    },\n    shareClosed(mediaItemShare) {\n      if (this.entityName === 'items' || this.entityName === 'series-books') {\n        var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)\n        if (indexOf >= 0) {\n          if (this.entityComponentRefs[indexOf]) {\n            const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }\n            libraryItem.mediaItemShare = null\n            this.entityComponentRefs[indexOf].setEntity?.(libraryItem)\n          }\n        }\n      }\n    },\n    updatePagesLoaded() {\n      let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)\n      this.pagesLoaded = {}\n      for (let page = 0; page < numPages; page++) {\n        let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)\n        this.pagesLoaded[page] = Promise.resolve()\n        for (let i = 0; i < numEntities; i++) {\n          const index = page * this.booksPerFetch + i\n          if (!this.entities[index]) {\n            if (this.pagesLoaded[page]) delete this.pagesLoaded[page]\n            break\n          }\n        }\n      }\n    },\n    initSizeData(_bookshelf) {\n      var bookshelf = _bookshelf || document.getElementById('bookshelf')\n      if (!bookshelf) {\n        console.error('Failed to init size data')\n        return\n      }\n      var entitiesPerShelfBefore = this.entitiesPerShelf\n\n      var { clientHeight, clientWidth } = bookshelf\n      this.mountWindowWidth = window.innerWidth\n      this.bookshelfHeight = clientHeight\n      this.bookshelfWidth = clientWidth\n      this.entitiesPerShelf = Math.max(1, Math.floor((this.bookshelfWidth - this.shelfPadding) / this.totalEntityCardWidth))\n      this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2\n      this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2\n      const booksPerFetch = this.entitiesPerShelf * this.shelvesPerPage\n      if (booksPerFetch !== this.booksPerFetch) {\n        this.booksPerFetch = booksPerFetch\n        if (this.totalEntities) {\n          this.updatePagesLoaded()\n        }\n      }\n\n      this.currentBookWidth = this.bookWidth\n      if (this.totalEntities) {\n        this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)\n      }\n      return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed\n    },\n    async init(bookshelf) {\n      this.initSizeData(bookshelf)\n      this.checkUpdateSearchParams()\n\n      await this.loadPage(0)\n      var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)\n      this.mountEntities(0, lastBookIndex)\n\n      // Set last scroll position for this bookshelf page\n      if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {\n        const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]\n        if (path === this.routeFullPath) {\n          // Exact path match with query so use scroll position\n          window.bookshelf.scrollTop = scrollTop\n        }\n      }\n    },\n    executeRebuild() {\n      clearTimeout(this.resizeTimeout)\n      this.resizeTimeout = setTimeout(() => {\n        this.rebuild()\n      }, 200)\n    },\n    windowResize() {\n      this.executeRebuild()\n    },\n    initListeners() {\n      window.addEventListener('resize', this.windowResize)\n\n      this.$nextTick(() => {\n        var bookshelf = document.getElementById('bookshelf')\n        if (bookshelf) {\n          this.init(bookshelf)\n          bookshelf.addEventListener('scroll', this.scroll, { passive: true })\n        }\n      })\n\n      this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)\n      this.$eventBus.$on('user-settings', this.settingsUpdated)\n\n      if (this.$root.socket) {\n        this.$root.socket.on('item_updated', this.libraryItemUpdated)\n        this.$root.socket.on('item_added', this.libraryItemAdded)\n        this.$root.socket.on('item_removed', this.libraryItemRemoved)\n        this.$root.socket.on('items_updated', this.libraryItemsUpdated)\n        this.$root.socket.on('items_added', this.libraryItemsAdded)\n        this.$root.socket.on('collection_added', this.collectionAdded)\n        this.$root.socket.on('collection_updated', this.collectionUpdated)\n        this.$root.socket.on('collection_removed', this.collectionRemoved)\n        this.$root.socket.on('playlist_added', this.playlistAdded)\n        this.$root.socket.on('playlist_updated', this.playlistUpdated)\n        this.$root.socket.on('playlist_removed', this.playlistRemoved)\n        this.$root.socket.on('author_added', this.authorAdded)\n        this.$root.socket.on('author_updated', this.authorUpdated)\n        this.$root.socket.on('author_removed', this.authorRemoved)\n        this.$root.socket.on('share_open', this.shareOpen)\n        this.$root.socket.on('share_closed', this.shareClosed)\n      } else {\n        console.error('Bookshelf - Socket not initialized')\n      }\n    },\n    removeListeners() {\n      window.removeEventListener('resize', this.windowResize)\n      var bookshelf = document.getElementById('bookshelf')\n      if (bookshelf) {\n        bookshelf.removeEventListener('scroll', this.scroll)\n      }\n\n      this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)\n      this.$eventBus.$off('user-settings', this.settingsUpdated)\n\n      if (this.$root.socket) {\n        this.$root.socket.off('item_updated', this.libraryItemUpdated)\n        this.$root.socket.off('item_added', this.libraryItemAdded)\n        this.$root.socket.off('item_removed', this.libraryItemRemoved)\n        this.$root.socket.off('items_updated', this.libraryItemsUpdated)\n        this.$root.socket.off('items_added', this.libraryItemsAdded)\n        this.$root.socket.off('collection_added', this.collectionAdded)\n        this.$root.socket.off('collection_updated', this.collectionUpdated)\n        this.$root.socket.off('collection_removed', this.collectionRemoved)\n        this.$root.socket.off('playlist_added', this.playlistAdded)\n        this.$root.socket.off('playlist_updated', this.playlistUpdated)\n        this.$root.socket.off('playlist_removed', this.playlistRemoved)\n        this.$root.socket.off('author_added', this.authorAdded)\n        this.$root.socket.off('author_updated', this.authorUpdated)\n        this.$root.socket.off('author_removed', this.authorRemoved)\n        this.$root.socket.off('share_open', this.shareOpen)\n        this.$root.socket.off('share_closed', this.shareClosed)\n      } else {\n        console.error('Bookshelf - Socket not initialized')\n      }\n    },\n    destroyEntityComponents() {\n      for (const key in this.entityComponentRefs) {\n        const ref = this.entityComponentRefs[key]\n        if (ref && ref.destroy) {\n          if (ref.$el) ref.$el.remove()\n          ref.destroy()\n        }\n      }\n      this.entityComponentRefs = {}\n      this.entityIndexesMounted = []\n    },\n    scan() {\n      this.tempIsScanning = true\n      this.$store\n        .dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })\n        .catch((error) => {\n          console.error('Failed to start scan', error)\n          this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)\n        })\n        .finally(() => {\n          this.tempIsScanning = false\n        })\n    },\n    entitiesInShelf(shelf) {\n      return shelf == this.totalShelves ? this.totalEntities % this.entitiesPerShelf || this.entitiesPerShelf : this.entitiesPerShelf\n    },\n    entityTransform(entityIndex) {\n      const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier\n      const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft\n      return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`\n    }\n  },\n  async mounted() {\n    await this.cardsHelpers.setCardSize()\n    this.initListeners()\n\n    this.routeFullPath = window.location.pathname + (window.location.search || '')\n  },\n  updated() {\n    this.routeFullPath = window.location.pathname + (window.location.search || '')\n\n    setTimeout(() => {\n      if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {\n        console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)\n        this.executeRebuild()\n      }\n    }, 50)\n  },\n  beforeDestroy() {\n    this.destroyEntityComponents()\n    this.removeListeners()\n\n    // Set bookshelf scroll position for specific bookshelf page and query\n    if (window.bookshelf) {\n      this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })\n    }\n  }\n}\n</script>\n\n<style>\n.bookshelfRow {\n  background-image: var(--bookshelf-texture-img);\n}\n\n.bookshelfDivider {\n  background: rgb(149, 119, 90);\n  background: var(--bookshelf-divider-bg);\n  box-shadow: 0.125em 0.875em 0.5em #111111aa;\n}\n</style>\n"
  },
  {
    "path": "client/components/app/MediaPlayerContainer.vue",
    "content": "<template>\n  <div v-if=\"streamLibraryItem\" id=\"mediaPlayerContainer\" class=\"w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2\">\n    <div class=\"absolute left-2 top-2 lg:left-4 cursor-pointer\">\n      <covers-book-cover expand-on-click :library-item=\"streamLibraryItem\" :width=\"bookCoverWidth\" :book-cover-aspect-ratio=\"coverAspectRatio\" />\n    </div>\n    <div class=\"flex items-start mb-6 lg:mb-0\" :class=\"isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'\">\n      <div class=\"min-w-0 w-full\">\n        <div class=\"flex items-center\">\n          <nuxt-link :to=\"`/item/${streamLibraryItem.id}`\" class=\"hover:underline cursor-pointer text-sm sm:text-lg block truncate\">\n            {{ title }}\n          </nuxt-link>\n          <widgets-explicit-indicator v-if=\"isExplicit\" />\n        </div>\n        <div class=\"text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5\">\n          <span class=\"material-symbols text-sm\">person</span>\n          <div v-if=\"podcastAuthor\" class=\"pl-1 sm:pl-1.5 text-xs sm:text-base truncate\">{{ podcastAuthor }}</div>\n          <div v-else-if=\"authors.length\" class=\"pl-1 sm:pl-1.5 text-xs sm:text-base truncate\">\n            <nuxt-link v-for=\"(author, index) in authors\" :key=\"index\" :to=\"`/author/${author.id}`\" class=\"hover:underline\">{{ author.name }}<span v-if=\"index < authors.length - 1\">,&nbsp;</span></nuxt-link>\n          </div>\n          <div v-else class=\"text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5\">{{ $strings.LabelUnknown }}</div>\n        </div>\n\n        <div class=\"text-gray-400 flex items-center\">\n          <span class=\"material-symbols text-xs\">schedule</span>\n          <p class=\"font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px\">{{ totalDurationPretty }}</p>\n        </div>\n      </div>\n      <div class=\"grow\" />\n      <ui-tooltip direction=\"top\" :text=\"$strings.LabelClosePlayer\">\n        <button :aria-label=\"$strings.LabelClosePlayer\" class=\"material-symbols sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl\" @click=\"closePlayer\">close</button>\n      </ui-tooltip>\n    </div>\n    <player-ui\n      ref=\"audioPlayer\"\n      :chapters=\"chapters\"\n      :current-chapter=\"currentChapter\"\n      :paused=\"!isPlaying\"\n      :loading=\"playerLoading\"\n      :bookmarks=\"bookmarks\"\n      :sleep-timer-set=\"sleepTimerSet\"\n      :sleep-timer-remaining=\"sleepTimerRemaining\"\n      :sleep-timer-type=\"sleepTimerType\"\n      :is-podcast=\"isPodcast\"\n      :hasNextItemInQueue=\"hasNextItemInQueue\"\n      @playPause=\"playPause\"\n      @jumpForward=\"jumpForward\"\n      @jumpBackward=\"jumpBackward\"\n      @setVolume=\"setVolume\"\n      @setPlaybackRate=\"setPlaybackRate\"\n      @seek=\"seek\"\n      @nextItemInQueue=\"playNextItemInQueue\"\n      @close=\"closePlayer\"\n      @showBookmarks=\"showBookmarks\"\n      @showSleepTimer=\"showSleepTimerModal = true\"\n      @showPlayerQueueItems=\"showPlayerQueueItemsModal = true\"\n    />\n\n    <modals-bookmarks-modal v-model=\"showBookmarksModal\" :bookmarks=\"bookmarks\" :current-time=\"bookmarkCurrentTime\" :playback-rate=\"currentPlaybackRate\" :library-item-id=\"libraryItemId\" @select=\"selectBookmark\" />\n\n    <modals-sleep-timer-modal v-model=\"showSleepTimerModal\" :timer-set=\"sleepTimerSet\" :timer-type=\"sleepTimerType\" :remaining=\"sleepTimerRemaining\" :has-chapters=\"!!chapters.length\" @set=\"setSleepTimer\" @cancel=\"cancelSleepTimer\" @increment=\"incrementSleepTimer\" @decrement=\"decrementSleepTimer\" />\n\n    <modals-player-queue-items-modal v-model=\"showPlayerQueueItemsModal\" />\n  </div>\n</template>\n\n<script>\nimport PlayerHandler from '@/players/PlayerHandler'\n\nexport default {\n  data() {\n    return {\n      playerHandler: new PlayerHandler(this),\n      totalDuration: 0,\n      showBookmarksModal: false,\n      bookmarkCurrentTime: 0,\n      playerLoading: false,\n      isPlaying: false,\n      currentTime: 0,\n      showSleepTimerModal: false,\n      showPlayerQueueItemsModal: false,\n      sleepTimerSet: false,\n      sleepTimerRemaining: 0,\n      sleepTimerType: null,\n      sleepTimer: null,\n      displayTitle: null,\n      currentPlaybackRate: 1,\n      syncFailedToast: null,\n      coverAspectRatio: 1,\n      lastChapterId: null\n    }\n  },\n  computed: {\n    isSquareCover() {\n      return this.coverAspectRatio === 1\n    },\n    isMobile() {\n      return this.$store.state.globals.isMobile\n    },\n    bookCoverWidth() {\n      if (this.isMobile) return 64 / this.coverAspectRatio\n      return 77 / this.coverAspectRatio\n    },\n    cover() {\n      if (this.media.coverPath) return this.media.coverPath\n      return 'Logo.png'\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    userMediaProgress() {\n      if (!this.libraryItemId) return\n      return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)\n    },\n    userItemCurrentTime() {\n      return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0\n    },\n    bookmarks() {\n      if (!this.libraryItemId) return []\n      return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    streamEpisode() {\n      if (!this.$store.state.streamEpisodeId) return null\n      const episodes = this.streamLibraryItem.media.episodes || []\n      return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)\n    },\n    libraryItemId() {\n      return this.streamLibraryItem?.id || null\n    },\n    media() {\n      return this.streamLibraryItem?.media || {}\n    },\n    isPodcast() {\n      return this.streamLibraryItem?.mediaType === 'podcast'\n    },\n    isExplicit() {\n      return !!this.mediaMetadata.explicit\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    chapters() {\n      if (this.streamEpisode) return this.streamEpisode.chapters || []\n      return this.media.chapters || []\n    },\n    currentChapter() {\n      return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)\n    },\n    title() {\n      if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle\n      return this.mediaMetadata.title || 'No Title'\n    },\n    authors() {\n      return this.mediaMetadata.authors || []\n    },\n    libraryId() {\n      return this.streamLibraryItem?.libraryId || null\n    },\n    totalDurationPretty() {\n      // Adjusted by playback rate\n      return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)\n    },\n    podcastAuthor() {\n      if (!this.isPodcast) return null\n      return this.mediaMetadata.author || this.$strings.LabelUnknown\n    },\n    hasNextItemInQueue() {\n      return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1\n    },\n    currentPlayerQueueIndex() {\n      if (!this.libraryItemId) return -1\n      return this.playerQueueItems.findIndex((i) => {\n        if (this.streamEpisode?.id) return i.episodeId === this.streamEpisode.id\n        return i.libraryItemId === this.libraryItemId\n      })\n    },\n    playerQueueItems() {\n      return this.$store.state.playerQueueItems || []\n    }\n  },\n  methods: {\n    mediaFinished(libraryItemId, episodeId) {\n      // Play next item in queue\n      if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {\n        // TODO: Set media finished flag so play button will play next queue item\n        return\n      }\n      var currentQueueIndex = this.playerQueueItems.findIndex((i) => {\n        if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId\n        return i.libraryItemId === libraryItemId\n      })\n      if (currentQueueIndex < 0) {\n        console.error('Media finished not found in queue - using first in queue', this.playerQueueItems)\n        currentQueueIndex = -1\n      }\n      if (currentQueueIndex === this.playerQueueItems.length - 1) {\n        console.log('Finished last item in queue')\n        return\n      }\n      const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]\n      if (nextItemInQueue) {\n        this.playLibraryItem({\n          libraryItemId: nextItemInQueue.libraryItemId,\n          episodeId: nextItemInQueue.episodeId || null,\n          queueItems: this.playerQueueItems\n        })\n      }\n    },\n    setPlaying(isPlaying) {\n      this.isPlaying = isPlaying\n      this.$store.commit('setIsPlaying', isPlaying)\n      this.updateMediaSessionPlaybackState()\n    },\n    setSleepTimer(time) {\n      this.sleepTimerSet = true\n      this.showSleepTimerModal = false\n\n      this.sleepTimerType = time.timerType\n      if (this.sleepTimerType === this.$constants.SleepTimerTypes.COUNTDOWN) {\n        this.runSleepTimer(time)\n      }\n    },\n    runSleepTimer(time) {\n      this.sleepTimerRemaining = time.seconds\n\n      var lastTick = Date.now()\n      clearInterval(this.sleepTimer)\n      this.sleepTimer = setInterval(() => {\n        var elapsed = Date.now() - lastTick\n        lastTick = Date.now()\n        this.sleepTimerRemaining -= elapsed / 1000\n\n        if (this.sleepTimerRemaining <= 0) {\n          this.sleepTimerEnd()\n        }\n      }, 1000)\n    },\n    checkChapterEnd() {\n      if (!this.currentChapter) return\n\n      // Track chapter transitions by comparing current chapter with last chapter\n      if (this.lastChapterId !== this.currentChapter.id) {\n        // Chapter changed - if we had a previous chapter, this means we crossed a boundary\n        if (this.lastChapterId) {\n          this.sleepTimerEnd()\n        }\n        this.lastChapterId = this.currentChapter.id\n      }\n    },\n    sleepTimerEnd() {\n      this.clearSleepTimer()\n      this.playerHandler.pause()\n      this.$toast.info(this.$strings.ToastSleepTimerDone)\n    },\n    cancelSleepTimer() {\n      this.showSleepTimerModal = false\n      this.clearSleepTimer()\n    },\n    clearSleepTimer() {\n      clearInterval(this.sleepTimer)\n      this.sleepTimerRemaining = 0\n      this.sleepTimer = null\n      this.sleepTimerSet = false\n      this.sleepTimerType = null\n    },\n    incrementSleepTimer(amount) {\n      if (!this.sleepTimerSet) return\n      this.sleepTimerRemaining += amount\n    },\n    decrementSleepTimer(amount) {\n      if (this.sleepTimerRemaining < amount) {\n        this.sleepTimerRemaining = 3\n        return\n      }\n      this.sleepTimerRemaining = Math.max(0, this.sleepTimerRemaining - amount)\n    },\n    playPause() {\n      this.playerHandler.playPause()\n    },\n    jumpForward() {\n      this.playerHandler.jumpForward()\n    },\n    jumpBackward() {\n      this.playerHandler.jumpBackward()\n    },\n    setVolume(volume) {\n      this.playerHandler.setVolume(volume)\n    },\n    setPlaybackRate(playbackRate) {\n      this.currentPlaybackRate = playbackRate\n      this.playerHandler.setPlaybackRate(playbackRate)\n    },\n    seek(time) {\n      this.playerHandler.seek(time)\n    },\n    playbackTimeUpdate(time) {\n      // When updating progress from another session\n      this.playerHandler.seek(time, false)\n    },\n    setCurrentTime(time) {\n      this.currentTime = time\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.setCurrentTime(time)\n      }\n\n      if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {\n        this.checkChapterEnd()\n      }\n    },\n    setDuration(duration) {\n      this.totalDuration = duration\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.setDuration(duration)\n      }\n    },\n    setBufferTime(buffertime) {\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.setBufferTime(buffertime)\n      }\n    },\n    showBookmarks() {\n      this.bookmarkCurrentTime = this.currentTime\n      this.showBookmarksModal = true\n    },\n    selectBookmark(bookmark) {\n      this.seek(bookmark.time)\n      this.showBookmarksModal = false\n    },\n    closePlayer() {\n      this.playerHandler.closePlayer()\n      this.$store.commit('setMediaPlaying', null)\n    },\n    mediaSessionPlay() {\n      console.log('Media session play')\n      this.playerHandler.play()\n    },\n    mediaSessionPause() {\n      console.log('Media session pause')\n      this.playerHandler.pause()\n    },\n    mediaSessionStop() {\n      console.log('Media session stop')\n      this.playerHandler.pause()\n    },\n    mediaSessionSeekBackward() {\n      console.log('Media session seek backward')\n      this.playerHandler.jumpBackward()\n    },\n    mediaSessionSeekForward() {\n      console.log('Media session seek forward')\n      this.playerHandler.jumpForward()\n    },\n    mediaSessionSeekTo(e) {\n      console.log('Media session seek to', e)\n      if (e.seekTime !== null && !isNaN(e.seekTime)) {\n        this.playerHandler.seek(e.seekTime)\n      }\n    },\n    mediaSessionPreviousTrack() {\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.prevChapter()\n      }\n    },\n    mediaSessionNextTrack() {\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.nextChapter()\n      }\n    },\n    updateMediaSessionPlaybackState() {\n      if ('mediaSession' in navigator) {\n        navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'\n      }\n    },\n    setMediaSession() {\n      if (!this.streamLibraryItem) {\n        console.error('setMediaSession: No library item set')\n        return\n      }\n\n      // https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API\n      if ('mediaSession' in navigator) {\n        const chapterInfo = []\n        if (this.chapters.length) {\n          this.chapters.forEach((chapter) => {\n            chapterInfo.push({\n              title: chapter.title,\n              startTime: chapter.start\n            })\n          })\n        }\n\n        navigator.mediaSession.metadata = new MediaMetadata({\n          title: this.title,\n          artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',\n          album: this.mediaMetadata.seriesName || '',\n          artwork: [\n            {\n              src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)\n            }\n          ],\n          chapterInfo\n        })\n        console.log('Set media session metadata', navigator.mediaSession.metadata)\n\n        navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)\n        navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)\n        navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)\n        navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)\n        navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)\n        navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)\n        navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)\n        navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)\n      } else {\n        console.warn('Media session not available')\n      }\n    },\n    streamProgress(data) {\n      if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {\n        if (!data.numSegments) return\n        var chunks = data.chunks\n        console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)\n        if (this.$refs.audioPlayer) {\n          this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)\n        } else {\n          console.error('No Audio Ref')\n        }\n      }\n    },\n    sessionOpen(session) {\n      // For opening session on init (temporarily unused)\n      this.$store.commit('setMediaPlaying', {\n        libraryItem: session.libraryItem,\n        episodeId: session.episodeId\n      })\n      this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)\n    },\n    streamOpen(session) {\n      console.log(`[MediaPlayerContainer] Stream session open`, session)\n    },\n    streamClosed(streamId) {\n      // Stream was closed from the server\n      if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {\n        console.warn('[MediaPlayerContainer] Closing stream due to request from server')\n        this.playerHandler.closePlayer()\n      }\n    },\n    streamReady() {\n      console.log(`[MediaPlayerContainer] Stream Ready`)\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.setStreamReady()\n      } else {\n        console.error('No Audio Ref')\n      }\n    },\n    streamError(streamId) {\n      // Stream had critical error from the server\n      if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {\n        console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')\n        this.playerHandler.closePlayer()\n      }\n    },\n    streamReset({ startTime, streamId }) {\n      this.playerHandler.resetStream(startTime, streamId)\n    },\n    castSessionActive(isActive) {\n      if (isActive && this.playerHandler.isPlayingLocalItem) {\n        // Cast session started switch to cast player\n        this.playerHandler.switchPlayer()\n      } else if (!isActive && this.playerHandler.isPlayingCastedItem) {\n        // Cast session ended switch to local player\n        this.playerHandler.switchPlayer()\n      }\n    },\n    playNextItemInQueue() {\n      if (this.hasNextItemInQueue) {\n        this.playQueueItem({ index: this.currentPlayerQueueIndex + 1 })\n      }\n    },\n    /**\n     * @param {{ index: number }} payload\n     */\n    playQueueItem(payload) {\n      if (payload?.index === undefined) {\n        console.error('playQueueItem: No index provided')\n        return\n      }\n      if (!this.playerQueueItems[payload.index]) {\n        console.error('playQueueItem: No item found at index', payload.index)\n        return\n      }\n      const item = this.playerQueueItems[payload.index]\n      this.playLibraryItem({\n        libraryItemId: item.libraryItemId,\n        episodeId: item.episodeId || null,\n        queueItems: this.playerQueueItems\n      })\n    },\n    async playLibraryItem(payload) {\n      const libraryItemId = payload.libraryItemId\n      const episodeId = payload.episodeId || null\n\n      if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {\n        if (payload.startTime !== null && !isNaN(payload.startTime)) {\n          this.seek(payload.startTime)\n        } else {\n          this.playerHandler.play()\n        }\n        return\n      }\n\n      const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {\n        console.error('Failed to fetch full item', error)\n        return null\n      })\n      if (!libraryItem) return\n\n      this.$store.commit('setMediaPlaying', {\n        libraryItem,\n        episodeId,\n        queueItems: payload.queueItems || []\n      })\n      // Set cover aspect ratio for this item's library since the library may change\n      this.coverAspectRatio = this.$store.getters['libraries/getBookCoverAspectRatio']\n\n      this.$nextTick(() => {\n        if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()\n      })\n\n      this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)\n    },\n    pauseItem() {\n      this.playerHandler.pause()\n    },\n    showFailedProgressSyncs() {\n      if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)\n      this.syncFailedToast = this.$toast(this.$strings.ToastProgressIsNotBeingSynced, { timeout: false, type: 'error' })\n    },\n    sessionClosedEvent(sessionId) {\n      if (this.playerHandler.currentSessionId === sessionId) {\n        console.log('sessionClosedEvent closing current session', sessionId)\n        this.playerHandler.resetPlayer() // Closes player without reporting to server\n        this.$store.commit('setMediaPlaying', null)\n      }\n    }\n  },\n  mounted() {\n    this.$eventBus.$on('cast-session-active', this.castSessionActive)\n    this.$eventBus.$on('playback-seek', this.seek)\n    this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)\n    this.$eventBus.$on('play-queue-item', this.playQueueItem)\n    this.$eventBus.$on('play-item', this.playLibraryItem)\n    this.$eventBus.$on('pause-item', this.pauseItem)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('cast-session-active', this.castSessionActive)\n    this.$eventBus.$off('playback-seek', this.seek)\n    this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)\n    this.$eventBus.$off('play-queue-item', this.playQueueItem)\n    this.$eventBus.$off('play-item', this.playLibraryItem)\n    this.$eventBus.$off('pause-item', this.pauseItem)\n  }\n}\n</script>\n\n<style>\n#mediaPlayerContainer {\n  box-shadow: 0px -6px 8px #1111113f;\n}\n</style>\n"
  },
  {
    "path": "client/components/app/SettingsContent.vue",
    "content": "<template>\n  <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-2 sm:p-4 mb-8\">\n    <div class=\"flex items-center mb-2\">\n      <slot name=\"header-prefix\"></slot>\n      <h1 class=\"text-xl\">{{ headerText }}</h1>\n\n      <slot name=\"header-items\"></slot>\n    </div>\n\n    <p v-if=\"description\" id=\"settings-description\" class=\"mb-6 text-gray-200\" v-html=\"description\" />\n\n    <slot></slot>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    headerText: String,\n    description: String,\n    note: String\n  },\n  methods: {}\n}\n</script>\n\n<style>\n#settings-description a {\n  color: rgb(96 165 250);\n}\n#settings-description a:hover {\n  color: rgb(147 197 253);\n  text-decoration-line: underline;\n}\n#settings-description code {\n  font-size: 0.875rem;\n  border-radius: 6px;\n  background-color: rgb(82, 82, 82);\n  color: white;\n  padding: 2px 4px;\n}\n</style>\n"
  },
  {
    "path": "client/components/app/SideRail.vue",
    "content": "<template>\n  <div role=\"toolbar\" aria-orientation=\"vertical\" aria-label=\"Library Sidebar\" class=\"w-20 bg-bg h-full fixed left-0 box-shadow-side z-50\" style=\"min-width: 80px\" :style=\"{ top: offsetTop + 'px' }\">\n    <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->\n    <div v-if=\"isShowingBookshelfToolbar\" class=\"absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none\" />\n\n    <div id=\"siderail-buttons-container\" role=\"navigation\" aria-label=\"Library Navigation\" :class=\"{ 'player-open': streamLibraryItem }\" class=\"w-full overflow-y-auto overflow-x-hidden\">\n      <nuxt-link :to=\"`/library/${currentLibraryId}`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"homePage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">home</span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonHome }}</p>\n\n        <div v-show=\"homePage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isPodcastLibrary\" :to=\"`/library/${currentLibraryId}/podcast/latest`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isPodcastLatestPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">&#xe241;</span>\n\n        <p class=\"pt-1 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonLatest }}</p>\n\n        <div v-show=\"isPodcastLatestPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"showLibrary ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">import_contacts</span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonLibrary }}</p>\n\n        <div v-show=\"showLibrary\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/bookshelf/series`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isSeriesPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">view_column</span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonSeries }}</p>\n\n        <div v-show=\"isSeriesPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/bookshelf/collections`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"paramId === 'collections' ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">&#xe431;</span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonCollections }}</p>\n\n        <div v-show=\"paramId === 'collections'\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"showPlaylists\" :to=\"`/library/${currentLibraryId}/bookshelf/playlists`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isPlaylistsPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2.5xl\">&#xe03d;</span>\n\n        <p class=\"pt-0.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonPlaylists }}</p>\n\n        <div v-show=\"isPlaylistsPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/bookshelf/authors`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isAuthorsPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">groups</span>\n\n        <p class=\"pt-1 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonAuthors }}</p>\n\n        <div v-show=\"isAuthorsPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isBookLibrary\" :to=\"`/library/${currentLibraryId}/narrators`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isNarratorsPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">&#xe91f;</span>\n\n        <p class=\"pt-1 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.LabelNarrators }}</p>\n\n        <div v-show=\"isNarratorsPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isBookLibrary && userIsAdminOrUp\" :to=\"`/library/${currentLibraryId}/stats`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isStatsPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">&#xf190;</span>\n\n        <p class=\"pt-1 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonStats }}</p>\n\n        <div v-show=\"isStatsPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isPodcastLibrary && userIsAdminOrUp\" :to=\"`/library/${currentLibraryId}/podcast/search`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isPodcastSearchPage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"abs-icons icon-podcast text-xl\"></span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonAdd }}</p>\n\n        <div v-show=\"isPodcastSearchPage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"isPodcastLibrary && userIsAdminOrUp\" :to=\"`/library/${currentLibraryId}/podcast/download-queue`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative\" :class=\"isPodcastDownloadQueuePage ? 'bg-primary/80' : 'bg-bg/60'\">\n        <span class=\"material-symbols text-2xl\">&#xf090;</span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 0.9rem\">{{ $strings.ButtonDownloadQueue }}</p>\n\n        <div v-show=\"isPodcastDownloadQueuePage\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n      </nuxt-link>\n\n      <nuxt-link v-if=\"numIssues\" :to=\"`/library/${currentLibraryId}/bookshelf?filter=issues`\" class=\"w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-error/40 cursor-pointer relative\" :class=\"showingIssues ? 'bg-error/40' : 'bg-error/20'\">\n        <span class=\"material-symbols text-2xl\">warning</span>\n\n        <p class=\"pt-1.5 text-center leading-4\" style=\"font-size: 1rem\">{{ $strings.ButtonIssues }}</p>\n\n        <div v-show=\"showingIssues\" class=\"h-full w-0.5 bg-yellow-400 absolute top-0 left-0\" />\n        <div class=\"absolute top-1 right-1 w-4 h-4 rounded-full bg-white/30 flex items-center justify-center\">\n          <p class=\"text-xs font-mono pb-0.5\">{{ numIssues }}</p>\n        </div>\n      </nuxt-link>\n    </div>\n\n    <div class=\"w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0\" :style=\"{ bottom: streamLibraryItem ? '224px' : '65px' }\">\n      <p class=\"underline font-mono text-xs text-center text-gray-300 leading-3 mb-1 cursor-pointer\" @click=\"clickChangelog\">v{{ $config.version }}</p>\n      <a v-if=\"hasUpdate\" :href=\"githubTagUrl\" target=\"_blank\" class=\"text-warning text-xxs text-center block leading-3\">Update</a>\n      <p v-else class=\"text-xxs text-gray-400 leading-3 text-center italic\">{{ Source }}</p>\n    </div>\n\n    <modals-changelog-view-modal v-model=\"showChangelogModal\" :versionData=\"versionData\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      showChangelogModal: false\n    }\n  },\n  computed: {\n    Source() {\n      return this.$store.state.Source\n    },\n    isMobileLandscape() {\n      return this.$store.state.globals.isMobileLandscape\n    },\n    isShowingBookshelfToolbar() {\n      if (!this.$route.name) return false\n      return this.$route.name.startsWith('library')\n    },\n    offsetTop() {\n      return 64\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    paramId() {\n      return this.$route.params ? this.$route.params.id || '' : ''\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    currentLibraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isBookLibrary() {\n      return this.currentLibraryMediaType === 'book'\n    },\n    isPodcastLibrary() {\n      return this.currentLibraryMediaType === 'podcast'\n    },\n    isPodcastDownloadQueuePage() {\n      return this.$route.name === 'library-library-podcast-download-queue'\n    },\n    isPodcastSearchPage() {\n      return this.$route.name === 'library-library-podcast-search'\n    },\n    isPodcastLatestPage() {\n      return this.$route.name === 'library-library-podcast-latest'\n    },\n    homePage() {\n      return this.$route.name === 'library-library'\n    },\n    isSeriesPage() {\n      return this.$route.name === 'library-library-series-id' || this.paramId === 'series'\n    },\n    isAuthorsPage() {\n      return this.libraryBookshelfPage && this.paramId === 'authors'\n    },\n    isNarratorsPage() {\n      return this.$route.name === 'library-library-narrators'\n    },\n    isPlaylistsPage() {\n      return this.paramId === 'playlists'\n    },\n    isStatsPage() {\n      return this.$route.name === 'library-library-stats'\n    },\n    libraryBookshelfPage() {\n      return this.$route.name === 'library-library-bookshelf-id'\n    },\n    showLibrary() {\n      return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues\n    },\n    filterBy() {\n      return this.$store.getters['user/getUserSetting']('filterBy')\n    },\n    showingIssues() {\n      if (!this.$route.query) return false\n      return this.libraryBookshelfPage && this.$route.query.filter === 'issues'\n    },\n    numIssues() {\n      return this.$store.state.libraries.issues || 0\n    },\n    versionData() {\n      return this.$store.state.versionData || {}\n    },\n    hasUpdate() {\n      return !!this.versionData.hasUpdate\n    },\n    githubTagUrl() {\n      return this.versionData.githubTagUrl\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    showPlaylists() {\n      return this.$store.state.libraries.numUserPlaylists > 0\n    }\n  },\n  methods: {\n    clickChangelog() {\n      this.showChangelogModal = true\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style>\n#siderail-buttons-container {\n  max-height: calc(100vh - 64px - 48px);\n}\n#siderail-buttons-container.player-open {\n  max-height: calc(100vh - 64px - 48px - 160px);\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/AuthorCard.vue",
    "content": "<template>\n  <div class=\"pb-3e\" :style=\"{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }\">\n    <nuxt-link :to=\"`/author/${author?.id}`\">\n      <div cy-id=\"card\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n        <div cy-id=\"imageArea\" :style=\"{ height: cardHeight + 'px' }\" class=\"bg-primary box-shadow-book rounded-md relative overflow-hidden\">\n          <!-- Image or placeholder -->\n          <covers-author-image :author=\"author\" />\n\n          <!-- Author name & num books overlay -->\n          <div cy-id=\"textInline\" v-show=\"!searching && !nameBelow\" class=\"absolute bottom-0 left-0 w-full py-1e bg-black/60 px-2e\">\n            <p class=\"text-center font-semibold truncate\" :style=\"{ fontSize: 0.75 + 'em' }\">{{ name }}</p>\n            <p class=\"text-center text-gray-200\" :style=\"{ fontSize: 0.65 + 'em' }\">{{ numBooks }} {{ $strings.LabelBooks }}</p>\n          </div>\n\n          <!-- Search icon btn -->\n          <div cy-id=\"match\" v-show=\"!searching && isHovering && userCanUpdate\" class=\"absolute top-0 left-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150\" @click.prevent.stop=\"searchAuthor\">\n            <ui-tooltip :text=\"$strings.ButtonQuickMatch\" direction=\"bottom\">\n              <span class=\"material-symbols\" :style=\"{ fontSize: 1.125 + 'em' }\">search</span>\n            </ui-tooltip>\n          </div>\n          <div cy-id=\"edit\" v-show=\"isHovering && !searching && userCanUpdate\" class=\"absolute top-0 right-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150\" @click.prevent.stop=\"$emit('edit', author)\">\n            <ui-tooltip :text=\"$strings.LabelEdit\" direction=\"bottom\">\n              <span class=\"material-symbols\" :style=\"{ fontSize: 1.125 + 'em' }\">edit</span>\n            </ui-tooltip>\n          </div>\n\n          <!-- Loading spinner -->\n          <div cy-id=\"spinner\" v-show=\"searching\" class=\"absolute top-0 left-0 z-10 w-full h-full bg-black/50 flex items-center justify-center\">\n            <widgets-loading-spinner size=\"\" />\n          </div>\n        </div>\n        <div cy-id=\"nameBelow\" v-show=\"nameBelow\" class=\"w-full py-1e px-2e\">\n          <p class=\"text-center font-semibold truncate text-gray-200\" :style=\"{ fontSize: 0.75 + 'em' }\">{{ name }}</p>\n        </div>\n      </div>\n    </nuxt-link>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    authorMount: {\n      type: Object,\n      default: () => {}\n    },\n    width: Number,\n    height: {\n      type: Number,\n      default: 192\n    },\n    nameBelow: {\n      type: Boolean,\n      default: false\n    }\n  },\n  data() {\n    return {\n      searching: false,\n      isHovering: false,\n      author: null\n    }\n  },\n  computed: {\n    cardWidth() {\n      return this.width || this.cardHeight * 0.8\n    },\n    cardHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    coverHeight() {\n      return this.cardHeight\n    },\n    _author() {\n      return this.author || {}\n    },\n    authorId() {\n      return this._author?.id || ''\n    },\n    name() {\n      return this._author?.name || ''\n    },\n    asin() {\n      return this._author?.asin || ''\n    },\n    numBooks() {\n      return this._author?.numBooks || 0\n    },\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    userCanUpdate() {\n      return this.store.getters['user/getUserCanUpdate']\n    },\n    currentLibraryId() {\n      return this.store.state.libraries.currentLibraryId\n    },\n    libraryProvider() {\n      return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'\n    },\n    sizeMultiplier() {\n      return this.store.getters['user/getSizeMultiplier']\n    }\n  },\n  methods: {\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    async searchAuthor() {\n      this.searching = true\n      const payload = {}\n      if (this.asin) payload.asin = this.asin\n      else payload.q = this.name\n\n      payload.region = 'us'\n      if (this.libraryProvider.startsWith('audible.')) {\n        payload.region = this.libraryProvider.split('.').pop() || 'us'\n      }\n\n      var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {\n        console.error('Failed', error)\n        return null\n      })\n      if (!response) {\n        this.$toast.error(this.$getString('ToastAuthorNotFound', [this.name]))\n      } else if (response.updated) {\n        if (response.author.imagePath) {\n          this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)\n        } else {\n          this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)\n        }\n      } else {\n        this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n      }\n      this.searching = false\n    },\n    setSearching(isSearching) {\n      this.searching = isSearching\n    },\n    setEntity(author) {\n      this.removeListeners()\n      this.author = author\n      this.addListeners()\n    },\n    addListeners() {\n      if (this.author) {\n        this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)\n      }\n    },\n    removeListeners() {\n      if (this.author) {\n        this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)\n      }\n    },\n    destroy() {\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      if (this.$el && this.$el.parentNode) {\n        this.$el.parentNode.removeChild(this.$el)\n      } else if (this.$el && this.$el.remove) {\n        this.$el.remove()\n      }\n    },\n    setSelectionMode(val) {}\n  },\n  mounted() {\n    if (this.authorMount) this.setEntity(this.authorMount)\n  },\n  beforeDestroy() {\n    this.removeListeners()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/AuthorSearchCard.vue",
    "content": "<template>\n  <div class=\"flex h-full px-1 overflow-hidden\">\n    <div class=\"overflow-hidden bg-primary rounded-sm\" style=\"height: 50px; width: 40px\">\n      <covers-author-image :author=\"author\" />\n    </div>\n    <div class=\"grow px-2 authorSearchCardContent h-full\">\n      <p class=\"truncate text-sm\">{{ name }}</p>\n      <p class=\"text-xs text-gray-400\">{{ $getString('LabelXBooks', [numBooks]) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    author: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    name() {\n      return this.author.name\n    },\n    numBooks() {\n      return this.author.numBooks\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n.authorSearchCardContent {\n  width: calc(100% - 80px);\n  height: 44px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/BookMatchCard.vue",
    "content": "<template>\n  <div v-if=\"book\" class=\"w-full border-b border-gray-700 pb-2\">\n    <div class=\"flex py-1 hover:bg-gray-300/10 cursor-pointer\" @click=\"selectMatch\">\n      <div class=\"min-w-12 max-w-12 md:min-w-20 md:max-w-20\">\n        <div class=\"w-full bg-primary\">\n          <img v-if=\"selectedCover\" :src=\"selectedCover\" class=\"h-full w-full object-contain\" />\n          <div v-else class=\"w-12 h-12 md:w-20 md:h-20 bg-primary\" />\n        </div>\n      </div>\n      <div v-if=\"!isPodcast\" class=\"px-2 md:px-4 grow\">\n        <div class=\"flex items-center\">\n          <h1 class=\"text-sm md:text-base\">{{ book.title }}</h1>\n          <div class=\"grow\" />\n          <p class=\"text-sm md:text-base\">{{ book.publishedYear }}</p>\n        </div>\n\n        <div class=\"flex items-center\">\n          <div>\n            <p v-if=\"book.author\" class=\"text-gray-300 text-xs md:text-sm\">{{ $getString('LabelByAuthor', [book.author]) }}</p>\n            <p v-if=\"book.narrator\" class=\"text-gray-400 text-xs\">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>\n            <p v-if=\"book.duration\" class=\"text-gray-400 text-xs\">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>\n          </div>\n          <div class=\"grow\" />\n          <div v-if=\"book.matchConfidence\" class=\"rounded-full px-2 py-1 text-xs whitespace-nowrap text-white\" :class=\"book.matchConfidence > 0.95 ? 'bg-success/80' : 'bg-info/80'\">{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%</div>\n        </div>\n\n        <div v-if=\"book.series?.length\" class=\"flex py-1 -mx-1\">\n          <div v-for=\"(series, index) in book.series\" :key=\"index\" class=\"bg-white/10 rounded-full px-1 py-0.5 mx-1\">\n            <p class=\"leading-3 text-xs text-gray-400\">\n              {{ series.series }}<span v-if=\"series.sequence\">&nbsp;#{{ series.sequence }}</span>\n            </p>\n          </div>\n        </div>\n        <div class=\"w-full max-h-12 overflow-hidden\">\n          <p class=\"text-gray-500 text-xs\">{{ book.descriptionPlain }}</p>\n        </div>\n      </div>\n      <div v-else class=\"px-4 grow\">\n        <h1>\n          <div class=\"flex items-center\">{{ book.title }}<widgets-explicit-indicator v-if=\"book.explicit\" /></div>\n        </h1>\n        <p class=\"text-base text-gray-300 whitespace-nowrap truncate\">{{ $getString('LabelByAuthor', [book.author]) }}</p>\n        <p v-if=\"book.genres\" class=\"text-xs text-gray-400 leading-5\">{{ book.genres.join(', ') }}</p>\n        <p class=\"text-xs text-gray-400 leading-5\">{{ book.trackCount }} Episodes</p>\n      </div>\n    </div>\n    <div v-if=\"bookCovers.length > 1\" class=\"flex\">\n      <template v-for=\"cover in bookCovers\">\n        <div :key=\"cover\" class=\"border-2 hover:border-yellow-300 border-transparent\" :class=\"cover === selectedCover ? 'border-yellow-200' : ''\" @mousedown.stop @mouseup.stop @click.stop=\"clickCover(cover)\">\n          <img :src=\"cover\" class=\"h-20 w-12 object-cover mr-1\" />\n        </div>\n      </template>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    book: {\n      type: Object,\n      default: () => {}\n    },\n    isPodcast: Boolean,\n    bookCoverAspectRatio: Number,\n    currentBookDuration: Number\n  },\n  data() {\n    return {\n      selectedCover: null\n    }\n  },\n  computed: {\n    bookCovers() {\n      return this.book.covers || []\n    },\n    bookDuration() {\n      return (this.book.duration || 0) * 60\n    },\n    bookDurationComparison() {\n      if (!this.book.duration || !this.currentBookDuration) return ''\n      const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60)\n      let differenceInMinutes = currentBookDurationMinutes - this.book.duration\n      if (differenceInMinutes < 0) {\n        differenceInMinutes = Math.abs(differenceInMinutes)\n        return this.$getString('LabelDurationComparisonLonger', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])\n      } else if (differenceInMinutes > 0) {\n        return this.$getString('LabelDurationComparisonShorter', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])\n      }\n      return this.$strings.LabelDurationComparisonExactMatch\n    }\n  },\n  methods: {\n    selectMatch() {\n      const book = { ...this.book }\n      book.cover = this.selectedCover\n      this.$emit('select', book)\n    },\n    clickCover(cover) {\n      this.selectedCover = cover\n    }\n  },\n  mounted() {\n    this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/EpisodeSearchCard.vue",
    "content": "<template>\n  <div class=\"flex items-center h-full px-1 overflow-hidden\">\n    <covers-book-cover :library-item=\"libraryItem\" :width=\"coverWidth\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n    <div class=\"grow px-2 episodeSearchCardContent\">\n      <p class=\"truncate text-sm\">{{ episodeTitle }}</p>\n      <p class=\"text-xs text-gray-200 truncate\">{{ podcastTitle }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    episode: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    coverWidth() {\n      if (this.bookCoverAspectRatio === 1) return 50 * 1.2\n      return 50\n    },\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    episodeTitle() {\n      return this.episode.title || 'No Title'\n    },\n    podcastTitle() {\n      return this.mediaMetadata.title || 'No Title'\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n.episodeSearchCardContent {\n  width: calc(100% - 80px);\n  height: 75px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/GenreSearchCard.vue",
    "content": "<template>\n  <div class=\"flex h-full px-1 overflow-hidden\">\n    <div class=\"w-10 h-10 flex items-center justify-center\">\n      <span class=\"material-symbols text-2xl text-gray-200\">category</span>\n    </div>\n    <div class=\"grow px-2 tagSearchCardContent h-full\">\n      <p class=\"truncate text-sm\">{{ genre }}</p>\n      <p class=\"text-xs text-gray-400\">{{ $getString('LabelXItems', [numItems]) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    genre: String,\n    numItems: Number\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n.tagSearchCardContent {\n  width: calc(100% - 40px);\n  height: 44px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/GroupCard.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <div class=\"rounded-xs h-full relative\" :style=\"{ width: cardWidth + 'px', height: cardHeight + 'px' }\" @mouseover=\"mouseoverCard\" @mouseleave=\"mouseleaveCard\" @click=\"clickCard\">\n      <nuxt-link :to=\"groupTo\" class=\"cursor-pointer\">\n        <div class=\"w-full h-full relative\" :class=\"isHovering ? 'bg-black-400' : 'bg-primary'\">\n          <covers-group-cover ref=\"groupcover\" :id=\"groupEncode\" :name=\"groupName\" :type=\"groupType\" :book-items=\"bookItems\" :width=\"cardWidth\" :height=\"cardHeight\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n\n          <div v-if=\"hasValidCovers\" class=\"bg-black/60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30\" :class=\"isHovering ? '' : 'opacity-0'\" :style=\"{ padding: `${sizeMultiplier}rem` }\">\n            <p :style=\"{ fontSize: 1.2 * sizeMultiplier + 'rem' }\">{{ groupName }}</p>\n          </div>\n\n          <div class=\"absolute z-10 top-1.5e right-1.5e rounded-md leading-3e p-1e font-semibold text-white flex items-center justify-center\" :style=\"{ fontSize: 0.8 + 'em' }\" style=\"background-color: #cd9d49dd\">{{ bookItems.length }}</div>\n        </div>\n      </nuxt-link>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    group: {\n      type: Object,\n      default: () => null\n    },\n    width: Number,\n    height: {\n      type: Number,\n      default: 192\n    }\n  },\n  data() {\n    return {\n      isHovering: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    cardWidth() {\n      return this.width || this.cardHeight * 2\n    },\n    cardHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    _group() {\n      return this.group || {}\n    },\n    groupType() {\n      return this._group.type\n    },\n    groupTo() {\n      return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`\n    },\n    sizeMultiplier() {\n      return this.$store.getters['user/getSizeMultiplier']\n    },\n    bookItems() {\n      return this._group.books || []\n    },\n    groupName() {\n      return this._group.name || 'No Name'\n    },\n    groupEncode() {\n      return this.$encode(this.groupName)\n    },\n    filter() {\n      return `${this.groupType}.${this.$encode(this.groupName)}`\n    },\n    hasValidCovers() {\n      var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)\n      return !!validCovers.length\n    }\n  },\n  methods: {\n    mouseoverCard() {\n      this.isHovering = true\n    },\n    mouseleaveCard() {\n      this.isHovering = false\n    },\n    clickCard() {\n      this.$emit('click', this.group)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/ItemSearchCard.vue",
    "content": "<template>\n  <div class=\"flex items-center h-full px-1 overflow-hidden\">\n    <covers-book-cover :library-item=\"libraryItem\" :width=\"coverWidth\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n    <div class=\"grow px-2 audiobookSearchCardContent\">\n      <p class=\"truncate text-sm\">{{ title }}</p>\n      <p v-if=\"subtitle\" class=\"truncate text-xs text-gray-300\">{{ subtitle }}</p>\n      <p class=\"text-xs text-gray-200 truncate\">{{ $getString('LabelByAuthor', [authorName]) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    coverWidth() {\n      if (this.bookCoverAspectRatio === 1) return 50 * 1.2\n      return 50\n    },\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    mediaType() {\n      return this.libraryItem ? this.libraryItem.mediaType : null\n    },\n    isPodcast() {\n      return this.mediaType == 'podcast'\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    title() {\n      return this.mediaMetadata.title || 'No Title'\n    },\n    subtitle() {\n      return this.mediaMetadata.subtitle || ''\n    },\n    authorName() {\n      if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'\n      return this.mediaMetadata.authorName || 'Unknown'\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n.audiobookSearchCardContent {\n  width: calc(100% - 80px);\n  height: 75px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/ItemTaskRunningCard.vue",
    "content": "<template>\n  <div class=\"flex items-center px-1 overflow-hidden\">\n    <div class=\"w-8 flex items-center justify-center\">\n      <span v-if=\"isFinished\" :class=\"taskIconStatus\" class=\"material-symbols text-base\">{{ actionIcon }}</span>\n      <widgets-loading-spinner v-else />\n    </div>\n    <div class=\"grow px-2 taskRunningCardContent\">\n      <p class=\"truncate text-sm\">{{ title }}</p>\n\n      <p class=\"truncate text-xs text-gray-300\">{{ description }}</p>\n      <p v-if=\"specialMessage\" class=\"truncate text-xs text-gray-300\">{{ specialMessage }}</p>\n\n      <p v-if=\"isFailed && failedMessage\" class=\"text-xs truncate text-red-500\">{{ failedMessage }}</p>\n      <p v-else-if=\"!isFinished && cancelingScan\" class=\"text-xs truncate\">Canceling...</p>\n    </div>\n    <ui-btn v-if=\"userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan\" color=\"bg-primary\" :padding-y=\"1\" :padding-x=\"1\" class=\"text-xs w-16 max-w-16 truncate mr-1\" @click.stop=\"cancelScan\">{{ this.$strings.ButtonCancel }}</ui-btn>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    task: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      cancelingScan: false,\n      specialMessage: ''\n    }\n  },\n  watch: {\n    task: {\n      immediate: true,\n      handler() {\n        this.initTask()\n      }\n    }\n  },\n  computed: {\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    title() {\n      if (this.task.titleKey && this.$strings[this.task.titleKey]) {\n        return this.$getString(this.task.titleKey, this.task.titleSubs)\n      }\n      return this.task.title || 'No Title'\n    },\n    description() {\n      if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) {\n        return this.$getString(this.task.descriptionKey, this.task.descriptionSubs)\n      }\n      return this.task.description || ''\n    },\n    isFinished() {\n      return !!this.task.isFinished\n    },\n    isFailed() {\n      return !!this.task.isFailed\n    },\n    isSuccess() {\n      return this.isFinished && !this.isFailed\n    },\n    failedMessage() {\n      if (this.task.errorKey && this.$strings[this.task.errorKey]) {\n        return this.$getString(this.task.errorKey, this.task.errorSubs)\n      }\n      return this.task.error || ''\n    },\n    action() {\n      return this.task.action || ''\n    },\n    actionIcon() {\n      if (this.isFailed) {\n        return 'error'\n      } else if (this.isSuccess) {\n        return 'done'\n      }\n      switch (this.action) {\n        case 'download-podcast-episode':\n          return 'cloud_download'\n        case 'encode-m4b':\n          return 'sync'\n        default:\n          return 'settings'\n      }\n    },\n    taskIconStatus() {\n      if (this.isFinished && this.isFailed) {\n        return 'text-red-500'\n      }\n      if (this.isFinished && !this.isFailed) {\n        return 'text-green-500'\n      }\n\n      return ''\n    },\n    isLibraryScan() {\n      return this.action === 'library-scan' || this.action === 'library-match-all'\n    }\n  },\n  methods: {\n    initTask() {\n      // special message for library scan tasks\n      if (this.task?.data?.scanResults) {\n        const scanResults = this.task.data.scanResults\n        const strs = []\n        if (scanResults.added) strs.push(this.$getString('MessageTaskScanItemsAdded', [scanResults.added]))\n        if (scanResults.updated) strs.push(this.$getString('MessageTaskScanItemsUpdated', [scanResults.updated]))\n        if (scanResults.missing) strs.push(this.$getString('MessageTaskScanItemsMissing', [scanResults.missing]))\n        const changesDetected = strs.length > 0 ? strs.join(', ') : this.$strings.MessageTaskScanNoChangesNeeded\n        const timeElapsed = scanResults.elapsed ? ` (${this.$elapsedPretty(scanResults.elapsed / 1000, false, true)})` : ''\n        this.specialMessage = `${changesDetected}${timeElapsed}`\n      } else {\n        this.specialMessage = ''\n      }\n    },\n    cancelScan() {\n      const libraryId = this.task?.data?.libraryId\n      if (!libraryId) {\n        console.error('No library id in library-scan task', this.task)\n        return\n      }\n      this.cancelingScan = true\n      this.$root.socket.emit('cancel_scan', libraryId)\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style>\n.taskRunningCardContent {\n  width: calc(100% - 84px);\n  height: 60px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/ItemUploadCard.vue",
    "content": "<template>\n  <div class=\"relative w-full py-4 px-6 border border-white/10 shadow-lg rounded-md my-6\">\n    <div class=\"absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full\">\n      <p class=\"text-base text-white/80 font-mono\">#{{ item.index }}</p>\n    </div>\n\n    <div v-if=\"!processing && !uploadFailed && !uploadSuccess\" class=\"absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer\" @click=\"$emit('remove')\">\n      <span class=\"text-base text-white/80 font-mono material-symbols\">close</span>\n    </div>\n\n    <template v-if=\"!uploadSuccess && !uploadFailed\">\n      <widgets-alert v-if=\"error\" type=\"error\">\n        <p class=\"text-base\">{{ error }}</p>\n      </widgets-alert>\n\n      <div class=\"flex my-2 -mx-2\">\n        <div class=\"w-1/2 px-2\">\n          <ui-text-input-with-label v-model.trim=\"itemData.title\" :disabled=\"processing\" :label=\"$strings.LabelTitle\" @input=\"titleUpdated\" />\n        </div>\n        <div class=\"w-1/2 px-2\">\n          <div v-if=\"!isPodcast\" class=\"flex items-end\">\n            <ui-text-input-with-label v-model.trim=\"itemData.author\" :disabled=\"processing\" :label=\"$strings.LabelAuthor\" />\n            <ui-tooltip direction=\"top\" :text=\"$strings.LabelUploaderItemFetchMetadataHelp\">\n              <button type=\"button\" class=\"ml-2 mb-1 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer\" @click=\"fetchMetadata\">\n                <span class=\"text-base text-white/80 font-mono material-symbols\">sync</span>\n              </button>\n            </ui-tooltip>\n          </div>\n          <div v-else class=\"w-full\">\n            <p class=\"px-1 text-sm font-semibold\">\n              {{ $strings.LabelDirectory }}\n              <em class=\"font-normal text-xs pl-2\">(auto)</em>\n            </p>\n            <ui-text-input :value=\"directory\" disabled class=\"w-full font-mono text-xs\" />\n          </div>\n        </div>\n      </div>\n      <div v-if=\"!isPodcast\" class=\"flex my-2 -mx-2\">\n        <div class=\"w-1/2 px-2\">\n          <ui-text-input-with-label v-model.trim=\"itemData.series\" :disabled=\"processing\" :label=\"$strings.LabelSeries\" note=\"(optional)\" inputClass=\"h-10\" />\n        </div>\n        <div class=\"w-1/2 px-2\">\n          <div class=\"w-full\">\n            <label class=\"px-1 text-sm font-semibold\">\n              {{ $strings.LabelDirectory }}\n              <em class=\"font-normal text-xs pl-2\">(auto)</em>\n            </label>\n            <ui-text-input :value=\"directory\" disabled class=\"w-full font-mono text-xs h-10\" />\n          </div>\n        </div>\n      </div>\n\n      <tables-uploaded-files-table :files=\"item.itemFiles\" :title=\"$strings.HeaderItemFiles\" class=\"mt-8\" />\n      <tables-uploaded-files-table v-if=\"item.otherFiles.length\" :title=\"$strings.HeaderOtherFiles\" :files=\"item.otherFiles\" />\n      <tables-uploaded-files-table v-if=\"item.ignoredFiles.length\" :title=\"$strings.HeaderIgnoredFiles\" :files=\"item.ignoredFiles\" />\n    </template>\n    <widgets-alert v-if=\"uploadSuccess\" type=\"success\">\n      <p class=\"text-base\">\"{{ itemData.title }}\" {{ $strings.MessageUploaderItemSuccess }}</p>\n    </widgets-alert>\n    <widgets-alert v-if=\"uploadFailed\" type=\"error\">\n      <p class=\"text-base\">\"{{ itemData.title }}\" {{ $strings.MessageUploaderItemFailed }}</p>\n    </widgets-alert>\n\n    <div v-if=\"isNonInteractable\" class=\"absolute top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center z-20\">\n      <ui-loading-indicator>\n        <div class=\"mb-4\">\n          <span class=\"text-lg font-medium text-white\">\n            {{ nonInteractionLabel }}\n          </span>\n        </div>\n\n        <div v-if=\"isUploading\" class=\"w-64 mx-auto mb-2\">\n          <div class=\"flex items-center justify-center mb-2\">\n            <span class=\"text-sm font-medium text-white/60 text-center w-full\">\n              {{ uploadProgressText }}\n            </span>\n          </div>\n          <div class=\"w-full bg-primary/20 rounded-full h-2.5\">\n            <div class=\"bg-green-500 h-2.5 rounded-full transition-all duration-300 ease-out\" :style=\"{ width: uploadProgressPercent + '%' }\"></div>\n          </div>\n        </div>\n      </ui-loading-indicator>\n    </div>\n  </div>\n</template>\n\n<script>\nimport Path from 'path'\n\nexport default {\n  props: {\n    item: {\n      type: Object,\n      default: () => {}\n    },\n    mediaType: String,\n    processing: Boolean,\n    provider: String\n  },\n  data() {\n    return {\n      itemData: {\n        title: '',\n        author: '',\n        series: ''\n      },\n      error: '',\n      isUploading: false,\n      uploadFailed: false,\n      uploadSuccess: false,\n      isFetchingMetadata: false,\n      uploadProgress: {\n        loaded: 0,\n        total: 0\n      }\n    }\n  },\n  computed: {\n    isPodcast() {\n      return this.mediaType === 'podcast'\n    },\n    directory() {\n      if (!this.itemData.title) return ''\n      if (this.isPodcast) return this.itemData.title\n\n      const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]\n      const cleanedOutputPathParts = outputPathParts.filter(Boolean).map((part) => this.$sanitizeFilename(part))\n\n      return Path.join(...cleanedOutputPathParts)\n    },\n    isNonInteractable() {\n      return this.isUploading || this.isFetchingMetadata\n    },\n    nonInteractionLabel() {\n      if (this.isUploading) {\n        return this.$strings.MessageUploading\n      } else if (this.isFetchingMetadata) {\n        return this.$strings.LabelFetchingMetadata\n      }\n    },\n    uploadProgressPercent() {\n      if (this.uploadProgress.total === 0) return 0\n      return Math.min(100, Math.round((this.uploadProgress.loaded / this.uploadProgress.total) * 100))\n    },\n    uploadProgressText() {\n      const loaded = this.$bytesPretty(this.uploadProgress.loaded)\n      const total = this.$bytesPretty(this.uploadProgress.total)\n      return `${this.uploadProgressPercent}% (${loaded} / ${total})`\n    }\n  },\n  methods: {\n    setUploadStatus(status) {\n      this.isUploading = status === 'uploading'\n      this.uploadFailed = status === 'failed'\n      this.uploadSuccess = status === 'success'\n\n      if (status !== 'uploading') {\n        this.uploadProgress = {\n          loaded: 0,\n          total: 0\n        }\n      }\n    },\n    setUploadProgress(progress) {\n      if (this.isUploading && progress) {\n        this.uploadProgress = {\n          loaded: progress.loaded || 0,\n          total: progress.total || 0\n        }\n      }\n    },\n    titleUpdated() {\n      this.error = ''\n    },\n    async fetchMetadata() {\n      if (!this.itemData.title.trim().length) {\n        return\n      }\n\n      this.isFetchingMetadata = true\n      this.error = ''\n\n      try {\n        const searchQueryString = new URLSearchParams({\n          title: this.itemData.title,\n          author: this.itemData.author,\n          provider: this.provider\n        })\n        const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)\n\n        if (bestCandidate) {\n          this.itemData = {\n            ...this.itemData,\n            title: bestCandidate.title,\n            author: bestCandidate.author,\n            series: (bestCandidate.series || [])[0]?.series\n          }\n        } else {\n          this.error = this.$strings.ErrorUploadFetchMetadataNoResults\n        }\n      } catch (e) {\n        console.error('Failed', e)\n        this.error = this.$strings.ErrorUploadFetchMetadataAPI\n      } finally {\n        this.isFetchingMetadata = false\n      }\n    },\n    getData() {\n      if (!this.itemData.title) {\n        this.error = this.$strings.ErrorUploadLacksTitle\n        return null\n      }\n      this.error = ''\n      var files = this.item.itemFiles.concat(this.item.otherFiles)\n      return {\n        index: this.item.index,\n        directory: this.directory,\n        ...this.itemData,\n        files\n      }\n    }\n  },\n  mounted() {\n    if (this.item) {\n      this.itemData.title = this.item.title\n      this.itemData.author = this.item.author\n      this.itemData.series = this.item.series\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/LazyBookCard.vue",
    "content": "<template>\n  <div ref=\"card\" :id=\"`book-card-${index}`\" tabindex=\"0\" :style=\"{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }\" class=\"absolute rounded-xs z-10 cursor-pointer\" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover=\"mouseover\" @mouseleave=\"mouseleave\" @click=\"clickCard\">\n    <div :id=\"`cover-area-${index}`\" class=\"relative w-full top-0 left-0 rounded-sm overflow-hidden z-10 bg-primary box-shadow-book\" :style=\"{ height: coverHeight + 'px ' }\">\n      <!-- When cover image does not fill -->\n      <div cy-id=\"coverBg\" v-show=\"showCoverBg\" class=\"absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary\">\n        <div class=\"absolute cover-bg\" ref=\"coverBg\" />\n      </div>\n\n      <div cy-id=\"seriesSequenceList\" v-if=\"seriesSequenceList\" class=\"absolute rounded-lg bg-black/90 box-shadow-md z-20 text-right\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }\" style=\"background-color: #78350f\">\n        <p :style=\"{ fontSize: 0.8 + 'em' }\">#{{ seriesSequenceList }}</p>\n      </div>\n      <div cy-id=\"booksInSeries\" v-else-if=\"booksInSeries\" class=\"absolute rounded-lg bg-black/90 box-shadow-md z-20\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }\" style=\"background-color: #cd9d49dd\">\n        <p :style=\"{ fontSize: 0.8 + 'em' }\">{{ booksInSeries }}</p>\n      </div>\n\n      <div class=\"w-full h-full absolute top-0 left-0 rounded-sm overflow-hidden z-10\">\n        <div cy-id=\"titleImageNotReady\" v-show=\"libraryItem && !imageReady\" aria-hidden=\"true\" class=\"absolute top-0 left-0 w-full h-full flex items-center justify-center\" :style=\"{ padding: 0.5 + 'em' }\">\n          <p :style=\"{ fontSize: 0.8 + 'em' }\" class=\"text-gray-300 text-center\">{{ title }}</p>\n        </div>\n\n        <!-- Cover Image -->\n        <img cy-id=\"coverImage\" v-if=\"libraryItem\" :alt=\"`${displayTitle}, ${$strings.LabelCover}`\" ref=\"cover\" aria-hidden=\"true\" :src=\"bookCoverSrc\" class=\"relative w-full h-full transition-opacity duration-300\" :class=\"showCoverBg ? 'object-contain' : 'object-fill'\" @load=\"imageLoaded\" :style=\"{ opacity: imageReady ? 1 : 0 }\" />\n\n        <!-- Placeholder Cover Title & Author -->\n        <div cy-id=\"placeholderTitle\" v-if=\"!hasCover\" class=\"absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center\" :style=\"{ padding: placeholderCoverPadding + 'em' }\">\n          <div>\n            <p cy-id=\"placeholderTitleText\" aria-hidden=\"true\" class=\"text-center\" style=\"color: rgb(247 223 187)\" :style=\"{ fontSize: titleFontSize + 'em' }\">{{ titleCleaned }}</p>\n          </div>\n        </div>\n        <div cy-id=\"placeholderAuthor\" v-if=\"!hasCover\" class=\"absolute left-0 right-0 w-full flex items-center justify-center\" :style=\"{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }\">\n          <p cy-id=\"placeholderAuthorText\" aria-hidden=\"true\" class=\"text-center\" style=\"color: rgb(247 223 187); opacity: 0.75\" :style=\"{ fontSize: authorFontSize + 'em' }\">{{ authorCleaned }}</p>\n        </div>\n\n        <!-- No progress shown for podcasts (unless showing podcast episode) -->\n        <div cy-id=\"progressBar\" v-if=\"!isPodcast || episodeProgress\" class=\"absolute bottom-0 left-0 h-1e max-w-full z-20 rounded-b box-shadow-progressbar\" :class=\"itemIsFinished ? 'bg-success' : 'bg-yellow-400'\" :style=\"{ width: coverWidth * userProgressPercent + 'px' }\"></div>\n\n        <!-- Overlay is not shown if collapsing series in library -->\n        <div cy-id=\"overlay\" v-show=\"!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing\" class=\"w-full h-full absolute top-0 left-0 z-10 bg-black rounded-sm md:block\" :class=\"overlayWrapperClasslist\">\n          <div cy-id=\"playButton\" v-show=\"showPlayButton\" class=\"h-full flex items-center justify-center pointer-events-none\">\n            <div class=\"hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto\" @click.stop.prevent=\"play\">\n              <span class=\"material-symbols fill\" :style=\"{ fontSize: playIconFontSize + 'em' }\">play_arrow</span>\n            </div>\n          </div>\n\n          <div cy-id=\"readButton\" v-show=\"showReadButton\" class=\"h-full flex items-center justify-center pointer-events-none\">\n            <div class=\"hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto\" @click.stop.prevent=\"clickReadEBook\">\n              <span class=\"material-symbols\" :style=\"{ fontSize: playIconFontSize + 'em' }\">auto_stories</span>\n            </div>\n          </div>\n\n          <div cy-id=\"editButton\" v-if=\"userCanUpdate\" v-show=\"!isSelectionMode\" class=\"absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0\" :style=\"{ padding: 0.375 + 'em' }\" @click.stop.prevent=\"editClick\">\n            <span class=\"material-symbols\" :style=\"{ fontSize: 1 + 'em' }\">edit</span>\n          </div>\n\n          <!-- Radio button -->\n          <div cy-id=\"selectedRadioButton\" v-if=\"!isAuthorBookshelfView\" class=\"absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100\" :style=\"{ top: 0.375 + 'em', left: 0.375 + 'em' }\" @click.stop.prevent=\"selectBtnClick\">\n            <span class=\"material-symbols\" :class=\"selected ? 'text-yellow-400' : ''\" :style=\"{ fontSize: 1.25 + 'em' }\">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>\n          </div>\n\n          <!-- More Menu Icon -->\n          <div cy-id=\"moreButton\" ref=\"moreIcon\" v-show=\"!isSelectionMode && moreMenuItems.length\" class=\"md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150\" :style=\"{ bottom: 0.375 + 'em', right: 0.375 + 'em' }\" @click.stop.prevent=\"clickShowMore\">\n            <span class=\"material-symbols\" :style=\"{ fontSize: 1.2 + 'em' }\">more_vert</span>\n          </div>\n\n          <div cy-id=\"ebookFormat\" v-if=\"ebookFormat\" class=\"absolute\" :style=\"{ bottom: 0.375 + 'em', left: 0.375 + 'em' }\">\n            <span class=\"text-white/80\" :style=\"{ fontSize: 0.8 + 'em' }\">{{ ebookFormat }}</span>\n          </div>\n        </div>\n\n        <!-- Processing/loading spinner overlay -->\n        <div cy-id=\"loadingSpinner\" v-if=\"processing\" class=\"w-full h-full absolute top-0 left-0 z-10 bg-black/40 rounded-sm flex items-center justify-center\">\n          <widgets-loading-spinner size=\"la-lg\" />\n        </div>\n\n        <!-- Series name overlay -->\n        <div cy-id=\"seriesNameOverlay\" v-if=\"booksInSeries && libraryItem && isHovering\" class=\"w-full h-full absolute top-0 left-0 z-10 bg-black/60 rounded-sm flex items-center justify-center\" :style=\"{ padding: 1 + 'em' }\">\n          <p v-if=\"seriesName\" class=\"text-gray-200 text-center\" :style=\"{ fontSize: 1.1 + 'em' }\">{{ seriesName }}</p>\n        </div>\n\n        <!-- Error widget -->\n        <ui-tooltip cy-id=\"ErrorTooltip\" v-if=\"showError\" :text=\"errorText\" plaintext class=\"absolute bottom-4e left-0 z-10\">\n          <div :style=\"{ height: 1.5 + 'em', width: 2.5 + 'em' }\" class=\"bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300\">\n            <span class=\"material-symbols text-red-100 pr-1e\" :style=\"{ fontSize: 0.875 + 'em' }\">priority_high</span>\n          </div>\n        </ui-tooltip>\n\n        <!-- rss feed icon -->\n        <div cy-id=\"rssFeed\" v-if=\"rssFeed && !isSelectionMode && !isHovering\" class=\"absolute text-success top-0 left-0 z-10\" :style=\"{ padding: 0.375 + 'em' }\">\n          <span class=\"material-symbols\" aria-hidden=\"true\" :style=\"{ fontSize: 1.5 + 'em' }\">rss_feed</span>\n        </div>\n        <!-- media item shared icon -->\n        <div cy-id=\"mediaItemShare\" v-if=\"mediaItemShare && !isSelectionMode && !isHovering\" class=\"absolute text-success left-0 z-10\" :style=\"{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }\">\n          <span class=\"material-symbols\" aria-hidden=\"true\" :style=\"{ fontSize: 1.5 + 'em' }\">public</span>\n        </div>\n\n        <!-- Series sequence -->\n        <div cy-id=\"seriesSequence\" v-if=\"seriesSequence && !isHovering && !isSelectionMode\" class=\"absolute rounded-lg bg-black/90 box-shadow-md z-10\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }\">\n          <p :style=\"{ fontSize: 0.8 + 'em' }\">#{{ seriesSequence }}</p>\n        </div>\n\n        <!-- Podcast Episode # -->\n        <div cy-id=\"podcastEpisodeNumber\" v-if=\"recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing\" class=\"absolute rounded-lg bg-black/90 box-shadow-md z-10\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }\">\n          <p :style=\"{ fontSize: 0.8 + 'em' }\">\n            Episode\n            <span v-if=\"recentEpisodeNumber\">#{{ recentEpisodeNumber }}</span>\n          </p>\n        </div>\n\n        <!-- Podcast Num Episodes -->\n        <div cy-id=\"numEpisodes\" v-else-if=\"!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode\" class=\"absolute rounded-full bg-black/90 box-shadow-md z-10 flex items-center justify-center\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }\">\n          <p :style=\"{ fontSize: 0.8 + 'em' }\" role=\"status\" :aria-label=\"$strings.LabelNumberOfEpisodes\">{{ numEpisodes }}</p>\n        </div>\n\n        <!-- Podcast Num Episodes -->\n        <div cy-id=\"numEpisodesIncomplete\" v-else-if=\"numEpisodesIncomplete && !isHovering && !isSelectionMode\" class=\"absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }\">\n          <p :style=\"{ fontSize: 0.8 + 'em' }\">{{ numEpisodesIncomplete }}</p>\n        </div>\n      </div>\n    </div>\n\n    <!-- Alternative bookshelf title/author/sort -->\n    <div cy-id=\"detailBottom\" :id=\"`description-area-${index}`\" v-if=\"isAlternativeBookshelfView || isAuthorBookshelfView\" dir=\"auto\" class=\"relative mt-2e mb-2e left-0 z-50 w-full\">\n      <div :style=\"{ fontSize: 0.9 + 'em' }\">\n        <ui-tooltip v-if=\"displayTitle\" :text=\"displayTitle\" plaintext :disabled=\"!displayTitleTruncated\" direction=\"bottom\" :delayOnShow=\"500\" class=\"flex items-center\">\n          <p cy-id=\"title\" ref=\"displayTitle\" class=\"truncate\">{{ displayTitle }}</p>\n          <widgets-explicit-indicator cy-id=\"explicitIndicator\" v-if=\"isExplicit\" />\n        </ui-tooltip>\n      </div>\n      <ui-tooltip v-if=\"showSubtitles\" :text=\"displaySubtitle\" plaintext :disabled=\"!displaySubtitleTruncated\" direction=\"bottom\" :delayOnShow=\"500\" class=\"flex items-center\">\n        <p cy-id=\"subtitle\" class=\"truncate\" ref=\"displaySubtitle\" :style=\"{ fontSize: 0.6 + 'em' }\">{{ displaySubtitle }}</p>\n      </ui-tooltip>\n      <p cy-id=\"line2\" class=\"truncate text-gray-400\" :style=\"{ fontSize: 0.8 + 'em' }\">{{ displayLineTwo || '&nbsp;' }}</p>\n      <p cy-id=\"line3\" v-if=\"displaySortLine\" class=\"truncate text-gray-400\" :style=\"{ fontSize: 0.8 + 'em' }\">{{ displaySortLine }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nimport Vue from 'vue'\nimport MoreMenu from '@/components/widgets/MoreMenu'\n\nexport default {\n  props: {\n    index: Number,\n    width: Number,\n    height: {\n      type: Number,\n      default: 192\n    },\n    bookshelfView: Number,\n    bookMount: {\n      // Book can be passed as prop or set with setEntity()\n      type: Object,\n      default: () => null\n    },\n    orderBy: String,\n    filterBy: String,\n    sortingIgnorePrefix: Boolean,\n    continueListeningShelf: Boolean\n  },\n  data() {\n    return {\n      isHovering: false,\n      isMoreMenuOpen: false,\n      processing: false,\n      libraryItem: null,\n      imageReady: false,\n      selected: false,\n      isSelectionMode: false,\n      displayTitleTruncated: false,\n      displaySubtitleTruncated: false,\n      showCoverBg: false\n    }\n  },\n  watch: {\n    bookMount: {\n      handler(newVal) {\n        if (newVal) {\n          this.libraryItem = newVal\n        }\n      }\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.store.getters['libraries/getBookCoverAspectRatio']\n    },\n    coverWidth() {\n      return this.width || this.coverHeight / this.bookCoverAspectRatio\n    },\n    coverHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    cardWidth() {\n      // This method returns immediately without waiting for the DOM to update\n      return this.coverWidth\n    },\n    sizeMultiplier() {\n      return this.store.getters['user/getSizeMultiplier']\n    },\n    dateFormat() {\n      return this.store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.store.getters['getServerSetting']('timeFormat')\n    },\n    _libraryItem() {\n      return this.libraryItem || {}\n    },\n    isFile() {\n      // Library item is not in a folder\n      return this._libraryItem.isFile\n    },\n    media() {\n      return this._libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    mediaType() {\n      return this._libraryItem.mediaType\n    },\n    isPodcast() {\n      return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'\n    },\n    isExplicit() {\n      return this.mediaMetadata.explicit || false\n    },\n    placeholderUrl() {\n      return this.store.getters['globals/getPlaceholderCoverSrc']\n    },\n    bookCoverSrc() {\n      return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)\n    },\n    libraryItemId() {\n      return this._libraryItem.id\n    },\n    series() {\n      // Only included when filtering by series or collapse series or Continue Series shelf on home page\n      return this.mediaMetadata.series\n    },\n    seriesName() {\n      if (this.collapsedSeries?.name) return this.collapsedSeries.name\n      return this.series?.name || null\n    },\n    seriesSequence() {\n      return this.series?.sequence || null\n    },\n    libraryId() {\n      return this._libraryItem.libraryId\n    },\n    ebookFormat() {\n      return this.media.ebookFormat\n    },\n    numTracks() {\n      if (this.media.tracks) return this.media.tracks.length\n      return this.media.numTracks || 0 // toJSONMinified\n    },\n    numEpisodes() {\n      return this.media.numEpisodes || 0\n    },\n    numEpisodesIncomplete() {\n      return this._libraryItem.numEpisodesIncomplete || 0\n    },\n    processingBatch() {\n      return this.store.state.processingBatch\n    },\n    recentEpisode() {\n      // Only added to item when getting currently listening podcasts\n      return this._libraryItem.recentEpisode\n    },\n    recentEpisodeNumber() {\n      if (!this.recentEpisode) return null\n      if (this.recentEpisode.episode) {\n        return this.recentEpisode.episode.replace(/^#/, '')\n      }\n      return ''\n    },\n    collapsedSeries() {\n      // Only added to item object when collapseSeries is enabled\n      return this._libraryItem.collapsedSeries\n    },\n    booksInSeries() {\n      // Only added to item object when collapseSeries is enabled\n      return this.collapsedSeries?.numBooks || 0\n    },\n    seriesSequenceList() {\n      return this.collapsedSeries?.seriesSequenceList || null\n    },\n    libraryItemIdsInSeries() {\n      // Only added to item object when collapseSeries is enabled\n      return this.collapsedSeries?.libraryItemIds || []\n    },\n    hasCover() {\n      return !!this.media.coverPath\n    },\n    squareAspectRatio() {\n      return this.bookCoverAspectRatio === 1\n    },\n    title() {\n      return this.mediaMetadata.title || ''\n    },\n    playIconFontSize() {\n      return Math.max(2, 3 * this.sizeMultiplier)\n    },\n    author() {\n      if (this.isPodcast) return this.mediaMetadata.author\n      return this.mediaMetadata.authorName\n    },\n    authorLF() {\n      return this.mediaMetadata.authorNameLF\n    },\n    artist() {\n      const artists = this.mediaMetadata.artists || []\n      return artists.join(', ')\n    },\n    displayTitle() {\n      if (this.recentEpisode) return this.recentEpisode.title\n      const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix\n      if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name\n      return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix || '\\u00A0' : this.title || '\\u00A0'\n    },\n    displaySubtitle() {\n      if (!this.libraryItem) return '\\u00A0'\n      if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}`\n      if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle\n      if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName\n      return ''\n    },\n    displayLineTwo() {\n      if (this.recentEpisode) return this.title\n      if (this.isPodcast) return this.author\n      if (this.collapsedSeries) return ''\n      if (this.isAuthorBookshelfView) {\n        return this.mediaMetadata.publishedYear || ''\n      }\n      if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF\n      return this.author\n    },\n    displaySortLine() {\n      if (this.collapsedSeries) return null\n      if (this.orderBy === 'mtimeMs') return this.$getString('LabelFileModifiedDate', [this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)])\n      if (this.orderBy === 'birthtimeMs') return this.$getString('LabelFileBornDate', [this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)])\n      if (this.orderBy === 'addedAt') return this.$getString('LabelAddedDate', [this.$formatDate(this._libraryItem.addedAt, this.dateFormat)])\n      if (this.orderBy === 'media.duration') return this.$strings.LabelDuration + ': ' + this.$elapsedPrettyExtended(this.media.duration, false)\n      if (this.orderBy === 'size') return this.$strings.LabelSize + ': ' + this.$bytesPretty(this._libraryItem.size)\n      if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} ` + this.$strings.LabelEpisodes\n      if (this.orderBy === 'media.metadata.publishedYear') {\n        if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])\n        return '\\u00A0'\n      }\n      if (this.orderBy === 'progress') {\n        if (!this.userProgressLastUpdated) return '\\u00A0'\n        return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])\n      }\n      if (this.orderBy === 'progress.createdAt') {\n        if (!this.userProgressStartedDate) return '\\u00A0'\n        return this.$getString('LabelStartedDate', [this.$formatDatetime(this.userProgressStartedDate, this.dateFormat, this.timeFormat)])\n      }\n      if (this.orderBy === 'progress.finishedAt') {\n        if (!this.userProgressFinishedDate) return '\\u00A0'\n        return this.$getString('LabelFinishedDate', [this.$formatDatetime(this.userProgressFinishedDate, this.dateFormat, this.timeFormat)])\n      }\n      return null\n    },\n    episodeProgress() {\n      // Only used on home page currently listening podcast shelf\n      if (!this.recentEpisode) return null\n      return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)\n    },\n    userProgress() {\n      if (this.episodeProgress) return this.episodeProgress\n      return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)\n    },\n    isEBookOnly() {\n      return !this.numTracks && this.ebookFormat\n    },\n    useEBookProgress() {\n      if (!this.userProgress || this.userProgress.progress) return false\n      return this.userProgress.ebookProgress > 0\n    },\n    seriesProgressPercent() {\n      if (!this.libraryItemIdsInSeries.length) return 0\n      let progressPercent = 0\n      const useEBookProgress = this.useEBookProgress\n      this.libraryItemIdsInSeries.forEach((lid) => {\n        const progress = this.store.getters['user/getUserMediaProgress'](lid)\n        if (progress) progressPercent += progress.isFinished ? 1 : useEBookProgress ? progress.ebookProgress || 0 : progress.progress || 0\n      })\n      return progressPercent / this.libraryItemIdsInSeries.length\n    },\n    userProgressPercent() {\n      let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0\n      return Math.max(Math.min(1, progressPercent), 0)\n    },\n    userProgressLastUpdated() {\n      if (!this.userProgress) return null\n      return this.userProgress.lastUpdate\n    },\n    userProgressStartedDate() {\n      if (!this.userProgress) return null\n      return this.userProgress.startedAt\n    },\n    userProgressFinishedDate() {\n      if (!this.userProgress) return null\n      return this.userProgress.finishedAt\n    },\n    itemIsFinished() {\n      if (this.booksInSeries) return this.seriesIsFinished\n      return this.userProgress ? !!this.userProgress.isFinished : false\n    },\n    seriesIsFinished() {\n      return !this.libraryItemIdsInSeries.some((lid) => {\n        const progress = this.store.getters['user/getUserMediaProgress'](lid)\n        return !progress || !progress.isFinished\n      })\n    },\n    showError() {\n      if (this.recentEpisode) return false // Dont show podcast error on episode card\n      return this.isMissing || this.isInvalid\n    },\n    libraryItemIdStreaming() {\n      return this.store.getters['getLibraryItemIdStreaming']\n    },\n    isStreaming() {\n      return this.libraryItemIdStreaming === this.libraryItemId\n    },\n    isQueued() {\n      const episodeId = this.recentEpisode ? this.recentEpisode.id : null\n      return this.store.getters['getIsMediaQueued'](this.libraryItemId, episodeId)\n    },\n    isStreamingFromDifferentLibrary() {\n      return this.store.getters['getIsStreamingFromDifferentLibrary']\n    },\n    showReadButton() {\n      return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat\n    },\n    showPlayButton() {\n      return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)\n    },\n    showSmallEBookIcon() {\n      return !this.isSelectionMode && this.ebookFormat\n    },\n    isMissing() {\n      return this._libraryItem.isMissing\n    },\n    isInvalid() {\n      return this._libraryItem.isInvalid\n    },\n    errorText() {\n      if (this.isMissing) return 'Item directory is missing!'\n      else if (this.isInvalid) {\n        if (this.isPodcast) return 'Podcast has no episodes'\n        return 'Item has no audio tracks & ebook'\n      }\n      return 'Unknown Error'\n    },\n    overlayWrapperClasslist() {\n      const classes = []\n      if (this.isSelectionMode) classes.push('bg-black/60')\n      else classes.push('bg-black/40')\n      if (this.selected) {\n        classes.push('border-2 border-yellow-400')\n      }\n      return classes\n    },\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    userCanUpdate() {\n      return this.store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.store.getters['user/getUserCanDelete']\n    },\n    userCanDownload() {\n      return this.store.getters['user/getUserCanDownload']\n    },\n    userIsAdminOrUp() {\n      return this.store.getters['user/getIsAdminOrUp']\n    },\n    moreMenuItems() {\n      if (this.recentEpisode) {\n        const items = [\n          {\n            func: 'editPodcast',\n            text: this.$strings.ButtonEditPodcast\n          },\n          {\n            func: 'toggleFinished',\n            text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished\n          },\n          {\n            func: 'openPlaylists',\n            text: this.$strings.LabelAddToPlaylist\n          }\n        ]\n        if (this.continueListeningShelf) {\n          items.push({\n            func: 'removeFromContinueListening',\n            text: this.$strings.ButtonRemoveFromContinueListening\n          })\n        }\n        if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {\n          if (!this.isQueued) {\n            items.push({\n              func: 'addToQueue',\n              text: this.$strings.ButtonQueueAddItem\n            })\n          } else if (!this.isStreaming) {\n            items.push({\n              func: 'removeFromQueue',\n              text: this.$strings.ButtonQueueRemoveItem\n            })\n          }\n        }\n        return items\n      }\n\n      let items = []\n      if (!this.isPodcast) {\n        items = [\n          {\n            func: 'toggleFinished',\n            text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished\n          }\n        ]\n        if (this.userCanUpdate) {\n          items.push({\n            func: 'openCollections',\n            text: this.$strings.LabelAddToCollection\n          })\n        }\n        if (this.numTracks) {\n          items.push({\n            func: 'openPlaylists',\n            text: this.$strings.LabelAddToPlaylist\n          })\n          if (this.userIsAdminOrUp) {\n            items.push({\n              func: 'openShare',\n              text: this.$strings.LabelShare\n            })\n          }\n        }\n        if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {\n          items.push({\n            text: this.$strings.LabelSendEbookToDevice,\n            subitems: this.store.state.libraries.ereaderDevices.map((d) => {\n              return {\n                text: d.name,\n                func: 'sendToDevice',\n                data: d.name\n              }\n            })\n          })\n        }\n      }\n      if (this.userCanUpdate) {\n        items.push({\n          func: 'showEditModalFiles',\n          text: this.$strings.HeaderFiles\n        })\n        items.push({\n          func: 'showEditModalMatch',\n          text: this.$strings.HeaderMatch\n        })\n      }\n      if (this.userIsAdminOrUp && !this.isFile) {\n        items.push({\n          func: 'rescan',\n          text: this.$strings.ButtonReScan\n        })\n      }\n      if (this.series && this.bookMount) {\n        items.push({\n          func: 'removeSeriesFromContinueListening',\n          text: this.$strings.ButtonRemoveSeriesFromContinueSeries\n        })\n      }\n      if (this.continueListeningShelf) {\n        items.push({\n          func: 'removeFromContinueListening',\n          text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening\n        })\n      }\n      if (!this.isPodcast) {\n        if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {\n          if (!this.isQueued) {\n            items.push({\n              func: 'addToQueue',\n              text: this.$strings.ButtonQueueAddItem\n            })\n          } else if (!this.isStreaming) {\n            items.push({\n              func: 'removeFromQueue',\n              text: this.$strings.ButtonQueueRemoveItem\n            })\n          }\n        }\n      }\n\n      if (this.userCanDelete) {\n        items.push({\n          func: 'deleteLibraryItem',\n          text: this.$strings.ButtonDelete\n        })\n      }\n\n      return items\n    },\n    _socket() {\n      return this.$root.socket || this.$nuxt.$root.socket\n    },\n    titleFontSize() {\n      return 0.75\n    },\n    authorFontSize() {\n      return 0.6\n    },\n    placeholderCoverPadding() {\n      return 0.8\n    },\n    authorBottom() {\n      return 0.75\n    },\n    titleCleaned() {\n      if (!this.title) return ''\n      if (this.title.length > 60) {\n        return this.title.slice(0, 57) + '...'\n      }\n      return this.title\n    },\n    authorCleaned() {\n      if (!this.author) return ''\n      if (this.author.length > 30) {\n        return this.author.slice(0, 27) + '...'\n      }\n      return this.author\n    },\n    isAlternativeBookshelfView() {\n      const constants = this.$constants || this.$nuxt.$constants\n      return this.bookshelfView === constants.BookshelfView.DETAIL\n    },\n    isAuthorBookshelfView() {\n      const constants = this.$constants || this.$nuxt.$constants\n      return this.bookshelfView === constants.BookshelfView.AUTHOR\n    },\n    rssFeed() {\n      if (this.booksInSeries) return null\n      return this._libraryItem.rssFeed || null\n    },\n    mediaItemShare() {\n      return this._libraryItem.mediaItemShare || null\n    },\n    showSubtitles() {\n      return !this.isPodcast && this.store.getters['user/getUserSetting']('showSubtitles')\n    }\n  },\n  methods: {\n    setSelectionMode(val) {\n      this.isSelectionMode = val\n      if (!val) this.selected = false\n    },\n    setEntity(_libraryItem) {\n      var libraryItem = _libraryItem\n\n      // this code block is only necessary when showing a selected series with sequence #\n      //   it will update the selected series so we get realtime updates for series sequence changes\n      if (this.series) {\n        // i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy\n        libraryItem = {\n          ..._libraryItem,\n          media: {\n            ..._libraryItem.media,\n            metadata: {\n              ..._libraryItem.media.metadata\n            }\n          }\n        }\n        var mediaMetadata = libraryItem.media.metadata\n        if (mediaMetadata.series && Array.isArray(mediaMetadata.series)) {\n          var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)\n          if (newSeries) {\n            // update selected series\n            libraryItem.media.metadata.series = newSeries\n            this.libraryItem = libraryItem\n            return\n          }\n        }\n      }\n\n      this.libraryItem = libraryItem\n\n      this.$nextTick(() => {\n        if (this.$refs.displayTitle) {\n          this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth\n        }\n        if (this.$refs.displaySubtitle) {\n          this.displaySubtitleTruncated = this.$refs.displaySubtitle.scrollWidth > this.$refs.displaySubtitle.clientWidth\n        }\n      })\n    },\n    clickCard(e) {\n      if (this.processing) return\n      if (this.isSelectionMode) {\n        e.stopPropagation()\n        e.preventDefault()\n        this.selectBtnClick(e)\n      } else {\n        var router = this.$router || this.$nuxt.$router\n        if (router) {\n          if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)\n          else router.push(`/item/${this.libraryItemId}`)\n        }\n      }\n    },\n    editClick() {\n      if (this.recentEpisode) {\n        return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })\n      }\n      this.$emit('edit', this.libraryItem)\n    },\n    toggleFinished(confirmed = false) {\n      if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {\n        const payload = {\n          message: this.$getString('MessageConfirmMarkItemFinished', [this.displayTitle]),\n          callback: (confirmed) => {\n            if (confirmed) {\n              this.toggleFinished(true)\n            }\n          },\n          type: 'yesNo'\n        }\n        this.store.commit('globals/setConfirmPrompt', payload)\n        return\n      }\n\n      var updatePayload = {\n        isFinished: !this.itemIsFinished\n      }\n      this.processing = true\n\n      var apiEndpoint = `/api/me/progress/${this.libraryItemId}`\n      if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`\n\n      var toast = this.$toast || this.$nuxt.$toast\n      var axios = this.$axios || this.$nuxt.$axios\n      axios\n        .$patch(apiEndpoint, updatePayload)\n        .then(() => {\n          this.processing = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.processing = false\n          toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)\n        })\n    },\n    editPodcast() {\n      this.$emit('editPodcast', this.libraryItem)\n    },\n    rescan() {\n      if (this.processing) return\n      const axios = this.$axios || this.$nuxt.$axios\n      this.processing = true\n      axios\n        .$post(`/api/items/${this.libraryItemId}/scan`)\n        .then((data) => {\n          var result = data.result\n          if (!result) {\n            this.$toast.error(this.$getString('ToastRescanFailed', [this.displayTitle]))\n          } else if (result === 'UPDATED') {\n            this.$toast.success(this.$strings.ToastRescanUpdated)\n          } else if (result === 'UPTODATE') {\n            this.$toast.success(this.$strings.ToastRescanUpToDate)\n          } else if (result === 'REMOVED') {\n            this.$toast.error(this.$strings.ToastRescanRemoved)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to scan library item', error)\n          this.$toast.error(this.$strings.ToastScanFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    showEditModalFiles() {\n      // More menu func\n      this.$emit('edit', this.libraryItem, 'files')\n    },\n    showEditModalMatch() {\n      // More menu func\n      this.$emit('edit', this.libraryItem, 'match')\n    },\n    sendToDevice(deviceName) {\n      // More menu func\n      const payload = {\n        // message: `Are you sure you want to send ${this.ebookFormat} ebook \"${this.title}\" to device \"${deviceName}\"?`,\n        message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            const payload = {\n              libraryItemId: this.libraryItemId,\n              deviceName\n            }\n            this.processing = true\n            const axios = this.$axios || this.$nuxt.$axios\n            axios\n              .$post(`/api/emails/send-ebook-to-device`, payload)\n              .then(() => {\n                this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))\n              })\n              .catch((error) => {\n                console.error('Failed to send ebook to device', error)\n                this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)\n              })\n              .finally(() => {\n                this.processing = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.store.commit('globals/setConfirmPrompt', payload)\n    },\n    removeSeriesFromContinueListening() {\n      if (!this.series) return\n\n      const axios = this.$axios || this.$nuxt.$axios\n      this.processing = true\n      axios\n        .$get(`/api/me/series/${this.series.id}/remove-from-continue-listening`)\n        .then((data) => {\n          console.log('User updated', data)\n        })\n        .catch((error) => {\n          console.error('Failed to remove series from home', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    removeFromContinueListening() {\n      if (!this.userProgress) return\n\n      const axios = this.$axios || this.$nuxt.$axios\n      this.processing = true\n      axios\n        .$get(`/api/me/progress/${this.userProgress.id}/remove-from-continue-listening`)\n        .then((data) => {\n          console.log('User updated', data)\n        })\n        .catch((error) => {\n          console.error('Failed to hide item from home', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    addToQueue() {\n      var queueItem = {}\n      if (this.recentEpisode) {\n        queueItem = {\n          libraryItemId: this.libraryItemId,\n          libraryId: this.libraryId,\n          episodeId: this.recentEpisode.id,\n          title: this.recentEpisode.title,\n          subtitle: this.mediaMetadata.title,\n          caption: this.recentEpisode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n          duration: this.recentEpisode.audioFile.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n      } else {\n        queueItem = {\n          libraryItemId: this.libraryItemId,\n          libraryId: this.libraryId,\n          episodeId: null,\n          title: this.title,\n          subtitle: this.author,\n          caption: '',\n          duration: this.media.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n      }\n      this.store.commit('addItemToQueue', queueItem)\n    },\n    removeFromQueue() {\n      const episodeId = this.recentEpisode ? this.recentEpisode.id : null\n      this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId })\n    },\n    openCollections() {\n      this.store.commit('setSelectedLibraryItem', this.libraryItem)\n      this.store.commit('globals/setShowCollectionsModal', true)\n    },\n    openPlaylists() {\n      this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])\n      this.store.commit('globals/setShowPlaylistsModal', true)\n    },\n    openShare() {\n      this.store.commit('setSelectedLibraryItem', this.libraryItem)\n      this.store.commit('globals/setShareModal', this.mediaItemShare)\n    },\n    deleteLibraryItem() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteLibraryItem,\n        checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,\n        yesButtonText: this.$strings.ButtonDelete,\n        yesButtonColor: 'error',\n        checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),\n        callback: (confirmed, hardDelete) => {\n          if (confirmed) {\n            localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)\n\n            this.processing = true\n            const axios = this.$axios || this.$nuxt.$axios\n            axios\n              .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastItemDeletedSuccess)\n              })\n              .catch((error) => {\n                console.error('Failed to delete item', error)\n                this.$toast.error(this.$strings.ToastItemDeletedFailed)\n              })\n              .finally(() => {\n                this.processing = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.store.commit('globals/setConfirmPrompt', payload)\n    },\n    createMoreMenu() {\n      if (!this.$refs.moreIcon) return\n\n      var ComponentClass = Vue.extend(MoreMenu)\n\n      var _this = this\n      var instance = new ComponentClass({\n        propsData: {\n          items: this.moreMenuItems\n        },\n        created() {\n          this.$on('action', (action) => {\n            if (action.func && _this[action.func]) _this[action.func](action.data)\n          })\n          this.$on('close', () => {\n            _this.isMoreMenuOpen = false\n          })\n        }\n      })\n      instance.$mount()\n\n      var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()\n      var el = instance.$el\n\n      var elHeight = this.moreMenuItems.length * 28 + 10\n      var elWidth = 130\n\n      var bottomOfIcon = wrapperBox.top + wrapperBox.height\n      var rightOfIcon = wrapperBox.left + wrapperBox.width\n\n      var elTop = bottomOfIcon\n      var elLeft = rightOfIcon\n      if (bottomOfIcon + elHeight > window.innerHeight - 100) {\n        elTop = wrapperBox.top - elHeight\n        elLeft = wrapperBox.left\n      }\n\n      if (rightOfIcon + elWidth > window.innerWidth - 100) {\n        elLeft = rightOfIcon - elWidth\n      }\n\n      el.style.top = elTop + 'px'\n      el.style.left = elLeft + 'px'\n\n      this.isMoreMenuOpen = true\n      document.body.appendChild(el)\n    },\n    clickShowMore() {\n      this.createMoreMenu()\n    },\n    async clickReadEBook() {\n      const axios = this.$axios || this.$nuxt.$axios\n      var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {\n        console.error('Failed to get lirbary item', this.libraryItemId)\n        return null\n      })\n      if (!libraryItem) return\n      this.store.commit('showEReader', { libraryItem, keepProgress: true })\n    },\n    selectBtnClick(evt) {\n      if (this.processingBatch) return\n      this.selected = !this.selected\n      this.$emit('select', { entity: this.libraryItem, shiftKey: evt.shiftKey })\n    },\n    async play() {\n      var eventBus = this.$eventBus || this.$nuxt.$eventBus\n\n      const queueItems = []\n      // Podcast episode load queue items\n      if (this.recentEpisode) {\n        const axios = this.$axios || this.$nuxt.$axios\n        this.processing = true\n        const fullLibraryItem = await axios.$get(`/api/items/${this.libraryItemId}`).catch((err) => {\n          console.error('Failed to fetch library item', err)\n          return null\n        })\n        this.processing = false\n\n        if (fullLibraryItem && fullLibraryItem.media.episodes) {\n          const episodes = fullLibraryItem.media.episodes || []\n          // Sort from least recent to most recent\n          episodes.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))\n\n          const episodeIndex = episodes.findIndex((ep) => ep.id === this.recentEpisode.id)\n          if (episodeIndex >= 0) {\n            for (let i = episodeIndex; i < episodes.length; i++) {\n              const episode = episodes[i]\n              const podcastProgress = this.store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)\n              if (!podcastProgress || !podcastProgress.isFinished) {\n                queueItems.push({\n                  libraryItemId: this.libraryItemId,\n                  libraryId: this.libraryId,\n                  episodeId: episode.id,\n                  title: episode.title,\n                  subtitle: this.mediaMetadata.title,\n                  caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n                  duration: episode.audioFile.duration || null,\n                  coverPath: this.media.coverPath || null\n                })\n              }\n            }\n          }\n        }\n      } else {\n        const queueItem = {\n          libraryItemId: this.libraryItemId,\n          libraryId: this.libraryId,\n          episodeId: null,\n          title: this.title,\n          subtitle: this.author,\n          caption: '',\n          duration: this.media.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n        queueItems.push(queueItem)\n      }\n\n      eventBus.$emit('play-item', {\n        libraryItemId: this.libraryItemId,\n        episodeId: this.recentEpisode ? this.recentEpisode.id : null,\n        queueItems\n      })\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    destroy() {\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      if (this.$el && this.$el.parentNode) {\n        this.$el.parentNode.removeChild(this.$el)\n      } else if (this.$el && this.$el.remove) {\n        this.$el.remove()\n      }\n    },\n    setCoverBg() {\n      if (this.$refs.coverBg) {\n        this.$refs.coverBg.style.backgroundImage = `url(\"${this.bookCoverSrc}\")`\n      }\n    },\n    imageLoaded() {\n      this.imageReady = true\n\n      if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {\n        var { naturalWidth, naturalHeight } = this.$refs.cover\n        var aspectRatio = naturalHeight / naturalWidth\n        var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)\n\n        // If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit\n        if (arDiff > 0.15) {\n          this.showCoverBg = true\n          this.$nextTick(this.setCoverBg)\n        } else {\n          this.showCoverBg = false\n        }\n      }\n    }\n  },\n  mounted() {\n    if (this.bookMount) {\n      this.setEntity(this.bookMount)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/LazyCollectionCard.vue",
    "content": "<template>\n  <div ref=\"card\" :id=\"`collection-card-${index}`\" role=\"button\" :style=\"{ width: cardWidth + 'px' }\" class=\"absolute top-0 left-0 rounded-xs z-30 cursor-pointer\" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover=\"mouseover\" @mouseleave=\"mouseleave\" @click=\"clickCard\">\n    <div class=\"relative\" :style=\"{ height: coverHeight + 'px' }\">\n      <div class=\"absolute top-0 left-0 w-full box-shadow-book shadow-height\" />\n      <div class=\"w-full h-full bg-primary relative rounded-sm overflow-hidden\">\n        <covers-collection-cover ref=\"cover\" :book-items=\"books\" :width=\"cardWidth\" :height=\"coverHeight\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n      </div>\n      <div v-show=\"isHovering && userCanUpdate\" class=\"w-full h-full absolute top-0 left-0 z-10 bg-black/40 pointer-events-none\">\n        <div class=\"absolute pointer-events-auto\" :style=\"{ top: 0.5 + 'em', right: 0.5 + 'em' }\" @click.stop.prevent=\"clickEdit\">\n          <span class=\"material-symbols text-white/75 hover:text-white/100\" :style=\"{ fontSize: 1.25 + 'em' }\">edit</span>\n        </div>\n      </div>\n\n      <span v-if=\"!isHovering && rssFeed\" class=\"absolute z-10 material-symbols text-success\" :style=\"{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }\">rss_feed</span>\n    </div>\n\n    <div v-if=\"!isAlternativeBookshelfView\" class=\"categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center\" :style=\"{ width: Math.min(200, cardWidth) + 'px' }\">\n      <div class=\"w-full h-full shinyBlack flex items-center justify-center rounded-xs border\" :style=\"{ padding: `0em ${0.5}em` }\">\n        <p class=\"truncate\" :style=\"{ fontSize: labelFontSize + 'em' }\">{{ title }}</p>\n      </div>\n    </div>\n    <div v-else class=\"relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center\">\n      <p class=\"truncate\" :style=\"{ fontSize: labelFontSize + 'em' }\">{{ title }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    index: Number,\n    width: Number,\n    height: {\n      type: Number,\n      default: 192\n    },\n    bookshelfView: {\n      type: Number,\n      default: 0\n    },\n    collectionMount: {\n      type: Object,\n      default: () => null\n    },\n    isTag: Boolean\n  },\n  data() {\n    return {\n      collection: null,\n      isSelectionMode: false,\n      selected: false,\n      isHovering: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.store.getters['libraries/getBookCoverAspectRatio']\n    },\n    cardWidth() {\n      return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2\n    },\n    coverHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    labelFontSize() {\n      if (this.width < 160) return 0.75\n      return 0.9\n    },\n    sizeMultiplier() {\n      return this.store.getters['user/getSizeMultiplier']\n    },\n    title() {\n      return this.collection ? this.collection.name : ''\n    },\n    books() {\n      return this.collection ? this.collection.books || [] : []\n    },\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    currentLibraryId() {\n      return this.store.state.libraries.currentLibraryId\n    },\n    isAlternativeBookshelfView() {\n      const constants = this.$constants || this.$nuxt.$constants\n      return this.bookshelfView == constants.BookshelfView.DETAIL\n    },\n    userCanUpdate() {\n      return this.store.getters['user/getUserCanUpdate']\n    },\n    rssFeed() {\n      return this.collection ? this.collection.rssFeed : null\n    }\n  },\n  methods: {\n    setEntity(_collection) {\n      this.collection = _collection\n    },\n    setSelectionMode(val) {\n      this.isSelectionMode = val\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    clickCard() {\n      if (!this.collection) return\n      var router = this.$router || this.$nuxt.$router\n      router.push(`/collection/${this.collection.id}`)\n    },\n    clickEdit() {\n      this.$emit('edit', this.collection)\n    },\n    destroy() {\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      if (this.$el && this.$el.parentNode) {\n        this.$el.parentNode.removeChild(this.$el)\n      } else if (this.$el && this.$el.remove) {\n        this.$el.remove()\n      }\n    }\n  },\n  mounted() {\n    if (this.collectionMount) {\n      this.setEntity(this.collectionMount)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/LazyPlaylistCard.vue",
    "content": "<template>\n  <div ref=\"card\" :id=\"`playlist-card-${index}`\" role=\"button\" :style=\"{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }\" class=\"absolute top-0 left-0 rounded-xs z-30 cursor-pointer\" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover=\"mouseover\" @mouseleave=\"mouseleave\" @click=\"clickCard\">\n    <div class=\"relative\" :style=\"{ height: coverHeight + 'px' }\">\n      <div class=\"absolute top-0 left-0 w-full box-shadow-book shadow-height\" />\n      <div class=\"w-full h-full bg-primary relative rounded-sm overflow-hidden\">\n        <covers-playlist-cover ref=\"cover\" :items=\"items\" :width=\"cardWidth\" :height=\"coverHeight\" />\n      </div>\n      <div v-show=\"isHovering && userCanUpdate\" class=\"w-full h-full absolute top-0 left-0 z-10 bg-black/40 pointer-events-none\">\n        <div class=\"absolute pointer-events-auto\" :style=\"{ top: 0.5 + 'em', right: 0.5 + 'em' }\" @click.stop.prevent=\"clickEdit\">\n          <span class=\"material-symbols text-white/75 hover:text-white/100\" :style=\"{ fontSize: 1.25 + 'em' }\">edit</span>\n        </div>\n      </div>\n    </div>\n\n    <div v-if=\"!isAlternativeBookshelfView\" class=\"categoryPlacard absolute z-30 -bottom-6e left-0 right-0 mx-auto h-6e rounded-md text-center\" :style=\"{ width: Math.min(200, width) + 'px' }\">\n      <div class=\"w-full h-full shinyBlack flex items-center justify-center rounded-xs border\" :style=\"{ padding: `0em ${0.5}em` }\">\n        <p class=\"truncate\" :style=\"{ fontSize: labelFontSize + 'em' }\">{{ title }}</p>\n      </div>\n    </div>\n    <div v-else class=\"relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center\">\n      <p class=\"truncate\" :style=\"{ fontSize: labelFontSize + 'em' }\">{{ title }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    index: Number,\n    width: Number,\n    height: {\n      type: Number,\n      default: 192\n    },\n    bookshelfView: {\n      type: Number,\n      default: 0\n    },\n    playlistMount: {\n      type: Object,\n      default: () => null\n    }\n  },\n  data() {\n    return {\n      playlist: null,\n      isSelectionMode: false,\n      selected: false,\n      isHovering: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.store.getters['libraries/getBookCoverAspectRatio']\n    },\n    cardWidth() {\n      return this.width || this.coverHeight\n    },\n    coverHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    labelFontSize() {\n      if (this.width < 160) return 0.75\n      return 0.9\n    },\n    sizeMultiplier() {\n      return this.store.getters['user/getSizeMultiplier']\n    },\n    title() {\n      return this.playlist ? this.playlist.name : ''\n    },\n    items() {\n      return this.playlist ? this.playlist.items || [] : []\n    },\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    currentLibraryId() {\n      return this.store.state.libraries.currentLibraryId\n    },\n    isAlternativeBookshelfView() {\n      const constants = this.$constants || this.$nuxt.$constants\n      return this.bookshelfView == constants.BookshelfView.DETAIL\n    },\n    userCanUpdate() {\n      return this.store.getters['user/getUserCanUpdate']\n    }\n  },\n  methods: {\n    setEntity(playlist) {\n      this.playlist = playlist\n    },\n    setSelectionMode(val) {\n      this.isSelectionMode = val\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    clickCard() {\n      if (!this.playlist) return\n      var router = this.$router || this.$nuxt.$router\n      router.push(`/playlist/${this.playlist.id}`)\n    },\n    clickEdit() {\n      this.$emit('edit', this.playlist)\n    },\n    destroy() {\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      if (this.$el && this.$el.parentNode) {\n        this.$el.parentNode.removeChild(this.$el)\n      } else if (this.$el && this.$el.remove) {\n        this.$el.remove()\n      }\n    }\n  },\n  mounted() {\n    if (this.playlistMount) {\n      this.setEntity(this.playlistMount)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/LazySeriesCard.vue",
    "content": "<template>\n  <div cy-id=\"card\" ref=\"card\" :id=\"`series-card-${index}`\" tabindex=\"0\" :style=\"{ width: cardWidth + 'px' }\" class=\"absolute rounded-xs z-30 cursor-pointer\" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover=\"mouseover\" @mouseleave=\"mouseleave\" @click=\"clickCard\">\n    <div cy-id=\"covers-area\" class=\"relative\" :style=\"{ height: coverHeight + 'px' }\">\n      <div class=\"absolute top-0 left-0 w-full box-shadow-book shadow-height\" />\n      <div class=\"w-full h-full bg-primary relative rounded-sm overflow-hidden z-0\">\n        <covers-group-cover v-if=\"series\" ref=\"cover\" :id=\"seriesId\" :name=\"displayTitle\" :book-items=\"books\" :width=\"cardWidth\" :height=\"coverHeight\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n      </div>\n\n      <div cy-id=\"seriesLengthMarker\" class=\"absolute rounded-lg bg-black/90 box-shadow-md z-20\" :style=\"{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }\" style=\"background-color: #cd9d49dd\">\n        <p :style=\"{ fontSize: 0.8 + 'em' }\" role=\"status\" :aria-label=\"$strings.LabelNumberOfBooks\">{{ books.length }}</p>\n      </div>\n\n      <div cy-id=\"seriesProgressBar\" v-if=\"seriesPercentInProgress > 0\" class=\"absolute bottom-0 left-0 h-1e shadow-xs max-w-full z-10 rounded-b w-full box-shadow-progressbar\" :class=\"isSeriesFinished ? 'bg-success' : 'bg-yellow-400'\" :style=\"{ width: seriesPercentInProgress * 100 + '%' }\" />\n\n      <div cy-id=\"hoveringDisplayTitle\" v-if=\"hasValidCovers\" aria-hidden=\"true\" class=\"bg-black/60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity\" :class=\"isHovering ? '' : 'opacity-0'\" :style=\"{ padding: '1em' }\">\n        <p :style=\"{ fontSize: 1.2 + 'em' }\">{{ displayTitle }}</p>\n      </div>\n\n      <span cy-id=\"rssFeedMarker\" v-if=\"!isHovering && rssFeed\" class=\"absolute z-10 material-symbols text-success\" :style=\"{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }\">rss_feed</span>\n    </div>\n\n    <div cy-id=\"standardBottomText\" v-if=\"!isAlternativeBookshelfView\" class=\"categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center\" :style=\"{ width: Math.min(200, cardWidth) + 'px' }\">\n      <div class=\"w-full h-full shinyBlack flex items-center justify-center rounded-xs border\" :style=\"{ padding: `0em 0.5em` }\">\n        <p cy-id=\"standardBottomDisplayTitle\" class=\"truncate\" :style=\"{ fontSize: labelFontSize + 'em' }\">{{ displayTitle }}</p>\n      </div>\n    </div>\n    <div cy-id=\"detailBottomText\" v-else class=\"relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center\">\n      <p cy-id=\"detailBottomDisplayTitle\" class=\"truncate\" :style=\"{ fontSize: labelFontSize + 'em' }\">{{ displayTitle }}</p>\n      <p cy-id=\"detailBottomSortLine\" v-if=\"displaySortLine\" class=\"truncate text-gray-400\" :style=\"{ fontSize: 0.8 + 'em' }\">{{ displaySortLine }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    index: Number,\n    width: Number,\n    height: {\n      type: Number,\n      default: 192\n    },\n    bookshelfView: {\n      type: Number,\n      default: 0\n    },\n    seriesMount: {\n      type: Object,\n      default: () => null\n    },\n    sortingIgnorePrefix: Boolean,\n    orderBy: String\n  },\n  data() {\n    return {\n      series: null,\n      isSelectionMode: false,\n      selected: false,\n      isHovering: false,\n      imageReady: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.store.getters['libraries/getBookCoverAspectRatio']\n    },\n    cardWidth() {\n      return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2\n    },\n    coverHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    dateFormat() {\n      return this.store.getters['getServerSetting']('dateFormat')\n    },\n    labelFontSize() {\n      if (this.width < 160) return 0.75\n      return 0.9\n    },\n    sizeMultiplier() {\n      return this.store.getters['user/getSizeMultiplier']\n    },\n    seriesId() {\n      return this.series?.id || ''\n    },\n    title() {\n      return this.series?.name || ''\n    },\n    nameIgnorePrefix() {\n      return this.series?.nameIgnorePrefix || ''\n    },\n    displayTitle() {\n      if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\\u00A0'\n      return this.title || '\\u00A0'\n    },\n    displaySortLine() {\n      switch (this.orderBy) {\n        case 'addedAt':\n          return this.$getString('LabelAddedDate', [this.$formatDate(this.addedAt, this.dateFormat)])\n        case 'totalDuration':\n          return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`\n        case 'lastBookUpdated':\n          const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)\n          return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`\n        case 'lastBookAdded':\n          const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)\n          return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`\n        default:\n          return null\n      }\n    },\n    books() {\n      return this.series?.books || []\n    },\n    addedAt() {\n      return this.series?.addedAt || 0\n    },\n    totalDuration() {\n      return this.series?.totalDuration || 0\n    },\n    seriesBookProgress() {\n      return this.books\n        .map((libraryItem) => {\n          return this.store.getters['user/getUserMediaProgress'](libraryItem.id)\n        })\n        .filter((p) => !!p)\n    },\n    seriesBooksFinished() {\n      return this.seriesBookProgress.filter((p) => p.isFinished)\n    },\n    hasSeriesBookInProgress() {\n      return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)\n    },\n    seriesPercentInProgress() {\n      if (!this.books.length) return 0\n      let progressPercent = 0\n      this.seriesBookProgress.forEach((progress) => {\n        progressPercent += progress.isFinished ? 1 : progress.progress || 0\n      })\n      progressPercent /= this.books.length\n      return Math.min(1, Math.max(0, progressPercent))\n    },\n    isSeriesFinished() {\n      return this.books.length === this.seriesBooksFinished.length\n    },\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    currentLibraryId() {\n      return this.store.state.libraries.currentLibraryId\n    },\n    seriesBooksRoute() {\n      return `/library/${this.currentLibraryId}/series/${this.seriesId}`\n    },\n    hasValidCovers() {\n      var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)\n      return !!validCovers.length\n    },\n    isAlternativeBookshelfView() {\n      const constants = this.$constants || this.$nuxt.$constants\n      return this.bookshelfView == constants.BookshelfView.DETAIL\n    },\n    rssFeed() {\n      return this.series?.rssFeed\n    }\n  },\n  methods: {\n    setEntity(_series) {\n      this.series = _series\n    },\n    setSelectionMode(val) {\n      this.isSelectionMode = val\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    clickCard() {\n      if (!this.series) return\n      var router = this.$router || this.$nuxt.$router\n      router.push(`/library/${this.currentLibraryId}/series/${this.seriesId}`)\n    },\n    imageLoaded() {\n      this.imageReady = true\n    },\n    destroy() {\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      if (this.$el && this.$el.parentNode) {\n        this.$el.parentNode.removeChild(this.$el)\n      } else if (this.$el && this.$el.remove) {\n        this.$el.remove()\n      }\n    }\n  },\n  mounted() {\n    if (this.seriesMount) {\n      this.setEntity(this.seriesMount)\n    }\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/NarratorCard.vue",
    "content": "<template>\n  <div>\n    <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`\">\n      <div cy-id=\"card\" :style=\"{ width: cardWidth + 'px', height: cardHeight + 'px', fontSize: sizeMultiplier + 'rem' }\" class=\"bg-primary box-shadow-book rounded-md relative overflow-hidden\">\n        <div class=\"absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40\">\n          <span class=\"material-symbols text-[10em]\">&#xe91f;</span>\n        </div>\n\n        <!-- Narrator name & num books overlay -->\n        <div class=\"absolute bottom-0 left-0 w-full py-1e bg-black/60 px-2e\">\n          <p cy-id=\"name\" class=\"text-center font-semibold truncate text-gray-200\" :style=\"{ fontSize: 0.75 + 'em' }\">{{ name }}</p>\n          <p cy-id=\"numBooks\" class=\"text-center text-gray-200\" :style=\"{ fontSize: 0.65 + 'em' }\">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>\n        </div>\n      </div>\n    </nuxt-link>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    narrator: {\n      type: Object,\n      default: () => {}\n    },\n    width: Number,\n    height: {\n      type: Number,\n      default: 100\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    cardWidth() {\n      return this.cardHeight * 1.5\n    },\n    cardHeight() {\n      return this.height * this.sizeMultiplier\n    },\n    name() {\n      return this.narrator?.name || ''\n    },\n    numBooks() {\n      return this.narrator?.numBooks || this.narrator?.books?.length || 0\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    sizeMultiplier() {\n      return this.$store.getters['user/getSizeMultiplier']\n    }\n  },\n  methods: {}\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/NarratorSearchCard.vue",
    "content": "<template>\n  <div class=\"flex h-full px-1 overflow-hidden\">\n    <div class=\"w-10 h-10 flex items-center justify-center\">\n      <span class=\"material-symbols text-2xl text-gray-200\">&#xe91f;</span>\n    </div>\n    <div class=\"grow px-2 narratorSearchCardContent h-full\">\n      <p class=\"truncate text-sm\">{{ narrator }}</p>\n      <p class=\"text-xs text-gray-400\">{{ $getString('LabelXBooks', [numBooks]) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    narrator: String,\n    numBooks: Number\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style scoped>\n.narratorSearchCardContent {\n  width: calc(100% - 40px);\n  height: 44px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/cards/NotificationCard.vue",
    "content": "<template>\n  <div class=\"w-full border border-white/10 rounded-xl p-4 my-2\" :class=\"notification.enabled ? 'bg-primary/25' : 'bg-error/5'\">\n    <div class=\"flex flex-wrap items-center\">\n      <p class=\"text-base md:text-lg font-semibold pr-4\">{{ eventName }}</p>\n      <div class=\"grow\" />\n\n      <ui-btn v-if=\"eventName === 'onTest' && notification.enabled\" :loading=\"testing\" small class=\"mr-2\" @click.stop=\"fireTestEventAndSucceed\">{{ this.$strings.ButtonFireOnTest }}</ui-btn>\n      <ui-btn v-if=\"eventName === 'onTest' && notification.enabled\" :loading=\"testing\" small class=\"mr-2\" color=\"bg-red-600\" @click.stop=\"fireTestEventAndFail\">{{ this.$strings.ButtonFireAndFail }}</ui-btn>\n      <!-- <ui-btn v-if=\"eventName === 'onTest' && notification.enabled\" :loading=\"testing\" small class=\"mr-2\" @click.stop=\"rapidFireTestEvents\">Rapid Fire</ui-btn> -->\n      <ui-btn v-else-if=\"notification.enabled\" :loading=\"sendingTest\" small class=\"mr-2\" @click.stop=\"sendTestClick\">{{ this.$strings.ButtonTest }}</ui-btn>\n      <ui-btn v-else :loading=\"enabling\" small color=\"bg-success\" class=\"mr-2\" @click=\"enableNotification\">{{ this.$strings.ButtonEnable }}</ui-btn>\n\n      <ui-icon-btn :size=\"7\" icon-font-size=\"1.1rem\" icon=\"edit\" class=\"mr-2\" @click=\"editNotification\" />\n      <ui-icon-btn bg-color=\"bg-error\" :size=\"7\" icon-font-size=\"1.2rem\" icon=\"delete\" @click=\"deleteNotificationClick\" />\n    </div>\n    <div class=\"pt-4\">\n      <p class=\"text-gray-300 text-xs md:text-sm mb-2\">{{ notification.urls.join(', ') }}</p>\n\n      <p v-if=\"lastFiredAt && lastAttemptFailed\" class=\"text-red-300 text-xs\">Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})</p>\n      <p v-else-if=\"lastFiredAt\" class=\"text-gray-400 text-xs\">Last fired {{ $dateDistanceFromNow(lastFiredAt) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    notification: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      sendingTest: false,\n      enabling: false,\n      deleting: false,\n      testing: false\n    }\n  },\n  computed: {\n    eventName() {\n      return this.notification ? this.notification.eventName : null\n    },\n    lastFiredAt() {\n      return this.notification ? this.notification.lastFiredAt : null\n    },\n    lastAttemptFailed() {\n      return this.notification ? this.notification.lastAttemptFailed : null\n    },\n    numConsecutiveFailedAttempts() {\n      return this.notification ? this.notification.numConsecutiveFailedAttempts : null\n    }\n  },\n  methods: {\n    // For testing using the onTest event\n    fireTestEventAndFail() {\n      this.fireTestEvent(true)\n    },\n    fireTestEventAndSucceed() {\n      this.fireTestEvent(false)\n    },\n    fireTestEvent(intentionallyFail = false) {\n      this.testing = true\n      this.$axios\n        .$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          const errorMsg = error.response ? error.response.data : null\n          this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)\n        })\n        .finally(() => {\n          this.testing = false\n        })\n    },\n    rapidFireTestEvents() {\n      this.testing = true\n      var numFired = 0\n      var interval = setInterval(() => {\n        this.fireTestEvent()\n        numFired++\n        if (numFired > 25) {\n          this.testing = false\n          clearInterval(interval)\n        }\n      }, 100)\n    },\n    // End testing functions\n    sendTestClick() {\n      const payload = {\n        message: this.$strings.MessageConfirmNotificationTestTrigger,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.sendTest()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    sendTest() {\n      this.sendingTest = true\n      this.$axios\n        .$get(`/api/notifications/${this.notification.id}/test`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          const errorMsg = error.response ? error.response.data : null\n          this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)\n        })\n        .finally(() => {\n          this.sendingTest = false\n        })\n    },\n    enableNotification() {\n      this.enabling = true\n      const payload = {\n        id: this.notification.id,\n        enabled: true\n      }\n      this.$axios\n        .$patch(`/api/notifications/${this.notification.id}`, payload)\n        .then((updatedSettings) => {\n          this.$emit('update', updatedSettings)\n        })\n        .catch((error) => {\n          console.error('Failed to update notification', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.enabling = false\n        })\n    },\n    deleteNotificationClick() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteNotification,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteNotification()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteNotification() {\n      this.deleting = true\n      this.$axios\n        .$delete(`/api/notifications/${this.notification.id}`)\n        .then((updatedSettings) => {\n          this.$emit('update', updatedSettings)\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.$toast.error(this.$strings.ToastNotificationDeleteFailed)\n        })\n        .finally(() => {\n          this.deleting = false\n        })\n    },\n    editNotification() {\n      this.$emit('edit', this.notification)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/PodcastFeedSummaryCard.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"w-full p-2 border border-white/10 rounded-sm\">\n    <div class=\"flex\">\n      <div class=\"w-16 min-w-16\">\n        <div class=\"w-full h-16 bg-primary\">\n          <img v-if=\"image\" :src=\"image\" class=\"w-full h-full object-cover\" />\n        </div>\n        <p class=\"text-gray-400 text-xxs pt-1 text-center\">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>\n      </div>\n      <div class=\"grow pl-2\" :style=\"{ maxWidth: detailsWidth + 'px' }\">\n        <p class=\"mb-1\">{{ title }}</p>\n        <p class=\"text-xs mb-1 text-gray-300\">{{ author }}</p>\n        <p class=\"text-xs mb-2 text-gray-200\">{{ description }}</p>\n        <p class=\"text-xs truncate text-blue-200\">\n          {{ $strings.LabelFolder }}: <span class=\"font-mono\">{{ folderPath }}</span>\n        </p>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    feed: {\n      type: Object,\n      default: () => {}\n    },\n    libraryFolderPath: String\n  },\n  data() {\n    return {\n      width: 900\n    }\n  },\n  computed: {\n    title() {\n      return this.metadata.title || 'No Title'\n    },\n    image() {\n      return this.metadata.imageUrl\n    },\n    description() {\n      return this.metadata.description || ''\n    },\n    author() {\n      return this.metadata.author || ''\n    },\n    metadata() {\n      return this.feed || {}\n    },\n    numEpisodes() {\n      return this.feed.numEpisodes || 0\n    },\n    folderPath() {\n      if (!this.libraryFolderPath) return ''\n      return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`\n    },\n    detailsWidth() {\n      return this.width - 85\n    }\n  },\n  methods: {},\n  updated() {\n    this.width = this.$refs.wrapper.clientWidth\n  },\n  mounted() {\n    this.width = this.$refs.wrapper.clientWidth\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/cards/SeriesSearchCard.vue",
    "content": "<template>\n  <div class=\"flex h-full px-1 overflow-hidden\">\n    <covers-group-cover :name=\"name\" :book-items=\"bookItems\" :width=\"60\" :height=\"60\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n    <div class=\"grow px-2 seriesSearchCardContent h-full\">\n      <p class=\"truncate text-sm\">{{ name }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    series: {\n      type: Object,\n      default: () => {}\n    },\n    bookItems: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    name() {\n      return this.series.name\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n.seriesSearchCardContent {\n  width: calc(100% - 80px);\n  height: 60px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>"
  },
  {
    "path": "client/components/cards/TagSearchCard.vue",
    "content": "<template>\n  <div class=\"flex h-full px-1 overflow-hidden\">\n    <div class=\"w-10 h-10 flex items-center justify-center\">\n      <span class=\"material-symbols text-2xl text-gray-200\">local_offer</span>\n    </div>\n    <div class=\"grow px-2 tagSearchCardContent h-full\">\n      <p class=\"truncate text-sm\">{{ tag }}</p>\n      <p class=\"text-xs text-gray-400\">{{ $getString('LabelXItems', [numItems]) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    tag: String,\n    numItems: Number\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n.tagSearchCardContent {\n  width: calc(100% - 40px);\n  height: 44px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n</style>\n"
  },
  {
    "path": "client/components/content/LibraryItemDetails.vue",
    "content": "<template>\n  <div>\n    <div v-if=\"narrators?.length\" class=\"flex py-0.5 mt-4\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelNarrators }}</span>\n      </div>\n      <div class=\"max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis\">\n        <template v-for=\"(narrator, index) in narrators\">\n          <nuxt-link :key=\"narrator\" :to=\"`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`\" class=\"hover:underline\">{{ narrator }}</nuxt-link\n          ><span :key=\"index\" v-if=\"index < narrators.length - 1\">,&nbsp;</span>\n        </template>\n      </div>\n    </div>\n    <div v-if=\"publishedYear\" role=\"paragraph\" class=\"flex py-0.5\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelPublishYear }}</span>\n      </div>\n      <div>\n        {{ publishedYear }}\n      </div>\n    </div>\n    <div v-if=\"publisher\" role=\"paragraph\" class=\"flex py-0.5\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelPublisher }}</span>\n      </div>\n      <div>\n        <nuxt-link :to=\"`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`\" class=\"hover:underline\">{{ publisher }}</nuxt-link>\n      </div>\n    </div>\n    <div v-if=\"podcastType\" role=\"paragraph\" class=\"flex py-0.5\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelPodcastType }}</span>\n      </div>\n      <div class=\"capitalize\">\n        {{ podcastType }}\n      </div>\n    </div>\n    <div class=\"flex py-0.5\" v-if=\"genres.length\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelGenres }}</span>\n      </div>\n      <div class=\"max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis\">\n        <template v-for=\"(genre, index) in genres\">\n          <nuxt-link :key=\"genre\" :to=\"`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`\" class=\"hover:underline\">{{ genre }}</nuxt-link\n          ><span :key=\"index\" v-if=\"index < genres.length - 1\">,&nbsp;</span>\n        </template>\n      </div>\n    </div>\n    <div class=\"flex py-0.5\" v-if=\"tags.length\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelTags }}</span>\n      </div>\n      <div class=\"max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis\">\n        <template v-for=\"(tag, index) in tags\">\n          <nuxt-link :key=\"tag\" :to=\"`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`\" class=\"hover:underline\">{{ tag }}</nuxt-link\n          ><span :key=\"index\" v-if=\"index < tags.length - 1\">,&nbsp;</span>\n        </template>\n      </div>\n    </div>\n    <div v-if=\"language\" class=\"flex py-0.5\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelLanguage }}</span>\n      </div>\n      <div>\n        <nuxt-link :to=\"`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`\" class=\"hover:underline\">{{ language }}</nuxt-link>\n      </div>\n    </div>\n    <div v-if=\"tracks.length || (isPodcast && totalPodcastDuration)\" role=\"paragraph\" class=\"flex py-0.5\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelDuration }}</span>\n      </div>\n      <div>\n        {{ durationPretty }}\n      </div>\n    </div>\n    <div role=\"paragraph\" class=\"flex py-0.5\">\n      <div class=\"w-34 min-w-34 sm:w-34 sm:min-w-34 break-words\">\n        <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelSize }}</span>\n      </div>\n      <div>\n        {{ sizePretty }}\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    libraryId() {\n      return this.libraryItem.libraryId\n    },\n    isPodcast() {\n      return this.libraryItem.mediaType === 'podcast'\n    },\n    media() {\n      return this.libraryItem.media || {}\n    },\n    tracks() {\n      return this.media.tracks || []\n    },\n    podcastEpisodes() {\n      return this.media.episodes || []\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    publishedYear() {\n      return this.mediaMetadata.publishedYear\n    },\n    genres() {\n      return this.mediaMetadata.genres || []\n    },\n    tags() {\n      return this.media.tags || []\n    },\n    podcastAuthor() {\n      return this.mediaMetadata.author || ''\n    },\n    authors() {\n      return this.mediaMetadata.authors || []\n    },\n    publisher() {\n      return this.mediaMetadata.publisher || ''\n    },\n    narrators() {\n      return this.mediaMetadata.narrators || []\n    },\n    language() {\n      return this.mediaMetadata.language || null\n    },\n    durationPretty() {\n      if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)\n\n      if (!this.tracks.length && !this.audioFile) return 'N/A'\n      if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)\n      return this.$elapsedPretty(this.duration)\n    },\n    duration() {\n      if (!this.tracks.length && !this.audioFile) return 0\n      return this.media.duration\n    },\n    totalPodcastDuration() {\n      if (!this.podcastEpisodes.length) return 0\n      let totalDuration = 0\n      this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))\n      return totalDuration\n    },\n    sizePretty() {\n      return this.$bytesPretty(this.media.size)\n    },\n    podcastType() {\n      return this.mediaMetadata.type\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/controls/FilterSelect.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"relative\" v-click-outside=\"clickOutside\">\n    <div class=\"relative h-9\">\n      <button type=\"button\" class=\"relative w-full h-full border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden cursor-pointer\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" @click.prevent=\"showMenu = !showMenu\">\n        <span class=\"flex items-center justify-between\">\n          <span class=\"block truncate text-xs\">{{ selectedText }}</span>\n        </span>\n      </button>\n      <span v-if=\"selected === 'all'\" class=\"ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none\">\n        <svg class=\"h-5 w-5 text-gray-400\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n          <path fill-rule=\"evenodd\" d=\"M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z\" clip-rule=\"evenodd\" />\n        </svg>\n      </span>\n      <button v-else type=\"button\" :aria-label=\"$strings.ButtonClearFilter\" class=\"ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300\" @mousedown.stop @mouseup.stop @click.stop.prevent=\"clearSelected\">\n        <span class=\"material-symbols\" style=\"font-size: 1.1rem\">close</span>\n      </button>\n    </div>\n\n    <div v-show=\"showMenu\" class=\"absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black/5 overflow-auto focus:outline-hidden\">\n      <ul class=\"h-full w-full\" role=\"menu\">\n        <template v-for=\"item in items\">\n          <li :key=\"item.value\" class=\"select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5\" :class=\"item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'\" role=\"menuitem\" @click=\"clickedOption(item)\">\n            <div class=\"flex items-center justify-between\">\n              <span class=\"font-normal ml-3 block truncate\">{{ item.text }}</span>\n            </div>\n\n            <!-- selected checkmark icon -->\n            <div v-if=\"item.value === selected\" class=\"absolute inset-y-0 right-2 h-full flex items-center pointer-events-none\">\n              <span class=\"material-symbols text-base text-yellow-400\">check</span>\n            </div>\n          </li>\n        </template>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    items: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      showMenu: false\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    selectedText() {\n      if (!this.selected) return ''\n      const filter = this.items.find((i) => i.value === this.selected)\n      return filter ? filter.text : ''\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    }\n  },\n  methods: {\n    clearSelected() {\n      this.selected = 'all'\n      this.showMenu = false\n      this.$nextTick(() => this.$emit('change', 'all'))\n    },\n    clickOutside() {\n      this.showMenu = false\n    },\n    clickedOption(option) {\n      var val = option.value\n      if (this.selected === val) {\n        this.showMenu = false\n        return\n      }\n      this.selected = val\n      this.showMenu = false\n      this.$nextTick(() => this.$emit('change', val))\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/controls/GlobalSearch.vue",
    "content": "<template>\n  <div class=\"\">\n    <div class=\"w-full relative sm:w-80\">\n      <form role=\"search\" @submit.prevent=\"submitSearch\">\n        <ui-text-input ref=\"input\" v-model=\"search\" :placeholder=\"$strings.PlaceholderSearch\" @input=\"inputUpdate\" @focus=\"focussed\" @blur=\"blurred\" class=\"w-full h-8 text-sm\" />\n      </form>\n      <button :aria-hidden=\"!search\" class=\"absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer\" @click=\"clickClear\">\n        <span v-if=\"!search\" class=\"material-symbols\" style=\"font-size: 1.2rem\">&#xe8b6;</span>\n        <span v-else class=\"material-symbols\" style=\"font-size: 1.2rem\">close</span>\n      </button>\n    </div>\n    <div v-show=\"showMenu && (lastSearch || isTyping)\" class=\"absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm globalSearchMenu\" @mousedown.stop.prevent>\n      <ul class=\"h-full w-full\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n        <li v-if=\"isTyping\" class=\"py-2 px-2\">\n          <p>{{ $strings.MessageThinking }}</p>\n        </li>\n        <li v-else-if=\"isFetching\" class=\"py-2 px-2\">\n          <p>{{ $strings.MessageFetching }}</p>\n        </li>\n        <li v-else-if=\"!totalResults\" class=\"py-2 px-2\">\n          <p>{{ $strings.MessageNoResults }}</p>\n        </li>\n        <template v-else>\n          <p v-if=\"bookResults.length\" class=\"uppercase text-xs text-gray-400 my-1 px-1 font-semibold\">{{ $strings.LabelBooks }}</p>\n          <template v-for=\"item in bookResults\">\n            <li :key=\"item.libraryItem.id\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/item/${item.libraryItem.id}`\">\n                <cards-item-search-card :library-item=\"item.libraryItem\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"podcastResults.length\" class=\"uppercase text-xs text-gray-400 my-1 px-1 font-semibold\">{{ $strings.LabelPodcasts }}</p>\n          <template v-for=\"item in podcastResults\">\n            <li :key=\"item.libraryItem.id\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/item/${item.libraryItem.id}`\">\n                <cards-item-search-card :library-item=\"item.libraryItem\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"episodeResults.length\" class=\"uppercase text-xs text-gray-400 my-1 px-1 font-semibold\">{{ $strings.LabelEpisodes }}</p>\n          <template v-for=\"item in episodeResults\">\n            <li :key=\"item.libraryItem.recentEpisode.id\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/item/${item.libraryItem.id}`\">\n                <cards-episode-search-card :episode=\"item.libraryItem.recentEpisode\" :library-item=\"item.libraryItem\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"authorResults.length\" class=\"uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold\">{{ $strings.LabelAuthors }}</p>\n          <template v-for=\"item in authorResults\">\n            <li :key=\"item.id\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/author/${item.id}`\">\n                <cards-author-search-card :author=\"item\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"seriesResults.length\" class=\"uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold\">{{ $strings.LabelSeries }}</p>\n          <template v-for=\"item in seriesResults\">\n            <li :key=\"item.series.id\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/library/${currentLibraryId}/series/${item.series.id}`\">\n                <cards-series-search-card :series=\"item.series\" :book-items=\"item.books\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"tagResults.length\" class=\"uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold\">{{ $strings.LabelTags }}</p>\n          <template v-for=\"item in tagResults\">\n            <li :key=\"`tag.${item.name}`\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`\">\n                <cards-tag-search-card :tag=\"item.name\" :num-items=\"item.numItems\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"genreResults.length\" class=\"uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold\">{{ $strings.LabelGenres }}</p>\n          <template v-for=\"item in genreResults\">\n            <li :key=\"`genre.${item.name}`\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(item.name)}`\">\n                <cards-genre-search-card :genre=\"item.name\" :num-items=\"item.numItems\" />\n              </nuxt-link>\n            </li>\n          </template>\n\n          <p v-if=\"narratorResults.length\" class=\"uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold\">{{ $strings.LabelNarrators }}</p>\n          <template v-for=\"narrator in narratorResults\">\n            <li :key=\"narrator.name\" class=\"text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1\" role=\"option\" @click=\"clickOption\">\n              <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`\">\n                <cards-narrator-search-card :narrator=\"narrator.name\" :num-books=\"narrator.numBooks\" />\n              </nuxt-link>\n            </li>\n          </template>\n        </template>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      showMenu: false,\n      isFocused: false,\n      focusTimeout: null,\n      isTyping: false,\n      isFetching: false,\n      search: null,\n      podcastResults: [],\n      episodeResults: [],\n      bookResults: [],\n      authorResults: [],\n      seriesResults: [],\n      tagResults: [],\n      genreResults: [],\n      narratorResults: [],\n      searchTimeout: null,\n      lastSearch: null\n    }\n  },\n  computed: {\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    totalResults() {\n      return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length + this.episodeResults.length\n    }\n  },\n  methods: {\n    clickOption() {\n      this.clearResults()\n    },\n    submitSearch() {\n      if (!this.search) return\n      var search = this.search\n      this.clearResults()\n      this.$router.push(`/library/${this.currentLibraryId}/search?q=${encodeURIComponent(search)}`)\n    },\n    clearResults() {\n      this.search = null\n      this.lastSearch = null\n      this.podcastResults = []\n      this.episodeResults = []\n      this.bookResults = []\n      this.authorResults = []\n      this.seriesResults = []\n      this.tagResults = []\n      this.genreResults = []\n      this.narratorResults = []\n      this.showMenu = false\n      this.isFetching = false\n      this.isTyping = false\n      clearTimeout(this.searchTimeout)\n      this.$nextTick(() => {\n        if (this.$refs.input) {\n          this.$refs.input.blur()\n        }\n      })\n    },\n    focussed() {\n      this.isFocused = true\n      this.showMenu = true\n    },\n    blurred() {\n      this.isFocused = false\n      clearTimeout(this.focusTimeout)\n      this.focusTimeout = setTimeout(() => {\n        this.showMenu = false\n      }, 100)\n    },\n    async runSearch(value) {\n      this.lastSearch = value\n      if (!this.lastSearch) {\n        return\n      }\n      this.isFetching = true\n\n      const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${encodeURIComponent(value)}&limit=3`).catch((error) => {\n        console.error('Search error', error)\n        return []\n      })\n\n      // Search was canceled\n      if (!this.isFetching) return\n\n      this.podcastResults = searchResults.podcast || []\n      this.episodeResults = searchResults.episodes || []\n      this.bookResults = searchResults.book || []\n      this.authorResults = searchResults.authors || []\n      this.seriesResults = searchResults.series || []\n      this.tagResults = searchResults.tags || []\n      this.genreResults = searchResults.genres || []\n      this.narratorResults = searchResults.narrators || []\n\n      this.isFetching = false\n      if (!this.showMenu) {\n        return\n      }\n    },\n    inputUpdate(val) {\n      clearTimeout(this.searchTimeout)\n      if (!val) {\n        this.lastSearch = ''\n        this.isTyping = false\n        return\n      }\n      this.isTyping = true\n      this.searchTimeout = setTimeout(() => {\n        // Canceled search\n        if (!this.isTyping) return\n\n        this.isTyping = false\n        this.runSearch(val)\n      }, 750)\n    },\n    clickClear() {\n      this.clearResults()\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n.globalSearchMenu {\n  max-height: calc(100vh - 75px);\n}\n</style>\n"
  },
  {
    "path": "client/components/controls/LibraryFilterSelect.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"relative\" v-click-outside=\"clickOutside\">\n    <div class=\"relative h-7\">\n      <button type=\"button\" class=\"relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden sm:text-sm cursor-pointer\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" @click.prevent=\"showMenu = !showMenu\">\n        <span class=\"flex items-center justify-between\">\n          <span class=\"block truncate text-xs\" :class=\"!selectedText ? 'text-gray-300' : ''\">{{ selectedText }}</span>\n        </span>\n      </button>\n      <span v-if=\"selected === 'all'\" class=\"ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none\">\n        <svg class=\"h-5 w-5 text-gray-400\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n          <path fill-rule=\"evenodd\" d=\"M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z\" clip-rule=\"evenodd\" />\n        </svg>\n      </span>\n      <button v-else :aria-label=\"$strings.ButtonClearFilter\" class=\"ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200\" @mousedown.stop @mouseup.stop @click.stop.prevent=\"clearSelected\">\n        <span class=\"material-symbols\" style=\"font-size: 1.1rem\">close</span>\n      </button>\n    </div>\n\n    <div v-show=\"showMenu\" class=\"absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm libraryFilterMenu\">\n      <ul v-show=\"!sublist\" class=\"h-full w-full\" role=\"menu\">\n        <template v-for=\"item in selectItems\">\n          <li :key=\"item.value\" class=\"select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5\" :class=\"item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'\" role=\"menuitem\" :aria-haspopup=\"item.sublist ? '' : 'menu'\" @click=\"clickedOption(item)\">\n            <div class=\"flex items-center justify-between\">\n              <span class=\"font-normal ml-3 block truncate text-sm\">{{ item.text }}</span>\n            </div>\n            <div v-if=\"item.sublist\" class=\"absolute right-1 top-0 bottom-0 h-full flex items-center\">\n              <span class=\"material-symbols text-2xl\" :aria-label=\"$strings.LabelMore\">arrow_right</span>\n            </div>\n            <!-- selected checkmark icon -->\n            <div v-if=\"item.value === selected\" class=\"absolute inset-y-0 right-2 h-full flex items-center pointer-events-none\">\n              <span class=\"material-symbols text-base text-yellow-400\">check</span>\n            </div>\n          </li>\n        </template>\n      </ul>\n      <ul v-show=\"sublist\" class=\"h-full w-full\" role=\"menu\">\n        <li class=\"text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5\" role=\"menuitem\" @click=\"sublist = null\">\n          <div class=\"absolute left-1 top-0 bottom-0 h-full flex items-center\">\n            <span class=\"material-symbols text-2xl\">arrow_left</span>\n          </div>\n          <div class=\"flex items-center justify-between\">\n            <span class=\"font-normal block truncate\">{{ $strings.ButtonBack }}</span>\n          </div>\n        </li>\n        <li v-if=\"!sublistItems.length\" class=\"text-gray-400 select-none relative px-2\" role=\"menuitem\">\n          <div class=\"flex items-center justify-center\">\n            <span class=\"font-normal block truncate py-2\">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>\n          </div>\n        </li>\n        <template v-for=\"item in sublistItems\">\n          <li :key=\"item.value\" class=\"select-none relative px-2 cursor-pointer hover:bg-white/5\" :class=\"`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'\" role=\"menuitem\" @click=\"clickedSublistOption(item.value)\">\n            <div class=\"flex items-center\">\n              <span class=\"font-normal truncate py-2 text-xs\">{{ item.text }}</span>\n            </div>\n            <!-- selected checkmark icon -->\n            <div v-if=\"`${sublist}.${item.value}` === selected\" class=\"absolute inset-y-0 right-2 h-full flex items-center pointer-events-none\">\n              <span class=\"material-symbols text-base text-yellow-400\">check</span>\n            </div>\n          </li>\n        </template>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    isSeries: Boolean\n  },\n  data() {\n    return {\n      showMenu: false,\n      sublist: null\n    }\n  },\n  watch: {\n    showMenu(newVal) {\n      if (newVal) {\n        this.sublist = this.selectedItemSublist\n      }\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    userCanAccessExplicitContent() {\n      return this.$store.getters['user/getUserCanAccessExplicitContent']\n    },\n    libraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isPodcast() {\n      return this.libraryMediaType === 'podcast'\n    },\n    seriesItems() {\n      return [\n        {\n          text: this.$strings.LabelAll,\n          value: 'all'\n        },\n        {\n          text: this.$strings.LabelGenre,\n          textPlural: this.$strings.LabelGenres,\n          value: 'genres',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelTag,\n          textPlural: this.$strings.LabelTags,\n          value: 'tags',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelAuthor,\n          textPlural: this.$strings.LabelAuthors,\n          value: 'authors',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelNarrator,\n          textPlural: this.$strings.LabelNarrators,\n          value: 'narrators',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelPublisher,\n          textPlural: this.$strings.LabelPublishers,\n          value: 'publishers',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelLanguage,\n          textPlural: this.$strings.LabelLanguages,\n          value: 'languages',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelSeriesProgress,\n          value: 'progress',\n          sublist: true\n        }\n      ]\n    },\n    bookItems() {\n      const items = [\n        {\n          text: this.$strings.LabelAll,\n          value: 'all'\n        },\n        {\n          text: this.$strings.LabelGenre,\n          textPlural: this.$strings.LabelGenres,\n          value: 'genres',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelTag,\n          textPlural: this.$strings.LabelTags,\n          value: 'tags',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelSeries,\n          textPlural: this.$strings.LabelSeries,\n          value: 'series',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelAuthor,\n          textPlural: this.$strings.LabelAuthors,\n          value: 'authors',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelNarrator,\n          textPlural: this.$strings.LabelNarrators,\n          value: 'narrators',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelPublisher,\n          textPlural: this.$strings.LabelPublishers,\n          value: 'publishers',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelPublishedDecade,\n          textPlural: this.$strings.LabelPublishedDecades,\n          value: 'publishedDecades',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelLanguage,\n          textPlural: this.$strings.LabelLanguages,\n          value: 'languages',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelProgress,\n          value: 'progress',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelMissing,\n          value: 'missing',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelTracks,\n          value: 'tracks',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelEbooks,\n          value: 'ebooks',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelAbridged,\n          value: 'abridged',\n          sublist: false\n        },\n        {\n          text: this.$strings.ButtonIssues,\n          value: 'issues',\n          sublist: false\n        },\n        {\n          text: this.$strings.LabelRSSFeedOpen,\n          value: 'feed-open',\n          sublist: false\n        }\n      ]\n\n      if (this.userCanAccessExplicitContent) {\n        items.push({\n          text: this.$strings.LabelExplicit,\n          value: 'explicit',\n          sublist: false\n        })\n      }\n\n      if (this.userIsAdminOrUp) {\n        items.push({\n          text: this.$strings.LabelShareOpen,\n          value: 'share-open',\n          sublist: false\n        })\n      }\n      return items\n    },\n    podcastItems() {\n      const items = [\n        {\n          text: this.$strings.LabelAll,\n          value: 'all'\n        },\n        {\n          text: this.$strings.LabelGenre,\n          textPlural: this.$strings.LabelGenres,\n          value: 'genres',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelTag,\n          textPlural: this.$strings.LabelTags,\n          value: 'tags',\n          sublist: true\n        },\n        {\n          text: this.$strings.LabelLanguage,\n          textPlural: this.$strings.LabelLanguages,\n          value: 'languages',\n          sublist: true\n        },\n        {\n          text: this.$strings.ButtonIssues,\n          value: 'issues',\n          sublist: false\n        },\n        {\n          text: this.$strings.LabelRSSFeedOpen,\n          value: 'feed-open',\n          sublist: false\n        }\n      ]\n\n      if (this.userCanAccessExplicitContent) {\n        items.push({\n          text: this.$strings.LabelExplicit,\n          value: 'explicit',\n          sublist: false\n        })\n      }\n\n      return items\n    },\n    selectItems() {\n      if (this.isSeries) return this.seriesItems\n      if (this.isPodcast) return this.podcastItems\n      return this.bookItems\n    },\n    selectedItemSublist() {\n      return this.selected?.includes('.') ? this.selected.split('.')[0] : null\n    },\n    selectedSublistText() {\n      if (!this.sublist) {\n        return ''\n      }\n      const sublistItem = this.selectItems.find((i) => i.value === this.sublist)\n      return sublistItem?.textPlural || sublistItem?.text || ''\n    },\n    selectedText() {\n      if (!this.selected) return ''\n      const parts = this.selected.split('.')\n      const filterName = this.selectItems.find((i) => i.value === parts[0])\n      let filterValue = null\n      if (parts.length > 1) {\n        const decoded = this.$decode(parts[1])\n        if (parts[0] === 'authors') {\n          const author = this.authors.find((au) => au.id == decoded)\n          if (author) filterValue = author.name\n        } else if (parts[0] === 'series') {\n          if (decoded === 'no-series') {\n            filterValue = this.$strings.MessageNoSeries\n          } else {\n            const series = this.series.find((se) => se.id == decoded)\n            if (series) filterValue = series.name\n          }\n        } else if (parts[0] === 'progress') {\n          const item = this.progress.find((p) => p.id == decoded)\n          if (item) filterValue = item.name\n        } else if (parts[0] === 'tracks') {\n          const item = this.tracks.find((t) => t.id == decoded)\n          if (item) filterValue = item.name\n        } else if (parts[0] === 'ebooks') {\n          const item = this.ebooks.find((e) => e.id == decoded)\n          if (item) filterValue = item.name\n        } else if (parts[0] === 'missing') {\n          const item = this.missing.find((m) => m.id == decoded)\n          if (item) filterValue = item.name\n        } else {\n          filterValue = decoded\n        }\n      }\n      if (filterName && filterValue) {\n        return `${filterName.text}: ${filterValue}`\n      } else if (filterName) {\n        return filterName.text\n      } else if (filterValue) {\n        return filterValue\n      } else {\n        return ''\n      }\n    },\n    genres() {\n      return this.filterData.genres || []\n    },\n    tags() {\n      return this.filterData.tags || []\n    },\n    series() {\n      return this.filterData.series || []\n    },\n    authors() {\n      return this.filterData.authors || []\n    },\n    narrators() {\n      return this.filterData.narrators || []\n    },\n    languages() {\n      return this.filterData.languages || []\n    },\n    publishers() {\n      return this.filterData.publishers || []\n    },\n    publishedDecades() {\n      return this.filterData.publishedDecades || []\n    },\n    progress() {\n      return [\n        {\n          id: 'finished',\n          name: this.$strings.LabelFinished\n        },\n        {\n          id: 'in-progress',\n          name: this.$strings.LabelInProgress\n        },\n        {\n          id: 'not-started',\n          name: this.$strings.LabelNotStarted\n        },\n        {\n          id: 'not-finished',\n          name: this.$strings.LabelNotFinished\n        }\n      ]\n    },\n    tracks() {\n      return [\n        {\n          id: 'none',\n          name: this.$strings.LabelTracksNone\n        },\n        {\n          id: 'single',\n          name: this.$strings.LabelTracksSingleTrack\n        },\n        {\n          id: 'multi',\n          name: this.$strings.LabelTracksMultiTrack\n        }\n      ]\n    },\n    ebooks() {\n      return [\n        {\n          id: 'ebook',\n          name: this.$strings.LabelHasEbook\n        },\n        {\n          id: 'no-ebook',\n          name: this.$strings.LabelMissingEbook\n        },\n        {\n          id: 'supplementary',\n          name: this.$strings.LabelHasSupplementaryEbook\n        },\n        {\n          id: 'no-supplementary',\n          name: this.$strings.LabelMissingSupplementaryEbook\n        }\n      ]\n    },\n    missing() {\n      return [\n        {\n          id: 'asin',\n          name: 'ASIN'\n        },\n        {\n          id: 'isbn',\n          name: 'ISBN'\n        },\n        {\n          id: 'authors',\n          name: this.$strings.LabelAuthor\n        },\n        {\n          id: 'chapters',\n          name: this.$strings.LabelChapters\n        },\n        {\n          id: 'cover',\n          name: this.$strings.LabelCover\n        },\n        {\n          id: 'description',\n          name: this.$strings.LabelDescription\n        },\n        {\n          id: 'genres',\n          name: this.$strings.LabelGenres\n        },\n        {\n          id: 'language',\n          name: this.$strings.LabelLanguage\n        },\n        {\n          id: 'narrators',\n          name: this.$strings.LabelNarrator\n        },\n        {\n          id: 'publishedYear',\n          name: this.$strings.LabelPublishYear\n        },\n        {\n          id: 'publisher',\n          name: this.$strings.LabelPublisher\n        },\n        {\n          id: 'series',\n          name: this.$strings.LabelSeries\n        },\n        {\n          id: 'subtitle',\n          name: this.$strings.LabelSubtitle\n        },\n        {\n          id: 'tags',\n          name: this.$strings.LabelTags\n        }\n      ]\n    },\n    sublistItems() {\n      const sublistItems = (this[this.sublist] || []).map((item) => {\n        if (typeof item === 'string') {\n          return {\n            text: item,\n            value: this.$encode(item)\n          }\n        } else {\n          return {\n            text: item.name,\n            value: this.$encode(item.id)\n          }\n        }\n      })\n      if (this.sublist === 'series') {\n        sublistItems.unshift({\n          text: this.$strings.MessageNoSeries,\n          value: this.$encode('no-series')\n        })\n      }\n      return sublistItems\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    }\n  },\n  methods: {\n    clearSelected() {\n      this.selected = 'all'\n      this.showMenu = false\n      this.$nextTick(() => this.$emit('change', 'all'))\n    },\n    clickOutside() {\n      if (!this.selectedItemSublist) this.sublist = null\n      this.showMenu = false\n    },\n    clickedSublistOption(item) {\n      this.clickedOption({ value: `${this.sublist}.${item}` })\n    },\n    clickedOption(option) {\n      if (option.sublist) {\n        this.sublist = option.value\n        return\n      }\n\n      const val = option.value\n      if (this.selected === val) {\n        this.showMenu = false\n        return\n      }\n      this.selected = val\n      this.showMenu = false\n      this.$nextTick(() => this.$emit('change', val))\n    }\n  }\n}\n</script>\n\n<style scoped>\n.libraryFilterMenu {\n  max-height: calc(100vh - 125px);\n}\n</style>\n"
  },
  {
    "path": "client/components/controls/LibrarySortSelect.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"relative\" v-click-outside=\"clickOutside\">\n    <button type=\"button\" class=\"relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden sm:text-sm cursor-pointer\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" @click.prevent=\"showMenu = !showMenu\">\n      <span class=\"flex items-center justify-between\">\n        <span class=\"block truncate text-xs\" :class=\"!selectedText ? 'text-gray-300' : ''\">{{ selectedText }}</span>\n        <span class=\"material-symbols text-lg text-yellow-400\" :aria-label=\"descending ? $strings.LabelSortDescending : $strings.LabelSortAscending\">{{ descending ? 'expand_more' : 'expand_less' }}</span>\n      </span>\n    </button>\n\n    <ul v-show=\"showMenu\" class=\"librarySortMenu absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm\" role=\"menu\">\n      <template v-for=\"item in selectItems\">\n        <li :key=\"item.value\" class=\"select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5\" :class=\"item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'\" role=\"menuitem\" @click=\"clickedOption(item.value)\">\n          <div class=\"flex items-center\">\n            <span class=\"font-normal ml-3 block truncate\">{{ item.text }}</span>\n          </div>\n          <span v-if=\"item.value === selected\" class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4\">\n            <span class=\"material-symbols text-xl\" :aria-label=\"descending ? $strings.LabelSortDescending : $strings.LabelSortAscending\">{{ descending ? 'expand_more' : 'expand_less' }}</span>\n          </span>\n        </li>\n      </template>\n    </ul>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    descending: Boolean\n  },\n  data() {\n    return {\n      showMenu: false\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    selectedDesc: {\n      get() {\n        return this.descending\n      },\n      set(val) {\n        this.$emit('update:descending', val)\n      }\n    },\n    libraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isPodcast() {\n      return this.libraryMediaType === 'podcast'\n    },\n    podcastItems() {\n      return [\n        {\n          text: this.$strings.LabelTitle,\n          value: 'media.metadata.title'\n        },\n        {\n          text: this.$strings.LabelAuthor,\n          value: 'media.metadata.author'\n        },\n        {\n          text: this.$strings.LabelAddedAt,\n          value: 'addedAt'\n        },\n        {\n          text: this.$strings.LabelSize,\n          value: 'size'\n        },\n        {\n          text: this.$strings.LabelNumberOfEpisodes,\n          value: 'media.numTracks'\n        },\n        {\n          text: this.$strings.LabelFileBirthtime,\n          value: 'birthtimeMs'\n        },\n        {\n          text: this.$strings.LabelFileModified,\n          value: 'mtimeMs'\n        },\n        {\n          text: this.$strings.LabelRandomly,\n          value: 'random'\n        }\n      ]\n    },\n    bookItems() {\n      return [\n        {\n          text: this.$strings.LabelTitle,\n          value: 'media.metadata.title'\n        },\n        {\n          text: this.$strings.LabelAuthorFirstLast,\n          value: 'media.metadata.authorName'\n        },\n        {\n          text: this.$strings.LabelAuthorLastFirst,\n          value: 'media.metadata.authorNameLF'\n        },\n        {\n          text: this.$strings.LabelPublishYear,\n          value: 'media.metadata.publishedYear'\n        },\n        {\n          text: this.$strings.LabelAddedAt,\n          value: 'addedAt'\n        },\n        {\n          text: this.$strings.LabelSize,\n          value: 'size'\n        },\n        {\n          text: this.$strings.LabelDuration,\n          value: 'media.duration'\n        },\n        {\n          text: this.$strings.LabelFileBirthtime,\n          value: 'birthtimeMs'\n        },\n        {\n          text: this.$strings.LabelFileModified,\n          value: 'mtimeMs'\n        },\n        {\n          text: this.$strings.LabelLibrarySortByProgress,\n          value: 'progress'\n        },\n        {\n          text: this.$strings.LabelLibrarySortByProgressStarted,\n          value: 'progress.createdAt'\n        },\n        {\n          text: this.$strings.LabelLibrarySortByProgressFinished,\n          value: 'progress.finishedAt'\n        },\n        {\n          text: this.$strings.LabelRandomly,\n          value: 'random'\n        }\n      ]\n    },\n    seriesItems() {\n      return [\n        ...this.bookItems,\n        {\n          text: this.$strings.LabelSequence,\n          value: 'sequence'\n        }\n      ]\n    },\n    selectItems() {\n      let items = null\n      if (this.isPodcast) {\n        items = this.podcastItems\n      } else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {\n        items = this.seriesItems\n      } else {\n        items = this.bookItems\n      }\n\n      if (!items.some((i) => i.value === this.selected)) {\n        this.selected = items[0].value\n        this.selectedDesc = !this.defaultsToAsc(items[0].value)\n      }\n\n      return items\n    },\n    selectedText() {\n      var _selected = this.selected\n      if (!_selected) return ''\n      if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')\n      var _sel = this.selectItems.find((i) => i.value === _selected)\n      if (!_sel) return ''\n      return _sel.text\n    }\n  },\n  methods: {\n    clickOutside() {\n      this.showMenu = false\n    },\n    clickedOption(val) {\n      if (this.selected === val) {\n        this.selectedDesc = !this.selectedDesc\n      } else {\n        this.selected = val\n        if (this.defaultsToAsc(val)) this.selectedDesc = false\n      }\n      this.showMenu = false\n      this.$nextTick(() => this.$emit('change', val))\n    },\n    defaultsToAsc(val) {\n      return val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF' || val == 'sequence'\n    }\n  }\n}\n</script>\n\n<style scoped>\n.librarySortMenu {\n  max-height: calc(100vh - 125px);\n}\n</style>\n"
  },
  {
    "path": "client/components/controls/PlaybackSpeedControl.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"relative ml-4 sm:ml-8\" v-click-outside=\"clickOutside\">\n    <div class=\"flex items-center justify-center text-gray-300 cursor-pointer h-full\" @mousedown.prevent @mouseup.prevent @click=\"setShowMenu(true)\">\n      <span class=\"text-gray-200 text-sm sm:text-base\">{{ playbackRateDisplay }}<span class=\"text-base\">x</span></span>\n    </div>\n    <div v-show=\"showMenu\" class=\"absolute -top-[5.5rem] z-20 bg-bg border-black-200 border shadow-xl rounded-lg\" :style=\"{ left: menuLeft + 'px' }\">\n      <div class=\"absolute -bottom-1.5 right-0 w-full flex justify-center\" :style=\"{ left: arrowLeft + 'px' }\">\n        <div class=\"arrow-down\" />\n      </div>\n      <div class=\"flex items-center h-9 relative overflow-hidden rounded-lg\" style=\"width: 220px\">\n        <template v-for=\"rate in rates\">\n          <div :key=\"rate\" class=\"h-full border-black-300 w-11 cursor-pointer border rounded-xs\" :class=\"value === rate ? 'bg-black-100' : 'hover:bg-black/10'\" style=\"min-width: 44px; max-width: 44px\" @click=\"set(rate)\">\n            <div class=\"w-full h-full flex justify-center items-center\">\n              <p class=\"text-xs text-center\">{{ rate }}<span class=\"text-sm\">x</span></p>\n            </div>\n          </div>\n        </template>\n      </div>\n      <div class=\"w-full py-1 px-1\">\n        <div class=\"flex items-center justify-between\">\n          <ui-icon-btn :disabled=\"!canDecrement\" icon=\"remove\" @click=\"decrement\" />\n          <p class=\"px-2 text-2xl sm:text-3xl\">{{ playbackRateDisplay }}<span class=\"text-2xl\">x</span></p>\n          <ui-icon-btn :disabled=\"!canIncrement\" icon=\"add\" @click=\"increment\" />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: {\n      type: [String, Number],\n      default: 1\n    },\n    playbackRateIncrementDecrement: {\n      type: Number,\n      default: 0.1\n    }\n  },\n  data() {\n    return {\n      showMenu: false,\n      currentPlaybackRate: 0,\n      MIN_SPEED: 0.5,\n      MAX_SPEED: 10,\n      menuLeft: -96,\n      arrowLeft: 0\n    }\n  },\n  computed: {\n    playbackRate: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    rates() {\n      return [0.5, 1, 1.2, 1.5, 2]\n    },\n    canIncrement() {\n      return this.playbackRate + this.playbackRateIncrementDecrement <= this.MAX_SPEED\n    },\n    canDecrement() {\n      return this.playbackRate - this.playbackRateIncrementDecrement >= this.MIN_SPEED\n    },\n    playbackRateDisplay() {\n      if (this.playbackRateIncrementDecrement == 0.05) return this.playbackRate.toFixed(2)\n      // For 0.1 increment: Only show 2 decimal places if the playback rate is 2 decimals\n      const numDecimals = String(this.playbackRate).split('.')[1]?.length || 0\n      if (numDecimals <= 1) return this.playbackRate.toFixed(1)\n      return this.playbackRate.toFixed(2)\n    }\n  },\n  methods: {\n    clickOutside() {\n      this.setShowMenu(false)\n    },\n    set(rate) {\n      this.playbackRate = Number(rate)\n      this.$nextTick(() => this.setShowMenu(false))\n    },\n    increment() {\n      if (this.playbackRate + this.playbackRateIncrementDecrement > this.MAX_SPEED) return\n      var newPlaybackRate = this.playbackRate + this.playbackRateIncrementDecrement\n      this.playbackRate = Number(newPlaybackRate.toFixed(2))\n    },\n    decrement() {\n      if (this.playbackRate - this.playbackRateIncrementDecrement < this.MIN_SPEED) return\n      var newPlaybackRate = this.playbackRate - this.playbackRateIncrementDecrement\n      this.playbackRate = Number(newPlaybackRate.toFixed(2))\n    },\n    updateMenuPositions() {\n      if (!this.$refs.wrapper) return\n      const boundingBox = this.$refs.wrapper.getBoundingClientRect()\n\n      if (boundingBox.left + 110 > window.innerWidth - 10) {\n        this.menuLeft = window.innerWidth - 230 - boundingBox.left\n\n        this.arrowLeft = Math.abs(this.menuLeft) - 96\n      } else {\n        this.menuLeft = -96\n        this.arrowLeft = 0\n      }\n    },\n    setShowMenu(val) {\n      if (val) {\n        this.updateMenuPositions()\n        this.currentPlaybackRate = this.playbackRate\n      } else if (this.currentPlaybackRate !== this.playbackRate) {\n        this.$emit('change', this.playbackRate)\n      }\n      this.showMenu = val\n    }\n  },\n  mounted() {\n    this.currentPlaybackRate = this.playbackRate\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/controls/SortSelect.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"relative\" v-click-outside=\"clickOutside\">\n    <button type=\"button\" class=\"relative w-full h-full border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden cursor-pointer\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" @click.prevent=\"showMenu = !showMenu\">\n      <span class=\"flex items-center justify-between\">\n        <span class=\"block truncate text-xs\" :class=\"!selectedText ? 'text-gray-300' : ''\">{{ selectedText }}</span>\n        <span class=\"material-symbols text-lg text-yellow-400\" :aria-label=\"descending ? $strings.LabelSortDescending : $strings.LabelSortAscending\">{{ descending ? 'expand_more' : 'expand_less' }}</span>\n      </span>\n    </button>\n\n    <ul v-show=\"showMenu\" class=\"absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm\" role=\"menu\">\n      <template v-for=\"item in items\">\n        <li :key=\"item.value\" class=\"select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5\" :class=\"item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'\" role=\"menuitem\" @click=\"clickedOption(item.value)\">\n          <div class=\"flex items-center\">\n            <span class=\"font-normal ml-3 block truncate\">{{ item.text }}</span>\n          </div>\n          <span v-if=\"item.value === selected\" class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4\">\n            <span class=\"material-symbols text-xl\" :aria-label=\"descending ? $strings.LabelSortDescending : $strings.LabelSortAscending\">{{ descending ? 'expand_more' : 'expand_less' }}</span>\n          </span>\n        </li>\n      </template>\n    </ul>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    descending: Boolean,\n    items: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      showMenu: false\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    selectedDesc: {\n      get() {\n        return this.descending\n      },\n      set(val) {\n        this.$emit('update:descending', val)\n      }\n    },\n    selectedText() {\n      var _selected = this.selected\n      if (!_selected) return ''\n      var _sel = this.items.find((i) => i.value === _selected)\n      if (!_sel) return ''\n      return _sel.text\n    }\n  },\n  methods: {\n    clickOutside() {\n      this.showMenu = false\n    },\n    clickedOption(val) {\n      if (this.selected === val) {\n        this.selectedDesc = !this.selectedDesc\n      } else {\n        this.selected = val\n      }\n      this.showMenu = false\n      this.$nextTick(() => this.$emit('change', val))\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/controls/VolumeControl.vue",
    "content": "<template>\n  <div class=\"relative\" v-click-outside=\"clickOutside\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <button :aria-label=\"$strings.LabelVolume\" class=\"text-gray-300 hover:text-white\" @mousedown.prevent @mouseup.prevent @click=\"clickVolumeIcon\">\n      <span class=\"material-symbols text-2xl sm:text-3xl\">{{ volumeIcon }}</span>\n    </button>\n    <transition name=\"menux\">\n      <div v-show=\"isOpen\" class=\"volumeMenu h-28 absolute bottom-2 w-6 py-2 bg-bg shadow-xs rounded-lg\" style=\"top: -116px\">\n        <div ref=\"volumeTrack\" class=\"w-1 h-full bg-gray-500 mx-2.5 relative cursor-pointer rounded-full\" @mousedown=\"mousedownTrack\" @click=\"clickVolumeTrack\">\n          <div class=\"bg-gray-100 w-full absolute left-0 bottom-0 pointer-events-none rounded-full\" :style=\"{ height: volume * trackHeight + 'px' }\" />\n          <div class=\"w-2.5 h-2.5 bg-white shadow-xs rounded-full absolute pointer-events-none\" :class=\"isDragging ? 'transform scale-125 origin-center' : ''\" :style=\"{ bottom: cursorBottom + 'px', left: '-3px' }\" />\n        </div>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Number\n  },\n  data() {\n    return {\n      isOpen: false,\n      isDragging: false,\n      isHovering: false,\n      posY: 0,\n      lastValue: 0.5,\n      isMute: false,\n      trackHeight: 112 - 20,\n      openTimeout: null\n    }\n  },\n  computed: {\n    volume: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        try {\n          localStorage.setItem('volume', val)\n        } catch (error) {\n          console.error('Failed to store volume', err)\n        }\n        this.$emit('input', val)\n      }\n    },\n    cursorBottom() {\n      var bottom = this.trackHeight * this.volume\n      return bottom - 3\n    },\n    volumeIcon() {\n      if (this.volume <= 0) return 'volume_mute'\n      else if (this.volume <= 0.5) return 'volume_down'\n      else return 'volume_up'\n    }\n  },\n  methods: {\n    scroll(e) {\n      if (!e || !e.wheelDeltaY) return\n      if (e.wheelDeltaY > 0) {\n        this.volume = Math.min(1, this.volume + 0.1)\n      } else {\n        this.volume = Math.max(0, this.volume - 0.1)\n      }\n    },\n    mouseover() {\n      if (!this.isHovering) {\n        window.addEventListener('mousewheel', this.scroll)\n      }\n      this.isHovering = true\n      this.setOpen()\n    },\n    mouseleave() {\n      if (this.isHovering) {\n        window.removeEventListener('mousewheel', this.scroll)\n      }\n      this.isHovering = false\n    },\n    setOpen() {\n      this.isOpen = true\n      clearTimeout(this.openTimeout)\n      this.openTimeout = setTimeout(() => {\n        if (!this.isHovering && !this.isDragging) {\n          this.isOpen = false\n        } else {\n          this.setOpen()\n        }\n      }, 600)\n    },\n    mousemove(e) {\n      var diff = this.posY - e.y\n      this.posY = e.y\n      var volShift = diff / this.trackHeight\n      var newVol = this.volume + volShift\n      newVol = Math.min(Math.max(0, newVol), 1)\n      this.volume = newVol\n      e.preventDefault()\n    },\n    mouseup(e) {\n      if (this.isDragging) {\n        this.isDragging = false\n        document.body.removeEventListener('mousemove', this.mousemove)\n        document.body.removeEventListener('mouseup', this.mouseup)\n      }\n    },\n    mousedownTrack(e) {\n      this.isDragging = true\n      this.posY = e.y\n      var vol = 1 - e.offsetY / this.trackHeight\n      vol = Math.min(Math.max(vol, 0), 1)\n      this.volume = vol\n      document.body.addEventListener('mousemove', this.mousemove)\n      document.body.addEventListener('mouseup', this.mouseup)\n      e.preventDefault()\n    },\n    clickOutside() {\n      this.isOpen = false\n    },\n    clickVolumeIcon() {\n      this.isMute = !this.isMute\n      if (this.isMute) {\n        this.lastValue = this.volume\n        this.volume = 0\n      } else {\n        this.volume = this.lastValue || 0.5\n      }\n    },\n    toggleMute() {\n      this.clickVolumeIcon()\n    },\n    clickVolumeTrack(e) {\n      var vol = 1 - e.offsetY / this.trackHeight\n      vol = Math.min(Math.max(vol, 0), 1)\n      this.volume = vol\n    }\n  },\n  mounted() {\n    if (this.value === 0) {\n      this.isMute = true\n    }\n    const storageVolume = localStorage.getItem('volume')\n    if (storageVolume && !isNaN(storageVolume)) {\n      this.volume = parseFloat(storageVolume)\n    }\n  },\n  beforeDestroy() {\n    window.removeEventListener('mousewheel', this.scroll)\n    document.body.removeEventListener('mousemove', this.mousemove)\n    document.body.removeEventListener('mouseup', this.mouseup)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/covers/AuthorImage.vue",
    "content": "<template>\n  <div ref=\"wrapper\" :class=\"`rounded-${rounded}`\" class=\"w-full h-full bg-primary overflow-hidden\">\n    <svg v-if=\"!imagePath\" width=\"140%\" height=\"140%\" style=\"margin-left: -20%; margin-top: -20%; opacity: 0.6\" viewBox=\"0 0 177 266\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path fill=\"white\" d=\"M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z\" stroke=\"white\" stroke-width=\"4\" transform=\"translate(-2 -1)\" />\n      <path d=\"M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z\" fill=\"white\" />\n      <path\n        d=\"M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z\"\n        fill=\"white\"\n      />\n      <path d=\"M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z\" fill=\"white\" stroke=\"white\" stroke-width=\"10px\" />\n    </svg>\n    <div v-else class=\"w-full h-full relative\">\n      <div v-if=\"showCoverBg\" class=\"cover-bg absolute\" :style=\"{ backgroundImage: `url(${imgSrc})` }\" />\n      <img ref=\"img\" :src=\"imgSrc\" @load=\"imageLoaded\" class=\"absolute top-0 left-0 h-full w-full\" :class=\"coverContain ? 'object-contain' : 'object-cover'\" />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    author: {\n      type: Object,\n      default: () => {}\n    },\n    rounded: {\n      type: String,\n      default: 'lg'\n    }\n  },\n  data() {\n    return {\n      showCoverBg: false,\n      coverContain: true\n    }\n  },\n  computed: {\n    _author() {\n      return this.author || {}\n    },\n    authorId() {\n      return this._author.id\n    },\n    imagePath() {\n      return this._author.imagePath\n    },\n    updatedAt() {\n      return this._author.updatedAt\n    },\n    imgSrc() {\n      if (!this.imagePath) return null\n      return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`\n    }\n  },\n  methods: {\n    imageLoaded() {\n      if (this.$refs.img) {\n        var { naturalWidth, naturalHeight } = this.$refs.img\n        var imgAr = naturalHeight / naturalWidth\n        if (imgAr < 0.5 || imgAr > 2) {\n          this.showCoverBg = true\n        } else {\n          this.showCoverBg = false\n          this.coverContain = false\n        }\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/covers/BookCover.vue",
    "content": "<template>\n  <div class=\"relative rounded-xs overflow-hidden\" :style=\"{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }\">\n    <div class=\"w-full h-full relative bg-bg\">\n      <div v-show=\"showCoverBg\" class=\"absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary\">\n        <div class=\"absolute cover-bg\" ref=\"coverBg\" />\n      </div>\n\n      <img v-if=\"libraryItem\" ref=\"cover\" :src=\"fullCoverUrl\" loading=\"lazy\" draggable=\"false\" @error=\"imageError\" @load=\"imageLoaded\" class=\"w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity\" :style=\"{ opacity: imageReady ? '1' : '0' }\" :class=\"showCoverBg ? 'object-contain' : 'object-fill'\" @click=\"clickCover\" />\n\n      <div v-show=\"loading && libraryItem\" class=\"absolute top-0 left-0 h-full w-full flex items-center justify-center\">\n        <p class=\"text-center\" :style=\"{ fontSize: 0.75 * sizeMultiplier + 'rem' }\">{{ title }}</p>\n        <div class=\"absolute top-2 right-2\">\n          <widgets-loading-spinner />\n        </div>\n      </div>\n    </div>\n\n    <div v-if=\"imageFailed\" class=\"absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100\" :style=\"{ padding: placeholderCoverPadding + 'rem' }\">\n      <div class=\"w-full h-full border-2 border-error flex flex-col items-center justify-center\">\n        <img src=\"/Logo.png\" loading=\"lazy\" class=\"mb-2\" :style=\"{ height: 64 * sizeMultiplier + 'px' }\" />\n        <p class=\"text-center text-error\" :style=\"{ fontSize: titleFontSize + 'rem' }\">Invalid Cover</p>\n      </div>\n    </div>\n\n    <div v-if=\"!hasCover\" class=\"absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10\" :style=\"{ padding: placeholderCoverPadding + 'rem' }\">\n      <div>\n        <p class=\"text-center\" style=\"color: rgb(247 223 187)\" :style=\"{ fontSize: titleFontSize + 'rem' }\">{{ titleCleaned }}</p>\n      </div>\n    </div>\n    <div v-if=\"!hasCover\" class=\"absolute left-0 right-0 w-full flex items-center justify-center z-10\" :style=\"{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }\">\n      <p class=\"text-center\" style=\"color: rgb(247 223 187); opacity: 0.75\" :style=\"{ fontSize: authorFontSize + 'rem' }\">{{ authorCleaned }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    width: {\n      type: Number,\n      default: 120\n    },\n    expandOnClick: Boolean,\n    bookCoverAspectRatio: Number\n  },\n  data() {\n    return {\n      loading: true,\n      imageFailed: false,\n      showCoverBg: false,\n      imageReady: false\n    }\n  },\n  watch: {\n    cover() {\n      this.imageFailed = false\n    }\n  },\n  computed: {\n    squareAspectRatio() {\n      return this.bookCoverAspectRatio === 1\n    },\n    height() {\n      return this.width * this.bookCoverAspectRatio\n    },\n    media() {\n      if (!this.libraryItem) return {}\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    title() {\n      return this.mediaMetadata.title || 'No Title'\n    },\n    titleCleaned() {\n      if (this.title.length > 60) {\n        return this.title.slice(0, 57) + '...'\n      }\n      return this.title\n    },\n    authors() {\n      return this.mediaMetadata.authors || []\n    },\n    author() {\n      return this.authors.map((au) => au.name).join(', ')\n    },\n    authorCleaned() {\n      if (this.author.length > 30) {\n        return this.author.slice(0, 27) + '...'\n      }\n      return this.author\n    },\n    placeholderUrl() {\n      const store = this.$store || this.$nuxt.$store\n      return store.getters['globals/getPlaceholderCoverSrc']\n    },\n    fullCoverUrl() {\n      if (!this.libraryItem) return null\n      const store = this.$store || this.$nuxt.$store\n      return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)\n    },\n    rawCoverUrl() {\n      if (!this.libraryItem) return null\n      const store = this.$store || this.$nuxt.$store\n      return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, true)\n    },\n    cover() {\n      return this.media.coverPath || this.placeholderUrl\n    },\n    hasCover() {\n      return !!this.media.coverPath\n    },\n    sizeMultiplier() {\n      var baseSize = this.squareAspectRatio ? 192 : 120\n      return this.width / baseSize\n    },\n    titleFontSize() {\n      return 0.75 * this.sizeMultiplier\n    },\n    authorFontSize() {\n      return 0.6 * this.sizeMultiplier\n    },\n    placeholderCoverPadding() {\n      return 0.8 * this.sizeMultiplier\n    },\n    authorBottom() {\n      return 0.75 * this.sizeMultiplier\n    },\n    resolution() {\n      return `${this.naturalWidth}x${this.naturalHeight}px`\n    }\n  },\n  methods: {\n    clickCover() {\n      if (this.expandOnClick && this.libraryItem) {\n        this.$store.commit('globals/setRawCoverPreviewModal', this.rawCoverUrl)\n      }\n    },\n    setCoverBg() {\n      if (this.$refs.coverBg) {\n        this.$refs.coverBg.style.backgroundImage = `url(\"${this.fullCoverUrl}\")`\n      }\n    },\n    imageLoaded() {\n      this.loading = false\n      this.$nextTick(() => {\n        this.imageReady = true\n      })\n\n      if (this.$refs.cover && this.cover !== this.placeholderUrl) {\n        var { naturalWidth, naturalHeight } = this.$refs.cover\n        var aspectRatio = naturalHeight / naturalWidth\n        var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)\n\n        // If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit\n        if (arDiff > 0.15) {\n          this.showCoverBg = true\n          this.$nextTick(this.setCoverBg)\n        } else {\n          this.showCoverBg = false\n        }\n      }\n    },\n    imageError(err) {\n      this.loading = false\n      console.error('ImgError', err)\n      this.imageFailed = true\n    }\n  },\n  mounted() {}\n}\n</script>\n\n"
  },
  {
    "path": "client/components/covers/CollectionCover.vue",
    "content": "<template>\n  <div class=\"relative rounded-xs overflow-hidden\" :style=\"{ width: width + 'px', height: height + 'px' }\">\n    <!-- <div class=\"absolute top-0 left-0 w-full h-full rounded-xs overflow-hidden z-10\">\n      <div class=\"w-full h-full border border-white/10\" />\n    </div> -->\n\n    <div v-if=\"hasOwnCover\" class=\"w-full h-full relative rounded-xs\">\n      <div v-if=\"showCoverBg\" class=\"bg-primary absolute top-0 left-0 w-full h-full\">\n        <div class=\"w-full h-full z-0\" ref=\"coverBg\" />\n      </div>\n      <img ref=\"cover\" :src=\"fullCoverUrl\" @error=\"imageError\" @load=\"imageLoaded\" class=\"w-full h-full absolute top-0 left-0\" :class=\"showCoverBg ? 'object-contain' : 'object-cover'\" />\n    </div>\n    <div v-else-if=\"books.length\" class=\"flex justify-center h-full relative bg-primary/95 rounded-xs\">\n      <div class=\"absolute top-0 left-0 w-full h-full bg-gray-400/5\" />\n\n      <covers-book-cover :library-item=\"books[0]\" :width=\"width / 2\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n      <covers-book-cover v-if=\"books.length > 1\" :library-item=\"books[1]\" :width=\"width / 2\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n    </div>\n    <div v-else class=\"relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-xs\">\n      <div class=\"absolute top-0 left-0 w-full h-full bg-gray-400/5\" />\n\n      <p class=\"text-white/60 text-center\" :style=\"{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }\">Empty Collection</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    bookItems: {\n      type: Array,\n      default: () => []\n    },\n    width: Number,\n    height: Number,\n    bookCoverAspectRatio: Number\n  },\n  data() {\n    return {\n      imageFailed: false,\n      showCoverBg: false\n    }\n  },\n  computed: {\n    sizeMultiplier() {\n      if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)\n      return this.width / 240\n    },\n    hasOwnCover() {\n      return false\n    },\n    fullCoverUrl() {\n      return null\n    },\n    books() {\n      return this.bookItems || []\n    }\n  },\n  methods: {\n    imageError() {},\n    imageLoaded() {}\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/covers/GroupCover.vue",
    "content": "<template>\n  <div ref=\"wrapper\" :style=\"{ height: height + 'px', width: width + 'px' }\" class=\"relative\">\n    <div v-if=\"noValidCovers\" class=\"absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book\" :style=\"{ padding: `${sizeMultiplier}rem` }\">\n      <p :style=\"{ fontSize: sizeMultiplier + 'rem' }\">{{ name }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    id: String,\n    name: String,\n    bookItems: {\n      type: Array,\n      default: () => []\n    },\n    width: Number,\n    height: Number,\n    bookCoverAspectRatio: Number\n  },\n  data() {\n    return {\n      noValidCovers: false,\n      coverDiv: null,\n      isHovering: false,\n      coverWrapperEl: null,\n      coverImageEls: [],\n      coverWidth: 0,\n      offsetIncrement: 0,\n      isFannedOut: false,\n      isDetached: false,\n      isAttaching: false,\n      isInit: false\n    }\n  },\n  watch: {\n    bookItems: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) {\n          // ensure wrapper is initialized\n          this.$nextTick(this.init)\n        }\n      }\n    },\n    width: {\n      handler(newVal) {\n        if (newVal) {\n          this.isInit = false\n          this.$nextTick(this.init)\n        }\n      }\n    }\n  },\n  computed: {\n    sizeMultiplier() {\n      if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)\n      return this.width / 240\n    },\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    router() {\n      return this.$router || this.$nuxt.$router\n    }\n  },\n  methods: {\n    getCoverUrl(book) {\n      return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')\n    },\n    async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {\n      var src = coverData.coverUrl\n\n      var showCoverBg =\n        forceCoverBg ||\n        (await new Promise((resolve) => {\n          var image = new Image()\n\n          image.onload = () => {\n            var { naturalWidth, naturalHeight } = image\n            var aspectRatio = naturalHeight / naturalWidth\n            var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)\n\n            // If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit\n            if (arDiff > 0.15) {\n              resolve(true)\n            } else {\n              resolve(false)\n            }\n          }\n          image.onerror = (err) => {\n            console.error(err)\n            resolve(false)\n          }\n          image.src = src\n        }))\n\n      var imgdiv = document.createElement('div')\n      imgdiv.style.height = this.height + 'px'\n      imgdiv.style.width = bgCoverWidth + 'px'\n      imgdiv.style.left = offsetLeft + 'px'\n      imgdiv.style.zIndex = zIndex\n      imgdiv.dataset.audiobookId = coverData.id\n      imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''\n      imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'\n      imgdiv.style.boxShadow = '4px 0px 4px #11111166'\n      // imgdiv.style.transform = 'skew(0deg, 15deg)'\n\n      if (showCoverBg) {\n        var coverbgwrapper = document.createElement('div')\n        coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary'\n\n        var coverbg = document.createElement('div')\n        coverbg.className = 'absolute cover-bg'\n        coverbg.style.backgroundImage = `url(\"${src}\")`\n\n        coverbgwrapper.appendChild(coverbg)\n        imgdiv.appendChild(coverbgwrapper)\n      }\n\n      var img = document.createElement('img')\n      img.src = src\n      img.alt = `${this.name}, ${this.$strings.LabelCover}`\n      img.ariaHidden = true\n      img.className = 'absolute top-0 left-0 w-full h-full'\n      img.style.objectFit = showCoverBg ? 'contain' : 'cover'\n\n      imgdiv.appendChild(img)\n      return imgdiv\n    },\n    createSeriesNameCover(offsetLeft) {\n      var imgdiv = document.createElement('div')\n      imgdiv.style.height = this.height + 'px'\n      imgdiv.style.width = this.height / this.bookCoverAspectRatio + 'px'\n      imgdiv.style.left = offsetLeft + 'px'\n      imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'\n      imgdiv.style.boxShadow = '4px 0px 4px #11111166'\n      imgdiv.style.backgroundColor = '#111'\n\n      var innerP = document.createElement('p')\n      innerP.textContent = this.name\n      innerP.className = 'text-sm text-white'\n      imgdiv.appendChild(innerP)\n\n      return imgdiv\n    },\n    async init() {\n      if (this.isInit) return\n      this.isInit = true\n\n      if (this.coverDiv) {\n        this.coverDiv.remove()\n        this.coverDiv = null\n      }\n      var validCovers = this.bookItems\n        .map((bookItem) => {\n          return {\n            id: bookItem.id,\n            coverUrl: this.getCoverUrl(bookItem)\n          }\n        })\n        .filter((b) => b.coverUrl !== '')\n      if (!validCovers.length) {\n        this.noValidCovers = true\n        return\n      }\n      this.noValidCovers = false\n\n      validCovers = validCovers.slice(0, 10)\n\n      var coverWidth = this.width\n      var widthPer = this.width\n      if (validCovers.length > 1) {\n        coverWidth = this.height / this.bookCoverAspectRatio\n        widthPer = (this.width - coverWidth) / (validCovers.length - 1)\n      }\n      this.coverWidth = coverWidth\n      this.offsetIncrement = widthPer\n\n      var outerdiv = document.createElement('div')\n      outerdiv.id = `group-cover-${this.id}`\n      this.coverWrapperEl = outerdiv\n      outerdiv.className = 'w-full h-full relative box-shadow-book'\n\n      var coverImageEls = []\n      var offsetLeft = 0\n      for (let i = 0; i < validCovers.length; i++) {\n        offsetLeft = widthPer * i\n        var zIndex = validCovers.length - i\n        var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)\n        outerdiv.appendChild(img)\n        coverImageEls.push(img)\n      }\n\n      this.coverImageEls = coverImageEls\n\n      if (this.$refs.wrapper) {\n        this.coverDiv = outerdiv\n        this.$refs.wrapper.appendChild(outerdiv)\n      }\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    if (this.coverWrapperEl) this.coverWrapperEl.remove()\n    if (this.coverImageEls && this.coverImageEls.length) {\n      this.coverImageEls.forEach((el) => el.remove())\n    }\n    if (this.coverDiv) this.coverDiv.remove()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/covers/PlaylistCover.vue",
    "content": "<template>\n  <div class=\"relative rounded-xs overflow-hidden\" :style=\"{ width: width + 'px', height: height + 'px' }\">\n    <div v-if=\"items.length\" class=\"flex flex-wrap justify-center h-full relative bg-primary/95 rounded-xs\">\n      <div class=\"absolute top-0 left-0 w-full h-full bg-gray-400/5\" />\n      <covers-book-cover v-for=\"(li, index) in libraryItemCovers\" :key=\"index\" :library-item=\"li\" :width=\"itemCoverWidth\" :book-cover-aspect-ratio=\"1\" />\n    </div>\n    <div v-else class=\"relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-xs\">\n      <div class=\"absolute top-0 left-0 w-full h-full bg-gray-400/5\" />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    items: {\n      type: Array,\n      default: () => []\n    },\n    width: Number,\n    height: Number\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    sizeMultiplier() {\n      return this.width / (120 * 1.6 * 2)\n    },\n    itemCoverWidth() {\n      if (this.libraryItemCovers.length === 1) return this.width\n      return this.width / 2\n    },\n    libraryItemCovers() {\n      if (!this.items.length) return []\n      if (this.items.length === 1) return [this.items[0].libraryItem]\n\n      const covers = []\n      for (let i = 0; i < 4; i++) {\n        let index = i % this.items.length\n        if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern\n\n        covers.push(this.items[index].libraryItem)\n      }\n      return covers\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/covers/PreviewCover.vue",
    "content": "<template>\n  <div class=\"relative rounded-xs\" :style=\"{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }\" @mouseover=\"isHovering = true\" @mouseleave=\"isHovering = false\">\n    <div class=\"w-full h-full relative overflow-hidden\">\n      <div v-show=\"showCoverBg\" class=\"absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary\">\n        <div class=\"absolute cover-bg\" ref=\"coverBg\" />\n      </div>\n      <img ref=\"cover\" :src=\"cover\" @error=\"imageError\" @load=\"imageLoaded\" class=\"w-full h-full absolute top-0 left-0\" :class=\"showCoverBg ? 'object-contain' : 'object-fill'\" />\n\n      <a v-if=\"!imageFailed && showOpenNewTab && isHovering\" :href=\"cover\" @click.stop target=\"_blank\" class=\"absolute bg-primary flex items-center justify-center shadow-xs rounded-full hover:scale-110 transform duration-100\" :style=\"{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }\">\n        <span class=\"material-symbols\" :style=\"{ fontSize: sizeMultiplier * 1.75 + 'rem' }\">open_in_new</span>\n      </a>\n    </div>\n\n    <div v-if=\"imageFailed\" class=\"absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100\" :style=\"{ padding: placeholderCoverPadding + 'rem' }\">\n      <div class=\"w-full h-full border-2 border-error flex flex-col items-center justify-center\">\n        <img v-if=\"width > 100\" src=\"/Logo.png\" class=\"mb-2\" :style=\"{ height: 40 * sizeMultiplier + 'px' }\" />\n        <p class=\"text-center text-error\" :style=\"{ fontSize: invalidCoverFontSize + 'rem' }\">Invalid Cover</p>\n      </div>\n    </div>\n\n    <p v-if=\"!imageFailed && showResolution && resolution\" class=\"absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center\">{{ resolution }}</p>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    src: String,\n    width: {\n      type: Number,\n      default: 120\n    },\n    showOpenNewTab: Boolean,\n    bookCoverAspectRatio: Number,\n    showResolution: {\n      type: Boolean,\n      default: true\n    }\n  },\n  data() {\n    return {\n      imageFailed: false,\n      showCoverBg: false,\n      isHovering: false,\n      naturalHeight: 0,\n      naturalWidth: 0\n    }\n  },\n  watch: {\n    cover() {\n      this.imageFailed = false\n    }\n  },\n  computed: {\n    cover() {\n      return this.src\n    },\n    sizeMultiplier() {\n      return this.width / 120\n    },\n    invalidCoverFontSize() {\n      return Math.max(this.sizeMultiplier * 0.8, 0.5)\n    },\n    placeholderCoverPadding() {\n      return 0.8 * this.sizeMultiplier\n    },\n    resolution() {\n      if (!this.naturalWidth || !this.naturalHeight) return null\n      return `${this.naturalWidth}×${this.naturalHeight}px`\n    },\n    placeholderUrl() {\n      const store = this.$store || this.$nuxt.$store\n      return store.getters['globals/getPlaceholderCoverSrc']\n    }\n  },\n  methods: {\n    setCoverBg() {\n      if (this.$refs.coverBg) {\n        this.$refs.coverBg.style.backgroundImage = `url(\"${this.src}\")`\n      }\n    },\n    imageLoaded() {\n      if (this.$refs.cover && this.src !== this.placeholderUrl) {\n        var { naturalWidth, naturalHeight } = this.$refs.cover\n        this.naturalHeight = naturalHeight\n        this.naturalWidth = naturalWidth\n\n        var aspectRatio = naturalHeight / naturalWidth\n        var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)\n\n        // If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit\n        if (arDiff > 0.15) {\n          this.showCoverBg = true\n          this.$nextTick(this.setCoverBg)\n        } else {\n          this.showCoverBg = false\n        }\n      }\n    },\n    imageError(err) {\n      console.error('ImgError', err)\n      this.imageFailed = true\n    }\n  },\n  mounted() {}\n}\n</script>"
  },
  {
    "path": "client/components/modals/AccountModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"account\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n        <div class=\"w-full p-8\">\n          <div class=\"flex py-2\">\n            <div class=\"w-1/2 px-2\">\n              <ui-text-input-with-label v-model.trim=\"newUser.username\" :label=\"$strings.LabelUsername\" />\n            </div>\n            <div class=\"w-1/2 px-2\">\n              <ui-text-input-with-label v-if=\"!isEditingRoot\" v-model=\"newUser.password\" :label=\"isNew ? $strings.LabelPassword : $strings.LabelChangePassword\" type=\"password\" />\n              <ui-text-input-with-label v-else v-model.trim=\"newUser.email\" :label=\"$strings.LabelEmail\" />\n            </div>\n          </div>\n          <div v-show=\"!isEditingRoot\" class=\"flex py-2\">\n            <div class=\"w-1/2 px-2\">\n              <ui-text-input-with-label v-model.trim=\"newUser.email\" :label=\"$strings.LabelEmail\" />\n            </div>\n            <div class=\"px-2 w-52\">\n              <ui-dropdown v-model=\"newUser.type\" :label=\"$strings.LabelAccountType\" :disabled=\"isEditingRoot\" :items=\"accountTypes\" small @input=\"userTypeUpdated\" />\n            </div>\n\n            <div class=\"flex items-center pt-4 px-2\">\n              <p class=\"px-3 font-semibold\" id=\"user-enabled-toggle\" :class=\"isEditingRoot ? 'text-gray-300' : ''\">{{ $strings.LabelEnable }}</p>\n              <ui-toggle-switch labeledBy=\"user-enabled-toggle\" v-model=\"newUser.isActive\" :disabled=\"isEditingRoot\" />\n            </div>\n          </div>\n\n          <div v-if=\"!isEditingRoot && newUser.permissions\" class=\"w-full border-t border-b border-black-200 py-2 px-3 mt-4\">\n            <p class=\"text-lg mb-2 font-semibold\">{{ $strings.HeaderPermissions }}</p>\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"download-permissions-toggle\">{{ $strings.LabelPermissionsDownload }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"download-permissions-toggle\" v-model=\"newUser.permissions.download\" />\n              </div>\n            </div>\n\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"update-permissions-toggle\">{{ $strings.LabelPermissionsUpdate }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"update-permissions-toggle\" v-model=\"newUser.permissions.update\" />\n              </div>\n            </div>\n\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"delete-permissions-toggle\">{{ $strings.LabelPermissionsDelete }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"delete-permissions-toggle\" v-model=\"newUser.permissions.delete\" />\n              </div>\n            </div>\n\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"upload-permissions-toggle\">{{ $strings.LabelPermissionsUpload }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"upload-permissions-toggle\" v-model=\"newUser.permissions.upload\" />\n              </div>\n            </div>\n\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"ereader-permissions-toggle\">{{ $strings.LabelPermissionsCreateEreader }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"ereader-permissions-toggle\" v-model=\"newUser.permissions.createEreader\" />\n              </div>\n            </div>\n\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"explicit-content-permissions-toggle\">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"explicit-content-permissions-toggle\" v-model=\"newUser.permissions.accessExplicitContent\" />\n              </div>\n            </div>\n\n            <div class=\"flex items-center my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p id=\"access-all-libs--permissions-toggle\">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch labeledBy=\"access-all-libs--permissions-toggle\" v-model=\"newUser.permissions.accessAllLibraries\" @input=\"accessAllLibrariesToggled\" />\n              </div>\n            </div>\n\n            <div v-if=\"!newUser.permissions.accessAllLibraries\" class=\"my-4\">\n              <ui-multi-select-dropdown v-model=\"newUser.librariesAccessible\" :items=\"libraryItems\" :label=\"$strings.LabelLibrariesAccessibleToUser\" />\n            </div>\n\n            <div class=\"flex items-cen~ter my-2 max-w-md\">\n              <div class=\"w-1/2\">\n                <p>{{ $strings.LabelPermissionsAccessAllTags }}</p>\n              </div>\n              <div class=\"w-1/2\">\n                <ui-toggle-switch v-model=\"newUser.permissions.accessAllTags\" @input=\"accessAllTagsToggled\" />\n              </div>\n            </div>\n            <div v-if=\"!newUser.permissions.accessAllTags\" class=\"my-4\">\n              <div class=\"flex items-center\">\n                <ui-multi-select-dropdown v-model=\"newUser.itemTagsSelected\" :items=\"itemTags\" :label=\"tagsSelectionText\" />\n                <div class=\"flex items-center pt-4 px-2\">\n                  <p class=\"px-3 font-semibold\" id=\"selected-tags-not-accessible--permissions-toggle\">{{ $strings.LabelInvert }}</p>\n                  <ui-toggle-switch labeledBy=\"selected-tags-not-accessible--permissions-toggle\" v-model=\"newUser.permissions.selectedTagsNotAccessible\" />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"flex pt-4 px-2\">\n            <ui-btn v-if=\"hasOpenIDLink\" small :loading=\"unlinkingFromOpenID\" color=\"bg-primary\" type=\"button\" class=\"mr-2\" @click.stop=\"unlinkOpenID\">{{ $strings.ButtonUnlinkOpenId }}</ui-btn>\n            <ui-btn v-if=\"isEditingRoot\" small class=\"flex items-center\" to=\"/account\">{{ $strings.ButtonChangeRootPassword }}</ui-btn>\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    account: {\n      type: Object,\n      default: () => null\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      newUser: {},\n      isNew: true,\n      tags: [],\n      loadingTags: false,\n      unlinkingFromOpenID: false\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    accountTypes() {\n      return [\n        {\n          text: this.$strings.LabelAccountTypeGuest,\n          value: 'guest'\n        },\n        {\n          text: this.$strings.LabelAccountTypeUser,\n          value: 'user'\n        },\n        {\n          text: this.$strings.LabelAccountTypeAdmin,\n          value: 'admin'\n        }\n      ]\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    title() {\n      return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount\n    },\n    isEditingRoot() {\n      return this.account?.type === 'root'\n    },\n    libraries() {\n      return this.$store.state.libraries.libraries\n    },\n    libraryItems() {\n      return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))\n    },\n    itemTags() {\n      return this.tags.map((t) => {\n        return {\n          text: t,\n          value: t\n        }\n      })\n    },\n    tagsSelectionText() {\n      return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser\n    },\n    hasOpenIDLink() {\n      return !!this.account?.hasOpenIDLink\n    }\n  },\n  methods: {\n    close() {\n      // Force close when navigating - used in UsersTable\n      if (this.$refs.modal) this.$refs.modal.setHide()\n    },\n    unlinkOpenID() {\n      const payload = {\n        message: this.$strings.MessageConfirmUnlinkOpenId,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.unlinkingFromOpenID = true\n            this.$axios\n              .$patch(`/api/users/${this.account.id}/openid-unlink`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastUnlinkOpenIdSuccess)\n                this.show = false\n              })\n              .catch((error) => {\n                console.error('Failed to unlink user from OpenID', error)\n                this.$toast.error(this.$strings.ToastUnlinkOpenIdFailed)\n              })\n              .finally(() => {\n                this.unlinkingFromOpenID = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    accessAllTagsToggled(val) {\n      if (val) {\n        if (this.newUser.itemTagsSelected?.length) {\n          this.newUser.itemTagsSelected = []\n        }\n        this.newUser.permissions.selectedTagsNotAccessible = false\n      }\n    },\n    fetchAllTags() {\n      this.loadingTags = true\n      this.$axios\n        .$get(`/api/tags`)\n        .then((res) => {\n          this.tags = res.tags\n          this.loadingTags = false\n        })\n        .catch((error) => {\n          console.error('Failed to load tags', error)\n          this.loadingTags = false\n        })\n    },\n    accessAllLibrariesToggled(val) {\n      if (!val && !this.newUser.librariesAccessible.length) {\n        this.newUser.librariesAccessible = this.libraries.map((l) => l.id)\n      } else if (val && this.newUser.librariesAccessible.length) {\n        this.newUser.librariesAccessible = []\n      }\n    },\n    submitForm() {\n      if (!this.newUser.username) {\n        this.$toast.error(this.$strings.ToastNewUserUsernameError)\n        return\n      }\n      if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {\n        this.$toast.error(this.$strings.ToastNewUserLibraryError)\n        return\n      }\n      if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {\n        this.$toast.error(this.$strings.ToastNewUserTagError)\n        return\n      }\n\n      if (this.isNew) {\n        this.submitCreateAccount()\n      } else {\n        this.submitUpdateAccount()\n      }\n    },\n    submitUpdateAccount() {\n      var account = { ...this.newUser }\n      if (!account.password || account.type === 'root') {\n        delete account.password\n      }\n      if (account.type === 'root' && !account.isActive) return\n\n      this.processing = true\n      this.$axios\n        .$patch(`/api/users/${this.account.id}`, account)\n        .then((data) => {\n          this.processing = false\n          if (data.error) {\n            this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)\n          } else {\n            console.log('Account updated', data.user)\n\n            if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {\n              console.log('Current user access token was updated')\n              this.$store.commit('user/setAccessToken', data.user.accessToken)\n            }\n\n            this.$toast.success(this.$strings.ToastAccountUpdateSuccess)\n            this.show = false\n          }\n        })\n        .catch((error) => {\n          this.processing = false\n          console.error('Failed to update account', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)\n        })\n    },\n    submitCreateAccount() {\n      if (!this.newUser.password) {\n        this.$toast.error(this.$strings.ToastNewUserPasswordError)\n        return\n      }\n\n      var account = { ...this.newUser }\n      this.processing = true\n      this.$axios\n        .$post('/api/users', account)\n        .then((data) => {\n          this.processing = false\n          if (data.error) {\n            this.$toast.error(this.$strings.ToastNewUserCreatedFailed + ': ' + data.error)\n          } else {\n            this.$toast.success(this.$strings.ToastNewUserCreatedSuccess)\n            this.show = false\n          }\n        })\n        .catch((error) => {\n          this.processing = false\n          console.error('Failed to create account', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || 'Failed to create account')\n        })\n    },\n    userTypeUpdated(type) {\n      this.newUser.permissions = {\n        download: type !== 'guest',\n        update: type === 'admin',\n        delete: type === 'admin',\n        upload: type === 'admin',\n        accessExplicitContent: type === 'admin',\n        accessAllLibraries: true,\n        accessAllTags: true,\n        selectedTagsNotAccessible: false,\n        createEreader: type === 'admin'\n      }\n    },\n    init() {\n      this.fetchAllTags()\n      this.isNew = !this.account\n\n      if (this.account) {\n        this.newUser = {\n          username: this.account.username,\n          email: this.account.email,\n          password: this.account.password,\n          type: this.account.type,\n          isActive: this.account.isActive,\n          permissions: { ...this.account.permissions },\n          librariesAccessible: [...(this.account.librariesAccessible || [])],\n          itemTagsSelected: [...(this.account.itemTagsSelected || [])]\n        }\n      } else {\n        this.newUser = {\n          username: null,\n          email: null,\n          password: null,\n          type: 'user',\n          isActive: true,\n          permissions: {\n            download: true,\n            update: false,\n            delete: false,\n            upload: false,\n            accessAllLibraries: true,\n            accessAllTags: true,\n            accessExplicitContent: false,\n            selectedTagsNotAccessible: false,\n            createEreader: false\n          },\n          librariesAccessible: [],\n          itemTagsSelected: []\n        }\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/AddCustomMetadataProviderModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"custom-metadata-provider\" :width=\"600\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.HeaderAddCustomMetadataProvider }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n        <div class=\"w-full p-8\">\n          <div class=\"flex mb-2\">\n            <div class=\"w-3/4 p-1\">\n              <ui-text-input-with-label v-model=\"newName\" :label=\"$strings.LabelName\" trim-whitespace />\n            </div>\n            <div class=\"w-1/4 p-1\">\n              <ui-text-input-with-label value=\"Book\" readonly :label=\"$strings.LabelMediaType\" />\n            </div>\n          </div>\n          <div class=\"w-full mb-2 p-1\">\n            <ui-text-input-with-label v-model=\"newUrl\" label=\"URL\" trim-whitespace />\n          </div>\n          <div class=\"w-full mb-2 p-1\">\n            <ui-text-input-with-label v-model=\"newAuthHeaderValue\" :label=\"$strings.LabelProviderAuthorizationValue\" type=\"password\" />\n          </div>\n          <div class=\"flex px-1 pt-4\">\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonAdd }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean\n  },\n  data() {\n    return {\n      processing: false,\n      newName: '',\n      newUrl: '',\n      newAuthHeaderValue: ''\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    async submitForm() {\n      // Remove focus from active input\n      document.activeElement?.blur?.()\n      await this.$nextTick()\n\n      if (!this.newName || !this.newUrl) {\n        this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)\n        return\n      }\n\n      this.processing = true\n      this.$axios\n        .$post('/api/custom-metadata-providers', {\n          name: this.newName,\n          url: this.newUrl,\n          mediaType: 'book', // Currently only supporting book mediaType\n          authHeaderValue: this.newAuthHeaderValue\n        })\n        .then((data) => {\n          this.$emit('added', data.provider)\n          this.$toast.success(this.$strings.ToastProviderCreatedSuccess)\n          this.show = false\n        })\n        .catch((error) => {\n          const errorMsg = error.response?.data || 'Unknown error'\n          console.error('Failed to add provider', error)\n          this.$toast.error(this.$strings.ToastProviderCreatedFailed + ': ' + errorMsg)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    init() {\n      this.processing = false\n      this.newName = ''\n      this.newUrl = ''\n      this.newAuthHeaderValue = ''\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/ApiKeyCreatedModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"api-key-created\" :width=\"800\" :height=\"'unset'\" persistent>\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden\" style=\"min-height: 200px; max-height: 80vh\">\n        <div class=\"w-full p-8\">\n          <p class=\"text-lg text-white mb-4\">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>\n\n          <p class=\"text-lg text-white mb-4\">{{ $strings.LabelApiKeyCreatedDescription }}</p>\n\n          <ui-text-input label=\"API Key\" :value=\"apiKeyKey\" readonly show-copy />\n\n          <div class=\"flex justify-end mt-4\">\n            <ui-btn color=\"bg-primary\" @click=\"show = false\">{{ $strings.ButtonClose }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    apiKey: {\n      type: Object,\n      default: () => null\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      return this.$strings.HeaderNewApiKey\n    },\n    apiKeyName() {\n      return this.apiKey?.name || ''\n    },\n    apiKeyKey() {\n      return this.apiKey?.apiKey || ''\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/ApiKeyModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"api-key\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n        <div class=\"w-full p-8\">\n          <div class=\"flex py-2\">\n            <div class=\"w-1/2 px-2\">\n              <ui-text-input-with-label v-model.trim=\"newApiKey.name\" :readonly=\"!isNew\" :label=\"$strings.LabelName\" />\n            </div>\n            <div v-if=\"isNew\" class=\"w-1/2 px-2\">\n              <ui-text-input-with-label v-model.trim=\"newApiKey.expiresIn\" :label=\"$strings.LabelExpiresInSeconds\" type=\"number\" :min=\"0\" />\n            </div>\n          </div>\n          <div class=\"flex items-center pt-4 pb-2 gap-2\">\n            <div class=\"flex items-center px-2\">\n              <p class=\"px-3 font-semibold\" id=\"user-enabled-toggle\">{{ $strings.LabelEnable }}</p>\n              <ui-toggle-switch :disabled=\"isExpired && !apiKey.isActive\" labeledBy=\"user-enabled-toggle\" v-model=\"newApiKey.isActive\" />\n            </div>\n            <div v-if=\"isExpired\" class=\"px-2\">\n              <p class=\"text-sm text-error\">{{ $strings.LabelExpired }}</p>\n            </div>\n          </div>\n\n          <div class=\"w-full border-t border-b border-black-200 py-4 px-3 mt-4\">\n            <p class=\"text-lg mb-2 font-semibold\">{{ $strings.LabelApiKeyUser }}</p>\n            <p class=\"text-sm mb-2 text-gray-400\">{{ $strings.LabelApiKeyUserDescription }}</p>\n            <ui-select-input v-model=\"newApiKey.userId\" :disabled=\"isExpired && !apiKey.isActive\" :items=\"userItems\" :placeholder=\"$strings.LabelSelectUser\" :label=\"$strings.LabelApiKeyUser\" label-hidden />\n          </div>\n\n          <div class=\"flex pt-4 px-2\">\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    apiKey: {\n      type: Object,\n      default: () => null\n    },\n    users: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      newApiKey: {},\n      isNew: true\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey\n    },\n    userItems() {\n      return this.users\n        .filter((u) => {\n          // Only show root user if the current user is root\n          return u.type !== 'root' || this.$store.getters['user/getIsRoot']\n        })\n        .map((u) => ({ text: u.username, value: u.id, subtext: u.type }))\n    },\n    isExpired() {\n      if (!this.apiKey || !this.apiKey.expiresAt) return false\n\n      return new Date(this.apiKey.expiresAt).getTime() < Date.now()\n    }\n  },\n  methods: {\n    submitForm() {\n      if (!this.newApiKey.name) {\n        this.$toast.error(this.$strings.ToastNameRequired)\n        return\n      }\n\n      if (!this.newApiKey.userId) {\n        this.$toast.error(this.$strings.ToastNewApiKeyUserError)\n        return\n      }\n\n      if (this.isNew) {\n        this.submitCreateApiKey()\n      } else {\n        this.submitUpdateApiKey()\n      }\n    },\n    submitUpdateApiKey() {\n      if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {\n        this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n        this.show = false\n        return\n      }\n\n      const apiKey = {\n        isActive: this.newApiKey.isActive,\n        userId: this.newApiKey.userId\n      }\n\n      this.processing = true\n      this.$axios\n        .$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)\n        .then((data) => {\n          this.processing = false\n          if (data.error) {\n            this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)\n          } else {\n            this.show = false\n            this.$emit('updated', data.apiKey)\n          }\n        })\n        .catch((error) => {\n          this.processing = false\n          console.error('Failed to update apiKey', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)\n        })\n    },\n    submitCreateApiKey() {\n      const apiKey = { ...this.newApiKey }\n\n      if (this.newApiKey.expiresIn) {\n        apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)\n      } else {\n        delete apiKey.expiresIn\n      }\n\n      this.processing = true\n      this.$axios\n        .$post('/api/api-keys', apiKey)\n        .then((data) => {\n          this.processing = false\n          if (data.error) {\n            this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)\n          } else {\n            this.show = false\n            this.$emit('created', data.apiKey)\n          }\n        })\n        .catch((error) => {\n          this.processing = false\n          console.error('Failed to create apiKey', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)\n        })\n    },\n    init() {\n      this.isNew = !this.apiKey\n\n      if (this.apiKey) {\n        this.newApiKey = {\n          name: this.apiKey.name,\n          isActive: this.apiKey.isActive,\n          userId: this.apiKey.userId\n        }\n      } else {\n        this.newApiKey = {\n          name: null,\n          expiresIn: null,\n          isActive: true,\n          userId: null\n        }\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/AudioFileDataModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"audiofile-data-modal\" :width=\"700\" :height=\"'unset'\">\n    <div v-if=\"audioFile\" ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6\" style=\"max-height: 80vh\">\n      <div class=\"flex items-center justify-between\">\n        <p class=\"text-base text-gray-200 truncate\">{{ metadata.filename }}</p>\n        <ui-btn v-if=\"ffprobeData\" small class=\"ml-2\" @click=\"ffprobeData = null\">{{ $strings.ButtonReset }}</ui-btn>\n        <ui-btn v-else-if=\"userIsAdminOrUp\" small :loading=\"probingFile\" class=\"ml-2\" @click=\"getFFProbeData\">{{ $strings.ButtonProbeAudioFile }}</ui-btn>\n      </div>\n\n      <div class=\"w-full h-px bg-white/10 my-4\" />\n\n      <template v-if=\"!ffprobeData\">\n        <ui-text-input-with-label :value=\"metadata.path\" readonly :label=\"$strings.LabelPath\" class=\"mb-4 text-sm\" />\n\n        <div class=\"flex flex-col sm:flex-row text-sm\">\n          <div class=\"w-full sm:w-1/2\">\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelSize }}\n              </p>\n              <p>{{ $bytesPretty(metadata.size) }}</p>\n            </div>\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelDuration }}\n              </p>\n              <p>{{ $secondsToTimestamp(audioFile.duration) }}</p>\n            </div>\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">{{ $strings.LabelFormat }}</p>\n              <p>{{ audioFile.format }}</p>\n            </div>\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelChapters }}\n              </p>\n              <p>{{ audioFile.chapters?.length || 0 }}</p>\n            </div>\n            <div v-if=\"audioFile.embeddedCoverArt\" class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelEmbeddedCover }}\n              </p>\n              <p>{{ audioFile.embeddedCoverArt || '' }}</p>\n            </div>\n          </div>\n          <div class=\"w-full sm:w-1/2\">\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelCodec }}\n              </p>\n              <p>{{ audioFile.codec }}</p>\n            </div>\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelChannels }}\n              </p>\n              <p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>\n            </div>\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelBitrate }}\n              </p>\n              <p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>\n            </div>\n            <div class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">{{ $strings.LabelTimeBase }}</p>\n              <p>{{ audioFile.timeBase }}</p>\n            </div>\n            <div v-if=\"audioFile.language\" class=\"flex mb-1\">\n              <p class=\"w-32 text-black-50\">\n                {{ $strings.LabelLanguage }}\n              </p>\n              <p>{{ audioFile.language || '' }}</p>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"w-full h-px bg-white/10 my-4\" />\n\n        <p class=\"font-bold mb-2\">{{ $strings.LabelMetaTags }}</p>\n\n        <div v-for=\"(value, key) in metaTags\" :key=\"key\" class=\"flex mb-1 text-sm\">\n          <p class=\"w-32 min-w-32 text-black-50 mb-1\">\n            {{ key.replace('tag', '') }}\n          </p>\n          <p>{{ value }}</p>\n        </div>\n      </template>\n      <div v-else class=\"w-full\">\n        <div class=\"relative\">\n          <ui-textarea-with-label :value=\"prettyFfprobeData\" readonly :rows=\"30\" class=\"text-xs\" />\n\n          <button class=\"absolute top-4 right-4\" :class=\"hasCopied ? 'text-success' : 'text-gray-400 hover:text-white'\" @click.stop=\"copyToClipboard\">\n            <span class=\"material-symbols\">{{ hasCopied ? 'done' : 'content_copy' }}</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    audioFile: {\n      type: Object,\n      default: () => {}\n    },\n    libraryItemId: String\n  },\n  data() {\n    return {\n      probingFile: false,\n      ffprobeData: null,\n      hasCopied: null\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.ffprobeData = null\n        this.probingFile = false\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    metadata() {\n      return this.audioFile?.metadata || {}\n    },\n    metaTags() {\n      return this.audioFile?.metaTags || {}\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    prettyFfprobeData() {\n      if (!this.ffprobeData) return ''\n      return JSON.stringify(this.ffprobeData, null, 2)\n    }\n  },\n  methods: {\n    getFFProbeData() {\n      this.probingFile = true\n      this.$axios\n        .$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)\n        .then((data) => {\n          console.log('Got ffprobe data', data)\n          this.ffprobeData = data\n        })\n        .catch((error) => {\n          console.error('Failed to get ffprobe data', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n        .finally(() => {\n          this.probingFile = false\n        })\n    },\n    copyToClipboard() {\n      clearTimeout(this.hasCopied)\n      this.$copyToClipboard(this.prettyFfprobeData).then((success) => {\n        this.hasCopied = setTimeout(() => {\n          this.hasCopied = null\n        }, 2000)\n      })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/BackupScheduleModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"backup-scheduler\" :width=\"700\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.HeaderSetBackupSchedule }}</p>\n      </div>\n    </template>\n    <div v-if=\"show && newCronExpression\" class=\"p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n      <widgets-cron-expression-builder ref=\"expressionBuilder\" v-model=\"newCronExpression\" @input=\"expressionUpdated\" />\n\n      <div class=\"flex items-center justify-end\">\n        <ui-btn :disabled=\"!isUpdated\" @click=\"submit\">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    cronExpression: {\n      type: String,\n      default: '* * * * *'\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      newCronExpression: null,\n      isUpdated: false\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    expressionUpdated() {\n      this.isUpdated = this.newCronExpression !== this.cronExpression\n    },\n    init() {\n      this.newCronExpression = this.cronExpression\n      this.isUpdated = false\n    },\n    submit() {\n      // If custom expression input is focused then unfocus it instead of submitting\n      if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {\n        if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {\n          return\n        }\n      }\n\n      this.processing = true\n\n      var updatePayload = {\n        backupSchedule: this.newCronExpression\n      }\n      this.$store\n        .dispatch('updateServerSettings', updatePayload)\n        .then((success) => {\n          console.log('Updated Server Settings', success)\n          this.processing = false\n          this.show = false\n          this.$emit('update:cronExpression', this.newCronExpression)\n        })\n        .catch((error) => {\n          console.error('Failed to update server settings', error)\n          this.processing = false\n        })\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/BatchQuickMatchModel.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"batchQuickMatch\" :processing=\"processing\" :width=\"500\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <div v-if=\"show\" class=\"w-full h-full py-4\">\n        <div class=\"w-full overflow-y-auto overflow-x-hidden max-h-96\">\n          <div class=\"flex px-8 items-center py-2\">\n            <p class=\"pr-4\">{{ $strings.LabelProvider }}</p>\n            <ui-dropdown v-model=\"options.provider\" :items=\"providers\" small />\n          </div>\n          <p class=\"text-base px-8 py-2\">{{ $strings.MessageBatchQuickMatchDescription }}</p>\n          <div class=\"flex px-8 items-end py-2\">\n            <ui-toggle-switch v-model=\"options.overrideCover\" />\n            <ui-tooltip :text=\"$strings.LabelUpdateCoverHelp\">\n              <p class=\"pl-4\">\n                {{ $strings.LabelUpdateCover }}\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n          <div class=\"flex px-8 items-end py-2\">\n            <ui-toggle-switch v-model=\"options.overrideDetails\" />\n            <ui-tooltip :text=\"$strings.LabelUpdateDetailsHelp\">\n              <p class=\"pl-4\">\n                {{ $strings.LabelUpdateDetails }}\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n          <div class=\"mt-4 pt-4 text-white/80 border-t border-white/5\">\n            <div class=\"flex items-center px-4\">\n              <ui-btn type=\"button\" @click=\"show = false\">{{ $strings.ButtonCancel }}</ui-btn>\n              <div class=\"grow\" />\n              <ui-btn color=\"bg-success\" @click=\"doBatchQuickMatch\">{{ $strings.ButtonSubmit }}</ui-btn>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      processing: false,\n      lastUsedLibrary: undefined,\n      options: {\n        provider: undefined,\n        overrideDetails: true,\n        overrideCover: true\n      }\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showBatchQuickMatchModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowBatchQuickMatchModal', val)\n      }\n    },\n    title() {\n      return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])\n    },\n    showBatchQuickMatchModal() {\n      return this.$store.state.globals.showBatchQuickMatchModal\n    },\n    selectedBookIds() {\n      return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    providers() {\n      if (this.isPodcast) return this.$store.state.scanners.podcastProviders\n      return this.$store.state.scanners.bookProviders\n    },\n    libraryProvider() {\n      return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'\n    }\n  },\n  methods: {\n    init() {\n      // Fetch providers when modal is shown\n      this.$store.dispatch('scanners/fetchProviders')\n\n      // If we don't have a set provider (first open of dialog) or we've switched library, set\n      // the selected provider to the current library default provider\n      if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {\n        this.lastUsedLibrary = this.currentLibraryId\n        this.options.provider = this.libraryProvider\n      }\n    },\n    doBatchQuickMatch() {\n      if (!this.selectedBookIds.length) return\n      if (this.processing) return\n\n      this.processing = true\n      this.$store.commit('setProcessingBatch', true)\n      this.$axios\n        .$post(`/api/items/batch/quickmatch`, {\n          options: this.options,\n          libraryItemIds: this.selectedBookIds\n        })\n        .then(() => {\n          this.$toast.info(this.$getString('ToastBatchQuickMatchStarted', [this.selectedBookIds.length]))\n        })\n        .catch((error) => {\n          this.$toast.error(this.$strings.ToastBatchQuickMatchFailed)\n          console.error('Failed to batch quick match', error)\n        })\n        .finally(() => {\n          this.processing = false\n          this.$store.commit('setProcessingBatch', false)\n          this.show = false\n        })\n    }\n  }\n}\n</script>\n\n"
  },
  {
    "path": "client/components/modals/BookmarksModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"bookmarks\" :width=\"500\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.LabelYourBookmarks }}</p>\n      </div>\n    </template>\n    <div v-if=\"show\" class=\"w-full rounded-lg bg-bg box-shadow-md relative\" style=\"max-height: 80vh\">\n      <div v-if=\"bookmarks.length\" class=\"h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden\">\n        <template v-for=\"bookmark in bookmarks\">\n          <modals-bookmarks-bookmark-item :key=\"bookmark.id\" :highlight=\"currentTime === bookmark.time\" :bookmark=\"bookmark\" :playback-rate=\"playbackRate\" @click=\"clickBookmark\" @delete=\"deleteBookmark\" />\n        </template>\n      </div>\n      <div v-else class=\"flex h-32 items-center justify-center\">\n        <p class=\"text-xl\">{{ $strings.MessageNoBookmarks }}</p>\n      </div>\n\n      <div v-if=\"canCreateBookmark && !hideCreate\" class=\"w-full border-t border-white/10\">\n        <form @submit.prevent=\"submitCreateBookmark\">\n          <div class=\"flex px-4 py-2 items-center text-center border-b border-white/10 text-white/80\">\n            <div class=\"w-16 max-w-16 text-center\">\n              <p class=\"text-sm font-mono text-gray-400\">\n                {{ this.$secondsToTimestamp(currentTime / playbackRate) }}\n              </p>\n            </div>\n            <div class=\"grow px-2\">\n              <ui-text-input v-model=\"newBookmarkTitle\" placeholder=\"Note\" class=\"w-full h-10\" />\n            </div>\n            <ui-btn type=\"submit\" color=\"bg-success\" :padding-x=\"4\" class=\"h-10\"><span class=\"material-symbols text-2xl -mt-px\">add</span></ui-btn>\n          </div>\n        </form>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    bookmarks: {\n      type: Array,\n      default: () => []\n    },\n    currentTime: {\n      type: Number,\n      default: 0\n    },\n    libraryItemId: String,\n    playbackRate: Number,\n    hideCreate: Boolean\n  },\n  data() {\n    return {\n      selectedBookmark: null,\n      showBookmarkTitleInput: false,\n      newBookmarkTitle: ''\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.selectedBookmark = null\n        this.showBookmarkTitleInput = false\n        this.newBookmarkTitle = ''\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    canCreateBookmark() {\n      return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    editBookmark(bm) {\n      this.selectedBookmark = bm\n      this.newBookmarkTitle = bm.title\n      this.showBookmarkTitleInput = true\n    },\n    deleteBookmark(bm) {\n      this.$axios\n        .$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)\n        })\n        .catch((error) => {\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n          console.error(error)\n        })\n      this.show = false\n    },\n    clickBookmark(bm) {\n      this.$emit('select', bm)\n    },\n    submitCreateBookmark() {\n      if (!this.newBookmarkTitle) {\n        this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)\n      }\n      var bookmark = {\n        title: this.newBookmarkTitle,\n        time: Math.floor(this.currentTime)\n      }\n      this.$axios\n        .$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastBookmarkCreateSuccess)\n        })\n        .catch((error) => {\n          this.$toast.error(this.$strings.ToastBookmarkCreateFailed)\n          console.error(error)\n        })\n\n      this.newBookmarkTitle = ''\n      this.showBookmarkTitleInput = false\n\n      this.show = false\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/ChaptersModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"chapters\" :width=\"600\" :height=\"'unset'\">\n    <div id=\"chapter-modal-wrapper\" ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <template v-for=\"chap in chapters\">\n        <div :key=\"chap.id\" :id=\"`chapter-row-${chap.id}`\" class=\"flex items-center px-6 py-3 justify-start cursor-pointer relative\" :class=\"chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'\" @click=\"clickChapter(chap)\">\n          <p class=\"chapter-title truncate text-sm md:text-base\">\n            {{ chap.title }}\n          </p>\n          <span class=\"font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap\">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>\n          <span class=\"grow\" />\n          <span class=\"font-mono text-xs sm:text-sm text-gray-300\">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>\n\n          <div v-show=\"chap.id === currentChapterId\" class=\"w-0.5 h-full absolute top-0 left-0 bg-yellow-400\" />\n        </div>\n      </template>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    chapters: {\n      type: Array,\n      default: () => []\n    },\n    currentChapter: {\n      type: Object,\n      default: () => null\n    },\n    playbackRate: Number\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    _playbackRate() {\n      if (!this.playbackRate || isNaN(this.playbackRate)) return 1\n      return this.playbackRate\n    },\n    currentChapterId() {\n      return this.currentChapter?.id || null\n    },\n    currentChapterStart() {\n      return (this.currentChapter?.start || 0) / this._playbackRate\n    }\n  },\n  methods: {\n    clickChapter(chap) {\n      this.$emit('select', chap)\n    },\n    scrollToChapter() {\n      if (!this.currentChapterId) return\n\n      if (this.$refs.container) {\n        const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)\n        if (currChapterEl) {\n          const containerHeight = this.$refs.container.clientHeight\n          this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })\n        }\n      }\n    }\n  },\n  updated() {\n    if (this.value) {\n      this.$nextTick(this.scrollToChapter)\n    }\n  }\n}\n</script>\n\n<style>\n#chapter-modal-wrapper .chapter-title {\n  max-width: calc(100% - 120px);\n}\n@media (min-width: 640px) {\n  #chapter-modal-wrapper .chapter-title {\n    max-width: calc(100% - 150px);\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/Dialog.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" :width=\"300\" height=\"100%\">\n    <template #outer>\n      <div v-if=\"title\" class=\"absolute top-7 left-4 z-40\" style=\"max-width: 80%\">\n        <p class=\"text-white text-lg truncate\">{{ title }}</p>\n      </div>\n    </template>\n\n    <div class=\"w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center\" @click=\"show = false\">\n      <div ref=\"container\" class=\"w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white/20\" style=\"max-height: 75%\" @click.stop>\n        <ul class=\"h-full w-full\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n          <template v-for=\"item in items\">\n            <li :key=\"item.value\" class=\"text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400\" :class=\"selected === item.value ? 'bg-success/10' : ''\" role=\"option\" @click=\"clickedOption(item.value)\">\n              <div class=\"relative flex items-center px-3\">\n                <p class=\"font-normal block truncate text-base text-white/80\">{{ item.text }}</p>\n              </div>\n            </li>\n          </template>\n        </ul>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    title: String,\n    items: {\n      type: Array,\n      default: () => []\n    },\n    selected: String // optional\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    clickedOption(action) {\n      this.$emit('action', { action })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/EditSeriesInputInnerModal.vue",
    "content": "<template>\n  <div ref=\"wrapper\" role=\"dialog\" aria-modal=\"true\" class=\"hidden absolute top-0 left-0 w-full h-full bg-black/50 rounded-lg items-center justify-center\" style=\"z-index: 61\" @click=\"clickClose\">\n    <button type=\"button\" class=\"absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300\" aria-label=\"Close modal\">\n      <span class=\"material-symbols text-2xl md:text-4xl\">close</span>\n    </button>\n    <div ref=\"content\" class=\"text-white\">\n      <form v-if=\"selectedSeries\" @submit.prevent=\"submitSeriesForm\">\n        <div class=\"bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8\" @click.stop>\n          <div class=\"flex\">\n            <div class=\"grow p-1 min-w-48 sm:min-w-64 md:min-w-80\">\n              <ui-input-dropdown ref=\"newSeriesSelect\" v-model=\"selectedSeries.name\" :items=\"existingSeriesNames\" :disabled=\"!isNewSeries\" :label=\"$strings.LabelSeriesName\" @input=\"seriesNameInputHandler\" />\n            </div>\n            <div class=\"w-24 sm:w-28 md:w-40 p-1\">\n              <ui-text-input-with-label ref=\"sequenceInput\" v-model=\"selectedSeries.sequence\" :label=\"$strings.LabelSequence\" />\n            </div>\n          </div>\n          <div v-if=\"error\" class=\"text-error text-sm mt-2 p-1\">{{ error }}</div>\n          <div class=\"flex justify-end mt-2 p-1\">\n            <ui-btn type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </div>\n      </form>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    selectedSeries: {\n      type: Object,\n      default: () => {}\n    },\n    existingSeriesNames: {\n      type: Array,\n      default: () => []\n    },\n    originalSeriesSequence: {\n      type: String,\n      default: null\n    }\n  },\n  data() {\n    return {\n      el: null,\n      content: null,\n      error: null\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.$nextTick(this.setShow)\n      } else {\n        this.setHide()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    isNewSeries() {\n      if (!this.selectedSeries || !this.selectedSeries.id) return false\n      return this.selectedSeries.id.startsWith('new')\n    }\n  },\n  methods: {\n    seriesNameInputHandler() {\n      if (this.$refs.sequenceInput) {\n        this.$refs.sequenceInput.setFocus()\n      }\n    },\n    setInputFocus() {\n      if (this.isNewSeries) {\n        // Focus on series input if new series\n        if (this.$refs.newSeriesSelect) {\n          this.$refs.newSeriesSelect.setFocus()\n        }\n      } else {\n        // Focus on sequence input if existing series\n        if (this.$refs.sequenceInput) {\n          this.$refs.sequenceInput.setFocus()\n        }\n      }\n    },\n    submitSeriesForm() {\n      this.error = null\n\n      if (this.$refs.newSeriesSelect) {\n        this.$refs.newSeriesSelect.blur()\n      }\n\n      if (this.selectedSeries.sequence !== this.originalSeriesSequence && this.selectedSeries.sequence.includes(' ')) {\n        this.error = this.$strings.MessageSeriesSequenceCannotContainSpaces\n        return\n      }\n\n      this.$emit('submit')\n    },\n    clickClose() {\n      this.show = false\n    },\n    hotkey(action) {\n      if (action === this.$hotkeys.Modal.CLOSE) {\n        this.show = false\n      }\n    },\n    setShow() {\n      this.error = null\n      if (!this.el || !this.content) {\n        this.init()\n      }\n      if (!this.el || !this.content) {\n        return\n      }\n\n      document.body.appendChild(this.el)\n      setTimeout(() => {\n        this.content.style.transform = 'scale(1)'\n      }, 10)\n\n      this.$store.commit('setInnerModalOpen', true)\n      this.$eventBus.$on('modal-hotkey', this.hotkey)\n\n      this.setInputFocus()\n    },\n    setHide() {\n      if (this.content) this.content.style.transform = 'scale(0)'\n      if (this.el) this.el.remove()\n\n      this.$store.commit('setInnerModalOpen', false)\n      this.$eventBus.$off('modal-hotkey', this.hotkey)\n    },\n    init() {\n      this.el = this.$refs.wrapper\n      this.content = this.$refs.content\n      if (this.content && this.el) {\n        this.el.classList.remove('hidden')\n        this.el.classList.add('flex')\n        this.content.style.transform = 'scale(0)'\n        this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'\n        this.el.style.opacity = 1\n        this.el.remove()\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/ListeningSessionModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"listening-session-modal\" :processing=\"processing\" :width=\"700\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-lg md:text-2xl text-white truncate\">{{ $strings.HeaderSession }} {{ _session.id }}</p>\n      </div>\n    </template>\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6\" style=\"max-height: 80vh\">\n      <div class=\"flex items-center\">\n        <p class=\"text-base text-gray-200\">{{ _session.displayTitle }}</p>\n        <p v-if=\"_session.displayAuthor\" class=\"text-xs text-gray-400 px-4\">{{ $getString('LabelByAuthor', [_session.displayAuthor]) }}</p>\n      </div>\n\n      <div class=\"w-full h-px bg-white/10 my-4\" />\n\n      <div class=\"flex flex-wrap mb-4\">\n        <div class=\"w-full md:w-2/3\">\n          <p class=\"font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2\">{{ $strings.HeaderDetails }}</p>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelStartedAt }}</div>\n            <div class=\"px-1\">\n              {{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelUpdatedAt }}</div>\n            <div class=\"px-1\">\n              {{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelTimeListened }}</div>\n            <div class=\"px-1\">\n              {{ $elapsedPrettyExtended(_session.timeListening) }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelStartTime }}</div>\n            <div class=\"px-1\">\n              {{ $secondsToTimestamp(_session.startTime) }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelLastTime }}</div>\n            <div class=\"px-1\">\n              {{ $secondsToTimestamp(_session.currentTime) }}\n            </div>\n          </div>\n\n          <p class=\"font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2\">{{ $strings.LabelItem }}</p>\n          <div v-if=\"_session.libraryId\" class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelLibrary }} Id</div>\n            <div class=\"px-1 text-xs\">\n              {{ _session.libraryId }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelLibraryItem }} Id</div>\n            <div class=\"px-1 text-xs\">\n              {{ _session.libraryItemId }}\n            </div>\n          </div>\n          <div v-if=\"_session.episodeId\" class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelEpisode }} Id</div>\n            <div class=\"px-1 text-xs\">\n              {{ _session.episodeId }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelMediaType }}</div>\n            <div class=\"px-1\">\n              {{ _session.mediaType }}\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-1\">\n            <div class=\"w-40 px-1 text-gray-200\">{{ $strings.LabelDuration }}</div>\n            <div class=\"px-1\">\n              {{ $elapsedPretty(_session.duration) }}\n            </div>\n          </div>\n        </div>\n        <div class=\"w-full md:w-1/3\">\n          <p v-if=\"!isMediaItemShareSession\" class=\"font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0\">{{ $strings.LabelUser }}</p>\n          <p v-if=\"!isMediaItemShareSession\" class=\"mb-1 text-xs\">{{ username }}</p>\n\n          <p class=\"font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2\">{{ $strings.LabelMediaPlayer }}</p>\n          <p class=\"mb-1\">{{ playMethodName }}</p>\n          <p class=\"mb-1\">{{ _session.mediaPlayer }}</p>\n\n          <p v-if=\"hasDeviceInfo\" class=\"font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2\">{{ $strings.LabelDevice }}</p>\n          <p v-if=\"clientDisplayName\" class=\"mb-1\">{{ clientDisplayName }}</p>\n          <p v-if=\"deviceInfo.ipAddress\" class=\"mb-1\">{{ deviceInfo.ipAddress }}</p>\n          <p v-if=\"osDisplayName\" class=\"mb-1\">{{ osDisplayName }}</p>\n          <p v-if=\"deviceInfo.browserName\" class=\"mb-1\">{{ deviceInfo.browserName }}</p>\n          <p v-if=\"deviceDisplayName\" class=\"mb-1\">{{ deviceDisplayName }}</p>\n          <p v-if=\"deviceInfo.sdkVersion\" class=\"mb-1\">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>\n          <p v-if=\"deviceInfo.deviceType\" class=\"mb-1\">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>\n        </div>\n      </div>\n\n      <div class=\"flex items-center\">\n        <ui-btn v-if=\"!isOpenSession && !isMediaItemShareSession\" small color=\"bg-error\" @click.stop=\"deleteSessionClick\">{{ $strings.ButtonDelete }}</ui-btn>\n        <ui-btn v-else-if=\"!isMediaItemShareSession\" small color=\"bg-error\" @click.stop=\"closeSessionClick\">{{ $strings.ButtonCloseSession }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    session: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      processing: false\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    _session() {\n      return this.session || {}\n    },\n    username() {\n      return this._session.user?.username || this._session.userId || ''\n    },\n    deviceInfo() {\n      return this._session.deviceInfo || {}\n    },\n    hasDeviceInfo() {\n      return Object.keys(this.deviceInfo).length\n    },\n    osDisplayName() {\n      if (!this.deviceInfo.osName) return null\n      return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`\n    },\n    deviceDisplayName() {\n      if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null\n      return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`\n    },\n    clientDisplayName() {\n      if (!this.deviceInfo.clientName) return null\n      return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`\n    },\n    playMethodName() {\n      const playMethod = this._session.playMethod\n      if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'\n      else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'\n      else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'\n      else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'\n      return 'Unknown'\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    },\n    isOpenSession() {\n      return !!this._session.open\n    },\n    isMediaItemShareSession() {\n      return this._session.mediaPlayer === 'web-share'\n    }\n  },\n  methods: {\n    deleteSessionClick() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteSession,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteSession()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteSession() {\n      this.processing = true\n      this.$axios\n        .$delete(`/api/sessions/${this._session.id}`)\n        .then(() => {\n          this.processing = false\n          this.$toast.success(this.$strings.ToastSessionDeleteSuccess)\n          this.$emit('removedSession')\n          this.show = false\n        })\n        .catch((error) => {\n          this.processing = false\n          console.error('Failed to delete session', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)\n        })\n    },\n    closeSessionClick() {\n      this.processing = true\n      this.$axios\n        .$post(`/api/session/${this._session.id}/close`)\n        .then(() => {\n          this.show = false\n          this.$emit('closedSession')\n        })\n        .catch((error) => {\n          console.error('Failed to close session', error)\n          const errMsg = error.response?.data || ''\n          this.$toast.error(errMsg || this.$strings.ToastSessionCloseFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/Modal.vue",
    "content": "<template>\n  <div ref=\"wrapper\" role=\"dialog\" aria-modal=\"true\" class=\"modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden\" :class=\"`z-${zIndex} bg-primary/${bgOpacity}`\">\n    <div class=\"absolute top-0 left-0 right-0 w-full h-36 bg-linear-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none\" />\n\n    <button class=\"absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white\" aria-label=\"Close modal\" @click=\"clickClose\">\n      <span class=\"material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl\">close</span>\n    </button>\n    <slot name=\"outer\" />\n    <div ref=\"content\" tabindex=\"0\" style=\"min-width: 380px; min-height: 200px; max-width: 100vw\" class=\"relative text-white outline-hidden\" :style=\"{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }\" @mousedown=\"mousedownModal\" @mouseup=\"mouseupModal\" v-click-outside=\"clickBg\">\n      <slot />\n      <div v-if=\"processing\" class=\"absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black/60 rounded-lg flex items-center justify-center\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    name: String,\n    value: Boolean,\n    processing: Boolean,\n    persistent: {\n      type: Boolean,\n      default: false\n    },\n    width: {\n      type: [String, Number],\n      default: 500\n    },\n    height: {\n      type: [String, Number],\n      default: 'unset'\n    },\n    contentMarginTop: {\n      type: Number,\n      default: 50\n    },\n    zIndex: {\n      type: Number,\n      default: 60\n    },\n    bgOpacity: {\n      type: Number,\n      default: 75\n    }\n  },\n  data() {\n    return {\n      el: null,\n      content: null,\n      preventClickoutside: false,\n      isShowingPrompt: false\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.$nextTick(this.setShow)\n      } else {\n        this.setHide()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    modalHeight() {\n      if (typeof this.height === 'string') {\n        return this.height\n      } else {\n        return this.height + 'px'\n      }\n    },\n    modalWidth() {\n      return typeof this.width === 'string' ? this.width : this.width + 'px'\n    }\n  },\n  methods: {\n    mousedownModal() {\n      this.preventClickoutside = true\n    },\n    mouseupModal() {\n      this.preventClickoutside = false\n    },\n    clickClose() {\n      this.show = false\n    },\n    clickBg(ev) {\n      if (!this.show || this.isShowingPrompt) return\n      if (this.preventClickoutside) {\n        this.preventClickoutside = false\n        return\n      }\n      if (this.processing || this.persistent) return\n      if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {\n        this.show = false\n      }\n    },\n    hotkey(action) {\n      if (this.$store.state.innerModalOpen) return\n      if (action === this.$hotkeys.Modal.CLOSE) {\n        this.show = false\n      }\n    },\n    setShow() {\n      if (!this.el || !this.content) {\n        this.init()\n      }\n      if (!this.el || !this.content) {\n        return\n      }\n\n      document.body.appendChild(this.el)\n      setTimeout(() => {\n        this.content.style.transform = 'scale(1)'\n      }, 10)\n      document.documentElement.classList.add('modal-open')\n\n      this.$eventBus.$on('modal-hotkey', this.hotkey)\n      this.$store.commit('setOpenModal', this.name)\n\n      // Set focus to the modal content\n      this.content.focus()\n    },\n    setHide() {\n      if (this.content) this.content.style.transform = 'scale(0)'\n      if (this.el) this.el.remove()\n      document.documentElement.classList.remove('modal-open')\n\n      this.$eventBus.$off('modal-hotkey', this.hotkey)\n      this.$store.commit('setOpenModal', null)\n    },\n    init() {\n      this.el = this.$refs.wrapper\n      this.content = this.$refs.content\n      if (this.content && this.el) {\n        this.el.classList.remove('hidden')\n        this.el.classList.add('flex')\n        this.content.style.transform = 'scale(0)'\n        this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'\n        this.el.style.opacity = 1\n        this.el.remove()\n      } else {\n        console.warn('Invalid modal init', this.name)\n      }\n    },\n    showingPrompt(isShowing) {\n      this.isShowingPrompt = isShowing\n    }\n  },\n  mounted() {\n    this.$eventBus.$on('showing-prompt', this.showingPrompt)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('showing-prompt', this.showingPrompt)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/PlayerSettingsModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"player-settings\" :width=\"500\" :height=\"'unset'\">\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-4\" style=\"max-height: 80vh; min-height: 40vh\">\n      <h3 class=\"text-xl font-semibold mb-8\">{{ $strings.HeaderPlayerSettings }}</h3>\n      <div class=\"flex items-center mb-4\">\n        <ui-toggle-switch v-model=\"useChapterTrack\" @input=\"setUseChapterTrack\" />\n        <div class=\"pl-4\">\n          <span>{{ $strings.LabelUseChapterTrack }}</span>\n        </div>\n      </div>\n      <div class=\"flex items-center mb-4\">\n        <ui-select-input v-model=\"jumpForwardAmount\" :label=\"$strings.LabelJumpForwardAmount\" menuMaxHeight=\"250px\" :items=\"jumpValues\" @input=\"setJumpForwardAmount\" />\n      </div>\n      <div class=\"flex items-center mb-4\">\n        <ui-select-input v-model=\"jumpBackwardAmount\" :label=\"$strings.LabelJumpBackwardAmount\" menuMaxHeight=\"250px\" :items=\"jumpValues\" @input=\"setJumpBackwardAmount\" />\n      </div>\n      <div class=\"flex items-center mb-4\">\n        <ui-select-input v-model=\"playbackRateIncrementDecrement\" :label=\"$strings.LabelPlaybackRateIncrementDecrement\" menuMaxHeight=\"250px\" :items=\"playbackRateIncrementDecrementValues\" @input=\"setPlaybackRateIncrementDecrementAmount\" />\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean\n  },\n  data() {\n    return {\n      useChapterTrack: false,\n      jumpValues: [\n        { text: this.$getString('LabelTimeDurationXSeconds', ['10']), value: 10 },\n        { text: this.$getString('LabelTimeDurationXSeconds', ['15']), value: 15 },\n        { text: this.$getString('LabelTimeDurationXSeconds', ['30']), value: 30 },\n        { text: this.$getString('LabelTimeDurationXSeconds', ['60']), value: 60 },\n        { text: this.$getString('LabelTimeDurationXMinutes', ['2']), value: 120 },\n        { text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }\n      ],\n      jumpForwardAmount: 10,\n      jumpBackwardAmount: 10,\n      playbackRateIncrementDecrementValues: [0.1, 0.05],\n      playbackRateIncrementDecrement: 0.1\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    setUseChapterTrack() {\n      this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })\n    },\n    setJumpForwardAmount(val) {\n      this.jumpForwardAmount = val\n      this.$store.dispatch('user/updateUserSettings', { jumpForwardAmount: val })\n    },\n    setJumpBackwardAmount(val) {\n      this.jumpBackwardAmount = val\n      this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })\n    },\n    setPlaybackRateIncrementDecrementAmount(val) {\n      this.playbackRateIncrementDecrement = val\n      this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val })\n    },\n    settingsUpdated() {\n      this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')\n      this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')\n      this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')\n      this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')\n    }\n  },\n  mounted() {\n    this.settingsUpdated()\n    this.$eventBus.$on('user-settings', this.settingsUpdated)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('user-settings', this.settingsUpdated)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/RawCoverPreviewModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"cover\" :width=\"'90%'\" :height=\"'90%'\" :contentMarginTop=\"0\">\n    <div class=\"w-full h-full\" @click=\"show = false\">\n      <img loading=\"lazy\" :src=\"rawCoverUrl\" class=\"w-full h-full z-10 object-scale-down\" />\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {}\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showRawCoverPreviewModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowRawCoverPreviewModal', val)\n      }\n    },\n    rawCoverUrl() {\n      return this.$store.state.globals.selectedRawCoverUrl\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/ShareModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"share\" :width=\"600\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.LabelShare }}</p>\n      </div>\n    </template>\n    <div class=\"px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <div class=\"absolute top-0 right-0 p-4\">\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/media-item-shares\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n      </div>\n      <template v-if=\"currentShare\">\n        <div class=\"w-full py-2\">\n          <label class=\"px-1 text-sm font-semibold block\">{{ $strings.LabelShareURL }}</label>\n          <ui-text-input v-model=\"currentShareUrl\" show-copy readonly />\n        </div>\n        <div class=\"w-full py-2 px-1\">\n          <p v-if=\"currentShare.isDownloadable\" class=\"text-sm mb-2\">{{ $strings.LabelDownloadable }}</p>\n          <p v-if=\"currentShare.expiresAt\">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>\n          <p v-else>{{ $strings.LabelPermanent }}</p>\n        </div>\n      </template>\n      <template v-else>\n        <div class=\"flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-2\">\n          <div class=\"w-full sm:w-48\">\n            <label class=\"px-1 text-sm font-semibold block\">{{ $strings.LabelSlug }}</label>\n            <ui-text-input v-model=\"newShareSlug\" class=\"text-base h-10\" />\n          </div>\n          <div class=\"grow\" />\n          <div class=\"w-full sm:w-80\">\n            <label class=\"px-1 text-sm font-semibold block\">{{ $strings.LabelDuration }}</label>\n            <div class=\"inline-flex items-center space-x-2\">\n              <div>\n                <ui-icon-btn icon=\"remove\" :size=\"10\" @click=\"clickMinus\" />\n              </div>\n              <ui-text-input v-model=\"newShareDuration\" type=\"number\" text-center no-spinner class=\"text-center max-w-12 min-w-12 h-10 text-base\" />\n              <div>\n                <ui-icon-btn icon=\"add\" :size=\"10\" @click=\"clickPlus\" />\n              </div>\n              <div class=\"w-28\">\n                <ui-dropdown v-model=\"shareDurationUnit\" :items=\"durationUnits\" />\n              </div>\n            </div>\n          </div>\n        </div>\n        <div class=\"flex items-center w-full md:w-1/2 mb-4\">\n          <p class=\"text-sm text-gray-300 py-1 px-1\">{{ $strings.LabelDownloadable }}</p>\n          <ui-toggle-switch size=\"sm\" v-model=\"isDownloadable\" />\n          <ui-tooltip :text=\"$strings.LabelShareDownloadableHelp\">\n            <p class=\"pl-4 text-sm\">\n              <span class=\"material-symbols icon-text text-sm\">info</span>\n            </p>\n          </ui-tooltip>\n        </div>\n        <p class=\"text-sm text-gray-300 py-1 px-1\" v-html=\"$getString('MessageShareURLWillBe', [demoShareUrl])\" />\n        <p class=\"text-sm text-gray-300 py-1 px-1\" v-html=\"$getString('MessageShareExpirationWillBe', [expirationDateString])\" />\n      </template>\n      <div class=\"flex items-center pt-6\">\n        <div class=\"grow\" />\n        <ui-btn v-if=\"currentShare\" color=\"bg-error\" small @click=\"deleteShare\">{{ $strings.ButtonDelete }}</ui-btn>\n        <ui-btn v-if=\"!currentShare\" color=\"bg-success\" small @click=\"openShare\">{{ $strings.ButtonShare }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {},\n  data() {\n    return {\n      processing: false,\n      newShareSlug: '',\n      newShareDuration: 0,\n      currentShare: null,\n      shareDurationUnit: 'minutes',\n      durationUnits: [\n        {\n          text: this.$strings.LabelMinutes,\n          value: 'minutes'\n        },\n        {\n          text: this.$strings.LabelHours,\n          value: 'hours'\n        },\n        {\n          text: this.$strings.LabelDays,\n          value: 'days'\n        }\n      ],\n      isDownloadable: false\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showShareModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowShareModal', val)\n      }\n    },\n    mediaItemShare() {\n      return this.$store.state.globals.selectedMediaItemShare\n    },\n    libraryItem() {\n      return this.$store.state.selectedLibraryItem\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    demoShareUrl() {\n      return `${window.origin}${this.$config.routerBasePath}/share/${this.newShareSlug}`\n    },\n    currentShareUrl() {\n      if (!this.currentShare) return ''\n      return `${window.origin}${this.$config.routerBasePath}/share/${this.currentShare.slug}`\n    },\n    currentShareTimeRemaining() {\n      if (!this.currentShare) return 'Error'\n      if (!this.currentShare.expiresAt) return this.$strings.LabelPermanent\n      const msRemaining = new Date(this.currentShare.expiresAt).valueOf() - Date.now()\n      if (msRemaining <= 0) return 'Expired'\n      return this.$elapsedPrettyExtended(msRemaining / 1000, true, false)\n    },\n    expireDurationSeconds() {\n      let shareDuration = Number(this.newShareDuration)\n      if (!shareDuration || isNaN(shareDuration)) return 0\n      return this.newShareDuration * (this.shareDurationUnit === 'minutes' ? 60 : this.shareDurationUnit === 'hours' ? 3600 : 86400)\n    },\n    expirationDateString() {\n      if (!this.expireDurationSeconds) return this.$strings.LabelPermanent\n      const dateMs = Date.now() + this.expireDurationSeconds * 1000\n      return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat'))\n    }\n  },\n  methods: {\n    clickPlus() {\n      this.newShareDuration++\n    },\n    clickMinus() {\n      if (this.newShareDuration > 0) {\n        this.newShareDuration--\n      }\n    },\n    deleteShare() {\n      if (!this.currentShare) return\n      this.processing = true\n      this.$axios\n        .$delete(`/api/share/mediaitem/${this.currentShare.id}`)\n        .then(() => {\n          this.currentShare = null\n          this.$emit('removed')\n        })\n        .catch((error) => {\n          console.error('deleteShare', error)\n          let errorMsg = error.response?.data || 'Failed to delete share'\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    openShare() {\n      if (!this.newShareSlug) {\n        this.$toast.error(this.$strings.ToastSlugRequired)\n        return\n      }\n      const payload = {\n        slug: this.newShareSlug,\n        mediaItemType: 'book',\n        mediaItemId: this.libraryItem.media.id,\n        expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,\n        isDownloadable: this.isDownloadable\n      }\n      this.processing = true\n      this.$axios\n        .$post(`/api/share/mediaitem`, payload)\n        .then((data) => {\n          this.currentShare = data\n          this.$emit('opened', data)\n        })\n        .catch((error) => {\n          console.error('openShare', error)\n          let errorMsg = error.response?.data || 'Failed to share item'\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    init() {\n      this.newShareSlug = this.$randomId(10)\n      if (this.mediaItemShare) {\n        this.currentShare = { ...this.mediaItemShare }\n      } else {\n        this.currentShare = null\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/SleepTimerModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"sleep-timer\" :width=\"350\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none\">\n        <p class=\"text-3xl text-white truncate pointer-events-none\">{{ $strings.HeaderSleepTimer }}</p>\n      </div>\n    </template>\n\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <div class=\"w-full\">\n        <template v-for=\"time in sleepTimes\">\n          <div :key=\"time.text\" class=\"flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-primary/25 relative\" @click=\"setTime(time)\">\n            <p class=\"text-lg text-center\">{{ time.text }}</p>\n          </div>\n        </template>\n        <form class=\"flex items-center justify-center px-6 py-3\" @submit.prevent=\"submitCustomTime\">\n          <ui-text-input v-model=\"customTime\" type=\"number\" step=\"any\" min=\"0.1\" :placeholder=\"$strings.LabelTimeInMinutes\" class=\"w-48\" />\n          <ui-btn color=\"bg-success\" type=\"submit\" :padding-x=\"0\" class=\"h-9 w-18 flex items-center justify-center ml-1\">{{ $strings.ButtonSubmit }}</ui-btn>\n        </form>\n      </div>\n      <div v-if=\"timerSet\" class=\"w-full p-4\">\n        <div class=\"mb-4 h-px w-full bg-white/10\" />\n\n        <div v-if=\"timerType === $constants.SleepTimerTypes.COUNTDOWN\" class=\"mb-4 flex items-center justify-center space-x-4\">\n          <ui-btn :padding-x=\"2\" small :disabled=\"remaining < 30 * 60\" class=\"flex items-center h-9\" @click=\"decrement(30 * 60)\">\n            <span class=\"material-symbols text-lg\">remove</span>\n            <span class=\"pl-1 text-sm\">30m</span>\n          </ui-btn>\n\n          <ui-icon-btn icon=\"remove\" class=\"min-w-9\" @click=\"decrement(60 * 5)\" />\n\n          <p class=\"text-2xl font-mono\">{{ $secondsToTimestamp(remaining) }}</p>\n\n          <ui-icon-btn icon=\"add\" class=\"min-w-9\" @click=\"increment(60 * 5)\" />\n\n          <ui-btn :padding-x=\"2\" small class=\"flex items-center h-9\" @click=\"increment(30 * 60)\">\n            <span class=\"material-symbols text-lg\">add</span>\n            <span class=\"pl-1 text-sm\">30m</span>\n          </ui-btn>\n        </div>\n        <ui-btn class=\"w-full\" @click=\"$emit('cancel')\">{{ $strings.ButtonCancel }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    timerSet: Boolean,\n    timerType: String,\n    remaining: Number,\n    hasChapters: Boolean\n  },\n  data() {\n    return {\n      customTime: null\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    sleepTimes() {\n      const times = [\n        {\n          seconds: 60 * 5,\n          text: this.$getString('LabelTimeDurationXMinutes', ['5']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 15,\n          text: this.$getString('LabelTimeDurationXMinutes', ['15']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 20,\n          text: this.$getString('LabelTimeDurationXMinutes', ['20']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 30,\n          text: this.$getString('LabelTimeDurationXMinutes', ['30']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 45,\n          text: this.$getString('LabelTimeDurationXMinutes', ['45']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 60,\n          text: this.$getString('LabelTimeDurationXMinutes', ['60']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 90,\n          text: this.$getString('LabelTimeDurationXMinutes', ['90']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        },\n        {\n          seconds: 60 * 120,\n          text: this.$getString('LabelTimeDurationXHours', ['2']),\n          timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n        }\n      ]\n      if (this.hasChapters) {\n        times.push({ seconds: -1, text: this.$strings.LabelEndOfChapter, timerType: this.$constants.SleepTimerTypes.CHAPTER })\n      }\n      return times\n    }\n  },\n  methods: {\n    submitCustomTime() {\n      if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {\n        this.customTime = null\n        return\n      }\n\n      const timeInSeconds = Math.round(Number(this.customTime) * 60)\n      const time = {\n        seconds: timeInSeconds,\n        timerType: this.$constants.SleepTimerTypes.COUNTDOWN\n      }\n      this.setTime(time)\n    },\n    setTime(time) {\n      this.$emit('set', time)\n    },\n    increment(amount) {\n      this.$emit('increment', amount)\n    },\n    decrement(amount) {\n      if (amount > this.remaining) {\n        if (this.remaining > 60) amount = 60\n        else amount = 5\n      }\n      this.$emit('decrement', amount)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/UploadImageModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"upload-image\" :width=\"500\" :height=\"'unset'\">\n    <div ref=\"container\" class=\"w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <div class=\"flex items-center\">\n        <div class=\"w-40 pr-2 pt-4\" style=\"min-width: 160px\">\n          <ui-file-input ref=\"fileInput\" @change=\"fileUploadSelected\">Upload Cover</ui-file-input>\n        </div>\n        <form @submit.prevent=\"submitForm\" class=\"flex grow\">\n          <ui-text-input-with-label v-model=\"imageUrl\" label=\"Cover Image URL\" />\n          <ui-btn color=\"bg-success\" type=\"submit\" :padding-x=\"4\" class=\"mt-5 ml-3 w-24\">Update</ui-btn>\n        </form>\n      </div>\n      <div v-if=\"previewUpload\" class=\"absolute top-0 left-0 w-full h-full z-10 bg-bg p-8\">\n        <p class=\"text-lg\">Preview Cover</p>\n        <span class=\"absolute top-4 right-4 material-symbols text-2xl cursor-pointer\" @click=\"resetCoverPreview\">close</span>\n        <div class=\"flex justify-center py-4\">\n          <covers-preview-cover :src=\"previewUpload\" :width=\"240\" />\n        </div>\n        <div class=\"absolute bottom-0 right-0 flex py-4 px-5\">\n          <ui-btn :disabled=\"processingUpload\" class=\"mx-2\" @click=\"resetCoverPreview\">Clear</ui-btn>\n          <ui-btn :loading=\"processingUpload\" color=\"bg-success\" @click=\"submitCoverUpload\">Upload</ui-btn>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    entity: String,\n    entityId: String\n  },\n  data() {\n    return {\n      imageUrl: null,\n      previewUpload: null,\n      processingUpload: false\n    }\n  },\n  watch: {\n    value(newVal) {\n      if (newVal) this.init()\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    init() {},\n    fileUploadSelected() {\n      this.previewUpload = URL.createObjectURL(file)\n      this.selectedFile = file\n    },\n    resetCoverPreview() {\n      if (this.$refs.fileInput) {\n        this.$refs.fileInput.reset()\n      }\n      this.previewUpload = null\n      this.selectedFile = null\n    },\n    submitCoverUpload() {\n      this.processingUpload = true\n      var form = new FormData()\n      form.set('cover', this.selectedFile)\n\n      this.$axios\n        .$post(`/api/${this.entity}/${this.entityId}/cover`, form)\n        .then((data) => {\n          if (data.error) {\n            this.$toast.error(data.error)\n          } else {\n            this.resetCoverPreview()\n          }\n          this.processingUpload = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError\n          this.$toast.error(errorMsg)\n          this.processingUpload = false\n        })\n    },\n    async submitForm() {\n      this.processingUpload = true\n\n      var success = await this.$axios.$post(`/api/${this.entity}/${this.entityId}/cover`, { url: this.imageUrl }).catch((error) => {\n        console.error('Failed to download cover from url', error)\n        var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError\n        this.$toast.error(errorMsg)\n        return false\n      })\n\n      this.processingUpload = false\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/authors/EditModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"edit-author\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div v-if=\"author\" class=\"p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n      <div class=\"flex\">\n        <div class=\"w-40 p-2\">\n          <div class=\"w-full h-45 relative\">\n            <covers-author-image :author=\"authorCopy\" />\n            <div v-if=\"userCanDelete && !processing && author.imagePath\" class=\"absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100\">\n              <span class=\"absolute top-2 right-2 material-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg\" @click=\"removeCover\">delete</span>\n            </div>\n          </div>\n        </div>\n        <div class=\"grow\">\n          <form @submit.prevent=\"submitUploadCover\" class=\"flex grow mb-2 p-2\">\n            <ui-text-input v-model=\"imageUrl\" :placeholder=\"$strings.LabelImageURLFromTheWeb\" class=\"h-9 w-full\" />\n            <ui-btn color=\"bg-success\" type=\"submit\" :padding-x=\"4\" :disabled=\"!imageUrl\" class=\"ml-2 sm:ml-3 w-24 h-9\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </form>\n\n          <form v-if=\"author\" @submit.prevent=\"submitForm\">\n            <div class=\"flex\">\n              <div class=\"w-3/4 p-2\">\n                <ui-text-input-with-label v-model=\"authorCopy.name\" :disabled=\"processing\" :label=\"$strings.LabelName\" />\n              </div>\n              <div class=\"grow p-2\">\n                <ui-text-input-with-label v-model=\"authorCopy.asin\" :disabled=\"processing\" label=\"ASIN\" />\n              </div>\n            </div>\n            <div class=\"p-2\">\n              <ui-textarea-with-label v-model=\"authorCopy.description\" :disabled=\"processing\" :label=\"$strings.LabelDescription\" :rows=\"8\" />\n            </div>\n\n            <div class=\"flex pt-2 px-2\">\n              <ui-btn v-if=\"userCanDelete\" small color=\"bg-error\" type=\"button\" @click.stop=\"removeClick\">{{ $strings.ButtonRemove }}</ui-btn>\n              <div class=\"grow\" />\n              <ui-btn type=\"button\" class=\"mx-2\" @click=\"searchAuthor\">{{ $strings.ButtonQuickMatch }}</ui-btn>\n\n              <ui-btn type=\"submit\">{{ $strings.ButtonSave }}</ui-btn>\n            </div>\n          </form>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      authorCopy: {\n        name: '',\n        asin: '',\n        description: ''\n      },\n      imageUrl: '',\n      processing: false\n    }\n  },\n  watch: {\n    author: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showEditAuthorModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowEditAuthorModal', val)\n      }\n    },\n    author() {\n      return this.$store.state.globals.selectedAuthor\n    },\n    authorId() {\n      if (!this.author) return ''\n      return this.author.id\n    },\n    title() {\n      return this.$strings.HeaderUpdateAuthor\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    libraryProvider() {\n      return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    }\n  },\n  methods: {\n    init() {\n      this.imageUrl = ''\n      this.authorCopy = {\n        ...this.author\n      }\n    },\n    removeClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.processing = true\n            this.$axios\n              .$delete(`/api/authors/${this.authorId}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastAuthorRemoveSuccess)\n                this.show = false\n              })\n              .catch((error) => {\n                console.error('Failed to remove author', error)\n                this.$toast.error(this.$strings.ToastRemoveFailed)\n              })\n              .finally(() => {\n                this.processing = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    async submitForm() {\n      var keysToCheck = ['name', 'asin', 'description']\n      var updatePayload = {}\n      keysToCheck.forEach((key) => {\n        if (this.authorCopy[key] !== this.author[key]) {\n          updatePayload[key] = this.authorCopy[key]\n        }\n      })\n      if (!Object.keys(updatePayload).length) {\n        this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n        return\n      }\n      this.processing = true\n      var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {\n        console.error('Failed', error)\n        const errorMsg = error.response ? error.response.data : null\n        this.$toast.error(errorMsg || this.$strings.ToastFailedToUpdate)\n        return null\n      })\n      if (result) {\n        if (result.updated) {\n          this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)\n          this.show = false\n        } else if (result.merged) {\n          this.$toast.success(this.$strings.ToastAuthorUpdateMerged)\n          this.show = false\n        } else this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n      }\n      this.processing = false\n    },\n    removeCover() {\n      this.processing = true\n      this.$axios\n        .$delete(`/api/authors/${this.authorId}/image`)\n        .then((data) => {\n          this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)\n\n          this.authorCopy.updatedAt = data.author.updatedAt\n          this.authorCopy.imagePath = data.author.imagePath\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submitUploadCover() {\n      if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {\n        this.$toast.error(this.$strings.ToastInvalidImageUrl)\n        return\n      }\n\n      this.processing = true\n      const updatePayload = {\n        url: this.imageUrl\n      }\n      this.$axios\n        .$post(`/api/authors/${this.authorId}/image`, updatePayload)\n        .then((data) => {\n          this.imageUrl = ''\n          this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)\n\n          this.authorCopy.updatedAt = data.author.updatedAt\n          this.authorCopy.imagePath = data.author.imagePath\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.$toast.error(error.response.data || this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    async searchAuthor() {\n      if (!this.authorCopy.name && !this.authorCopy.asin) {\n        this.$toast.error(this.$strings.ToastNameRequired)\n        return\n      }\n      this.processing = true\n\n      const payload = {}\n      if (this.authorCopy.asin) payload.asin = this.authorCopy.asin\n      else payload.q = this.authorCopy.name\n\n      payload.region = 'us'\n      if (this.libraryProvider.startsWith('audible.')) {\n        payload.region = this.libraryProvider.split('.').pop() || 'us'\n      }\n\n      var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {\n        console.error('Failed', error)\n        return null\n      })\n      if (!response) {\n        this.$toast.error(this.$strings.ToastAuthorSearchNotFound)\n      } else if (response.updated) {\n        if (response.author.imagePath) {\n          this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)\n        } else {\n          this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)\n        }\n\n        this.authorCopy = {\n          ...response.author\n        }\n      } else {\n        this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n      }\n      this.processing = false\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/bookmarks/BookmarkItem.vue",
    "content": "<template>\n  <div class=\"flex items-center px-4 py-4 justify-start relative hover:bg-primary/10\" :class=\"wrapperClass\" @click.stop=\"click\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <div class=\"w-16 max-w-16 text-center\">\n      <p class=\"text-sm font-mono text-gray-400\">\n        {{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}\n      </p>\n    </div>\n    <div class=\"grow overflow-hidden px-2\">\n      <template v-if=\"isEditing\">\n        <form @submit.prevent=\"submitUpdate\">\n          <div class=\"flex items-center\">\n            <div class=\"grow pr-2\">\n              <ui-text-input v-model=\"newBookmarkTitle\" placeholder=\"Note\" class=\"w-full h-10\" />\n            </div>\n            <ui-btn type=\"submit\" color=\"bg-success\" :padding-x=\"4\" class=\"h-10\"><span class=\"material-symbols text-2xl -mt-px\">forward</span></ui-btn>\n            <div class=\"pl-2 flex items-center\">\n              <span class=\"material-symbols text-3xl text-white/70 hover:text-white/95 cursor-pointer\" @click.stop.prevent=\"cancelEditing\">close</span>\n            </div>\n          </div>\n        </form>\n      </template>\n      <p v-else class=\"pl-2 pr-2 truncate\">{{ bookmark.title }}</p>\n    </div>\n    <div v-if=\"!isEditing\" class=\"h-full flex items-center justify-end transform\" :class=\"isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'\">\n      <span class=\"material-symbols text-xl mr-2 text-gray-200 hover:text-yellow-400\" @click.stop=\"editClick\">edit</span>\n      <span class=\"material-symbols text-xl text-gray-200 hover:text-error cursor-pointer\" @click.stop=\"deleteClick\">delete</span>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    bookmark: {\n      type: Object,\n      default: () => {}\n    },\n    highlight: Boolean,\n    playbackRate: Number\n  },\n  data() {\n    return {\n      isHovering: false,\n      isEditing: false,\n      newBookmarkTitle: null\n    }\n  },\n  computed: {\n    wrapperClass() {\n      var classes = []\n      if (this.highlight) classes.push('bg-bg/60')\n      if (!this.isEditing) classes.push('cursor-pointer')\n      return classes.join(' ')\n    }\n  },\n  methods: {\n    mouseover() {\n      if (this.isEditing) return\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    click(e) {\n      if (this.isEditing) {\n        if (e) e.stopPropagation()\n        return\n      }\n      this.$emit('click', this.bookmark)\n    },\n    deleteClick() {\n      if (this.isEditing) return\n      this.$emit('delete', this.bookmark)\n    },\n    editClick() {\n      this.newBookmarkTitle = this.bookmark.title\n      this.isEditing = true\n      this.isHovering = false\n    },\n    cancelEditing() {\n      this.isEditing = false\n    },\n    submitUpdate() {\n      if (this.newBookmarkTitle === this.bookmark.title) {\n        return this.cancelEditing()\n      }\n      const bookmark = { ...this.bookmark }\n      bookmark.title = this.newBookmarkTitle\n\n      this.$axios\n        .$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)\n        .then(() => {\n          this.isEditing = false\n        })\n        .catch((error) => {\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n          console.error(error)\n        })\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/changelog/ViewModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"changelog\" :width=\"800\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <h1 class=\"text-3xl text-white truncate\">Changelog</h1>\n      </div>\n    </template>\n    <div class=\"px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll\" style=\"max-height: 80vh\">\n      <template v-for=\"release in releasesToShow\">\n        <div :key=\"release.name\">\n          <p class=\"text-xl font-bold pb-4\">\n            Changelog <a :href=\"`https://github.com/advplyr/audiobookshelf/releases/tag/${release.name}`\" target=\"_blank\" class=\"hover:underline\">{{ release.name }}</a> ({{ $formatDate(release.pubdate, dateFormat) }})\n          </p>\n          <div class=\"custom-text\" v-html=\"getChangelog(release)\" />\n        </div>\n        <div v-if=\"release !== releasesToShow[releasesToShow.length - 1]\" :key=\"`${release.name}-divider`\" class=\"border-b border-black-300 my-8\" />\n      </template>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nimport { marked } from '@/static/libs/marked/index.js'\n\nexport default {\n  props: {\n    value: Boolean,\n    versionData: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    releasesToShow() {\n      return this.versionData?.releasesToShow || []\n    }\n  },\n  methods: {\n    getChangelog(release) {\n      return marked.parse(release.changelog || 'No Changelog Available', { gfm: true, breaks: true })\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n/*\n1. we need to manually define styles to apply to the parsed markdown elements,\nsince we don't have access to the actual elements in this component\n\n2. v-deep allows these to take effect on the content passed in to the v-html in the div above\n*/\n@reference \"tailwindcss\";\n\n.custom-text ::v-deep > h2 {\n  @apply text-lg font-bold;\n}\n.custom-text ::v-deep > h3 {\n  @apply text-lg font-bold;\n}\n.custom-text ::v-deep > ul {\n  @apply list-disc list-inside pb-4;\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/collections/AddCreateModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"collections\" :processing=\"processing\" :width=\"500\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <div v-if=\"show\" class=\"w-full h-full\">\n        <div class=\"py-4 px-4\">\n          <h1 v-if=\"!showBatchCollectionModal\" class=\"text-2xl\">{{ $strings.LabelAddToCollection }}</h1>\n          <h1 v-else class=\"text-2xl\">{{ $getString('LabelAddToCollectionBatch', [selectedBookIds.length]) }}</h1>\n        </div>\n        <div class=\"w-full overflow-y-auto overflow-x-hidden max-h-96\">\n          <transition-group name=\"list-complete\" tag=\"div\">\n            <template v-for=\"collection in sortedCollections\">\n              <modals-collections-collection-item :key=\"collection.id\" :collection=\"collection\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" class=\"list-complete-item\" @add=\"addToCollection\" @remove=\"removeFromCollection\" @close=\"show = false\" />\n            </template>\n          </transition-group>\n        </div>\n        <div v-if=\"!collections.length\" class=\"flex h-32 items-center justify-center text-center px-2\">\n          <div>\n            <p class=\"text-xl mb-2\">{{ $strings.MessageNoCollections }}</p>\n            <div class=\"text-sm flex items-center justify-center text-gray-200\">\n              <p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>\n              <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n                <a href=\"https://www.audiobookshelf.org/guides/collections\" target=\"_blank\" class=\"inline-flex\">\n                  <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n                </a>\n              </ui-tooltip>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"w-full h-px bg-white/10\" />\n        <form @submit.prevent=\"submitCreateCollection\">\n          <div class=\"flex px-4 py-2 items-center text-center border-b border-white/10 text-white/80\">\n            <div class=\"grow px-2\">\n              <ui-text-input v-model=\"newCollectionName\" :placeholder=\"$strings.PlaceholderNewCollection\" class=\"w-full\" />\n            </div>\n            <ui-btn type=\"submit\" color=\"bg-success\" :padding-x=\"4\" class=\"h-10\">{{ $strings.ButtonCreate }}</ui-btn>\n          </div>\n        </form>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      newCollectionName: '',\n      processing: false\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.loadCollections()\n        this.newCollectionName = ''\n      } else {\n        this.$store.commit('setSelectedLibraryItem', null)\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showCollectionsModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowCollectionsModal', val)\n      }\n    },\n    title() {\n      if (this.showBatchCollectionModal) {\n        return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])\n      }\n      return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''\n    },\n    collections() {\n      return this.$store.state.libraries.collections || []\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    selectedLibraryItem() {\n      return this.$store.state.selectedLibraryItem\n    },\n    selectedLibraryItemId() {\n      return this.selectedLibraryItem ? this.selectedLibraryItem.id : null\n    },\n    sortedCollections() {\n      return this.collections\n        .map((c) => {\n          var includesBook = false\n          if (this.showBatchCollectionModal) {\n            // Only show collection added if all books are in the collection\n            var collectionBookIds = c.books.map((b) => b.id)\n            includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))\n          } else {\n            includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)\n          }\n\n          return {\n            isBookIncluded: includesBook,\n            ...c\n          }\n        })\n        .sort((a, b) => (a.isBookIncluded ? -1 : 1))\n    },\n    showBatchCollectionModal() {\n      return this.$store.state.globals.showBatchCollectionModal\n    },\n    selectedBookIds() {\n      return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    }\n  },\n  methods: {\n    loadCollections() {\n      this.processing = true\n      this.$axios\n        .$get(`/api/libraries/${this.currentLibraryId}/collections`)\n        .then((data) => {\n          if (data.results) {\n            this.$store.commit('libraries/setCollections', data.results || [])\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to get collections', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    removeFromCollection(collection) {\n      if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return\n      this.processing = true\n\n      if (this.showBatchCollectionModal) {\n        // BATCH Remove books\n        this.$axios\n          .$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })\n          .then((updatedCollection) => {\n            console.log(`Books removed from collection`, updatedCollection)\n            this.processing = false\n          })\n          .catch((error) => {\n            console.error('Failed to remove books from collection', error)\n            this.$toast.error(this.$strings.ToastRemoveFailed)\n            this.processing = false\n          })\n      } else {\n        // Remove single book\n        this.$axios\n          .$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)\n          .then((updatedCollection) => {\n            console.log(`Book removed from collection`, updatedCollection)\n            this.processing = false\n          })\n          .catch((error) => {\n            console.error('Failed to remove book from collection', error)\n            this.$toast.error(this.$strings.ToastRemoveFailed)\n            this.processing = false\n          })\n      }\n    },\n    addToCollection(collection) {\n      if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return\n      this.processing = true\n\n      if (this.showBatchCollectionModal) {\n        // BATCH Add books\n        this.$axios\n          .$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })\n          .then((updatedCollection) => {\n            console.log(`Books added to collection`, updatedCollection)\n            this.processing = false\n          })\n          .catch((error) => {\n            console.error('Failed to add books to collection', error)\n            this.$toast.error(this.$strings.ToastCollectionItemsAddFailed)\n            this.processing = false\n          })\n      } else {\n        if (!this.selectedLibraryItemId) return\n\n        this.$axios\n          .$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })\n          .then((updatedCollection) => {\n            console.log(`Book added to collection`, updatedCollection)\n            this.processing = false\n          })\n          .catch((error) => {\n            console.error('Failed to add book to collection', error)\n            this.$toast.error(this.$strings.ToastCollectionItemsAddFailed)\n            this.processing = false\n          })\n      }\n    },\n    submitCreateCollection() {\n      if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {\n        return\n      }\n      this.processing = true\n\n      var books = this.showBatchCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]\n      var newCollection = {\n        books: books,\n        libraryId: this.currentLibraryId,\n        name: this.newCollectionName\n      }\n\n      this.$axios\n        .$post('/api/collections', newCollection)\n        .then((data) => {\n          console.log('New Collection Created', data)\n          this.processing = false\n          this.newCollectionName = ''\n        })\n        .catch((error) => {\n          console.error('Failed to create collection', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg)\n          this.processing = false\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/collections/CollectionItem.vue",
    "content": "<template>\n  <div class=\"flex items-center px-4 py-2 justify-start relative hover:bg-black-400\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <div v-if=\"isBookIncluded\" class=\"absolute top-0 left-0 h-full w-1 bg-success z-10\" />\n    <div class=\"w-20 max-w-20 text-center\">\n      <covers-collection-cover :book-items=\"books\" :width=\"80\" :height=\"40 * bookCoverAspectRatio\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n    </div>\n    <div class=\"grow overflow-hidden px-2\">\n      <nuxt-link :to=\"`/collection/${collection.id}`\" class=\"pl-2 pr-2 truncate hover:underline cursor-pointer\" @click.native=\"clickNuxtLink\">{{ collection.name }}</nuxt-link>\n    </div>\n    <div class=\"h-full flex items-center justify-end transform\" :class=\"isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'\">\n      <ui-btn v-if=\"!isBookIncluded\" color=\"bg-success\" :padding-x=\"3\" small class=\"h-9\" @click.stop=\"clickAdd\"><span class=\"material-symbols text-2xl pt-px\">add</span></ui-btn>\n      <ui-btn v-else color=\"bg-error\" :padding-x=\"3\" class=\"h-9\" small @click.stop=\"clickRem\"><span class=\"material-symbols text-2xl pt-px\">remove</span></ui-btn>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    collection: {\n      type: Object,\n      default: () => {}\n    },\n    bookCoverAspectRatio: Number\n  },\n  data() {\n    return {\n      isHovering: false\n    }\n  },\n  computed: {\n    isBookIncluded() {\n      return !!this.collection.isBookIncluded\n    },\n    books() {\n      return this.collection.books || []\n    }\n  },\n  methods: {\n    clickNuxtLink() {\n      this.$emit('close')\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    clickAdd() {\n      this.$emit('add', this.collection)\n    },\n    clickRem() {\n      this.$emit('remove', this.collection)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/collections/EditModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"edit-collection\" :width=\"700\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.HeaderCollection }}</p>\n      </div>\n    </template>\n    <div class=\"p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n      <template v-if=\"!showImageUploader\">\n        <form @submit.prevent=\"submitForm\">\n          <div class=\"flex flex-wrap\">\n            <div class=\"w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block\">\n              <covers-collection-cover :book-items=\"books\" :width=\"200\" :height=\"100 * bookCoverAspectRatio\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n            </div>\n            <div class=\"grow px-4\">\n              <ui-text-input-with-label v-model=\"newCollectionName\" :label=\"$strings.LabelName\" class=\"mb-2\" />\n\n              <ui-textarea-with-label v-model=\"newCollectionDescription\" :label=\"$strings.LabelDescription\" />\n            </div>\n          </div>\n          <div class=\"absolute bottom-0 left-0 right-0 w-full py-4 px-4 flex\">\n            <ui-btn v-if=\"userCanDelete\" small color=\"bg-error\" type=\"button\" @click.stop=\"removeClick\">{{ $strings.ButtonRemove }}</ui-btn>\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSave }}</ui-btn>\n          </div>\n        </form>\n      </template>\n      <template v-else>\n        <div class=\"flex items-center mb-3\">\n          <div class=\"hover:bg-white/10 cursor-pointer h-11 w-11 flex items-center justify-center rounded-full\" @click=\"showImageUploader = false\">\n            <span class=\"material-symbols text-4xl\">arrow_back</span>\n          </div>\n          <p class=\"ml-2 text-xl mb-1\">Collection Cover Image</p>\n        </div>\n        <div class=\"flex mb-4\">\n          <ui-btn small class=\"mr-2\">Upload</ui-btn>\n          <ui-text-input v-model=\"newCoverImage\" class=\"grow\" placeholder=\"Collection Cover Image\" />\n        </div>\n        <div class=\"flex justify-end\">\n          <ui-btn color=\"bg-success\">Upload</ui-btn>\n        </div>\n      </template>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      processing: false,\n      newCollectionName: null,\n      newCollectionDescription: null,\n      showImageUploader: false\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showEditCollectionModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowEditCollectionModal', val)\n      }\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    collection() {\n      return this.$store.state.globals.selectedCollection || {}\n    },\n    collectionName() {\n      return this.collection.name\n    },\n    books() {\n      return this.collection.books || []\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    }\n  },\n  methods: {\n    init() {\n      this.newCollectionName = this.collectionName\n      this.newCollectionDescription = this.collection.description || ''\n    },\n    removeClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteCollection()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteCollection() {\n      this.processing = true\n      this.$axios\n        .$delete(`/api/collections/${this.collection.id}`)\n        .then(() => {\n          this.show = false\n          this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to remove collection', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submitForm() {\n      if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) {\n        return\n      }\n      if (!this.newCollectionName) {\n        return this.$toast.error(this.$strings.ToastNameRequired)\n      }\n\n      this.processing = true\n\n      var collectionUpdate = {\n        name: this.newCollectionName,\n        description: this.newCollectionDescription || null\n      }\n      this.$axios\n        .$patch(`/api/collections/${this.collection.id}`, collectionUpdate)\n        .then((collection) => {\n          console.log('Collection Updated', collection)\n          this.processing = false\n          this.show = false\n          this.$toast.success(this.$strings.ToastCollectionUpdateSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to update collection', error)\n          this.processing = false\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/emails/EReaderDeviceModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"ereader-device-edit\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300\">\n        <div class=\"w-full px-3 py-5 md:p-12\">\n          <div class=\"flex items-center -mx-1 mb-4\">\n            <div class=\"w-full md:w-1/2 px-1\">\n              <ui-text-input-with-label ref=\"ereaderNameInput\" v-model=\"newDevice.name\" :disabled=\"processing\" :label=\"$strings.LabelName\" />\n            </div>\n            <div class=\"w-full md:w-1/2 px-1\">\n              <ui-text-input-with-label ref=\"ereaderEmailInput\" v-model=\"newDevice.email\" :disabled=\"processing\" :label=\"$strings.LabelEmail\" />\n            </div>\n          </div>\n          <div class=\"flex items-center -mx-1 mb-4\">\n            <div class=\"w-full md:w-1/2 px-1\">\n              <ui-dropdown v-model=\"newDevice.availabilityOption\" :label=\"$strings.LabelDeviceIsAvailableTo\" :items=\"userAvailabilityOptions\" @input=\"availabilityOptionChanged\" />\n            </div>\n            <div class=\"w-full md:w-1/2 px-1\">\n              <ui-multi-select-dropdown v-if=\"newDevice.availabilityOption === 'specificUsers'\" v-model=\"newDevice.users\" :label=\"$strings.HeaderUsers\" :items=\"userOptions\" />\n            </div>\n          </div>\n\n          <div class=\"flex items-center pt-4\">\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    existingDevices: {\n      type: Array,\n      default: () => []\n    },\n    ereaderDevice: {\n      type: Object,\n      default: () => null\n    },\n    users: {\n      type: Array,\n      default: () => []\n    },\n    loadUsers: Function\n  },\n  data() {\n    return {\n      processing: false,\n      newDevice: {\n        name: '',\n        email: '',\n        availabilityOption: 'adminAndUp',\n        users: []\n      }\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      return !this.ereaderDevice ? 'Create Device' : 'Update Device'\n    },\n    userAvailabilityOptions() {\n      return [\n        {\n          text: this.$strings.LabelAdminUsersOnly,\n          value: 'adminOrUp'\n        },\n        {\n          text: this.$strings.LabelAllUsersExcludingGuests,\n          value: 'userOrUp'\n        },\n        {\n          text: this.$strings.LabelAllUsersIncludingGuests,\n          value: 'guestOrUp'\n        },\n        {\n          text: this.$strings.LabelSelectUsers,\n          value: 'specificUsers'\n        }\n      ]\n    },\n    userOptions() {\n      return this.users.map((u) => ({ text: u.username, value: u.id }))\n    }\n  },\n  methods: {\n    availabilityOptionChanged(option) {\n      if (option === 'specificUsers' && !this.users.length) {\n        this.callLoadUsers()\n      }\n    },\n    async callLoadUsers() {\n      this.processing = true\n      await this.loadUsers()\n      this.processing = false\n    },\n    submitForm() {\n      this.$refs.ereaderNameInput.blur()\n      this.$refs.ereaderEmailInput.blur()\n\n      if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {\n        this.$toast.error(this.$strings.ToastNameEmailRequired)\n        return\n      }\n\n      if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {\n        this.$toast.error(this.$strings.ToastSelectAtLeastOneUser)\n        return\n      }\n      if (this.newDevice.availabilityOption !== 'specificUsers') {\n        this.newDevice.users = []\n      }\n\n      this.newDevice.name = this.newDevice.name.trim()\n      this.newDevice.email = this.newDevice.email.trim()\n\n      if (!this.ereaderDevice) {\n        if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {\n          this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)\n          return\n        }\n\n        this.submitCreate()\n      } else {\n        if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {\n          this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)\n          return\n        }\n\n        this.submitUpdate()\n      }\n    },\n    submitUpdate() {\n      this.processing = true\n\n      const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)\n\n      const payload = {\n        ereaderDevices: [\n          ...existingDevicesWithoutThisOne,\n          {\n            ...this.newDevice\n          }\n        ]\n      }\n\n      this.$axios\n        .$post(`/api/emails/ereader-devices`, payload)\n        .then((data) => {\n          this.$emit('update', data.ereaderDevices)\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to update device', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submitCreate() {\n      this.processing = true\n\n      const payload = {\n        ereaderDevices: [\n          ...this.existingDevices,\n          {\n            ...this.newDevice\n          }\n        ]\n      }\n\n      this.$axios\n        .$post('/api/emails/ereader-devices', payload)\n        .then((data) => {\n          this.$emit('update', data.ereaderDevices || [])\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to add device', error)\n          this.$toast.error(this.$strings.ToastDeviceAddFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    init() {\n      if (this.ereaderDevice) {\n        this.newDevice.name = this.ereaderDevice.name\n        this.newDevice.email = this.ereaderDevice.email\n        this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'\n        this.newDevice.users = this.ereaderDevice.users || []\n      } else {\n        this.newDevice.name = ''\n        this.newDevice.email = ''\n        this.newDevice.availabilityOption = 'adminOrUp'\n        this.newDevice.users = []\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/emails/UserEReaderDeviceModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"ereader-device-edit\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300\">\n        <div class=\"w-full px-3 py-5 md:p-12\">\n          <div class=\"flex items-center -mx-1 mb-4\">\n            <div class=\"w-full md:w-1/2 px-1\">\n              <ui-text-input-with-label ref=\"ereaderNameInput\" v-model=\"newDevice.name\" :disabled=\"processing\" :label=\"$strings.LabelName\" />\n            </div>\n            <div class=\"w-full md:w-1/2 px-1\">\n              <ui-text-input-with-label ref=\"ereaderEmailInput\" v-model=\"newDevice.email\" :disabled=\"processing\" :label=\"$strings.LabelEmail\" />\n            </div>\n          </div>\n\n          <div class=\"flex items-center pt-4\">\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    existingDevices: {\n      type: Array,\n      default: () => []\n    },\n    ereaderDevice: {\n      type: Object,\n      default: () => null\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      newDevice: {\n        name: '',\n        email: '',\n        availabilityOption: 'adminAndUp',\n        users: []\n      }\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    title() {\n      return !this.ereaderDevice ? 'Create Device' : 'Update Device'\n    }\n  },\n  methods: {\n    submitForm() {\n      this.$refs.ereaderNameInput.blur()\n      this.$refs.ereaderEmailInput.blur()\n\n      if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {\n        this.$toast.error(this.$strings.ToastNameEmailRequired)\n        return\n      }\n\n      this.newDevice.name = this.newDevice.name.trim()\n      this.newDevice.email = this.newDevice.email.trim()\n\n      // Only catches duplicate names for the current user\n      // Duplicates with other users caught on server side\n      if (!this.ereaderDevice) {\n        if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {\n          this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)\n          return\n        }\n\n        this.submitCreate()\n      } else {\n        if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {\n          this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)\n          return\n        }\n\n        this.submitUpdate()\n      }\n    },\n    submitUpdate() {\n      this.processing = true\n\n      const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)\n\n      const payload = {\n        ereaderDevices: [\n          ...existingDevicesWithoutThisOne,\n          {\n            ...this.newDevice\n          }\n        ]\n      }\n\n      this.$axios\n        .$post(`/api/me/ereader-devices`, payload)\n        .then((data) => {\n          this.$emit('update', data.ereaderDevices)\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to update device', error)\n          if (error.response?.data?.toLowerCase().includes('duplicate')) {\n            this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)\n          } else {\n            this.$toast.error(this.$strings.ToastDeviceAddFailed)\n          }\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submitCreate() {\n      this.processing = true\n\n      const payload = {\n        ereaderDevices: [\n          ...this.existingDevices,\n          {\n            ...this.newDevice\n          }\n        ]\n      }\n\n      this.$axios\n        .$post('/api/me/ereader-devices', payload)\n        .then((data) => {\n          this.$emit('update', data.ereaderDevices || [])\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to add device', error)\n          if (error.response?.data?.toLowerCase().includes('duplicate')) {\n            this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)\n          } else {\n            this.$toast.error(this.$strings.ToastDeviceAddFailed)\n          }\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    init() {\n      if (this.ereaderDevice) {\n        this.newDevice.name = this.ereaderDevice.name\n        this.newDevice.email = this.ereaderDevice.email\n        this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'specificUsers'\n        this.newDevice.users = this.ereaderDevice.users || [this.user.id]\n      } else {\n        this.newDevice.name = ''\n        this.newDevice.email = ''\n        this.newDevice.availabilityOption = 'specificUsers'\n        this.newDevice.users = [this.user.id]\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/item/EditModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"edit-book\" :width=\"800\" :height=\"height\" :processing=\"processing\" :content-margin-top=\"marginTop\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none\">\n        <h1 class=\"text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none\">{{ title }}</h1>\n      </div>\n    </template>\n    <div role=\"tablist\" class=\"absolute -top-10 left-0 z-10 w-full flex\">\n      <template v-for=\"tab in availableTabs\">\n        <button :key=\"tab.id\" role=\"tab\" class=\"w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base\" :class=\"selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'\" @click=\"selectTab(tab.id)\">{{ tab.title }}</button>\n      </template>\n    </div>\n\n    <div role=\"tabpanel\" class=\"w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative\">\n      <component v-if=\"libraryItem && show\" :is=\"tabName\" :library-item=\"libraryItem\" :processing.sync=\"processing\" @close=\"show = false\" @selectTab=\"selectTab\" />\n    </div>\n\n    <div v-show=\"canGoPrev\" class=\"absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6\">\n      <button class=\"material-symbols text-5xl text-white/50 hover:text-white/90 cursor-pointer pointer-events-auto\" :aria-label=\"$strings.ButtonNext\" @click.stop.prevent=\"goPrevBook\" @mousedown.prevent>arrow_back_ios</button>\n    </div>\n    <div v-show=\"canGoNext\" class=\"absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6\">\n      <button class=\"material-symbols text-5xl text-white/50 hover:text-white/90 cursor-pointer pointer-events-auto\" :aria-label=\"$strings.ButtonPrevious\" @click.stop.prevent=\"goNextBook\" @mousedown.prevent>arrow_forward_ios</button>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      processing: false,\n      libraryItem: null,\n      availableHeight: 0,\n      marginTop: 0\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          var availableTabIds = this.availableTabs.map((tab) => tab.id)\n          if (!availableTabIds.length) {\n            this.show = false\n            return\n          }\n\n          if (!availableTabIds.includes(this.selectedTab)) {\n            this.selectedTab = availableTabIds[0]\n          }\n\n          this.libraryItem = null\n          this.init()\n          this.registerListeners()\n        } else {\n          this.unregisterListeners()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.showEditModal\n      },\n      set(val) {\n        this.$store.commit('setShowEditModal', val)\n      }\n    },\n    selectedTab: {\n      get() {\n        return this.$store.state.editModalTab\n      },\n      set(val) {\n        this.$store.commit('setEditModalTab', val)\n      }\n    },\n    height() {\n      return Math.min(this.availableHeight, 650)\n    },\n    tabs() {\n      return [\n        {\n          id: 'details',\n          title: this.$strings.HeaderDetails,\n          component: 'modals-item-tabs-details'\n        },\n        {\n          id: 'cover',\n          title: this.$strings.HeaderCover,\n          component: 'modals-item-tabs-cover'\n        },\n        {\n          id: 'chapters',\n          title: this.$strings.HeaderChapters,\n          component: 'modals-item-tabs-chapters',\n          mediaType: 'book'\n        },\n        {\n          id: 'episodes',\n          title: this.$strings.HeaderEpisodes,\n          component: 'modals-item-tabs-episodes',\n          mediaType: 'podcast'\n        },\n        {\n          id: 'files',\n          title: this.$strings.HeaderFiles,\n          component: 'modals-item-tabs-files'\n        },\n        {\n          id: 'match',\n          title: this.$strings.HeaderMatch,\n          component: 'modals-item-tabs-match'\n        },\n        {\n          id: 'tools',\n          title: this.$strings.HeaderTools,\n          component: 'modals-item-tabs-tools',\n          mediaType: 'book',\n          admin: true\n        },\n        {\n          id: 'schedule',\n          title: this.$strings.HeaderSchedule,\n          component: 'modals-item-tabs-schedule',\n          mediaType: 'podcast',\n          admin: true\n        }\n      ]\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    selectedLibraryItem() {\n      return this.$store.state.selectedLibraryItem || {}\n    },\n    selectedLibraryItemId() {\n      return this.selectedLibraryItem.id\n    },\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    availableTabs() {\n      if (!this.userCanUpdate && !this.userCanDownload) return []\n      return this.tabs.filter((tab) => {\n        if (tab.mediaType && this.mediaType !== tab.mediaType) return false\n        if (tab.admin && !this.userIsAdminOrUp) return false\n\n        if (tab.id === 'tools' && this.isMissing) return false\n        if (tab.id === 'chapters' && this.isEBookOnly) return false\n\n        if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true\n        if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true\n        if (tab.id === 'match' && this.userCanUpdate) return true\n        return false\n      })\n    },\n    tabName() {\n      var _tab = this.tabs.find((t) => t.id === this.selectedTab)\n      return _tab ? _tab.component : ''\n    },\n    isMissing() {\n      return this.selectedLibraryItem.isMissing\n    },\n    isEBookOnly() {\n      return this.media.ebookFile && !this.media.tracks?.length\n    },\n    mediaType() {\n      return this.libraryItem?.mediaType || null\n    },\n    title() {\n      return this.mediaMetadata.title || 'No Title'\n    },\n    bookshelfBookIds() {\n      return this.$store.state.bookshelfBookIds || []\n    },\n    currentBookshelfIndex() {\n      if (!this.bookshelfBookIds.length) return 0\n      return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)\n    },\n    canGoPrev() {\n      return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0\n    },\n    canGoNext() {\n      return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1\n    }\n  },\n  methods: {\n    async goPrevBook() {\n      if (this.currentBookshelfIndex - 1 < 0) return\n      // Remove focus from active input\n      document.activeElement?.blur?.()\n\n      var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]\n      this.processing = true\n      var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {\n        var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'\n        this.$toast.error(errorMsg)\n        return null\n      })\n      this.processing = false\n      if (prevBook) {\n        this.unregisterListeners()\n        this.libraryItem = prevBook\n        this.$store.commit('setSelectedLibraryItem', prevBook)\n        this.$nextTick(this.registerListeners)\n      } else {\n        console.error('Book not found', prevBookId)\n      }\n    },\n    async goNextBook() {\n      if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return\n      // Remove focus from active input\n      document.activeElement?.blur?.()\n\n      this.processing = true\n      var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]\n      var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {\n        var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'\n        this.$toast.error(errorMsg)\n        return null\n      })\n      this.processing = false\n      if (nextBook) {\n        this.unregisterListeners()\n        this.libraryItem = nextBook\n        this.$store.commit('setSelectedLibraryItem', nextBook)\n        this.$nextTick(this.registerListeners)\n      } else {\n        console.error('Book not found', nextBookId)\n      }\n    },\n    selectTab(tab) {\n      if (this.selectedTab === tab) return\n      if (this.availableTabs.find((t) => t.id === tab)) {\n        this.selectedTab = tab\n        this.processing = false\n      }\n    },\n    libraryItemUpdated(expandedLibraryItem) {\n      this.libraryItem = expandedLibraryItem\n    },\n    init() {\n      this.fetchFull()\n    },\n    async fetchFull() {\n      try {\n        this.processing = true\n        this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)\n        this.processing = false\n      } catch (error) {\n        console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)\n        this.processing = false\n        this.show = false\n      }\n    },\n    hotkey(action) {\n      if (action === this.$hotkeys.Modal.NEXT_PAGE) {\n        this.goNextBook()\n      } else if (action === this.$hotkeys.Modal.PREV_PAGE) {\n        this.goPrevBook()\n      }\n    },\n    registerListeners() {\n      window.addEventListener('orientationchange', this.orientationChange)\n      this.$eventBus.$on('modal-hotkey', this.hotkey)\n      this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)\n    },\n    unregisterListeners() {\n      window.removeEventListener('orientationchange', this.orientationChange)\n      this.$eventBus.$off('modal-hotkey', this.hotkey)\n      this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)\n    },\n    orientationChange() {\n      setTimeout(this.setHeight, 50)\n    },\n    setHeight() {\n      const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight\n\n      this.marginTop = smAndBelow ? 90 : 75\n      const heightModifier = smAndBelow ? 95 : 150\n      this.availableHeight = window.innerHeight - heightModifier\n    }\n  },\n  mounted() {\n    this.setHeight()\n  },\n  beforeDestroy() {\n    this.unregisterListeners()\n  }\n}\n</script>\n\n<style scoped>\n.tab {\n  height: 40px;\n}\n.tab.tab-selected {\n  height: 41px;\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Chapters.vue",
    "content": "<template>\n  <div class=\"w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6\">\n    <div class=\"w-full mb-4\">\n      <tables-chapters-table v-if=\"chapters.length\" :library-item=\"libraryItem\" keep-open @close=\"closeModal\" />\n      <div v-if=\"!chapters.length\" class=\"py-4 text-center\">\n        <p class=\"mb-8 text-xl\">{{ $strings.MessageNoChapters }}</p>\n        <ui-btn v-if=\"userCanUpdate\" :to=\"`/audiobook/${libraryItem.id}/chapters`\" @click=\"clickAddChapters\">{{ $strings.ButtonAddChapters }}</ui-btn>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    chapters() {\n      return this.media.chapters || []\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    }\n  },\n  methods: {\n    closeModal() {\n      this.$emit('close')\n    },\n    clickAddChapters() {\n      if (this.$route.name === 'audiobook-id-chapters' && this.$route.params?.id === this.libraryItem?.id) {\n        this.closeModal()\n      }\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Cover.vue",
    "content": "<template>\n  <div class=\"w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative\">\n    <div class=\"flex flex-col sm:flex-row mb-4\">\n      <div class=\"relative self-center md:self-start\">\n        <covers-preview-cover :src=\"coverUrl\" :width=\"120\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n\n        <!-- book cover overlay -->\n        <div v-if=\"media.coverPath\" class=\"absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100\">\n          <div class=\"absolute top-0 left-0 w-full h-16 bg-linear-to-b from-black-600 to-transparent\" />\n          <div v-if=\"userCanDelete\" class=\"p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-xs\" @click=\"removeCover\">\n            <ui-tooltip direction=\"top\" :text=\"$strings.LabelRemoveCover\">\n              <span class=\"material-symbols text-2xl\">delete</span>\n            </ui-tooltip>\n          </div>\n        </div>\n      </div>\n      <div class=\"grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0\">\n        <div class=\"flex items-center\">\n          <div v-if=\"userCanUpload\" class=\"w-10 md:w-40 pr-2 md:min-w-32\">\n            <ui-file-input ref=\"fileInput\" @change=\"fileUploadSelected\">\n              <span class=\"hidden md:inline-block\">{{ $strings.ButtonUploadCover }}</span>\n              <span class=\"material-symbols text-2xl inline-block md:hidden!\">upload</span>\n            </ui-file-input>\n          </div>\n\n          <form @submit.prevent=\"submitForm\" class=\"flex grow\">\n            <ui-text-input v-model=\"imageUrl\" :placeholder=\"$strings.LabelImageURLFromTheWeb\" class=\"h-9 w-full\" />\n            <ui-btn color=\"bg-success\" type=\"submit\" :padding-x=\"4\" :disabled=\"!imageUrl\" class=\"ml-2 sm:ml-3 w-24 h-9\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </form>\n        </div>\n\n        <div v-if=\"localCovers.length\" class=\"mb-4 mt-6 border-t border-b border-white/10\">\n          <div class=\"flex items-center justify-center py-2\">\n            <p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>\n            <div class=\"grow\" />\n            <ui-btn small @click=\"showLocalCovers = !showLocalCovers\">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>\n          </div>\n\n          <div v-if=\"showLocalCovers\" class=\"flex items-center justify-center flex-wrap pb-2\">\n            <template v-for=\"localCoverFile in localCovers\">\n              <div :key=\"localCoverFile.ino\" class=\"m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer\" :class=\"localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''\" @click=\"setCover(localCoverFile)\">\n                <div class=\"h-24 bg-primary\" :style=\"{ width: 96 / bookCoverAspectRatio + 'px' }\">\n                  <covers-preview-cover :src=\"localCoverFile.localPath\" :width=\"96 / bookCoverAspectRatio\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n                </div>\n              </div>\n            </template>\n          </div>\n        </div>\n      </div>\n    </div>\n    <form @submit.prevent=\"submitSearchForm\">\n      <div class=\"flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1\">\n        <div class=\"w-48 grow p-1\">\n          <ui-dropdown v-model=\"provider\" :items=\"providers\" :disabled=\"searchInProgress\" :label=\"$strings.LabelProvider\" small />\n        </div>\n        <div class=\"w-72 grow p-1\">\n          <ui-text-input-with-label v-model=\"searchTitle\" :disabled=\"searchInProgress\" :label=\"searchTitleLabel\" :placeholder=\"$strings.PlaceholderSearch\" />\n        </div>\n        <div v-show=\"provider != 'itunes' && provider != 'audiobookcovers'\" class=\"w-72 grow p-1\">\n          <ui-text-input-with-label v-model=\"searchAuthor\" :disabled=\"searchInProgress\" :label=\"$strings.LabelAuthor\" />\n        </div>\n        <ui-btn v-if=\"!searchInProgress\" class=\"mt-5 ml-1 md:min-w-24\" :padding-x=\"4\" type=\"submit\">{{ $strings.ButtonSearch }}</ui-btn>\n        <ui-btn v-else class=\"mt-5 ml-1 md:min-w-24\" :padding-x=\"4\" type=\"button\" color=\"bg-error\" @click.prevent=\"cancelCurrentSearch\">{{ $strings.ButtonCancel }}</ui-btn>\n      </div>\n    </form>\n    <div v-if=\"hasSearched\" class=\"flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full\">\n      <p v-if=\"searchInProgress && !coversFound.length\" class=\"text-gray-300 py-4\">{{ $strings.MessageLoading }}</p>\n      <p v-else-if=\"!searchInProgress && !coversFound.length\" class=\"text-gray-300 py-4\">{{ $strings.MessageNoCoversFound }}</p>\n      <template v-for=\"cover in coversFound\">\n        <div :key=\"cover\" class=\"m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer\" :class=\"cover === coverPath ? 'border-yellow-300' : ''\" @click=\"updateCover(cover)\">\n          <covers-preview-cover :src=\"cover\" :width=\"80\" show-open-new-tab :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n        </div>\n      </template>\n    </div>\n\n    <div v-if=\"previewUpload\" class=\"absolute top-0 left-0 w-full h-full z-10 bg-bg p-8\">\n      <p class=\"text-lg\">{{ $strings.HeaderPreviewCover }}</p>\n      <span class=\"absolute top-4 right-4 material-symbols text-2xl cursor-pointer\" @click=\"resetCoverPreview\">close</span>\n      <div class=\"flex justify-center py-4\">\n        <covers-preview-cover :src=\"previewUpload\" :width=\"240\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n      </div>\n      <div class=\"absolute bottom-0 right-0 flex py-4 px-5\">\n        <ui-btn :disabled=\"processingUpload\" class=\"mx-2\" @click=\"resetCoverPreview\">{{ $strings.ButtonReset }}</ui-btn>\n        <ui-btn :loading=\"processingUpload\" color=\"bg-success\" @click=\"submitCoverUpload\">{{ $strings.ButtonUpload }}</ui-btn>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      processingUpload: false,\n      searchTitle: null,\n      searchAuthor: null,\n      imageUrl: null,\n      coversFound: [],\n      hasSearched: false,\n      showLocalCovers: false,\n      previewUpload: null,\n      selectedFile: null,\n      provider: 'google',\n      currentSearchRequestId: null,\n      searchInProgress: false,\n      socketListenersActive: false\n    }\n  },\n  watch: {\n    libraryItem: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    isProcessing: {\n      get() {\n        return this.processing\n      },\n      set(val) {\n        this.$emit('update:processing', val)\n      }\n    },\n    providers() {\n      if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders\n      return this.$store.state.scanners.bookCoverProviders\n    },\n    searchTitleLabel() {\n      if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN\n      else if (this.provider == 'itunes') return this.$strings.LabelSearchTerm\n      return this.$strings.LabelSearchTitle\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    libraryItemId() {\n      return this.libraryItem?.id || null\n    },\n    libraryItemUpdatedAt() {\n      return this.libraryItem?.updatedAt || null\n    },\n    mediaType() {\n      return this.libraryItem?.mediaType || null\n    },\n    isPodcast() {\n      return this.mediaType == 'podcast'\n    },\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    coverPath() {\n      return this.media.coverPath\n    },\n    coverUrl() {\n      if (!this.coverPath) {\n        return this.$store.getters['globals/getPlaceholderCoverSrc']\n      }\n      return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId, this.libraryItemUpdatedAt, true)\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    libraryFiles() {\n      return this.libraryItem?.libraryFiles || []\n    },\n    userCanUpload() {\n      return this.$store.getters['user/getUserCanUpload']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    localCovers() {\n      return this.libraryFiles\n        .filter((f) => f.fileType === 'image')\n        .map((file) => {\n          const _file = { ...file }\n          _file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`\n          return _file\n        })\n    },\n    socket() {\n      return this.$root.socket\n    }\n  },\n  methods: {\n    submitCoverUpload() {\n      this.processingUpload = true\n      var form = new FormData()\n      form.set('cover', this.selectedFile)\n\n      this.$axios\n        .$post(`/api/items/${this.libraryItemId}/cover`, form)\n        .then((data) => {\n          if (data.error) {\n            this.$toast.error(data.error)\n          } else {\n            this.resetCoverPreview()\n          }\n          this.processingUpload = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          if (error.response && error.response.data) {\n            this.$toast.error(error.response.data)\n          } else {\n            this.$toast.error(this.$strings.ToastUnknownError)\n          }\n          this.processingUpload = false\n        })\n    },\n    resetCoverPreview() {\n      if (this.$refs.fileInput) {\n        this.$refs.fileInput.reset()\n      }\n      this.previewUpload = null\n      this.selectedFile = null\n    },\n    fileUploadSelected(file) {\n      this.previewUpload = URL.createObjectURL(file)\n      this.selectedFile = file\n    },\n    init() {\n      this.showLocalCovers = false\n      if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {\n        this.coversFound = []\n        this.hasSearched = false\n      }\n      this.imageUrl = ''\n      this.searchTitle = this.mediaMetadata.title || ''\n      this.searchAuthor = this.mediaMetadata.authorName || ''\n      if (this.isPodcast) this.provider = 'itunes'\n      else {\n        // Migrate from 'all' to 'best' (only once)\n        const migrationKey = 'book-cover-provider-migrated'\n        const currentProvider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'\n\n        if (!localStorage.getItem(migrationKey) && currentProvider === 'all') {\n          localStorage.setItem('book-cover-provider', 'best')\n          localStorage.setItem(migrationKey, 'true')\n          this.provider = 'best'\n        } else {\n          this.provider = currentProvider\n        }\n      }\n    },\n    removeCover() {\n      if (!this.coverPath) {\n        return\n      }\n      this.isProcessing = true\n      this.$axios\n        .$delete(`/api/items/${this.libraryItemId}/cover`)\n        .then(() => {})\n        .catch((error) => {\n          console.error('Failed to remove cover', error)\n          if (error.response?.data) {\n            this.$toast.error(error.response.data)\n          }\n        })\n        .finally(() => {\n          this.isProcessing = false\n        })\n    },\n    submitForm() {\n      this.updateCover(this.imageUrl)\n    },\n    async updateCover(cover) {\n      if (!cover.startsWith('http:') && !cover.startsWith('https:')) {\n        this.$toast.error(this.$strings.ToastInvalidUrl)\n        return\n      }\n\n      this.isProcessing = true\n      this.$axios\n        .$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })\n        .then(() => {\n          this.imageUrl = ''\n        })\n        .catch((error) => {\n          console.error('Failed to update cover', error)\n          this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)\n        })\n        .finally(() => {\n          this.isProcessing = false\n        })\n    },\n    getSearchQuery() {\n      var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`\n      if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`\n      if (this.isPodcast) searchQuery += '&podcast=1'\n      return searchQuery\n    },\n    persistProvider() {\n      try {\n        localStorage.setItem('book-cover-provider', this.provider)\n      } catch (error) {\n        console.error('PersistProvider', error)\n      }\n    },\n    generateRequestId() {\n      return `cover-search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`\n    },\n    addSocketListeners() {\n      if (!this.socket || this.socketListenersActive) return\n\n      this.socket.on('cover_search_result', this.handleSearchResult)\n      this.socket.on('cover_search_complete', this.handleSearchComplete)\n      this.socket.on('cover_search_error', this.handleSearchError)\n      this.socket.on('cover_search_provider_error', this.handleProviderError)\n      this.socket.on('cover_search_cancelled', this.handleSearchCancelled)\n      this.socket.on('disconnect', this.handleSocketDisconnect)\n      this.socketListenersActive = true\n    },\n    removeSocketListeners() {\n      if (!this.socket || !this.socketListenersActive) return\n\n      this.socket.off('cover_search_result', this.handleSearchResult)\n      this.socket.off('cover_search_complete', this.handleSearchComplete)\n      this.socket.off('cover_search_error', this.handleSearchError)\n      this.socket.off('cover_search_provider_error', this.handleProviderError)\n      this.socket.off('cover_search_cancelled', this.handleSearchCancelled)\n      this.socket.off('disconnect', this.handleSocketDisconnect)\n      this.socketListenersActive = false\n    },\n    handleSearchResult(data) {\n      if (data.requestId !== this.currentSearchRequestId) return\n\n      // Add new covers to the list (avoiding duplicates)\n      const newCovers = data.covers.filter((cover) => !this.coversFound.includes(cover))\n      this.coversFound.push(...newCovers)\n    },\n    handleSearchComplete(data) {\n      if (data.requestId !== this.currentSearchRequestId) return\n\n      this.searchInProgress = false\n      this.currentSearchRequestId = null\n    },\n    handleSearchError(data) {\n      if (data.requestId !== this.currentSearchRequestId) return\n\n      console.error('[Cover Search] Search error:', data.error)\n      this.$toast.error(this.$strings.ToastCoverSearchFailed)\n      this.searchInProgress = false\n      this.currentSearchRequestId = null\n    },\n    handleProviderError(data) {\n      if (data.requestId !== this.currentSearchRequestId) return\n\n      console.warn(`[Cover Search] Provider ${data.provider} failed:`, data.error)\n    },\n    handleSearchCancelled(data) {\n      if (data.requestId !== this.currentSearchRequestId) return\n\n      this.searchInProgress = false\n      this.currentSearchRequestId = null\n    },\n    handleSocketDisconnect() {\n      // If we were in the middle of a search, cancel it (server can't send results anymore)\n      if (this.searchInProgress && this.currentSearchRequestId) {\n        this.searchInProgress = false\n        this.currentSearchRequestId = null\n      }\n    },\n    cancelCurrentSearch() {\n      if (!this.currentSearchRequestId || !this.socket?.connected) {\n        console.error('[Cover Search] Socket not connected')\n        this.$toast.error(this.$strings.ToastConnectionNotAvailable)\n        return\n      }\n\n      this.socket.emit('cancel_cover_search', this.currentSearchRequestId)\n      this.currentSearchRequestId = null\n      this.searchInProgress = false\n    },\n    async submitSearchForm() {\n      if (!this.socket?.connected) {\n        console.error('[Cover Search] Socket not connected')\n        this.$toast.error(this.$strings.ToastConnectionNotAvailable)\n        return\n      }\n\n      // Cancel any existing search\n      if (this.searchInProgress) {\n        this.cancelCurrentSearch()\n      }\n\n      // Store provider in local storage\n      this.persistProvider()\n\n      // Setup socket listeners if not already done\n      this.addSocketListeners()\n\n      // Clear previous results\n      this.coversFound = []\n      this.hasSearched = true\n      this.searchInProgress = true\n\n      // Generate unique request ID\n      const requestId = this.generateRequestId()\n      this.currentSearchRequestId = requestId\n\n      // Emit search request via WebSocket\n      this.socket.emit('search_covers', {\n        requestId,\n        title: this.searchTitle,\n        author: this.searchAuthor || '',\n        provider: this.provider,\n        podcast: this.isPodcast\n      })\n    },\n    setCover(coverFile) {\n      this.isProcessing = true\n      this.$axios\n        .$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })\n        .catch((error) => {\n          console.error('Failed to set local cover', error)\n          this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)\n        })\n        .finally(() => {\n          this.isProcessing = false\n        })\n    }\n  },\n  mounted() {\n    // Setup socket listeners when component is mounted\n    this.addSocketListeners()\n    // Fetch providers if not already loaded\n    this.$store.dispatch('scanners/fetchProviders')\n  },\n  beforeDestroy() {\n    // Cancel any ongoing search when component is destroyed\n    if (this.searchInProgress) {\n      this.cancelCurrentSearch()\n    }\n    // Remove socket listeners\n    this.removeSocketListeners()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Details.vue",
    "content": "<template>\n  <div class=\"w-full h-full relative\">\n    <div id=\"formWrapper\" class=\"w-full overflow-y-auto\">\n      <widgets-book-details-edit v-if=\"mediaType == 'book'\" ref=\"itemDetailsEdit\" :library-item=\"libraryItem\" @submit=\"saveAndClose\" />\n      <widgets-podcast-details-edit v-else ref=\"itemDetailsEdit\" :library-item=\"libraryItem\" @submit=\"saveAndClose\" />\n    </div>\n\n    <div class=\"absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg\" :class=\"isScrollable ? 'box-shadow-md-up' : 'border-t border-white/5'\">\n      <div class=\"flex items-center px-4\">\n        <ui-tooltip :disabled=\"!!quickMatching\" :text=\"$getString('MessageQuickMatchDescription', [libraryProvider])\" direction=\"bottom\" class=\"mr-2 md:mr-4\">\n          <ui-btn v-if=\"userIsAdminOrUp\" :loading=\"quickMatching\" color=\"bg-bg\" type=\"button\" class=\"h-full\" small @click.stop.prevent=\"quickMatch\">{{ $strings.ButtonQuickMatch }}</ui-btn>\n        </ui-tooltip>\n\n        <ui-btn v-if=\"userIsAdminOrUp && !isFile\" :loading=\"rescanning\" :disabled=\"isLibraryScanning\" color=\"bg-bg\" type=\"button\" class=\"h-full\" small @click.stop.prevent=\"rescan\">{{ $strings.ButtonReScan }}</ui-btn>\n\n        <div class=\"grow\" />\n\n        <!-- desktop -->\n        <ui-btn @click=\"save\" class=\"mx-2 hidden md:block\">{{ $strings.ButtonSave }}</ui-btn>\n        <ui-btn @click=\"saveAndClose\" class=\"mx-2 hidden md:block\">{{ $strings.ButtonSaveAndClose }}</ui-btn>\n        <!-- mobile -->\n        <ui-btn @click=\"saveAndClose\" class=\"mx-2 md:hidden\">{{ $strings.ButtonSave }}</ui-btn>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      resettingProgress: false,\n      isScrollable: false,\n      rescanning: false,\n      quickMatching: false\n    }\n  },\n  computed: {\n    isProcessing: {\n      get() {\n        return this.processing\n      },\n      set(val) {\n        this.$emit('update:processing', val)\n      }\n    },\n    isFile() {\n      return !!this.libraryItem && this.libraryItem.isFile\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    isMissing() {\n      return !!this.libraryItem && !!this.libraryItem.isMissing\n    },\n    libraryItemId() {\n      return this.libraryItem ? this.libraryItem.id : null\n    },\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    mediaType() {\n      return this.libraryItem ? this.libraryItem.mediaType : null\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    libraryId() {\n      return this.libraryItem ? this.libraryItem.libraryId : null\n    },\n    libraryProvider() {\n      return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'\n    },\n    isLibraryScanning() {\n      if (!this.libraryId) return null\n      return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.libraryId)\n    }\n  },\n  methods: {\n    quickMatch() {\n      if (this.quickMatching) return\n      if (!this.$refs.itemDetailsEdit) return\n\n      var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()\n      if (!title) {\n        this.$toast.error(this.$strings.ToastTitleRequired)\n        return\n      }\n      this.quickMatching = true\n      var matchOptions = {\n        provider: this.libraryProvider,\n        title: title || null,\n        author: author || null\n      }\n      this.$axios\n        .$post(`/api/items/${this.libraryItemId}/match`, matchOptions)\n        .then((res) => {\n          this.quickMatching = false\n          if (res.warning) {\n            this.$toast.warning(res.warning)\n          } else if (res.updated) {\n            this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)\n          } else {\n            this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n          }\n        })\n        .catch((error) => {\n          var errMsg = error.response ? error.response.data || '' : ''\n          console.error('Failed to match', error)\n          this.$toast.error(errMsg || 'Failed to match')\n          this.quickMatching = false\n        })\n    },\n    rescan() {\n      this.rescanning = true\n      this.$axios\n        .$post(`/api/items/${this.libraryItemId}/scan`)\n        .then((data) => {\n          this.rescanning = false\n          var result = data.result\n          if (!result) {\n            this.$toast.error(this.$getString('ToastRescanFailed', [this.title]))\n          } else if (result === 'UPDATED') {\n            this.$toast.success(this.$strings.ToastRescanUpdated)\n          } else if (result === 'UPTODATE') {\n            this.$toast.success(this.$strings.ToastRescanUpToDate)\n          } else if (result === 'REMOVED') {\n            this.$toast.error(this.$strings.ToastRescanRemoved)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to scan library item', error)\n          this.$toast.error(this.$strings.ToastScanFailed)\n          this.rescanning = false\n        })\n    },\n    async saveAndClose() {\n      const wasUpdated = await this.save()\n      if (wasUpdated !== null) this.$emit('close')\n    },\n    async save() {\n      if (this.isProcessing) {\n        return null\n      }\n      if (!this.$refs.itemDetailsEdit) {\n        return null\n      }\n      var updatedDetails = this.$refs.itemDetailsEdit.getDetails()\n      if (!updatedDetails.hasChanges) {\n        this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n        return false\n      }\n      return this.updateDetails(updatedDetails)\n    },\n    async updateDetails(updatedDetails) {\n      this.isProcessing = true\n      var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {\n        console.error('Failed to update', error)\n        return false\n      })\n      this.isProcessing = false\n      if (updateResult) {\n        if (updateResult.updated) {\n          this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)\n          return true\n        } else {\n          this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n        }\n      }\n      return false\n    },\n    checkIsScrollable() {\n      this.$nextTick(() => {\n        var formWrapper = document.getElementById('formWrapper')\n        if (formWrapper) {\n          if (formWrapper.scrollHeight > formWrapper.clientHeight) {\n            this.isScrollable = true\n          } else {\n            this.isScrollable = false\n          }\n        }\n      })\n    },\n    setResizeObserver() {\n      try {\n        var formWrapper = document.getElementById('formWrapper')\n        if (formWrapper) {\n          this.$nextTick(() => {\n            const resizeObserver = new ResizeObserver(() => {\n              this.checkIsScrollable()\n            })\n            resizeObserver.observe(formWrapper)\n          })\n        }\n      } catch (error) {\n        console.error('Failed to set resize observer')\n      }\n    }\n  },\n  mounted() {\n    this.setResizeObserver()\n  }\n}\n</script>\n\n<style scoped>\n#formWrapper {\n  height: calc(100% - 80px);\n  max-height: calc(100% - 80px);\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Episodes.vue",
    "content": "<template>\n  <div class=\"w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6\">\n    <div class=\"w-full mb-4\">\n      <div v-if=\"userIsAdminOrUp\" class=\"flex items-end justify-end mb-4\">\n        <ui-text-input-with-label ref=\"lastCheckInput\" v-model=\"lastEpisodeCheckInput\" :disabled=\"checkingNewEpisodes\" type=\"datetime-local\" :label=\"$strings.LabelLookForNewEpisodesAfterDate\" class=\"max-w-xs mr-2\" />\n        <ui-text-input-with-label ref=\"maxEpisodesInput\" v-model=\"maxEpisodesToDownload\" :disabled=\"checkingNewEpisodes\" type=\"number\" :label=\"$strings.LabelLimit\" class=\"w-16 mr-2\" input-class=\"h-10\">\n          <div class=\"flex -mb-0.5\">\n            <p class=\"px-1 text-sm font-semibold\" :class=\"{ 'text-gray-400': checkingNewEpisodes }\">{{ $strings.LabelLimit }}</p>\n            <ui-tooltip direction=\"top\" :text=\"$strings.LabelMaxEpisodesToDownload\">\n              <span class=\"material-symbols text-base\">info</span>\n            </ui-tooltip>\n          </div>\n        </ui-text-input-with-label>\n        <ui-btn :loading=\"checkingNewEpisodes\" @click=\"checkForNewEpisodes\">{{ $strings.ButtonCheckAndDownloadNewEpisodes }}</ui-btn>\n      </div>\n\n      <div v-if=\"episodes.length\" class=\"w-full p-4 bg-primary\">\n        <p>{{ $strings.HeaderEpisodes }}</p>\n      </div>\n      <div v-if=\"!episodes.length\" class=\"flex my-4 text-center justify-center text-xl\">{{ $strings.MessageNoEpisodes }}</div>\n      <table v-else class=\"text-sm tracksTable\">\n        <tr>\n          <th class=\"text-center w-20 min-w-20\">{{ $strings.LabelEpisode }}</th>\n          <th class=\"text-left\">{{ $strings.LabelEpisodeTitle }}</th>\n          <th class=\"text-center w-28\">{{ $strings.LabelEpisodeDuration }}</th>\n          <th class=\"text-center w-28\">{{ $strings.LabelEpisodeSize }}</th>\n        </tr>\n        <tr v-for=\"episode in episodes\" :key=\"episode.id\">\n          <td class=\"text-center w-20 min-w-20\">\n            <p>{{ episode.episode }}</p>\n          </td>\n          <td dir=\"auto\">\n            {{ episode.title }}\n          </td>\n          <td class=\"font-mono text-center\">\n            {{ $secondsToTimestamp(episode.duration) }}\n          </td>\n          <td class=\"font-mono text-center\">\n            {{ $bytesPretty(episode.size) }}\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      checkingNewEpisodes: false,\n      lastEpisodeCheckInput: null,\n      maxEpisodesToDownload: 3\n    }\n  },\n  watch: {\n    lastEpisodeCheck: {\n      handler(newVal) {\n        if (newVal) {\n          this.setLastEpisodeCheckInput()\n        }\n      }\n    }\n  },\n  computed: {\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    autoDownloadEpisodes() {\n      return !!this.media.autoDownloadEpisodes\n    },\n    lastEpisodeCheck() {\n      return this.media.lastEpisodeCheck\n    },\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    libraryItemId() {\n      return this.libraryItem ? this.libraryItem.id : null\n    },\n    episodes() {\n      return this.media.episodes || []\n    }\n  },\n  methods: {\n    async checkForNewEpisodes() {\n      if (this.$refs.lastCheckInput) {\n        this.$refs.lastCheckInput.blur()\n      }\n      if (this.$refs.maxEpisodesInput) {\n        this.$refs.maxEpisodesInput.blur()\n      }\n\n      if (this.maxEpisodesToDownload < 0) {\n        this.maxEpisodesToDownload = 3\n        this.$toast.error(this.$strings.ToastInvalidMaxEpisodesToDownload)\n        return\n      }\n\n      this.checkingNewEpisodes = true\n      const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()\n\n      // If last episode check changed then update it first\n      if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {\n        var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {\n          console.error('Failed to update', error)\n          return false\n        })\n        console.log('updateResult', updateResult)\n      } else if (!lastEpisodeCheck) {\n        this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)\n        this.checkingNewEpisodes = false\n        return false\n      }\n\n      this.$axios\n        .$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)\n        .then((response) => {\n          if (response.episodes && response.episodes.length) {\n            console.log('New episodes', response.episodes.length)\n            this.$toast.success(this.$getString('ToastNewEpisodesFound', [response.episodes.length]))\n          } else {\n            this.$toast.info(this.$strings.ToastNoNewEpisodesFound)\n          }\n          this.checkingNewEpisodes = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'\n          this.$toast.error(errorMsg)\n          this.checkingNewEpisodes = false\n        })\n    },\n    setLastEpisodeCheckInput() {\n      this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, \"yyyy-MM-dd'T'HH:mm\") : null\n    }\n  },\n  mounted() {\n    this.setLastEpisodeCheckInput()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Files.vue",
    "content": "<template>\n  <div class=\"w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6\">\n    <tables-library-files-table expanded :library-item=\"libraryItem\" :is-missing=\"isMissing\" in-modal />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      tracks: []\n    }\n  },\n  watch: {\n    libraryItem: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  computed: {\n    media() {\n      return this.libraryItem.media || {}\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    isMissing() {\n      return this.libraryItem.isMissing\n    },\n    showDownload() {\n      return this.userCanDownload && !this.isMissing\n    }\n  },\n  methods: {\n    init() {\n      this.tracks = this.media.tracks || []\n    }\n  }\n}\n</script>"
  },
  {
    "path": "client/components/modals/item/tabs/Match.vue",
    "content": "<template>\n  <div id=\"match-wrapper\" class=\"w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative\">\n    <form @submit.prevent=\"submitSearch\">\n      <div class=\"flex flex-wrap md:flex-nowrap items-center justify-start -mx-1\">\n        <div v-if=\"providersLoaded\" class=\"w-36 px-1\">\n          <ui-dropdown v-model=\"provider\" :items=\"providers\" :label=\"$strings.LabelProvider\" small />\n        </div>\n        <div class=\"grow md:w-72 px-1\">\n          <ui-text-input-with-label v-model=\"searchTitle\" :label=\"searchTitleLabel\" :placeholder=\"$strings.PlaceholderSearch\" />\n        </div>\n        <div v-show=\"provider != 'itunes'\" class=\"w-60 md:w-72 px-1\">\n          <ui-text-input-with-label v-model=\"searchAuthor\" :label=\"$strings.LabelAuthor\" />\n        </div>\n        <ui-btn class=\"mt-5 ml-1\" type=\"submit\">{{ $strings.ButtonSearch }}</ui-btn>\n      </div>\n    </form>\n    <div v-show=\"processing\" class=\"flex h-full items-center justify-center\">\n      <p>{{ $strings.MessageLoading }}</p>\n    </div>\n    <div v-show=\"!processing && !searchResults.length && hasSearched\" class=\"flex h-full items-center justify-center\">\n      <p>{{ $strings.MessageNoResults }}</p>\n    </div>\n    <div v-show=\"!processing\" class=\"w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4\">\n      <template v-for=\"(res, index) in searchResults\">\n        <cards-book-match-card :key=\"index\" :book=\"res\" :current-book-duration=\"currentBookDuration\" :is-podcast=\"isPodcast\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" @select=\"selectMatch\" />\n      </template>\n    </div>\n    <div v-if=\"selectedMatchOrig\" class=\"absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden\">\n      <div class=\"flex mb-4\">\n        <div class=\"w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center cursor-pointer\" @click=\"clearSelectedMatch\">\n          <span class=\"material-symbols text-3xl\">arrow_back</span>\n        </div>\n        <p class=\"text-xl pl-3\">{{ $strings.HeaderUpdateDetails }}</p>\n      </div>\n      <ui-checkbox v-model=\"selectAll\" :label=\"$strings.LabelSelectAll\" checkbox-bg=\"bg\" @input=\"selectAllToggled\" />\n      <form @submit.prevent=\"submitMatchUpdate\">\n        <div v-if=\"selectedMatchOrig.cover\" class=\"flex flex-wrap md:flex-nowrap items-center justify-center\">\n          <div class=\"flex grow items-center py-2\">\n            <ui-checkbox v-model=\"selectedMatchUsage.cover\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n            <ui-text-input-with-label v-model=\"selectedMatch.cover\" :disabled=\"!selectedMatchUsage.cover\" readonly :label=\"$strings.LabelCover\" class=\"grow mx-4\" />\n          </div>\n\n          <div class=\"flex py-2\">\n            <div>\n              <p class=\"text-center text-gray-200\">{{ $strings.LabelNew }}</p>\n              <a :href=\"selectedMatch.cover\" target=\"_blank\" class=\"bg-primary\">\n                <covers-preview-cover :src=\"selectedMatch.cover\" :width=\"100\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n              </a>\n            </div>\n            <div v-if=\"media.coverPath\" class=\"ml-0.5\">\n              <p class=\"text-center text-gray-200\">{{ $strings.LabelCurrent }}</p>\n              <a :href=\"$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)\" target=\"_blank\" class=\"bg-primary\">\n                <covers-preview-cover :src=\"$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)\" :width=\"100\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n              </a>\n            </div>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.title\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.title\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.title\" :disabled=\"!selectedMatchUsage.title\" :label=\"$strings.LabelTitle\" />\n            <p v-if=\"mediaMetadata.title\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('title', mediaMetadata.title)\">{{ mediaMetadata.title || '' }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.subtitle\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.subtitle\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.subtitle\" :disabled=\"!selectedMatchUsage.subtitle\" :label=\"$strings.LabelSubtitle\" />\n            <p v-if=\"mediaMetadata.subtitle\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('subtitle', mediaMetadata.subtitle)\">{{ mediaMetadata.subtitle }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.author\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.author\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.author\" :disabled=\"!selectedMatchUsage.author\" :label=\"$strings.LabelAuthor\" />\n            <p v-if=\"mediaMetadata.authorName || (isPodcast && mediaMetadata.author)\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('author', isPodcast ? mediaMetadata.author : mediaMetadata.authorName)\">{{ isPodcast ? mediaMetadata.author : mediaMetadata.authorName }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.narrator\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.narrator\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-multi-select v-model=\"selectedMatch.narrator\" :items=\"narrators\" :disabled=\"!selectedMatchUsage.narrator\" :label=\"$strings.LabelNarrators\" />\n            <p v-if=\"mediaMetadata.narratorName\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('narrator', mediaMetadata.narrators)\">{{ mediaMetadata.narratorName }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.description\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.description\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-rich-text-editor v-model=\"selectedMatch.description\" :disabled=\"!selectedMatchUsage.description\" :label=\"$strings.LabelDescription\" />\n            <p v-if=\"mediaMetadata.description\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('description', mediaMetadata.description)\">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.publisher\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.publisher\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.publisher\" :disabled=\"!selectedMatchUsage.publisher\" :label=\"$strings.LabelPublisher\" />\n            <p v-if=\"mediaMetadata.publisher\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('publisher', mediaMetadata.publisher)\">{{ mediaMetadata.publisher }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.publishedYear\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.publishedYear\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.publishedYear\" :disabled=\"!selectedMatchUsage.publishedYear\" :label=\"$strings.LabelPublishYear\" />\n            <p v-if=\"mediaMetadata.publishedYear\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)\">{{ mediaMetadata.publishedYear }}</a>\n            </p>\n          </div>\n        </div>\n\n        <div v-if=\"selectedMatchOrig.series\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.series\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <widgets-series-input-widget v-model=\"selectedMatch.series\" :disabled=\"!selectedMatchUsage.series\" />\n            <p v-if=\"mediaMetadata.seriesName\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('series', mediaMetadata.series)\">{{ mediaMetadata.seriesName }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.genres?.length\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.genres\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-multi-select v-model=\"selectedMatch.genres\" :items=\"genres\" :disabled=\"!selectedMatchUsage.genres\" :label=\"$strings.LabelGenres\" />\n            <p v-if=\"mediaMetadata.genres?.length\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('genres', mediaMetadata.genres)\">{{ mediaMetadata.genres.join(', ') }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.tags\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.tags\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-multi-select v-model=\"selectedMatch.tags\" :items=\"tags\" :disabled=\"!selectedMatchUsage.tags\" :label=\"$strings.LabelTags\" />\n            <p v-if=\"media.tags?.length\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('tags', media.tags)\">{{ media.tags.join(', ') }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.language\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.language\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.language\" :disabled=\"!selectedMatchUsage.language\" :label=\"$strings.LabelLanguage\" />\n            <p v-if=\"mediaMetadata.language\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('language', mediaMetadata.language)\">{{ mediaMetadata.language }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.isbn\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.isbn\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.isbn\" :disabled=\"!selectedMatchUsage.isbn\" label=\"ISBN\" />\n            <p v-if=\"mediaMetadata.isbn\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('isbn', mediaMetadata.isbn)\">{{ mediaMetadata.isbn }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.asin\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.asin\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.asin\" :disabled=\"!selectedMatchUsage.asin\" label=\"ASIN\" />\n            <p v-if=\"mediaMetadata.asin\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('asin', mediaMetadata.asin)\">{{ mediaMetadata.asin }}</a>\n            </p>\n          </div>\n        </div>\n\n        <div v-if=\"selectedMatchOrig.itunesId\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.itunesId\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.itunesId\" type=\"number\" :disabled=\"!selectedMatchUsage.itunesId\" label=\"iTunes ID\" />\n            <p v-if=\"mediaMetadata.itunesId\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('itunesId', mediaMetadata.itunesId)\">{{ mediaMetadata.itunesId }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.feedUrl\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.feedUrl\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.feedUrl\" :disabled=\"!selectedMatchUsage.feedUrl\" label=\"RSS Feed URL\" />\n            <p v-if=\"mediaMetadata.feedUrl\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)\">{{ mediaMetadata.feedUrl }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.itunesPageUrl\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.itunesPageUrl\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.itunesPageUrl\" :disabled=\"!selectedMatchUsage.itunesPageUrl\" label=\"iTunes Page URL\" />\n            <p v-if=\"mediaMetadata.itunesPageUrl\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)\">{{ mediaMetadata.itunesPageUrl }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.releaseDate\" class=\"flex items-center py-2\">\n          <ui-checkbox v-model=\"selectedMatchUsage.releaseDate\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\">\n            <ui-text-input-with-label v-model=\"selectedMatch.releaseDate\" :disabled=\"!selectedMatchUsage.releaseDate\" :label=\"$strings.LabelReleaseDate\" />\n            <p v-if=\"mediaMetadata.releaseDate\" class=\"text-xs ml-1 text-white/60\">\n              {{ $strings.LabelCurrently }} <a :title=\"$strings.LabelClickToUseCurrentValue\" class=\"cursor-pointer hover:underline\" @click.stop=\"setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)\">{{ mediaMetadata.releaseDate }}</a>\n            </p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.explicit != null\" class=\"flex items-center pb-2\" :class=\"{ 'pt-2': mediaMetadata.explicit == null }\">\n          <ui-checkbox v-model=\"selectedMatchUsage.explicit\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\" :class=\"{ 'pt-4': mediaMetadata.explicit != null }\">\n            <ui-checkbox v-model=\"selectedMatch.explicit\" :label=\"$strings.LabelExplicit\" :disabled=\"!selectedMatchUsage.explicit\" :checkbox-bg=\"!selectedMatchUsage.explicit ? 'bg' : 'primary'\" border-color=\"gray-600\" label-class=\"pl-2 text-base font-semibold\" />\n            <p v-if=\"mediaMetadata.explicit != null\" class=\"text-xs ml-1 text-white/60\">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? $strings.LabelExplicitChecked : $strings.LabelExplicitUnchecked }}</p>\n          </div>\n        </div>\n        <div v-if=\"selectedMatchOrig.abridged != null\" class=\"flex items-center pb-2\" :class=\"{ 'pt-2': mediaMetadata.abridged == null }\">\n          <ui-checkbox v-model=\"selectedMatchUsage.abridged\" checkbox-bg=\"bg\" @input=\"checkboxToggled\" />\n          <div class=\"grow ml-4\" :class=\"{ 'pt-4': mediaMetadata.abridged != null }\">\n            <ui-checkbox v-model=\"selectedMatch.abridged\" :label=\"$strings.LabelAbridged\" :disabled=\"!selectedMatchUsage.abridged\" :checkbox-bg=\"!selectedMatchUsage.abridged ? 'bg' : 'primary'\" border-color=\"gray-600\" label-class=\"pl-2 text-base font-semibold\" />\n            <p v-if=\"mediaMetadata.abridged != null\" class=\"text-xs ml-1 text-white/60\">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? $strings.LabelAbridgedChecked : $strings.LabelAbridgedUnchecked }}</p>\n          </div>\n        </div>\n\n        <div class=\"flex items-center justify-end py-2\">\n          <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n        </div>\n      </form>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      libraryItemId: null,\n      searchTitle: null,\n      searchAuthor: null,\n      lastSearch: null,\n      provider: 'google',\n      searchResults: [],\n      hasSearched: false,\n      selectedMatch: null,\n      selectedMatchOrig: null,\n      waitingForProviders: false,\n      selectedMatchUsage: {\n        title: true,\n        subtitle: true,\n        cover: true,\n        author: true,\n        narrator: true,\n        description: true,\n        publisher: true,\n        publishedYear: true,\n        series: true,\n        genres: true,\n        tags: true,\n        language: true,\n        explicit: true,\n        asin: true,\n        isbn: true,\n        abridged: true,\n        // Podcast specific\n        itunesPageUrl: true,\n        itunesId: true,\n        feedUrl: true,\n        releaseDate: true\n      },\n      selectAll: true\n    }\n  },\n  watch: {\n    libraryItem: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    },\n    providersLoaded(isLoaded) {\n      // Complete initialization once providers are loaded\n      if (isLoaded && this.waitingForProviders) {\n        this.waitingForProviders = false\n        this.initProviderAndSearch()\n      }\n    }\n  },\n  computed: {\n    providersLoaded() {\n      return this.$store.getters['scanners/areProvidersLoaded']\n    },\n    isProcessing: {\n      get() {\n        return this.processing\n      },\n      set(val) {\n        this.$emit('update:processing', val)\n      }\n    },\n    seriesItems: {\n      get() {\n        return this.selectedMatch.series.map((se) => {\n          return {\n            id: `new-${Math.floor(Math.random() * 10000)}`,\n            displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,\n            name: se.series,\n            sequence: se.sequence || ''\n          }\n        })\n      },\n      set(val) {\n        this.selectedMatch.series = val\n      }\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    },\n    providers() {\n      if (this.isPodcast) return this.$store.state.scanners.podcastProviders\n      return this.$store.state.scanners.bookProviders\n    },\n    searchTitleLabel() {\n      if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN\n      else if (this.provider == 'itunes') return this.$strings.LabelSearchTerm\n      return this.$strings.LabelSearchTitle\n    },\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    currentBookDuration() {\n      if (this.isPodcast) return 0\n      return this.media.duration || 0\n    },\n    mediaType() {\n      return this.libraryItem?.mediaType || null\n    },\n    isPodcast() {\n      return this.mediaType == 'podcast'\n    },\n    narrators() {\n      return this.filterData.narrators || []\n    },\n    genres() {\n      const currentGenres = this.filterData.genres || []\n      const selectedMatchGenres = this.selectedMatch.genres || []\n      return [...new Set([...currentGenres, ...selectedMatchGenres])]\n    },\n    tags() {\n      return this.filterData.tags || []\n    }\n  },\n  methods: {\n    setMatchFieldValue(field, value) {\n      if (Array.isArray(value)) {\n        this.selectedMatch[field] = [...value]\n      } else {\n        this.selectedMatch[field] = value\n      }\n    },\n    selectAllToggled(val) {\n      for (const key in this.selectedMatchUsage) {\n        this.selectedMatchUsage[key] = val\n      }\n    },\n    checkboxToggled() {\n      this.selectAll = Object.values(this.selectedMatchUsage).findIndex((v) => v == false) < 0\n    },\n    persistProvider() {\n      try {\n        localStorage.setItem('book-provider', this.provider)\n      } catch (error) {\n        console.error('PersistProvider', error)\n      }\n    },\n    getDefaultBookProvider() {\n      let provider = localStorage.getItem('book-provider')\n      if (!provider) return 'google'\n      // Validate book provider\n      if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) {\n        console.error('Stored book provider does not exist', provider)\n        localStorage.removeItem('book-provider')\n        return 'google'\n      }\n      return provider\n    },\n    getSearchQuery() {\n      if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`\n      var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`\n      if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`\n      if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}`\n      return searchQuery\n    },\n    submitSearch() {\n      if (!this.searchTitle) {\n        this.$toast.warning(this.$strings.ToastTitleRequired)\n        return\n      }\n      if (!this.isPodcast) {\n        this.persistProvider()\n      }\n      this.runSearch()\n    },\n    async runSearch() {\n      const searchQuery = this.getSearchQuery()\n      if (this.lastSearch === searchQuery) return\n      this.searchResults = []\n      this.isProcessing = true\n      this.lastSearch = searchQuery\n      const searchEntity = this.isPodcast ? 'podcast' : 'books'\n      let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {\n        console.error('Failed', error)\n        return []\n      })\n      // console.log('Got search results', results)\n      results = (results || []).filter((res) => {\n        return !!res.title\n      })\n\n      if (this.isPodcast) {\n        // Map to match PodcastMetadata keys\n        results = results.map((res) => {\n          res.itunesPageUrl = res.pageUrl || null\n          res.itunesId = res.id || null\n          res.author = res.artistName || null\n          res.explicit = res.explicit || false\n          return res\n        })\n      }\n\n      this.searchResults = results || []\n      this.isProcessing = false\n      this.hasSearched = true\n    },\n    initSelectedMatchUsage() {\n      this.selectedMatchUsage = {\n        title: true,\n        subtitle: true,\n        cover: true,\n        author: true,\n        narrator: true,\n        description: true,\n        publisher: true,\n        publishedYear: true,\n        series: true,\n        genres: true,\n        tags: true,\n        language: true,\n        explicit: true,\n        asin: true,\n        isbn: true,\n        abridged: true,\n        // Podcast specific\n        itunesPageUrl: true,\n        itunesId: true,\n        feedUrl: true,\n        releaseDate: true\n      }\n\n      // Load saved selected match from local storage\n      try {\n        let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')\n        if (!savedSelectedMatchUsage) return\n        savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)\n\n        for (const key in savedSelectedMatchUsage) {\n          if (this.selectedMatchUsage[key] !== undefined) {\n            this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]\n          }\n        }\n      } catch (error) {\n        console.error('Failed to load saved selectedMatchUsage', error)\n      }\n\n      this.checkboxToggled()\n    },\n    initProviderAndSearch() {\n      // Set provider based on media type\n      if (this.isPodcast) {\n        this.provider = 'itunes'\n      } else {\n        this.provider = this.getDefaultBookProvider()\n      }\n\n      // Prefer using ASIN if set and using audible provider\n      if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {\n        this.searchTitle = this.libraryItem.media.metadata.asin\n        this.searchAuthor = ''\n      }\n\n      if (this.searchTitle) {\n        this.submitSearch()\n      }\n    },\n    init() {\n      this.clearSelectedMatch()\n      this.initSelectedMatchUsage()\n\n      if (this.libraryItem.id !== this.libraryItemId) {\n        this.searchResults = []\n        this.hasSearched = false\n        this.libraryItemId = this.libraryItem.id\n      }\n\n      if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {\n        this.searchTitle = null\n        this.searchAuthor = null\n        return\n      }\n      this.searchTitle = this.libraryItem.media.metadata.title\n      this.searchAuthor = this.libraryItem.media.metadata.authorName || ''\n\n      // Wait for providers to be loaded before setting provider and searching\n      if (this.providersLoaded || this.isPodcast) {\n        this.waitingForProviders = false\n        this.initProviderAndSearch()\n      } else {\n        this.waitingForProviders = true\n      }\n    },\n    selectMatch(match) {\n      if (match) {\n        if (match.series) {\n          if (!match.series.length) {\n            delete match.series\n          } else {\n            match.series = match.series.map((se) => {\n              return {\n                id: `new-${Math.floor(Math.random() * 10000)}`,\n                displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,\n                name: se.series,\n                sequence: se.sequence || ''\n              }\n            })\n          }\n        }\n        if (match.genres && !Array.isArray(match.genres)) {\n          // match.genres = match.genres.join(',')\n          match.genres = match.genres.split(',').map((g) => g.trim())\n        }\n        if (match.tags && !Array.isArray(match.tags)) {\n          match.tags = match.tags.split(',').map((g) => g.trim())\n        }\n        if (match.narrator && !Array.isArray(match.narrator)) {\n          match.narrator = match.narrator.split(',').map((g) => g.trim())\n        }\n      }\n\n      console.log('Select Match', match)\n      this.selectedMatch = match\n      this.selectedMatchOrig = JSON.parse(JSON.stringify(match))\n    },\n    buildMatchUpdatePayload() {\n      var updatePayload = {}\n      updatePayload.metadata = {}\n\n      for (const key in this.selectedMatchUsage) {\n        if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {\n          if (key === 'series') {\n            if (!Array.isArray(this.selectedMatch[key])) {\n              console.error('Invalid series in selectedMatch', this.selectedMatch[key])\n            } else {\n              var seriesPayload = []\n              this.selectedMatch[key].forEach((seriesItem) =>\n                seriesPayload.push({\n                  id: seriesItem.id,\n                  name: seriesItem.name,\n                  sequence: seriesItem.sequence\n                })\n              )\n              updatePayload.metadata.series = seriesPayload\n            }\n          } else if (key === 'author' && !this.isPodcast) {\n            var authors = this.selectedMatch[key]\n            if (!Array.isArray(authors)) {\n              authors = authors\n                .split(',')\n                .map((au) => au.trim())\n                .filter((au) => !!au)\n            }\n            var authorPayload = []\n            authors.forEach((authorName) =>\n              authorPayload.push({\n                id: `new-${Math.floor(Math.random() * 10000)}`,\n                name: authorName\n              })\n            )\n            updatePayload.metadata.authors = authorPayload\n          } else if (key === 'narrator') {\n            updatePayload.metadata.narrators = this.selectedMatch[key]\n          } else if (key === 'genres') {\n            updatePayload.metadata.genres = [...this.selectedMatch[key]]\n          } else if (key === 'tags') {\n            updatePayload.tags = this.selectedMatch[key]\n          } else if (key === 'itunesId') {\n            updatePayload.metadata.itunesId = Number(this.selectedMatch[key])\n          } else {\n            updatePayload.metadata[key] = this.selectedMatch[key]\n          }\n        }\n      }\n\n      return updatePayload\n    },\n    async submitMatchUpdate() {\n      var updatePayload = this.buildMatchUpdatePayload()\n      if (!Object.keys(updatePayload).length) {\n        return\n      }\n\n      console.log('Match payload', updatePayload)\n      this.isProcessing = true\n\n      // Persist in local storage\n      localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))\n\n      if (Object.keys(updatePayload).length) {\n        if (updatePayload.metadata.cover) {\n          updatePayload.url = updatePayload.metadata.cover\n          delete updatePayload.metadata.cover\n        }\n        const mediaUpdatePayload = updatePayload\n        const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {\n          console.error('Failed to update', error)\n          return false\n        })\n        if (updateResult) {\n          if (updateResult.updated) {\n            this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)\n          } else {\n            this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n          }\n          this.clearSelectedMatch()\n          this.$emit('selectTab', 'details')\n        } else {\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        }\n      } else {\n        this.clearSelectedMatch()\n      }\n\n      this.isProcessing = false\n    },\n    clearSelectedMatch() {\n      this.selectedMatch = null\n      this.selectedMatchOrig = null\n    }\n  },\n  mounted() {\n    // Fetch providers if not already loaded\n    this.$store.dispatch('scanners/fetchProviders')\n  }\n}\n</script>\n\n<style>\n.matchListWrapper {\n  height: calc(100% - 124px);\n}\n\n@media (min-width: 768px) {\n  .matchListWrapper {\n    height: calc(100% - 80px);\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Schedule.vue",
    "content": "<template>\n  <div class=\"w-full h-full relative\">\n    <div id=\"scheduleWrapper\" class=\"w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6\">\n      <template v-if=\"!feedUrl\">\n        <widgets-alert type=\"warning\" class=\"text-base mb-4\">{{ $strings.ToastPodcastNoRssFeed }}</widgets-alert>\n      </template>\n      <template v-if=\"feedUrl || autoDownloadEpisodes\">\n        <div class=\"flex items-center justify-between mb-4\">\n          <p class=\"text-base md:text-xl font-semibold\">{{ $strings.HeaderScheduleEpisodeDownloads }}</p>\n          <ui-checkbox v-model=\"enableAutoDownloadEpisodes\" :label=\"$strings.LabelEnable\" medium checkbox-bg=\"bg\" label-class=\"pl-2 text-base md:text-lg\" />\n        </div>\n\n        <div v-if=\"enableAutoDownloadEpisodes\" class=\"flex items-center py-2\">\n          <ui-text-input ref=\"maxEpisodesInput\" type=\"number\" v-model=\"newMaxEpisodesToKeep\" no-spinner :padding-x=\"1\" text-center class=\"w-10 text-base\" @change=\"updatedMaxEpisodesToKeep\" />\n          <ui-tooltip :text=\"$strings.LabelMaxEpisodesToKeepHelp\">\n            <p class=\"pl-4 text-base\">\n              {{ $strings.LabelMaxEpisodesToKeep }}\n              <span class=\"material-symbols icon-text\">info</span>\n            </p>\n          </ui-tooltip>\n        </div>\n        <div v-if=\"enableAutoDownloadEpisodes\" class=\"flex items-center py-2\">\n          <ui-text-input ref=\"maxEpisodesToDownloadInput\" type=\"number\" v-model=\"newMaxNewEpisodesToDownload\" no-spinner :padding-x=\"1\" text-center class=\"w-10 text-base\" @change=\"updateMaxNewEpisodesToDownload\" />\n          <ui-tooltip :text=\"$strings.LabelUseZeroForUnlimited\">\n            <p class=\"pl-4 text-base\">\n              {{ $strings.LabelMaxEpisodesToDownloadPerCheck }}\n              <span class=\"material-symbols icon-text\">info</span>\n            </p>\n          </ui-tooltip>\n        </div>\n\n        <widgets-cron-expression-builder ref=\"cronExpressionBuilder\" v-if=\"enableAutoDownloadEpisodes\" v-model=\"cronExpression\" />\n      </template>\n    </div>\n\n    <div v-if=\"feedUrl || autoDownloadEpisodes\" class=\"absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white/5\">\n      <div class=\"flex items-center px-2 md:px-4\">\n        <div class=\"grow\" />\n        <ui-btn @click=\"save\" :disabled=\"!isUpdated\" :color=\"isUpdated ? 'bg-success' : 'bg-primary'\" class=\"mx-2\">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      enableAutoDownloadEpisodes: false,\n      cronExpression: null,\n      newMaxEpisodesToKeep: 0,\n      newMaxNewEpisodesToDownload: 0\n    }\n  },\n  watch: {\n    libraryItem: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  computed: {\n    isProcessing: {\n      get() {\n        return this.processing\n      },\n      set(val) {\n        this.$emit('update:processing', val)\n      }\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    libraryItemId() {\n      return this.libraryItem ? this.libraryItem.id : null\n    },\n    feedUrl() {\n      return this.mediaMetadata.feedUrl\n    },\n    autoDownloadEpisodes() {\n      return !!this.media.autoDownloadEpisodes\n    },\n    autoDownloadSchedule() {\n      return this.media.autoDownloadSchedule\n    },\n    maxEpisodesToKeep() {\n      return this.media.maxEpisodesToKeep\n    },\n    maxNewEpisodesToDownload() {\n      return this.media.maxNewEpisodesToDownload\n    },\n    isUpdated() {\n      return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload)\n    }\n  },\n  methods: {\n    updatedMaxEpisodesToKeep() {\n      if (isNaN(this.newMaxEpisodesToKeep) || this.newMaxEpisodesToKeep < 0) {\n        this.newMaxEpisodesToKeep = 0\n      } else {\n        this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)\n      }\n    },\n    updateMaxNewEpisodesToDownload() {\n      if (isNaN(this.newMaxNewEpisodesToDownload) || this.newMaxNewEpisodesToDownload < 0) {\n        this.newMaxNewEpisodesToDownload = 0\n      } else {\n        this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)\n      }\n    },\n    save() {\n      // If custom expression input is focused then unfocus it instead of submitting\n      if (this.$refs.cronExpressionBuilder && this.$refs.cronExpressionBuilder.checkBlurExpressionInput) {\n        if (this.$refs.cronExpressionBuilder.checkBlurExpressionInput()) {\n          return\n        }\n      }\n\n      if (this.$refs.maxEpisodesInput?.isFocused) {\n        this.$refs.maxEpisodesInput.blur()\n      }\n      if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {\n        this.$refs.maxEpisodesToDownloadInput.blur()\n      }\n\n      const updatePayload = {\n        autoDownloadEpisodes: this.enableAutoDownloadEpisodes\n      }\n      if (this.enableAutoDownloadEpisodes) {\n        updatePayload.autoDownloadSchedule = this.cronExpression\n      }\n      this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)\n      if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {\n        updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep\n      }\n      this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)\n      if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {\n        updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload\n      }\n\n      this.updateDetails(updatePayload)\n    },\n    async updateDetails(updatePayload) {\n      this.isProcessing = true\n      var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {\n        console.error('Failed to update', error)\n        return false\n      })\n      this.isProcessing = false\n      if (updateResult) {\n        if (updateResult.updated) {\n          this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)\n          return true\n        } else {\n          this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n        }\n      }\n      return false\n    },\n    init() {\n      this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes\n      this.cronExpression = this.autoDownloadSchedule\n      this.newMaxEpisodesToKeep = this.maxEpisodesToKeep\n      this.newMaxNewEpisodesToDownload = this.maxNewEpisodesToDownload\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style scoped>\n#scheduleWrapper {\n  height: calc(100% - 80px);\n  max-height: calc(100% - 80px);\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/item/tabs/Tools.vue",
    "content": "<template>\n  <div class=\"w-full h-full overflow-hidden overflow-y-auto px-4 py-6\">\n    <p class=\"text-xl font-semibold mb-2\">{{ $strings.HeaderAudiobookTools }}</p>\n\n    <!-- Merge to m4b -->\n    <div v-if=\"showM4bDownload\" class=\"w-full border border-black-200 p-4 my-8\">\n      <div class=\"flex flex-wrap items-center\">\n        <div>\n          <p class=\"text-lg\">{{ $strings.LabelToolsMakeM4b }}</p>\n          <p class=\"max-w-sm text-sm pt-2 text-gray-300\">{{ $strings.LabelToolsMakeM4bDescription }}</p>\n        </div>\n        <div class=\"grow\" />\n        <div>\n          <ui-btn :to=\"`/audiobook/${libraryItemId}/manage?tool=m4b`\" class=\"flex items-center\"\n            >{{ $strings.ButtonOpenManager }}\n            <span class=\"material-symbols text-lg ml-2\">launch</span>\n          </ui-btn>\n        </div>\n      </div>\n    </div>\n\n    <!-- Embed Metadata -->\n    <div v-if=\"mediaTracks.length\" class=\"w-full border border-black-200 p-4 my-8\">\n      <div class=\"flex items-center\">\n        <div>\n          <p class=\"text-lg\">{{ $strings.LabelToolsEmbedMetadata }}</p>\n          <p class=\"max-w-sm text-sm pt-2 text-gray-300\">{{ $strings.LabelToolsEmbedMetadataDescription }}</p>\n        </div>\n        <div class=\"grow\" />\n        <div>\n          <ui-btn :to=\"`/audiobook/${libraryItemId}/manage?tool=embed`\" class=\"flex items-center\"\n            >{{ $strings.ButtonOpenManager }}\n            <span class=\"material-symbols text-lg ml-2\">launch</span>\n          </ui-btn>\n\n          <ui-btn v-if=\"!isMetadataEmbedQueued && !isEmbedTaskRunning\" class=\"w-full mt-4\" small @click.stop=\"quickEmbed\">{{ $strings.ButtonQuickEmbed }}</ui-btn>\n        </div>\n      </div>\n\n      <!-- queued alert -->\n      <widgets-alert v-if=\"isMetadataEmbedQueued\" type=\"warning\" class=\"mt-4\">\n        <p class=\"text-lg\">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p>\n      </widgets-alert>\n\n      <!-- processing alert -->\n      <widgets-alert v-if=\"isEmbedTaskRunning\" type=\"warning\" class=\"mt-4\">\n        <p class=\"text-lg\">{{ $strings.MessageQuickEmbedInProgress }}</p>\n      </widgets-alert>\n    </div>\n\n    <p v-if=\"!mediaTracks.length\" class=\"text-lg text-center my-8\">{{ $strings.MessageNoAudioTracks }}</p>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    libraryItemId() {\n      return this.libraryItem?.id || null\n    },\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    mediaTracks() {\n      return this.media.tracks || []\n    },\n    chapters() {\n      return this.media.chapters || []\n    },\n    showM4bDownload() {\n      if (!this.mediaTracks.length) return false\n      return true\n    },\n    queuedEmbedLIds() {\n      return this.$store.state.tasks.queuedEmbedLIds || []\n    },\n    isMetadataEmbedQueued() {\n      return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)\n    },\n    tasks() {\n      return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)\n    },\n    embedTask() {\n      return this.tasks.find((t) => t.action === 'embed-metadata')\n    },\n    encodeTask() {\n      return this.tasks.find((t) => t.action === 'encode-m4b')\n    },\n    isEmbedTaskRunning() {\n      return this.embedTask && !this.embedTask?.isFinished\n    },\n    isEncodeTaskRunning() {\n      return this.encodeTask && !this.encodeTask?.isFinished\n    }\n  },\n  methods: {\n    quickEmbed() {\n      const payload = {\n        message: this.$strings.MessageConfirmQuickEmbed,\n        allowHtml: true,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$axios\n              .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)\n              .then(() => {\n                console.log('Audio metadata encode started')\n              })\n              .catch((error) => {\n                console.error('Audio metadata encode failed', error)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/libraries/EditLibrary.vue",
    "content": "<template>\n  <div class=\"w-full h-full md:px-4 py-2 mb-4\">\n    <div v-if=\"!showDirectoryPicker\" class=\"w-full h-full md:py-4\">\n      <div class=\"flex flex-wrap md:flex-nowrap -mx-1 mb-2\">\n        <div class=\"w-2/5 md:w-72 px-1 py-1 md:py-0\">\n          <ui-dropdown v-model=\"mediaType\" :items=\"mediaTypes\" :label=\"$strings.LabelMediaType\" :disabled=\"!isNew\" small @input=\"changedMediaType\" />\n        </div>\n        <div class=\"w-full md:grow px-1 py-1 md:py-0\">\n          <ui-text-input-with-label ref=\"nameInput\" v-model=\"name\" :label=\"$strings.LabelLibraryName\" @blur=\"nameBlurred\" />\n        </div>\n        <div class=\"w-1/5 md:w-18 px-1 py-1 md:py-0\">\n          <ui-media-icon-picker v-model=\"icon\" :label=\"$strings.LabelIcon\" @input=\"iconChanged\" />\n        </div>\n        <div class=\"w-2/5 md:w-72 px-1 py-1 md:py-0\">\n          <ui-dropdown v-model=\"provider\" :items=\"providers\" :label=\"$strings.LabelMetadataProvider\" small @input=\"formUpdated\" />\n        </div>\n      </div>\n\n      <div class=\"folders-container overflow-y-auto w-full py-2 mb-2\">\n        <p class=\"px-1 text-sm font-semibold\">{{ $strings.LabelFolders }}</p>\n        <div v-for=\"(folder, index) in folders\" :key=\"index\" class=\"w-full flex items-center py-1 px-2\">\n          <span class=\"material-symbols fill mr-2 text-yellow-200\" style=\"font-size: 1.2rem\">folder</span>\n          <ui-editable-text ref=\"folderInput\" v-model=\"folder.fullPath\" :readonly=\"!!folder.id\" type=\"text\" class=\"w-full\" @blur=\"existingFolderInputBlurred(folder)\" />\n          <span v-show=\"folders.length > 1\" class=\"material-symbols text-2xl ml-2 cursor-pointer hover:text-error\" @click=\"removeFolder(folder)\">close</span>\n        </div>\n        <div class=\"flex py-1 px-2 items-center w-full\">\n          <span class=\"material-symbols fill mr-2 text-yellow-200\" style=\"font-size: 1.2rem\">folder</span>\n          <ui-editable-text ref=\"newFolderInput\" v-model=\"newFolderPath\" :placeholder=\"$strings.PlaceholderNewFolderPath\" type=\"text\" class=\"w-full\" @blur=\"newFolderInputBlurred\" />\n        </div>\n\n        <ui-btn class=\"w-full mt-2\" color=\"bg-primary\" @click=\"browseForFolder\">{{ $strings.ButtonBrowseForFolder }}</ui-btn>\n      </div>\n    </div>\n    <modals-libraries-lazy-folder-chooser v-else :paths=\"folderPaths\" @back=\"showDirectoryPicker = false\" @select=\"selectFolder\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    isNew: Boolean,\n    library: {\n      type: Object,\n      default: () => null\n    },\n    processing: Boolean\n  },\n  data() {\n    return {\n      name: '',\n      provider: 'google',\n      icon: '',\n      folders: [],\n      showDirectoryPicker: false,\n      newFolderPath: '',\n      mediaType: null\n    }\n  },\n  computed: {\n    mediaTypes() {\n      return [\n        {\n          value: 'book',\n          text: this.$strings.LabelBooks\n        },\n        {\n          value: 'podcast',\n          text: this.$strings.LabelPodcasts\n        }\n      ]\n    },\n    folderPaths() {\n      return this.folders.map((f) => f.fullPath)\n    },\n    providers() {\n      if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders\n      return this.$store.state.scanners.bookProviders\n    }\n  },\n  methods: {\n    checkBlurExpressionInput() {\n      if (this.$refs.nameInput) {\n        this.$refs.nameInput.blur()\n      }\n      if (this.$refs.folderInput && this.$refs.folderInput.length) {\n        this.$refs.folderInput.forEach((input) => {\n          if (input.blur) input.blur()\n        })\n      }\n      if (this.$refs.newFolderInput) {\n        this.$refs.newFolderInput.blur()\n      }\n    },\n    browseForFolder() {\n      this.showDirectoryPicker = true\n    },\n    getLibraryData() {\n      return {\n        name: this.name,\n        provider: this.provider,\n        folders: this.folders,\n        icon: this.icon,\n        mediaType: this.mediaType\n      }\n    },\n    formUpdated() {\n      this.$emit('update', this.getLibraryData())\n    },\n    existingFolderInputBlurred(folder) {\n      if (!folder.fullPath) {\n        this.removeFolder(folder)\n      }\n    },\n    newFolderInputBlurred() {\n      if (this.newFolderPath) {\n        this.folders.push({ fullPath: this.newFolderPath })\n        this.newFolderPath = ''\n        this.formUpdated()\n      }\n    },\n    iconChanged() {\n      this.formUpdated()\n    },\n    nameBlurred() {\n      if (this.name !== this.library.name) {\n        this.formUpdated()\n      }\n    },\n    changedMediaType() {\n      this.provider = this.providers[0].value\n      this.formUpdated()\n    },\n    selectFolder(fullPath) {\n      this.folders.push({ fullPath })\n      this.showDirectoryPicker = false\n      this.formUpdated()\n    },\n    removeFolder(folder) {\n      this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)\n      this.formUpdated()\n    },\n    backArrowPress() {\n      if (this.showDirectoryPicker) {\n        this.showDirectoryPicker = false\n      }\n    },\n    init() {\n      this.name = this.library ? this.library.name : ''\n      this.provider = this.library ? this.library.provider : 'google'\n      this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []\n      this.icon = this.library ? this.library.icon : 'default'\n      this.mediaType = this.library ? this.library.mediaType : 'book'\n\n      this.showDirectoryPicker = false\n    }\n  },\n  mounted() {\n    this.init()\n    // Fetch providers if not already loaded\n    this.$store.dispatch('scanners/fetchProviders')\n  }\n}\n</script>\n\n<style>\n.folders-container {\n  max-height: calc(80vh - 192px);\n}\n@media (max-device-width: 768px) {\n  .folders-container {\n    max-height: calc(80vh - 292px);\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/libraries/EditModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"edit-library\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-xl md:text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div class=\"absolute -top-10 left-0 z-10 w-full flex\">\n      <template v-for=\"tab in tabs\">\n        <div :key=\"tab.id\" class=\"w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base\" :class=\"selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'\" @click=\"selectTab(tab.id)\">{{ tab.title }}</div>\n      </template>\n    </div>\n\n    <div class=\"px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n      <component v-if=\"libraryCopy && show\" ref=\"tabComponent\" :is=\"tabName\" :is-new=\"!library\" :library=\"libraryCopy\" :library-id=\"libraryId\" :processing.sync=\"processing\" @update=\"updateLibrary\" @close=\"show = false\" />\n\n      <div v-show=\"selectedTab !== 'tools'\" class=\"absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white/10\">\n        <div class=\"flex justify-end\">\n          <ui-btn @click=\"submit\">{{ buttonText }}</ui-btn>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    library: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      selectedTab: 'details',\n      libraryCopy: null\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      return this.library ? this.$strings.HeaderUpdateLibrary : this.$strings.HeaderNewLibrary\n    },\n    buttonText() {\n      return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate\n    },\n    mediaType() {\n      return this.libraryCopy?.mediaType\n    },\n    libraryId() {\n      return this.library?.id\n    },\n    tabs() {\n      return [\n        {\n          id: 'details',\n          title: this.$strings.HeaderDetails,\n          component: 'modals-libraries-edit-library'\n        },\n        {\n          id: 'settings',\n          title: this.$strings.HeaderSettings,\n          component: 'modals-libraries-library-settings'\n        },\n        {\n          id: 'scanner',\n          title: this.$strings.HeaderSettingsScanner,\n          component: 'modals-libraries-library-scanner-settings'\n        },\n        {\n          id: 'schedule',\n          title: this.$strings.HeaderSchedule,\n          component: 'modals-libraries-schedule-scan'\n        },\n        {\n          id: 'tools',\n          title: this.$strings.HeaderTools,\n          component: 'modals-libraries-library-tools'\n        }\n      ].filter((tab) => {\n        // Do not show tools tab for new libraries\n        if (tab.id === 'tools' && !this.library) return false\n        return tab.id !== 'scanner' || this.mediaType === 'book'\n      })\n    },\n    tabName() {\n      var _tab = this.tabs.find((t) => t.id === this.selectedTab)\n      return _tab ? _tab.component : ''\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  methods: {\n    selectTab(tab) {\n      this.selectedTab = tab\n    },\n    updateLibrary(library) {\n      this.mapLibraryToCopy(library)\n    },\n    getNewLibraryData() {\n      return {\n        name: '',\n        provider: 'google',\n        folders: [],\n        icon: 'database',\n        mediaType: 'book',\n        settings: {\n          coverAspectRatio: this.$constants.BookCoverAspectRatio.SQUARE,\n          disableWatcher: false,\n          skipMatchingMediaWithAsin: false,\n          skipMatchingMediaWithIsbn: false,\n          autoScanCronExpression: null,\n          hideSingleBookSeries: false,\n          onlyShowLaterBooksInContinueSeries: false,\n          metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],\n          markAsFinishedPercentComplete: null,\n          markAsFinishedTimeRemaining: 10\n        }\n      }\n    },\n    init() {\n      this.selectedTab = 'details'\n      this.libraryCopy = this.getNewLibraryData()\n      if (this.library) {\n        this.mapLibraryToCopy(this.library)\n      }\n    },\n    mapLibraryToCopy(library) {\n      for (const key in this.libraryCopy) {\n        if (library[key] !== undefined) {\n          if (key === 'folders') {\n            this.libraryCopy.folders = library.folders.map((f) => ({ ...f })).filter((f) => !!f.fullPath?.trim())\n          } else if (key === 'settings') {\n            for (const settingKey in library.settings) {\n              this.libraryCopy.settings[settingKey] = library.settings[settingKey]\n            }\n          } else {\n            this.libraryCopy[key] = library[key]\n          }\n        }\n      }\n    },\n    validate() {\n      if (!this.libraryCopy.name) {\n        this.$toast.error(this.$strings.ToastNameRequired)\n        return false\n      }\n      if (!this.libraryCopy.folders.length) {\n        this.$toast.error(this.$strings.ToastMustHaveAtLeastOnePath)\n        return false\n      }\n\n      return true\n    },\n    submit() {\n      // If custom expression input is focused then unfocus it instead of submitting\n      if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {\n        if (this.$refs.tabComponent.checkBlurExpressionInput()) {\n          return\n        }\n      }\n\n      if (!this.validate()) return\n\n      if (this.library) {\n        this.submitUpdateLibrary()\n      } else {\n        this.submitCreateLibrary()\n      }\n    },\n    getLibraryUpdatePayload() {\n      var updatePayload = {}\n      for (const key in this.libraryCopy) {\n        if (key === 'folders') {\n          if (this.libraryCopy.folders.map((f) => f.fullPath).join(',') !== this.library.folders.map((f) => f.fullPath).join(',')) {\n            updatePayload.folders = [...this.libraryCopy.folders]\n          }\n        } else if (key === 'settings') {\n          for (const settingsKey in this.libraryCopy.settings) {\n            if (this.libraryCopy.settings[settingsKey] !== this.library.settings[settingsKey]) {\n              if (!updatePayload.settings) updatePayload.settings = {}\n              updatePayload.settings[settingsKey] = this.libraryCopy.settings[settingsKey]\n            }\n          }\n        } else if (key !== 'mediaType' && this.libraryCopy[key] !== this.library[key]) {\n          updatePayload[key] = this.libraryCopy[key]\n        }\n      }\n      return updatePayload\n    },\n    submitUpdateLibrary() {\n      var newLibraryPayload = this.getLibraryUpdatePayload()\n      if (!Object.keys(newLibraryPayload).length) {\n        this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n        return\n      }\n\n      this.processing = true\n      this.$axios\n        .$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)\n        .then((res) => {\n          this.processing = false\n          this.show = false\n          this.$toast.success(this.$getString('ToastLibraryUpdateSuccess', [res.name]))\n        })\n        .catch((error) => {\n          console.error(error)\n          if (error.response && error.response.data) {\n            this.$toast.error(error.response.data)\n          } else {\n            this.$toast.error(this.$strings.ToastFailedToUpdate)\n          }\n          this.processing = false\n        })\n    },\n    submitCreateLibrary() {\n      this.processing = true\n      this.$axios\n        .$post('/api/libraries', this.libraryCopy)\n        .then((res) => {\n          this.processing = false\n          this.show = false\n          this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name]))\n          if (!this.$store.state.libraries.currentLibraryId) {\n            // First library added\n            this.$store.dispatch('libraries/fetch', res.id)\n          }\n        })\n        .catch((error) => {\n          console.error(error)\n          if (error.response && error.response.data) {\n            this.$toast.error(error.response.data)\n          } else {\n            this.$toast.error(this.$strings.ToastLibraryCreateFailed)\n          }\n          this.processing = false\n        })\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n\n<style scoped>\n.tab {\n  height: 40px;\n}\n.tab.tab-selected {\n  height: 41px;\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/libraries/LazyFolderChooser.vue",
    "content": "<template>\n  <div class=\"w-full h-full bg-bg absolute top-0 left-0 px-4 py-4 z-10\">\n    <div class=\"flex items-center py-1 mb-2\">\n      <span class=\"material-symbols text-3xl cursor-pointer hover:text-gray-300\" @click=\"$emit('back')\">arrow_back</span>\n      <p class=\"px-4 text-xl\">{{ $strings.HeaderChooseAFolder }}</p>\n    </div>\n    <div v-if=\"rootDirs.length\" class=\"w-full bg-primary/70 py-1 px-4 mb-2\">\n      <p class=\"font-mono truncate\">{{ selectedPath || '/' }}</p>\n    </div>\n    <div v-if=\"rootDirs.length\" class=\"relative flex bg-primary/50 p-4 folder-container\">\n      <div class=\"w-1/2 border-r border-bg h-full overflow-y-auto\">\n        <div v-if=\"level > 0\" class=\"w-full p-1 cursor-pointer flex items-center hover:bg-white/10\" @click=\"goBack\">\n          <span class=\"material-symbols fill text-yellow-200\" style=\"font-size: 1.2rem\">folder</span>\n          <p class=\"text-base font-mono px-2\">..</p>\n        </div>\n        <div v-for=\"dir in _directories\" :key=\"dir.path\" class=\"dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10\" :class=\"dir.className\" @click=\"selectDir(dir)\">\n          <span class=\"material-symbols fill text-yellow-200\" style=\"font-size: 1.2rem\">folder</span>\n          <p class=\"text-base font-mono px-2 truncate\">{{ dir.dirname }}</p>\n          <span v-if=\"dir.path === selectedPath\" class=\"material-symbols\" style=\"font-size: 1.1rem\">arrow_right</span>\n        </div>\n      </div>\n      <div class=\"w-1/2 h-full overflow-y-auto\">\n        <div v-for=\"dir in _subdirs\" :key=\"dir.path\" :class=\"dir.className\" class=\"dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10\" @click=\"selectSubDir(dir)\">\n          <span class=\"material-symbols fill text-yellow-200\" style=\"font-size: 1.2rem\">folder</span>\n          <p class=\"text-base font-mono px-2 truncate\">{{ dir.dirname }}</p>\n        </div>\n      </div>\n      <div v-if=\"loadingDirs\" class=\"absolute inset-0 w-full h-full flex items-center justify-center bg-black/10\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n    <div v-else-if=\"initialLoad\" class=\"py-12 text-center\">\n      <p>{{ $strings.MessageLoadingFolders }}</p>\n    </div>\n    <div v-else class=\"py-12 text-center max-w-sm mx-auto\">\n      <p class=\"text-lg mb-2\">{{ $strings.MessageNoFoldersAvailable }}</p>\n      <p class=\"text-gray-300 mb-2\">{{ $strings.NoteFolderPicker }}</p>\n    </div>\n\n    <div class=\"w-full py-2\">\n      <ui-btn :disabled=\"!selectedPath\" color=\"bg-primary\" class=\"w-full mt-2\" @click=\"selectFolder\">{{ $strings.ButtonSelectFolderPath }}</ui-btn>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    paths: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      initialLoad: false,\n      loadingDirs: false,\n      isPosix: true,\n      rootDirs: [],\n      directories: [],\n      selectedPath: '',\n      subdirs: [],\n      level: 0,\n      currentDir: null,\n      previousDir: null\n    }\n  },\n  computed: {\n    _directories() {\n      return this.directories.map((d) => {\n        var isUsed = !!this.paths.find((path) => path.endsWith(d.path))\n        var isSelected = d.path === this.selectedPath\n        var classes = []\n        if (isSelected) classes.push('dir-selected')\n        if (isUsed) classes.push('dir-used')\n        return {\n          isUsed,\n          isSelected,\n          className: classes.join(' '),\n          ...d\n        }\n      })\n    },\n    _subdirs() {\n      return this.subdirs.map((d) => {\n        var isUsed = !!this.paths.find((path) => path.endsWith(d.path))\n        var classes = []\n        if (isUsed) classes.push('dir-used')\n        return {\n          isUsed,\n          className: classes.join(' '),\n          ...d\n        }\n      })\n    }\n  },\n  methods: {\n    async goBack() {\n      let selPath = this.selectedPath.replace(/^\\//, '')\n      var splitPaths = selPath.split('/')\n\n      let previousPath = ''\n      let lookupPath = ''\n\n      if (splitPaths.length > 2) {\n        lookupPath = splitPaths.slice(0, -2).join('/')\n      }\n      previousPath = splitPaths.slice(0, -1).join('/')\n\n      if (!this.isPosix) {\n        // For windows drives add a trailing slash. e.g. C:/\n        if (!this.isPosix && lookupPath.endsWith(':')) {\n          lookupPath += '/'\n        }\n        if (!this.isPosix && previousPath.endsWith(':')) {\n          previousPath += '/'\n        }\n      } else {\n        // Add leading slash\n        if (previousPath) previousPath = '/' + previousPath\n        if (lookupPath) lookupPath = '/' + lookupPath\n      }\n\n      this.level--\n      this.subdirs = this.directories\n      this.selectedPath = previousPath\n      this.directories = await this.fetchDirs(lookupPath, this.level)\n    },\n    async selectDir(dir) {\n      if (dir.isUsed) return\n      this.selectedPath = dir.path\n      this.level = dir.level\n      this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)\n    },\n    async selectSubDir(dir) {\n      if (dir.isUsed) return\n      this.selectedPath = dir.path\n      this.level = dir.level\n      this.directories = this.subdirs\n      this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)\n    },\n    selectFolder() {\n      if (!this.selectedPath) {\n        console.error('No Selected path')\n        return\n      }\n      if (this.paths.find((p) => p.startsWith(this.selectedPath))) {\n        this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)\n        return\n      }\n      this.$emit('select', this.selectedPath)\n      this.selectedPath = ''\n    },\n    fetchDirs(path, level) {\n      this.loadingDirs = true\n      return this.$axios\n        .$get(`/api/filesystem?path=${path}&level=${level}`)\n        .then((data) => {\n          console.log('Fetched directories', data.directories)\n          this.isPosix = !!data.posix\n          return data.directories\n        })\n        .catch((error) => {\n          console.error('Failed to get filesystem paths', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n          return []\n        })\n        .finally(() => {\n          this.loadingDirs = false\n        })\n    },\n    async init() {\n      this.initialLoad = true\n      this.rootDirs = await this.fetchDirs('', 0)\n      this.initialLoad = false\n\n      this.directories = this.rootDirs\n      this.subdirs = []\n      this.selectedPath = ''\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n\n\n<style>\n.dir-item.dir-selected {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n.dir-item.dir-used {\n  background-color: rgba(255, 25, 0, 0.1);\n}\n.folder-container {\n  max-height: calc(100% - 130px);\n  height: calc(100% - 130px);\n  min-height: calc(100% - 130px);\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/libraries/LibraryScannerSettings.vue",
    "content": "<template>\n  <div class=\"w-full h-full px-1 md:px-4 py-1 mb-4\">\n    <div class=\"flex items-center justify-between mb-2\">\n      <h2 class=\"text-base md:text-lg text-gray-200\">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2>\n      <ui-btn small @click=\"resetToDefault\">{{ $strings.ButtonResetToDefault }}</ui-btn>\n    </div>\n\n    <div class=\"flex items-center justify-between md:justify-start mb-4\">\n      <p class=\"text-sm text-gray-300 pr-2\">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>\n      <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex\">\n        <a href=\"https://www.audiobookshelf.org/guides/book-scanner\" target=\"_blank\" class=\"inline-flex\">\n          <span class=\"material-symbols text-xl w-5\">help_outline</span>\n        </a>\n      </ui-tooltip>\n    </div>\n\n    <draggable v-model=\"metadataSourceMapped\" v-bind=\"dragOptions\" class=\"list-group\" draggable=\".item\" handle=\".drag-handle\" tag=\"ul\" @start=\"drag = true\" @end=\"drag = false\" @update=\"draggableUpdate\">\n      <transition-group type=\"transition\" :name=\"!drag ? 'flip-list' : null\">\n        <li v-for=\"(source, index) in metadataSourceMapped\" :key=\"source.id\" :class=\"source.include ? 'item' : 'opacity-50'\" class=\"w-full px-2 flex items-center relative border border-white/10\">\n          <span class=\"material-symbols drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4\">reorder</span>\n          <div class=\"text-center py-1 w-8 min-w-8\">\n            {{ source.include ? getSourceIndex(source.id) : '' }}\n          </div>\n          <div class=\"grow inline-flex justify-between px-4 py-3\">\n            {{ source.name }} <span v-if=\"source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)\" class=\"px-2 italic font-semibold text-xs text-gray-400\">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>\n          </div>\n          <div class=\"px-2 opacity-100\">\n            <ui-toggle-switch v-model=\"source.include\" :off-color=\"'error'\" @input=\"includeToggled(source)\" />\n          </div>\n        </li>\n      </transition-group>\n    </draggable>\n  </div>\n</template>\n\n<script>\nimport draggable from 'vuedraggable'\n\nexport default {\n  components: {\n    draggable\n  },\n  props: {\n    library: {\n      type: Object,\n      default: () => null\n    },\n    processing: Boolean\n  },\n  data() {\n    return {\n      drag: false,\n      dragOptions: {\n        animation: 200,\n        group: 'description',\n        ghostClass: 'ghost'\n      },\n      metadataSourceData: {\n        folderStructure: {\n          id: 'folderStructure',\n          name: 'Folder structure',\n          include: true\n        },\n        audioMetatags: {\n          id: 'audioMetatags',\n          name: 'Audio file meta tags OR ebook metadata',\n          include: true\n        },\n        nfoFile: {\n          id: 'nfoFile',\n          name: 'NFO file',\n          include: true\n        },\n        txtFiles: {\n          id: 'txtFiles',\n          name: 'desc.txt & reader.txt files',\n          include: true\n        },\n        opfFile: {\n          id: 'opfFile',\n          name: 'OPF file',\n          include: true\n        },\n        absMetadata: {\n          id: 'absMetadata',\n          name: 'Audiobookshelf metadata file',\n          include: true\n        }\n      },\n      metadataSourceMapped: []\n    }\n  },\n  computed: {\n    librarySettings() {\n      return this.library.settings || {}\n    },\n    mediaType() {\n      return this.library.mediaType\n    },\n    isBookLibrary() {\n      return this.mediaType === 'book'\n    },\n    firstActiveSourceIndex() {\n      return this.metadataSourceMapped.findIndex((source) => source.include)\n    },\n    lastActiveSourceIndex() {\n      return this.metadataSourceMapped.findLastIndex((source) => source.include)\n    }\n  },\n  methods: {\n    getSourceIndex(source) {\n      const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse()\n      return activeSources.findIndex((s) => s === source) + 1\n    },\n    resetToDefault() {\n      this.metadataSourceMapped = []\n      for (const key in this.metadataSourceData) {\n        this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })\n      }\n      this.metadataSourceMapped.reverse()\n\n      this.$emit('update', this.getLibraryData())\n    },\n    getLibraryData() {\n      const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)\n      metadataSourceIds.reverse()\n      return {\n        settings: {\n          metadataPrecedence: metadataSourceIds\n        }\n      }\n    },\n    includeToggled(source) {\n      this.updated()\n    },\n    draggableUpdate() {\n      this.updated()\n    },\n    updated() {\n      this.$emit('update', this.getLibraryData())\n    },\n    init() {\n      const metadataPrecedence = this.librarySettings.metadataPrecedence || []\n      this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)\n\n      for (const sourceKey in this.metadataSourceData) {\n        if (!metadataPrecedence.includes(sourceKey)) {\n          const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }\n          this.metadataSourceMapped.unshift(unusedSourceData)\n        }\n      }\n\n      this.metadataSourceMapped.reverse()\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>"
  },
  {
    "path": "client/components/modals/libraries/LibrarySettings.vue",
    "content": "<template>\n  <div class=\"w-full h-full px-1 md:px-4 py-1 mb-4\">\n    <div class=\"flex flex-wrap\">\n      <div class=\"flex items-center p-2 w-full md:w-1/2\">\n        <ui-toggle-switch v-model=\"useSquareBookCovers\" size=\"sm\" @input=\"formUpdated\" />\n        <ui-tooltip :text=\"$strings.LabelSettingsSquareBookCoversHelp\">\n          <p class=\"pl-4 text-sm\">\n            {{ $strings.LabelSettingsSquareBookCovers }}\n            <span class=\"material-symbols icon-text text-sm\">info</span>\n          </p>\n        </ui-tooltip>\n      </div>\n      <div class=\"p-2 w-full md:w-1/2\">\n        <div class=\"flex items-center\">\n          <ui-toggle-switch v-if=\"!globalWatcherDisabled\" v-model=\"enableWatcher\" size=\"sm\" @input=\"formUpdated\" />\n          <ui-toggle-switch v-else disabled size=\"sm\" :value=\"false\" />\n          <p class=\"pl-4 text-sm\">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>\n        </div>\n        <p v-if=\"globalWatcherDisabled\" class=\"text-xs text-warning\">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>\n      </div>\n      <div v-if=\"isBookLibrary\" class=\"flex items-center p-2 w-full md:w-1/2\">\n        <ui-toggle-switch v-model=\"audiobooksOnly\" size=\"sm\" @input=\"formUpdated\" />\n        <ui-tooltip :text=\"$strings.LabelSettingsAudiobooksOnlyHelp\">\n          <p class=\"pl-4 text-sm\">\n            {{ $strings.LabelSettingsAudiobooksOnly }}\n            <span class=\"material-symbols icon-text text-sm\">info</span>\n          </p>\n        </ui-tooltip>\n      </div>\n      <div v-if=\"isBookLibrary\" class=\"p-2 w-full md:w-1/2\">\n        <div class=\"flex items-center\">\n          <ui-toggle-switch v-model=\"skipMatchingMediaWithAsin\" size=\"sm\" @input=\"formUpdated\" />\n          <p class=\"pl-4 text-sm\">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>\n        </div>\n      </div>\n      <div v-if=\"isBookLibrary\" class=\"p-2 w-full md:w-1/2\">\n        <div class=\"flex items-center\">\n          <ui-toggle-switch v-model=\"skipMatchingMediaWithIsbn\" size=\"sm\" @input=\"formUpdated\" />\n          <p class=\"pl-4 text-sm\">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>\n        </div>\n      </div>\n      <div v-if=\"isBookLibrary\" class=\"p-2 w-full md:w-1/2\">\n        <div class=\"flex items-center\">\n          <ui-toggle-switch v-model=\"hideSingleBookSeries\" size=\"sm\" @input=\"formUpdated\" />\n          <ui-tooltip :text=\"$strings.LabelSettingsHideSingleBookSeriesHelp\">\n            <p class=\"pl-4 text-sm\">\n              {{ $strings.LabelSettingsHideSingleBookSeries }}\n              <span class=\"material-symbols icon-text text-sm\">info</span>\n            </p>\n          </ui-tooltip>\n        </div>\n      </div>\n      <div v-if=\"isBookLibrary\" class=\"p-2 w-full md:w-1/2\">\n        <div class=\"flex items-center\">\n          <ui-toggle-switch v-model=\"onlyShowLaterBooksInContinueSeries\" size=\"sm\" @input=\"formUpdated\" />\n          <ui-tooltip :text=\"$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\">\n            <p class=\"pl-4 text-sm\">\n              {{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}\n              <span class=\"material-symbols icon-text text-sm\">info</span>\n            </p>\n          </ui-tooltip>\n        </div>\n      </div>\n      <div v-if=\"isBookLibrary\" class=\"p-2 w-full md:w-1/2\">\n        <div class=\"flex items-center\">\n          <ui-toggle-switch v-model=\"epubsAllowScriptedContent\" size=\"sm\" @input=\"formUpdated\" />\n          <ui-tooltip :text=\"$strings.LabelSettingsEpubsAllowScriptedContentHelp\">\n            <p class=\"pl-4 text-sm\">\n              {{ $strings.LabelSettingsEpubsAllowScriptedContent }}\n              <span class=\"material-symbols icon-text text-sm\">info</span>\n            </p>\n          </ui-tooltip>\n        </div>\n      </div>\n      <div v-if=\"isPodcastLibrary\" class=\"p-2 w-full md:w-1/2\">\n        <ui-dropdown :label=\"$strings.LabelPodcastSearchRegion\" v-model=\"podcastSearchRegion\" :items=\"$podcastSearchRegionOptions\" small class=\"max-w-72\" menu-max-height=\"200px\" @input=\"formUpdated\" />\n      </div>\n      <div class=\"p-2 w-full flex items-center space-x-2 flex-wrap\">\n        <div>\n          <ui-dropdown v-model=\"markAsFinishedWhen\" :items=\"maskAsFinishedWhenItems\" :label=\"$strings.LabelSettingsLibraryMarkAsFinishedWhen\" small class=\"w-72 min-w-72 text-sm\" menu-max-height=\"200px\" @input=\"markAsFinishedWhenChanged\" />\n        </div>\n        <div class=\"w-16\">\n          <div>\n            <label class=\"px-1 text-sm font-semibold\"></label>\n            <div class=\"relative\">\n              <ui-text-input v-model=\"markAsFinishedValue\" type=\"number\" label=\"\" no-spinner custom-input-class=\"pr-5\" @input=\"markAsFinishedChanged\" />\n              <div class=\"absolute top-0 bottom-0 right-4 flex items-center\">{{ markAsFinishedWhen === 'timeRemaining' ? '' : '%' }}</div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    library: {\n      type: Object,\n      default: () => null\n    },\n    processing: Boolean\n  },\n  data() {\n    return {\n      useSquareBookCovers: false,\n      enableWatcher: false,\n      skipMatchingMediaWithAsin: false,\n      skipMatchingMediaWithIsbn: false,\n      audiobooksOnly: false,\n      epubsAllowScriptedContent: false,\n      hideSingleBookSeries: false,\n      onlyShowLaterBooksInContinueSeries: false,\n      podcastSearchRegion: 'us',\n      markAsFinishedWhen: 'timeRemaining',\n      markAsFinishedValue: 10\n    }\n  },\n  computed: {\n    librarySettings() {\n      return this.library.settings || {}\n    },\n    globalWatcherDisabled() {\n      return this.$store.getters['getServerSetting']('scannerDisableWatcher')\n    },\n    mediaType() {\n      return this.library.mediaType\n    },\n    isBookLibrary() {\n      return this.mediaType === 'book'\n    },\n    isPodcastLibrary() {\n      return this.mediaType === 'podcast'\n    },\n    maskAsFinishedWhenItems() {\n      return [\n        {\n          text: this.$strings.LabelSettingsLibraryMarkAsFinishedTimeRemaining,\n          value: 'timeRemaining'\n        },\n        {\n          text: this.$strings.LabelSettingsLibraryMarkAsFinishedPercentComplete,\n          value: 'percentComplete'\n        }\n      ]\n    }\n  },\n  methods: {\n    markAsFinishedWhenChanged(val) {\n      if (val === 'percentComplete' && this.markAsFinishedValue > 100) {\n        this.markAsFinishedValue = 100\n      }\n      this.formUpdated()\n    },\n    markAsFinishedChanged(val) {\n      this.formUpdated()\n    },\n    getLibraryData() {\n      let markAsFinishedTimeRemaining = this.markAsFinishedWhen === 'timeRemaining' ? Number(this.markAsFinishedValue) : null\n      let markAsFinishedPercentComplete = this.markAsFinishedWhen === 'percentComplete' ? Number(this.markAsFinishedValue) : null\n\n      return {\n        settings: {\n          coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,\n          disableWatcher: !this.enableWatcher,\n          skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,\n          skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,\n          audiobooksOnly: !!this.audiobooksOnly,\n          epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,\n          hideSingleBookSeries: !!this.hideSingleBookSeries,\n          onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,\n          podcastSearchRegion: this.podcastSearchRegion,\n          markAsFinishedTimeRemaining: markAsFinishedTimeRemaining,\n          markAsFinishedPercentComplete: markAsFinishedPercentComplete\n        }\n      }\n    },\n    formUpdated() {\n      this.$emit('update', this.getLibraryData())\n    },\n    init() {\n      this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE\n      this.enableWatcher = !this.librarySettings.disableWatcher\n      this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin\n      this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn\n      this.audiobooksOnly = !!this.librarySettings.audiobooksOnly\n      this.epubsAllowScriptedContent = !!this.librarySettings.epubsAllowScriptedContent\n      this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries\n      this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries\n      this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'\n      this.markAsFinishedWhen = this.librarySettings.markAsFinishedTimeRemaining ? 'timeRemaining' : 'percentComplete'\n      if (!this.librarySettings.markAsFinishedTimeRemaining && !this.librarySettings.markAsFinishedPercentComplete) {\n        this.markAsFinishedWhen = 'timeRemaining'\n      }\n      this.markAsFinishedValue = this.librarySettings.markAsFinishedTimeRemaining || this.librarySettings.markAsFinishedPercentComplete || 10\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/libraries/LibraryTools.vue",
    "content": "<template>\n  <div class=\"w-full h-full px-1 md:px-2 py-1 mb-4\">\n    <div class=\"w-full border border-black-200 p-4 my-8\">\n      <div class=\"flex flex-wrap items-center\">\n        <div>\n          <p class=\"text-lg\">{{ $strings.LabelRemoveMetadataFile }}</p>\n          <p class=\"max-w-sm text-sm pt-2 text-gray-300\">{{ $getString('LabelRemoveMetadataFileHelp', [mediaType]) }}</p>\n        </div>\n        <div class=\"grow\" />\n        <div>\n          <ui-btn class=\"mb-4 block\" @click.stop=\"removeAllMetadataClick('json')\">{{ $strings.LabelRemoveAllMetadataJson }}</ui-btn>\n          <ui-btn @click.stop=\"removeAllMetadataClick('abs')\">{{ $strings.LabelRemoveAllMetadataAbs }}</ui-btn>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    library: {\n      type: Object,\n      default: () => null\n    },\n    libraryId: String,\n    processing: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    librarySettings() {\n      return this.library.settings || {}\n    },\n    mediaType() {\n      return this.library.mediaType\n    },\n    isBookLibrary() {\n      return this.mediaType === 'book'\n    }\n  },\n  methods: {\n    removeAllMetadataClick(ext) {\n      const payload = {\n        message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),\n        persistent: true,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removeAllMetadataInLibrary(ext)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    removeAllMetadataInLibrary(ext) {\n      this.$emit('update:processing', true)\n      this.$axios\n        .$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)\n        .then((data) => {\n          if (!data.found) {\n            this.$toast.info(this.$getString('ToastMetadataFilesRemovedNoneFound', [ext]))\n          } else if (!data.removed) {\n            this.$toast.success(this.$getString('ToastMetadataFilesRemovedNoneRemoved', [ext]))\n          } else {\n            this.$toast.success(this.$getString('ToastMetadataFilesRemovedSuccess', [data.removed, ext]))\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to remove metadata files', error)\n          this.$toast.error(this.$getString('ToastMetadataFilesRemovedError', [ext]))\n        })\n        .finally(() => {\n          this.$emit('update:processing', false)\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/libraries/ScheduleScan.vue",
    "content": "<template>\n  <div class=\"w-full h-full px-1 md:px-4 py-1 mb-4\">\n    <div class=\"flex items-center justify-between mb-4\">\n      <p class=\"text-base md:text-xl font-semibold\">{{ $strings.HeaderScheduleLibraryScans }}</p>\n      <ui-checkbox v-model=\"enableAutoScan\" @input=\"toggleEnableAutoScan\" :label=\"$strings.LabelEnable\" medium checkbox-bg=\"bg\" label-class=\"pl-2 text-base md:text-lg\" />\n    </div>\n    <widgets-cron-expression-builder ref=\"cronExpressionBuilder\" v-if=\"enableAutoScan\" v-model=\"cronExpression\" @input=\"updatedCron\" />\n    <div v-else>\n      <p class=\"text-yellow-400 text-base\">{{ $strings.MessageScheduleLibraryScanNote }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    library: {\n      type: Object,\n      default: () => null\n    },\n    processing: Boolean\n  },\n  data() {\n    return {\n      cronExpression: null,\n      enableAutoScan: false\n    }\n  },\n  computed: {},\n  methods: {\n    checkBlurExpressionInput() {\n      // returns true if advanced cron input is focused\n      if (!this.$refs.cronExpressionBuilder) return false\n      return this.$refs.cronExpressionBuilder.checkBlurExpressionInput()\n    },\n    toggleEnableAutoScan(v) {\n      if (!v) this.updatedCron(null)\n      else if (!this.cronExpression) {\n        this.cronExpression = '0 0 * * 1'\n        this.updatedCron(this.cronExpression)\n      }\n    },\n    updatedCron(expression) {\n      this.$emit('update', {\n        settings: {\n          autoScanCronExpression: expression\n        }\n      })\n    },\n    init() {\n      this.cronExpression = this.library.settings.autoScanCronExpression\n      this.enableAutoScan = !!this.cronExpression\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>"
  },
  {
    "path": "client/components/modals/notification/NotificationEditModal.vue",
    "content": "<template>\n  <modals-modal ref=\"modal\" v-model=\"show\" name=\"notification-edit\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <form @submit.prevent=\"submitForm\">\n      <div class=\"w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300\">\n        <div class=\"w-full px-3 py-5 md:p-12\">\n          <ui-dropdown v-model=\"newNotification.eventName\" :label=\"$strings.LabelNotificationEvent\" :items=\"eventOptions\" class=\"mb-4\" @input=\"eventOptionUpdated\" />\n\n          <ui-multi-select ref=\"urlsInput\" v-model=\"newNotification.urls\" :label=\"$strings.LabelNotificationAppriseURL\" class=\"mb-2\" />\n\n          <ui-text-input-with-label v-model=\"newNotification.titleTemplate\" :label=\"$strings.LabelNotificationTitleTemplate\" class=\"mb-2\" />\n\n          <ui-textarea-with-label v-model=\"newNotification.bodyTemplate\" :label=\"$strings.LabelNotificationBodyTemplate\" :rows=\"4\" class=\"mb-2\" />\n\n          <p v-if=\"availableVariables\" class=\"text-sm text-gray-300\">\n            <strong>{{ $strings.LabelNotificationAvailableVariables }}:</strong> {{ availableVariables.join(', ') }}\n          </p>\n\n          <div class=\"flex items-center pt-4\">\n            <div class=\"flex items-center\">\n              <ui-toggle-switch v-model=\"newNotification.enabled\" />\n              <p class=\"text-lg pl-2\">{{ $strings.LabelEnable }}</p>\n            </div>\n            <div class=\"grow\" />\n            <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </form>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    notification: {\n      type: Object,\n      default: () => null\n    },\n    notificationData: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      newNotification: {},\n      isNew: true\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    notificationEvents() {\n      if (!this.notificationData) return []\n      return this.notificationData.events || []\n    },\n    eventOptions() {\n      return this.notificationEvents.map((e) => {\n        return {\n          value: e.name,\n          text: e.name,\n          subtext: this.$strings[e.descriptionKey] || e.description\n        }\n      })\n    },\n    selectedEventData() {\n      return this.notificationEvents.find((e) => e.name === this.newNotification.eventName)\n    },\n    showLibrarySelectInput() {\n      return this.selectedEventData && this.selectedEventData.requiresLibrary\n    },\n    title() {\n      return this.isNew ? this.$strings.HeaderNotificationCreate : this.$strings.HeaderNotificationUpdate\n    },\n    availableVariables() {\n      return this.selectedEventData ? this.selectedEventData.variables || null : null\n    }\n  },\n  methods: {\n    eventOptionUpdated() {\n      if (!this.selectedEventData) return\n      this.newNotification.titleTemplate = this.selectedEventData.defaults.title || ''\n      this.newNotification.bodyTemplate = this.selectedEventData.defaults.body || ''\n    },\n    close() {\n      // Force close when navigating - used in UsersTable\n      if (this.$refs.modal) this.$refs.modal.setHide()\n    },\n    submitForm() {\n      this.$refs.urlsInput?.forceBlur()\n\n      if (!this.newNotification.urls.length) {\n        this.$toast.error(this.$strings.ToastAppriseUrlRequired)\n        return\n      }\n\n      if (this.isNew) {\n        this.submitCreate()\n      } else {\n        this.submitUpdate()\n      }\n    },\n    submitUpdate() {\n      this.processing = true\n\n      const payload = {\n        ...this.newNotification\n      }\n      console.log('Sending update notification', payload)\n      this.$axios\n        .$patch(`/api/notifications/${payload.id}`, payload)\n        .then((updatedSettings) => {\n          this.$emit('update', updatedSettings)\n          this.$toast.success(this.$strings.ToastNotificationUpdateSuccess)\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to update notification', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submitCreate() {\n      this.processing = true\n\n      const payload = {\n        ...this.newNotification\n      }\n      console.log('Sending create notification', payload)\n      this.$axios\n        .$post('/api/notifications', payload)\n        .then((updatedSettings) => {\n          this.$emit('update', updatedSettings)\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to create notification', error)\n          this.$toast.error(this.$strings.ToastNotificationCreateFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    init() {\n      this.isNew = !this.notification\n      if (this.notification) {\n        this.newNotification = {\n          id: this.notification.id,\n          libraryId: this.notification.libraryId,\n          eventName: this.notification.eventName,\n          urls: [...this.notification.urls],\n          titleTemplate: this.notification.titleTemplate,\n          bodyTemplate: this.notification.bodyTemplate,\n          enabled: this.notification.enabled,\n          type: this.notification.type\n        }\n      } else {\n        this.newNotification = {\n          libraryId: null,\n          eventName: 'onTest',\n          urls: [],\n          titleTemplate: '',\n          bodyTemplate: '',\n          enabled: true,\n          type: null\n        }\n        this.eventOptionUpdated()\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/player/QueueItemRow.vue",
    "content": "<template>\n  <div v-if=\"item\" class=\"w-full flex items-center px-4 py-2\" :class=\"wrapperClass\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <covers-preview-cover :src=\"coverUrl\" :width=\"48\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" :show-resolution=\"false\" />\n    <div class=\"grow px-2 py-1 queue-item-row-content truncate\">\n      <p class=\"text-gray-200 text-sm truncate\">{{ title }}</p>\n      <p class=\"text-gray-300 text-sm\">{{ subtitle }}</p>\n      <p v-if=\"caption\" class=\"text-gray-400 text-xs\">{{ caption }}</p>\n    </div>\n    <div class=\"w-28\">\n      <p v-if=\"isOpenInPlayer\" class=\"text-sm text-right text-gray-400\">{{ $strings.ButtonPlaying }}</p>\n      <div v-else-if=\"isHovering\" class=\"flex items-center justify-end -mx-1\">\n        <button class=\"outline-hidden mx-1 flex items-center\" @click.stop=\"playClick\">\n          <span class=\"material-symbols fill text-2xl text-success\">play_arrow</span>\n        </button>\n        <button class=\"outline-hidden mx-1 flex items-center\" @click.stop=\"removeClick\">\n          <span class=\"material-symbols text-2xl text-error\">close</span>\n        </button>\n      </div>\n      <p v-else class=\"text-gray-400 text-sm text-right\">{{ durationPretty }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    item: {\n      type: Object,\n      default: () => {}\n    },\n    index: Number\n  },\n  data() {\n    return {\n      isHovering: false\n    }\n  },\n  computed: {\n    title() {\n      return this.item.title || ''\n    },\n    subtitle() {\n      return this.item.subtitle || ''\n    },\n    caption() {\n      return this.item.caption\n    },\n    libraryItemId() {\n      return this.item.libraryItemId\n    },\n    episodeId() {\n      return this.item.episodeId\n    },\n    coverPath() {\n      return this.item.coverPath\n    },\n    coverUrl() {\n      if (!this.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc']\n      return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    duration() {\n      return this.item.duration\n    },\n    durationPretty() {\n      if (!this.duration) return 'N/A'\n      return this.$elapsedPretty(this.duration)\n    },\n    isOpenInPlayer() {\n      return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)\n    },\n    wrapperClass() {\n      if (this.isOpenInPlayer) return 'bg-yellow-400/10'\n      if (this.index % 2 === 0) return 'bg-gray-300/5 hover:bg-gray-300/10'\n      return 'bg-bg hover:bg-gray-300/10'\n    }\n  },\n  methods: {\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    playClick() {\n      this.$emit('play', this.item)\n    },\n    removeClick() {\n      this.$emit('remove', this.item)\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n.queue-item-row-content {\n  max-width: calc(100% - 48px - 128px);\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/player/QueueItemsModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"queue-items\" :width=\"800\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.HeaderPlayerQueue }}</p>\n      </div>\n    </template>\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4\" style=\"max-height: 80vh\">\n      <div v-if=\"show\" class=\"w-full h-full\">\n        <div class=\"pb-4 px-4 flex items-center\">\n          <p class=\"text-base text-gray-200\">{{ $strings.HeaderPlayerQueue }}</p>\n          <p class=\"text-base text-gray-400 px-4\">{{ playerQueueItems.length }} Items</p>\n          <div class=\"grow\" />\n          <ui-checkbox v-model=\"playerQueueAutoPlay\" label=\"Auto Play\" medium checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2 mb-px\" />\n        </div>\n        <modals-player-queue-item-row v-for=\"(item, index) in playerQueueItems\" :key=\"index\" :item=\"item\" :index=\"index\" @play=\"playItem(index)\" @remove=\"removeItem\" />\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    playerQueueAutoPlay: {\n      get() {\n        return this.$store.state.playerQueueAutoPlay\n      },\n      set(val) {\n        this.$store.commit('setPlayerQueueAutoPlay', val)\n      }\n    },\n    playerQueueItems() {\n      return this.$store.state.playerQueueItems || []\n    }\n  },\n  methods: {\n    playItem(index) {\n      this.$eventBus.$emit('play-queue-item', {\n        index\n      })\n      this.show = false\n    },\n    removeItem(item) {\n      this.$store.commit('removeItemFromQueue', item)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/playlists/AddCreateModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"playlists\" :processing=\"processing\" :width=\"500\" :height=\"'unset'\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n\n    <div ref=\"container\" class=\"w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden\" style=\"max-height: 80vh\">\n      <div v-if=\"show\" class=\"w-full h-full\">\n        <div class=\"py-4 px-4\">\n          <h1 v-if=\"!isBatch\" class=\"text-2xl\">{{ $strings.LabelAddToPlaylist }}</h1>\n          <h1 v-else class=\"text-2xl\">{{ $getString('LabelAddToPlaylistBatch', [selectedPlaylistItems.length]) }}</h1>\n        </div>\n        <div class=\"w-full overflow-y-auto overflow-x-hidden max-h-96\">\n          <transition-group name=\"list-complete\" tag=\"div\">\n            <template v-for=\"playlist in sortedPlaylists\">\n              <modals-playlists-user-playlist-item :key=\"playlist.id\" :playlist=\"playlist\" class=\"list-complete-item\" @add=\"addToPlaylist\" @remove=\"removeFromPlaylist\" @close=\"show = false\" />\n            </template>\n          </transition-group>\n        </div>\n        <div v-if=\"!playlists.length\" class=\"flex h-32 items-center justify-center text-center px-2\">\n          <div>\n            <p class=\"text-xl mb-2\">{{ $strings.MessageNoUserPlaylists }}</p>\n            <div class=\"text-sm flex items-center justify-center text-gray-200\">\n              <p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>\n              <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n                <a href=\"https://www.audiobookshelf.org/guides/collections\" target=\"_blank\" class=\"inline-flex\">\n                  <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n                </a>\n              </ui-tooltip>\n            </div>\n          </div>\n        </div>\n        <div class=\"w-full h-px bg-white/10\" />\n        <form @submit.prevent=\"submitCreatePlaylist\">\n          <div class=\"flex px-4 py-2 items-center text-center border-b border-white/10 text-white/80\">\n            <div class=\"grow px-2\">\n              <ui-text-input v-model=\"newPlaylistName\" :placeholder=\"$strings.PlaceholderNewPlaylist\" class=\"w-full\" />\n            </div>\n            <ui-btn type=\"submit\" color=\"bg-success\" :padding-x=\"4\" class=\"h-10\">{{ $strings.ButtonCreate }}</ui-btn>\n          </div>\n        </form>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      newPlaylistName: '',\n      processing: false\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.loadPlaylists()\n        this.newPlaylistName = ''\n      } else {\n        this.$store.commit('globals/setSelectedPlaylistItems', null)\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showPlaylistsModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowPlaylistsModal', val)\n      }\n    },\n    title() {\n      if (!this.selectedPlaylistItems.length) return ''\n      if (this.isBatch) {\n        return this.$getString('MessageItemsSelected', [this.selectedPlaylistItems.length])\n      }\n      const selectedPlaylistItem = this.selectedPlaylistItems[0]\n      if (selectedPlaylistItem.episode) {\n        return selectedPlaylistItem.episode.title\n      }\n      return selectedPlaylistItem.libraryItem.media.metadata.title || ''\n    },\n    playlists() {\n      return this.$store.state.libraries.userPlaylists || []\n    },\n    sortedPlaylists() {\n      return this.playlists\n        .map((playlist) => {\n          const includesItem = !this.selectedPlaylistItems.some((item) => !this.checkIsItemInPlaylist(playlist, item))\n\n          return {\n            isItemIncluded: includesItem,\n            ...playlist\n          }\n        })\n        .sort((a, b) => {\n          if (a.isItemIncluded !== b.isItemIncluded) return a.isItemIncluded ? -1 : 1\n          return a.name.localeCompare(b.name)\n        })\n    },\n    isBatch() {\n      return this.selectedPlaylistItems.length > 1\n    },\n    selectedPlaylistItems() {\n      return this.$store.state.globals.selectedPlaylistItems || []\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    }\n  },\n  methods: {\n    checkIsItemInPlaylist(playlist, item) {\n      if (item.episode) {\n        return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id && i.episodeId === item.episode.id)\n      }\n      return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id)\n    },\n    loadPlaylists() {\n      this.processing = true\n      this.$axios\n        .$get(`/api/libraries/${this.currentLibraryId}/playlists`)\n        .then((data) => {\n          this.$store.commit('libraries/setUserPlaylists', data.results || [])\n        })\n        .catch((error) => {\n          console.error('Failed to get playlists', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    removeFromPlaylist(playlist) {\n      if (!this.selectedPlaylistItems.length) return\n      this.processing = true\n\n      const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))\n      this.$axios\n        .$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })\n        .then((updatedPlaylist) => {\n          console.log(`Items removed from playlist`, updatedPlaylist)\n          this.processing = false\n        })\n        .catch((error) => {\n          console.error('Failed to remove items from playlist', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n          this.processing = false\n        })\n    },\n    addToPlaylist(playlist) {\n      if (!this.selectedPlaylistItems.length) return\n      this.processing = true\n\n      const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))\n      this.$axios\n        .$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })\n        .then((updatedPlaylist) => {\n          console.log(`Items added to playlist`, updatedPlaylist)\n          this.processing = false\n        })\n        .catch((error) => {\n          console.error('Failed to add items to playlist', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n          this.processing = false\n        })\n    },\n    submitCreatePlaylist() {\n      if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {\n        return\n      }\n      this.processing = true\n\n      const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))\n      const newPlaylist = {\n        items: itemObjects,\n        libraryId: this.currentLibraryId,\n        name: this.newPlaylistName\n      }\n\n      this.$axios\n        .$post('/api/playlists', newPlaylist)\n        .then((data) => {\n          console.log('New playlist created', data)\n          this.processing = false\n          this.newPlaylistName = ''\n        })\n        .catch((error) => {\n          console.error('Failed to create playlist', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(this.$strings.ToastPlaylistCreateFailed + ': ' + errMsg)\n          this.processing = false\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/playlists/EditModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"edit-playlist\" :width=\"700\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.HeaderPlaylist }}</p>\n      </div>\n    </template>\n    <div class=\"p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\" style=\"min-height: 400px; max-height: 80vh\">\n      <form @submit.prevent=\"submitForm\">\n        <div class=\"flex\">\n          <div>\n            <covers-playlist-cover :items=\"items\" :width=\"200\" :height=\"200\" />\n          </div>\n          <div class=\"grow px-4\">\n            <ui-text-input-with-label v-model=\"newPlaylistName\" :label=\"$strings.LabelName\" class=\"mb-2\" />\n\n            <ui-textarea-with-label v-model=\"newPlaylistDescription\" :label=\"$strings.LabelDescription\" />\n          </div>\n        </div>\n        <div class=\"absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex\">\n          <ui-btn v-if=\"userCanDelete\" small color=\"bg-error\" type=\"button\" @click.stop=\"removeClick\">{{ $strings.ButtonRemove }}</ui-btn>\n          <div class=\"grow\" />\n          <ui-btn color=\"bg-success\" type=\"submit\">{{ $strings.ButtonSave }}</ui-btn>\n        </div>\n      </form>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      processing: false,\n      newPlaylistName: null,\n      newPlaylistDescription: null,\n      showImageUploader: false\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showEditPlaylistModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowEditPlaylistModal', val)\n      }\n    },\n    playlist() {\n      return this.$store.state.globals.selectedPlaylist || {}\n    },\n    playlistName() {\n      return this.playlist.name\n    },\n    items() {\n      return this.playlist.items || []\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    }\n  },\n  methods: {\n    init() {\n      this.newPlaylistName = this.playlistName\n      this.newPlaylistDescription = this.playlist.description || ''\n    },\n    removeClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removePlaylist()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    removePlaylist() {\n      this.processing = true\n      this.$axios\n        .$delete(`/api/playlists/${this.playlist.id}`)\n        .then(() => {\n          this.show = false\n          this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to remove playlist', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submitForm() {\n      if (this.newPlaylistName === this.playlistName && this.newPlaylistDescription === this.playlist.description) {\n        return\n      }\n      if (!this.newPlaylistName) {\n        return this.$toast.error(this.$strings.ToastNameRequired)\n      }\n\n      this.processing = true\n\n      var playlistUpdate = {\n        name: this.newPlaylistName,\n        description: this.newPlaylistDescription || null\n      }\n      this.$axios\n        .$patch(`/api/playlists/${this.playlist.id}`, playlistUpdate)\n        .then((playlist) => {\n          console.log('Playlist Updated', playlist)\n          this.processing = false\n          this.show = false\n          this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to update playlist', error)\n          this.processing = false\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/playlists/UserPlaylistItem.vue",
    "content": "<template>\n  <div class=\"flex items-center px-4 py-2 justify-start relative hover:bg-black-400\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <div v-if=\"isItemIncluded\" class=\"absolute top-0 left-0 h-full w-1 bg-success z-10\" />\n    <div class=\"w-16 max-w-16 text-center\">\n      <covers-playlist-cover :items=\"items\" :width=\"64\" :height=\"64\" />\n    </div>\n    <div class=\"grow overflow-hidden px-2\">\n      <nuxt-link :to=\"`/playlist/${playlist.id}`\" class=\"pl-2 pr-2 truncate hover:underline cursor-pointer\" @click.native=\"clickNuxtLink\">{{ playlist.name }}</nuxt-link>\n    </div>\n    <div class=\"h-full flex items-center justify-end transform\" :class=\"isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'\">\n      <ui-btn v-if=\"!isItemIncluded\" color=\"bg-success\" :padding-x=\"3\" small class=\"h-9\" @click.stop=\"clickAdd\"><span class=\"material-symbols text-2xl pt-px\">add</span></ui-btn>\n      <ui-btn v-else color=\"bg-error\" :padding-x=\"3\" class=\"h-9\" small @click.stop=\"clickRem\"><span class=\"material-symbols text-2xl pt-px\">remove</span></ui-btn>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    playlist: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      isHovering: false\n    }\n  },\n  computed: {\n    isItemIncluded() {\n      return !!this.playlist.isItemIncluded\n    },\n    items() {\n      return this.playlist.items || []\n    }\n  },\n  methods: {\n    clickNuxtLink() {\n      this.$emit('close')\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    clickAdd() {\n      this.$emit('add', this.playlist)\n    },\n    clickRem() {\n      this.$emit('remove', this.playlist)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/podcast/EditEpisode.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"podcast-episode-edit-modal\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div class=\"absolute -top-10 left-0 z-10 w-full flex\">\n      <template v-for=\"tab in tabs\">\n        <div :key=\"tab.id\" class=\"w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base\" :class=\"selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'\" @click=\"selectTab(tab.id)\">{{ tab.title }}</div>\n      </template>\n    </div>\n\n    <div v-show=\"canGoPrev\" class=\"absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6\">\n      <div class=\"material-symbols text-5xl text-white/50 hover:text-white/90 cursor-pointer pointer-events-auto\" @click.stop.prevent=\"goPrevEpisode\" @mousedown.prevent>arrow_back_ios</div>\n    </div>\n    <div v-show=\"canGoNext\" class=\"absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6\">\n      <div class=\"material-symbols text-5xl text-white/50 hover:text-white/90 cursor-pointer pointer-events-auto\" @click.stop.prevent=\"goNextEpisode\" @mousedown.prevent>arrow_forward_ios</div>\n    </div>\n\n    <div ref=\"wrapper\" class=\"p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto\" style=\"max-height: 80vh\">\n      <component v-if=\"libraryItem && show\" :is=\"tabComponentName\" :library-item=\"libraryItem\" :episode=\"episodeItem\" :processing.sync=\"processing\" @close=\"show = false\" @selectTab=\"selectTab\" />\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      episodeItem: null,\n      processing: false,\n      tabs: [\n        {\n          id: 'details',\n          title: this.$strings.HeaderDetails,\n          component: 'modals-podcast-tabs-episode-details'\n        },\n        {\n          id: 'match',\n          title: this.$strings.HeaderMatch,\n          component: 'modals-podcast-tabs-episode-match'\n        }\n      ]\n    }\n  },\n  watch: {\n    show: {\n      handler(newVal) {\n        if (newVal) {\n          const availableTabIds = this.tabs.map((tab) => tab.id)\n          if (!availableTabIds.length) {\n            this.show = false\n            return\n          }\n\n          if (!availableTabIds.includes(this.selectedTab)) {\n            this.selectedTab = availableTabIds[0]\n          }\n\n          this.episodeItem = null\n          this.init()\n          this.registerListeners()\n        } else {\n          this.unregisterListeners()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showEditPodcastEpisode\n      },\n      set(val) {\n        this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)\n      }\n    },\n    selectedTab: {\n      get() {\n        return this.$store.state.editPodcastModalTab\n      },\n      set(val) {\n        this.$store.commit('setEditPodcastModalTab', val)\n      }\n    },\n    libraryItem() {\n      return this.$store.state.selectedLibraryItem\n    },\n    episode() {\n      return this.$store.state.globals.selectedEpisode\n    },\n    selectedEpisodeId() {\n      return this.episode.id\n    },\n    title() {\n      return this.libraryItem?.media.metadata.title || 'Unknown'\n    },\n    tabComponentName() {\n      const _tab = this.tabs.find((t) => t.id === this.selectedTab)\n      return _tab ? _tab.component : ''\n    },\n    episodeTableEpisodeIds() {\n      return this.$store.state.episodeTableEpisodeIds || []\n    },\n    currentEpisodeIndex() {\n      if (!this.episodeTableEpisodeIds.length) return 0\n      return this.episodeTableEpisodeIds.findIndex((bid) => bid === this.selectedEpisodeId)\n    },\n    canGoPrev() {\n      return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex > 0\n    },\n    canGoNext() {\n      return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex < this.episodeTableEpisodeIds.length - 1\n    }\n  },\n  methods: {\n    async goPrevEpisode() {\n      if (this.currentEpisodeIndex - 1 < 0) return\n      // Remove focus from active input\n      document.activeElement?.blur?.()\n\n      const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]\n      this.processing = true\n\n      const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {\n        const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'\n        this.$toast.error(errorMsg)\n        return null\n      })\n      this.processing = false\n      if (prevEpisode) {\n        this.episodeItem = prevEpisode\n        this.$store.commit('globals/setSelectedEpisode', prevEpisode)\n      } else {\n        console.error('Episode not found', prevEpisodeId)\n      }\n    },\n    async goNextEpisode() {\n      if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return\n      // Remove focus from active input\n      document.activeElement?.blur?.()\n\n      this.processing = true\n      const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]\n\n      const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {\n        const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'\n        this.$toast.error(errorMsg)\n        return null\n      })\n      this.processing = false\n      if (nextEpisode) {\n        this.episodeItem = nextEpisode\n        this.$store.commit('globals/setSelectedEpisode', nextEpisode)\n      } else {\n        console.error('Episode not found', nextEpisodeId)\n      }\n    },\n    selectTab(tab) {\n      if (this.selectedTab === tab) return\n      if (this.tabs.find((t) => t.id === tab)) {\n        this.selectedTab = tab\n        this.processing = false\n      }\n    },\n    init() {\n      this.fetchFull()\n    },\n    async fetchFull() {\n      try {\n        this.processing = true\n        this.episodeItem = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${this.selectedEpisodeId}`)\n        this.processing = false\n      } catch (error) {\n        console.error('Failed to fetch episode', this.selectedEpisodeId, error)\n        this.processing = false\n        this.show = false\n      }\n    },\n    libraryItemUpdated(libraryItem) {\n      const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)\n      if (episode) {\n        this.episodeItem = episode\n      }\n    },\n    hotkey(action) {\n      if (action === this.$hotkeys.Modal.NEXT_PAGE) {\n        this.goNextEpisode()\n      } else if (action === this.$hotkeys.Modal.PREV_PAGE) {\n        this.goPrevEpisode()\n      }\n    },\n    registerListeners() {\n      if (this.libraryItem) {\n        this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n      }\n      this.$eventBus.$on('modal-hotkey', this.hotkey)\n    },\n    unregisterListeners() {\n      if (this.libraryItem) {\n        this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n      }\n      this.$eventBus.$off('modal-hotkey', this.hotkey)\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    this.unregisterListeners()\n  }\n}\n</script>\n\n<style scoped>\n.tab {\n  height: 40px;\n}\n.tab.tab-selected {\n  height: 41px;\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/podcast/EpisodeFeed.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"podcast-episodes-modal\" :width=\"1200\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div ref=\"wrapper\" id=\"podcast-wrapper\" class=\"p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\">\n      <div v-if=\"episodesCleaned.length\" class=\"w-full py-3 mx-auto flex\">\n        <form @submit.prevent=\"submit\" class=\"flex grow\">\n          <ui-text-input v-model=\"search\" @input=\"inputUpdate\" type=\"search\" :placeholder=\"$strings.PlaceholderSearchEpisode\" class=\"grow mr-2 text-sm md:text-base\" />\n        </form>\n        <ui-btn :padding-x=\"4\" @click=\"toggleSort\">\n          <span class=\"pr-4\">{{ $strings.LabelSortPubDate }}</span>\n          <span class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-2\">\n            <span class=\"material-symbols text-xl\" :aria-label=\"sortDescending ? $strings.LabelSortDescending : $strings.LabelSortAscending\">{{ sortDescending ? 'expand_more' : 'expand_less' }}</span>\n          </span>\n        </ui-btn>\n      </div>\n      <div ref=\"episodeContainer\" id=\"episodes-scroll\" class=\"w-full overflow-x-hidden overflow-y-auto\">\n        <div v-for=\"(episode, index) in episodesList\" :key=\"index\" class=\"relative\" :class=\"episode.isDownloaded || episode.isDownloading ? 'bg-primary/40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success/10' : index % 2 == 0 ? 'cursor-pointer bg-primary/25 hover:bg-primary/40' : 'cursor-pointer bg-primary/5 hover:bg-primary/25'\" @click=\"toggleSelectEpisode(episode)\">\n          <div class=\"absolute top-0 left-0 h-full flex items-center p-2\">\n            <span v-if=\"episode.isDownloaded\" class=\"material-symbols text-success text-xl\">download_done</span>\n            <span v-else-if=\"episode.isDownloading\" class=\"material-symbols text-warning text-xl\">download</span>\n            <ui-checkbox v-else v-model=\"selectedEpisodes[episode.cleanUrl]\" small checkbox-bg=\"primary\" border-color=\"gray-600\" />\n          </div>\n          <div class=\"px-8 py-2\">\n            <div class=\"flex items-center font-semibold text-gray-200\">\n              <div v-if=\"episode.season || episode.episode\">#</div>\n              <div v-if=\"episode.season\">{{ episode.season }}x</div>\n              <div v-if=\"episode.episode\">{{ episode.episode }}</div>\n            </div>\n            <div class=\"flex items-center mb-1\">\n              <div class=\"break-words\">{{ episode.title }}</div>\n              <widgets-podcast-type-indicator :type=\"episode.episodeType\" />\n            </div>\n            <p v-if=\"episode.subtitle\" class=\"mb-1 text-sm text-gray-300 line-clamp-2\">{{ episode.subtitle }}</p>\n            <div class=\"flex items-center space-x-2\">\n              <!-- published -->\n              <p class=\"text-xs text-gray-300 w-40\">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>\n              <!-- duration -->\n              <p v-if=\"episode.durationSeconds && !isNaN(episode.durationSeconds)\" class=\"text-xs text-gray-300 min-w-28\">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p>\n              <!-- size -->\n              <p v-if=\"episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0\" class=\"text-xs text-gray-300\">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"flex justify-end pt-4\">\n        <ui-checkbox v-if=\"!allDownloaded\" v-model=\"selectAll\" @input=\"toggleSelectAll\" :label=\"selectAllLabel\" small checkbox-bg=\"primary\" border-color=\"gray-600\" class=\"mx-8\" />\n        <ui-btn v-if=\"!allDownloaded\" :disabled=\"!episodesSelected.length\" @click=\"submit\">{{ buttonText }}</ui-btn>\n        <p v-else class=\"text-success text-base px-2 py-4\">{{ $strings.LabelAllEpisodesDownloaded }}</p>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    episodes: {\n      type: Array,\n      default: () => []\n    },\n    downloadQueue: {\n      type: Array,\n      default: () => []\n    },\n    episodesDownloading: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      episodesCleaned: [],\n      selectedEpisodes: {},\n      selectAll: false,\n      search: null,\n      searchTimeout: null,\n      searchText: null,\n      downloadedEpisodeGuidMap: {},\n      downloadedEpisodeUrlMap: {},\n      sortDescending: true\n    }\n  },\n  watch: {\n    show: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    },\n    episodes: {\n      handler(newVal) {\n        if (newVal) this.updateEpisodeDownloadStatuses()\n      }\n    },\n    episodesDownloading: {\n      handler(newVal) {\n        if (newVal) this.updateEpisodeDownloadStatuses()\n      }\n    },\n    downloadQueue: {\n      handler(newVal) {\n        if (newVal) this.updateEpisodeDownloadStatuses()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      if (!this.libraryItem) return ''\n      return this.libraryItem.media.metadata.title || 'Unknown'\n    },\n    allDownloaded() {\n      return !this.episodesCleaned.some((episode) => !this.getIsEpisodeDownloaded(episode))\n    },\n    episodesSelected() {\n      return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])\n    },\n    buttonText() {\n      if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected\n      if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`\n      return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])\n    },\n    itemEpisodes() {\n      return this.libraryItem?.media.episodes || []\n    },\n    episodesList() {\n      return this.episodesCleaned.filter((episode) => {\n        if (!this.searchText) return true\n        return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)\n      })\n    },\n    selectAllLabel() {\n      if (this.episodesList.length === this.episodesCleaned.length) {\n        return this.$strings.LabelSelectAllEpisodes\n      }\n      const episodesNotDownloaded = this.episodesList.filter((ep) => !this.getIsEpisodeDownloaded(ep)).length\n      return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])\n    }\n  },\n  methods: {\n    toggleSort() {\n      this.sortDescending = !this.sortDescending\n      this.episodesCleaned = this.episodesCleaned.toSorted((a, b) => {\n        if (this.sortDescending) {\n          return a.publishedAt < b.publishedAt ? 1 : -1\n        }\n        return a.publishedAt > b.publishedAt ? 1 : -1\n      })\n      this.selectedEpisodes = {}\n      this.selectAll = false\n    },\n    getIsEpisodeDownloaded(episode) {\n      if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) {\n        return true\n      }\n      if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) {\n        return true\n      }\n      return false\n    },\n    getIsEpisodeDownloadingOrQueued(episode) {\n      const episodesToCheck = [...this.episodesDownloading, ...this.downloadQueue]\n      if (episode.guid) {\n        return episodesToCheck.some((download) => download.guid === episode.guid)\n      }\n      return episodesToCheck.some((download) => this.getCleanEpisodeUrl(download.url) === episode.cleanUrl)\n    },\n    /**\n     * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.\n     * Fallback to checking the clean url\n     * @see https://github.com/advplyr/audiobookshelf/issues/2207\n     *\n     * RSS feed episode url is used for matching with existing downloaded episodes.\n     * Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.\n     * These need to be removed in order to detect the same episode each time the feed is pulled.\n     *\n     * An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.\n     * @see https://github.com/advplyr/audiobookshelf/issues/1896\n     *\n     * @param {string} url - rss feed episode url\n     * @returns {string} rss feed episode url without dynamic query strings\n     */\n    getCleanEpisodeUrl(url) {\n      let queryString = url.split('?')[1]\n      if (!queryString) return url\n\n      const searchParams = new URLSearchParams(queryString)\n      for (const p of Array.from(searchParams.keys())) {\n        if (p !== 'id') searchParams.delete(p)\n      }\n\n      if (!searchParams.toString()) return url\n      return `${url}?${searchParams.toString()}`\n    },\n    inputUpdate() {\n      clearTimeout(this.searchTimeout)\n      this.searchTimeout = setTimeout(() => {\n        if (!this.search?.trim()) {\n          this.searchText = ''\n          this.checkSetIsSelectedAll()\n          return\n        }\n        this.searchText = this.search.toLowerCase().trim()\n        this.checkSetIsSelectedAll()\n      }, 500)\n    },\n    toggleSelectAll(val) {\n      for (const episode of this.episodesList) {\n        if (episode.isDownloaded || episode.isDownloading) this.selectedEpisodes[episode.cleanUrl] = false\n        else this.$set(this.selectedEpisodes, episode.cleanUrl, val)\n      }\n    },\n    checkSetIsSelectedAll() {\n      for (const episode of this.episodesList) {\n        if (!episode.isDownloaded && !episode.isDownloading && !this.selectedEpisodes[episode.cleanUrl]) {\n          this.selectAll = false\n          return\n        }\n      }\n      this.selectAll = true\n    },\n    toggleSelectEpisode(episode) {\n      if (episode.isDownloaded || episode.isDownloading) return\n      this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])\n      this.checkSetIsSelectedAll()\n    },\n    submit() {\n      let episodesToDownload = []\n      if (this.episodesSelected.length) {\n        episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))\n      }\n\n      const payloadSize = JSON.stringify(episodesToDownload).length\n      const sizeInMb = payloadSize / 1024 / 1024\n      const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'\n      console.log('Request size', sizeInMb)\n      if (sizeInMb > 9.99) {\n        return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 10Mb`)\n      }\n\n      this.processing = true\n      this.$axios\n        .$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)\n        .then(() => {\n          this.processing = false\n          this.$toast.success('Started downloading episodes')\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to download episodes', error)\n          this.processing = false\n          this.$toast.error(error.response?.data || 'Failed to download episodes')\n\n          this.selectedEpisodes = {}\n          this.selectAll = false\n        })\n    },\n    init() {\n      this.updateDownloadedEpisodeMaps()\n\n      this.episodesCleaned = this.episodes\n        .filter((ep) => ep.enclosure?.url)\n        .map((_ep) => {\n          return {\n            ..._ep,\n            cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url),\n            isDownloading: this.getIsEpisodeDownloadingOrQueued(_ep),\n            isDownloaded: this.getIsEpisodeDownloaded(_ep)\n          }\n        })\n      this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))\n      this.selectAll = false\n      this.selectedEpisodes = {}\n    },\n    updateDownloadedEpisodeMaps() {\n      this.downloadedEpisodeGuidMap = {}\n      this.downloadedEpisodeUrlMap = {}\n\n      this.itemEpisodes.forEach((episode) => {\n        if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id\n        if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id\n      })\n    },\n    updateEpisodeDownloadStatuses() {\n      this.updateDownloadedEpisodeMaps()\n      this.episodesCleaned = this.episodesCleaned.map((ep) => {\n        return {\n          ...ep,\n          isDownloading: this.getIsEpisodeDownloadingOrQueued(ep),\n          isDownloaded: this.getIsEpisodeDownloaded(ep)\n        }\n      })\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n#podcast-wrapper {\n  min-height: 400px;\n  max-height: 80vh;\n}\n#episodes-scroll {\n  max-height: calc(80vh - 200px);\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/podcast/NewModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"new-podcast-modal\" :width=\"1000\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-3/4 overflow-hidden\">\n        <p class=\"text-xl md:text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div ref=\"wrapper\" id=\"podcast-wrapper\" class=\"p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto\" style=\"max-height: 80vh\">\n      <div class=\"w-full\">\n        <p class=\"text-lg font-semibold mb-2 px-2\">{{ $strings.HeaderDetails }}</p>\n\n        <div v-if=\"podcast.imageUrl\" class=\"p-2 w-full\">\n          <img :src=\"podcast.imageUrl\" class=\"h-16 w-16 object-contain\" />\n        </div>\n        <div class=\"flex flex-wrap\">\n          <div class=\"w-full md:w-1/2 p-2\">\n            <ui-text-input-with-label v-model=\"podcast.title\" :label=\"$strings.LabelTitle\" @input=\"titleUpdated\" />\n          </div>\n          <div class=\"w-full md:w-1/2 p-2\">\n            <ui-text-input-with-label v-model=\"podcast.author\" :label=\"$strings.LabelAuthor\" />\n          </div>\n        </div>\n        <div class=\"flex flex-wrap\">\n          <div class=\"w-full md:w-1/2 p-2\">\n            <ui-text-input-with-label v-model=\"podcast.feedUrl\" :label=\"$strings.LabelFeedURL\" readonly />\n          </div>\n          <div class=\"w-full md:w-1/2 p-2\">\n            <ui-multi-select v-model=\"podcast.genres\" :items=\"podcast.genres\" :label=\"$strings.LabelGenres\" />\n          </div>\n        </div>\n        <div class=\"flex flex-wrap\">\n          <div class=\"md:w-1/4 p-2\">\n            <ui-dropdown :label=\"$strings.LabelPodcastType\" v-model=\"podcast.type\" :items=\"podcastTypes\" small />\n          </div>\n          <div class=\"md:w-1/4 p-2\">\n            <ui-text-input-with-label v-model=\"podcast.language\" :label=\"$strings.LabelLanguage\" />\n          </div>\n          <div class=\"md:w-1/4 px-2 pt-7\">\n            <ui-checkbox v-model=\"podcast.explicit\" :label=\"$strings.LabelExplicit\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2 text-base font-semibold\" />\n          </div>\n        </div>\n        <div class=\"p-2 w-full\">\n          <ui-textarea-with-label v-model=\"podcast.description\" :label=\"$strings.LabelDescription\" :rows=\"3\" />\n        </div>\n        <div class=\"flex flex-wrap\">\n          <div class=\"w-full md:w-1/2 p-2\">\n            <ui-dropdown v-model=\"selectedFolderId\" :items=\"folderItems\" :disabled=\"processing\" :label=\"$strings.LabelFolder\" @input=\"folderUpdated\" />\n          </div>\n          <div class=\"w-full md:w-1/2 p-2\">\n            <ui-text-input-with-label v-model=\"fullPath\" :label=\"`${$strings.LabelPodcast} ${$strings.LabelPath}`\" input-class=\"h-10\" readonly />\n          </div>\n        </div>\n      </div>\n      <div class=\"flex items-center py-4 px-2\">\n        <div class=\"grow\" />\n        <div class=\"px-4\">\n          <ui-checkbox v-model=\"podcast.autoDownloadEpisodes\" :label=\"$strings.LabelAutoDownloadEpisodes\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2 text-sm md:text-base font-semibold\" />\n        </div>\n        <ui-btn color=\"bg-success\" @click=\"submit\">{{ $strings.ButtonSubmit }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nimport Path from 'path'\n\nexport default {\n  props: {\n    value: Boolean,\n    podcastData: {\n      type: Object,\n      default: () => null\n    },\n    podcastFeedData: {\n      type: Object,\n      default: () => null\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      selectedFolderId: null,\n      fullPath: null,\n      podcast: {\n        title: '',\n        author: '',\n        description: '',\n        releaseDate: '',\n        genres: [],\n        feedUrl: '',\n        feedImageUrl: '',\n        itunesPageUrl: '',\n        itunesId: '',\n        itunesArtistId: '',\n        autoDownloadEpisodes: false,\n        language: '',\n        explicit: false,\n        type: ''\n      }\n    }\n  },\n  watch: {\n    show: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      return this._podcastData.title\n    },\n    currentLibrary() {\n      return this.$store.getters['libraries/getCurrentLibrary']\n    },\n    folders() {\n      if (!this.currentLibrary) return []\n      return this.currentLibrary.folders || []\n    },\n    folderItems() {\n      return this.folders.map((fold) => {\n        return {\n          value: fold.id,\n          text: fold.fullPath\n        }\n      })\n    },\n    _podcastData() {\n      return this.podcastData || {}\n    },\n    feedMetadata() {\n      if (!this.podcastFeedData) return {}\n      return this.podcastFeedData.metadata || {}\n    },\n    episodes() {\n      if (!this.podcastFeedData) return []\n      return this.podcastFeedData.episodes || []\n    },\n    selectedFolder() {\n      return this.folders.find((f) => f.id === this.selectedFolderId)\n    },\n    selectedFolderPath() {\n      if (!this.selectedFolder) return ''\n      return this.selectedFolder.fullPath\n    },\n    podcastTypes() {\n      return this.$store.state.globals.podcastTypes.map((e) => {\n        return {\n          text: this.$strings[e.descriptionKey] || e.text,\n          value: e.value\n        }\n      })\n    }\n  },\n  methods: {\n    titleUpdated() {\n      this.folderUpdated()\n    },\n    folderUpdated() {\n      if (!this.selectedFolderPath || !this.podcast.title) {\n        this.fullPath = ''\n        return\n      }\n      this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))\n    },\n    submit() {\n      const podcastPayload = {\n        path: this.fullPath,\n        folderId: this.selectedFolderId,\n        libraryId: this.currentLibrary.id,\n        media: {\n          metadata: {\n            title: this.podcast.title,\n            author: this.podcast.author,\n            description: this.podcast.description,\n            releaseDate: this.podcast.releaseDate,\n            genres: [...this.podcast.genres],\n            feedUrl: this.podcast.feedUrl,\n            imageUrl: this.podcast.imageUrl,\n            itunesPageUrl: this.podcast.itunesPageUrl,\n            itunesId: this.podcast.itunesId,\n            itunesArtistId: this.podcast.itunesArtistId,\n            language: this.podcast.language,\n            explicit: this.podcast.explicit,\n            type: this.podcast.type\n          },\n          autoDownloadEpisodes: this.podcast.autoDownloadEpisodes\n        }\n      }\n      console.log('Podcast payload', podcastPayload)\n\n      this.processing = true\n      this.$axios\n        .$post('/api/podcasts', podcastPayload)\n        .then((libraryItem) => {\n          this.processing = false\n          this.$toast.success(this.$strings.ToastPodcastCreateSuccess)\n          this.show = false\n          this.$router.push(`/item/${libraryItem.id}`)\n        })\n        .catch((error) => {\n          var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed\n          console.error('Failed to create podcast', error)\n          this.processing = false\n          this.$toast.error(errorMsg)\n        })\n    },\n    init() {\n      // Prefer using itunes podcast data but not always passed in if manually entering rss feed\n      this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''\n      this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''\n      this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''\n      this.podcast.releaseDate = this._podcastData.releaseDate || ''\n      this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []\n      this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''\n      this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''\n      this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''\n      this.podcast.itunesId = this._podcastData.id || ''\n      this.podcast.itunesArtistId = this._podcastData.artistId || ''\n      this.podcast.language = this._podcastData.language || this.feedMetadata.language || ''\n      this.podcast.autoDownloadEpisodes = false\n      this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic'\n\n      this.podcast.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true'\n      if (this.folderItems[0]) {\n        this.selectedFolderId = this.folderItems[0].value\n        this.folderUpdated()\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n#podcast-wrapper {\n  min-height: 400px;\n  max-height: 80vh;\n}\n#episodes-scroll {\n  max-height: calc(80vh - 200px);\n}\n</style>\n"
  },
  {
    "path": "client/components/modals/podcast/OpmlFeedsModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"opml-feeds-modal\" :width=\"1000\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div ref=\"wrapper\" class=\"p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\">\n      <div class=\"w-full p-4\">\n        <div class=\"flex items-center -mx-2 mb-2\">\n          <div class=\"w-full md:w-2/3 p-2\">\n            <ui-dropdown v-model=\"selectedFolderId\" :items=\"folderItems\" :disabled=\"processing\" :label=\"$strings.LabelFolder\" />\n          </div>\n          <div class=\"w-full md:w-1/3 p-2 pt-6\">\n            <ui-checkbox v-model=\"autoDownloadEpisodes\" :label=\"$strings.LabelAutoDownloadEpisodes\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"text-sm font-semibold pl-2\" />\n          </div>\n        </div>\n\n        <p class=\"text-lg font-semibold mb-1\">{{ $strings.HeaderPodcastsToAdd }}</p>\n        <p class=\"text-sm text-gray-300 mb-4\">{{ $strings.MessageOpmlPreviewNote }}</p>\n\n        <div class=\"w-full overflow-y-auto\" style=\"max-height: 50vh\">\n          <template v-for=\"(feed, index) in feeds\">\n            <div :key=\"index\" class=\"py-1 flex items-center\">\n              <p class=\"text-lg font-semibold\">{{ index + 1 }}.</p>\n              <div class=\"pl-2\">\n                <p v-if=\"feed.title\" class=\"text-sm font-semibold\">{{ feed.title }}</p>\n                <p class=\"text-xs text-gray-400\">{{ feed.feedUrl }}</p>\n              </div>\n            </div>\n          </template>\n        </div>\n      </div>\n      <div class=\"flex items-center py-4\">\n        <div class=\"grow\" />\n        <ui-btn color=\"bg-success\" @click=\"submit\">{{ $strings.ButtonAddPodcasts }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    feeds: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      selectedFolderId: null,\n      autoDownloadEpisodes: false\n    }\n  },\n  watch: {\n    show: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    title() {\n      return 'OPML Feeds'\n    },\n    currentLibrary() {\n      return this.$store.getters['libraries/getCurrentLibrary']\n    },\n    folders() {\n      if (!this.currentLibrary) return []\n      return this.currentLibrary.folders || []\n    },\n    folderItems() {\n      return this.folders.map((fold) => {\n        return {\n          value: fold.id,\n          text: fold.fullPath\n        }\n      })\n    },\n    selectedFolder() {\n      return this.folders.find((f) => f.id === this.selectedFolderId)\n    },\n    selectedFolderPath() {\n      if (!this.selectedFolder) return ''\n      return this.selectedFolder.fullPath\n    }\n  },\n  methods: {\n    init() {\n      if (this.folderItems[0]) {\n        this.selectedFolderId = this.folderItems[0].value\n      }\n    },\n    async submit() {\n      this.processing = true\n\n      const payload = {\n        feeds: this.feeds.map((f) => f.feedUrl),\n        folderId: this.selectedFolderId,\n        libraryId: this.currentLibrary.id,\n        autoDownloadEpisodes: this.autoDownloadEpisodes\n      }\n      this.$axios\n        .$post('/api/podcasts/opml/create', payload)\n        .then(() => {\n          this.show = false\n        })\n        .catch((error) => {\n          const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed\n          console.error('Failed to create podcast', payload, error)\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n\n"
  },
  {
    "path": "client/components/modals/podcast/RemoveEpisode.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"podcast-episode-remove-modal\" :width=\"500\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div ref=\"wrapper\" class=\"px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\">\n      <div class=\"mb-4\">\n        <p v-if=\"episode\" class=\"text-lg text-gray-200 mb-4\">\n          {{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}\n        </p>\n        <p v-else class=\"text-lg text-gray-200 mb-4\">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</p>\n        <p class=\"text-xs font-semibold text-warning/90\">{{ $strings.MessageConfirmRemoveEpisodeNote }}</p>\n      </div>\n      <div class=\"flex justify-between items-center pt-4\">\n        <ui-checkbox v-model=\"hardDeleteFile\" :label=\"$strings.LabelHardDeleteFile\" check-color=\"error\" checkbox-bg=\"bg\" small label-class=\"text-base text-gray-200 pl-3\" />\n\n        <ui-btn @click=\"submit\">{{ btnText }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    episodes: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      hardDeleteFile: false,\n      processing: false\n    }\n  },\n  watch: {\n    value(newVal) {\n      if (newVal) this.hardDeleteFile = false\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    episode() {\n      if (this.episodes.length === 1) return this.episodes[0]\n      return null\n    },\n    title() {\n      if (this.episodes.length > 1) return this.$getString('HeaderRemoveEpisodes', [this.episodes.length])\n      return this.$strings.HeaderRemoveEpisode\n    },\n    btnText() {\n      return this.hardDeleteFile ? this.$strings.ButtonDelete : this.$strings.ButtonRemove\n    },\n    episodeTitle() {\n      return this.episode ? this.episode.title : null\n    }\n  },\n  methods: {\n    async submit() {\n      this.processing = true\n\n      var queryString = this.hardDeleteFile ? '?hard=1' : ''\n      for (const episode of this.episodes) {\n        const success = await this.$axios\n          .$delete(`/api/podcasts/${this.libraryItem.id}/episode/${episode.id}${queryString}`)\n          .then(() => true)\n          .catch((error) => {\n            var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to remove episode'\n            console.error('Failed to remove episode', error)\n            this.$toast.error(errorMsg)\n            return false\n          })\n        if (!success) {\n          this.processing = false\n          this.show = false\n          this.$emit('clearSelected')\n          return\n        }\n      }\n\n      this.processing = false\n      this.show = false\n      this.$emit('clearSelected')\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/podcast/ViewEpisode.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"podcast-episode-view-modal\" :width=\"800\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ $strings.LabelEpisode }}</p>\n      </div>\n    </template>\n    <div ref=\"wrapper\" class=\"p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto\" style=\"max-height: 80vh\">\n      <div class=\"flex mb-4\">\n        <div class=\"w-12 h-12\">\n          <covers-book-cover :library-item=\"libraryItem\" :width=\"48\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n        </div>\n        <div class=\"grow px-2\">\n          <p class=\"text-base mb-1\">{{ podcastTitle }}</p>\n          <p class=\"text-xs text-gray-300\">{{ podcastAuthor }}</p>\n        </div>\n      </div>\n      <p dir=\"auto\" class=\"text-lg font-semibold mb-6\">{{ title }}</p>\n      <div v-if=\"description\" dir=\"auto\" class=\"default-style less-spacing\" @click=\"handleDescriptionClick\" v-html=\"description\" />\n      <p v-else class=\"mb-2\">{{ $strings.MessageNoDescription }}</p>\n\n      <div class=\"w-full h-px bg-white/5 my-4\" />\n\n      <div class=\"flex items-center\">\n        <div class=\"grow\">\n          <p class=\"font-semibold text-xs mb-1\">{{ $strings.LabelFilename }}</p>\n          <p class=\"mb-2 text-xs\">\n            {{ audioFileFilename }}\n          </p>\n        </div>\n        <div class=\"grow\">\n          <p class=\"font-semibold text-xs mb-1\">{{ $strings.LabelSize }}</p>\n          <p class=\"mb-2 text-xs\">\n            {{ audioFileSize }}\n          </p>\n        </div>\n        <div class=\"grow\">\n          <p class=\"font-semibold text-xs mb-1\">{{ $strings.LabelDuration }}</p>\n          <p class=\"mb-2 text-xs\">\n            {{ audioFileDuration }}\n          </p>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      processing: false\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showViewPodcastEpisodeModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowViewPodcastEpisodeModal', val)\n      }\n    },\n    libraryItem() {\n      return this.$store.state.selectedLibraryItem\n    },\n    episode() {\n      return this.$store.state.globals.selectedEpisode || {}\n    },\n    episodeId() {\n      return this.episode.id\n    },\n    title() {\n      return this.episode.title || 'No Episode Title'\n    },\n    description() {\n      return this.parseDescription(this.episode.description || '')\n    },\n    media() {\n      return this.libraryItem?.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    podcastTitle() {\n      return this.mediaMetadata.title\n    },\n    podcastAuthor() {\n      return this.mediaMetadata.author\n    },\n    audioFileFilename() {\n      return this.episode.audioFile?.metadata?.filename || ''\n    },\n    audioFileSize() {\n      const size = this.episode.audioFile?.metadata?.size || 0\n\n      return this.$bytesPretty(size)\n    },\n    audioFileDuration() {\n      const duration = this.episode.duration || 0\n      return this.$elapsedPretty(duration)\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    }\n  },\n  methods: {\n    handleDescriptionClick(e) {\n      if (e.target.matches('span.time-marker')) {\n        const time = parseInt(e.target.dataset.time)\n        if (!isNaN(time)) {\n          this.$eventBus.$emit('play-item', {\n            episodeId: this.episodeId,\n            libraryItemId: this.libraryItem.id,\n            startTime: time\n          })\n        }\n        e.preventDefault()\n      }\n    },\n    parseDescription(description) {\n      const timeMarkerLinkRegex = /<a href=\"#([^\"]*?\\b\\d{1,2}:\\d{1,2}(?::\\d{1,2})?)\">(.*?)<\\/a>/g\n      const timeMarkerRegex = /\\b\\d{1,2}:\\d{1,2}(?::\\d{1,2})?\\b/g\n\n      function convertToSeconds(time) {\n        const timeParts = time.split(':').map(Number)\n        return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)\n      }\n\n      return description\n        .replace(timeMarkerLinkRegex, (match, href, displayTime) => {\n          const time = displayTime.match(timeMarkerRegex)[0]\n          const seekTimeInSeconds = convertToSeconds(time)\n          return `<span class=\"time-marker cursor-pointer text-blue-400 hover:text-blue-300\" data-time=\"${seekTimeInSeconds}\">${displayTime}</span>`\n        })\n        .replace(timeMarkerRegex, (match) => {\n          const seekTimeInSeconds = convertToSeconds(match)\n          return `<span class=\"time-marker cursor-pointer text-blue-400 hover:text-blue-300\" data-time=\"${seekTimeInSeconds}\">${match}</span>`\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/podcast/tabs/EpisodeDetails.vue",
    "content": "<template>\n  <div>\n    <div class=\"flex flex-wrap\">\n      <div class=\"w-1/5 p-1\">\n        <ui-text-input-with-label v-model=\"newEpisode.season\" trim-whitespace :label=\"$strings.LabelSeason\" />\n      </div>\n      <div class=\"w-1/5 p-1\">\n        <ui-text-input-with-label v-model=\"newEpisode.episode\" trim-whitespace :label=\"$strings.LabelEpisode\" />\n      </div>\n      <div class=\"w-1/5 p-1\">\n        <ui-dropdown v-model=\"newEpisode.episodeType\" :label=\"$strings.LabelEpisodeType\" :items=\"episodeTypes\" small />\n      </div>\n      <div class=\"w-2/5 p-1\">\n        <ui-text-input-with-label v-model=\"pubDateInput\" ref=\"pubdate\" type=\"datetime-local\" :label=\"$strings.LabelPubDate\" @input=\"updatePubDate\" />\n      </div>\n      <div class=\"w-full p-1\">\n        <ui-text-input-with-label v-model=\"newEpisode.title\" :label=\"$strings.LabelTitle\" trim-whitespace />\n      </div>\n      <div class=\"w-full p-1\">\n        <ui-textarea-with-label v-model=\"newEpisode.subtitle\" :label=\"$strings.LabelSubtitle\" :rows=\"3\" trim-whitespace />\n      </div>\n      <div class=\"w-full p-1\">\n        <ui-rich-text-editor :label=\"$strings.LabelDescription\" v-model=\"newEpisode.description\" />\n      </div>\n    </div>\n    <div class=\"flex items-center justify-end pt-4\">\n      <!-- desktop -->\n      <ui-btn @click=\"submit\" class=\"mx-2 hidden md:block\">{{ $strings.ButtonSave }}</ui-btn>\n      <ui-btn @click=\"saveAndClose\" class=\"mx-2 hidden md:block\">{{ $strings.ButtonSaveAndClose }}</ui-btn>\n\n      <!-- mobile -->\n      <ui-btn @click=\"saveAndClose\" class=\"mx-2 md:hidden\">{{ $strings.ButtonSave }}</ui-btn>\n    </div>\n    <div v-if=\"enclosureUrl\" class=\"pb-4 pt-6\">\n      <ui-text-input-with-label :value=\"enclosureUrl\" readonly class=\"text-xs\">\n        <label class=\"px-1 text-xs text-gray-200 font-semibold\">{{ $strings.LabelEpisodeUrlFromRssFeed }}</label>\n      </ui-text-input-with-label>\n    </div>\n    <div v-else class=\"py-4\">\n      <p class=\"text-xs text-gray-300 font-semibold\">{{ $strings.LabelEpisodeNotLinkedToRssFeed }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    episode: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      newEpisode: {\n        season: null,\n        episode: null,\n        episodeType: null,\n        title: null,\n        subtitle: null,\n        description: null,\n        pubDate: null,\n        publishedAt: null\n      },\n      pubDateInput: null\n    }\n  },\n  watch: {\n    episode: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  computed: {\n    isProcessing: {\n      get() {\n        return this.processing\n      },\n      set(val) {\n        this.$emit('update:processing', val)\n      }\n    },\n    episodeId() {\n      return this.episode ? this.episode.id : null\n    },\n    enclosure() {\n      return this.episode ? this.episode.enclosure || {} : {}\n    },\n    enclosureUrl() {\n      return this.enclosure.url\n    },\n    episodeTypes() {\n      return this.$store.state.globals.episodeTypes.map((e) => {\n        return {\n          text: this.$strings[e.descriptionKey] || e.text,\n          value: e.value\n        }\n      })\n    }\n  },\n  methods: {\n    updatePubDate(val) {\n      if (val) {\n        this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')\n        this.newEpisode.publishedAt = new Date(val).valueOf()\n      } else {\n        this.newEpisode.pubDate = null\n        this.newEpisode.publishedAt = null\n      }\n    },\n    init() {\n      this.newEpisode.season = this.episode.season || ''\n      this.newEpisode.episode = this.episode.episode || ''\n      this.newEpisode.episodeType = this.episode.episodeType || ''\n      this.newEpisode.title = this.episode.title || ''\n      this.newEpisode.subtitle = this.episode.subtitle || ''\n      this.newEpisode.description = this.episode.description || ''\n      this.newEpisode.pubDate = this.episode.pubDate || ''\n      this.newEpisode.publishedAt = this.episode.publishedAt\n\n      this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), \"yyyy-MM-dd'T'HH:mm\") : null\n    },\n    getUpdatePayload() {\n      var updatePayload = {}\n      for (const key in this.newEpisode) {\n        if (this.newEpisode[key] != this.episode[key]) {\n          updatePayload[key] = this.newEpisode[key]\n        }\n      }\n      return updatePayload\n    },\n    async saveAndClose() {\n      const wasUpdated = await this.submit()\n      if (wasUpdated !== null) this.$emit('close')\n    },\n    async submit() {\n      if (this.isProcessing) {\n        return null\n      }\n\n      // Check pubdate is valid if it is being updated. Cannot be set to null in the web client\n      if (this.newEpisode.pubDate === null && this.$refs.pubdate?.$refs?.input?.isInvalidDate) {\n        this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)\n        return null\n      }\n\n      const updatedDetails = this.getUpdatePayload()\n      if (!Object.keys(updatedDetails).length) {\n        this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n        return false\n      }\n\n      return this.updateDetails(updatedDetails)\n    },\n    async updateDetails(updatedDetails) {\n      this.isProcessing = true\n      const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {\n        console.error('Failed update episode', error)\n        this.isProcessing = false\n        this.$toast.error(error?.response?.data || this.$strings.ToastFailedToUpdate)\n        return false\n      })\n\n      this.isProcessing = false\n      if (updateResult) {\n        this.$toast.success(this.$strings.ToastItemUpdateSuccess)\n        return true\n      }\n\n      return false\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/podcast/tabs/EpisodeMatch.vue",
    "content": "<template>\n  <div style=\"min-height: 200px\">\n    <template v-if=\"!podcastFeedUrl\">\n      <div class=\"py-8\">\n        <widgets-alert type=\"error\">{{ $strings.MessagePodcastHasNoRSSFeedForMatching }}</widgets-alert>\n      </div>\n    </template>\n    <template v-else>\n      <form @submit.prevent=\"submitForm\">\n        <div class=\"flex mb-2\">\n          <ui-text-input-with-label v-model=\"episodeTitle\" :disabled=\"isProcessing\" :label=\"$strings.LabelEpisodeTitle\" class=\"pr-1\" />\n          <ui-btn class=\"mt-5 ml-1\" :loading=\"isProcessing\" type=\"submit\">{{ $strings.ButtonSearch }}</ui-btn>\n        </div>\n      </form>\n      <div v-if=\"!isProcessing && searchedTitle && !episodesFound.length\" class=\"w-full py-8\">\n        <p class=\"text-center text-lg\">{{ $strings.MessageNoEpisodeMatchesFound }}</p>\n      </div>\n      <div v-for=\"(episode, index) in episodesFound\" :key=\"index\" class=\"w-full py-4 border-b border-white/5 hover:bg-gray-300/10 cursor-pointer px-2\" @click.stop=\"selectEpisode(episode)\">\n        <p v-if=\"episode.episode\" class=\"font-semibold text-gray-200\">#{{ episode.episode }}</p>\n        <p class=\"break-words mb-1\">{{ episode.title }}</p>\n        <p v-if=\"episode.subtitle\" class=\"mb-1 text-sm text-gray-300 line-clamp-2\">{{ episode.subtitle }}</p>\n        <p class=\"text-xs text-gray-400\">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>\n      </div>\n    </template>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    episode: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      episodeTitle: '',\n      searchedTitle: '',\n      episodesFound: []\n    }\n  },\n  watch: {\n    episode: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  computed: {\n    isProcessing: {\n      get() {\n        return this.processing\n      },\n      set(val) {\n        this.$emit('update:processing', val)\n      }\n    },\n    episodeId() {\n      return this.episode ? this.episode.id : null\n    },\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    podcastFeedUrl() {\n      return this.mediaMetadata.feedUrl\n    }\n  },\n  methods: {\n    getUpdatePayload(episodeData) {\n      var updatePayload = {}\n      for (const key in episodeData) {\n        if (key === 'enclosure') {\n          if (!this.episode.enclosure || JSON.stringify(this.episode.enclosure) !== JSON.stringify(episodeData.enclosure)) {\n            updatePayload[key] = {\n              ...episodeData.enclosure\n            }\n          }\n        } else if (episodeData[key] != this.episode[key]) {\n          updatePayload[key] = episodeData[key]\n        }\n      }\n      return updatePayload\n    },\n    selectEpisode(episode) {\n      const episodeData = {\n        title: episode.title || '',\n        subtitle: episode.subtitle || '',\n        description: episode.description || '',\n        enclosure: episode.enclosure || null,\n        episode: episode.episode || '',\n        episodeType: episode.episodeType || '',\n        season: episode.season || '',\n        pubDate: episode.pubDate || '',\n        publishedAt: episode.publishedAt\n      }\n      const updatePayload = this.getUpdatePayload(episodeData)\n      if (!Object.keys(updatePayload).length) {\n        return this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n      }\n      console.log('Episode update payload', updatePayload)\n\n      this.isProcessing = true\n      this.$axios\n        .$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)\n        .then(() => {\n          this.isProcessing = false\n          this.$toast.success(this.$strings.ToastPodcastEpisodeUpdated)\n          this.$emit('selectTab', 'details')\n        })\n        .catch((error) => {\n          var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'\n          console.error('Failed update episode', error)\n          this.isProcessing = false\n          this.$toast.error(errorMsg)\n        })\n    },\n    submitForm() {\n      if (!this.episodeTitle || !this.episodeTitle.length) {\n        this.$toast.error(this.$strings.ToastTitleRequired)\n        return\n      }\n      this.searchedTitle = this.episodeTitle\n      this.isProcessing = true\n      this.$axios\n        .$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${encodeURIComponent(this.episodeTitle)}`)\n        .then((results) => {\n          this.episodesFound = results.episodes.map((ep) => ep.episode)\n          console.log('Episodes found', this.episodesFound)\n          this.isProcessing = false\n        })\n        .catch((error) => {\n          console.error('Failed to search for episode', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || 'Failed to search for episode')\n          this.isProcessing = false\n        })\n    },\n    init() {\n      this.searchedTitle = null\n      this.episodesFound = []\n      this.episodeTitle = this.episode ? this.episode.title || '' : ''\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/rssfeed/OpenCloseModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"rss-feed-modal\" :width=\"600\" :height=\"'unset'\" :processing=\"processing\">\n    <template #outer>\n      <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden\">\n        <p class=\"text-3xl text-white truncate\">{{ title }}</p>\n      </div>\n    </template>\n    <div ref=\"wrapper\" class=\"px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\">\n      <div v-if=\"currentFeed\" class=\"w-full\">\n        <p class=\"text-lg font-semibold mb-4\">{{ $strings.HeaderRSSFeedIsOpen }}</p>\n\n        <div class=\"w-full relative\">\n          <ui-text-input :value=\"feedUrl\" readonly show-copy />\n        </div>\n\n        <div v-if=\"currentFeed.meta\" class=\"mt-5\">\n          <div class=\"flex py-0.5\">\n            <div class=\"w-48\">\n              <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelRSSFeedPreventIndexing }}</span>\n            </div>\n            <div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>\n          </div>\n          <div v-if=\"currentFeed.meta.ownerName\" class=\"flex py-0.5\">\n            <div class=\"w-48\">\n              <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>\n            </div>\n            <div>{{ currentFeed.meta.ownerName }}</div>\n          </div>\n          <div v-if=\"currentFeed.meta.ownerEmail\" class=\"flex py-0.5\">\n            <div class=\"w-48\">\n              <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>\n            </div>\n            <div>{{ currentFeed.meta.ownerEmail }}</div>\n          </div>\n        </div>\n      </div>\n      <div v-else class=\"w-full\">\n        <p class=\"text-lg font-semibold mb-4\">{{ $strings.HeaderOpenRSSFeed }}</p>\n\n        <div class=\"w-full relative mb-2\">\n          <ui-text-input-with-label v-model=\"newFeedSlug\" :label=\"$strings.LabelRSSFeedSlug\" />\n          <p class=\"text-xs text-gray-400 py-0.5 px-1\">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>\n        </div>\n        <widgets-rss-feed-metadata-builder v-model=\"metadataDetails\" />\n\n        <p v-if=\"isHttp\" class=\"w-full pt-2 text-warning text-xs\">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>\n        <p v-if=\"hasEpisodesWithoutPubDate\" class=\"w-full pt-2 text-warning text-xs\">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>\n      </div>\n      <div v-show=\"userIsAdminOrUp\" class=\"flex items-center pt-6\">\n        <div class=\"grow\" />\n        <ui-btn v-if=\"currentFeed\" color=\"bg-error\" small @click=\"closeFeed\">{{ $strings.ButtonCloseFeed }}</ui-btn>\n        <ui-btn v-else color=\"bg-success\" small @click=\"openFeed\">{{ $strings.ButtonOpenFeed }}</ui-btn>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      processing: false,\n      newFeedSlug: null,\n      currentFeed: null,\n      metadataDetails: {\n        preventIndexing: true,\n        ownerName: '',\n        ownerEmail: ''\n      }\n    }\n  },\n  watch: {\n    show: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) {\n          this.init()\n        }\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showRSSFeedOpenCloseModal\n      },\n      set(val) {\n        this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)\n      }\n    },\n    rssFeedEntity() {\n      return this.$store.state.globals.rssFeedEntity || {}\n    },\n    entityId() {\n      return this.rssFeedEntity.id\n    },\n    entityType() {\n      return this.rssFeedEntity.type\n    },\n    entityFeed() {\n      return this.rssFeedEntity.feed\n    },\n    hasEpisodesWithoutPubDate() {\n      return !!this.rssFeedEntity.hasEpisodesWithoutPubDate\n    },\n    title() {\n      return this.rssFeedEntity.name\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    feedUrl() {\n      return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''\n    },\n    demoFeedUrl() {\n      return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`\n    },\n    isHttp() {\n      return window.origin.startsWith('http://')\n    }\n  },\n  methods: {\n    openFeed() {\n      if (!this.newFeedSlug) {\n        this.$toast.error(this.$strings.ToastSlugRequired)\n        return\n      }\n\n      const sanitized = this.$sanitizeSlug(this.newFeedSlug)\n      if (this.newFeedSlug !== sanitized) {\n        this.newFeedSlug = sanitized\n        this.$toast.warning(this.$strings.ToastSlugMustChange)\n        return\n      }\n\n      this.processing = true\n\n      const payload = {\n        serverAddress: window.origin,\n        slug: this.newFeedSlug,\n        metadataDetails: this.metadataDetails\n      }\n      if (this.$isDev) payload.serverAddress = process.env.serverUrl\n\n      console.log('Payload', payload)\n      this.$axios\n        .$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)\n        .then((data) => {\n          console.log('Opened RSS Feed', data)\n          this.currentFeed = data.feed\n        })\n        .catch((error) => {\n          console.error('Failed to open RSS Feed', error)\n          const errorMsg = error.response ? error.response.data : null\n          this.$toast.error(errorMsg || 'Failed to open RSS Feed')\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    closeFeed() {\n      this.processing = true\n      this.$axios\n        .$post(`/api/feeds/${this.currentFeed.id}/close`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)\n          this.show = false\n        })\n        .catch((error) => {\n          console.error('Failed to close RSS feed', error)\n          this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    init() {\n      if (!this.entityId) return\n      this.newFeedSlug = this.entityId\n      this.currentFeed = this.entityFeed\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/modals/rssfeed/ViewFeedModal.vue",
    "content": "<template>\n  <modals-modal v-model=\"show\" name=\"rss-feed-view-modal\" :processing=\"processing\" :width=\"700\" :height=\"'unset'\">\n    <div ref=\"wrapper\" class=\"px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden\">\n      <div v-if=\"feed\" class=\"w-full\">\n        <p class=\"text-lg font-semibold mb-4\">{{ $strings.HeaderRSSFeedGeneral }}</p>\n\n        <div class=\"w-full relative\">\n          <ui-text-input :value=\"feedUrl\" readonly show-copy />\n        </div>\n\n        <div v-if=\"feed.meta\" class=\"mt-5\">\n          <div class=\"flex py-0.5\">\n            <div class=\"w-48\">\n              <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelRSSFeedPreventIndexing }}</span>\n            </div>\n            <div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>\n          </div>\n          <div v-if=\"feed.meta.ownerName\" class=\"flex py-0.5\">\n            <div class=\"w-48\">\n              <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>\n            </div>\n            <div>{{ feed.meta.ownerName }}</div>\n          </div>\n          <div v-if=\"feed.meta.ownerEmail\" class=\"flex py-0.5\">\n            <div class=\"w-48\">\n              <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>\n            </div>\n            <div>{{ feed.meta.ownerEmail }}</div>\n          </div>\n        </div>\n        <!--  -->\n        <div class=\"episodesTable mt-2\">\n          <div class=\"bg-primary/40 h-12 header\">\n            {{ $strings.LabelEpisodeTitle }}\n          </div>\n          <div class=\"scroller\">\n            <div v-for=\"episode in feed.episodes\" :key=\"episode.id\" class=\"h-8 text-xs truncate\">\n              {{ episode.title }}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </modals-modal>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    feed: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      processing: false\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    _feed() {\n      return this.feed || {}\n    },\n    feedUrl() {\n      return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''\n    }\n  }\n}\n</script>\n\n<style scoped>\n.episodesTable {\n  width: 100%;\n  max-width: 100%;\n  border: 1px solid #474747;\n  display: flex;\n  flex-direction: column;\n}\n\n.episodesTable div.header {\n  background-color: #272727;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: flex-start;\n  padding: 4px 8px;\n}\n\n.episodesTable .scroller {\n  display: flex;\n  flex-direction: column;\n  max-height: 250px;\n  overflow-x: hidden;\n  overflow-y: scroll;\n}\n\n.episodesTable .scroller div {\n  background-color: #373838;\n  padding: 4px 8px;\n  display: flex;\n  align-items: center;\n  justify-content: flex-start;\n  height: 32px;\n  flex: 0 0 32px;\n}\n\n.episodesTable .scroller div:nth-child(even) {\n  background-color: #2f2f2f;\n}\n</style>\n\n"
  },
  {
    "path": "client/components/player/PlayerPlaybackControls.vue",
    "content": "<template>\n  <div class=\"flex justify-center pt-4 pb-2 lg:pt-0 lg:pb-2\">\n    <div class=\"flex items-center justify-center grow\">\n      <template v-if=\"!loading\">\n        <ui-tooltip direction=\"top\" :text=\"$strings.ButtonPreviousChapter\" class=\"mr-4 lg:mr-8\">\n          <button :aria-label=\"$strings.ButtonPreviousChapter\" class=\"text-gray-300\" @mousedown.prevent @mouseup.prevent @click.stop=\"prevChapter\">\n            <span class=\"material-symbols text-2xl sm:text-3xl\">first_page</span>\n          </button>\n        </ui-tooltip>\n        <ui-tooltip direction=\"top\" :text=\"jumpBackwardText\">\n          <button :aria-label=\"jumpBackwardText\" class=\"text-gray-300\" @mousedown.prevent @mouseup.prevent @click.stop=\"jumpBackward\">\n            <span class=\"material-symbols text-2xl sm:text-3xl\">replay</span>\n          </button>\n        </ui-tooltip>\n        <button :aria-label=\"paused ? $strings.ButtonPlay : $strings.ButtonPause\" class=\"p-2 shadow-xs bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8\" :class=\"seekLoading ? 'animate-spin' : ''\" @mousedown.prevent @mouseup.prevent @click.stop=\"playPause\">\n          <span class=\"material-symbols fill text-2xl\">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>\n        </button>\n        <ui-tooltip direction=\"top\" :text=\"jumpForwardText\">\n          <button :aria-label=\"jumpForwardText\" class=\"text-gray-300\" @mousedown.prevent @mouseup.prevent @click.stop=\"jumpForward\">\n            <span class=\"material-symbols text-2xl sm:text-3xl\">forward_media</span>\n          </button>\n        </ui-tooltip>\n        <ui-tooltip direction=\"top\" :text=\"hasNextLabel\" class=\"ml-4 lg:ml-8\">\n          <button :aria-label=\"hasNextLabel\" :disabled=\"!hasNext\" class=\"text-gray-300 disabled:text-gray-500\" @mousedown.prevent @mouseup.prevent @click.stop=\"next\">\n            <span class=\"material-symbols text-2xl sm:text-3xl\">last_page</span>\n          </button>\n        </ui-tooltip>\n      </template>\n      <template v-else>\n        <div class=\"cursor-pointer p-2 shadow-xs bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin\">\n          <span class=\"material-symbols text-2xl\">autorenew</span>\n        </div>\n      </template>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    loading: Boolean,\n    seekLoading: Boolean,\n    paused: Boolean,\n    hasNextChapter: Boolean,\n    hasNextItemInQueue: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    jumpForwardText() {\n      return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)\n    },\n    jumpBackwardText() {\n      return this.getJumpText('jumpBackwardAmount', this.$strings.ButtonJumpBackward)\n    },\n    hasNextLabel() {\n      if (this.hasNextItemInQueue && !this.hasNextChapter) return this.$strings.ButtonNextItemInQueue\n      return this.$strings.ButtonNextChapter\n    },\n    hasNext() {\n      return this.hasNextItemInQueue || this.hasNextChapter\n    }\n  },\n  methods: {\n    playPause() {\n      this.$emit('playPause')\n    },\n    prevChapter() {\n      this.$emit('prevChapter')\n    },\n    next() {\n      if (!this.hasNext) return\n      this.$emit('next')\n    },\n    jumpBackward() {\n      this.$emit('jumpBackward')\n    },\n    jumpForward() {\n      this.$emit('jumpForward')\n    },\n    getJumpText(setting, prefix) {\n      const amount = this.$store.getters['user/getUserSetting'](setting)\n      if (!amount) return prefix\n\n      let formattedTime = ''\n      if (amount <= 60) {\n        formattedTime = this.$getString('LabelTimeDurationXSeconds', [amount])\n      } else {\n        const minutes = Math.floor(amount / 60)\n        formattedTime = this.$getString('LabelTimeDurationXMinutes', [minutes])\n      }\n\n      return `${prefix} - ${formattedTime}`\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/player/PlayerTrackBar.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <!-- Track -->\n    <div ref=\"track\" class=\"w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden\" @mousemove=\"mousemoveTrack\" @mouseleave=\"mouseleaveTrack\" @click.stop=\"clickTrack\">\n      <div ref=\"readyTrack\" class=\"h-full bg-gray-600 absolute top-0 left-0 pointer-events-none\" />\n      <div ref=\"bufferTrack\" class=\"h-full bg-gray-500 absolute top-0 left-0 pointer-events-none\" />\n      <div ref=\"playedTrack\" class=\"h-full bg-gray-200 absolute top-0 left-0 pointer-events-none\" />\n      <div ref=\"trackCursor\" class=\"h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none\" />\n      <div v-if=\"loading\" class=\"h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white/25\" />\n    </div>\n    <div class=\"w-full h-2 relative overflow-hidden\" :class=\"useChapterTrack ? 'opacity-0' : ''\">\n      <template v-for=\"(tick, index) in chapterTicks\">\n        <div :key=\"index\" :style=\"{ left: tick.left + 'px' }\" class=\"absolute top-0 w-px bg-white/30 h-1 pointer-events-none\" />\n      </template>\n    </div>\n\n    <!-- Hover timestamp -->\n    <div ref=\"hoverTimestamp\" class=\"absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none z-10\">\n      <p ref=\"hoverTimestampText\" class=\"text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap\">00:00</p>\n    </div>\n    <div ref=\"hoverTimestampArrow\" class=\"absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none\">\n      <div class=\"absolute -bottom-1.5 left-0 right-0 w-full flex justify-center\">\n        <div class=\"arrow-down\" />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    loading: Boolean,\n    duration: Number,\n    chapters: {\n      type: Array,\n      default: () => []\n    },\n    currentChapter: {\n      type: Object,\n      default: () => {}\n    },\n    playbackRate: Number\n  },\n  data() {\n    return {\n      trackWidth: 0,\n      currentTime: 0,\n      percentReady: 0,\n      bufferTime: 0,\n      chapterTicks: [],\n      trackOffsetLeft: 16, // Track is 16px from edge\n      playedTrackWidth: 0,\n      readyTrackWidth: 0,\n      bufferTrackWidth: 0,\n      useChapterTrack: false\n    }\n  },\n  watch: {\n    duration: {\n      handler() {\n        this.setChapterTicks()\n      }\n    }\n  },\n  computed: {\n    _playbackRate() {\n      if (!this.playbackRate || isNaN(this.playbackRate)) return 1\n      return this.playbackRate\n    },\n    currentChapterDuration() {\n      if (!this.currentChapter) return 0\n      return this.currentChapter.end - this.currentChapter.start\n    },\n    currentChapterStart() {\n      if (!this.currentChapter) return 0\n      return this.currentChapter.start\n    },\n    isMobile() {\n      return this.$store.state.globals.isMobile\n    }\n  },\n  methods: {\n    setUseChapterTrack(useChapterTrack) {\n      this.useChapterTrack = useChapterTrack\n      this.updateBufferTrack()\n      this.updatePlayedTrackWidth()\n    },\n    clickTrack(e) {\n      if (this.loading) return\n\n      const offsetX = e.offsetX\n      const perc = offsetX / this.trackWidth\n      const baseTime = this.useChapterTrack ? this.currentChapterStart : 0\n      const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration\n      const time = baseTime + perc * duration\n      if (isNaN(time) || time === null) {\n        console.error('Invalid time', perc, time)\n        return\n      }\n      this.$emit('seek', time)\n    },\n    setBufferTime(time) {\n      this.bufferTime = time\n      this.updateBufferTrack()\n    },\n    updateBufferTrack() {\n      const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime\n      const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration\n\n      var bufferlen = (time / duration) * this.trackWidth\n      bufferlen = Math.round(bufferlen)\n      if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return\n      if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'\n      this.bufferTrackWidth = bufferlen\n    },\n    setPercentageReady(percent) {\n      this.percentReady = percent\n      this.updateReadyTrack()\n    },\n    updateReadyTrack() {\n      const widthReady = Math.round(this.trackWidth * this.percentReady)\n      if (this.readyTrackWidth === widthReady) return\n      this.readyTrackWidth = widthReady\n      if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'\n    },\n    setCurrentTime(time) {\n      this.currentTime = time\n      this.updatePlayedTrackWidth()\n    },\n    updatePlayedTrackWidth() {\n      const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime\n      const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration\n\n      const ptWidth = Math.round((time / duration) * this.trackWidth)\n      if (this.playedTrackWidth === ptWidth) {\n        return\n      }\n      if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'\n      this.playedTrackWidth = ptWidth\n    },\n    setChapterTicks() {\n      this.chapterTicks = this.chapters.map((chap) => {\n        const perc = chap.start / this.duration\n        return {\n          title: chap.title,\n          left: perc * this.trackWidth\n        }\n      })\n    },\n    mousemoveTrack(e) {\n      if (this.isMobile) {\n        return\n      }\n      const offsetX = e.offsetX\n\n      const baseTime = this.useChapterTrack ? this.currentChapterStart : 0\n      const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration\n      const progressTime = (offsetX / this.trackWidth) * duration\n      const totalTime = baseTime + progressTime\n\n      if (this.$refs.hoverTimestamp) {\n        var width = this.$refs.hoverTimestamp.clientWidth\n        this.$refs.hoverTimestamp.style.opacity = 1\n        var posLeft = offsetX - width / 2\n        if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {\n          posLeft = window.innerWidth - width - this.trackOffsetLeft\n        } else if (posLeft < -this.trackOffsetLeft) {\n          posLeft = -this.trackOffsetLeft\n        }\n        this.$refs.hoverTimestamp.style.left = posLeft + 'px'\n      }\n\n      if (this.$refs.hoverTimestampArrow) {\n        var width = this.$refs.hoverTimestampArrow.clientWidth\n        var posLeft = offsetX - width / 2\n        this.$refs.hoverTimestampArrow.style.opacity = 1\n        this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'\n      }\n      if (this.$refs.hoverTimestampText) {\n        var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)\n\n        var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)\n        if (chapter && chapter.title) {\n          hoverText += ` - ${chapter.title}`\n        }\n        this.$refs.hoverTimestampText.innerText = hoverText\n      }\n      if (this.$refs.trackCursor) {\n        this.$refs.trackCursor.style.opacity = 1\n        this.$refs.trackCursor.style.left = offsetX - 1 + 'px'\n      }\n    },\n    mouseleaveTrack() {\n      if (this.$refs.hoverTimestamp) {\n        this.$refs.hoverTimestamp.style.opacity = 0\n      }\n      if (this.$refs.hoverTimestampArrow) {\n        this.$refs.hoverTimestampArrow.style.opacity = 0\n      }\n      if (this.$refs.trackCursor) {\n        this.$refs.trackCursor.style.opacity = 0\n      }\n    },\n    setTrackWidth() {\n      if (this.$refs.track) {\n        this.trackWidth = this.$refs.track.clientWidth\n        this.trackOffsetLeft = this.$refs.track.getBoundingClientRect().left\n      } else {\n        console.error('Track not loaded', this.$refs)\n      }\n    },\n    windowResize() {\n      this.setTrackWidth()\n      this.setChapterTicks()\n      this.updatePlayedTrackWidth()\n      this.updateBufferTrack()\n    }\n  },\n  mounted() {\n    this.setTrackWidth()\n    this.setChapterTicks()\n    window.addEventListener('resize', this.windowResize)\n  },\n  beforeDestroy() {\n    window.removeEventListener('resize', this.windowResize)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/player/PlayerUi.vue",
    "content": "<template>\n  <div class=\"w-full -mt-6\">\n    <div class=\"w-full relative mb-1\">\n      <div class=\"absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full\">\n        <controls-playback-speed-control v-model=\"playbackRate\" @input=\"setPlaybackRate\" @change=\"playbackRateChanged\" :playbackRateIncrementDecrement=\"playbackRateIncrementDecrement\" class=\"mx-2 block\" />\n\n        <ui-tooltip direction=\"bottom\" :text=\"$strings.LabelVolume\">\n          <controls-volume-control ref=\"volumeControl\" v-model=\"volume\" @input=\"setVolume\" class=\"mx-2 hidden sm:block\" />\n        </ui-tooltip>\n\n        <ui-tooltip v-if=\"!hideSleepTimer\" direction=\"top\" :text=\"$strings.LabelSleepTimer\">\n          <button :aria-label=\"$strings.LabelSleepTimer\" class=\"text-gray-300 hover:text-white mx-1 lg:mx-2\" @mousedown.prevent @mouseup.prevent @click.stop=\"$emit('showSleepTimer')\">\n            <span v-if=\"!sleepTimerSet\" class=\"material-symbols text-2xl\">snooze</span>\n            <div v-else class=\"flex items-center\">\n              <span class=\"material-symbols text-lg text-warning\">snooze</span>\n              <p class=\"text-sm sm:text-lg text-warning font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8\">{{ sleepTimerRemainingString }}</p>\n            </div>\n          </button>\n        </ui-tooltip>\n\n        <ui-tooltip v-if=\"!isPodcast && !hideBookmarks\" direction=\"top\" :text=\"$strings.LabelViewBookmarks\">\n          <button :aria-label=\"$strings.LabelViewBookmarks\" class=\"text-gray-300 hover:text-white mx-1 lg:mx-2\" @mousedown.prevent @mouseup.prevent @click.stop=\"$emit('showBookmarks')\">\n            <span class=\"material-symbols text-2xl\">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>\n          </button>\n        </ui-tooltip>\n\n        <ui-tooltip v-if=\"chapters.length\" direction=\"top\" :text=\"$strings.LabelViewChapters\">\n          <button :aria-label=\"$strings.LabelViewChapters\" class=\"text-gray-300 hover:text-white mx-1 lg:mx-2\" @mousedown.prevent @mouseup.prevent @click.stop=\"showChapters\">\n            <span class=\"material-symbols text-2xl\">format_list_bulleted</span>\n          </button>\n        </ui-tooltip>\n\n        <ui-tooltip v-if=\"playerQueueItems.length\" direction=\"top\" :text=\"$strings.LabelViewQueue\">\n          <button :aria-label=\"$strings.LabelViewQueue\" class=\"outline-hidden text-gray-300 mx-1 lg:mx-2 hover:text-white\" @mousedown.prevent @mouseup.prevent @click.stop=\"$emit('showPlayerQueueItems')\">\n            <span class=\"material-symbols text-2.5xl sm:text-3xl\">playlist_play</span>\n          </button>\n        </ui-tooltip>\n\n        <ui-tooltip direction=\"top\" :text=\"$strings.LabelViewPlayerSettings\">\n          <button :aria-label=\"$strings.LabelViewPlayerSettings\" class=\"outline-hidden text-gray-300 mx-1 lg:mx-2 hover:text-white\" @mousedown.prevent @mouseup.prevent @click.stop=\"showPlayerSettings\">\n            <span class=\"material-symbols text-2xl sm:text-2.5xl\">settings_slow_motion</span>\n          </button>\n        </ui-tooltip>\n      </div>\n\n      <player-playback-controls :loading=\"loading\" :seek-loading=\"seekLoading\" :playback-rate.sync=\"playbackRate\" :paused=\"paused\" :hasNextChapter=\"hasNextChapter\" :hasNextItemInQueue=\"hasNextItemInQueue\" @prevChapter=\"prevChapter\" @next=\"goToNext\" @jumpForward=\"jumpForward\" @jumpBackward=\"jumpBackward\" @setPlaybackRate=\"setPlaybackRate\" @playPause=\"playPause\" />\n    </div>\n\n    <player-track-bar ref=\"trackbar\" :loading=\"loading\" :chapters=\"chapters\" :duration=\"duration\" :current-chapter=\"currentChapter\" :playback-rate=\"playbackRate\" @seek=\"seek\" />\n\n    <div class=\"relative flex items-center justify-between\">\n      <div class=\"grow flex items-center\">\n        <p ref=\"currentTimestamp\" class=\"font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto\">00:00:00</p>\n        <p class=\"font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto\">&nbsp;/&nbsp;{{ progressPercent }}%</p>\n      </div>\n      <div class=\"absolute left-1/2 transform -translate-x-1/2\">\n        <p class=\"text-xs sm:text-sm text-gray-300 pt-0.5 px-2 truncate\">\n          {{ currentChapterName }} <span v-if=\"useChapterTrack\" class=\"text-xs text-gray-400\">&nbsp;({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})</span>\n        </p>\n      </div>\n      <div class=\"grow flex items-center justify-end\">\n        <p class=\"font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto\">{{ timeRemainingPretty }}</p>\n      </div>\n    </div>\n\n    <modals-chapters-modal v-model=\"showChaptersModal\" :current-chapter=\"currentChapter\" :playback-rate=\"playbackRate\" :chapters=\"chapters\" @select=\"selectChapter\" />\n\n    <modals-player-settings-modal v-model=\"showPlayerSettingsModal\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    loading: Boolean,\n    paused: Boolean,\n    chapters: {\n      type: Array,\n      default: () => []\n    },\n    currentChapter: Object,\n    bookmarks: {\n      type: Array,\n      default: () => []\n    },\n    sleepTimerSet: Boolean,\n    sleepTimerRemaining: Number,\n    sleepTimerType: String,\n    isPodcast: Boolean,\n    hideBookmarks: Boolean,\n    hideSleepTimer: Boolean,\n    hasNextItemInQueue: Boolean\n  },\n  data() {\n    return {\n      volume: 1,\n      playbackRate: 1,\n      audioEl: null,\n      seekLoading: false,\n      showChaptersModal: false,\n      showPlayerSettingsModal: false,\n      currentTime: 0,\n      duration: 0\n    }\n  },\n  watch: {\n    playbackRate() {\n      this.updateTimestamp()\n    },\n    useChapterTrack() {\n      if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)\n      this.updateTimestamp()\n    }\n  },\n  computed: {\n    sleepTimerRemainingString() {\n      if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER) {\n        return 'EoC'\n      } else {\n        var rounded = Math.round(this.sleepTimerRemaining)\n        if (rounded < 90) {\n          return `${rounded}s`\n        }\n        var minutesRounded = Math.round(rounded / 60)\n        if (minutesRounded <= 90) {\n          return `${minutesRounded}m`\n        }\n        var hoursRounded = Math.round(minutesRounded / 60)\n        return `${hoursRounded}h`\n      }\n    },\n    timeRemaining() {\n      if (this.useChapterTrack && this.currentChapter) {\n        var currChapTime = this.currentTime - this.currentChapter.start\n        return (this.currentChapterDuration - currChapTime) / this.playbackRate\n      }\n      return (this.duration - this.currentTime) / this.playbackRate\n    },\n    timeRemainingPretty() {\n      if (this.timeRemaining < 0) {\n        return this.$secondsToTimestamp(this.timeRemaining * -1)\n      }\n      return '-' + this.$secondsToTimestamp(this.timeRemaining)\n    },\n    progressPercent() {\n      const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration\n      const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime\n\n      if (!duration) return 0\n      return Math.round((100 * time) / duration)\n    },\n    currentChapterName() {\n      return this.currentChapter?.title || ''\n    },\n    currentChapterDuration() {\n      if (!this.currentChapter) return 0\n      return this.currentChapter.end - this.currentChapter.start\n    },\n    currentChapterStart() {\n      if (!this.currentChapter) return 0\n      return this.currentChapter.start\n    },\n    isFullscreen() {\n      return this.$store.state.playerIsFullscreen\n    },\n    currentChapterIndex() {\n      if (!this.currentChapter) return 0\n      return this.chapters.findIndex((ch) => ch.id === this.currentChapter.id)\n    },\n    hasNextChapter() {\n      if (!this.chapters.length) return false\n      return this.currentChapterIndex < this.chapters.length - 1\n    },\n    playerQueueItems() {\n      return this.$store.state.playerQueueItems || []\n    },\n    useChapterTrack() {\n      const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false\n      return this.chapters.length ? _useChapterTrack : false\n    },\n    playbackRateIncrementDecrement() {\n      return this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')\n    }\n  },\n  methods: {\n    toggleFullscreen(isFullscreen) {\n      this.$store.commit('setPlayerIsFullscreen', isFullscreen)\n    },\n    setDuration(duration) {\n      this.duration = duration\n    },\n    setCurrentTime(time) {\n      this.currentTime = time\n      this.updateTimestamp()\n      if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)\n    },\n    playPause() {\n      this.$emit('playPause')\n    },\n    jumpBackward() {\n      this.$emit('jumpBackward')\n    },\n    jumpForward() {\n      this.$emit('jumpForward')\n    },\n    increaseVolume() {\n      if (this.volume >= 1) return\n      this.volume = Math.min(1, this.volume + 0.1)\n      this.setVolume(this.volume)\n    },\n    decreaseVolume() {\n      if (this.volume <= 0) return\n      this.volume = Math.max(0, this.volume - 0.1)\n      this.setVolume(this.volume)\n    },\n    setVolume(volume) {\n      this.$emit('setVolume', volume)\n    },\n    toggleMute() {\n      if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {\n        this.$refs.volumeControl.toggleMute()\n      }\n    },\n    increasePlaybackRate() {\n      if (this.playbackRate >= 10) return\n      this.playbackRate = Number((this.playbackRate + this.playbackRateIncrementDecrement || 0.1).toFixed(2))\n      this.setPlaybackRate(this.playbackRate)\n    },\n    decreasePlaybackRate() {\n      if (this.playbackRate <= 0.5) return\n      this.playbackRate = Number((this.playbackRate - this.playbackRateIncrementDecrement || 0.1).toFixed(2))\n      this.setPlaybackRate(this.playbackRate)\n    },\n    playbackRateChanged(playbackRate) {\n      this.setPlaybackRate(playbackRate)\n      this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {\n        console.error('Failed to update settings', err)\n      })\n    },\n    setPlaybackRate(playbackRate) {\n      this.$emit('setPlaybackRate', playbackRate)\n    },\n    selectChapter(chapter) {\n      this.seek(chapter.start)\n      this.showChaptersModal = false\n    },\n    setUseChapterTrack() {\n      this.useChapterTrack = !this.useChapterTrack\n      if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)\n\n      this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })\n      this.updateTimestamp()\n    },\n    checkUpdateChapterTrack() {\n      // Changing media in player may not have chapters\n      if (!this.chapters.length && this.useChapterTrack) {\n        this.useChapterTrack = false\n        if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)\n      }\n    },\n    seek(time) {\n      this.$emit('seek', time)\n    },\n    restart() {\n      this.seek(0)\n    },\n    prevChapter() {\n      if (!this.currentChapter || this.currentChapterIndex === 0) {\n        return this.restart()\n      }\n      var timeInCurrentChapter = this.currentTime - this.currentChapter.start\n      if (timeInCurrentChapter <= 3 && this.chapters[this.currentChapterIndex - 1]) {\n        var prevChapter = this.chapters[this.currentChapterIndex - 1]\n        this.seek(prevChapter.start)\n      } else {\n        this.seek(this.currentChapter.start)\n      }\n    },\n    goToNext() {\n      if (this.hasNextChapter) {\n        const nextChapter = this.chapters[this.currentChapterIndex + 1]\n        this.seek(nextChapter.start)\n      } else if (this.hasNextItemInQueue) {\n        this.$emit('nextItemInQueue')\n      }\n    },\n    setStreamReady() {\n      if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)\n    },\n    setChunksReady(chunks, numSegments) {\n      var largestSeg = 0\n      for (let i = 0; i < chunks.length; i++) {\n        var chunk = chunks[i]\n        if (typeof chunk === 'string') {\n          var chunkRange = chunk.split('-').map((c) => Number(c))\n          if (chunkRange.length < 2) continue\n          if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]\n        } else if (chunk > largestSeg) {\n          largestSeg = chunk\n        }\n      }\n      var percentageReady = largestSeg / numSegments\n      if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)\n    },\n    updateTimestamp() {\n      const ts = this.$refs.currentTimestamp\n      if (!ts) {\n        console.error('No timestamp el')\n        return\n      }\n      const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime\n      ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)\n    },\n    setBufferTime(bufferTime) {\n      if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)\n    },\n    showChapters() {\n      if (!this.chapters.length) return\n      this.showChaptersModal = !this.showChaptersModal\n    },\n    showPlayerSettings() {\n      this.showPlayerSettingsModal = !this.showPlayerSettingsModal\n    },\n    init() {\n      this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1\n\n      if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)\n      this.setPlaybackRate(this.playbackRate)\n    },\n    settingsUpdated(settings) {\n      if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {\n        this.setPlaybackRate(settings.playbackRate)\n      }\n    },\n    closePlayer() {\n      if (this.isFullscreen) {\n        this.toggleFullscreen(false)\n        return\n      }\n\n      if (this.loading) return\n      this.$emit('close')\n    },\n    hotkey(action) {\n      if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()\n      else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()\n      else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()\n      else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()\n      else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()\n      else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()\n      else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()\n      else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()\n      else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()\n      else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()\n    }\n  },\n  mounted() {\n    this.$eventBus.$on('player-hotkey', this.hotkey)\n    this.$eventBus.$on('user-settings', this.settingsUpdated)\n\n    this.init()\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('player-hotkey', this.hotkey)\n    this.$eventBus.$off('user-settings', this.settingsUpdated)\n  }\n}\n</script>\n\n<style>\n.loadingTrack {\n  animation-name: loadingTrack;\n  animation-duration: 1s;\n  animation-iteration-count: infinite;\n}\n@keyframes loadingTrack {\n  0% {\n    left: -25%;\n  }\n  100% {\n    left: 100%;\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/prompt/Confirm.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"modal modal-bg w-full h-full fixed top-0 left-0 bg-primary/75 flex items-center justify-center z-60 opacity-0\">\n    <div class=\"absolute top-0 left-0 right-0 w-full h-36 bg-linear-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none\" />\n    <div ref=\"content\" class=\"relative text-white\" :style=\"{ height: modalHeight, width: modalWidth }\" v-click-outside=\"clickedOutside\">\n      <div class=\"px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300\">\n        <p v-if=\"allowHtmlMessage\" id=\"confirm-prompt-message\" class=\"text-lg mb-6 mt-2 px-1\" v-html=\"sanitizedMessage\" />\n        <p v-else id=\"confirm-prompt-message\" class=\"text-lg mb-6 mt-2 px-1\">{{ message }}</p>\n\n        <ui-checkbox v-if=\"checkboxLabel\" v-model=\"checkboxValue\" checkbox-bg=\"bg\" :label=\"checkboxLabel\" label-class=\"pl-2 text-base\" class=\"mb-6 px-1\" />\n\n        <div class=\"flex px-1 items-center\">\n          <ui-btn v-if=\"isYesNo\" color=\"bg-primary\" @click=\"nevermind\">{{ $strings.ButtonCancel }}</ui-btn>\n          <div class=\"grow\" />\n          <ui-btn v-if=\"isYesNo\" :color=\"`bg-${yesButtonColor}`\" @click=\"confirm\">{{ yesButtonText }}</ui-btn>\n          <ui-btn v-else color=\"bg-primary\" @click=\"confirm\">{{ $strings.ButtonOk }}</ui-btn>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {},\n  data() {\n    return {\n      el: null,\n      content: null,\n      checkboxValue: false\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.setShow()\n      } else {\n        this.setHide()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.globals.showConfirmPrompt\n      },\n      set(val) {\n        this.$store.commit('globals/setShowConfirmPrompt', val)\n      }\n    },\n    confirmPromptOptions() {\n      return this.$store.state.globals.confirmPromptOptions || {}\n    },\n    message() {\n      return this.confirmPromptOptions.message || ''\n    },\n    allowHtmlMessage() {\n      return !!this.confirmPromptOptions.allowHtml\n    },\n    sanitizedMessage() {\n      if (!this.allowHtmlMessage) return this.message\n\n      return this.escapeHtml(this.message)\n        .replace(/&lt;br\\s*\\/?&gt;/gi, '<br>')\n        .replace(/&lt;code&gt;/gi, '<code>')\n        .replace(/&lt;\\/code&gt;/gi, '</code>')\n    },\n    callback() {\n      return this.confirmPromptOptions.callback\n    },\n    type() {\n      return this.confirmPromptOptions.type || 'ok'\n    },\n    persistent() {\n      return !!this.confirmPromptOptions.persistent\n    },\n    checkboxLabel() {\n      return this.confirmPromptOptions.checkboxLabel\n    },\n    yesButtonText() {\n      return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes\n    },\n    yesButtonColor() {\n      return this.confirmPromptOptions.yesButtonColor || 'success'\n    },\n    checkboxDefaultValue() {\n      return !!this.confirmPromptOptions.checkboxDefaultValue\n    },\n    isYesNo() {\n      return this.type === 'yesNo'\n    },\n    modalHeight() {\n      return 'unset'\n    },\n    modalWidth() {\n      return '500px'\n    }\n  },\n  methods: {\n    clickedOutside(evt) {\n      if (!this.show) return\n      if (evt) {\n        evt.stopPropagation()\n        evt.preventDefault()\n      }\n\n      if (this.persistent) return\n      if (this.callback) this.callback(false)\n      this.show = false\n    },\n    nevermind() {\n      if (this.callback) this.callback(false)\n      this.show = false\n    },\n    confirm() {\n      if (this.callback) this.callback(true, this.checkboxValue)\n      this.show = false\n    },\n    escapeHtml(value) {\n      return String(value)\n        .replace(/&/g, '&amp;')\n        .replace(/</g, '&lt;')\n        .replace(/>/g, '&gt;')\n        .replace(/\"/g, '&quot;')\n        .replace(/'/g, '&#39;')\n    },\n    setShow() {\n      this.checkboxValue = this.checkboxDefaultValue\n      this.$eventBus.$emit('showing-prompt', true)\n      document.body.appendChild(this.el)\n      setTimeout(() => {\n        this.content.style.transform = 'scale(1)'\n      }, 10)\n    },\n    setHide() {\n      this.$eventBus.$emit('showing-prompt', false)\n      this.content.style.transform = 'scale(0)'\n      this.el.remove()\n    }\n  },\n  mounted() {\n    this.el = this.$refs.wrapper\n    this.content = this.$refs.content\n    this.content.style.transform = 'scale(0)'\n    this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'\n    this.el.style.opacity = 1\n    this.el.remove()\n  },\n  beforeDestroy() {\n    if (this.show) {\n      this.$eventBus.$emit('showing-prompt', false)\n    }\n  }\n}\n</script>\n\n<style>\n#confirm-prompt-message code {\n  font-size: 1rem;\n  border-radius: 6px;\n  background-color: rgb(82, 82, 82);\n  color: white;\n  padding: 2px 4px;\n}\n</style>\n"
  },
  {
    "path": "client/components/prompt/Dialog.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"modal modal-bg w-full h-full fixed top-0 left-0 bg-primary/75 flex items-center justify-center z-40 opacity-0\">\n    <div class=\"absolute top-0 left-0 right-0 w-full h-36 bg-linear-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none\" />\n    <div ref=\"content\" style=\"min-width: 400px; min-height: 200px\" class=\"relative text-white\" :style=\"{ height: modalHeight, width: modalWidth }\">\n      <slot />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    persistent: {\n      type: Boolean,\n      default: true\n    },\n    width: {\n      type: [String, Number],\n      default: 500\n    },\n    height: {\n      type: [String, Number],\n      default: 'unset'\n    }\n  },\n  data() {\n    return {\n      el: null,\n      content: null\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.setShow()\n      } else {\n        this.setHide()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    modalHeight() {\n      if (typeof this.height === 'string') {\n        return this.height\n      } else {\n        return this.height + 'px'\n      }\n    },\n    modalWidth() {\n      return typeof this.width === 'string' ? this.width : this.width + 'px'\n    }\n  },\n  methods: {\n    setShow() {\n      document.body.appendChild(this.el)\n      setTimeout(() => {\n        this.content.style.transform = 'scale(1)'\n      }, 10)\n    },\n    setHide() {\n      this.content.style.transform = 'scale(0)'\n      this.el.remove()\n    }\n  },\n  mounted() {\n    this.el = this.$refs.wrapper\n    this.content = this.$refs.content\n    this.content.style.transform = 'scale(0)'\n    this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'\n    this.el.style.opacity = 1\n    this.el.remove()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/readers/ComicReader.vue",
    "content": "<template>\n  <div class=\"w-full h-full\">\n    <div v-show=\"showPageMenu\" v-click-outside=\"clickOutside\" class=\"pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400\" :style=\"{ width: pageMenuWidth + 'px' }\">\n      <div v-for=\"(file, index) in cleanedPageNames\" :key=\"file\" class=\"w-full cursor-pointer hover:bg-black-200 px-2 py-1\" :class=\"page === index + 1 ? 'bg-black-200' : ''\" @click=\"setPage(index + 1)\">\n        <p class=\"text-sm truncate\">{{ file }}</p>\n      </div>\n    </div>\n    <div v-show=\"showInfoMenu\" v-click-outside=\"clickOutside\" class=\"pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96\">\n      <div v-for=\"key in comicMetadataKeys\" :key=\"key\" class=\"w-full px-2 py-1\">\n        <p class=\"text-xs\">\n          <strong>{{ key }}</strong\n          >: {{ comicMetadata[key] }}\n        </p>\n      </div>\n    </div>\n\n    <div v-if=\"numPages\" class=\"absolute top-0 left-4 sm:left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20\" @mousedown.prevent @click.stop.prevent=\"clickShowPageMenu\">\n      <span class=\"material-symbols text-xl\">menu</span>\n    </div>\n    <div v-if=\"comicMetadata\" class=\"absolute top-0 left-16 sm:left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20\" @mousedown.prevent @click.stop.prevent=\"clickShowInfoMenu\">\n      <span class=\"material-symbols text-xl\">more</span>\n    </div>\n    <a v-if=\"pages && numPages\" :href=\"mainImg\" :download=\"pages[page - 1]\" class=\"absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20\" :class=\"comicMetadata ? 'left-28 sm:left-32' : 'left-16 sm:left-20'\">\n      <span class=\"material-symbols text-xl\">download</span>\n    </a>\n\n    <div v-if=\"numPages\" class=\"absolute top-0 right-14 sm:right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20\">\n      <p class=\"font-mono\">{{ page }} / {{ numPages }}</p>\n    </div>\n    <div v-if=\"mainImg\" class=\"absolute top-0 right-36 sm:right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20\">\n      <ui-icon-btn icon=\"zoom_out\" :size=\"8\" :disabled=\"!canScaleDown\" borderless class=\"mr-px\" @click=\"zoomOut\" />\n      <ui-icon-btn icon=\"zoom_in\" :size=\"8\" :disabled=\"!canScaleUp\" borderless class=\"ml-px\" @click=\"zoomIn\" />\n    </div>\n\n    <div class=\"w-full h-full relative\">\n      <div v-show=\"canGoPrev\" ref=\"prevButton\" class=\"absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer\" @click.stop.prevent=\"prev\" @mousedown.prevent>\n        <div class=\"flex items-center justify-center h-full w-1/2\">\n          <span v-show=\"loadedFirstPage\" class=\"material-symbols text-5xl text-white/30 cursor-pointer hover:text-white/90\">arrow_back_ios</span>\n        </div>\n      </div>\n      <div v-show=\"canGoNext\" ref=\"nextButton\" class=\"absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer\" @click.stop.prevent=\"next\" @mousedown.prevent>\n        <div class=\"flex items-center justify-center h-full w-1/2 ml-auto\">\n          <span v-show=\"loadedFirstPage\" class=\"material-symbols text-5xl text-white/30 cursor-pointer hover:text-white/90\">arrow_forward_ios</span>\n        </div>\n      </div>\n      <div ref=\"imageContainer\" class=\"w-full h-full relative overflow-auto\">\n        <div class=\"h-full flex\" :class=\"scale > 100 ? '' : 'justify-center'\">\n          <img v-if=\"mainImg\" :style=\"{ minWidth: scale + '%', width: scale + '%' }\" :src=\"mainImg\" class=\"object-contain m-auto\" />\n        </div>\n      </div>\n      <div v-show=\"loading\" class=\"w-full h-full absolute top-0 left-0 flex items-center justify-center z-10\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport Path from 'path'\nimport { Archive } from 'libarchive.js/main.js'\nimport { CompressedFile } from 'libarchive.js/src/compressed-file'\n\n// This is % with respect to the screen width\nconst MAX_SCALE = 400\nconst MIN_SCALE = 10\n\nArchive.init({\n  workerUrl: '/libarchive/worker-bundle.js'\n})\n\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    playerOpen: Boolean,\n    keepProgress: Boolean,\n    fileId: String\n  },\n  data() {\n    return {\n      loading: false,\n      pages: null,\n      filesObject: null,\n      mainImg: null,\n      page: 0,\n      numPages: 0,\n      pageMenuWidth: 256,\n      showPageMenu: false,\n      showInfoMenu: false,\n      loadTimeout: null,\n      loadedFirstPage: false,\n      comicMetadata: null,\n      scale: 80\n    }\n  },\n  watch: {\n    url: {\n      immediate: true,\n      handler() {\n        this.extract()\n      }\n    }\n  },\n  computed: {\n    libraryItemId() {\n      return this.libraryItem?.id\n    },\n    ebookUrl() {\n      if (this.fileId) {\n        return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`\n      }\n      return `/api/items/${this.libraryItemId}/ebook`\n    },\n    comicMetadataKeys() {\n      return this.comicMetadata ? Object.keys(this.comicMetadata) : []\n    },\n    canGoNext() {\n      return this.page < this.numPages\n    },\n    canGoPrev() {\n      return this.page > 1\n    },\n    userMediaProgress() {\n      if (!this.libraryItemId) return\n      return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)\n    },\n    savedPage() {\n      if (!this.keepProgress) return 0\n\n      // Validate ebookLocation is a number\n      if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0\n      return Number(this.userMediaProgress.ebookLocation)\n    },\n    cleanedPageNames() {\n      return (\n        this.pages?.map((p) => {\n          if (p.length > 50) {\n            let firstHalf = p.slice(0, 22)\n            let lastHalf = p.slice(p.length - 23)\n            return `${firstHalf} ... ${lastHalf}`\n          }\n          return p\n        }) || []\n      )\n    },\n    canScaleUp() {\n      return this.scale < MAX_SCALE\n    },\n    canScaleDown() {\n      return this.scale > MIN_SCALE\n    }\n  },\n  methods: {\n    clickShowPageMenu() {\n      this.showInfoMenu = false\n      this.showPageMenu = !this.showPageMenu\n    },\n    clickShowInfoMenu() {\n      this.showPageMenu = false\n      this.showInfoMenu = !this.showInfoMenu\n    },\n    updateProgress() {\n      if (!this.keepProgress) return\n\n      if (!this.numPages) {\n        console.error('Num pages not loaded')\n        return\n      }\n      if (this.savedPage === this.page) {\n        return\n      }\n\n      const payload = {\n        ebookLocation: this.page,\n        ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))\n      }\n      this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => {\n        console.error('ComicReader.updateProgress failed:', error)\n      })\n    },\n    clickOutside() {\n      if (this.showPageMenu) this.showPageMenu = false\n      if (this.showInfoMenu) this.showInfoMenu = false\n    },\n    next() {\n      if (!this.canGoNext) return\n      this.setPage(this.page + 1)\n    },\n    prev() {\n      if (!this.canGoPrev) return\n      this.setPage(this.page - 1)\n    },\n    setPage(page) {\n      if (page <= 0 || page > this.numPages) {\n        return\n      }\n      this.showPageMenu = false\n      this.showInfoMenu = false\n      const filename = this.pages[page - 1]\n      this.page = page\n      this.updateProgress()\n      return this.extractFile(filename)\n    },\n    setLoadTimeout() {\n      this.loadTimeout = setTimeout(() => {\n        this.loading = true\n      }, 150)\n    },\n    extractFile(filename) {\n      return new Promise(async (resolve) => {\n        this.setLoadTimeout()\n        var file = await this.filesObject[filename].extract()\n        var reader = new FileReader()\n        reader.onload = (e) => {\n          this.mainImg = e.target.result\n          this.loading = false\n          resolve()\n        }\n        reader.onerror = (e) => {\n          console.error(e)\n          this.$toast.error('Read page file failed')\n          this.loading = false\n          resolve()\n        }\n        reader.readAsDataURL(file)\n        clearTimeout(this.loadTimeout)\n      })\n    },\n    async extract() {\n      this.loading = true\n      var buff = await this.$axios.$get(this.ebookUrl, {\n        responseType: 'blob'\n      })\n      const archive = await Archive.open(buff)\n      const originalFilesObject = await archive.getFilesObject()\n      // to support images in subfolders we need to flatten the object\n      //   ref: https://github.com/advplyr/audiobookshelf/issues/811\n      this.filesObject = this.flattenFilesObject(originalFilesObject)\n      console.log('Extracted files object', this.filesObject)\n      var filenames = Object.keys(this.filesObject)\n      this.parseFilenames(filenames)\n\n      var xmlFile = filenames.find((f) => (Path.extname(f) || '').toLowerCase() === '.xml')\n      if (xmlFile) await this.extractXmlFile(xmlFile)\n\n      this.numPages = this.pages.length\n\n      // Calculate page menu size\n      const largestFilename = this.cleanedPageNames\n        .map((p) => p)\n        .sort((a, b) => a.length - b.length)\n        .pop()\n      const pEl = document.createElement('p')\n      pEl.innerText = largestFilename\n      pEl.style.fontSize = '0.875rem'\n      pEl.style.opacity = 0\n      pEl.style.position = 'absolute'\n      document.body.appendChild(pEl)\n      const textWidth = pEl.getBoundingClientRect()?.width\n      if (textWidth) {\n        this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)\n      }\n      pEl.remove()\n\n      if (this.pages.length) {\n        this.loading = false\n\n        const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1\n        await this.setPage(startPage)\n        this.loadedFirstPage = true\n      } else {\n        this.$toast.error('Unable to extract pages')\n        this.loading = false\n      }\n    },\n    flattenFilesObject(filesObject) {\n      const flattenObject = (obj, prefix = '') => {\n        var _obj = {}\n        for (const key in obj) {\n          const newKey = prefix ? prefix + '/' + key : key\n          if (obj[key] instanceof CompressedFile) {\n            _obj[newKey] = obj[key]\n          } else if (!key.startsWith('_') && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {\n            _obj = {\n              ..._obj,\n              ...flattenObject(obj[key], newKey)\n            }\n          } else {\n            _obj[newKey] = obj[key]\n          }\n        }\n        return _obj\n      }\n      return flattenObject(filesObject)\n    },\n    async extractXmlFile(filename) {\n      console.log('extracting xml filename', filename)\n      try {\n        var file = await this.filesObject[filename].extract()\n        var reader = new FileReader()\n        reader.onload = (e) => {\n          this.comicMetadata = this.$xmlToJson(e.target.result)\n          console.log('Metadata', this.comicMetadata)\n        }\n        reader.onerror = (e) => {\n          console.error(e)\n        }\n        reader.readAsText(file)\n      } catch (error) {\n        console.error(error)\n      }\n    },\n    parseImageFilename(filename) {\n      var basename = Path.basename(filename, Path.extname(filename))\n      var numbersinpath = basename.match(/\\d+/g)\n      if (!numbersinpath?.length) {\n        return {\n          index: -1,\n          filename\n        }\n      } else {\n        return {\n          index: Number(numbersinpath[numbersinpath.length - 1]),\n          filename\n        }\n      }\n    },\n    parseFilenames(filenames) {\n      const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']\n      var imageFiles = filenames.filter((f) => {\n        return acceptableImages.includes((Path.extname(f) || '').toLowerCase())\n      })\n      var imageFileObjs = imageFiles.map((img) => {\n        return this.parseImageFilename(img)\n      })\n\n      var imagesWithNum = imageFileObjs.filter((i) => i.index >= 0)\n      var orderedImages = imagesWithNum.sort((a, b) => a.index - b.index).map((i) => i.filename)\n      var noNumImages = imageFileObjs.filter((i) => i.index < 0)\n      orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))\n\n      this.pages = orderedImages\n    },\n    zoomIn() {\n      this.scale += 10\n    },\n    zoomOut() {\n      this.scale -= 10\n    },\n    scroll(event) {\n      const imageContainer = this.$refs.imageContainer\n\n      imageContainer.scrollBy({\n        top: event.deltaY,\n        left: event.deltaX,\n        behavior: 'auto'\n      })\n    }\n  },\n  mounted() {\n    const prevButton = this.$refs.prevButton\n    const nextButton = this.$refs.nextButton\n\n    prevButton.addEventListener('wheel', this.scroll, { passive: false })\n    nextButton.addEventListener('wheel', this.scroll, { passive: false })\n  },\n  beforeDestroy() {\n    const prevButton = this.$refs.prevButton\n    const nextButton = this.$refs.nextButton\n\n    prevButton.removeEventListener('wheel', this.scroll, { passive: false })\n    nextButton.removeEventListener('wheel', this.scroll, { passive: false })\n  }\n}\n</script>\n\n<style scoped>\n.pagemenu {\n  max-height: calc(100% - 48px);\n}\n</style>\n"
  },
  {
    "path": "client/components/readers/EpubReader.vue",
    "content": "<template>\n  <div id=\"epub-reader\" class=\"h-full w-full\">\n    <div class=\"h-full flex items-center justify-center\">\n      <button type=\"button\" aria-label=\"Previous page\" class=\"w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100\">\n        <span v-if=\"hasPrev\" class=\"material-symbols text-6xl\" @mousedown.prevent @click=\"prev\">chevron_left</span>\n      </button>\n      <div id=\"frame\" class=\"w-full\" style=\"height: 80%\">\n        <div id=\"viewer\"></div>\n      </div>\n      <button type=\"button\" aria-label=\"Next page\" class=\"w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100\">\n        <span v-if=\"hasNext\" class=\"material-symbols text-6xl\" @mousedown.prevent @click=\"next\">chevron_right</span>\n      </button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport ePub from 'epubjs'\n\n/**\n * @typedef {object} EpubReader\n * @property {ePub.Book} book\n * @property {ePub.Rendition} rendition\n */\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    playerOpen: Boolean,\n    keepProgress: Boolean,\n    fileId: String\n  },\n  data() {\n    return {\n      windowWidth: 0,\n      windowHeight: 0,\n      /** @type {ePub.Book} */\n      book: null,\n      /** @type {ePub.Rendition} */\n      rendition: null,\n      chapters: [],\n      ereaderSettings: {\n        theme: 'dark',\n        font: 'serif',\n        fontScale: 100,\n        lineSpacing: 115,\n        spread: 'auto',\n        textStroke: 0\n      }\n    }\n  },\n  watch: {\n    playerOpen() {\n      this.resize()\n    }\n  },\n  computed: {\n    /** @returns {string} */\n    libraryItemId() {\n      return this.libraryItem?.id\n    },\n    allowScriptedContent() {\n      return this.$store.getters['libraries/getLibraryEpubsAllowScriptedContent']\n    },\n    hasPrev() {\n      return !this.rendition?.location?.atStart\n    },\n    hasNext() {\n      return !this.rendition?.location?.atEnd\n    },\n    userMediaProgress() {\n      if (!this.libraryItemId) return\n      return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)\n    },\n    savedEbookLocation() {\n      if (!this.keepProgress) return null\n      if (!this.userMediaProgress?.ebookLocation) return null\n      // Validate ebookLocation is an epubcfi\n      if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null\n      return this.userMediaProgress.ebookLocation\n    },\n    localStorageLocationsKey() {\n      return `ebookLocations-${this.libraryItemId}`\n    },\n    readerWidth() {\n      if (this.windowWidth < 640) return this.windowWidth\n      return this.windowWidth - 200\n    },\n    readerHeight() {\n      if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight\n      return this.windowHeight - 164\n    },\n    ebookUrl() {\n      if (this.fileId) {\n        return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`\n      }\n      return `/api/items/${this.libraryItemId}/ebook`\n    },\n    themeRules() {\n      const theme = this.ereaderSettings.theme\n      const isDark = theme === 'dark'\n      const isSepia = theme === 'sepia'\n\n      const fontColor = isDark\n        ? '#fff'\n        : isSepia\n        ? '#5b4636'\n        : '#000'\n\n      const backgroundColor = isDark\n        ? 'rgb(35 35 35)'\n        : isSepia\n        ? 'rgb(244, 236, 216)'\n        : 'rgb(255, 255, 255)'\n\n      const lineSpacing = this.ereaderSettings.lineSpacing / 100\n      const fontScale   = this.ereaderSettings.fontScale   / 100\n      const textStroke  = this.ereaderSettings.textStroke  / 100\n\n      return {\n        '*': {\n          color: `${fontColor}!important`,\n          'background-color': `${backgroundColor}!important`,\n          'line-height': `${lineSpacing * fontScale}rem!important`,\n          '-webkit-text-stroke': `${textStroke}px ${fontColor}!important`\n        },\n        a: {\n          color: `${fontColor}!important`\n        }\n      }\n    }\n  },\n  methods: {\n    updateSettings(settings) {\n      this.ereaderSettings = settings\n\n      if (!this.rendition) return\n\n      this.applyTheme()\n\n      const fontScale = settings.fontScale || 100\n      this.rendition.themes.fontSize(`${fontScale}%`)\n      this.rendition.themes.font(settings.font)\n      this.rendition.spread(settings.spread || 'auto')\n    },\n    prev() {\n      if (!this.rendition?.manager) return\n      return this.rendition?.prev()\n    },\n    next() {\n      if (!this.rendition?.manager) return\n      return this.rendition?.next()\n    },\n    goToChapter(href) {\n      if (!this.rendition?.manager) return\n      return this.rendition?.display(href)\n    },\n    /** @returns {object} Returns the chapter that the `position` in the book is in */\n    findChapterFromPosition(chapters, position) {\n      let foundChapter\n      for (let i = 0; i < chapters.length; i++) {\n        if (position >= chapters[i].start && (!chapters[i + 1] || position < chapters[i + 1].start)) {\n          foundChapter = chapters[i]\n          if (chapters[i].subitems && chapters[i].subitems.length > 0) {\n            return this.findChapterFromPosition(chapters[i].subitems, position, foundChapter)\n          }\n          break\n        }\n      }\n      return foundChapter\n    },\n    /** @returns {Array} Returns an array of chapters that only includes chapters with query results */\n    async searchBook(query) {\n      const chapters = structuredClone(await this.chapters)\n      const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item))))\n      const mergedResults = [].concat(...searchResults)\n\n      mergedResults.forEach((chapter) => {\n        chapter.start = this.book.locations.percentageFromCfi(chapter.cfi)\n        const foundChapter = this.findChapterFromPosition(chapters, chapter.start)\n        if (foundChapter) foundChapter.searchResults.push(chapter)\n      })\n\n      let filteredResults = chapters.filter(function f(o) {\n        if (o.searchResults.length) return true\n        if (o.subitems.length) {\n          return (o.subitems = o.subitems.filter(f)).length\n        }\n      })\n      return filteredResults\n    },\n    keyUp(e) {\n      const rtl = this.book.package.metadata.direction === 'rtl'\n      if ((e.keyCode || e.which) == 37) {\n        return rtl ? this.next() : this.prev()\n      } else if ((e.keyCode || e.which) == 39) {\n        return rtl ? this.prev() : this.next()\n      }\n    },\n    /**\n     * @param {object} payload\n     * @param {string} payload.ebookLocation - CFI of the current location\n     * @param {string} payload.ebookProgress - eBook Progress Percentage\n     */\n    updateProgress(payload) {\n      if (!this.keepProgress) return\n      this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => {\n        console.error('EpubReader.updateProgress failed:', error)\n      })\n    },\n    getAllEbookLocationData() {\n      const locations = []\n      let totalSize = 0 // Total in bytes\n\n      for (const key in localStorage) {\n        if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {\n          continue\n        }\n\n        try {\n          const ebookLocations = JSON.parse(localStorage[key])\n          if (!ebookLocations.locations) throw new Error('Invalid locations object')\n\n          ebookLocations.key = key\n          ebookLocations.size = (localStorage[key].length + key.length) * 2\n          locations.push(ebookLocations)\n          totalSize += ebookLocations.size\n        } catch (error) {\n          console.error('Failed to parse ebook locations', key, error)\n          localStorage.removeItem(key)\n        }\n      }\n\n      // Sort by oldest lastAccessed first\n      locations.sort((a, b) => a.lastAccessed - b.lastAccessed)\n\n      return {\n        locations,\n        totalSize\n      }\n    },\n    /** @param {string} locationString */\n    checkSaveLocations(locationString) {\n      const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space\n      const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2\n\n      // Too large overall\n      if (newLocationsSize > maxSizeInBytes) {\n        console.error('Epub locations are too large to store. Size =', newLocationsSize)\n        return\n      }\n\n      const ebookLocationsData = this.getAllEbookLocationData()\n\n      let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize\n\n      // Remove epub locations until there is room for locations\n      while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {\n        const oldestLocation = ebookLocationsData.locations.shift()\n        console.log(`Removing cached locations for epub \"${oldestLocation.key}\" taking up ${oldestLocation.size} bytes`)\n        availableSpace += oldestLocation.size\n        localStorage.removeItem(oldestLocation.key)\n      }\n\n      console.log(`Cacheing epub locations with key \"${this.localStorageLocationsKey}\" taking up ${newLocationsSize} bytes`)\n      this.saveLocations(locationString)\n    },\n    /** @param {string} locationString */\n    saveLocations(locationString) {\n      localStorage.setItem(\n        this.localStorageLocationsKey,\n        JSON.stringify({\n          lastAccessed: Date.now(),\n          locations: locationString\n        })\n      )\n    },\n    loadLocations() {\n      const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)\n      if (!locationsObjString) return null\n\n      const locationsObject = JSON.parse(locationsObjString)\n\n      // Remove invalid location objects\n      if (!locationsObject.locations) {\n        console.error('Invalid epub locations stored', this.localStorageLocationsKey)\n        localStorage.removeItem(this.localStorageLocationsKey)\n        return null\n      }\n\n      // Update lastAccessed\n      this.saveLocations(locationsObject.locations)\n\n      return locationsObject.locations\n    },\n    /** @param {string} location - CFI of the new location */\n    relocated(location) {\n      if (this.savedEbookLocation === location.start.cfi) {\n        return\n      }\n\n      if (location.end.percentage) {\n        this.updateProgress({\n          ebookLocation: location.start.cfi,\n          ebookProgress: location.end.percentage\n        })\n      } else {\n        this.updateProgress({\n          ebookLocation: location.start.cfi\n        })\n      }\n    },\n    initEpub() {\n      /** @type {EpubReader} */\n      const reader = this\n\n      // Use axios to make request because we have token refresh logic in interceptor\n      const customRequest = async (url) => {\n        try {\n          return this.$axios.$get(url, {\n            responseType: 'arraybuffer'\n          })\n        } catch (error) {\n          console.error('EpubReader.initEpub customRequest failed:', error)\n          throw error\n        }\n      }\n\n      /** @type {ePub.Book} */\n      reader.book = new ePub(reader.ebookUrl, {\n        width: this.readerWidth,\n        height: this.readerHeight - 50,\n        openAs: 'epub',\n        requestMethod: customRequest\n      })\n\n      /** @type {ePub.Rendition} */\n      reader.rendition = reader.book.renderTo('viewer', {\n        width: this.readerWidth,\n        height: this.readerHeight * 0.8,\n        allowScriptedContent: this.allowScriptedContent,\n        spread: 'auto',\n        snap: true,\n        manager: 'continuous',\n        flow: 'paginated'\n      })\n\n      // load saved progress\n      reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)\n\n      reader.rendition.on('rendered', () => {\n        this.applyTheme()\n      })\n\n      reader.book.ready\n        .then(() => {\n          // set up event listeners\n          reader.rendition.on('relocated', reader.relocated)\n          reader.rendition.on('keydown', reader.keyUp)\n\n          reader.rendition.on('touchstart', (event) => {\n            this.$emit('touchstart', event)\n          })\n          reader.rendition.on('touchend', (event) => {\n            this.$emit('touchend', event)\n          })\n\n          // load ebook cfi locations\n          const savedLocations = this.loadLocations()\n          if (savedLocations) {\n            reader.book.locations.load(savedLocations)\n          } else {\n            reader.book.locations.generate().then(() => {\n              this.checkSaveLocations(reader.book.locations.save())\n            })\n          }\n          this.getChapters()\n        })\n        .catch((error) => {\n          console.error('EpubReader.initEpub failed:', error)\n        })\n    },\n    getChapters() {\n      // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759\n      const toc = this.book?.navigation?.toc || []\n\n      const tocTree = []\n\n      const resolveURL = (url, relativeTo) => {\n        // see https://github.com/futurepress/epub.js/issues/1084\n        // HACK-ish: abuse the URL API a little to resolve the path\n        // the base needs to be a valid URL, or it will throw a TypeError,\n        // so we just set a random base URI and remove it later\n        const base = 'https://example.invalid/'\n        return new URL(url, base + relativeTo).href.replace(base, '')\n      }\n\n      const basePath = this.book.packaging.navPath || this.book.packaging.ncxPath\n\n      const createTree = async (toc, parent) => {\n        const promises = toc.map(async (tocItem, i) => {\n          const href = resolveURL(tocItem.href, basePath)\n          const id = href.split('#')[1]\n          const item = this.book.spine.get(href)\n          await item.load(this.book.load.bind(this.book))\n          const el = id ? item.document.getElementById(id) : item.document.body\n\n          const cfi = item.cfiFromElement(el)\n\n          parent[i] = {\n            title: tocItem.label.trim(),\n            subitems: [],\n            href,\n            cfi,\n            start: this.book.locations.percentageFromCfi(cfi),\n            end: null, // set by flattenChapters()\n            id: null, // set by flattenChapters()\n            searchResults: []\n          }\n\n          if (tocItem.subitems) {\n            await createTree(tocItem.subitems, parent[i].subitems)\n          }\n        })\n        await Promise.all(promises)\n      }\n      return createTree(toc, tocTree).then(() => {\n        this.chapters = tocTree\n      })\n    },\n    flattenChapters(chapters) {\n      // Convert the nested epub chapters into something that looks like audiobook chapters for player-ui\n      const unwrap = (chapters) => {\n        return chapters.reduce((acc, chapter) => {\n          return chapter.subitems ? [...acc, chapter, ...unwrap(chapter.subitems)] : [...acc, chapter]\n        }, [])\n      }\n      let flattenedChapters = unwrap(chapters)\n\n      flattenedChapters = flattenedChapters.sort((a, b) => a.start - b.start)\n      for (let i = 0; i < flattenedChapters.length; i++) {\n        flattenedChapters[i].id = i\n        if (i < flattenedChapters.length - 1) {\n          flattenedChapters[i].end = flattenedChapters[i + 1].start\n        } else {\n          flattenedChapters[i].end = 1\n        }\n      }\n      return flattenedChapters\n    },\n    resize() {\n      this.windowWidth = window.innerWidth\n      this.windowHeight = window.innerHeight\n      this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)\n    },\n    applyTheme() {\n      if (!this.rendition) return\n      this.rendition.getContents().forEach((c) => {\n        c.addStylesheetRules(this.themeRules)\n      })\n    }\n  },\n  mounted() {\n    this.windowWidth = window.innerWidth\n    this.windowHeight = window.innerHeight\n    window.addEventListener('resize', this.resize)\n    this.initEpub()\n  },\n  beforeDestroy() {\n    window.removeEventListener('resize', this.resize)\n    this.book?.destroy()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/readers/MobiReader.vue",
    "content": "<template>\n  <div class=\"w-full h-full\">\n    <div class=\"h-full max-h-full w-full\">\n      <div class=\"ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black/20 shadow-md bg-white\">\n        <iframe title=\"html-viewer\" width=\"100%\"> Loading </iframe>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport MobiParser from '@/assets/ebooks/mobi.js'\nimport HtmlParser from '@/assets/ebooks/htmlParser.js'\nimport defaultCss from '@/assets/ebooks/basic.js'\n\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    playerOpen: Boolean,\n    fileId: String\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    libraryItemId() {\n      return this.libraryItem?.id\n    },\n    ebookUrl() {\n      if (this.fileId) {\n        return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`\n      }\n      return `/api/items/${this.libraryItemId}/ebook`\n    }\n  },\n  methods: {\n    addHtmlCss() {\n      let iframe = document.getElementsByTagName('iframe')[0]\n      if (!iframe) return\n      let doc = iframe.contentDocument\n      if (!doc) return\n      let style = doc.createElement('style')\n      style.id = 'default-style'\n      style.textContent = defaultCss\n      doc.head.appendChild(style)\n    },\n    handleIFrameHeight(iFrame) {\n      const isElement = (obj) => !!(obj && obj.nodeType === 1)\n\n      var body = iFrame.contentWindow.document.body,\n        html = iFrame.contentWindow.document.documentElement\n      iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2\n\n      setTimeout(() => {\n        let lastchild = body.lastElementChild\n        let lastEle = body.lastChild\n\n        let itemAs = body.querySelectorAll('a')\n        let itemPs = body.querySelectorAll('p')\n        let lastItemA = itemAs[itemAs.length - 1]\n        let lastItemP = itemPs[itemPs.length - 1]\n        let lastItem\n        if (isElement(lastItemA) && isElement(lastItemP)) {\n          if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {\n            lastItem = lastItemA\n          } else {\n            lastItem = lastItemP\n          }\n        }\n\n        if (!lastchild && !lastItem && !lastEle) return\n        if (lastEle.nodeType === 3 && !lastchild && !lastItem) return\n\n        let nodeHeight = 0\n        if (lastEle.nodeType === 3 && document.createRange) {\n          let range = document.createRange()\n          range.selectNodeContents(lastEle)\n          if (range.getBoundingClientRect) {\n            let rect = range.getBoundingClientRect()\n            if (rect) {\n              nodeHeight = rect.bottom - rect.top\n            }\n          }\n        }\n        var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0\n        var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0\n        var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0\n        iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight\n      }, 500)\n    },\n    async initMobi() {\n      // Fetch mobi file as blob\n      const buff = await this.$axios.$get(this.ebookUrl, {\n        responseType: 'blob'\n      })\n      var reader = new FileReader()\n      reader.onload = async (event) => {\n        var file_content = event.target.result\n\n        let mobiFile = new MobiParser(file_content)\n\n        let content = await mobiFile.render()\n        let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))\n        var anchoredDoc = htmlParser.getAnchoredDoc()\n\n        let iFrame = document.getElementsByTagName('iframe')[0]\n        iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML\n\n        // Add css\n        let style = iFrame.contentDocument.createElement('style')\n        style.id = 'default-style'\n        style.textContent = defaultCss\n        iFrame.contentDocument.head.appendChild(style)\n\n        this.handleIFrameHeight(iFrame)\n      }\n      reader.readAsArrayBuffer(buff)\n    }\n  },\n  mounted() {\n    this.initMobi()\n  }\n}\n</script>\n\n<style>\n.ebook-viewer {\n  height: calc(100% - 96px);\n}\n</style>\n"
  },
  {
    "path": "client/components/readers/PdfReader.vue",
    "content": "<template>\n  <div class=\"w-full h-full pt-20 relative\">\n    <div v-show=\"canGoPrev\" class=\"absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer\" @click.stop.prevent=\"prev\" @mousedown.prevent>\n      <div class=\"flex items-center justify-center h-full w-1/2\">\n        <span class=\"material-symbols text-5xl text-white/30 cursor-pointer hover:text-white/90\">arrow_back_ios</span>\n      </div>\n    </div>\n    <div v-show=\"canGoNext\" class=\"absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer\" @click.stop.prevent=\"next\" @mousedown.prevent>\n      <div class=\"flex items-center justify-center h-full w-1/2 ml-auto\">\n        <span class=\"material-symbols text-5xl text-white/30 cursor-pointer hover:text-white/90\">arrow_forward_ios</span>\n      </div>\n    </div>\n\n    <div class=\"absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center\">\n      <p class=\"font-mono\">{{ page }} / {{ numPages }}</p>\n    </div>\n    <div class=\"absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center\">\n      <ui-icon-btn icon=\"zoom_out\" :size=\"8\" :disabled=\"!canScaleDown\" borderless class=\"mr-px\" @click=\"zoomOut\" />\n      <ui-icon-btn icon=\"zoom_in\" :size=\"8\" :disabled=\"!canScaleUp\" borderless class=\"ml-px\" @click=\"zoomIn\" />\n    </div>\n\n    <div :style=\"{ height: pdfHeight + 'px' }\" class=\"overflow-hidden m-auto\">\n      <div class=\"flex items-center justify-center\">\n        <div :style=\"{ width: pdfWidth + 'px', height: pdfHeight + 'px' }\" class=\"overflow-auto\">\n          <div v-if=\"loadedRatio > 0 && loadedRatio < 1\" style=\"background-color: green; color: white; text-align: center\" :style=\"{ width: loadedRatio * 100 + '%' }\">{{ Math.floor(loadedRatio * 100) }}%</div>\n          <pdf v-if=\"pdfDocInitParams\" ref=\"pdf\" class=\"m-auto z-10 border border-black/20 shadow-md\" :src=\"pdfDocInitParams\" :page=\"page\" :rotate=\"rotate\" @progress=\"progressEvt\" @error=\"error\" @num-pages=\"numPagesLoaded\" @link-clicked=\"page = $event\" @loaded=\"loadedEvt\"></pdf>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport pdf from '@teckel/vue-pdf'\n\nexport default {\n  components: {\n    pdf\n  },\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    playerOpen: Boolean,\n    keepProgress: Boolean,\n    fileId: String\n  },\n  data() {\n    return {\n      windowWidth: 0,\n      windowHeight: 0,\n      scale: 1,\n      rotate: 0,\n      loadedRatio: 0,\n      page: 1,\n      numPages: 0,\n      pdfDocInitParams: null,\n      isRefreshing: false\n    }\n  },\n  computed: {\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    libraryItemId() {\n      return this.libraryItem?.id\n    },\n    fitToPageWidth() {\n      return this.pdfHeight * 0.6\n    },\n    pdfWidth() {\n      return this.fitToPageWidth * this.scale\n    },\n    pdfHeight() {\n      if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120\n      return this.windowHeight - 284\n    },\n    maxScale() {\n      return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10\n    },\n    canGoNext() {\n      return this.page < this.numPages\n    },\n    canGoPrev() {\n      return this.page > 1\n    },\n    canScaleUp() {\n      return this.scale < this.maxScale\n    },\n    canScaleDown() {\n      return this.scale > 1\n    },\n    userMediaProgress() {\n      if (!this.libraryItemId) return\n      return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)\n    },\n    savedPage() {\n      if (!this.keepProgress) return 0\n\n      // Validate ebookLocation is a number\n      if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0\n      return Number(this.userMediaProgress.ebookLocation)\n    },\n    ebookUrl() {\n      if (this.fileId) {\n        return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`\n      }\n      return `/api/items/${this.libraryItemId}/ebook`\n    }\n  },\n  methods: {\n    zoomIn() {\n      this.scale += 0.1\n    },\n    zoomOut() {\n      this.scale -= 0.1\n    },\n    updateProgress() {\n      if (!this.keepProgress) return\n      if (!this.numPages) {\n        console.error('Num pages not loaded')\n        return\n      }\n\n      const payload = {\n        ebookLocation: this.page,\n        ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))\n      }\n      this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => {\n        console.error('EpubReader.updateProgress failed:', error)\n      })\n    },\n    loadedEvt() {\n      if (this.savedPage > 0 && this.savedPage <= this.numPages) {\n        this.page = this.savedPage\n      }\n    },\n    progressEvt(progress) {\n      this.loadedRatio = progress\n    },\n    numPagesLoaded(e) {\n      if (!e) return\n      this.numPages = e\n    },\n    prev() {\n      if (this.page <= 1) return\n      this.page--\n      this.updateProgress()\n    },\n    next() {\n      if (this.page >= this.numPages) return\n      this.page++\n      this.updateProgress()\n    },\n    async refreshToken() {\n      if (this.isRefreshing) return\n      this.isRefreshing = true\n      const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {\n        console.error('Failed to refresh token', error)\n        return null\n      })\n      if (!newAccessToken) {\n        // Redirect to login on failed refresh\n        this.$router.push('/login')\n        return\n      }\n\n      // Force Vue to re-render the PDF component by creating a new object\n      this.pdfDocInitParams = {\n        url: this.ebookUrl,\n        httpHeaders: {\n          Authorization: `Bearer ${newAccessToken}`\n        }\n      }\n      this.isRefreshing = false\n    },\n    async error(err) {\n      if (err && err.status === 401) {\n        console.log('Received 401 error, refreshing token')\n        await this.refreshToken()\n        return\n      }\n      console.error(err)\n    },\n    resize() {\n      this.windowWidth = window.innerWidth\n      this.windowHeight = window.innerHeight\n    },\n    init() {\n      this.pdfDocInitParams = {\n        url: this.ebookUrl,\n        httpHeaders: {\n          Authorization: `Bearer ${this.userToken}`\n        }\n      }\n    }\n  },\n  mounted() {\n    this.windowWidth = window.innerWidth\n    this.windowHeight = window.innerHeight\n    window.addEventListener('resize', this.resize)\n\n    this.init()\n  },\n  beforeDestroy() {\n    window.removeEventListener('resize', this.resize)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/readers/Reader.vue",
    "content": "<template>\n  <div v-if=\"show\" id=\"reader\" :data-theme=\"ereaderTheme\" class=\"group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black data-[theme=sepia]:bg-[rgb(244,236,216)] data-[theme=sepia]:text-[#5b4636]\" :class=\"{ 'reader-player-open': !!streamLibraryItem }\">\n    <div class=\"absolute top-4 left-4 z-20 flex items-center\">\n      <button v-if=\"isEpub\" @click=\"toggleToC\" type=\"button\" aria-label=\"Table of contents menu\" class=\"inline-flex opacity-80 hover:opacity-100\">\n        <span class=\"material-symbols text-2xl\">menu</span>\n      </button>\n      <button v-if=\"hasSettings\" @click=\"openSettings\" type=\"button\" aria-label=\"Ereader settings\" class=\"mx-4 inline-flex opacity-80 hover:opacity-100\">\n        <span class=\"material-symbols text-1.5xl\">settings</span>\n      </button>\n    </div>\n\n    <div class=\"absolute top-4 left-1/2 transform -translate-x-1/2\">\n      <h1 :data-type=\"ebookType\" class=\"text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden\" style=\"line-height: 1.15; font-weight: 100\">\n        <span style=\"font-weight: 600\">{{ abTitle }}</span>\n        <span v-if=\"abAuthor\" class=\"hidden md:inline\"> – </span>\n        <span v-if=\"abAuthor\" class=\"hidden md:inline\">{{ abAuthor }}</span>\n      </h1>\n    </div>\n\n    <div class=\"absolute top-4 right-4 z-20\">\n      <button @click=\"close\" type=\"button\" aria-label=\"Close ereader\" class=\"inline-flex opacity-80 hover:opacity-100\">\n        <span class=\"material-symbols text-2xl\">close</span>\n      </button>\n    </div>\n\n    <component v-if=\"componentName\" ref=\"readerComponent\" :is=\"componentName\" :library-item=\"selectedLibraryItem\" :player-open=\"!!streamLibraryItem\" :keep-progress=\"keepProgress\" :file-id=\"ebookFileId\" @touchstart=\"touchstart\" @touchend=\"touchend\" @hook:mounted=\"readerMounted\" />\n\n    <!-- TOC side nav -->\n    <div v-if=\"tocOpen\" class=\"w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20\" @click.stop.prevent=\"toggleToC\"></div>\n    <div\n      v-if=\"isEpub\"\n      class=\"w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black group-data-[theme=sepia]:bg-[rgb(244,236,216)] group-data-[theme=sepia]:text-[#5b4636]\"\n      :class=\"tocOpen ? 'translate-x-0' : '-translate-x-96'\"\n      @click.stop.prevent\n    >\n      <div class=\"flex flex-col p-4 h-full\">\n        <div class=\"flex items-center mb-2\">\n          <button @click.stop.prevent=\"toggleToC\" type=\"button\" aria-label=\"Close table of contents\" class=\"inline-flex opacity-80 hover:opacity-100\">\n            <span class=\"material-symbols text-2xl\">arrow_back</span>\n          </button>\n\n          <p class=\"text-lg font-semibold ml-2\">{{ $strings.HeaderTableOfContents }}</p>\n        </div>\n        <form @submit.prevent=\"searchBook\" @click.stop.prevent>\n          <ui-text-input clearable ref=\"input\" @clear=\"searchBook\" v-model=\"searchQuery\" :placeholder=\"$strings.PlaceholderSearch\" custom-input-class=\"text-inherit !bg-inherit\" class=\"h-8 w-full text-sm flex mb-2\" />\n        </form>\n\n        <div class=\"overflow-y-auto\">\n          <div v-if=\"isSearching && !this.searchResults.length\" class=\"w-full h-40 justify-center\">\n            <p class=\"text-center text-xl py-4\">{{ $strings.MessageNoResults }}</p>\n          </div>\n\n          <ul>\n            <li v-for=\"chapter in isSearching ? this.searchResults : chapters\" :key=\"chapter.id\" class=\"py-1\">\n              <a :href=\"chapter.href\" class=\"opacity-80 hover:opacity-100\" @click.prevent=\"goToChapter(chapter.href)\">{{ chapter.title }}</a>\n              <div v-for=\"searchResults in chapter.searchResults\" :key=\"searchResults.cfi\" class=\"text-sm py-1 pl-4\">\n                <a :href=\"searchResults.cfi\" class=\"opacity-50 hover:opacity-100\" @click.prevent=\"goToChapter(searchResults.cfi)\">{{ searchResults.excerpt }}</a>\n              </div>\n\n              <ul v-if=\"chapter.subitems.length\">\n                <li v-for=\"subchapter in chapter.subitems\" :key=\"subchapter.id\" class=\"py-1 pl-4\">\n                  <a :href=\"subchapter.href\" class=\"opacity-80 hover:opacity-100\" @click.prevent=\"goToChapter(subchapter.href)\">{{ subchapter.title }}</a>\n                  <div v-for=\"subChapterSearchResults in subchapter.searchResults\" :key=\"subChapterSearchResults.cfi\" class=\"text-sm py-1 pl-4\">\n                    <a :href=\"subChapterSearchResults.cfi\" class=\"opacity-50 hover:opacity-100\" @click.prevent=\"goToChapter(subChapterSearchResults.cfi)\">{{ subChapterSearchResults.excerpt }}</a>\n                  </div>\n                </li>\n              </ul>\n            </li>\n          </ul>\n        </div>\n      </div>\n    </div>\n\n    <!-- ereader settings modal -->\n    <modals-modal v-model=\"showSettings\" name=\"ereader-settings-modal\" :width=\"500\" :height=\"'unset'\" :processing=\"false\">\n      <template #outer>\n        <div class=\"absolute top-0 left-0 p-5 w-3/4 overflow-hidden\">\n          <p class=\"text-xl md:text-3xl text-white truncate\">{{ $strings.HeaderEreaderSettings }}</p>\n        </div>\n      </template>\n      <div class=\"px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto\" style=\"max-height: 80vh\">\n        <div class=\"flex items-center mb-4\">\n          <div class=\"w-40\">\n            <p class=\"text-lg\">{{ $strings.LabelTheme }}:</p>\n          </div>\n          <ui-toggle-btns v-model=\"ereaderSettings.theme\" :items=\"themeItems.theme\" @input=\"settingsUpdated\" />\n        </div>\n        <div class=\"flex items-center mb-4\">\n          <div class=\"w-40\">\n            <p class=\"text-lg\">{{ $strings.LabelFontFamily }}:</p>\n          </div>\n          <ui-toggle-btns v-model=\"ereaderSettings.font\" :items=\"themeItems.font\" @input=\"settingsUpdated\" />\n        </div>\n        <div class=\"flex items-center mb-4\">\n          <div class=\"w-40\">\n            <p class=\"text-lg\">{{ $strings.LabelFontScale }}:</p>\n          </div>\n          <ui-range-input v-model=\"ereaderSettings.fontScale\" :min=\"5\" :max=\"300\" :step=\"5\" @input=\"settingsUpdated\" />\n        </div>\n        <div class=\"flex items-center mb-4\">\n          <div class=\"w-40\">\n            <p class=\"text-lg\">{{ $strings.LabelLineSpacing }}:</p>\n          </div>\n          <ui-range-input v-model=\"ereaderSettings.lineSpacing\" :min=\"100\" :max=\"300\" :step=\"5\" @input=\"settingsUpdated\" />\n        </div>\n        <div class=\"flex items-center mb-4\">\n          <div class=\"w-40\">\n            <p class=\"text-lg\">{{ $strings.LabelFontBoldness }}:</p>\n          </div>\n          <ui-range-input v-model=\"ereaderSettings.textStroke\" :min=\"0\" :max=\"300\" :step=\"5\" @input=\"settingsUpdated\" />\n        </div>\n        <div class=\"flex items-center\">\n          <div class=\"w-40\">\n            <p class=\"text-lg\">{{ $strings.LabelLayout }}:</p>\n          </div>\n          <ui-toggle-btns v-model=\"ereaderSettings.spread\" :items=\"spreadItems\" @input=\"settingsUpdated\" />\n        </div>\n      </div>\n    </modals-modal>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      touchstartX: 0,\n      touchstartY: 0,\n      touchendX: 0,\n      touchendY: 0,\n      touchstartTime: 0,\n      touchIdentifier: null,\n      chapters: [],\n      isSearching: false,\n      searchResults: [],\n      searchQuery: '',\n      tocOpen: false,\n      showSettings: false,\n      ereaderSettings: {\n        theme: 'dark',\n        font: 'serif',\n        fontScale: 100,\n        lineSpacing: 115,\n        fontBoldness: 100,\n        spread: 'auto',\n        textStroke: 0\n      }\n    }\n  },\n  watch: {\n    show(newVal) {\n      if (newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    show: {\n      get() {\n        return this.$store.state.showEReader\n      },\n      set(val) {\n        this.$store.commit('setShowEReader', val)\n      }\n    },\n    ereaderTheme() {\n      if (this.isEpub) return this.ereaderSettings.theme\n      return 'dark'\n    },\n    spreadItems() {\n      return [\n        {\n          text: this.$strings.LabelLayoutSinglePage,\n          value: 'none'\n        },\n        {\n          text: this.$strings.LabelLayoutSplitPage,\n          value: 'auto'\n        }\n      ]\n    },\n    themeItems() {\n      return {\n        theme: [\n          {\n            text: this.$strings.LabelThemeDark,\n            value: 'dark'\n          },\n          {\n            text: this.$strings.LabelThemeSepia,\n            value: 'sepia'\n          },\n          {\n            text: this.$strings.LabelThemeLight,\n            value: 'light'\n          }\n        ],\n        font: [\n          {\n            text: 'Sans',\n            value: 'sans-serif'\n          },\n          {\n            text: 'Serif',\n            value: 'serif'\n          }\n        ]\n      }\n    },\n    componentName() {\n      if (this.ebookType === 'epub') return 'readers-epub-reader'\n      else if (this.ebookType === 'mobi') return 'readers-mobi-reader'\n      else if (this.ebookType === 'pdf') return 'readers-pdf-reader'\n      else if (this.ebookType === 'comic') return 'readers-comic-reader'\n      return null\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    hasSettings() {\n      return this.isEpub\n    },\n    abTitle() {\n      return this.mediaMetadata.title\n    },\n    abAuthor() {\n      return this.mediaMetadata.authorName\n    },\n    selectedLibraryItem() {\n      return this.$store.state.selectedLibraryItem || {}\n    },\n    media() {\n      return this.selectedLibraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    libraryId() {\n      return this.selectedLibraryItem.libraryId\n    },\n    folderId() {\n      return this.selectedLibraryItem.folderId\n    },\n    ebookFile() {\n      // ebook file id is passed when reading a supplementary ebook\n      if (this.ebookFileId) {\n        return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)\n      }\n      return this.media.ebookFile\n    },\n    ebookFormat() {\n      if (!this.ebookFile) return null\n      // Use file extension for supplementary ebook\n      if (!this.ebookFile.ebookFormat) {\n        return this.ebookFile.metadata.ext.toLowerCase().slice(1)\n      }\n      return this.ebookFile.ebookFormat\n    },\n    ebookType() {\n      if (this.isMobi) return 'mobi'\n      else if (this.isEpub) return 'epub'\n      else if (this.isPdf) return 'pdf'\n      else if (this.isComic) return 'comic'\n      return null\n    },\n    isEpub() {\n      return this.ebookFormat == 'epub'\n    },\n    isMobi() {\n      return this.ebookFormat == 'mobi' || this.ebookFormat == 'azw3'\n    },\n    isPdf() {\n      return this.ebookFormat == 'pdf'\n    },\n    isComic() {\n      return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'\n    },\n    keepProgress() {\n      return this.$store.state.ereaderKeepProgress\n    },\n    ebookFileId() {\n      return this.$store.state.ereaderFileId\n    },\n    isDarkTheme() {\n      return this.ereaderSettings.theme === 'dark'\n    }\n  },\n  methods: {\n    goToChapter(uri) {\n      this.toggleToC()\n      this.$refs.readerComponent.goToChapter(uri)\n    },\n    readerMounted() {\n      if (this.isEpub) {\n        this.loadEreaderSettings()\n      }\n    },\n    settingsUpdated() {\n      this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)\n      localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))\n    },\n    toggleToC() {\n      this.tocOpen = !this.tocOpen\n      this.chapters = this.$refs.readerComponent.chapters\n    },\n    openSettings() {\n      this.showSettings = true\n    },\n    hotkey(action) {\n      if (!this.$refs.readerComponent) return\n\n      if (action === this.$hotkeys.EReader.NEXT_PAGE) {\n        this.next()\n      } else if (action === this.$hotkeys.EReader.PREV_PAGE) {\n        this.prev()\n      } else if (action === this.$hotkeys.EReader.CLOSE) {\n        this.close()\n      }\n    },\n    async searchBook() {\n      if (this.searchQuery.length > 1) {\n        this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery)\n        this.isSearching = true\n      } else {\n        this.isSearching = false\n        this.searchResults = []\n      }\n    },\n    next() {\n      if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()\n    },\n    prev() {\n      if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()\n    },\n    handleGesture() {\n      // Touch must be less than 1s. Must be > 60px drag and X distance > Y distance\n      const touchTimeMs = Date.now() - this.touchstartTime\n      if (touchTimeMs >= 1000) {\n        console.log('Touch too long', touchTimeMs)\n        return\n      }\n\n      const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)\n      const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)\n      const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))\n      if (touchDistance < 60) {\n        return\n      }\n\n      if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {\n        return\n      }\n\n      if (this.touchendX < this.touchstartX) {\n        this.next()\n      }\n      if (this.touchendX > this.touchstartX) {\n        this.prev()\n      }\n    },\n    touchstart(e) {\n      // Ignore rapid touch\n      if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {\n        return\n      }\n\n      this.touchstartX = e.touches[0].screenX\n      this.touchstartY = e.touches[0].screenY\n      this.touchstartTime = Date.now()\n      this.touchIdentifier = e.touches[0].identifier\n    },\n    touchend(e) {\n      if (this.touchIdentifier !== e.changedTouches[0].identifier) {\n        return\n      }\n\n      this.touchendX = e.changedTouches[0].screenX\n      this.touchendY = e.changedTouches[0].screenY\n      this.handleGesture()\n    },\n    registerListeners() {\n      this.$eventBus.$on('reader-hotkey', this.hotkey)\n      document.body.addEventListener('touchstart', this.touchstart)\n      document.body.addEventListener('touchend', this.touchend)\n    },\n    unregisterListeners() {\n      this.$eventBus.$off('reader-hotkey', this.hotkey)\n      document.body.removeEventListener('touchstart', this.touchstart)\n      document.body.removeEventListener('touchend', this.touchend)\n    },\n    loadEreaderSettings() {\n      try {\n        const settings = localStorage.getItem('ereaderSettings')\n        if (settings) {\n          const _ereaderSettings = JSON.parse(settings)\n          for (const key in this.ereaderSettings) {\n            if (_ereaderSettings[key] !== undefined) {\n              this.ereaderSettings[key] = _ereaderSettings[key]\n            }\n          }\n          this.settingsUpdated()\n        }\n      } catch (error) {\n        console.error('Failed to load ereader settings', error)\n      }\n    },\n    init() {\n      this.registerListeners()\n    },\n    close() {\n      this.unregisterListeners()\n      this.isSearching = false\n      this.searchQuery = ''\n      this.show = false\n    }\n  },\n  mounted() {\n    if (this.show) this.init()\n  },\n  beforeDestroy() {\n    this.unregisterListeners()\n  }\n}\n</script>\n\n<style>\n#reader {\n  height: 100%;\n}\n#reader.reader-player-open {\n  height: calc(100% - 164px);\n}\n@media (max-height: 400px) {\n  #reader.reader-player-open {\n    height: 100%;\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/stats/DailyListeningChart.vue",
    "content": "<template>\n  <div class=\"w-96 my-6 mx-auto\">\n    <h1 class=\"text-2xl mb-4\">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>\n    <div class=\"relative w-96 h-72\">\n      <div class=\"absolute top-0 left-0\">\n        <template v-for=\"lbl in yAxisLabels\">\n          <div :key=\"lbl\" :style=\"{ height: lineSpacing + 'px' }\" class=\"flex items-center justify-end\">\n            <p class=\"text-xs font-semibold\">{{ lbl }}</p>\n          </div>\n        </template>\n      </div>\n\n      <template v-for=\"n in 7\">\n        <div :key=\"n\" class=\"absolute pointer-events-none left-0 h-px bg-white/10\" :style=\"{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }\" />\n\n        <div :key=\"`dot-${n}`\" class=\"absolute z-10\" :style=\"{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }\">\n          <ui-tooltip :text=\"last7DaysOfListening[n - 1].minutesListening\" plaintext direction=\"top\">\n            <div class=\"h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125\" />\n          </ui-tooltip>\n        </div>\n      </template>\n\n      <template v-for=\"(line, index) in pointLines\">\n        <div :key=\"`line-${index}`\" class=\"absolute h-0.5 bg-yellow-400 origin-bottom-left pointer-events-none\" :style=\"{ width: line.width + 'px', left: line.x + 'px', bottom: line.y + 'px', transform: `rotate(${line.angle}deg)` }\" />\n      </template>\n\n      <div class=\"absolute -bottom-2 left-0 flex ml-6\">\n        <template v-for=\"dayObj in last7Days\">\n          <div :key=\"dayObj.date\" :style=\"{ width: daySpacing + daySpacing / 14 + 'px' }\">\n            <p class=\"text-sm\">{{ dayObj.dayOfWeekAbbr }}</p>\n          </div>\n        </template>\n      </div>\n    </div>\n    <div class=\"flex justify-between pt-12\">\n      <div>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsWeekListening }}</p>\n        <p class=\"text-5xl font-semibold text-center\" style=\"line-height: 0.85\">{{ $formatNumber(totalMinutesListeningThisWeek) }}</p>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsMinutes }}</p>\n      </div>\n      <div>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsDailyAverage }}</p>\n        <p class=\"text-5xl font-semibold text-center\" style=\"line-height: 0.85\">{{ $formatNumber(averageMinutesPerDay) }}</p>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsMinutes }}</p>\n      </div>\n      <div>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsBestDay }}</p>\n        <p class=\"text-5xl font-semibold text-center\" style=\"line-height: 0.85\">{{ $formatNumber(mostListenedDay) }}</p>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsMinutes }}</p>\n      </div>\n      <div>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsDays }}</p>\n        <p class=\"text-5xl font-semibold text-center\" style=\"line-height: 0.85\">{{ $formatNumber(daysInARow) }}</p>\n        <p class=\"text-sm text-center\">{{ $strings.LabelStatsInARow }}</p>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    listeningStats: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      // test: [111, 120, 4, 156, 273, 76, 12],\n      chartHeight: 288,\n      chartWidth: 384,\n      chartContentWidth: 360,\n      chartContentHeight: 268\n    }\n  },\n  computed: {\n    yAxisLabels() {\n      var lbls = []\n      for (let i = 6; i >= 0; i--) {\n        lbls.push(i * this.yAxisFactor)\n      }\n      return lbls\n    },\n    chartContentMarginLeft() {\n      return this.chartWidth - this.chartContentWidth\n    },\n    chartContentMarginBottom() {\n      return this.chartHeight - this.chartContentHeight\n    },\n    lineSpacing() {\n      return this.chartHeight / 7\n    },\n    daySpacing() {\n      return this.chartContentWidth / 7\n    },\n    linePositions() {\n      var poses = []\n      for (let i = 7; i > 0; i--) {\n        poses.push(i * this.lineSpacing)\n      }\n      poses.push(0)\n      return poses\n    },\n    last7Days() {\n      var days = []\n      for (let i = 6; i >= 0; i--) {\n        var _date = this.$addDaysToToday(i * -1)\n        days.push({\n          dayOfWeek: this.$formatJsDate(_date, 'EEEE'),\n          dayOfWeekAbbr: this.$formatJsDate(_date, 'EEE'),\n          date: this.$formatJsDate(_date, 'yyyy-MM-dd')\n        })\n      }\n      return days\n    },\n    last7DaysOfListening() {\n      var listeningDays = {}\n      var _index = 0\n      this.last7Days.forEach((dayObj) => {\n        listeningDays[_index++] = {\n          dayOfWeek: dayObj.dayOfWeek,\n          // minutesListening: this.test[_index - 1]\n          minutesListening: this.getMinutesListeningForDate(dayObj.date)\n        }\n      })\n      return listeningDays\n    },\n    mostListenedDay() {\n      var sorted = Object.values(this.last7DaysOfListening)\n        .map((dl) => ({ ...dl }))\n        .sort((a, b) => b.minutesListening - a.minutesListening)\n      return sorted[0].minutesListening\n    },\n    yAxisFactor() {\n      var factor = Math.ceil(this.mostListenedDay / 5)\n\n      if (factor > 25) {\n        // Use nearest multiple of 5\n        return Math.ceil(factor / 5) * 5\n      }\n\n      return Math.max(1, factor)\n    },\n    points() {\n      var data = []\n      for (let i = 0; i < 7; i++) {\n        var listeningObj = this.last7DaysOfListening[String(i)]\n        var minutesListening = listeningObj.minutesListening || 0\n        var yPercent = minutesListening / (this.yAxisFactor * 7)\n        data.push({\n          x: 4 + this.chartContentMarginLeft + (this.daySpacing + this.daySpacing / 14) * i,\n          y: this.chartContentMarginBottom + this.chartHeight * yPercent - 2\n        })\n      }\n      return data\n    },\n    pointLines() {\n      var lines = []\n      for (let i = 1; i < 7; i++) {\n        var lastPoint = this.points[i - 1]\n        var nextPoint = this.points[i]\n\n        var x1 = lastPoint.x\n        var x2 = nextPoint.x\n        var y1 = lastPoint.y\n        var y2 = nextPoint.y\n\n        lines.push({\n          x: x1 + 4,\n          y: y1 + 2,\n          angle: this.getAngleBetweenPoints(x1, y1, x2, y2),\n          width: Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) - 2\n        })\n      }\n      return lines\n    },\n    totalMinutesListeningThisWeek() {\n      var _total = 0\n      Object.values(this.last7DaysOfListening).forEach((listeningObj) => (_total += listeningObj.minutesListening))\n      return _total\n    },\n    averageMinutesPerDay() {\n      return Math.round(this.totalMinutesListeningThisWeek / 7)\n    },\n    daysInARow() {\n      var count = 0\n      while (true) {\n        const _date = this.$addDaysToToday(count * -1 - 1)\n        const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')\n\n        if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {\n          // don't require listening today to count towards days in a row, but do count it if already listened today\n          const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')\n          if (this.listeningStatsDays[today]) {\n            count++\n          }\n\n          return count\n        }\n        count++\n\n        if (count > 9999) {\n          console.error('Overflow protection')\n          return 0\n        }\n      }\n    },\n    listeningStatsDays() {\n      return this.listeningStats ? this.listeningStats.days || [] : []\n    }\n  },\n  methods: {\n    getAngleBetweenPoints(cx, cy, ex, ey) {\n      var dy = ey - cy\n      var dx = ex - cx\n      var theta = Math.atan2(dy, dx)\n      theta *= 180 / Math.PI // convert to degrees\n      return theta * -1\n    },\n    getMinutesListeningForDate(date) {\n      if (!this.listeningStats || !this.listeningStats.days) return 0\n      return Math.round((this.listeningStats.days[date] || 0) / 60)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/stats/Heatmap.vue",
    "content": "<template>\n  <div id=\"heatmap\" class=\"w-full\">\n    <div class=\"mx-auto\" :style=\"{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }\" style=\"background-color: rgba(13, 17, 23, 0)\">\n      <p class=\"mb-2 px-1 text-sm text-gray-200\">{{ $getString('MessageDaysListenedInTheLastYear', [daysListenedInTheLastYear]) }}</p>\n      <div class=\"border border-white/25 rounded-sm py-2 w-full\" style=\"background-color: #232323\" :style=\"{ height: innerHeight + 80 + 'px' }\">\n        <div :style=\"{ width: innerWidth + 'px', height: innerHeight + 'px' }\" class=\"ml-10 mt-5 absolute\" @mouseover=\"mouseover\" @mouseout=\"mouseout\">\n          <div v-for=\"dayLabel in dayLabels\" :key=\"dayLabel.label\" :style=\"dayLabel.style\" class=\"absolute top-0 left-0 text-gray-300\">{{ dayLabel.label }}</div>\n\n          <div v-for=\"monthLabel in monthLabels\" :key=\"monthLabel.id\" :style=\"monthLabel.style\" class=\"absolute top-0 left-0 text-gray-300\">{{ monthLabel.label }}</div>\n\n          <div v-for=\"(block, index) in data\" :key=\"block.dateString\" :style=\"block.style\" :data-index=\"index\" class=\"absolute top-0 left-0 h-2.5 w-2.5 rounded-xs\" />\n\n          <div class=\"flex py-2 px-4\" :style=\"{ marginTop: innerHeight + 'px' }\">\n            <div class=\"grow\" />\n            <p style=\"font-size: 10px; line-height: 10px\" class=\"text-gray-400 px-1\">{{ $strings.LabelLess }}</p>\n            <div v-for=\"block in legendBlocks\" :key=\"block.id\" :style=\"block.style\" class=\"h-2.5 w-2.5 rounded-xs\" style=\"margin-left: 1.5px; margin-right: 1.5px\" />\n            <p style=\"font-size: 10px; line-height: 10px\" class=\"text-gray-400 px-1\">{{ $strings.LabelMore }}</p>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    daysListening: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      contentWidth: 0,\n      maxInnerWidth: 0,\n      innerHeight: 13 * 7,\n      blockWidth: 13,\n      data: [],\n      daysListenedInTheLastYear: 0,\n      monthLabels: [],\n      tooltipEl: null,\n      tooltipTextEl: null,\n      tooltipArrowEl: null,\n      showingTooltipIndex: -1,\n      outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],\n      bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']\n      // GH Colors\n      // outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],\n      // bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']\n    }\n  },\n  computed: {\n    weeksToShow() {\n      return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)\n    },\n    innerWidth() {\n      return (this.weeksToShow + 1) * 13\n    },\n    daysToShow() {\n      return this.weeksToShow * 7 + this.dayOfWeekToday\n    },\n    dayOfWeekToday() {\n      return new Date().getDay()\n    },\n    dayLabels() {\n      return [\n        {\n          label: this.$formatJsDate(new Date(2023, 0, 2), 'EEE'),\n          style: {\n            transform: `translate(${-25}px, ${13}px)`,\n            lineHeight: '10px',\n            fontSize: '10px'\n          }\n        },\n        {\n          label: this.$formatJsDate(new Date(2023, 0, 4), 'EEE'),\n          style: {\n            transform: `translate(${-25}px, ${13 * 3}px)`,\n            lineHeight: '10px',\n            fontSize: '10px'\n          }\n        },\n        {\n          label: this.$formatJsDate(new Date(2023, 0, 6), 'EEE'),\n          style: {\n            transform: `translate(${-25}px, ${13 * 5}px)`,\n            lineHeight: '10px',\n            fontSize: '10px'\n          }\n        }\n      ]\n    },\n    legendBlocks() {\n      return [\n        {\n          id: 'legend-0',\n          style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`\n        },\n        {\n          id: 'legend-1',\n          style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`\n        },\n        {\n          id: 'legend-2',\n          style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`\n        },\n        {\n          id: 'legend-3',\n          style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`\n        },\n        {\n          id: 'legend-4',\n          style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`\n        }\n      ]\n    }\n  },\n  methods: {\n    destroyTooltip() {\n      if (this.tooltipEl) this.tooltipEl.remove()\n      this.tooltipEl = null\n      this.showingTooltipIndex = -1\n    },\n    createTooltip() {\n      const tooltip = document.createElement('div')\n      tooltip.className = 'absolute top-0 left-0 rounded-sm bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'\n      tooltip.style.display = 'none'\n      tooltip.id = 'heatmap-tooltip'\n\n      const tooltipText = document.createElement('p')\n      tooltipText.innerText = 'Tooltip'\n      tooltipText.style.fontSize = '10px'\n      tooltipText.style.lineHeight = '10px'\n      tooltip.appendChild(tooltipText)\n\n      const tooltipArrow = document.createElement('div')\n      tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'\n      tooltip.appendChild(tooltipArrow)\n\n      this.tooltipEl = tooltip\n      this.tooltipTextEl = tooltipText\n      this.tooltipArrowEl = tooltipArrow\n\n      document.body.appendChild(this.tooltipEl)\n    },\n    showTooltip(index, block, rect) {\n      if (this.tooltipEl && this.showingTooltipIndex === index) return\n      if (!this.tooltipEl) {\n        this.createTooltip()\n      }\n\n      this.showingTooltipIndex = index\n      this.tooltipEl.style.display = 'block'\n      this.tooltipTextEl.innerHTML = block.value ? this.$getString('MessageHeatmapListeningTimeTooltip', [this.$elapsedPrettyLocalized(block.value, true), block.datePretty]) : this.$getString('MessageHeatmapNoListeningSessions', [block.datePretty])\n\n      const calculateRect = this.tooltipEl.getBoundingClientRect()\n\n      const w = calculateRect.width / 2\n      var left = rect.x - w\n      var offsetX = 0\n      if (left < 0) {\n        offsetX = Math.abs(left)\n        left = 0\n      } else if (rect.x + w > window.innerWidth - 10) {\n        offsetX = window.innerWidth - 10 - (rect.x + w)\n        left += offsetX\n      }\n\n      this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`\n      this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`\n    },\n    hideTooltip() {\n      if (this.showingTooltipIndex >= 0 && this.tooltipEl) {\n        this.tooltipEl.style.display = 'none'\n        this.showingTooltipIndex = -1\n      }\n    },\n    mouseover(e) {\n      if (isNaN(e.target.dataset.index)) {\n        this.hideTooltip()\n        return\n      }\n      var block = this.data[e.target.dataset.index]\n      var rect = e.target.getBoundingClientRect()\n      this.showTooltip(e.target.dataset.index, block, rect)\n    },\n    mouseout(e) {\n      this.hideTooltip()\n    },\n    buildData() {\n      this.data = []\n\n      let maxValue = 0\n      let minValue = 0\n\n      const dates = []\n\n      const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday\n      const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)\n      for (let i = 0; i < numDaysInTheLastYear + 1; i++) {\n        const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)\n        const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')\n\n        if (this.daysListening[dateString] > 0) {\n          this.daysListenedInTheLastYear++\n        }\n\n        const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)\n        if (visibleDayIndex < 0) {\n          continue\n        }\n\n        const dateObj = {\n          col: Math.floor(visibleDayIndex / 7),\n          row: visibleDayIndex % 7,\n          date,\n          dateString,\n          datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),\n          monthString: this.$formatJsDate(date, 'MMM'),\n          dayOfMonth: Number(dateString.split('-').pop()),\n          yearString: dateString.split('-').shift(),\n          value: this.daysListening[dateString] || 0\n        }\n        dates.push(dateObj)\n\n        if (dateObj.value > 0) {\n          if (dateObj.value > maxValue) maxValue = dateObj.value\n          if (!minValue || dateObj.value < minValue) minValue = dateObj.value\n        }\n      }\n      const range = maxValue - minValue + 0.01\n\n      for (const dateObj of dates) {\n        let bgColor = this.bgColors[0]\n        let outlineColor = this.outlineColors[0]\n        if (dateObj.value) {\n          outlineColor = this.outlineColors[1]\n          const percentOfAvg = (dateObj.value - minValue) / range\n          const bgIndex = Math.floor(percentOfAvg * 4) + 1\n          bgColor = this.bgColors[bgIndex] || 'red'\n        }\n\n        this.data.push({\n          ...dateObj,\n          style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`\n        })\n      }\n\n      this.monthLabels = []\n      var lastMonth = null\n      for (let i = 0; i < this.data.length; i++) {\n        if (this.data[i].monthString !== lastMonth) {\n          const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)\n          if (weekOfMonth <= 2) {\n            this.monthLabels.push({\n              id: this.data[i].dateString + '-ml',\n              label: this.data[i].monthString,\n              style: {\n                transform: `translate(${this.data[i].col * 13}px, -15px)`,\n                lineHeight: '10px',\n                fontSize: '10px'\n              }\n            })\n            lastMonth = this.data[i].monthString\n          }\n        }\n      }\n    },\n    init() {\n      const heatmapEl = document.getElementById('heatmap')\n      this.contentWidth = heatmapEl.clientWidth\n      this.maxInnerWidth = this.contentWidth - 52\n      this.daysListenedInTheLastYear = 0\n      this.buildData()\n    }\n  },\n  updated() {},\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/stats/PreviewIcons.vue",
    "content": "<template>\n  <div class=\"flex flex-wrap justify-center mt-6\">\n    <div class=\"flex p-2\">\n      <span class=\"material-symbols text-5xl py-1\">newsstand</span>\n      <div class=\"px-1\">\n        <p class=\"text-4.5xl leading-none font-bold\">{{ $formatNumber(totalItems) }}</p>\n        <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelStatsItemsInLibrary }}</p>\n      </div>\n    </div>\n\n    <div class=\"flex p-2\">\n      <span class=\"material-symbols text-5xl py-1\">show_chart</span>\n      <div class=\"px-1\">\n        <p class=\"text-4.5xl leading-none font-bold\">{{ $formatNumber(totalTime) }}</p>\n        <p class=\"text-xs md:text-sm text-white/80\">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>\n      </div>\n    </div>\n\n    <div v-if=\"isBookLibrary\" class=\"flex p-2\">\n      <span class=\"material-symbols text-5xl py-1\">person</span>\n      <div class=\"px-1\">\n        <p class=\"text-4.5xl leading-none font-bold\">{{ $formatNumber(totalAuthors) }}</p>\n        <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelStatsAuthors }}</p>\n      </div>\n    </div>\n\n    <div class=\"flex p-2\">\n      <span class=\"material-symbols text-5xl pt-1\">insert_drive_file</span>\n      <div class=\"px-1\">\n        <p class=\"text-4.5xl leading-none font-bold\">{{ $formatNumber(totalSizeNum) }}</p>\n        <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>\n      </div>\n    </div>\n\n    <div class=\"flex p-2\">\n      <span class=\"material-symbols text-5xl pt-1\">audio_file</span>\n      <div class=\"px-1\">\n        <p class=\"text-4.5xl leading-none font-bold\">{{ $formatNumber(numAudioTracks) }}</p>\n        <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelStatsAudioTracks }}</p>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryStats: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    currentLibraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isBookLibrary() {\n      return this.currentLibraryMediaType === 'book'\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    totalItems() {\n      return this.libraryStats?.totalItems || 0\n    },\n    totalAuthors() {\n      return this.libraryStats?.totalAuthors || 0\n    },\n    numAudioTracks() {\n      return this.libraryStats?.numAudioTracks || 0\n    },\n    totalDuration() {\n      return this.libraryStats?.totalDuration || 0\n    },\n    totalHours() {\n      return Math.round(this.totalDuration / (60 * 60))\n    },\n    totalSizePretty() {\n      var totalSize = this.libraryStats?.totalSize || 0\n      return this.$bytesPretty(totalSize, 1)\n    },\n    totalSizeNum() {\n      return this.totalSizePretty.split(' ')[0]\n    },\n    totalSizeMod() {\n      return this.totalSizePretty.split(' ')[1]\n    },\n    useOverallHours() {\n      return this.totalHours < 10000\n    },\n    totalTime() {\n      if (this.useOverallHours) return this.totalHours\n      return Math.round(this.totalHours / 24)\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/stats/YearInReview.vue",
    "content": "<template>\n  <div>\n    <div v-if=\"processing\" role=\"img\" :aria-label=\"$strings.MessageLoading\" class=\"max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center\">\n      <widgets-loading-spinner />\n    </div>\n    <img v-else-if=\"dataUrl\" :src=\"dataUrl\" class=\"mx-auto\" :aria-label=\"$getString('LabelPersonalYearReview', [variant + 1])\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    variant: {\n      type: Number,\n      default: 0\n    },\n    year: Number,\n    processing: Boolean\n  },\n  data() {\n    return {\n      canvas: null,\n      dataUrl: null,\n      yearStats: null\n    }\n  },\n  watch: {\n    variant() {\n      this.init()\n    }\n  },\n  methods: {\n    async initCanvas() {\n      if (!this.yearStats) return\n\n      const canvas = document.createElement('canvas')\n      canvas.width = 800\n      canvas.height = 800\n      const ctx = canvas.getContext('2d')\n\n      const createRoundedRect = (x, y, w, h) => {\n        const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)\n        grd1.addColorStop(0, '#44444455')\n        grd1.addColorStop(1, '#ffffff11')\n        ctx.fillStyle = grd1\n        ctx.strokeStyle = '#C0C0C088'\n        ctx.beginPath()\n        ctx.roundRect(x, y, w, h, [20])\n        ctx.fill()\n        ctx.stroke()\n      }\n\n      const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {\n        ctx.fillStyle = color\n        ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`\n        ctx.letterSpacing = letterSpacing\n\n        // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis\n        if (maxWidth) {\n          let txtWidth = ctx.measureText(text).width\n          while (txtWidth > maxWidth) {\n            console.warn(`Text \"${text}\" is greater than max width ${maxWidth} (width:${txtWidth})`)\n            if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time\n            else text = text.slice(0, -3) // First check remove last 3 chars\n            text += '...'\n            txtWidth = ctx.measureText(text).width\n            console.log(`Checking text \"${text}\" (width:${txtWidth})`)\n          }\n        }\n\n        ctx.fillText(text, x, y)\n      }\n\n      const addIcon = (icon, color, fontSize, x, y) => {\n        ctx.fillStyle = color\n        ctx.font = `${fontSize} Material Symbols Rounded`\n        ctx.fillText(icon, x, y)\n      }\n\n      // Bg color\n      ctx.fillStyle = '#232323'\n      ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n      // Cover image tiles\n      const bookCovers = this.yearStats.finishedBooksWithCovers\n      bookCovers.push(...this.yearStats.booksWithCovers)\n\n      let finishedBookCoverImgs = {}\n\n      if (bookCovers.length) {\n        let index = 0\n        ctx.globalAlpha = 0.25\n        ctx.save()\n        ctx.translate(canvas.width / 2, canvas.height / 2)\n        ctx.rotate((-Math.PI / 180) * 25)\n        ctx.translate(-canvas.width / 2, -canvas.height / 2)\n        ctx.translate(-130, -120)\n        for (let x = 0; x < 5; x++) {\n          for (let y = 0; y < 5; y++) {\n            const coverIndex = index % bookCovers.length\n            let libraryItemId = bookCovers[coverIndex]\n            index++\n\n            await new Promise((resolve) => {\n              const img = new Image()\n              img.crossOrigin = 'anonymous'\n              img.addEventListener('load', () => {\n                let sw = img.width\n                if (img.width > img.height) {\n                  sw = img.height\n                }\n                let sx = -(sw - img.width) / 2\n                let sy = -(sw - img.height) / 2\n                ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)\n                resolve()\n                if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {\n                  finishedBookCoverImgs[libraryItemId] = {\n                    img,\n                    sx,\n                    sy,\n                    sw\n                  }\n                }\n              })\n              img.addEventListener('error', () => {\n                resolve()\n              })\n              img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)\n            })\n          }\n        }\n        ctx.restore()\n      }\n\n      const twoColumnWidth = 210\n\n      ctx.globalAlpha = 1\n      ctx.textBaseline = 'middle'\n\n      // Create gradient\n      const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)\n      grd1.addColorStop(0, '#000000aa')\n      grd1.addColorStop(1, '#cd9d49aa')\n      ctx.fillStyle = grd1\n      ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n      // Top Abs icon\n      let tanColor = '#ffdb70'\n      ctx.fillStyle = tanColor\n      ctx.font = '42px absicons'\n      ctx.fillText('\\ue900', 15, 36)\n\n      // Top text\n      addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)\n      addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)\n\n      // Top left box\n      createRoundedRect(50, 100, 340, 160)\n      addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)\n      addText(this.$strings.StatsBooksFinished, '28px', 'normal', tanColor, '0px', 160, 210, twoColumnWidth)\n      const readIconPath = new Path2D()\n      readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })\n      ctx.fillStyle = '#ffffff'\n      ctx.fill(readIconPath)\n\n      // Box top right\n      createRoundedRect(410, 100, 340, 160)\n      addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)\n      addText(this.$strings.StatsSpentListening, '28px', 'normal', tanColor, '0px', 500, 205, twoColumnWidth)\n      addIcon('watch_later', 'white', '52px', 440, 180)\n\n      // Box bottom left\n      createRoundedRect(50, 280, 340, 160)\n      addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)\n      addText(this.$strings.StatsSessions, '28px', 'normal', tanColor, '1px', 160, 390, twoColumnWidth)\n      addIcon('headphones', 'white', '52px', 95, 360)\n\n      // Box bottom right\n      createRoundedRect(410, 280, 340, 160)\n      addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)\n      addText(this.$strings.StatsBooksListenedTo, '28px', 'normal', tanColor, '0px', 500, 390, twoColumnWidth)\n      addIcon('local_library', 'white', '52px', 440, 360)\n\n      if (!this.variant) {\n        // Text stats\n        const topNarrator = this.yearStats.mostListenedNarrator\n        if (topNarrator) {\n          addText(this.$strings.StatsTopNarrator, '24px', 'normal', tanColor, '1px', 70, 520, 330)\n          addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)\n          addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)\n        }\n\n        const topGenre = this.yearStats.topGenres[0]\n        if (topGenre) {\n          addText(this.$strings.StatsTopGenre, '24px', 'normal', tanColor, '1px', 430, 520, 330)\n          addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)\n          addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)\n        }\n\n        const topAuthor = this.yearStats.topAuthors[0]\n        if (topAuthor) {\n          addText(this.$strings.StatsTopAuthor, '24px', 'normal', tanColor, '1px', 70, 670, 330)\n          addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)\n          addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)\n        }\n\n        if (this.yearStats.mostListenedMonth?.time) {\n          const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)\n          const monthName = this.$formatJsDate(jsdate, 'LLLL')\n          addText(this.$strings.StatsTopMonth, '24px', 'normal', tanColor, '1px', 430, 670, 330)\n          addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)\n          addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)\n        }\n      } else if (this.variant === 1) {\n        // Bottom images\n        finishedBookCoverImgs = Object.values(finishedBookCoverImgs)\n        if (finishedBookCoverImgs.length > 0) {\n          ctx.textAlign = 'center'\n          addText(this.$strings.StatsBooksFinishedThisYear, '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)\n\n          for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {\n            let imgToAdd = finishedBookCoverImgs[i]\n            ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)\n          }\n        }\n      } else if (this.variant === 2) {\n        // Text stats\n        if (this.yearStats.topAuthors.length) {\n          addText(this.$strings.StatsTopAuthors, '24px', 'normal', tanColor, '1px', 70, 524)\n          for (let i = 0; i < this.yearStats.topAuthors.length; i++) {\n            addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)\n          }\n        }\n\n        if (this.yearStats.topGenres.length) {\n          addText(this.$strings.StatsTopGenres, '24px', 'normal', tanColor, '1px', 430, 524)\n          for (let i = 0; i < this.yearStats.topGenres.length; i++) {\n            addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)\n          }\n        }\n      }\n\n      this.canvas = canvas\n      this.dataUrl = canvas.toDataURL('png')\n    },\n    refresh() {\n      this.init()\n    },\n    share() {\n      this.canvas.toBlob((blob) => {\n        const file = new File([blob], 'yearinreview.png', { type: blob.type })\n        const shareData = {\n          files: [file]\n        }\n        if (navigator.canShare(shareData)) {\n          navigator\n            .share(shareData)\n            .then(() => {\n              console.log('Share success')\n            })\n            .catch((error) => {\n              console.error('Failed to share', error)\n              if (error.name !== 'AbortError') {\n                this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)\n              }\n            })\n        } else {\n          this.$toast.error(this.$strings.ToastErrorCannotShare)\n        }\n      })\n    },\n    async init() {\n      this.$emit('update:processing', true)\n      this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {\n        console.error('Failed to load stats for year', err)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return null\n      })\n      await this.initCanvas()\n      this.$emit('update:processing', false)\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/stats/YearInReviewBanner.vue",
    "content": "<template>\n  <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-1 sm:p-4 mb-4\">\n    <!-- hack to get icon fonts loaded on init -->\n    <div class=\"h-0 w-0 overflow-hidden opacity-0\">\n      <span class=\"material-symbols\">close</span>\n      <span class=\"abs-icons icon-audiobookshelf\" />\n    </div>\n\n    <div class=\"flex items-center\">\n      <h1 class=\"hidden md:block text-xl font-semibold\">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</h1>\n      <div class=\"hidden md:block grow\" />\n      <ui-btn class=\"w-full md:w-auto\" @click.stop=\"clickShowYearInReview\">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>\n    </div>\n\n    <!-- your year in review -->\n    <div v-if=\"showYearInReview\">\n      <div class=\"w-full h-px bg-slate-200/10 my-4\" />\n\n      <div v-if=\"availableYears.length > 1\" class=\"mb-2 py-2 max-w-[800px] mx-auto\">\n        <!-- year selector -->\n        <ui-dropdown v-model=\"yearInReviewYear\" small :items=\"availableYears\" :disabled=\"processingYearInReview\" class=\"max-w-24\" @input=\"yearInReviewYearChanged\" />\n      </div>\n\n      <div role=\"toolbar\" class=\"flex items-center justify-center mb-2 max-w-[800px] mx-auto\">\n        <!-- previous button -->\n        <ui-btn small :disabled=\"!yearInReviewVariant || processingYearInReview\" :aria-label=\"$strings.ButtonPrevious\" class=\"inline-flex items-center font-semibold\" @click=\"yearInReviewVariant--\">\n          <span class=\"material-symbols text-lg sm:pr-1 py-px sm:py-0\">chevron_left</span>\n          <span class=\"hidden sm:inline-block pr-2\">{{ $strings.ButtonPrevious }}</span>\n        </ui-btn>\n        <!-- share button -->\n        <ui-btn v-if=\"showShareButton\" small :disabled=\"processingYearInReview\" class=\"inline-flex items-center font-semibold ml-1 sm:ml-2\" @click=\"shareYearInReview\">{{ $strings.ButtonShare }} </ui-btn>\n\n        <div class=\"grow\" />\n        <h2 class=\"hidden sm:block text-lg font-semibold\">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</h2>\n        <p class=\"block sm:hidden text-lg font-semibold\">{{ yearInReviewVariant + 1 }}</p>\n        <div class=\"grow\" />\n\n        <!-- refresh button -->\n        <ui-btn small :disabled=\"processingYearInReview\" class=\"inline-flex items-center font-semibold mr-1 sm:mr-2\" @click=\"refreshYearInReview\">\n          <span class=\"hidden sm:inline-block\">{{ $strings.ButtonRefresh }}</span>\n          <span class=\"material-symbols sm:hidden! text-lg py-px\">refresh</span>\n        </ui-btn>\n        <!-- next button -->\n        <ui-btn small :disabled=\"yearInReviewVariant >= 2 || processingYearInReview\" :aria-label=\"$strings.ButtonNext\" class=\"inline-flex items-center font-semibold\" @click=\"yearInReviewVariant++\">\n          <span class=\"hidden sm:inline-block pl-2\">{{ $strings.ButtonNext }}</span>\n          <span class=\"material-symbols text-lg sm:pl-1 py-px sm:py-0\">chevron_right</span>\n        </ui-btn>\n      </div>\n      <stats-year-in-review ref=\"yearInReview\" :variant=\"yearInReviewVariant\" :year=\"yearInReviewYear\" :processing.sync=\"processingYearInReview\" />\n\n      <!-- your year in review short -->\n      <div class=\"w-full max-w-[800px] mx-auto my-4\">\n        <!-- share button -->\n        <ui-btn v-if=\"showShareButton\" small :disabled=\"processingYearInReviewShort\" class=\"inline-flex items-center font-semibold mb-1\" @click=\"shareYearInReviewShort\">{{ $strings.ButtonShare }}</ui-btn>\n        <stats-year-in-review-short ref=\"yearInReviewShort\" :year=\"yearInReviewYear\" :processing.sync=\"processingYearInReviewShort\" />\n      </div>\n\n      <!-- your server in review -->\n      <div v-if=\"isAdminOrUp\" role=\"toolbar\" class=\"w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10\">\n        <div class=\"flex items-center justify-center mb-2\">\n          <!-- previous button -->\n          <ui-btn small :disabled=\"!yearInReviewServerVariant || processingYearInReviewServer\" :aria-label=\"$strings.ButtonPrevious\" class=\"inline-flex items-center font-semibold\" @click=\"yearInReviewServerVariant--\">\n            <span class=\"material-symbols text-lg sm:pr-1 py-px sm:py-0\">chevron_left</span>\n            <span class=\"hidden sm:inline-block pr-2\">{{ $strings.ButtonPrevious }}</span>\n          </ui-btn>\n          <!-- share button -->\n          <ui-btn v-if=\"showShareButton\" small :disabled=\"processingYearInReviewServer\" class=\"inline-flex items-center font-semibold ml-1 sm:ml-2\" @click=\"shareYearInReviewServer\">{{ $strings.ButtonShare }} </ui-btn>\n\n          <div class=\"grow\" />\n          <h2 class=\"hidden sm:block text-lg font-semibold\">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</h2>\n          <p class=\"block sm:hidden text-lg font-semibold\">{{ yearInReviewServerVariant + 1 }}</p>\n          <div class=\"grow\" />\n\n          <!-- refresh button -->\n          <ui-btn small :disabled=\"processingYearInReviewServer\" class=\"inline-flex items-center font-semibold mr-1 sm:mr-2\" @click=\"refreshYearInReviewServer\">\n            <span class=\"hidden sm:inline-block\">{{ $strings.ButtonRefresh }}</span>\n            <span class=\"material-symbols sm:hidden! text-lg py-px\">refresh</span>\n          </ui-btn>\n          <!-- next button -->\n          <ui-btn small :disabled=\"yearInReviewServerVariant >= 2 || processingYearInReviewServer\" :aria-label=\"$strings.ButtonNext\" class=\"inline-flex items-center font-semibold\" @click=\"yearInReviewServerVariant++\">\n            <span class=\"hidden sm:inline-block pl-2\">{{ $strings.ButtonNext }}</span>\n            <span class=\"material-symbols text-lg sm:pl-1 py-px sm:py-0\">chevron_right</span>\n          </ui-btn>\n        </div>\n      </div>\n      <stats-year-in-review-server v-if=\"isAdminOrUp\" ref=\"yearInReviewServer\" :year=\"yearInReviewYear\" :variant=\"yearInReviewServerVariant\" :processing.sync=\"processingYearInReviewServer\" />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      showYearInReview: false,\n      availableYears: [],\n      yearInReviewYear: 0,\n      yearInReviewVariant: 0,\n      yearInReviewServerVariant: 0,\n      processingYearInReview: false,\n      processingYearInReviewShort: false,\n      processingYearInReviewServer: false,\n      showShareButton: false\n    }\n  },\n  computed: {\n    isAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    user() {\n      return this.$store.state.user.user\n    }\n  },\n  methods: {\n    shareYearInReviewServer() {\n      this.$refs.yearInReviewServer.share()\n    },\n    shareYearInReview() {\n      this.$refs.yearInReview.share()\n    },\n    shareYearInReviewShort() {\n      this.$refs.yearInReviewShort.share()\n    },\n    yearInReviewYearChanged() {\n      this.$nextTick(() => {\n        this.refreshYearInReview()\n        this.refreshYearInReviewServer()\n      })\n    },\n    refreshYearInReviewServer() {\n      if (this.$refs.yearInReviewServer != null) {\n        this.$refs.yearInReviewServer.refresh()\n      }\n    },\n    refreshYearInReview() {\n      if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {\n        this.$refs.yearInReview.refresh()\n        this.$refs.yearInReviewShort.refresh()\n      }\n    },\n    clickShowYearInReview() {\n      this.showYearInReview = !this.showYearInReview\n    },\n    getAvailableYears() {\n      if (this.user) {\n        const oldestDate = this.user.createdAt\n        if (oldestDate) {\n          const date = new Date(oldestDate)\n          const oldestYear = date.getFullYear()\n          const currentYear = new Date().getFullYear()\n\n          const years = []\n          for (let year = currentYear; year >= oldestYear; year--) {\n            years.push({ value: year, text: year.toString() })\n          }\n\n          return years\n        }\n      }\n      // Fallback on error\n      return [{ value: this.yearInReviewYear, text: this.yearInReviewYear.toString() }]\n    }\n  },\n  beforeMount() {\n    this.yearInReviewYear = new Date().getFullYear()\n\n    this.availableYears = this.getAvailableYears()\n    const availableYearValues = this.availableYears.map((y) => y.value)\n\n    // When not December show previous year if data is available\n    if (new Date().getMonth() < 11 && availableYearValues.includes(this.yearInReviewYear - 1)) {\n      this.yearInReviewYear--\n    }\n  },\n  mounted() {\n    if (typeof navigator.share !== 'undefined' && navigator.share) {\n      this.showShareButton = true\n    } else {\n      console.warn('Navigator.share not supported')\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/stats/YearInReviewServer.vue",
    "content": "<template>\n  <div>\n    <div v-if=\"processing\" role=\"img\" :aria-label=\"$strings.MessageLoading\" class=\"max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center\">\n      <widgets-loading-spinner />\n    </div>\n    <img v-else-if=\"dataUrl\" :src=\"dataUrl\" class=\"mx-auto\" :aria-label=\"$getString('LabelServerYearReview', [variant + 1])\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    variant: {\n      type: Number,\n      default: 0\n    },\n    processing: Boolean,\n    year: Number\n  },\n  data() {\n    return {\n      canvas: null,\n      dataUrl: null,\n      yearStats: null\n    }\n  },\n  watch: {\n    variant() {\n      this.init()\n    }\n  },\n  methods: {\n    async initCanvas() {\n      if (!this.yearStats) return\n\n      const canvas = document.createElement('canvas')\n      canvas.width = 800\n      canvas.height = 800\n      const ctx = canvas.getContext('2d')\n\n      const createRoundedRect = (x, y, w, h) => {\n        const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)\n        grd1.addColorStop(0, '#44444455')\n        grd1.addColorStop(1, '#ffffff11')\n        ctx.fillStyle = grd1\n        ctx.strokeStyle = '#C0C0C088'\n        ctx.beginPath()\n        ctx.roundRect(x, y, w, h, [20])\n        ctx.fill()\n        ctx.stroke()\n      }\n\n      const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {\n        ctx.fillStyle = color\n        ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`\n        ctx.letterSpacing = letterSpacing\n\n        // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis\n        if (maxWidth) {\n          let txtWidth = ctx.measureText(text).width\n          while (txtWidth > maxWidth) {\n            console.warn(`Text \"${text}\" is greater than max width ${maxWidth} (width:${txtWidth})`)\n            if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time\n            else text = text.slice(0, -3) // First check remove last 3 chars\n            text += '...'\n            txtWidth = ctx.measureText(text).width\n            console.log(`Checking text \"${text}\" (width:${txtWidth})`)\n          }\n        }\n\n        ctx.fillText(text, x, y)\n      }\n\n      // Bg color\n      ctx.fillStyle = '#232323'\n      ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n      // Cover image tiles\n      let imgsToAdd = {}\n\n      if (this.yearStats.booksAddedWithCovers.length) {\n        let index = 0\n        ctx.globalAlpha = 0.25\n        ctx.save()\n        ctx.translate(canvas.width / 2, canvas.height / 2)\n        ctx.rotate((-Math.PI / 180) * 25)\n        ctx.translate(-canvas.width / 2, -canvas.height / 2)\n        ctx.translate(-130, -120)\n        for (let x = 0; x < 5; x++) {\n          for (let y = 0; y < 5; y++) {\n            const coverIndex = index % this.yearStats.booksAddedWithCovers.length\n            let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]\n            index++\n\n            await new Promise((resolve) => {\n              const img = new Image()\n              img.crossOrigin = 'anonymous'\n              img.addEventListener('load', () => {\n                let sw = img.width\n                if (img.width > img.height) {\n                  sw = img.height\n                }\n                let sx = -(sw - img.width) / 2\n                let sy = -(sw - img.height) / 2\n                ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)\n                if (!imgsToAdd[libraryItemId]) {\n                  imgsToAdd[libraryItemId] = {\n                    img,\n                    sx,\n                    sy,\n                    sw\n                  }\n                }\n                resolve()\n              })\n              img.addEventListener('error', () => {\n                resolve()\n              })\n              img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)\n            })\n          }\n        }\n        ctx.restore()\n      }\n\n      const threeColumnTextWidth = 200\n\n      ctx.globalAlpha = 1\n      ctx.textBaseline = 'middle'\n\n      // Create gradient\n      const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)\n      grd1.addColorStop(0, '#000000aa')\n      grd1.addColorStop(1, '#cd9d49aa')\n      ctx.fillStyle = grd1\n      ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n      // Top Abs icon\n      let tanColor = '#ffdb70'\n      ctx.fillStyle = tanColor\n      ctx.font = '42px absicons'\n      ctx.fillText('\\ue900', 15, 36)\n\n      // Top text\n      addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)\n      addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)\n\n      // Top left box\n      createRoundedRect(40, 100, 230, 100)\n      ctx.textAlign = 'center'\n      addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)\n      addText(this.$strings.StatsBooksAdded, '18px', 'normal', tanColor, '0px', 155, 170, threeColumnTextWidth)\n\n      // Box top right\n      createRoundedRect(285, 100, 230, 100)\n      addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)\n      addText(this.$strings.StatsAuthorsAdded, '18px', 'normal', tanColor, '0px', 400, 170, threeColumnTextWidth)\n\n      // Box bottom left\n      createRoundedRect(530, 100, 230, 100)\n      addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)\n      addText(this.$strings.StatsSessions, '18px', 'normal', tanColor, '1px', 645, 170, threeColumnTextWidth)\n\n      // Text stats\n      if (this.yearStats.totalBooksAddedSize) {\n        addText(this.$strings.StatsCollectionGrewTo, '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)\n        addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)\n        addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)\n      }\n\n      if (this.yearStats.totalBooksAddedDuration) {\n        addText(this.$strings.StatsTotalDuration, '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)\n        addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)\n        addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)\n      }\n\n      if (!this.variant) {\n        // Bottom images\n        imgsToAdd = Object.values(imgsToAdd)\n        if (imgsToAdd.length > 0) {\n          addText(this.$strings.StatsBooksAdditional, '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)\n\n          for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {\n            let imgToAdd = imgsToAdd[i]\n            ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)\n          }\n        }\n      } else if (this.variant === 1) {\n        // Text stats\n        ctx.textAlign = 'left'\n        if (this.yearStats.topAuthors.length) {\n          addText(this.$strings.StatsTopAuthors, '24px', 'normal', tanColor, '1px', 70, 549, 330)\n          for (let i = 0; i < this.yearStats.topAuthors.length; i++) {\n            addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)\n          }\n        }\n\n        if (this.yearStats.topNarrators.length) {\n          addText(this.$strings.StatsTopNarrators, '24px', 'normal', tanColor, '1px', 430, 549)\n          for (let i = 0; i < this.yearStats.topNarrators.length; i++) {\n            addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)\n          }\n        }\n      } else if (this.variant === 2) {\n        // Text stats\n        ctx.textAlign = 'left'\n        if (this.yearStats.topAuthors.length) {\n          addText(this.$strings.StatsTopAuthors, '24px', 'normal', tanColor, '1px', 70, 549, 330)\n          for (let i = 0; i < this.yearStats.topAuthors.length; i++) {\n            addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)\n          }\n        }\n\n        if (this.yearStats.topGenres.length) {\n          addText(this.$strings.StatsTopGenres, '24px', 'normal', tanColor, '1px', 430, 549)\n          for (let i = 0; i < this.yearStats.topGenres.length; i++) {\n            addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)\n          }\n        }\n      }\n\n      this.canvas = canvas\n      this.dataUrl = canvas.toDataURL('png')\n    },\n    share() {\n      this.canvas.toBlob((blob) => {\n        const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })\n        const shareData = {\n          files: [file]\n        }\n        if (navigator.canShare(shareData)) {\n          navigator\n            .share(shareData)\n            .then(() => {\n              console.log('Share success')\n            })\n            .catch((error) => {\n              console.error('Failed to share', error)\n              if (error.name !== 'AbortError') {\n                this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)\n              }\n            })\n        } else {\n          this.$toast.error(this.$strings.ToastErrorCannotShare)\n        }\n      })\n    },\n    refresh() {\n      this.init()\n    },\n    async init() {\n      this.$emit('update:processing', true)\n      this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {\n        console.error('Failed to load stats for year', err)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return null\n      })\n      await this.initCanvas()\n      this.$emit('update:processing', false)\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/stats/YearInReviewShort.vue",
    "content": "<template>\n  <div>\n    <div v-if=\"processing\" class=\"max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center\">\n      <widgets-loading-spinner />\n    </div>\n    <img v-else-if=\"dataUrl\" :src=\"dataUrl\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    processing: Boolean,\n    year: Number\n  },\n  data() {\n    return {\n      canvas: null,\n      dataUrl: null,\n      yearStats: null\n    }\n  },\n  methods: {\n    async initCanvas() {\n      if (!this.yearStats) return\n\n      const canvas = document.createElement('canvas')\n      canvas.width = 600\n      canvas.height = 200\n      const ctx = canvas.getContext('2d')\n\n      const createRoundedRect = (x, y, w, h) => {\n        const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)\n        grd1.addColorStop(0, '#44444455')\n        grd1.addColorStop(1, '#ffffff11')\n        ctx.fillStyle = grd1\n        ctx.strokeStyle = '#C0C0C088'\n        ctx.beginPath()\n        ctx.roundRect(x, y, w, h, [20])\n        ctx.fill()\n        ctx.stroke()\n      }\n\n      const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {\n        ctx.fillStyle = color\n        ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`\n        ctx.letterSpacing = letterSpacing\n\n        // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis\n        if (maxWidth) {\n          let txtWidth = ctx.measureText(text).width\n          while (txtWidth > maxWidth) {\n            console.warn(`Text \"${text}\" is greater than max width ${maxWidth} (width:${txtWidth})`)\n            if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time\n            else text = text.slice(0, -3) // First check remove last 3 chars\n            text += '...'\n            txtWidth = ctx.measureText(text).width\n            console.log(`Checking text \"${text}\" (width:${txtWidth})`)\n          }\n        }\n\n        ctx.fillText(text, x, y)\n      }\n\n      const addIcon = (icon, color, fontSize, x, y) => {\n        ctx.fillStyle = color\n        ctx.font = `${fontSize} Material Symbols Rounded`\n        ctx.fillText(icon, x, y)\n      }\n\n      // Bg color\n      ctx.fillStyle = '#232323'\n      ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n      // Cover image tiles\n      const bookCovers = this.yearStats.finishedBooksWithCovers\n      bookCovers.push(...this.yearStats.booksWithCovers)\n\n      if (bookCovers.length) {\n        let index = 0\n        ctx.globalAlpha = 0.25\n        ctx.save()\n        ctx.translate(canvas.width / 2, canvas.height / 2)\n        ctx.rotate((-Math.PI / 180) * 25)\n        ctx.translate(-canvas.width / 2, -canvas.height / 2)\n        ctx.translate(-10, -90)\n        for (let x = 0; x < 4; x++) {\n          for (let y = 0; y < 3; y++) {\n            const coverIndex = index % bookCovers.length\n            let libraryItemId = bookCovers[coverIndex]\n            index++\n\n            await new Promise((resolve) => {\n              const img = new Image()\n              img.crossOrigin = 'anonymous'\n              img.addEventListener('load', () => {\n                let sw = img.width\n                if (img.width > img.height) {\n                  sw = img.height\n                }\n                let sx = -(sw - img.width) / 2\n                let sy = -(sw - img.height) / 2\n                ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)\n                resolve()\n              })\n              img.addEventListener('error', () => {\n                resolve()\n              })\n              img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)\n            })\n          }\n        }\n        ctx.restore()\n      }\n\n      const twoColumnWidth = 180\n\n      ctx.globalAlpha = 1\n      ctx.textBaseline = 'middle'\n\n      // Create gradient\n      const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)\n      grd1.addColorStop(0, '#000000aa')\n      grd1.addColorStop(1, '#cd9d49aa')\n      ctx.fillStyle = grd1\n      ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n      // Top Abs icon\n      let tanColor = '#ffdb70'\n      ctx.fillStyle = tanColor\n      ctx.font = '42px absicons'\n      ctx.fillText('\\ue900', 15, 36)\n\n      // Top text\n      addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)\n      addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)\n\n      // Top left box\n      createRoundedRect(15, 75, 280, 110)\n      addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)\n      addText(this.$strings.StatsBooksFinished, '20px', 'normal', tanColor, '0px', 105, 155, twoColumnWidth)\n      const readIconPath = new Path2D()\n      readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })\n      ctx.fillStyle = '#ffffff'\n      ctx.fill(readIconPath)\n\n      createRoundedRect(305, 75, 280, 110)\n      addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)\n      addText(this.$strings.StatsBooksListenedTo, '20px', 'normal', tanColor, '0px', 400, 155, twoColumnWidth)\n      addIcon('local_library', 'white', '42px', 345, 130)\n\n      this.canvas = canvas\n      this.dataUrl = canvas.toDataURL('png')\n    },\n    share() {\n      this.canvas.toBlob((blob) => {\n        const file = new File([blob], 'yearinreviewshort.png', { type: blob.type })\n        const shareData = {\n          files: [file]\n        }\n        if (navigator.canShare(shareData)) {\n          navigator\n            .share(shareData)\n            .then(() => {\n              console.log('Share success')\n            })\n            .catch((error) => {\n              console.error('Failed to share', error)\n              if (error.name !== 'AbortError') {\n                this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)\n              }\n            })\n        } else {\n          this.$toast.error(this.$strings.ToastErrorCannotShare)\n        }\n      })\n    },\n    refresh() {\n      this.init()\n    },\n    async init() {\n      this.$emit('update:processing', true)\n      this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {\n        console.error('Failed to load stats for year', err)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return null\n      })\n      await this.initCanvas()\n      this.$emit('update:processing', false)\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/ApiKeysTable.vue",
    "content": "<template>\n  <div>\n    <div class=\"text-center\">\n      <table v-if=\"apiKeys.length > 0\" id=\"api-keys\">\n        <tr>\n          <th>{{ $strings.LabelName }}</th>\n          <th class=\"w-44\">{{ $strings.LabelApiKeyUser }}</th>\n          <th class=\"w-32\">{{ $strings.LabelExpiresAt }}</th>\n          <th class=\"w-32\">{{ $strings.LabelCreatedAt }}</th>\n          <th class=\"w-32\"></th>\n        </tr>\n        <tr v-for=\"apiKey in apiKeys\" :key=\"apiKey.id\" :class=\"apiKey.isActive ? '' : 'bg-error/10!'\">\n          <td>\n            <div class=\"flex items-center\">\n              <p class=\"pl-2 truncate\">{{ apiKey.name }}</p>\n            </div>\n          </td>\n          <td class=\"text-xs\">\n            <nuxt-link v-if=\"apiKey.user\" :to=\"`/config/users/${apiKey.user.id}`\" class=\"text-xs hover:underline\">\n              {{ apiKey.user.username }}\n            </nuxt-link>\n            <p v-else class=\"text-xs\">Error</p>\n          </td>\n          <td class=\"text-xs\">\n            <p v-if=\"apiKey.expiresAt\" class=\"text-xs\" :title=\"apiKey.expiresAt\">{{ getExpiresAtText(apiKey) }}</p>\n            <p v-else class=\"text-xs\">{{ $strings.LabelExpiresNever }}</p>\n          </td>\n          <td class=\"text-xs font-mono\">\n            <ui-tooltip direction=\"top\" :text=\"$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)\">\n              {{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}\n            </ui-tooltip>\n          </td>\n          <td class=\"py-0\">\n            <div class=\"w-full flex justify-left\">\n              <div class=\"h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer\" @click.stop=\"editApiKey(apiKey)\">\n                <button type=\"button\" :aria-label=\"$strings.ButtonEdit\" class=\"material-symbols text-base\">edit</button>\n              </div>\n              <div class=\"h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer\" @click.stop=\"deleteApiKeyClick(apiKey)\">\n                <button type=\"button\" :aria-label=\"$strings.ButtonDelete\" class=\"material-symbols text-base\">delete</button>\n              </div>\n            </div>\n          </td>\n        </tr>\n      </table>\n      <p v-else class=\"text-base text-gray-300 py-4\">{{ $strings.LabelNoApiKeys }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      apiKeys: [],\n      isDeletingApiKey: false\n    }\n  },\n  computed: {\n    dateFormat() {\n      return this.$store.state.serverSettings.dateFormat\n    },\n    timeFormat() {\n      return this.$store.state.serverSettings.timeFormat\n    }\n  },\n  methods: {\n    getExpiresAtText(apiKey) {\n      if (new Date(apiKey.expiresAt).getTime() < Date.now()) {\n        return this.$strings.LabelExpired\n      }\n      return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)\n    },\n    deleteApiKeyClick(apiKey) {\n      if (this.isDeletingApiKey) return\n\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteApiKey', [apiKey.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteApiKey(apiKey)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteApiKey(apiKey) {\n      this.isDeletingApiKey = true\n      this.$axios\n        .$delete(`/api/api-keys/${apiKey.id}`)\n        .then((data) => {\n          if (data.error) {\n            this.$toast.error(data.error)\n          } else {\n            this.removeApiKey(apiKey.id)\n            this.$emit('numApiKeys', this.apiKeys.length)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to delete apiKey', error)\n          this.$toast.error(this.$strings.ToastFailedToDelete)\n        })\n        .finally(() => {\n          this.isDeletingApiKey = false\n        })\n    },\n    editApiKey(apiKey) {\n      this.$emit('edit', apiKey)\n    },\n    addApiKey(apiKey) {\n      this.apiKeys.push(apiKey)\n    },\n    removeApiKey(apiKeyId) {\n      this.apiKeys = this.apiKeys.filter((a) => a.id !== apiKeyId)\n    },\n    updateApiKey(apiKey) {\n      this.apiKeys = this.apiKeys.map((a) => (a.id === apiKey.id ? apiKey : a))\n    },\n    loadApiKeys() {\n      this.$axios\n        .$get('/api/api-keys')\n        .then((res) => {\n          this.apiKeys = res.apiKeys.sort((a, b) => {\n            return a.createdAt - b.createdAt\n          })\n          this.$emit('numApiKeys', this.apiKeys.length)\n        })\n        .catch((error) => {\n          console.error('Failed to load apiKeys', error)\n        })\n    }\n  },\n  mounted() {\n    this.loadApiKeys()\n  }\n}\n</script>\n\n<style>\n#api-keys {\n  table-layout: fixed;\n  border-collapse: collapse;\n  border: 1px solid #474747;\n  width: 100%;\n}\n\n#api-keys td,\n#api-keys th {\n  /* border: 1px solid #2e2e2e; */\n  padding: 8px 8px;\n  text-align: left;\n}\n\n#api-keys td.py-0 {\n  padding: 0px 8px;\n}\n\n#api-keys tr:nth-child(even) {\n  background-color: #373838;\n}\n\n#api-keys tr:nth-child(odd) {\n  background-color: #2f2f2f;\n}\n\n#api-keys tr:hover {\n  background-color: #444;\n}\n\n#api-keys th {\n  font-size: 0.8rem;\n  font-weight: 600;\n  padding-top: 5px;\n  padding-bottom: 5px;\n  background-color: #272727;\n}\n</style>\n"
  },
  {
    "path": "client/components/tables/AudioTracksTableRow.vue",
    "content": "<template>\n  <tr>\n    <td class=\"text-center\">\n      <p>{{ track.index }}</p>\n    </td>\n    <td class=\"font-sans\">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>\n    <td v-if=\"!showFullPath\" class=\"hidden lg:table-cell\">\n      {{ track.audioFile.codec || '' }}\n    </td>\n    <td v-if=\"!showFullPath\" class=\"hidden xl:table-cell\">\n      {{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}\n    </td>\n    <td class=\"hidden md:table-cell\">\n      {{ $bytesPretty(track.metadata.size) }}\n    </td>\n    <td class=\"hidden sm:table-cell\">\n      {{ $secondsToTimestamp(track.duration) }}\n    </td>\n    <td v-if=\"contextMenuItems.length\" class=\"text-center\">\n      <ui-context-menu-dropdown :items=\"contextMenuItems\" :menu-width=\"110\" @action=\"contextMenuAction\" />\n    </td>\n  </tr>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItemId: String,\n    showFullPath: Boolean,\n    track: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userIsAdmin() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    contextMenuItems() {\n      const items = []\n      if (this.userCanDownload) {\n        items.push({\n          text: this.$strings.LabelDownload,\n          action: 'download'\n        })\n      }\n\n      if (this.userCanDelete) {\n        items.push({\n          text: this.$strings.ButtonDelete,\n          action: 'delete'\n        })\n      }\n\n      if (this.userIsAdmin) {\n        items.push({\n          text: this.$strings.LabelMoreInfo,\n          action: 'more'\n        })\n      }\n      return items\n    },\n    downloadUrl() {\n      return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?token=${this.userToken}`\n    }\n  },\n  methods: {\n    contextMenuAction({ action }) {\n      if (action === 'delete') {\n        this.deleteLibraryFile()\n      } else if (action === 'download') {\n        this.downloadLibraryFile()\n      } else if (action === 'more') {\n        this.$emit('showMore', this.track.audioFile)\n      }\n    },\n    deleteLibraryFile() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteFile,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$axios\n              .$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastDeleteFileSuccess)\n              })\n              .catch((error) => {\n                console.error('Failed to delete file', error)\n                this.$toast.error(this.$strings.ToastDeleteFileFailed)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    downloadLibraryFile() {\n      this.$downloadFile(this.downloadUrl, this.track.metadata.filename)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/BackupsTable.vue",
    "content": "<template>\n  <div class=\"text-center mt-4 relative\">\n    <div class=\"flex py-4\">\n      <ui-file-input ref=\"fileInput\" class=\"mr-2\" accept=\".audiobookshelf\" @change=\"backupUploaded\">{{ $strings.ButtonUploadBackup }}</ui-file-input>\n      <div class=\"grow\" />\n      <ui-btn :loading=\"isBackingUp\" @click=\"clickCreateBackup\">{{ $strings.ButtonCreateBackup }}</ui-btn>\n    </div>\n    <div class=\"relative\">\n      <table id=\"backups\">\n        <tr>\n          <th>{{ $strings.LabelFile }}</th>\n          <th class=\"hidden sm:table-cell w-32 md:w-56\">{{ $strings.LabelDatetime }}</th>\n          <th class=\"hidden sm:table-cell w-20 md:w-28\">{{ $strings.LabelSize }}</th>\n          <th class=\"w-36\"></th>\n        </tr>\n        <tr v-for=\"backup in backups\" :key=\"backup.id\" :class=\"!backup.serverVersion ? 'bg-error/10' : ''\">\n          <td>\n            <p class=\"truncate text-xs sm:text-sm md:text-base\">/{{ backup.path.replace(/\\\\/g, '/') }}</p>\n          </td>\n          <td class=\"hidden sm:table-cell font-sans text-sm\">{{ $formatDatetime(backup.createdAt, dateFormat, timeFormat) }}</td>\n          <td class=\"hidden sm:table-cell font-mono md:text-sm text-xs\">{{ $bytesPretty(backup.fileSize) }}</td>\n          <td>\n            <div class=\"w-full flex flex-row items-center justify-center\">\n              <ui-btn v-if=\"backup.serverVersion && backup.key\" small color=\"bg-primary\" @click=\"applyBackup(backup)\">{{ $strings.ButtonRestore }}</ui-btn>\n              <ui-tooltip v-else text=\"This backup was created with an old version of audiobookshelf no longer supported\" direction=\"bottom\" class=\"mx-2 flex items-center\">\n                <span class=\"material-symbols text-2xl text-error\">error_outline</span>\n              </ui-tooltip>\n\n              <button aria-label=\"Download backup\" class=\"inline-flex material-symbols text-xl mx-1 mt-1 text-white/70 hover:text-white/100\" @click.stop=\"downloadBackup(backup)\">download</button>\n\n              <button aria-label=\"Delete backup\" class=\"inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error\" @click.stop=\"deleteBackupClick(backup)\">delete</button>\n            </div>\n          </td>\n        </tr>\n        <tr v-if=\"!backups.length\" class=\"staticrow\">\n          <td colspan=\"4\" class=\"text-lg\">{{ $strings.MessageNoBackups }}</td>\n        </tr>\n      </table>\n      <div v-show=\"processing\" class=\"absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n\n    <prompt-dialog v-model=\"showConfirmApply\" :width=\"675\">\n      <div v-if=\"selectedBackup\" class=\"px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300\">\n        <p class=\"text-error text-lg font-semibold\">{{ $strings.MessageImportantNotice }}</p>\n        <p class=\"text-base py-1\" v-html=\"$strings.MessageRestoreBackupWarning\" />\n\n        <p class=\"text-lg text-center my-8\">{{ $strings.MessageRestoreBackupConfirm }} {{ $formatDatetime(selectedBackup.createdAt, dateFormat, timeFormat) }}?</p>\n        <div class=\"flex px-1 items-center\">\n          <ui-btn color=\"bg-primary\" @click=\"showConfirmApply = false\">{{ $strings.ButtonNevermind }}</ui-btn>\n          <div class=\"grow\" />\n          <ui-btn color=\"bg-success\" @click=\"confirm\">{{ $strings.ButtonRestore }}</ui-btn>\n        </div>\n      </div>\n    </prompt-dialog>\n\n    <div v-if=\"isApplyingBackup\" class=\"absolute inset-0 w-full h-full flex items-center justify-center bg-black/20 rounded-md\">\n      <ui-loading-indicator />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      showConfirmApply: false,\n      selectedBackup: null,\n      isBackingUp: false,\n      isApplyingBackup: false,\n      processing: false,\n      backups: []\n    }\n  },\n  computed: {\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    downloadBackup(backup) {\n      this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)\n    },\n    confirm() {\n      this.showConfirmApply = false\n      this.isApplyingBackup = true\n\n      this.$axios\n        .$get(`/api/backups/${this.selectedBackup.id}/apply`)\n        .then(() => {\n          location.replace('/config/backups?backup=1')\n        })\n        .catch((error) => {\n          console.error('Failed to apply backup', error)\n          const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.isApplyingBackup = false\n        })\n    },\n    deleteBackupClick(backup) {\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteBackup(backup)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteBackup(backup) {\n      this.processing = true\n      this.$axios\n        .$delete(`/api/backups/${backup.id}`)\n        .then((data) => {\n          this.setBackups(data.backups || [])\n          this.$toast.success(this.$strings.ToastBackupDeleteSuccess)\n        })\n        .catch((error) => {\n          console.error(error)\n          this.$toast.error(this.$strings.ToastBackupDeleteFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    applyBackup(backup) {\n      this.selectedBackup = backup\n      this.showConfirmApply = true\n    },\n    clickCreateBackup() {\n      this.isBackingUp = true\n      this.$axios\n        .$post('/api/backups')\n        .then((data) => {\n          this.isBackingUp = false\n          this.$toast.success(this.$strings.ToastBackupCreateSuccess)\n          this.setBackups(data.backups || [])\n        })\n        .catch((error) => {\n          this.isBackingUp = false\n          console.error('Failed', error)\n          this.$toast.error(this.$strings.ToastBackupCreateFailed)\n        })\n    },\n    backupUploaded(file) {\n      var form = new FormData()\n      form.set('file', file)\n\n      this.processing = true\n\n      this.$axios\n        .$post('/api/backups/upload', form)\n        .then((data) => {\n          this.setBackups(data.backups || [])\n          this.$toast.success(this.$strings.ToastBackupUploadSuccess)\n          this.processing = false\n        })\n        .catch((error) => {\n          console.error(error)\n          var errorMessage = error.response && error.response.data ? error.response.data : this.$strings.ToastBackupUploadFailed\n          this.$toast.error(errorMessage)\n          this.processing = false\n        })\n    },\n    setBackups(backups) {\n      backups.sort((a, b) => b.createdAt - a.createdAt)\n      this.backups = backups\n    },\n    loadBackups() {\n      this.processing = true\n      this.$axios\n        .$get('/api/backups')\n        .then((data) => {\n          this.$emit('loaded', data)\n          this.setBackups(data.backups || [])\n        })\n        .catch((error) => {\n          console.error('Failed to load backups', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    }\n  },\n  mounted() {\n    this.loadBackups()\n    if (this.$route.query.backup) {\n      this.$toast.success(this.$strings.ToastBackupAppliedSuccess)\n    }\n  }\n}\n</script>\n\n<style>\n#backups {\n  table-layout: fixed;\n  border-collapse: collapse;\n  width: 100%;\n}\n\n#backups td,\n#backups th {\n  border: 1px solid #2e2e2e;\n  padding: 8px 8px;\n  text-align: left;\n}\n\n#backups tr.staticrow td {\n  text-align: center;\n}\n\n#backups tr:nth-child(even):not(.bg-error) {\n  background-color: #3a3a3a;\n}\n\n#backups tr:not(.staticrow):not(.bg-error):hover {\n  background-color: #444;\n}\n\n#backups th {\n  font-size: 0.8rem;\n  font-weight: 600;\n  padding-top: 5px;\n  padding-bottom: 5px;\n  background-color: #333;\n}\n</style>\n"
  },
  {
    "path": "client/components/tables/ChaptersTable.vue",
    "content": "<template>\n  <div class=\"w-full my-2\">\n    <div class=\"w-full bg-primary px-6 py-2 flex items-center cursor-pointer\" @click.stop=\"clickBar\">\n      <p class=\"pr-4\">{{ $strings.HeaderChapters }}</p>\n      <span class=\"bg-black-400 rounded-xl py-1 px-2 text-sm font-mono\">{{ chapters.length }}</span>\n      <div class=\"grow\" />\n      <ui-btn v-if=\"userCanUpdate\" small :to=\"`/audiobook/${libraryItemId}/chapters`\" color=\"bg-primary\" class=\"mr-2\" @click=\"clickEditChapters\">{{ $strings.ButtonEditChapters }}</ui-btn>\n      <div v-if=\"!keepOpen\" class=\"cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500\" :class=\"expanded ? 'transform rotate-180' : ''\">\n        <span class=\"material-symbols text-4xl\">&#xe313;</span>\n      </div>\n    </div>\n    <transition name=\"slide\">\n      <table class=\"text-sm tracksTable\" v-show=\"expanded || keepOpen\">\n        <tr>\n          <th class=\"text-left w-16\"><span class=\"px-4\">Id</span></th>\n          <th class=\"text-left\">{{ $strings.LabelTitle }}</th>\n          <th class=\"text-center\">{{ $strings.LabelStart }}</th>\n          <th class=\"text-center\">{{ $strings.LabelDuration }}</th>\n        </tr>\n        <tr v-for=\"chapter in chapters\" :key=\"chapter.id\">\n          <td class=\"text-left\">\n            <p class=\"px-4\">{{ chapter.id }}</p>\n          </td>\n          <td dir=\"auto\">\n            {{ chapter.title }}\n          </td>\n          <td class=\"font-mono text-center hover:underline cursor-pointer\" @click.stop=\"goToTimestamp(chapter.start)\">\n            {{ $secondsToTimestamp(chapter.start) }}\n          </td>\n          <td class=\"font-mono text-center\">\n            {{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}\n          </td>\n        </tr>\n      </table>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    keepOpen: Boolean\n  },\n  data() {\n    return {\n      expanded: false\n    }\n  },\n  computed: {\n    libraryItemId() {\n      return this.libraryItem.id\n    },\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    metadata() {\n      return this.media.metadata || {}\n    },\n    chapters() {\n      return this.media.chapters || []\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    }\n  },\n  methods: {\n    clickBar() {\n      this.expanded = !this.expanded\n    },\n    goToTimestamp(time) {\n      const queueItem = {\n        libraryItemId: this.libraryItemId,\n        libraryId: this.libraryItem.libraryId,\n        episodeId: null,\n        title: this.metadata.title,\n        subtitle: this.metadata.authors.map((au) => au.name).join(', '),\n        caption: '',\n        duration: this.media.duration || null,\n        coverPath: this.media.coverPath || null\n      }\n\n      if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {\n        this.$eventBus.$emit('play-item', {\n          libraryItemId: this.libraryItemId,\n          episodeId: null,\n          startTime: time,\n          queueItems: [queueItem]\n        })\n      } else {\n        const payload = {\n          message: `Start playback for \"${this.metadata.title}\" at ${this.$secondsToTimestamp(time)}?`,\n          callback: (confirmed) => {\n            if (confirmed) {\n              this.$eventBus.$emit('play-item', {\n                libraryItemId: this.libraryItemId,\n                episodeId: null,\n                startTime: time,\n                queueItems: [queueItem]\n              })\n            }\n          },\n          type: 'yesNo'\n        }\n        this.$store.commit('globals/setConfirmPrompt', payload)\n      }\n    },\n    clickEditChapters() {\n      // Used for Chapters tab in modal\n      if (this.$route.name === 'audiobook-id-chapters' && this.$route.params?.id === this.libraryItem?.id) {\n        this.$emit('close')\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/CollectionBooksTable.vue",
    "content": "<template>\n  <div class=\"w-full bg-primary/40\">\n    <div class=\"w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary\">\n      <p class=\"pr-4\">{{ $strings.HeaderCollectionItems }}</p>\n\n      <div class=\"w-6 h-6 md:w-7 md:h-7 bg-white/10 rounded-full flex items-center justify-center\">\n        <span class=\"text-xs md:text-sm font-mono leading-none\">{{ books.length }}</span>\n      </div>\n      <div class=\"grow\" />\n      <p v-if=\"totalDuration\" class=\"text-sm text-gray-200\">{{ totalDurationPretty }}</p>\n    </div>\n    <draggable v-model=\"booksCopy\" v-bind=\"dragOptions\" class=\"list-group\" handle=\".drag-handle\" draggable=\".item\" tag=\"div\" @start=\"drag = true\" @end=\"drag = false\" @update=\"draggableUpdate\">\n      <transition-group type=\"transition\" :name=\"!drag ? 'collection-book' : null\">\n        <template v-for=\"book in booksCopy\">\n          <tables-collection-book-table-row :key=\"book.id\" :is-dragging=\"drag\" :book=\"book\" :collection-id=\"collectionId\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" class=\"item\" :class=\"drag ? '' : 'collection-book-item'\" @edit=\"editBook\" />\n        </template>\n      </transition-group>\n    </draggable>\n  </div>\n</template>\n\n<script>\nimport draggable from 'vuedraggable'\n\nexport default {\n  components: {\n    draggable\n  },\n  props: {\n    collectionId: String,\n    books: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      drag: false,\n      dragOptions: {\n        animation: 200,\n        group: 'description',\n        ghostClass: 'ghost'\n      },\n      booksCopy: []\n    }\n  },\n  watch: {\n    books: {\n      handler(newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    totalDuration() {\n      var _total = 0\n      this.books.forEach((book) => {\n        _total += book.media.duration\n      })\n      return _total\n    },\n    totalDurationPretty() {\n      return this.$elapsedPrettyExtended(this.totalDuration)\n    }\n  },\n  methods: {\n    draggableUpdate() {\n      var collectionUpdate = {\n        books: this.booksCopy.map((b) => b.id)\n      }\n      this.$axios\n        .$patch(`/api/collections/${this.collectionId}`, collectionUpdate)\n        .then((collection) => {\n          console.log('Collection updated', collection)\n        })\n        .catch((error) => {\n          console.error('Failed to update collection', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n    },\n    editBook(book) {\n      var bookIds = this.books.map((b) => b.id)\n      this.$store.commit('setBookshelfBookIds', bookIds)\n      this.$store.commit('showEditModal', book)\n    },\n    init() {\n      this.booksCopy = this.books.map((b) => ({ ...b }))\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style>\n.collection-book-item {\n  transition: all 0.4s ease;\n}\n\n.collection-book-enter-from,\n.collection-book-leave-to {\n  opacity: 0;\n  transform: translateX(30px);\n}\n\n.collection-book-leave-active {\n  position: absolute;\n}\n</style>\n"
  },
  {
    "path": "client/components/tables/CustomMetadataProviderTable.vue",
    "content": "<template>\n  <div class=\"min-h-40\">\n    <table v-if=\"providers.length\" id=\"providers\">\n      <tr>\n        <th>{{ $strings.LabelName }}</th>\n        <th>URL</th>\n        <th>Authorization Header Value</th>\n        <th class=\"w-12\"></th>\n      </tr>\n      <tr v-for=\"provider in providers\" :key=\"provider.id\">\n        <td class=\"text-sm\">{{ provider.name }}</td>\n        <td class=\"text-sm\">{{ provider.url }}</td>\n        <td class=\"text-sm\">\n          <span v-if=\"provider.authHeaderValue\" class=\"custom-provider-api-key\">{{ provider.authHeaderValue }}</span>\n        </td>\n        <td class=\"py-0\">\n          <div class=\"h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer\" @click.stop=\"removeProvider(provider)\">\n            <button type=\"button\" :aria-label=\"$strings.ButtonDelete\" class=\"material-symbols text-base\">delete</button>\n          </div>\n        </td>\n      </tr>\n    </table>\n    <div v-else-if=\"!processing\" class=\"text-center py-8\">\n      <p class=\"text-lg\">{{ $strings.LabelNoCustomMetadataProviders }}</p>\n    </div>\n\n    <div v-if=\"processing\" class=\"absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md\">\n      <ui-loading-indicator />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    providers: {\n      type: Array,\n      default: () => []\n    },\n    processing: Boolean\n  },\n  data() {\n    return {}\n  },\n  methods: {\n    removeProvider(provider) {\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteMetadataProvider', [provider.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$emit('update:processing', true)\n\n            this.$axios\n              .$delete(`/api/custom-metadata-providers/${provider.id}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastProviderRemoveSuccess)\n                this.$emit('removed', provider.id)\n              })\n              .catch((error) => {\n                console.error('Failed to remove provider', error)\n                this.$toast.error(this.$strings.ToastRemoveFailed)\n              })\n              .finally(() => {\n                this.$emit('update:processing', false)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    }\n  }\n}\n</script>\n\n<style>\n#providers {\n  table-layout: fixed;\n  border-collapse: collapse;\n  border: 1px solid #474747;\n  width: 100%;\n}\n\n#providers td,\n#providers th {\n  /* border: 1px solid #2e2e2e; */\n  padding: 8px 8px;\n  text-align: left;\n}\n\n#providers td.py-0 {\n  padding: 0px 8px;\n}\n\n#providers tr:nth-child(even) {\n  background-color: #373838;\n}\n\n#providers tr:nth-child(odd) {\n  background-color: #2f2f2f;\n}\n\n#providers tr:hover {\n  background-color: #444;\n}\n\n#providers th {\n  font-size: 0.8rem;\n  font-weight: 600;\n  padding-top: 5px;\n  padding-bottom: 5px;\n  background-color: #272727;\n}\n\n.custom-provider-api-key {\n  padding: 1px;\n  background-color: #272727;\n  border-radius: 4px;\n  color: transparent;\n  transition: color, background-color 0.5s ease;\n}\n\n.custom-provider-api-key:hover {\n  background-color: transparent;\n  color: white;\n}\n</style>\n"
  },
  {
    "path": "client/components/tables/EbookFilesTable.vue",
    "content": "<template>\n  <div class=\"w-full my-2\">\n    <div class=\"w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer\" @click.stop=\"clickBar\">\n      <p class=\"pr-2 md:pr-4\">{{ $strings.HeaderEbookFiles }}</p>\n      <div class=\"h-5 md:h-7 w-5 md:w-7 rounded-full bg-white/10 flex items-center justify-center\">\n        <span class=\"text-sm font-mono\">{{ ebookFiles.length }}</span>\n      </div>\n      <div class=\"grow\" />\n      <ui-btn v-if=\"userIsAdmin\" small :color=\"showFullPath ? 'bg-gray-600' : 'bg-primary'\" class=\"mr-2 hidden md:block\" @click.stop=\"toggleFullPath\">{{ $strings.ButtonFullPath }}</ui-btn>\n      <div class=\"cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500\" :class=\"showFiles ? 'transform rotate-180' : ''\">\n        <span class=\"material-symbols text-4xl\">&#xe313;</span>\n      </div>\n    </div>\n    <transition name=\"slide\">\n      <div class=\"w-full\" v-show=\"showFiles\">\n        <table class=\"text-sm tracksTable\">\n          <tr>\n            <th class=\"text-left px-4\">{{ $strings.LabelPath }}</th>\n            <th class=\"text-left w-24 min-w-24\">{{ $strings.LabelSize }}</th>\n            <th class=\"text-left px-4 w-24\">\n              {{ $strings.LabelRead }} <ui-tooltip :text=\"$strings.LabelReadEbookWithoutProgress\" direction=\"top\" class=\"inline-block\"><span class=\"material-symbols text-sm align-middle\">info</span></ui-tooltip>\n            </th>\n            <th v-if=\"showMoreColumn\" class=\"text-center w-16\"></th>\n          </tr>\n          <template v-for=\"file in ebookFiles\">\n            <tables-ebook-files-table-row :key=\"file.path\" :libraryItemId=\"libraryItemId\" :showFullPath=\"showFullPath\" :file=\"file\" @read=\"readEbook\" />\n          </template>\n        </table>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      showFiles: false,\n      showFullPath: false\n    }\n  },\n  computed: {\n    libraryItemId() {\n      return this.libraryItem.id\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userIsAdmin() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    libraryIsAudiobooksOnly() {\n      return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']\n    },\n    showMoreColumn() {\n      return this.userCanDelete || this.userCanDownload || (this.userCanUpdate && !this.libraryIsAudiobooksOnly)\n    },\n    ebookFiles() {\n      return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')\n    }\n  },\n  methods: {\n    toggleFullPath() {\n      this.showFullPath = !this.showFullPath\n      localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)\n    },\n    readEbook(fileIno) {\n      this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })\n    },\n    clickBar() {\n      this.showFiles = !this.showFiles\n    }\n  },\n  mounted() {\n    if (this.userIsAdmin) {\n      this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/EbookFilesTableRow.vue",
    "content": "<template>\n  <tr>\n    <td class=\"px-4\">\n      {{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text=\"$strings.LabelPrimaryEbook\" class=\"inline-block\"><span v-if=\"isPrimary\" class=\"material-symbols text-success align-text-bottom\">check_circle</span></ui-tooltip>\n    </td>\n    <td>\n      {{ $bytesPretty(file.metadata.size) }}\n    </td>\n    <td class=\"text-xs\">\n      <ui-icon-btn icon=\"auto_stories\" outlined borderless icon-font-size=\"1.125rem\" :size=\"8\" @click=\"readEbook\" />\n    </td>\n    <td v-if=\"contextMenuItems.length\" class=\"text-center\">\n      <ui-context-menu-dropdown :items=\"contextMenuItems\" :menu-width=\"130\" :processing=\"processing\" @action=\"contextMenuAction\" />\n    </td>\n  </tr>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItemId: String,\n    showFullPath: Boolean,\n    file: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      processing: false\n    }\n  },\n  computed: {\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userIsAdmin() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    downloadUrl() {\n      return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`\n    },\n    isPrimary() {\n      return !this.file.isSupplementary\n    },\n    libraryIsAudiobooksOnly() {\n      return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']\n    },\n    contextMenuItems() {\n      const items = []\n      if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {\n        items.push({\n          text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,\n          action: 'updateStatus'\n        })\n      }\n      if (this.userCanDownload) {\n        items.push({\n          text: this.$strings.LabelDownload,\n          action: 'download'\n        })\n      }\n      if (this.userCanDelete) {\n        items.push({\n          text: this.$strings.ButtonDelete,\n          action: 'delete'\n        })\n      }\n      return items\n    }\n  },\n  methods: {\n    readEbook() {\n      this.$emit('read', this.file.ino)\n    },\n    contextMenuAction({ action }) {\n      if (action === 'delete') {\n        this.deleteLibraryFile()\n      } else if (action === 'download') {\n        this.downloadLibraryFile()\n      } else if (action === 'updateStatus') {\n        this.updateEbookStatus()\n      }\n    },\n    updateEbookStatus() {\n      this.processing = true\n      this.$axios\n        .$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)\n        .then(() => {\n          this.$toast.success('Ebook updated')\n        })\n        .catch((error) => {\n          console.error('Failed to update ebook', error)\n          this.$toast.error('Failed to update ebook')\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    deleteLibraryFile() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteFile,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.processing = true\n            this.$axios\n              .$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastDeleteFileSuccess)\n              })\n              .catch((error) => {\n                console.error('Failed to delete file', error)\n                this.$toast.error(this.$strings.ToastDeleteFileFailed)\n              })\n              .finally(() => {\n                this.processing = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    downloadLibraryFile() {\n      this.$downloadFile(this.downloadUrl, this.file.metadata.filename)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/LibraryFilesTable.vue",
    "content": "<template>\n  <div class=\"w-full my-2\">\n    <div class=\"w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer\" @click.stop=\"clickBar\">\n      <p class=\"pr-2 md:pr-4\">{{ $strings.HeaderLibraryFiles }}</p>\n      <div class=\"h-5 md:h-7 w-5 md:w-7 rounded-full bg-white/10 flex items-center justify-center\">\n        <span class=\"text-sm font-mono\">{{ files.length }}</span>\n      </div>\n      <div class=\"grow\" />\n      <ui-btn v-if=\"userIsAdmin\" small :color=\"showFullPath ? 'bg-gray-600' : 'bg-primary'\" class=\"mr-2 hidden md:block\" @click.stop=\"toggleFullPath\">{{ $strings.ButtonFullPath }}</ui-btn>\n      <div class=\"cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500\" :class=\"showFiles ? 'transform rotate-180' : ''\">\n        <span class=\"material-symbols text-4xl\">&#xe313;</span>\n      </div>\n    </div>\n    <transition name=\"slide\">\n      <div class=\"w-full\" v-if=\"showFiles\">\n        <table class=\"text-sm tracksTable\">\n          <tr>\n            <th class=\"text-left px-4\">{{ $strings.LabelPath }}</th>\n            <th class=\"text-left w-24 min-w-24\">{{ $strings.LabelSize }}</th>\n            <th class=\"text-left px-4 w-24\">{{ $strings.LabelType }}</th>\n            <th v-if=\"userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)\" class=\"text-center w-16\"></th>\n          </tr>\n          <template v-for=\"file in filesWithAudioFile\">\n            <tables-library-files-table-row :key=\"file.path\" :libraryItemId=\"libraryItemId\" :showFullPath=\"showFullPath\" :file=\"file\" :inModal=\"inModal\" @showMore=\"showMore\" />\n          </template>\n        </table>\n      </div>\n    </transition>\n\n    <modals-audio-file-data-modal v-model=\"showAudioFileDataModal\" :library-item-id=\"libraryItemId\" :audio-file=\"selectedAudioFile\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    },\n    expanded: Boolean, // start expanded\n    inModal: Boolean\n  },\n  data() {\n    return {\n      showFiles: false,\n      showFullPath: false,\n      showAudioFileDataModal: false,\n      selectedAudioFile: null\n    }\n  },\n  computed: {\n    libraryItemId() {\n      return this.libraryItem.id\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userIsAdmin() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    files() {\n      return this.libraryItem.libraryFiles || []\n    },\n    audioFiles() {\n      if (this.libraryItem.mediaType === 'podcast') {\n        return this.libraryItem.media?.episodes.map((ep) => ep.audioFile).filter((af) => af) || []\n      }\n      return this.libraryItem.media?.audioFiles || []\n    },\n    filesWithAudioFile() {\n      return this.files.map((file) => {\n        if (file.fileType === 'audio') {\n          file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)\n        }\n        return file\n      })\n    }\n  },\n  methods: {\n    toggleFullPath() {\n      this.showFullPath = !this.showFullPath\n      localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)\n    },\n    clickBar() {\n      this.showFiles = !this.showFiles\n    },\n    showMore(audioFile) {\n      this.selectedAudioFile = audioFile\n      this.showAudioFileDataModal = true\n    }\n  },\n  mounted() {\n    if (this.userIsAdmin) {\n      this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)\n    }\n    this.showFiles = this.expanded\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/LibraryFilesTableRow.vue",
    "content": "<template>\n  <tr>\n    <td class=\"px-4\">\n      {{ showFullPath ? file.metadata.path : file.metadata.relPath }}\n    </td>\n    <td>\n      {{ $bytesPretty(file.metadata.size) }}\n    </td>\n    <td class=\"text-xs\">\n      <div class=\"flex items-center\">\n        <p>{{ file.fileType }}</p>\n      </div>\n    </td>\n    <td v-if=\"contextMenuItems.length\" class=\"text-center\">\n      <ui-context-menu-dropdown :items=\"contextMenuItems\" :menu-width=\"110\" @action=\"contextMenuAction\" />\n    </td>\n  </tr>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItemId: String,\n    showFullPath: Boolean,\n    file: {\n      type: Object,\n      default: () => {}\n    },\n    inModal: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userIsAdmin() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    downloadUrl() {\n      return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`\n    },\n    contextMenuItems() {\n      const items = []\n      if (this.userCanDownload) {\n        items.push({\n          text: this.$strings.LabelDownload,\n          action: 'download'\n        })\n      }\n      if (this.userCanDelete) {\n        items.push({\n          text: this.$strings.ButtonDelete,\n          action: 'delete'\n        })\n      }\n      // Currently not showing this option in the Files tab modal\n      if (this.userIsAdmin && this.file.audioFile && !this.inModal) {\n        items.push({\n          text: this.$strings.LabelMoreInfo,\n          action: 'more'\n        })\n      }\n      return items\n    }\n  },\n  methods: {\n    contextMenuAction({ action }) {\n      if (action === 'delete') {\n        this.deleteLibraryFile()\n      } else if (action === 'download') {\n        this.downloadLibraryFile()\n      } else if (action === 'more') {\n        this.$emit('showMore', this.file.audioFile)\n      }\n    },\n    deleteLibraryFile() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteFile,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$axios\n              .$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastDeleteFileSuccess)\n              })\n              .catch((error) => {\n                console.error('Failed to delete file', error)\n                this.$toast.error(this.$strings.ToastDeleteFileFailed)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    downloadLibraryFile() {\n      this.$downloadFile(this.downloadUrl, this.file.metadata.filename)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/PlaylistItemsTable.vue",
    "content": "<template>\n  <div class=\"w-full bg-primary/40\">\n    <div class=\"w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary\">\n      <p class=\"pr-4\">{{ $strings.HeaderPlaylistItems }}</p>\n\n      <div class=\"w-6 h-6 md:w-7 md:h-7 bg-white/10 rounded-full flex items-center justify-center\">\n        <span class=\"text-xs md:text-sm font-mono leading-none\">{{ items.length }}</span>\n      </div>\n      <div class=\"grow\" />\n      <p v-if=\"totalDuration\" class=\"text-sm text-gray-200\">{{ totalDurationPretty }}</p>\n    </div>\n    <draggable v-model=\"itemsCopy\" v-bind=\"dragOptions\" class=\"list-group\" handle=\".drag-handle\" draggable=\".item\" tag=\"div\" @start=\"drag = true\" @end=\"drag = false\" @update=\"draggableUpdate\">\n      <transition-group type=\"transition\" :name=\"!drag ? 'playlist-item' : null\">\n        <template v-for=\"(item, index) in itemsCopy\">\n          <tables-playlist-item-table-row :key=\"index\" :is-dragging=\"drag\" :item=\"item\" :playlist-id=\"playlistId\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" class=\"item\" :class=\"drag ? '' : 'playlist-item-item'\" @edit=\"editItem\" />\n        </template>\n      </transition-group>\n    </draggable>\n  </div>\n</template>\n\n<script>\nimport draggable from 'vuedraggable'\n\nexport default {\n  components: {\n    draggable\n  },\n  props: {\n    playlistId: String,\n    items: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      drag: false,\n      dragOptions: {\n        animation: 200,\n        group: 'description',\n        ghostClass: 'ghost'\n      },\n      itemsCopy: []\n    }\n  },\n  watch: {\n    items: {\n      handler(newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    totalDuration() {\n      var _total = 0\n      this.items.forEach((item) => {\n        if (item.episode) _total += item.episode.duration\n        else _total += item.libraryItem.media.duration\n      })\n      return _total\n    },\n    totalDurationPretty() {\n      return this.$elapsedPrettyExtended(this.totalDuration)\n    }\n  },\n  methods: {\n    editItem(playlistItem) {\n      if (playlistItem.episode) {\n        const episodeIds = this.items.map((pi) => pi.episodeId)\n        this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)\n        this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)\n        this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)\n        this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)\n      } else {\n        const itemIds = this.items.map((i) => i.libraryItemId)\n        this.$store.commit('setBookshelfBookIds', itemIds)\n        this.$store.commit('showEditModal', playlistItem.libraryItem)\n      }\n    },\n    draggableUpdate() {\n      var playlistUpdate = {\n        items: this.itemsCopy.map((i) => ({ libraryItemId: i.libraryItemId, episodeId: i.episodeId }))\n      }\n      this.$axios\n        .$patch(`/api/playlists/${this.playlistId}`, playlistUpdate)\n        .then((playlist) => {\n          console.log('Playlist updated', playlist)\n        })\n        .catch((error) => {\n          console.error('Failed to update playlist', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n    },\n    init() {\n      this.itemsCopy = this.items.map((i) => ({ ...i }))\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style>\n.playlist-item-item {\n  transition: all 0.4s ease;\n}\n\n.playlist-item-enter-from,\n.playlist-item-leave-to {\n  opacity: 0;\n  transform: translateX(30px);\n}\n\n.playlist-item-leave-active {\n  position: absolute;\n}\n</style>\n"
  },
  {
    "path": "client/components/tables/TracksTable.vue",
    "content": "<template>\n  <div class=\"w-full my-2\">\n    <div class=\"w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer\" @click.stop=\"clickBar\">\n      <p class=\"pr-2 md:pr-4\">{{ title }}</p>\n      <div class=\"h-5 md:h-7 w-5 md:w-7 rounded-full bg-white/10 flex items-center justify-center\">\n        <span class=\"text-sm font-mono\">{{ tracks.length }}</span>\n      </div>\n      <div class=\"grow\" />\n      <ui-btn v-if=\"userIsAdmin\" small :color=\"showFullPath ? 'bg-gray-600' : 'bg-primary'\" class=\"mr-2 hidden md:block\" @click.stop=\"toggleFullPath\">{{ $strings.ButtonFullPath }}</ui-btn>\n      <nuxt-link v-if=\"userCanUpdate && !isFile\" :to=\"`/audiobook/${libraryItemId}/edit`\" class=\"mr-2 md:mr-4\" @mousedown.prevent>\n        <ui-btn small color=\"bg-primary\">{{ $strings.ButtonManageTracks }}</ui-btn>\n      </nuxt-link>\n      <div class=\"cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500\" :class=\"showTracks ? 'transform rotate-180' : ''\">\n        <span class=\"material-symbols text-4xl\">&#xe313;</span>\n      </div>\n    </div>\n    <transition name=\"slide\">\n      <div class=\"w-full\" v-show=\"showTracks\">\n        <table class=\"text-sm tracksTable\">\n          <tr>\n            <th class=\"w-10\">#</th>\n            <th class=\"text-left\">{{ $strings.LabelFilename }}</th>\n            <th v-if=\"!showFullPath\" class=\"text-left w-20 hidden lg:table-cell\">{{ $strings.LabelCodec }}</th>\n            <th v-if=\"!showFullPath\" class=\"text-left w-20 hidden xl:table-cell\">{{ $strings.LabelBitrate }}</th>\n            <th class=\"text-left w-20 hidden md:table-cell\">{{ $strings.LabelSize }}</th>\n            <th class=\"text-left w-20 hidden sm:table-cell\">{{ $strings.LabelDuration }}</th>\n            <th class=\"text-center w-16\"></th>\n          </tr>\n          <template v-for=\"track in tracks\">\n            <tables-audio-tracks-table-row :key=\"track.index\" :track=\"track\" :library-item-id=\"libraryItemId\" :showFullPath=\"showFullPath\" @showMore=\"showMore\" />\n          </template>\n        </table>\n      </div>\n    </transition>\n\n    <modals-audio-file-data-modal v-model=\"showAudioFileDataModal\" :library-item-id=\"libraryItemId\" :audio-file=\"selectedAudioFile\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    title: {\n      type: String,\n      default: 'Audio Tracks'\n    },\n    tracks: {\n      type: Array,\n      default: () => []\n    },\n    libraryItemId: String,\n    isFile: Boolean\n  },\n  data() {\n    return {\n      showTracks: false,\n      showFullPath: false,\n      selectedAudioFile: null,\n      showAudioFileDataModal: false\n    }\n  },\n  computed: {\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userIsAdmin() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    }\n  },\n  methods: {\n    toggleFullPath() {\n      this.showFullPath = !this.showFullPath\n      localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)\n    },\n    clickBar() {\n      this.showTracks = !this.showTracks\n    },\n    showMore(audioFile) {\n      this.selectedAudioFile = audioFile\n      this.showAudioFileDataModal = true\n    }\n  },\n  mounted() {\n    if (this.userIsAdmin) {\n      this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/UploadedFilesTable.vue",
    "content": "<template>\n  <div class=\"w-full my-4\">\n    <div class=\"w-full bg-primary px-6 py-1 flex items-center cursor-pointer\" @click.stop=\"clickBar\">\n      <p class=\"pr-4\">{{ title }}</p>\n      <span class=\"bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono\">{{ files.length }}</span>\n      <div class=\"grow\" />\n      <div class=\"cursor-pointer h-9 w-9 rounded-full hover:bg-black-400 flex justify-center items-center duration-500\" :class=\"expand ? 'transform rotate-180' : ''\">\n        <span class=\"material-symbols text-3xl\">expand_more</span>\n      </div>\n    </div>\n    <transition name=\"slide\">\n      <div class=\"w-full\" v-show=\"expand\">\n        <table class=\"text-sm tracksTable\">\n          <tr>\n            <th class=\"text-left\">{{ $strings.LabelFilename }}</th>\n            <th class=\"text-left\">{{ $strings.LabelSize }}</th>\n            <th class=\"text-left\">{{ $strings.LabelType }}</th>\n          </tr>\n          <template v-for=\"file in files\">\n            <tr :key=\"file.path\">\n              <td class=\"pl-2\">\n                {{ file.name }}\n              </td>\n              <td class=\"font-mono\">\n                {{ $bytesPretty(file.size) }}\n              </td>\n              <td>\n                {{ file.filetype }}\n              </td>\n            </tr>\n          </template>\n        </table>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    title: String,\n    files: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      expand: false\n    }\n  },\n  computed: {},\n  methods: {\n    clickBar() {\n      this.expand = !this.expand\n    }\n  },\n  mounted() {}\n}\n</script>"
  },
  {
    "path": "client/components/tables/UsersTable.vue",
    "content": "<template>\n  <div>\n    <div class=\"text-center\">\n      <table id=\"accounts\">\n        <tr>\n          <th>{{ $strings.LabelUsername }}</th>\n          <th class=\"w-20\">{{ $strings.LabelAccountType }}</th>\n          <th class=\"hidden lg:table-cell\">{{ $strings.LabelActivity }}</th>\n          <th class=\"w-32 hidden sm:table-cell\">{{ $strings.LabelLastSeen }}</th>\n          <th class=\"w-32 hidden sm:table-cell\">{{ $strings.LabelCreatedAt }}</th>\n          <th class=\"w-32\"></th>\n        </tr>\n        <tr v-for=\"user in users\" :key=\"user.id\" class=\"cursor-pointer\" :class=\"user.isActive ? '' : 'bg-error/10!'\" @click=\"$router.push(`/config/users/${user.id}`)\">\n          <td>\n            <div class=\"flex items-center\">\n              <widgets-online-indicator :value=\"!!usersOnline[user.id]\" />\n              <p class=\"pl-2 truncate\">{{ user.username }}</p>\n            </div>\n          </td>\n          <td class=\"text-sm\">{{ user.type }}</td>\n          <td class=\"hidden lg:table-cell\">\n            <div v-if=\"usersOnline[user.id]?.session?.displayTitle\">\n              <p class=\"truncate text-xs\">Listening: {{ usersOnline[user.id].session.displayTitle || '' }}</p>\n              <p class=\"truncate text-xs text-gray-300\">{{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}</p>\n            </div>\n            <div v-else-if=\"user.latestSession?.displayTitle\">\n              <p class=\"truncate text-xs\">Last: {{ user.latestSession.displayTitle || '' }}</p>\n              <p class=\"truncate text-xs text-gray-300\">{{ getDeviceInfoString(user.latestSession.deviceInfo) }}</p>\n            </div>\n          </td>\n          <td class=\"text-xs font-mono hidden sm:table-cell\">\n            <ui-tooltip v-if=\"user.lastSeen\" direction=\"top\" :text=\"$formatDatetime(user.lastSeen, dateFormat, timeFormat)\">\n              {{ $dateDistanceFromNow(user.lastSeen) }}\n            </ui-tooltip>\n          </td>\n          <td class=\"text-xs font-mono hidden sm:table-cell\">\n            <ui-tooltip direction=\"top\" :text=\"$formatDatetime(user.createdAt, dateFormat, timeFormat)\">\n              {{ $formatDate(user.createdAt, dateFormat) }}\n            </ui-tooltip>\n          </td>\n          <td class=\"py-0\">\n            <div class=\"w-full flex justify-left\">\n              <!-- Dont show edit for non-root users -->\n              <div v-if=\"user.type !== 'root' || userIsRoot\" class=\"h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer\" @click.stop=\"editUser(user)\">\n                <button type=\"button\" :aria-label=\"$getString('ButtonUserEdit', [user.username])\" class=\"material-symbols text-base\">edit</button>\n              </div>\n              <div v-show=\"user.type !== 'root' && user.id !== currentUserId\" class=\"h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer\" @click.stop=\"deleteUserClick(user)\">\n                <button type=\"button\" :aria-label=\"$getString('ButtonUserDelete', [user.username])\" class=\"material-symbols text-base\">delete</button>\n              </div>\n            </div>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      users: [],\n      isDeletingUser: false\n    }\n  },\n  computed: {\n    currentUserId() {\n      return this.$store.state.user.user.id\n    },\n    userIsRoot() {\n      return this.$store.getters['user/getIsRoot']\n    },\n    usersOnline() {\n      var usermap = {}\n      this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u))\n      return usermap\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    getDeviceInfoString(deviceInfo) {\n      if (!deviceInfo) return ''\n      if (deviceInfo.manufacturer && deviceInfo.model) return `${deviceInfo.manufacturer} ${deviceInfo.model}`\n\n      return `${deviceInfo.osName || 'Unknown'} ${deviceInfo.osVersion || ''} ${deviceInfo.browserName || ''}`\n    },\n    deleteUserClick(user) {\n      if (this.isDeletingUser) return\n\n      const payload = {\n        message: this.$getString('MessageRemoveUserWarning', [user.username]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteUser(user)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteUser(user) {\n      this.isDeletingUser = true\n      this.$axios\n        .$delete(`/api/users/${user.id}`)\n        .then((data) => {\n          if (data.error) {\n            this.$toast.error(data.error)\n          } else {\n            this.$toast.success(this.$strings.ToastUserDeleteSuccess)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to delete user', error)\n          this.$toast.error(this.$strings.ToastUserDeleteFailed)\n        })\n        .finally(() => {\n          this.isDeletingUser = false\n        })\n    },\n    editUser(user) {\n      this.$emit('edit', user)\n    },\n    loadUsers() {\n      this.$axios\n        .$get('/api/users?include=latestSession')\n        .then((res) => {\n          this.users = res.users.sort((a, b) => {\n            return a.createdAt - b.createdAt\n          })\n          this.$emit('numUsers', this.users.length)\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n        })\n    },\n    addUpdateUser(user) {\n      if (!this.users) return\n      var index = this.users.findIndex((u) => u.id === user.id)\n      if (index >= 0) {\n        this.users.splice(index, 1, user)\n      } else {\n        this.users.push(user)\n      }\n    },\n    userRemoved(user) {\n      this.users = this.users.filter((u) => u.id !== user.id)\n    },\n    init(attempts = 0) {\n      if (!this.$root.socket) {\n        if (attempts > 10) {\n          return console.error('Failed to setup socket listeners')\n        }\n        setTimeout(() => {\n          this.init(++attempts)\n        }, 250)\n        return\n      }\n      this.$root.socket.on('user_added', this.addUpdateUser)\n      this.$root.socket.on('user_updated', this.addUpdateUser)\n      this.$root.socket.on('user_removed', this.userRemoved)\n    }\n  },\n  mounted() {\n    this.loadUsers()\n    this.init()\n  },\n  beforeDestroy() {\n    if (this.$root.socket) {\n      this.$root.socket.off('user_added', this.addUpdateUser)\n      this.$root.socket.off('user_updated', this.addUpdateUser)\n      this.$root.socket.off('user_removed', this.userRemoved)\n    }\n  }\n}\n</script>\n\n<style>\n#accounts {\n  table-layout: fixed;\n  border-collapse: collapse;\n  border: 1px solid #474747;\n  width: 100%;\n}\n\n#accounts td,\n#accounts th {\n  /* border: 1px solid #2e2e2e; */\n  padding: 8px 8px;\n  text-align: left;\n}\n\n#accounts td.py-0 {\n  padding: 0px 8px;\n}\n\n#accounts tr:nth-child(even) {\n  background-color: #373838;\n}\n\n#accounts tr:nth-child(odd) {\n  background-color: #2f2f2f;\n}\n\n#accounts tr:hover {\n  background-color: #444;\n}\n\n#accounts th {\n  font-size: 0.8rem;\n  font-weight: 600;\n  padding-top: 5px;\n  padding-bottom: 5px;\n  background-color: #272727;\n}\n</style>\n"
  },
  {
    "path": "client/components/tables/collection/BookTableRow.vue",
    "content": "<template>\n  <div class=\"w-full px-1 md:px-2 py-2 overflow-hidden relative\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\" :class=\"isHovering ? 'bg-white/5' : ''\">\n    <div v-if=\"book\" class=\"flex h-18 md:h-[5.5rem]\">\n      <div class=\"w-10 min-w-10 md:w-16 md:max-w-16 h-full\">\n        <div class=\"flex h-full items-center justify-center\">\n          <span class=\"material-symbols drag-handle text-lg md:text-xl\">menu</span>\n        </div>\n      </div>\n      <div class=\"h-full flex items-center\" :style=\"{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }\">\n        <div class=\"relative\" :style=\"{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }\">\n          <covers-book-cover :library-item=\"book\" :width=\"coverWidth\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n          <div class=\"absolute top-0 left-0 flex items-center justify-center bg-black/50 h-full w-full z-10\" v-show=\"isHovering && showPlayBtn\">\n            <div class=\"w-8 h-8 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/40 cursor-pointer\" @click=\"playClick\">\n              <span class=\"material-symbols fill text-2xl\">play_arrow</span>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3\">\n        <div>\n          <div class=\"truncate max-w-48 md:max-w-md\">\n            <nuxt-link :to=\"`/item/${book.id}`\" class=\"truncate hover:underline text-sm md:text-base\">{{ bookTitle }}</nuxt-link>\n          </div>\n          <div class=\"truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300\">\n            <nuxt-link v-for=\"_series in seriesList\" :key=\"_series.id\" :to=\"`/library/${book.libraryId}/series/${_series.id}`\" class=\"hover:underline font-sans text-gray-300\"> {{ _series.text }}</nuxt-link>\n          </div>\n          <div class=\"truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300\">\n            <template v-for=\"(author, index) in bookAuthors\">\n              <nuxt-link :key=\"author.id\" :to=\"`/author/${author.id}`\" class=\"truncate hover:underline\">{{ author.name }}</nuxt-link\n              ><span :key=\"author.id + '-comma'\" v-if=\"index < bookAuthors.length - 1\">,&nbsp;</span>\n            </template>\n          </div>\n          <p v-if=\"media.duration\" class=\"text-xs md:text-sm text-gray-400\">{{ bookDuration }}</p>\n        </div>\n      </div>\n    </div>\n    <div class=\"w-40 absolute top-0 -right-24 h-full transform transition-transform\" :class=\"!isHovering ? 'translate-x-0' : translateDistance\">\n      <div class=\"flex h-full items-center\">\n        <ui-tooltip :text=\"userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"top\">\n          <ui-read-icon-btn :disabled=\"isProcessingReadUpdate\" :is-read=\"userIsFinished\" borderless class=\"mx-1 mt-0.5\" @click=\"toggleFinished\" />\n        </ui-tooltip>\n        <div v-if=\"userCanUpdate\" class=\"mx-1\" :class=\"isHovering ? '' : 'ml-6'\">\n          <ui-icon-btn icon=\"edit\" borderless @click=\"clickEdit\" />\n        </div>\n        <div v-if=\"userCanDelete\" class=\"mx-1\">\n          <ui-icon-btn icon=\"close\" borderless @click=\"removeClick\" />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    collectionId: String,\n    book: {\n      type: Object,\n      default: () => {}\n    },\n    isDragging: Boolean,\n    bookCoverAspectRatio: Number\n  },\n  data() {\n    return {\n      isProcessingReadUpdate: false,\n      processingRemove: false,\n      isHovering: false\n    }\n  },\n  watch: {\n    isDragging: {\n      handler(newVal) {\n        if (newVal) {\n          this.isHovering = false\n        }\n      }\n    }\n  },\n  computed: {\n    translateDistance() {\n      if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'\n      else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'\n      return '-translate-x-24'\n    },\n    media() {\n      return this.book.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    tracks() {\n      return this.media.tracks || []\n    },\n    bookTitle() {\n      return this.mediaMetadata.title || ''\n    },\n    bookAuthors() {\n      return this.mediaMetadata.authors || []\n    },\n    bookDuration() {\n      return this.$elapsedPretty(this.media.duration)\n    },\n    series() {\n      return this.mediaMetadata.series || []\n    },\n    seriesList() {\n      return this.series.map((se) => {\n        let text = se.name\n        if (se.sequence) text += ` #${se.sequence}`\n        return {\n          ...se,\n          text\n        }\n      })\n    },\n    isMissing() {\n      return this.book.isMissing\n    },\n    isInvalid() {\n      return this.book.isInvalid\n    },\n    isStreaming() {\n      return this.$store.getters['getLibraryItemIdStreaming'] === this.book.id\n    },\n    showPlayBtn() {\n      return !this.isMissing && !this.isInvalid && !this.isStreaming && this.tracks.length\n    },\n    itemProgress() {\n      return this.$store.getters['user/getUserMediaProgress'](this.book.id)\n    },\n    userIsFinished() {\n      return this.itemProgress ? !!this.itemProgress.isFinished : false\n    },\n    coverSize() {\n      return this.$store.state.globals.isMobile ? 30 : 50\n    },\n    coverHeight() {\n      return this.coverSize * 1.6\n    },\n    coverWidth() {\n      if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6\n      return this.coverSize\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    }\n  },\n  methods: {\n    mouseover() {\n      if (this.isDragging) return\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    playClick() {\n      const queueItems = [\n        {\n          libraryItemId: this.book.id,\n          libraryId: this.book.libraryId,\n          episodeId: null,\n          title: this.bookTitle,\n          subtitle: this.bookAuthors.map((au) => au.name).join(', '),\n          caption: '',\n          duration: this.media.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n      ]\n\n      this.$eventBus.$emit('play-item', {\n        libraryItemId: this.book.id,\n        queueItems\n      })\n    },\n    clickEdit() {\n      this.$emit('edit', this.book)\n    },\n    toggleFinished() {\n      var updatePayload = {\n        isFinished: !this.userIsFinished\n      }\n      this.isProcessingReadUpdate = true\n      this.$axios\n        .$patch(`/api/me/progress/${this.book.id}`, updatePayload)\n        .then(() => {\n          this.isProcessingReadUpdate = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.isProcessingReadUpdate = false\n          this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)\n        })\n    },\n    removeClick() {\n      this.processingRemove = true\n\n      this.$axios\n        .$delete(`/api/collections/${this.collectionId}/book/${this.book.id}`)\n        .then((updatedCollection) => {\n          console.log(`Book removed from collection`, updatedCollection)\n          this.$toast.success(this.$strings.ToastRemoveItemFromCollectionSuccess)\n          this.processingRemove = false\n        })\n        .catch((error) => {\n          console.error('Failed to remove book from collection', error)\n          this.$toast.error(this.$strings.ToastRemoveItemFromCollectionFailed)\n          this.processingRemove = false\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/library/LibrariesTable.vue",
    "content": "<template>\n  <div>\n    <draggable v-if=\"libraryCopies.length\" :list=\"libraryCopies\" v-bind=\"dragOptions\" class=\"list-group\" handle=\".drag-handle\" draggable=\".item\" tag=\"div\" @start=\"startDrag\" @end=\"endDrag\">\n      <template v-for=\"library in libraryCopies\">\n        <div :key=\"library.id\" class=\"item\">\n          <tables-library-item :library=\"library\" :selected=\"currentLibraryId === library.id\" :dragging=\"drag\" @edit=\"editLibrary\" @click=\"setLibrary\" />\n        </div>\n      </template>\n    </draggable>\n    <div v-if=\"!libraries.length\" class=\"pb-4\">\n      <ui-btn @click=\"clickAddLibrary\">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>\n    </div>\n\n    <p v-if=\"libraries.length && libraries.some((li) => li.mediaType === 'book')\" class=\"text-xs mt-4 text-gray-200\">\n      **<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}\n    </p>\n  </div>\n</template>\n\n<script>\nimport draggable from 'vuedraggable'\n\nexport default {\n  components: {\n    draggable\n  },\n  data() {\n    return {\n      libraryCopies: [],\n      currentOrder: [],\n      drag: false,\n      dragOptions: {\n        animation: 200,\n        group: 'description',\n        ghostClass: 'ghost'\n      },\n      orderTimeout: null\n    }\n  },\n  computed: {\n    currentLibrary() {\n      return this.$store.getters['libraries/getCurrentLibrary']\n    },\n    currentLibraryId() {\n      return this.currentLibrary?.id || null\n    },\n    libraries() {\n      return this.$store.getters['libraries/getSortedLibraries']()\n    }\n  },\n  methods: {\n    startDrag() {\n      this.drag = true\n      clearTimeout(this.orderTimeout)\n    },\n    endDrag(e) {\n      this.drag = false\n      this.checkOrder()\n    },\n    checkOrder() {\n      clearTimeout(this.orderTimeout)\n      this.orderTimeout = setTimeout(() => {\n        this.saveOrder()\n      }, 500)\n    },\n    saveOrder() {\n      var _newOrder = 1\n      var currOrder = this.libraries.map((lib) => lib.id).join(',')\n      var libraryOrderData = this.libraryCopies.map((library) => {\n        return {\n          newOrder: _newOrder++,\n          oldOrder: library.displayOrder,\n          id: library.id\n        }\n      })\n      var newOrder = libraryOrderData.map((lib) => lib.id).join(',')\n      if (currOrder !== newOrder) {\n        this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => {\n          if (response.libraries?.length) {\n            this.$store.commit('libraries/set', response.libraries)\n          }\n        })\n      }\n    },\n    async setLibrary(library) {\n      await this.$store.dispatch('libraries/fetch', library.id)\n      this.$router.push(`/library/${library.id}`)\n    },\n    clickAddLibrary() {\n      this.$emit('showLibraryModal', null)\n    },\n    editLibrary(library) {\n      this.$emit('showLibraryModal', library)\n    },\n    init() {\n      this.libraryCopies = this.libraries.map((lib) => {\n        return { ...lib }\n      })\n    },\n    librariesUpdated() {\n      this.init()\n    }\n  },\n  mounted() {\n    this.$store.commit('libraries/addListener', { id: 'libraries-table', meth: this.librariesUpdated })\n    this.init()\n  },\n  beforeDestroy() {\n    this.$store.commit('libraries/removeListener', 'libraries-table')\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/library/LibraryItem.vue",
    "content": "<template>\n  <div class=\"w-full pl-2 pr-4 md:px-4 h-12 border border-white/10 flex items-center relative -mt-px\" :class=\"selected ? 'bg-primary/50' : 'hover:bg-primary/25'\" @mouseover=\"mouseover = true\" @mouseleave=\"mouseover = false\">\n    <div v-show=\"selected\" class=\"absolute top-0 left-0 h-full w-0.5 bg-warning z-10\" />\n    <ui-library-icon v-if=\"!isScanning\" :icon=\"library.icon\" :size=\"6\" font-size=\"text-lg md:text-xl\" :class=\"isHovering ? 'text-white/90' : 'text-white/50'\" />\n    <svg v-else viewBox=\"0 0 24 24\" class=\"h-6 w-6 text-white/50 animate-spin\">\n      <path fill=\"currentColor\" d=\"M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z\" />\n    </svg>\n    <p class=\"text-base md:text-xl pl-2 md:pl-4 hover:underline cursor-pointer\" @click.stop=\"$emit('click', library)\">{{ library.name }}</p>\n\n    <div class=\"grow\" />\n\n    <!-- Scan button only shown on desktop -->\n    <ui-btn v-if=\"!isScanning && !isDeleting\" color=\"bg-bg\" class=\"hidden md:block mx-2 text-xs\" :padding-y=\"1\" :padding-x=\"3\" @click.stop=\"scanBtnClick\">{{ this.$strings.ButtonScan }}</ui-btn>\n\n    <!-- Desktop context menu icon -->\n    <ui-context-menu-dropdown v-if=\"!isScanning && !isDeleting\" :items=\"contextMenuItems\" :icon-class=\"`text-1.5xl text-gray-${isHovering ? 50 : 400}`\" class=\"hidden! md:block!\" @action=\"contextMenuAction\" />\n\n    <!-- Mobile context menu icon -->\n    <span v-if=\"!isScanning && !isDeleting\" class=\"block! md:hidden! material-symbols text-xl text-gray-300 ml-3 cursor-pointer\" @click.stop=\"showMenu\">more_vert</span>\n\n    <div v-show=\"isDeleting\" class=\"text-xl text-gray-300 ml-3 animate-spin\">\n      <svg viewBox=\"0 0 24 24\" class=\"w-6 h-6\">\n        <path fill=\"currentColor\" d=\"M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z\" />\n      </svg>\n    </div>\n    <span class=\"material-symbols drag-handle text-xl text-gray-400 hover:text-gray-50 ml-2 md:ml-4\">reorder</span>\n\n    <!-- For mobile -->\n    <modals-dialog v-model=\"showMobileMenu\" :title=\"menuTitle\" :items=\"contextMenuItems\" @action=\"contextMenuAction\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    library: {\n      type: Object,\n      default: () => {}\n    },\n    selected: Boolean,\n    dragging: Boolean\n  },\n  data() {\n    return {\n      mouseover: false,\n      isDeleting: false,\n      showMobileMenu: false\n    }\n  },\n  computed: {\n    isHovering() {\n      return this.mouseover && !this.dragging\n    },\n    isScanning() {\n      return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.library.id)\n    },\n    mediaType() {\n      return this.library.mediaType\n    },\n    isBookLibrary() {\n      return this.mediaType === 'book'\n    },\n    menuTitle() {\n      return this.library.name\n    },\n    contextMenuItems() {\n      const items = [\n        {\n          text: this.$strings.ButtonEdit,\n          action: 'edit',\n          value: 'edit'\n        },\n        {\n          text: this.$strings.ButtonScan,\n          action: 'scan',\n          value: 'scan'\n        }\n      ]\n      if (this.isBookLibrary) {\n        items.push({\n          text: this.$strings.ButtonMatchBooks,\n          action: 'match-books',\n          value: 'match-books'\n        })\n      }\n      items.push({\n        text: this.$strings.ButtonDelete,\n        action: 'delete',\n        value: 'delete'\n      })\n      return items\n    }\n  },\n  methods: {\n    scanBtnClick() {\n      this.scan()\n    },\n    contextMenuAction({ action }) {\n      this.showMobileMenu = false\n      if (action === 'edit') {\n        this.editClick()\n      } else if (action === 'scan') {\n        this.scan()\n      } else if (action === 'force-rescan') {\n        this.scan(true)\n      } else if (action === 'match-books') {\n        this.matchAll()\n      } else if (action === 'delete') {\n        this.deleteClick()\n      }\n    },\n    showMenu() {\n      this.showMobileMenu = true\n    },\n    matchAll() {\n      this.$axios\n        .$get(`/api/libraries/${this.library.id}/matchall`)\n        .then(() => {\n          console.log('Starting scan for matches')\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          var errorMsg = err.response ? err.response.data : ''\n          this.$toast.error(errorMsg || 'Match all failed')\n        })\n    },\n    editClick() {\n      this.$emit('edit', this.library)\n    },\n    scan(force = false) {\n      this.$store\n        .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force })\n        .then(() => {\n          // this.$toast.success(this.$strings.ToastLibraryScanStarted)\n        })\n        .catch((error) => {\n          console.error('Failed to start scan', error)\n          this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)\n        })\n    },\n    deleteClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.isDeleting = true\n            this.$axios\n              .$delete(`/api/libraries/${this.library.id}`)\n              .then((data) => {\n                if (data.error) {\n                  this.$toast.error(data.error)\n                } else {\n                  this.$toast.success(this.$strings.ToastLibraryDeleteSuccess)\n                }\n              })\n              .catch((error) => {\n                console.error('Failed to delete library', error)\n                this.$toast.error(this.$strings.ToastLibraryDeleteFailed)\n              })\n              .finally(() => {\n                this.isDeleting = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/playlist/ItemTableRow.vue",
    "content": "<template>\n  <div class=\"w-full px-1 md:px-2 py-2 overflow-hidden relative\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\" :class=\"isHovering ? 'bg-white/5' : ''\">\n    <div v-if=\"item\" class=\"flex h-16 md:h-20\">\n      <div class=\"w-10 min-w-10 md:w-16 md:max-w-16 h-full\">\n        <div class=\"flex h-full items-center justify-center\">\n          <span class=\"material-symbols drag-handle text-lg md:text-xl\">menu</span>\n        </div>\n      </div>\n      <div class=\"h-full relative flex items-center\" :style=\"{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }\">\n        <covers-book-cover :library-item=\"libraryItem\" :width=\"coverWidth\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n        <div class=\"absolute top-0 left-0 bg-black/50 flex items-center justify-center h-full w-full z-10\" v-show=\"isHovering && showPlayBtn\">\n          <div class=\"w-8 h-8 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/40 cursor-pointer\" @click=\"playClick\">\n            <span class=\"material-symbols fill text-2xl\">play_arrow</span>\n          </div>\n        </div>\n      </div>\n      <div class=\"grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3\">\n        <div>\n          <div class=\"truncate max-w-48 md:max-w-md\">\n            <nuxt-link :to=\"`/item/${libraryItem.id}`\" class=\"truncate hover:underline text-sm md:text-base\">{{ itemTitle }}</nuxt-link>\n          </div>\n          <div class=\"truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300\">\n            <template v-for=\"(author, index) in bookAuthors\">\n              <nuxt-link :key=\"author.id\" :to=\"`/author/${author.id}`\" class=\"truncate hover:underline\">{{ author.name }}</nuxt-link\n              ><span :key=\"author.id + '-comma'\" v-if=\"index < bookAuthors.length - 1\">,&nbsp;</span>\n            </template>\n            <nuxt-link v-if=\"episode\" :to=\"`/item/${libraryItem.id}`\" class=\"truncate hover:underline\">{{ mediaMetadata.title }}</nuxt-link>\n          </div>\n          <p class=\"text-xs md:text-sm text-gray-400\">{{ itemDuration }}</p>\n        </div>\n      </div>\n    </div>\n    <div class=\"w-40 absolute top-0 -right-24 h-full transform transition-transform\" :class=\"!isHovering ? 'translate-x-0' : translateDistance\">\n      <div class=\"flex h-full items-center\">\n        <ui-tooltip :text=\"userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"top\">\n          <ui-read-icon-btn :disabled=\"isProcessingReadUpdate\" :is-read=\"userIsFinished\" borderless class=\"mx-1 mt-0.5\" @click=\"toggleFinished\" />\n        </ui-tooltip>\n        <div v-if=\"userCanUpdate\" class=\"mx-1\" :class=\"isHovering ? '' : 'ml-6'\">\n          <ui-icon-btn icon=\"edit\" borderless @click=\"clickEdit\" />\n        </div>\n        <div class=\"mx-1\" :class=\"isHovering ? '' : 'ml-6'\">\n          <ui-icon-btn icon=\"close\" borderless @click=\"removeClick\" />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    playlistId: String,\n    item: {\n      type: Object,\n      default: () => {}\n    },\n    isDragging: Boolean,\n    bookCoverAspectRatio: Number\n  },\n  data() {\n    return {\n      isProcessingReadUpdate: false,\n      processingRemove: false,\n      isHovering: false\n    }\n  },\n  watch: {\n    isDragging: {\n      handler(newVal) {\n        if (newVal) {\n          this.isHovering = false\n        }\n      }\n    }\n  },\n  computed: {\n    translateDistance() {\n      if (!this.userCanUpdate) return '-translate-x-12'\n      return '-translate-x-24'\n    },\n    libraryItem() {\n      return this.item.libraryItem || {}\n    },\n    episode() {\n      return this.item.episode\n    },\n    episodeId() {\n      return this.episode ? this.episode.id : null\n    },\n    media() {\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    tracks() {\n      if (this.episode) return []\n      return this.media.tracks || []\n    },\n    itemTitle() {\n      if (this.episode) return this.episode.title\n      return this.mediaMetadata.title || ''\n    },\n    bookAuthors() {\n      if (this.episode) return []\n      return this.mediaMetadata.authors || []\n    },\n    itemDuration() {\n      if (this.episode) return this.$elapsedPretty(this.episode.duration)\n      return this.$elapsedPretty(this.media.duration)\n    },\n    isMissing() {\n      return this.libraryItem.isMissing\n    },\n    isInvalid() {\n      return this.libraryItem.isInvalid\n    },\n    isStreaming() {\n      return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)\n    },\n    showPlayBtn() {\n      return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)\n    },\n    itemProgress() {\n      return this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, this.episodeId)\n    },\n    userIsFinished() {\n      return this.itemProgress ? !!this.itemProgress.isFinished : false\n    },\n    coverSize() {\n      return this.$store.state.globals.isMobile ? 30 : 50\n    },\n    coverWidth() {\n      if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6\n      return this.coverSize\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    }\n  },\n  methods: {\n    mouseover() {\n      if (this.isDragging) return\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    playClick() {\n      let queueItem = null\n      if (this.episode) {\n        queueItem = {\n          libraryItemId: this.libraryItem.id,\n          libraryId: this.libraryItem.libraryId,\n          episodeId: this.episodeId,\n          title: this.itemTitle,\n          subtitle: this.mediaMetadata.title,\n          caption: '',\n          duration: this.media.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n      } else {\n        queueItem = {\n          libraryItemId: this.libraryItem.id,\n          libraryId: this.libraryItem.libraryId,\n          episodeId: null,\n          title: this.itemTitle,\n          subtitle: this.bookAuthors.map((au) => au.name).join(', '),\n          caption: '',\n          duration: this.media.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n      }\n\n      this.$eventBus.$emit('play-item', {\n        libraryItemId: this.libraryItem.id,\n        episodeId: this.episodeId,\n        queueItems: [queueItem]\n      })\n    },\n    clickEdit() {\n      this.$emit('edit', this.item)\n    },\n    toggleFinished() {\n      var updatePayload = {\n        isFinished: !this.userIsFinished\n      }\n      this.isProcessingReadUpdate = true\n\n      let routepath = `/api/me/progress/${this.libraryItem.id}`\n      if (this.episodeId) routepath += `/${this.episodeId}`\n\n      this.$axios\n        .$patch(routepath, updatePayload)\n        .then(() => {\n          this.isProcessingReadUpdate = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.isProcessingReadUpdate = false\n          this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)\n        })\n    },\n    removeClick() {\n      this.processingRemove = true\n\n      let routepath = `/api/playlists/${this.playlistId}/item/${this.libraryItem.id}`\n      if (this.episodeId) routepath += `/${this.episodeId}`\n\n      this.$axios\n        .$delete(routepath)\n        .then((updatedPlaylist) => {\n          if (!updatedPlaylist.items.length) {\n            console.log(`All items removed so playlist was removed`, updatedPlaylist)\n            this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)\n          } else {\n            console.log(`Item removed from playlist`, updatedPlaylist)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to remove item from playlist', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.processingRemove = false\n        })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/podcast/DownloadQueueTable.vue",
    "content": "<template>\n  <div class=\"w-full my-2\">\n    <div class=\"w-full bg-primary px-4 md:px-6 py-2 flex items-center\">\n      <p class=\"pr-2 md:pr-4\">{{ $strings.HeaderDownloadQueue }}</p>\n      <div class=\"h-5 md:h-7 w-5 md:w-7 rounded-full bg-white/10 flex items-center justify-center\">\n        <span class=\"text-sm font-mono\">{{ queue.length }}</span>\n      </div>\n    </div>\n    <transition name=\"slide\">\n      <div class=\"w-full\">\n        <table class=\"text-sm tracksTable\">\n          <tr>\n            <th class=\"text-left px-4 min-w-48\">{{ $strings.LabelPodcast }}</th>\n            <th class=\"text-left w-32 min-w-32\">{{ $strings.LabelEpisode }}</th>\n            <th class=\"text-left px-4\">{{ $strings.LabelEpisodeTitle }}</th>\n            <th class=\"text-left px-4 w-48\">{{ $strings.LabelPubDate }}</th>\n          </tr>\n          <template v-for=\"downloadQueued in queue\">\n            <tr :key=\"downloadQueued.id\">\n              <td class=\"px-4\">\n                <div class=\"flex items-center\">\n                  <nuxt-link :to=\"`/item/${downloadQueued.libraryItemId}`\" class=\"text-sm text-gray-200 hover:underline\">{{ downloadQueued.podcastTitle }}</nuxt-link>\n                  <widgets-explicit-indicator v-if=\"downloadQueued.podcastExplicit\" />\n                </div>\n              </td>\n              <td>\n                <div class=\"flex items-center\">\n                  <div v-if=\"downloadQueued.season\">{{ downloadQueued.season }}x</div>\n                  <div v-if=\"downloadQueued.episode\">{{ downloadQueued.episode }}</div>\n                  <widgets-podcast-type-indicator :type=\"downloadQueued.episodeType\" />\n                </div>\n              </td>\n              <td dir=\"auto\" class=\"px-4\">\n                {{ downloadQueued.episodeDisplayTitle }}\n              </td>\n              <td class=\"text-xs\">\n                <div class=\"flex items-center\">\n                  <p>{{ $dateDistanceFromNow(downloadQueued.publishedAt) }}</p>\n                </div>\n              </td>\n            </tr>\n          </template>\n        </table>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    queue: {\n      type: Array,\n      default: () => []\n    },\n    libraryItemId: String\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/podcast/LazyEpisodeRow.vue",
    "content": "<template>\n  <div :id=\"`lazy-episode-${index}`\" class=\"w-full h-full cursor-pointer\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <div class=\"flex\" @click=\"clickedEpisode\">\n      <div class=\"grow\">\n        <div dir=\"auto\" class=\"flex items-center\">\n          <span class=\"text-sm font-semibold\">{{ episodeTitle }}</span>\n          <widgets-podcast-type-indicator :type=\"episodeType\" />\n        </div>\n\n        <div class=\"h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden\">\n          <div dir=\"auto\" class=\"text-sm text-gray-200 line-clamp-2\" v-html=\"episodeSubtitle\"></div>\n        </div>\n\n        <div class=\"h-8 flex items-center\">\n          <p v-if=\"sortKey === 'audioFile.metadata.filename'\" class=\"text-sm text-gray-300 truncate font-light\">\n            <strong className=\"font-bold\">{{ $strings.LabelFilename }}</strong\n            >: {{ episode.audioFile.metadata.filename }}\n          </p>\n          <div v-else class=\"w-full inline-flex justify-between max-w-xl\">\n            <p v-if=\"episode?.season\" class=\"text-sm text-gray-300\">{{ $getString('LabelSeasonNumber', [episode.season]) }}</p>\n            <p v-if=\"episode?.episode\" class=\"text-sm text-gray-300\">{{ $getString('LabelEpisodeNumber', [episode.episode]) }}</p>\n            <p v-if=\"episode?.chapters?.length\" class=\"text-sm text-gray-300\">{{ $getString('LabelChapterCount', [episode.chapters.length]) }}</p>\n            <p v-if=\"publishedAt\" class=\"text-sm text-gray-300\">{{ $getString('LabelPublishedDate', [$formatDate(publishedAt, dateFormat)]) }}</p>\n          </div>\n        </div>\n\n        <div class=\"flex items-center pt-2\">\n          <button class=\"h-8 px-4 border border-white/20 hover:bg-white/10 rounded-full flex items-center justify-center cursor-pointer focus:outline-hidden\" :class=\"userIsFinished ? 'text-white/40' : ''\" @click.stop=\"playClick\">\n            <span class=\"material-symbols fill text-2xl\" aria-hidden=\"true\" :class=\"streamIsPlaying ? '' : 'text-success'\">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>\n            <span class=\"sr-only\">{{ streamIsPlaying ? $strings.ButtonPause : $strings.ButtonPlay }}</span>\n            <p class=\"pl-2 pr-1 text-sm font-semibold\" aria-hidden=\"true\">{{ timeRemaining }}</p>\n          </button>\n\n          <ui-tooltip v-if=\"libraryItemIdStreaming && !isStreamingFromDifferentLibrary\" :text=\"isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue\" :class=\"isQueued ? 'text-success' : ''\" direction=\"top\">\n            <ui-icon-btn :icon=\"isQueued ? 'playlist_add_check' : 'playlist_play'\" :aria-label=\"isQueued ? $strings.LabelRemoveFromPlayerQueue : $strings.LabelAddToPlayerQueue\" borderless @click=\"queueBtnClick\" />\n          </ui-tooltip>\n\n          <ui-tooltip :text=\"userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"top\">\n            <ui-read-icon-btn :disabled=\"isProcessingReadUpdate\" :is-read=\"userIsFinished\" borderless class=\"mx-1 mt-0.5\" @click=\"toggleFinished\" />\n          </ui-tooltip>\n\n          <ui-tooltip :text=\"$strings.LabelYourPlaylists\" direction=\"top\">\n            <ui-icon-btn icon=\"playlist_add\" :aria-label=\"$strings.LabelYourPlaylists\" borderless @click=\"clickAddToPlaylist\" />\n          </ui-tooltip>\n\n          <ui-icon-btn v-if=\"userCanUpdate\" icon=\"edit\" borderless @click=\"clickEdit\" />\n          <ui-icon-btn v-if=\"userCanDelete\" icon=\"close\" :aria-label=\"$strings.HeaderRemoveEpisode\" borderless @click=\"removeClick\" />\n        </div>\n      </div>\n      <div v-if=\"isHovering || isSelected || isSelectionMode\" class=\"hidden md:block w-12 min-w-12\" />\n    </div>\n\n    <div v-if=\"isSelected || isSelectionMode\" class=\"absolute top-0 left-0 w-full h-full bg-black/10 z-10 cursor-pointer\" @click.stop=\"clickedSelectionBg\" />\n    <div class=\"hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20\" :class=\"!isHovering && !isSelected && !isSelectionMode ? 'translate-x-24' : 'translate-x-0'\">\n      <div class=\"flex h-full items-center\">\n        <div class=\"mx-1\">\n          <ui-checkbox v-model=\"isSelected\" @input=\"selectedUpdated\" checkbox-bg=\"bg\" aria-label=\"Select episode\" />\n        </div>\n      </div>\n    </div>\n\n    <div v-if=\"!userIsFinished\" class=\"absolute bottom-0 left-0 h-0.5 bg-warning\" :style=\"{ width: itemProgressPercent * 100 + '%' }\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    index: Number,\n    libraryItemId: String,\n    episode: {\n      type: Object,\n      default: () => null\n    },\n    sortKey: String\n  },\n  data() {\n    return {\n      isProcessingReadUpdate: false,\n      processingRemove: false,\n      isHovering: false,\n      isSelected: false,\n      isSelectionMode: false\n    }\n  },\n  computed: {\n    store() {\n      return this.$store || this.$nuxt.$store\n    },\n    axios() {\n      return this.$axios || this.$nuxt.$axios\n    },\n    userCanUpdate() {\n      return this.store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.store.getters['user/getUserCanDelete']\n    },\n    episodeId() {\n      return this.episode?.id || ''\n    },\n    episodeTitle() {\n      return this.episode?.title || ''\n    },\n    episodeSubtitle() {\n      return this.episode?.subtitle || this.episode?.description || ''\n    },\n    episodeType() {\n      return this.episode?.episodeType || ''\n    },\n    publishedAt() {\n      return this.episode?.publishedAt\n    },\n    dateFormat() {\n      return this.store.getters['getServerSetting']('dateFormat')\n    },\n    itemProgress() {\n      return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)\n    },\n    itemProgressPercent() {\n      return this.itemProgress?.progress || 0\n    },\n    userIsFinished() {\n      return !!this.itemProgress?.isFinished\n    },\n    libraryItemIdStreaming() {\n      return this.store.getters['getLibraryItemIdStreaming']\n    },\n    isStreamingFromDifferentLibrary() {\n      return this.store.getters['getIsStreamingFromDifferentLibrary']\n    },\n    isStreaming() {\n      return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)\n    },\n    isQueued() {\n      return this.store.getters['getIsMediaQueued'](this.libraryItemId, this.episodeId)\n    },\n    streamIsPlaying() {\n      return this.store.state.streamIsPlaying && this.isStreaming\n    },\n    timeRemaining() {\n      if (this.streamIsPlaying) return this.$strings.ButtonPlaying\n      if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)\n      if (this.userIsFinished) return this.$strings.LabelFinished\n\n      const duration = this.itemProgress.duration || this.episode?.duration || 0\n      const remaining = Math.floor(duration - this.itemProgress.currentTime)\n      return this.$getString('LabelTimeLeft', [this.$elapsedPretty(remaining)])\n    }\n  },\n  methods: {\n    setSelectionMode(isSelectionMode) {\n      this.isSelectionMode = isSelectionMode\n      if (!this.isSelectionMode) this.isSelected = false\n    },\n    clickedEpisode() {\n      this.$emit('view', this.episode)\n    },\n    clickedSelectionBg() {\n      this.isSelected = !this.isSelected\n      this.selectedUpdated(this.isSelected)\n    },\n    selectedUpdated(value) {\n      this.$emit('selected', { isSelected: value, episode: this.episode })\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    },\n    playClick() {\n      if (this.streamIsPlaying) {\n        const eventBus = this.$eventBus || this.$nuxt.$eventBus\n        eventBus.$emit('pause-item')\n      } else {\n        this.$emit('play', this.episode)\n      }\n    },\n    queueBtnClick() {\n      if (this.isQueued) {\n        // Remove from queue\n        this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episodeId })\n      } else {\n        // Add to queue\n        this.$emit('addToQueue', this.episode)\n      }\n    },\n    toggleFinished(confirmed = false) {\n      if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {\n        const payload = {\n          message: this.$getString('MessageConfirmMarkItemFinished', [this.episodeTitle]),\n          callback: (confirmed) => {\n            if (confirmed) {\n              this.toggleFinished(true)\n            }\n          },\n          type: 'yesNo'\n        }\n        this.store.commit('globals/setConfirmPrompt', payload)\n        return\n      }\n\n      const updatePayload = {\n        isFinished: !this.userIsFinished\n      }\n      this.isProcessingReadUpdate = true\n      this.axios\n        .$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload)\n        .then(() => {\n          this.isProcessingReadUpdate = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.isProcessingReadUpdate = false\n          const toast = this.$toast || this.$nuxt.$toast\n          toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)\n        })\n    },\n    clickAddToPlaylist() {\n      this.$emit('addToPlaylist', this.episode)\n    },\n    clickEdit() {\n      this.$emit('edit', this.episode)\n    },\n    removeClick() {\n      this.$emit('remove', this.episode)\n    },\n    destroy() {\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      if (this.$el && this.$el.parentNode) {\n        this.$el.parentNode.removeChild(this.$el)\n      } else if (this.$el && this.$el.remove) {\n        this.$el.remove()\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/tables/podcast/LazyEpisodesTable.vue",
    "content": "<template>\n  <div id=\"lazy-episodes-table\" class=\"w-full py-6\">\n    <div class=\"flex flex-wrap flex-col md:flex-row md:items-center mb-4\">\n      <div class=\"flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0\">\n        <p class=\"text-lg mb-0 font-semibold\">{{ $strings.HeaderEpisodes }}</p>\n        <div class=\"inline-flex bg-white/5 px-1 mx-2 rounded-md text-sm text-gray-100\">\n          <p v-if=\"episodesList.length === episodes.length\">{{ episodes.length }}</p>\n          <p v-else>{{ episodesList.length }} / {{ episodes.length }}</p>\n        </div>\n      </div>\n      <div class=\"grow hidden md:block\" />\n      <div class=\"flex items-center\">\n        <template v-if=\"isSelectionMode\">\n          <ui-tooltip :text=\"selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"bottom\">\n            <ui-read-icon-btn :disabled=\"processing\" :is-read=\"selectedIsFinished\" @click=\"toggleBatchFinished\" class=\"mx-1.5\" />\n          </ui-tooltip>\n          <ui-btn color=\"bg-error\" :disabled=\"processing\" small class=\"h-9\" @click=\"removeSelectedEpisodes\">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>\n          <ui-btn :disabled=\"processing\" small class=\"ml-2 h-9\" @click=\"clearSelected\">{{ $strings.ButtonCancel }}</ui-btn>\n        </template>\n        <template v-else>\n          <controls-filter-select v-model=\"filterKey\" :items=\"filterItems\" class=\"w-36 h-9 md:ml-4\" @change=\"filterSortChanged\" />\n          <controls-sort-select v-model=\"sortKey\" :descending.sync=\"sortDesc\" :items=\"sortItems\" class=\"w-44 md:w-48 h-9 ml-1 sm:ml-4\" @change=\"filterSortChanged\" />\n          <div class=\"grow md:hidden\" />\n          <ui-context-menu-dropdown v-if=\"contextMenuItems.length\" :items=\"contextMenuItems\" class=\"ml-1\" @action=\"contextMenuAction\" />\n        </template>\n      </div>\n    </div>\n    <div v-if=\"episodes.length\" class=\"w-full py-3 mx-auto flex\">\n      <form @submit.prevent=\"submit\" class=\"flex grow\">\n        <ui-text-input v-model=\"search\" @input=\"inputUpdate\" type=\"search\" :placeholder=\"$strings.PlaceholderSearchEpisode\" class=\"grow mr-2 text-sm md:text-base\" />\n      </form>\n    </div>\n    <div class=\"relative min-h-44\">\n      <template v-for=\"episode in totalEpisodes\">\n        <div :key=\"episode\" :id=\"`episode-${episode - 1}`\" class=\"w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10\">\n          <!-- episode is mounted here -->\n        </div>\n      </template>\n      <div v-if=\"isSearching\" class=\"w-full h-full absolute inset-0 flex justify-center py-12\" :class=\"{ 'bg-black/50': totalEpisodes }\">\n        <ui-loading-indicator />\n      </div>\n      <div v-else-if=\"!totalEpisodes\" id=\"no-episodes\" class=\"h-44 flex items-center justify-center\">\n        <p class=\"text-lg\">{{ $strings.MessageNoEpisodes }}</p>\n      </div>\n    </div>\n\n    <modals-podcast-remove-episode v-model=\"showPodcastRemoveModal\" @input=\"removeEpisodeModalToggled\" :library-item=\"libraryItem\" :episodes=\"episodesToRemove\" @clearSelected=\"clearSelected\" />\n  </div>\n</template>\n\n<script>\nimport Vue from 'vue'\nimport LazyEpisodeRow from './LazyEpisodeRow.vue'\n\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      episodesCopy: [],\n      filterKey: 'incomplete',\n      sortKey: 'publishedAt',\n      sortDesc: true,\n      selectedEpisode: null,\n      showPodcastRemoveModal: false,\n      selectedEpisodes: [],\n      episodesToRemove: [],\n      processing: false,\n      search: null,\n      searchTimeout: null,\n      searchText: null,\n      isSearching: false,\n      totalEpisodes: 0,\n      episodesPerPage: null,\n      episodeIndexesMounted: [],\n      episodeComponentRefs: {},\n      windowHeight: 0,\n      episodesTableOffsetTop: 0,\n      episodeRowHeight: 44 * 4, // h-44,\n      currScrollTop: 0\n    }\n  },\n  watch: {\n    libraryItem: {\n      handler() {\n        this.refresh()\n      }\n    }\n  },\n  computed: {\n    contextMenuItems() {\n      const menuItems = []\n      if (this.userIsAdminOrUp) {\n        menuItems.push({\n          text: this.$strings.MessageQuickMatchAllEpisodes,\n          action: 'quick-match-episodes'\n        })\n      }\n      menuItems.push({\n        text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,\n        action: 'batch-mark-as-finished'\n      })\n      return menuItems\n    },\n    sortItems() {\n      return [\n        {\n          text: this.$strings.LabelPubDate,\n          value: 'publishedAt'\n        },\n        {\n          text: this.$strings.LabelTitle,\n          value: 'title'\n        },\n        {\n          text: this.$strings.LabelSeason,\n          value: 'season'\n        },\n        {\n          text: this.$strings.LabelEpisode,\n          value: 'episode'\n        },\n        {\n          text: this.$strings.LabelFilename,\n          value: 'audioFile.metadata.filename'\n        }\n      ]\n    },\n    filterItems() {\n      return [\n        {\n          value: 'all',\n          text: this.$strings.LabelShowAll\n        },\n        {\n          value: 'incomplete',\n          text: this.$strings.LabelIncomplete\n        },\n        {\n          value: 'complete',\n          text: this.$strings.LabelComplete\n        },\n        {\n          value: 'in_progress',\n          text: this.$strings.LabelInProgress\n        }\n      ]\n    },\n    isSelectionMode() {\n      return this.selectedEpisodes.length > 0\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    media() {\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    episodes() {\n      return this.media.episodes || []\n    },\n    episodesSorted() {\n      return this.episodesCopy\n        .filter((ep) => {\n          if (this.filterKey === 'all') return true\n          const episodeProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, ep.id)\n          if (this.filterKey === 'incomplete') return !episodeProgress || !episodeProgress.isFinished\n          if (this.filterKey === 'complete') return episodeProgress && episodeProgress.isFinished\n          return episodeProgress && !episodeProgress.isFinished\n        })\n        .sort((a, b) => {\n          // Swap values if sort descending\n          if (this.sortDesc) {\n            const temp = a\n            a = b\n            b = temp\n          }\n\n          let aValue\n          let bValue\n\n          if (this.sortKey.includes('.')) {\n            const getNestedValue = (ob, s) => s.split('.').reduce((o, k) => o?.[k], ob)\n            aValue = getNestedValue(a, this.sortKey)\n            bValue = getNestedValue(b, this.sortKey)\n          } else {\n            aValue = a[this.sortKey]\n            bValue = b[this.sortKey]\n          }\n\n          // Sort episodes with no pub date as the oldest\n          if (this.sortKey === 'publishedAt') {\n            if (!aValue) aValue = Number.MAX_VALUE\n            if (!bValue) bValue = Number.MAX_VALUE\n          }\n\n          const primaryCompare = String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })\n          if (primaryCompare !== 0 || this.sortKey === 'publishedAt') return primaryCompare\n\n          // When sorting by season, secondary sort is by episode number\n          if (this.sortKey === 'season') {\n            const aEpisode = a.episode || ''\n            const bEpisode = b.episode || ''\n\n            const secondaryCompare = String(aEpisode).localeCompare(String(bEpisode), undefined, { numeric: true, sensitivity: 'base' })\n            if (secondaryCompare !== 0) return secondaryCompare\n          }\n\n          // Final sort by publishedAt\n          let aPubDate = a.publishedAt || Number.MAX_VALUE\n          let bPubDate = b.publishedAt || Number.MAX_VALUE\n\n          return String(aPubDate).localeCompare(String(bPubDate), undefined, { numeric: true, sensitivity: 'base' })\n        })\n    },\n    episodesList() {\n      return this.episodesSorted.filter((episode) => {\n        if (!this.searchText) return true\n        return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)\n      })\n    },\n    selectedIsFinished() {\n      // Find an item that is not finished, if none then all items finished\n      return !this.selectedEpisodes.some((episode) => {\n        const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)\n        return !itemProgress?.isFinished\n      })\n    },\n    allEpisodesFinished() {\n      return !this.episodesSorted.some((episode) => {\n        const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)\n        return !itemProgress?.isFinished\n      })\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    submit() {},\n    inputUpdate() {\n      clearTimeout(this.searchTimeout)\n      this.isSearching = true\n      let searchStart = this.searchText\n      this.searchTimeout = setTimeout(() => {\n        this.isSearching = false\n        if (!this.search?.trim()) {\n          this.searchText = ''\n        } else {\n          this.searchText = this.search.toLowerCase().trim()\n        }\n        if (searchStart !== this.searchText) {\n          this.init()\n        }\n      }, 750)\n    },\n    contextMenuAction({ action }) {\n      if (action === 'quick-match-episodes') {\n        if (this.processing) return\n\n        this.quickMatchAllEpisodes()\n      } else if (action === 'batch-mark-as-finished') {\n        if (this.processing) return\n\n        this.markAllEpisodesFinished()\n      }\n    },\n    markAllEpisodesFinished() {\n      const newIsFinished = !this.allEpisodesFinished\n      const payload = {\n        message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.batchUpdateEpisodesFinished(this.episodesCopy, newIsFinished)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    quickMatchAllEpisodes() {\n      if (!this.mediaMetadata.feedUrl) {\n        this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)\n        return\n      }\n      this.processing = true\n\n      const payload = {\n        message: this.$strings.MessageConfirmQuickMatchEpisodes,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$axios\n              .$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)\n              .then((data) => {\n                if (data.numEpisodesUpdated) {\n                  this.$toast.success(this.$getString('ToastEpisodeUpdateSuccess', [data.numEpisodesUpdated]))\n                } else {\n                  this.$toast.info(this.$strings.ToastNoUpdatesNecessary)\n                }\n              })\n              .catch((error) => {\n                console.error('Failed to request match episodes', error)\n                this.$toast.error(this.$strings.ToastFailedToMatch)\n              })\n          }\n          this.processing = false\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    addToPlaylist(episode) {\n      this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])\n      this.$store.commit('globals/setShowPlaylistsModal', true)\n    },\n    addEpisodeToQueue(episode) {\n      const queueItem = {\n        libraryItemId: this.libraryItem.id,\n        libraryId: this.libraryItem.libraryId,\n        episodeId: episode.id,\n        title: episode.title,\n        subtitle: this.mediaMetadata.title,\n        caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n        duration: episode.audioFile.duration || null,\n        coverPath: this.media.coverPath || null\n      }\n      this.$store.commit('addItemToQueue', queueItem)\n    },\n    toggleBatchFinished() {\n      this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)\n    },\n    batchUpdateEpisodesFinished(episodes, newIsFinished) {\n      if (!episodes.length) return\n      this.processing = true\n\n      const updateProgressPayloads = episodes.map((episode) => {\n        return {\n          libraryItemId: this.libraryItem.id,\n          episodeId: episode.id,\n          isFinished: newIsFinished\n        }\n      })\n      return this.$axios\n        .patch(`/api/me/progress/batch/update`, updateProgressPayloads)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastBatchUpdateSuccess)\n          this.processing = false\n          this.clearSelected()\n        })\n        .catch((error) => {\n          this.$toast.error(this.$strings.ToastBatchUpdateFailed)\n          console.error('Failed to batch update read/not read', error)\n          this.processing = false\n        })\n    },\n    removeEpisodeModalToggled(val) {\n      if (!val) this.episodesToRemove = []\n    },\n    clearSelected() {\n      this.selectedEpisodes = []\n      this.setSelectionModeForEpisodes()\n    },\n    removeSelectedEpisodes() {\n      this.episodesToRemove = this.selectedEpisodes\n      this.showPodcastRemoveModal = true\n    },\n    episodeSelected({ isSelected, episode }) {\n      let isSelectionModeBefore = this.isSelectionMode\n      if (isSelected) {\n        this.selectedEpisodes.push(episode)\n      } else {\n        this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)\n      }\n      if (this.isSelectionMode !== isSelectionModeBefore) {\n        this.setSelectionModeForEpisodes()\n      }\n    },\n    setSelectionModeForEpisodes() {\n      for (const key in this.episodeComponentRefs) {\n        if (this.episodeComponentRefs[key]?.setSelectionMode) {\n          this.episodeComponentRefs[key].setSelectionMode(this.isSelectionMode)\n        }\n      }\n    },\n    playEpisode(episode) {\n      const queueItems = []\n\n      const episodesInListeningOrder = this.episodesList\n      const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)\n      for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {\n        const _episode = episodesInListeningOrder[i]\n        const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, _episode.id)\n        if (!podcastProgress?.isFinished || episode.id === _episode.id) {\n          queueItems.push({\n            libraryItemId: this.libraryItem.id,\n            libraryId: this.libraryItem.libraryId,\n            episodeId: _episode.id,\n            title: _episode.title,\n            subtitle: this.mediaMetadata.title,\n            caption: _episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(_episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n            duration: _episode.audioFile.duration || null,\n            coverPath: this.media.coverPath || null\n          })\n        }\n      }\n\n      this.$eventBus.$emit('play-item', {\n        libraryItemId: this.libraryItem.id,\n        episodeId: episode.id,\n        queueItems\n      })\n    },\n    removeEpisode(episode) {\n      this.episodesToRemove = [episode]\n      this.showPodcastRemoveModal = true\n    },\n    editEpisode(episode) {\n      const episodeIds = this.episodesSorted.map((e) => e.id)\n      this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)\n      this.$store.commit('setSelectedLibraryItem', this.libraryItem)\n      this.$store.commit('globals/setSelectedEpisode', episode)\n      this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)\n    },\n    viewEpisode(episode) {\n      this.$store.commit('setSelectedLibraryItem', this.libraryItem)\n      this.$store.commit('globals/setSelectedEpisode', episode)\n      this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)\n    },\n    destroyEpisodeComponents() {\n      for (const key in this.episodeComponentRefs) {\n        if (this.episodeComponentRefs[key]?.destroy) {\n          this.episodeComponentRefs[key].destroy()\n        }\n      }\n      this.episodeComponentRefs = {}\n      this.episodeIndexesMounted = []\n    },\n    mountEpisode(index) {\n      const episodeEl = document.getElementById(`episode-${index}`)\n      if (!episodeEl) {\n        console.warn('Episode row el not found at ' + index)\n        return\n      }\n\n      this.episodeIndexesMounted.push(index)\n\n      if (this.episodeComponentRefs[index]) {\n        const episodeComponent = this.episodeComponentRefs[index]\n        episodeEl.appendChild(episodeComponent.$el)\n        if (this.isSelectionMode) {\n          episodeComponent.setSelectionMode(true)\n          if (this.selectedEpisodes.some((i) => i.id === episodeComponent.episodeId)) {\n            episodeComponent.isSelected = true\n          } else {\n            episodeComponent.isSelected = false\n          }\n        } else {\n          episodeComponent.setSelectionMode(false)\n        }\n      } else {\n        const _this = this\n        const ComponentClass = Vue.extend(LazyEpisodeRow)\n        const instance = new ComponentClass({\n          propsData: {\n            index,\n            libraryItemId: this.libraryItem.id,\n            episode: this.episodesList[index],\n            sortKey: this.sortKey\n          },\n          created() {\n            this.$on('selected', (payload) => {\n              _this.episodeSelected(payload)\n            })\n            this.$on('view', (payload) => {\n              _this.viewEpisode(payload)\n            })\n            this.$on('play', (payload) => {\n              _this.playEpisode(payload)\n            })\n            this.$on('addToQueue', (payload) => {\n              _this.addEpisodeToQueue(payload)\n            })\n            this.$on('remove', (payload) => {\n              _this.removeEpisode(payload)\n            })\n            this.$on('edit', (payload) => {\n              _this.editEpisode(payload)\n            })\n            this.$on('addToPlaylist', (payload) => {\n              _this.addToPlaylist(payload)\n            })\n          }\n        })\n        this.episodeComponentRefs[index] = instance\n        instance.$mount()\n        episodeEl.appendChild(instance.$el)\n\n        if (this.isSelectionMode) {\n          instance.setSelectionMode(true)\n          if (this.selectedEpisodes.some((i) => i.id === this.episodesList[index].id)) {\n            instance.isSelected = true\n          }\n        }\n      }\n    },\n    mountEpisodes(startIndex, endIndex) {\n      for (let i = startIndex; i < endIndex; i++) {\n        if (!this.episodeIndexesMounted.includes(i)) {\n          this.mountEpisode(i)\n        }\n      }\n    },\n    handleScroll() {\n      const scrollTop = this.currScrollTop\n      let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)\n      let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)\n      lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)\n\n      this.episodeIndexesMounted = this.episodeIndexesMounted.filter((_index) => {\n        if (_index < firstEpisodeIndex || _index >= lastEpisodeIndex) {\n          const el = document.getElementById(`lazy-episode-${_index}`)\n          if (el) el.remove()\n          return false\n        }\n        return true\n      })\n      this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)\n    },\n    scroll(evt) {\n      if (!evt?.target?.scrollTop) return\n      const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)\n      this.currScrollTop = scrollTop\n      this.handleScroll()\n    },\n    initListeners() {\n      const itemPageWrapper = document.getElementById('item-page-wrapper')\n      if (itemPageWrapper) {\n        itemPageWrapper.addEventListener('scroll', this.scroll)\n      }\n    },\n    removeListeners() {\n      const itemPageWrapper = document.getElementById('item-page-wrapper')\n      if (itemPageWrapper) {\n        itemPageWrapper.removeEventListener('scroll', this.scroll)\n      }\n    },\n    filterSortChanged() {\n      // Save filterKey and sortKey to local storage\n      localStorage.setItem('podcastEpisodesFilter', this.filterKey)\n      localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))\n\n      this.init()\n    },\n    refresh() {\n      this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))\n      this.init()\n    },\n    init() {\n      this.destroyEpisodeComponents()\n      this.totalEpisodes = this.episodesList.length\n\n      const lazyEpisodesTableEl = document.getElementById('lazy-episodes-table')\n      this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64\n\n      this.windowHeight = window.innerHeight\n\n      this.$nextTick(() => {\n        this.recalcEpisodeRowHeight()\n        this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)\n        // Maybe update currScrollTop if items were removed\n        const itemPageWrapper = document.getElementById('item-page-wrapper')\n        const { scrollHeight, clientHeight } = itemPageWrapper\n        const maxScrollTop = scrollHeight - clientHeight\n        this.currScrollTop = Math.min(this.currScrollTop, maxScrollTop)\n        this.handleScroll()\n      })\n    },\n    recalcEpisodeRowHeight() {\n      const episodeRowEl = document.getElementById('episode-0') || document.getElementById('no-episodes')\n      if (episodeRowEl) {\n        const height = getComputedStyle(episodeRowEl).height\n        this.episodeRowHeight = parseInt(height) || this.episodeRowHeight\n      }\n    }\n  },\n  mounted() {\n    this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete'\n    const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc'\n    this.sortKey = sortBy.split('-')[0]\n    this.sortDesc = sortBy.split('-')[1] === 'desc'\n\n    this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))\n    this.initListeners()\n    this.init()\n  },\n  beforeDestroy() {\n    this.removeListeners()\n  }\n}\n</script>\n\n<style>\n.episode-item {\n  transition: all 0.4s ease;\n}\n\n.episode-enter-from,\n.episode-leave-to {\n  opacity: 0;\n  transform: translateX(30px);\n}\n\n.episode-leave-active {\n  position: absolute;\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/Btn.vue",
    "content": "<template>\n  <nuxt-link v-if=\"to\" :to=\"to\" class=\"abs-btn rounded-md shadow-md relative border border-gray-600 text-center\" :disabled=\"disabled || loading\" :class=\"classList\" @click.native=\"click\">\n    <slot />\n    <div v-if=\"loading\" class=\"text-white/100 absolute top-0 left-0 w-full h-full flex items-center justify-center\">\n      <svg class=\"animate-spin\" style=\"width: 24px; height: 24px\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z\" />\n      </svg>\n    </div>\n  </nuxt-link>\n  <button v-else class=\"abs-btn rounded-md shadow-md relative border border-gray-600\" :disabled=\"disabled || loading\" :type=\"type\" :class=\"classList\" @mousedown.prevent @click=\"click\">\n    <slot />\n    <div v-if=\"loading\" class=\"text-white/100 absolute top-0 left-0 w-full h-full flex items-center justify-center\">\n      <span v-if=\"progress\">{{ progress }}</span>\n      <svg v-else class=\"animate-spin\" style=\"width: 24px; height: 24px\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z\" />\n      </svg>\n    </div>\n  </button>\n</template>\n\n<script>\nexport default {\n  props: {\n    to: String,\n    color: {\n      type: String,\n      default: 'bg-primary'\n    },\n    type: {\n      type: String,\n      default: ''\n    },\n    paddingX: Number,\n    paddingY: Number,\n    small: Boolean,\n    loading: Boolean,\n    disabled: Boolean,\n    progress: String\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    classList() {\n      var list = []\n      list.push(this.loading ? 'text-white/0' : 'text-white')\n      list.push(`${this.color}`)\n      if (this.small) {\n        list.push('text-sm')\n        if (this.paddingX === undefined) list.push('px-4')\n        if (this.paddingY === undefined) list.push('py-1')\n      } else {\n        if (this.paddingX === undefined) list.push('px-8')\n        if (this.paddingY === undefined) list.push('py-2')\n      }\n      if (this.paddingX !== undefined) {\n        list.push(`px-${this.paddingX}`)\n      }\n      if (this.paddingY !== undefined) {\n        list.push(`py-${this.paddingY}`)\n      }\n      if (this.disabled) {\n        list.push('cursor-not-allowed')\n      }\n      return list\n    }\n  },\n  methods: {\n    click(e) {\n      this.$emit('click', e)\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/Checkbox.vue",
    "content": "<template>\n  <label tabindex=\"0\" ref=\"labelRef\" class=\"flex justify-start items-center\" :class=\"!disabled ? 'cursor-pointer' : ''\" @keydown.enter=\"enterKeydown\">\n    <div class=\"border-2 rounded-sm flex shrink-0 justify-center items-center\" :class=\"wrapperClass\">\n      <input v-model=\"selected\" tabindex=\"-1\" :disabled=\"disabled\" type=\"checkbox\" :aria-label=\"ariaLabel\" class=\"opacity-0 absolute\" :class=\"!disabled ? 'cursor-pointer' : ''\" />\n      <span v-if=\"partial\" class=\"material-symbols text-base leading-none text-gray-400\">remove</span>\n      <svg v-else-if=\"selected\" class=\"fill-current pointer-events-none\" :class=\"svgClass\" viewBox=\"0 0 20 20\"><path d=\"M0 11l2-2 5 5L18 3l2 2L7 18z\" /></svg>\n    </div>\n    <div v-if=\"label\" class=\"select-none\" :class=\"[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']\">{{ label }}</div>\n  </label>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    label: String,\n    small: Boolean,\n    medium: Boolean,\n    checkboxBg: {\n      type: String,\n      default: 'white'\n    },\n    borderColor: {\n      type: String,\n      default: 'gray-400'\n    },\n    checkColor: {\n      type: String,\n      default: 'green-500'\n    },\n    labelClass: {\n      type: String,\n      default: ''\n    },\n    disabled: Boolean,\n    partial: Boolean,\n    ariaLabel: {\n      type: String,\n      default: ''\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', !!val)\n      }\n    },\n    wrapperClass() {\n      var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]\n      if (this.small) classes.push('w-4 h-4')\n      else if (this.medium) classes.push('w-5 h-5')\n      else classes.push('w-6 h-6')\n\n      return classes.join(' ')\n    },\n    labelClassname() {\n      if (this.labelClass) return this.labelClass\n      var classes = []\n      if (this.small) classes.push('text-xs md:text-sm pl-1')\n      else if (this.medium) classes.push('text-base md:text-lg pl-2')\n      else classes.push('pl-2')\n      return classes.join(' ')\n    },\n    svgClass() {\n      var classes = [`text-${this.checkColor}`]\n      if (this.small) classes.push('w-3 h-3')\n      else if (this.medium) classes.push('w-3.5 h-3.5')\n      else classes.push('w-4 h-4')\n\n      return classes.join(' ')\n    }\n  },\n  methods: {\n    enterKeydown() {\n      // Only toggle checkbox if label is focused (from tabbing)\n      if (document.activeElement === this.$refs.labelRef) {\n        this.toggleCheckbox()\n      }\n    },\n    toggleCheckbox() {\n      if (!this.disabled) this.selected = !this.selected\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/ContextMenuDropdown.vue",
    "content": "<template>\n  <div class=\"relative h-9 w-9\" v-click-outside=\"clickOutsideObj\">\n    <slot :disabled=\"disabled\" :showMenu=\"showMenu\" :clickShowMenu=\"clickShowMenu\" :processing=\"processing\">\n      <button v-if=\"!processing\" type=\"button\" :disabled=\"disabled\" class=\"relative h-full w-full flex items-center justify-center shadow-xs pl-3 pr-3 text-left focus:outline-hidden cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5\" :aria-label=\"$strings.LabelMore\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" @click.stop.prevent=\"clickShowMenu\">\n        <span class=\"material-symbols text-2xl\" :class=\"iconClass\">&#xe5d4;</span>\n      </button>\n      <div v-else class=\"h-full w-full flex items-center justify-center\">\n        <widgets-loading-spinner />\n      </div>\n    </slot>\n\n    <transition name=\"menu\">\n      <div v-show=\"showMenu\" ref=\"menuWrapper\" role=\"menu\" class=\"absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-hidden sm:text-sm\" :style=\"{ width: menuWidth + 'px' }\">\n        <template v-for=\"(item, index) in items\">\n          <template v-if=\"item.subitems\">\n            <button :key=\"index\" role=\"menuitem\" aria-haspopup=\"menu\" class=\"flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full\" :class=\"{ 'bg-white/5': mouseoverItemIndex == index }\" @mouseover=\"mouseoverItem(index)\" @mouseleave=\"mouseleaveItem(index)\" @click.stop>\n              <p>{{ item.text }}</p>\n            </button>\n            <div\n              v-if=\"mouseoverItemIndex === index\"\n              :key=\"`subitems-${index}`\"\n              @mouseover=\"mouseoverSubItemMenu(index)\"\n              @mouseleave=\"mouseleaveSubItemMenu(index)\"\n              class=\"absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1\"\n              :class=\"openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'\"\n              :style=\"{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }\"\n            >\n              <button v-for=\"(subitem, subitemindex) in item.subitems\" :key=\"`subitem-${subitemindex}`\" role=\"menuitem\" class=\"flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full\" @click.stop=\"clickAction(subitem.action, subitem.data)\">\n                <p>{{ subitem.text }}</p>\n              </button>\n            </div>\n          </template>\n          <button v-else :key=\"index\" role=\"menuitem\" class=\"flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full\" @click.stop=\"clickAction(item.action)\">\n            <p class=\"text-left\">{{ item.text }}</p>\n          </button>\n        </template>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    disabled: Boolean,\n    items: {\n      type: Array,\n      default: () => []\n    },\n    iconClass: {\n      type: String,\n      default: ''\n    },\n    menuWidth: {\n      type: Number,\n      default: 192\n    },\n    processing: Boolean\n  },\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      submenuWidth: 144,\n      showMenu: false,\n      mouseoverItemIndex: null,\n      isOverSubItemMenu: false,\n      openSubMenuLeft: false\n    }\n  },\n  computed: {\n    submenuLeftPos() {\n      return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5\n    }\n  },\n  methods: {\n    mouseoverSubItemMenu(index) {\n      this.isOverSubItemMenu = true\n    },\n    mouseleaveSubItemMenu(index) {\n      setTimeout(() => {\n        if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null\n      }, 1)\n    },\n    mouseoverItem(index) {\n      this.isOverSubItemMenu = false\n      this.mouseoverItemIndex = index\n    },\n    mouseleaveItem(index) {\n      setTimeout(() => {\n        if (this.isOverSubItemMenu) return\n        if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null\n      }, 1)\n    },\n    clickShowMenu() {\n      if (this.disabled) return\n      this.showMenu = !this.showMenu\n      this.$nextTick(() => {\n        const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()\n        if (boundingRect) {\n          this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5\n        }\n      })\n    },\n    clickedOutside() {\n      this.showMenu = false\n    },\n    clickAction(action, data) {\n      if (this.disabled) return\n      this.showMenu = false\n      this.$emit('action', { action, data })\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/Dropdown.vue",
    "content": "<template>\n  <div class=\"relative w-full\" v-click-outside=\"clickOutsideObj\">\n    <p v-if=\"label\" class=\"text-sm font-semibold px-1\" :class=\"disabled ? 'text-gray-300' : ''\">{{ label }}</p>\n    <button type=\"button\" :aria-label=\"longLabel\" :disabled=\"disabled\" class=\"relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm\" :class=\"buttonClass\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" @click.stop.prevent=\"clickShowMenu\">\n      <span class=\"flex items-center\">\n        <span class=\"block truncate font-sans\" :class=\"{ 'font-semibold': selectedSubtext, 'text-sm': small }\">{{ selectedText }}</span>\n        <span v-if=\"selectedSubtext\">:&nbsp;</span>\n        <span v-if=\"selectedSubtext\" class=\"font-normal block truncate font-sans text-sm text-gray-400\">{{ selectedSubtext }}</span>\n      </span>\n      <span class=\"ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none\">\n        <span class=\"material-symbols text-2xl\">expand_more</span>\n      </span>\n    </button>\n\n    <transition name=\"menu\">\n      <ul v-show=\"showMenu\" class=\"absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black/5 overflow-auto sm:text-sm\" tabindex=\"-1\" role=\"menu\" :style=\"{ maxHeight: menuMaxHeight }\">\n        <template v-for=\"item in itemsToShow\">\n          <li :key=\"item.value\" class=\"text-gray-100 relative py-2 cursor-pointer hover:bg-black-400\" role=\"menuitem\" tabindex=\"0\" @keyup.enter=\"clickedOption(item.value)\" @click=\"clickedOption(item.value)\">\n            <div class=\"flex items-center\">\n              <span class=\"ml-3 block truncate font-sans text-sm\" :class=\"{ 'font-semibold': item.subtext }\">{{ item.text }}</span>\n              <span v-if=\"item.subtext\">:&nbsp;</span>\n              <span v-if=\"item.subtext\" class=\"font-normal block truncate font-sans text-sm text-gray-400\">{{ item.subtext }}</span>\n            </div>\n          </li>\n        </template>\n      </ul>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    label: {\n      type: String,\n      default: ''\n    },\n    items: {\n      type: Array,\n      default: () => []\n    },\n    disabled: Boolean,\n    small: Boolean,\n    menuMaxHeight: {\n      type: String,\n      default: '224px'\n    }\n  },\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      showMenu: false\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    itemsToShow() {\n      return this.items.map((i) => {\n        if (typeof i === 'string' || typeof i === 'number') {\n          return {\n            text: i,\n            value: i\n          }\n        }\n        return i\n      })\n    },\n    selectedItem() {\n      return this.itemsToShow.find((i) => i.value === this.selected)\n    },\n    selectedText() {\n      return this.selectedItem ? this.selectedItem.text : ''\n    },\n    selectedSubtext() {\n      return this.selectedItem ? this.selectedItem.subtext : ''\n    },\n    buttonClass() {\n      var classes = []\n      if (this.small) classes.push('h-9')\n      else classes.push('h-10')\n\n      if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary/70 border-opacity-70 text-gray-400')\n      else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')\n\n      return classes.join(' ')\n    },\n    longLabel() {\n      let result = ''\n      if (this.label) result += this.label + ': '\n      if (this.selectedText) result += this.selectedText\n      if (this.selectedSubtext) result += ' ' + this.selectedSubtext\n      return result\n    }\n  },\n  methods: {\n    clickShowMenu() {\n      if (this.disabled) return\n      this.showMenu = !this.showMenu\n    },\n    clickedOutside() {\n      this.showMenu = false\n    },\n    clickedOption(itemValue) {\n      this.selected = itemValue\n      this.showMenu = false\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/EditableText.vue",
    "content": "<template>\n  <input ref=\"input\" v-model=\"inputValue\" :type=\"type\" :readonly=\"readonly\" :disabled=\"disabled\" :placeholder=\"placeholder\" class=\"py-2 px-1 bg-transparent border-b/0 border-gray-400 focus:border-opacity-100 focus:outline-hidden\" @keyup=\"keyup\" @change=\"change\" @focus=\"focused\" @blur=\"blurred\" />\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    placeholder: String,\n    readonly: Boolean,\n    type: {\n      type: String,\n      default: 'text'\n    },\n    disabled: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    inputValue: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    focused() {\n      this.$emit('focus')\n    },\n    blurred() {\n      this.$emit('blur')\n    },\n    change(e) {\n      this.$emit('change', e.target.value)\n    },\n    keyup(e) {\n      this.$emit('keyup', e)\n    },\n    blur() {\n      if (this.$refs.input) this.$refs.input.blur()\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\ninput {\n  border-style: inherit !important;\n}\ninput:read-only {\n  background-color: #444;\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/FileInput.vue",
    "content": "<template>\n  <div>\n    <input ref=\"fileInput\" type=\"file\" :accept=\"accept\" class=\"hidden\" @change=\"inputChanged\" />\n    <ui-btn @click=\"clickUpload\" color=\"bg-primary\" class=\"hidden md:block w-full\" type=\"text\"><slot /></ui-btn>\n    <ui-icon-btn @click=\"clickUpload\" icon=\"upload\" class=\"block md:hidden\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    accept: {\n      type: String,\n      default: '.png, .jpg, .jpeg, .webp'\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {\n    reset() {\n      if (this.$refs.fileInput) {\n        this.$refs.fileInput.value = ''\n      }\n    },\n    clickUpload() {\n      if (this.$refs.fileInput) {\n        this.$refs.fileInput.click()\n      }\n    },\n    inputChanged(e) {\n      if (!e.target || !e.target.files) return\n      var _files = Array.from(e.target.files)\n      if (_files && _files.length) {\n        var file = _files[0]\n        this.$emit('change', file)\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/IconBtn.vue",
    "content": "<template>\n  <button :aria-label=\"ariaLabel\" class=\"icon-btn rounded-md flex items-center justify-center relative\" @mousedown.prevent :disabled=\"disabled || loading\" :class=\"className\" @click=\"clickBtn\">\n    <div v-if=\"loading\" class=\"text-white/100 absolute top-0 left-0 w-full h-full flex items-center justify-center\">\n      <svg class=\"animate-spin\" style=\"width: 24px; height: 24px\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z\" />\n      </svg>\n    </div>\n    <span v-else :class=\"outlined ? 'material-symbols' : 'material-symbols fill'\" :style=\"{ fontSize }\" v-html=\"icon\" />\n  </button>\n</template>\n\n<script>\nexport default {\n  props: {\n    icon: String,\n    disabled: Boolean,\n    bgColor: {\n      type: String,\n      default: 'bg-primary'\n    },\n    outlined: Boolean,\n    borderless: Boolean,\n    loading: Boolean,\n    iconFontSize: {\n      type: String,\n      default: ''\n    },\n    size: {\n      type: Number,\n      default: 9\n    },\n    ariaLabel: String\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    className() {\n      var classes = [`h-${this.size} w-${this.size}`]\n      if (!this.borderless) {\n        classes.push(`${this.bgColor} border border-gray-600`)\n      }\n      return classes.join(' ')\n    },\n    fontSize() {\n      if (this.iconFontSize) return this.iconFontSize\n      if (this.icon === 'edit') return '1.25rem'\n      return '1.4rem'\n    }\n  },\n  methods: {\n    clickBtn(e) {\n      if (this.disabled || this.loading) {\n        e.preventDefault()\n        return\n      }\n      e.preventDefault()\n      this.$emit('click', e)\n      e.stopPropagation()\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style>\nbutton.icon-btn:disabled {\n  cursor: not-allowed;\n}\nbutton.icon-btn::before {\n  content: '';\n  position: absolute;\n  border-radius: 6px;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(255, 255, 255, 0);\n  transition: all 0.1s ease-in-out;\n}\nbutton.icon-btn:hover:not(:disabled)::before {\n  background-color: rgba(255, 255, 255, 0.1);\n}\nbutton.icon-btn:disabled::before {\n  background-color: rgba(0, 0, 0, 0.2);\n}\nbutton.icon-btn:disabled span {\n  color: #777;\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/InputDropdown.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <label v-if=\"label\" class=\"px-1 text-sm font-semibold\" :class=\"disabled ? 'text-gray-400' : ''\">{{ label }}</label>\n    <div ref=\"wrapper\" class=\"relative\">\n      <form @submit.prevent=\"submitForm\">\n        <div ref=\"inputWrapper\" class=\"input-wrapper flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-2\" :class=\"disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'\">\n          <input ref=\"input\" v-model=\"textInput\" :disabled=\"disabled\" :readonly=\"!editable\" class=\"h-full w-full bg-transparent focus:outline-hidden px-1\" @focus=\"inputFocus\" @blur=\"inputBlur\" @keydown=\"keydownHandler\" />\n        </div>\n      </form>\n\n      <ul ref=\"menu\" v-show=\"isFocused && itemsToShow.length\" class=\"absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-sm py-1 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n        <template v-for=\"item in itemsToShow\">\n          <li :key=\"item\" class=\"text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400\" :class=\"isMenuItemSelected(item) ? 'text-yellow-300' : ''\" role=\"option\" @click=\"clickedOption($event, item)\" @mouseup.stop.prevent @mousedown.prevent>\n            <div class=\"flex items-center\">\n              <span class=\"font-normal ml-3 block truncate\">{{ item }}</span>\n            </div>\n            <span v-if=\"input === item\" class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4\">\n              <span class=\"material-symbols text-xl\">check</span>\n            </span>\n          </li>\n        </template>\n        <li v-if=\"!itemsToShow.length\" class=\"text-gray-50 select-none relative py-2 pr-9\" role=\"option\">\n          <div class=\"flex items-center justify-center\">\n            <span class=\"font-normal\">No items</span>\n          </div>\n        </li>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nimport menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'\n\nexport default {\n  mixins: [menuKeyboardNavigationMixin],\n  props: {\n    value: [String, Number],\n    disabled: Boolean,\n    label: String,\n    items: {\n      type: Array,\n      default: () => []\n    },\n    editable: {\n      type: Boolean,\n      default: true\n    },\n    showAllWhenEmpty: Boolean\n  },\n  data() {\n    return {\n      isFocused: false,\n      textInput: null\n    }\n  },\n  watch: {\n    value: {\n      immediate: true,\n      handler(newVal) {\n        this.textInput = newVal\n      }\n    }\n  },\n  computed: {\n    input: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    itemsToShow() {\n      if (!this.editable) return this.items\n      if (!this.textInput || this.textInput === this.input) {\n        if (this.showAllWhenEmpty) return this.items\n        return []\n      }\n      return this.items.filter((i) => {\n        var iValue = String(i).toLowerCase()\n        return iValue.includes(this.textInput.toLowerCase())\n      })\n    }\n  },\n  methods: {\n    keydownHandler(e) {\n      this.menuNavigationHandler(e)\n    },\n    setFocus() {\n      if (this.$refs.input && this.editable) this.$refs.input.focus()\n    },\n    inputFocus() {\n      this.isFocused = true\n    },\n    blur() {\n      // Handle blur immediately\n      this.isFocused = false\n      if (this.input !== this.textInput) {\n        var val = this.textInput ? this.textInput.trim() : null\n        this.input = val\n        if (val && !this.items.includes(val)) {\n          this.$emit('newItem', val)\n        }\n      }\n\n      if (this.$refs.input) {\n        this.$refs.input.blur()\n      }\n    },\n    inputBlur() {\n      if (!this.isFocused) return\n\n      setTimeout(() => {\n        if (document.activeElement === this.$refs.input) {\n          return\n        }\n        this.isFocused = false\n        if (this.input !== this.textInput) {\n          var val = this.textInput ? this.textInput.trim() : null\n          this.input = val\n          if (val && !this.items.includes(val)) {\n            this.$emit('newItem', val)\n          }\n        }\n      }, 50)\n    },\n    submitForm() {\n      var val = this.textInput ? this.textInput.trim() : null\n      this.input = val\n      if (val && !this.items.includes(val)) {\n        this.$emit('newItem', val)\n      }\n    },\n    clickedOption(e, item) {\n      this.textInput = null\n      this.input = item\n      if (this.$refs.input) this.$refs.input.blur()\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/LibrariesDropdown.vue",
    "content": "<template>\n  <div v-if=\"currentLibrary\" class=\"relative h-8 max-w-52 md:min-w-32\" v-click-outside=\"clickOutsideObj\">\n    <button type=\"button\" :disabled=\"disabled\" class=\"w-10 sm:w-full relative h-full border border-white/10 hover:border-opacity-20 rounded-sm shadow-xs px-2 text-left text-sm cursor-pointer bg-black/20 text-gray-400 hover:text-gray-200\" aria-haspopup=\"menu\" :aria-expanded=\"showMenu\" :aria-label=\"$strings.ButtonLibrary + ': ' + currentLibrary.name\" @click.stop.prevent=\"clickShowMenu\">\n      <div class=\"flex items-center justify-center sm:justify-start\">\n        <ui-library-icon :icon=\"currentLibraryIcon\" class=\"sm:mr-1.5\" />\n        <span class=\"hidden sm:block truncate\">{{ currentLibrary.name }}</span>\n      </div>\n    </button>\n\n    <transition name=\"menu\">\n      <ul v-show=\"showMenu\" class=\"absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-hidden sm:text-sm librariesDropdownMenu\" tabindex=\"-1\" role=\"menu\">\n        <template v-for=\"library in librariesFiltered\">\n          <li :key=\"library.id\" class=\"text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400\" role=\"menuitem\" tabindex=\"0\" @keydown.enter=\"selectLibrary(library)\" @click=\"selectLibrary(library)\">\n            <div class=\"flex items-center px-2\">\n              <ui-library-icon :icon=\"library.icon\" class=\"mr-1.5\" />\n              <span class=\"font-normal block truncate font-sans text-sm\">{{ library.name }}</span>\n            </div>\n          </li>\n        </template>\n      </ul>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      showMenu: false,\n      disabled: false\n    }\n  },\n  computed: {\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    currentLibrary() {\n      return this.libraries.find((lib) => lib.id === this.currentLibraryId)\n    },\n    currentLibraryIcon() {\n      return this.currentLibrary ? this.currentLibrary.icon || 'database' : 'database'\n    },\n    libraries() {\n      return this.$store.getters['libraries/getSortedLibraries']()\n    },\n    canUserAccessAllLibraries() {\n      return this.$store.getters['user/getUserCanAccessAllLibraries']\n    },\n    userLibrariesAccessible() {\n      return this.$store.getters['user/getLibrariesAccessible']\n    },\n    librariesFiltered() {\n      if (this.canUserAccessAllLibraries) return this.libraries\n      return this.libraries.filter((lib) => {\n        return this.userLibrariesAccessible.includes(lib.id)\n      })\n    }\n  },\n  methods: {\n    clickShowMenu() {\n      if (this.disabled) return\n      this.showMenu = !this.showMenu\n    },\n    clickedOutside() {\n      this.showMenu = false\n    },\n    selectLibrary(library) {\n      this.updateLibrary(library)\n      this.showMenu = false\n    },\n    async updateLibrary(library) {\n      var currLibraryId = this.currentLibraryId\n      if (currLibraryId === library.id) {\n        return\n      }\n\n      this.disabled = true\n      await this.$store.dispatch('libraries/fetch', library.id)\n\n      if (this.$route.name.startsWith('config')) {\n        // No need to refresh\n      } else if (this.$route.name === 'library-library-series-id' && library.mediaType === 'book') {\n        // For series item page redirect to root series page\n        this.$router.push(`/library/${library.id}/bookshelf/series`)\n      } else if (this.$route.name === 'library-library-search') {\n        this.$router.push(this.$route.fullPath.replace(currLibraryId, library.id))\n      } else if (this.$route.name.startsWith('library')) {\n        this.$router.push(this.$route.path.replace(currLibraryId, library.id))\n      } else {\n        this.$router.push(`/library/${library.id}`)\n      }\n\n      this.disabled = false\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n.librariesDropdownMenu {\n  max-height: calc(100vh - 75px);\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/LibraryIcon.vue",
    "content": "<template>\n  <div :class=\"`${classList}`\" class=\"flex items-center justify-center\">\n    <span class=\"abs-icons\" :class=\"`icon-${iconToUse}`\"></span>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    icon: {\n      type: String,\n      default: 'audiobookshelf'\n    },\n    fontSize: {\n      type: String,\n      default: 'text-lg'\n    },\n    size: {\n      type: Number,\n      default: 5\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    classList() {\n      switch (this.size) {\n        case 6:\n          return `h-6 w-6 min-w-6 ${this.fontSize}`\n        default:\n          return `h-5 w-5 min-w-5 ${this.fontSize}`\n      }\n    },\n    iconToUse() {\n      return this.icons.includes(this.icon) ? this.icon : 'audiobookshelf'\n    },\n    icons() {\n      return this.$store.state.globals.libraryIcons\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>"
  },
  {
    "path": "client/components/ui/LoadingIndicator.vue",
    "content": "<template>\n  <div :class=\"hasSlotContent ? 'w-auto' : 'w-40'\">\n    <div class=\"bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md\">\n      <div class=\"loader-dots block relative w-20 h-5 mt-2\">\n        <div class=\"absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500\"></div>\n        <div class=\"absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500\"></div>\n        <div class=\"absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500\"></div>\n        <div class=\"absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500\"></div>\n      </div>\n      <slot>\n        <div class=\"text-gray-200 text-xs font-light mt-2 text-center\">{{ message }}</div>\n      </slot>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    text: {\n      type: String,\n      default: null\n    }\n  },\n  computed: {\n    message() {\n      return this.text || this.$strings.MessagePleaseWait\n    },\n    hasSlotContent() {\n      return this.$slots.default && this.$slots.default.length > 0\n    }\n  }\n}\n</script>\n\n<style>\n.loader-dots div {\n  animation-timing-function: cubic-bezier(0, 1, 1, 0);\n}\n.loader-dots div:nth-child(1) {\n  left: 8px;\n  animation: loader-dots1 0.6s infinite;\n}\n.loader-dots div:nth-child(2) {\n  left: 8px;\n  animation: loader-dots2 0.6s infinite;\n}\n.loader-dots div:nth-child(3) {\n  left: 32px;\n  animation: loader-dots2 0.6s infinite;\n}\n.loader-dots div:nth-child(4) {\n  left: 56px;\n  animation: loader-dots3 0.6s infinite;\n}\n@keyframes loader-dots1 {\n  0% {\n    transform: scale(0);\n  }\n  100% {\n    transform: scale(1);\n  }\n}\n@keyframes loader-dots3 {\n  0% {\n    transform: scale(1);\n  }\n  100% {\n    transform: scale(0);\n  }\n}\n@keyframes loader-dots2 {\n  0% {\n    transform: translate(0, 0);\n  }\n  100% {\n    transform: translate(24px, 0);\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/MediaIconPicker.vue",
    "content": "<template>\n  <div class=\"relative w-full h-9\" v-click-outside=\"clickOutsideObj\">\n    <p class=\"text-sm font-semibold\">{{ label }}</p>\n\n    <button type=\"button\" :disabled=\"disabled\" class=\"relative h-full w-full border border-gray-600 rounded-sm shadow-xs pl-3 pr-3 text-left focus:outline-hidden cursor-pointer bg-primary text-gray-100 hover:text-gray-200\" aria-haspopup=\"listbox\" aria-expanded=\"true\" @click.stop.prevent=\"clickShowMenu\">\n      <ui-library-icon :icon=\"selectedItem\" />\n    </button>\n\n    <transition name=\"menu\">\n      <div v-show=\"showMenu\" class=\"absolute -left-[4.5rem] z-10 -mt-px bg-primary border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-hidden sm:text-sm\">\n        <div class=\"flex justify-center items-center flex-wrap\">\n          <template v-for=\"icon in icons\">\n            <div :key=\"icon\" class=\"p-2\">\n              <span class=\"abs-icons text-xl text-white/80 hover:text-white/100 cursor-pointer\" :class=\"`icon-${icon}`\" @click=\"select(icon)\"></span>\n            </div>\n          </template>\n        </div>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    disabled: Boolean,\n    label: {\n      type: String,\n      default: 'Icon'\n    }\n  },\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      showMenu: false\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value || 'database'\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    icons() {\n      return this.$store.state.globals.libraryIcons\n    },\n    selectedItem() {\n      return this.icons.find((i) => i === this.selected) || 'audiobookshelf'\n    }\n  },\n  methods: {\n    clickShowMenu() {\n      if (this.disabled) return\n      this.showMenu = !this.showMenu\n    },\n    clickedOutside() {\n      this.showMenu = false\n    },\n    select(icon) {\n      if (this.disabled) return\n      this.selected = icon\n      this.showMenu = false\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/MultiSelect.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <label :for=\"identifier\" class=\"px-1 text-sm font-semibold\" :class=\"disabled ? 'text-gray-400' : ''\">{{ label }}</label>\n    <div ref=\"wrapper\" class=\"relative\">\n      <form @submit.prevent=\"submitForm\">\n        <div ref=\"inputWrapper\" role=\"list\" style=\"min-height: 36px\" class=\"flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-1\" :class=\"wrapperClass\" @click.stop.prevent=\"clickWrapper\" @mouseup.stop.prevent @mousedown.prevent>\n          <!-- Use index in v-for and key in case the same key exists multiple times -->\n          <div v-for=\"(item, idx) in selected\" :key=\"item + '-' + idx\" role=\"listitem\" class=\"rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative\">\n            <div v-if=\"!disabled\" class=\"w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg/75 flex items-center justify-end opacity-0 hover:opacity-100\" :class=\"{ 'opacity-100': inputFocused }\">\n              <button v-if=\"showEdit\" type=\"button\" :aria-label=\"$strings.ButtonEdit\" class=\"material-symbols text-white hover:text-warning cursor-pointer\" style=\"font-size: 1.1rem\" @click.stop=\"editItem(item)\">edit</button>\n              <button type=\"button\" :aria-label=\"$strings.ButtonRemove\" class=\"material-symbols text-white hover:text-error focus:text-error cursor-pointer\" style=\"font-size: 1.1rem\" @click.stop=\"removeItem(item, idx)\" @keydown.enter.stop.prevent=\"removeItem(item, idx)\" @focus=\"setInputFocused(true)\" @blur=\"setInputFocused(false)\" tabindex=\"0\">close</button>\n            </div>\n            {{ item }}\n          </div>\n          <input v-show=\"!readonly\" v-model=\"textInput\" ref=\"input\" :id=\"identifier\" :disabled=\"disabled\" class=\"h-full bg-primary focus:outline-hidden px-1 w-6\" @keydown=\"keydownInput\" @focus=\"inputFocus\" @blur=\"inputBlur\" @paste=\"inputPaste\" />\n        </div>\n      </form>\n\n      <ul ref=\"menu\" v-show=\"showMenu\" class=\"absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n        <template v-for=\"item in itemsToShow\">\n          <li :key=\"item\" class=\"text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400\" :class=\"itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''\" role=\"option\" @click=\"clickedOption($event, item)\" @mouseup.stop.prevent @mousedown.prevent>\n            <div class=\"flex items-center\">\n              <span class=\"font-normal ml-3 block truncate\">{{ item }}</span>\n            </div>\n            <span v-if=\"selected.includes(item)\" class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4\">\n              <span class=\"material-symbols text-xl\">check</span>\n            </span>\n          </li>\n        </template>\n        <li v-if=\"!itemsToShow.length\" class=\"text-gray-50 select-none relative py-2 pr-9\" role=\"option\">\n          <div class=\"flex items-center justify-center\">\n            <span class=\"font-normal\">{{ $strings.MessageNoItems }}</span>\n          </div>\n        </li>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nimport menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'\n\nexport default {\n  mixins: [menuKeyboardNavigationMixin],\n  props: {\n    value: {\n      type: Array,\n      default: () => []\n    },\n    items: {\n      type: Array,\n      default: () => []\n    },\n    label: String,\n    disabled: Boolean,\n    readonly: Boolean,\n    showEdit: Boolean,\n    menuDisabled: {\n      type: Boolean,\n      default: false\n    }\n  },\n  data() {\n    return {\n      textInput: null,\n      currentSearch: null,\n      typingTimeout: null,\n      isFocused: false,\n      menu: null,\n      filteredItems: null,\n      inputFocused: false\n    }\n  },\n  watch: {\n    showMenu(newVal) {\n      if (newVal) this.setListener()\n      else this.removeListener()\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value || []\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    showMenu() {\n      return this.isFocused && !this.menuDisabled\n    },\n    wrapperClass() {\n      var classes = []\n      if (this.disabled) classes.push('bg-black-300')\n      else classes.push('bg-primary')\n      if (!this.readonly) classes.push('cursor-text')\n      return classes.join(' ')\n    },\n    itemsToShow() {\n      if (!this.currentSearch || !this.textInput || !this.filteredItems) {\n        return this.items\n      }\n\n      return this.filteredItems\n    },\n    identifier() {\n      return Math.random().toString(36).substring(2)\n    }\n  },\n  methods: {\n    editItem(item) {\n      this.$emit('edit', item)\n    },\n    search() {\n      if (!this.textInput) {\n        this.filteredItems = null\n        return\n      }\n      this.currentSearch = this.textInput\n\n      const results = this.items.filter((i) => {\n        var iValue = String(i).toLowerCase()\n        return iValue.includes(this.currentSearch.toLowerCase())\n      })\n\n      this.filteredItems = results || []\n    },\n    keydownInput(event) {\n      this.menuNavigationHandler(event)\n\n      clearTimeout(this.typingTimeout)\n      this.typingTimeout = setTimeout(() => {\n        this.search()\n      }, 100)\n      this.setInputWidth()\n    },\n    setInputFocused(focused) {\n      this.inputFocused = focused\n    },\n    setInputWidth() {\n      setTimeout(() => {\n        var value = this.$refs.input.value\n        var len = value.length * 7 + 24\n        this.$refs.input.style.width = len + 'px'\n        this.recalcMenuPos()\n      }, 50)\n    },\n    recalcMenuPos() {\n      if (!this.menu || !this.$refs.inputWrapper) return\n      var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()\n      if (boundingBox.y > window.innerHeight - 8) {\n        // Input is off the page\n        return this.forceBlur()\n      }\n      var menuHeight = this.menu.clientHeight\n      var top = boundingBox.y + boundingBox.height - 4\n      if (top + menuHeight > window.innerHeight - 20) {\n        // Reverse menu to open upwards\n        top = boundingBox.y - menuHeight - 4\n      }\n\n      this.menu.style.top = top + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    unmountMountMenu() {\n      if (!this.$refs.menu || !this.$refs.inputWrapper) return\n      this.menu = this.$refs.menu\n\n      var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()\n      this.menu.remove()\n      document.body.appendChild(this.menu)\n      this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    inputPaste(evt) {\n      setTimeout(() => {\n        const pastedText = evt.target?.value || ''\n        console.log('Pasted text=', pastedText)\n        const pastedItems = [\n          ...new Set(\n            pastedText\n              .split(';')\n              .map((i) => i.trim())\n              .filter((i) => i)\n          )\n        ]\n\n        // Filter out items already selected\n        const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i.toLowerCase() === i.toLowerCase()))\n        if (pastedItems.length && !itemsToAdd.length) {\n          this.textInput = null\n          this.currentSearch = null\n        } else {\n          for (const itemToAdd of itemsToAdd) {\n            this.insertNewItem(itemToAdd)\n          }\n        }\n      }, 10)\n    },\n    inputFocus() {\n      if (!this.menu) {\n        this.unmountMountMenu()\n      }\n      this.isFocused = true\n      this.$nextTick(this.recalcMenuPos)\n    },\n    inputBlur() {\n      if (!this.isFocused) return\n\n      setTimeout(() => {\n        if (document.activeElement === this.$refs.input) {\n          return\n        }\n        this.isFocused = false\n        if (this.textInput) this.submitForm()\n      }, 50)\n    },\n    focus() {\n      if (this.$refs.input) this.$refs.input.focus()\n    },\n    blur() {\n      if (this.$refs.input) this.$refs.input.blur()\n    },\n    forceBlur() {\n      this.isFocused = false\n      if (this.textInput) this.submitForm()\n      if (this.$refs.input) this.$refs.input.blur()\n    },\n    clickedOption(e, itemValue) {\n      if (e) {\n        e.stopPropagation()\n        e.preventDefault()\n      }\n      if (this.$refs.input) {\n        this.$refs.input.style.width = '24px'\n        this.$refs.input.focus()\n      }\n\n      var newSelected = null\n      if (this.selected.includes(itemValue)) {\n        newSelected = this.selected.filter((s) => s !== itemValue)\n        this.$emit('removedItem', itemValue)\n      } else {\n        newSelected = this.selected.concat([itemValue])\n      }\n      this.textInput = null\n      this.currentSearch = null\n      this.selectedMenuItemIndex = null\n      this.$emit('input', newSelected)\n      this.$nextTick(() => {\n        this.recalcMenuPos()\n      })\n    },\n    clickWrapper() {\n      if (this.disabled) return\n      if (this.showMenu) {\n        return this.blur()\n      }\n      this.focus()\n    },\n    removeItem(item, idx) {\n      var remaining = this.selected.slice()\n      remaining.splice(idx, 1)\n      this.$emit('input', remaining)\n      this.$emit('removedItem', item)\n      this.$nextTick(() => {\n        this.recalcMenuPos()\n      })\n    },\n    resetInput() {\n      this.textInput = null\n      this.currentSearch = null\n      this.selectedMenuItemIndex = null\n      this.$nextTick(() => {\n        this.blur()\n      })\n    },\n    insertNewItem(item) {\n      if (!this.selected.includes(item)) this.selected.push(item)\n      this.$emit('input', this.selected)\n      this.$emit('newItem', item)\n      this.textInput = null\n      this.currentSearch = null\n      this.selectedMenuItemIndex = null\n    },\n    submitForm() {\n      if (!this.textInput) return\n\n      const cleaned = this.textInput.trim()\n      if (!cleaned) {\n        this.resetInput()\n      } else {\n        const matchesItem = this.items.find((i) => i === cleaned)\n        if (matchesItem) {\n          this.clickedOption(null, matchesItem)\n        } else {\n          this.insertNewItem(cleaned)\n        }\n      }\n\n      if (this.$refs.input) this.$refs.input.style.width = '24px'\n    },\n    scroll() {\n      this.recalcMenuPos()\n    },\n    setListener() {\n      document.addEventListener('scroll', this.scroll, true)\n    },\n    removeListener() {\n      document.removeEventListener('scroll', this.scroll, true)\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    if (this.menu) this.menu.remove()\n  }\n}\n</script>\n\n<style scoped>\ninput {\n  border-style: inherit !important;\n}\ninput:read-only {\n  color: #aaa;\n  background-color: #444;\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/MultiSelectDropdown.vue",
    "content": "<template>\n  <div class=\"w-full\" v-click-outside=\"clickOutsideObj\">\n    <p class=\"px-1 text-sm font-semibold\">{{ label }}</p>\n    <div ref=\"wrapper\" class=\"relative\">\n      <div ref=\"inputWrapper\" style=\"min-height: 40px\" class=\"flex-wrap relative w-full shadow-xs flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer\" @click.stop.prevent=\"clickWrapper\" @mouseup.stop.prevent @mousedown.prevent>\n        <div v-for=\"item in selectedItems\" :key=\"item.value\" class=\"rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative\">\n          <div class=\"w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg/75 flex items-center justify-end cursor-pointer\">\n            <span class=\"material-symbols text-white hover:text-error\" style=\"font-size: 1.1rem\" @click.stop=\"removeItem(item.value)\">close</span>\n          </div>\n          {{ item.text }}\n        </div>\n      </div>\n\n      <transition name=\"menu\">\n        <ul ref=\"menu\" v-show=\"showMenu\" class=\"absolute z-60 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n          <template v-for=\"item in items\">\n            <li :key=\"item.value\" class=\"text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400\" role=\"option\" @click=\"clickedOption($event, item)\" @mouseup.stop.prevent @mousedown.prevent>\n              <p class=\"font-normal ml-3 block truncate\">{{ item.text }}</p>\n\n              <div v-if=\"selected.includes(item.value)\" class=\"text-yellow-400 absolute inset-y-0 right-0 my-auto w-5 h-5 mr-3 overflow-hidden\">\n                <span class=\"material-symbols text-xl\">check</span>\n              </div>\n            </li>\n          </template>\n          <li v-if=\"!items.length\" class=\"text-gray-50 select-none relative py-2 pr-9\" role=\"option\">\n            <div class=\"flex items-center justify-center\">\n              <span class=\"font-normal\">{{ $strings.MessageNoItems }}</span>\n            </div>\n          </li>\n        </ul>\n      </transition>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: {\n      type: Array,\n      default: () => []\n    },\n    items: {\n      type: Array,\n      default: () => []\n    },\n    label: String\n  },\n  data() {\n    return {\n      showMenu: false,\n      menu: null,\n      clickOutsideObj: {\n        handler: this.closeMenu,\n        events: ['mousedown'],\n        isActive: true\n      }\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    selectedItems() {\n      return (this.value || []).map((v) => {\n        return this.items.find((i) => i.value === v) || { text: v, value: v }\n      })\n    }\n  },\n  methods: {\n    recalcMenuPos() {\n      if (!this.menu || !this.$refs.inputWrapper) return\n      var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()\n      this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    unmountMountMenu() {\n      if (!this.$refs.menu || !this.$refs.inputWrapper) return\n      this.menu = this.$refs.menu\n\n      var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()\n      this.menu.remove()\n      document.body.appendChild(this.menu)\n      this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    clickedOption(e, item) {\n      if (e) {\n        e.stopPropagation()\n        e.preventDefault()\n      }\n      var newSelected = null\n      if (this.selected.includes(item.value)) {\n        newSelected = this.selected.filter((s) => s !== item.value)\n      } else {\n        newSelected = this.selected.concat([item.value])\n      }\n      this.$emit('input', newSelected)\n      this.$nextTick(() => {\n        this.recalcMenuPos()\n      })\n    },\n    closeMenu() {\n      this.showMenu = false\n      this.removeListener()\n    },\n    clickWrapper() {\n      this.showMenu = !this.showMenu\n      if (this.showMenu) this.setListener()\n      else this.removeListener()\n    },\n    removeItem(itemValue) {\n      var remaining = this.selected.filter((i) => i !== itemValue)\n      this.$emit('input', remaining)\n\n      this.$nextTick(() => {\n        this.recalcMenuPos()\n      })\n    },\n    scroll() {\n      this.recalcMenuPos()\n    },\n    setListener() {\n      document.addEventListener('scroll', this.scroll, true)\n    },\n    removeListener() {\n      document.removeEventListener('scroll', this.scroll, true)\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    if (this.menu) this.menu.remove()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/MultiSelectQueryInput.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <label :for=\"identifier\" class=\"px-1 text-sm font-semibold\" :class=\"disabled ? 'text-gray-400' : ''\">{{ label }}</label>\n    <div ref=\"wrapper\" class=\"relative\">\n      <form @submit.prevent=\"submitForm\">\n        <div ref=\"inputWrapper\" role=\"list\" style=\"min-height: 36px\" class=\"flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-0.5\" :class=\"wrapperClass\" @click.stop.prevent=\"clickWrapper\" @mouseup.stop.prevent @mousedown.prevent>\n          <div v-for=\"item in selected\" :key=\"item.id\" role=\"listitem\" class=\"rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12\">\n            <div v-if=\"!disabled\" class=\"w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg/75 flex items-center justify-end cursor-pointer\" :class=\"{ 'opacity-100': inputFocused }\">\n              <button v-if=\"showEdit\" type=\"button\" :aria-label=\"$strings.ButtonEdit\" class=\"material-symbols text-base text-white hover:text-warning focus:text-warning mr-1\" @click.stop=\"editItem(item)\" @keydown.enter.stop.prevent=\"editItem(item)\" @focus=\"setInputFocused(true)\" @blur=\"setInputFocused(false)\" tabindex=\"0\">edit</button>\n              <button type=\"button\" :aria-label=\"$strings.ButtonRemove\" class=\"material-symbols text-white hover:text-error focus:text-error\" style=\"font-size: 1.1rem\" @click.stop=\"removeItem(item.id)\" @keydown.enter.stop=\"removeItem(item.id)\" @focus=\"setInputFocused(true)\" @blur=\"setInputFocused(false)\" tabindex=\"0\">close</button>\n            </div>\n            {{ item[textKey] }}\n          </div>\n          <div v-if=\"showEdit && !disabled\" class=\"rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center\">\n            <button type=\"button\" :aria-label=\"$strings.ButtonAdd\" class=\"material-symbols text-white hover:text-success focus:text-success pt-px pr-px\" style=\"font-size: 1.1rem\" @click.stop=\"addItem\" @keydown.enter.stop=\"addItem\" tabindex=\"0\">add</button>\n          </div>\n          <input v-show=\"!readonly\" v-model=\"textInput\" ref=\"input\" :id=\"identifier\" :disabled=\"disabled\" class=\"h-full bg-primary focus:outline-hidden px-1 w-6\" @keydown=\"keydownInput\" @focus=\"inputFocus\" @blur=\"inputBlur\" @paste=\"inputPaste\" />\n        </div>\n      </form>\n\n      <ul ref=\"menu\" v-show=\"showMenu\" class=\"absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n        <template v-for=\"item in itemsToShow\">\n          <li :key=\"item.id\" class=\"text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400\" :class=\"isMenuItemSelected(item) ? 'text-yellow-300' : ''\" role=\"option\" @click=\"clickedOption($event, item)\" @mouseup.stop.prevent @mousedown.prevent>\n            <div class=\"flex items-center\">\n              <span class=\"font-normal ml-3 block truncate\">{{ item.name }}</span>\n            </div>\n            <span v-if=\"getIsSelected(item.id)\" class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4\">\n              <span class=\"material-symbols text-xl\">check</span>\n            </span>\n          </li>\n        </template>\n        <li v-if=\"!itemsToShow.length\" class=\"text-gray-50 select-none relative py-2 pr-9\" role=\"option\">\n          <div class=\"flex items-center justify-center\">\n            <span class=\"font-normal\">{{ $strings.MessageNoItems }}</span>\n          </div>\n        </li>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nimport menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'\n\nexport default {\n  mixins: [menuKeyboardNavigationMixin],\n  props: {\n    value: {\n      type: Array,\n      default: () => []\n    },\n    filterKey: String,\n    label: String,\n    disabled: Boolean,\n    readonly: Boolean,\n    showEdit: Boolean,\n    textKey: {\n      type: String,\n      default: 'name'\n    }\n  },\n  data() {\n    return {\n      textInput: null,\n      currentSearch: null,\n      typingTimeout: null,\n      isFocused: false,\n      inputFocused: false,\n      menu: null,\n      items: []\n    }\n  },\n  watch: {\n    showMenu(newVal) {\n      if (newVal) this.setListener()\n      else this.removeListener()\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value || []\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    wrapperClass() {\n      var classes = []\n      if (this.disabled) classes.push('bg-black-300')\n      else classes.push('bg-primary')\n      if (!this.readonly) classes.push('cursor-text')\n      return classes.join(' ')\n    },\n    showMenu() {\n      return this.isFocused && this.currentSearch\n    },\n    itemsToShow() {\n      return this.items\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    },\n    identifier() {\n      return Math.random().toString(36).substring(2)\n    }\n  },\n  methods: {\n    addItem() {\n      this.$emit('add')\n    },\n    editItem(item) {\n      this.$emit('edit', item)\n    },\n    getIsSelected(itemValue) {\n      return !!this.selected.find((i) => i.id === itemValue)\n    },\n    setInputFocused(focused) {\n      this.inputFocused = focused\n    },\n    search() {\n      if (!this.textInput) return\n      this.currentSearch = this.textInput\n      const dataToSearch = this.filterData[this.filterKey] || []\n\n      const results = dataToSearch.filter((au) => {\n        return au.name.toLowerCase().includes(this.currentSearch.toLowerCase().trim())\n      })\n\n      this.items = results || []\n    },\n    keydownInput(event) {\n      this.menuNavigationHandler(event)\n      clearTimeout(this.typingTimeout)\n      this.typingTimeout = setTimeout(() => {\n        this.search()\n      }, 250)\n      this.setInputWidth()\n    },\n    setInputWidth() {\n      setTimeout(() => {\n        var value = this.$refs.input.value\n        var len = value.length * 7 + 24\n        this.$refs.input.style.width = len + 'px'\n        this.recalcMenuPos()\n      }, 50)\n    },\n    recalcMenuPos() {\n      if (!this.menu || !this.$refs.inputWrapper) return\n      var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()\n      if (boundingBox.y > window.innerHeight - 8) {\n        // Input is off the page\n        return this.forceBlur()\n      }\n      var menuHeight = this.menu.clientHeight\n      var top = boundingBox.y + boundingBox.height - 4\n      if (top + menuHeight > window.innerHeight - 20) {\n        // Reverse menu to open upwards\n        top = boundingBox.y - menuHeight - 4\n      }\n\n      this.menu.style.top = top + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    unmountMountMenu() {\n      if (!this.$refs.menu || !this.$refs.inputWrapper) return\n      this.menu = this.$refs.menu\n\n      var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()\n      this.menu.remove()\n      document.body.appendChild(this.menu)\n      this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    inputPaste(evt) {\n      setTimeout(() => {\n        const pastedText = evt.target?.value || ''\n        console.log('Pasted text=', pastedText)\n        const pastedItems = [\n          ...new Set(\n            pastedText\n              .split(';')\n              .map((i) => i.trim())\n              .filter((i) => i)\n          )\n        ]\n\n        // Filter out items already selected\n        const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i[this.textKey].toLowerCase() === i.toLowerCase()))\n        if (pastedItems.length && !itemsToAdd.length) {\n          this.textInput = null\n          this.currentSearch = null\n        } else {\n          for (const [index, itemToAdd] of itemsToAdd.entries()) {\n            this.insertNewItem({\n              id: `new-${Date.now()}-${index}`,\n              name: itemToAdd\n            })\n          }\n        }\n      }, 10)\n    },\n    inputFocus() {\n      if (!this.menu) {\n        this.unmountMountMenu()\n      }\n      this.isFocused = true\n      this.$nextTick(this.recalcMenuPos)\n    },\n    inputBlur() {\n      if (!this.isFocused) return\n\n      if (typeof this.textInput === 'string') {\n        this.textInput = this.textInput.trim()\n      }\n\n      setTimeout(() => {\n        if (document.activeElement === this.$refs.input) {\n          return\n        }\n        this.isFocused = false\n        if (this.textInput) this.submitForm()\n      }, 50)\n    },\n    focus() {\n      if (this.$refs.input) this.$refs.input.focus()\n    },\n    blur() {\n      if (this.$refs.input) this.$refs.input.blur()\n    },\n    forceBlur() {\n      this.isFocused = false\n\n      if (typeof this.textInput === 'string') {\n        this.textInput = this.textInput.trim()\n      }\n\n      if (this.textInput) this.submitForm()\n      if (this.$refs.input) this.$refs.input.blur()\n    },\n    clickedOption(e, item) {\n      if (e) {\n        e.stopPropagation()\n        e.preventDefault()\n      }\n      if (this.$refs.input) {\n        this.$refs.input.style.width = '24px'\n        this.$refs.input.focus()\n      }\n\n      let newSelected = null\n      if (this.getIsSelected(item.id)) {\n        newSelected = this.selected.filter((s) => s.id !== item.id)\n        this.$emit('removedItem', item.id)\n      } else {\n        newSelected = this.selected.concat([\n          {\n            id: item.id,\n            name: item.name\n          }\n        ])\n      }\n      this.textInput = null\n      this.currentSearch = null\n      this.selectedMenuItemIndex = null\n\n      this.$emit('input', newSelected)\n      this.$nextTick(() => {\n        this.recalcMenuPos()\n      })\n    },\n    clickWrapper() {\n      if (this.disabled) return\n      if (this.showMenu) {\n        return this.blur()\n      }\n      this.focus()\n    },\n    removeItem(itemId) {\n      var remaining = this.selected.filter((i) => i.id !== itemId)\n      this.$emit('input', remaining)\n      this.$emit('removedItem', itemId)\n      this.$nextTick(() => {\n        this.recalcMenuPos()\n      })\n    },\n    insertNewItem(item) {\n      if (!this.selected.find((i) => i.name === item.name)) this.selected.push(item)\n      this.$emit('input', this.selected)\n      this.$emit('newItem', item)\n      this.textInput = null\n      this.currentSearch = null\n      this.selectedMenuItemIndex = null\n    },\n    submitForm() {\n      if (!this.textInput || !this.textInput.trim?.()) return\n\n      this.textInput = this.textInput.trim()\n\n      const matchesItem = this.items.find((i) => {\n        return i.name === this.textInput\n      })\n\n      if (matchesItem) {\n        this.clickedOption(null, matchesItem)\n      } else {\n        this.insertNewItem({\n          id: `new-${Date.now()}`,\n          name: this.textInput\n        })\n      }\n      if (this.$refs.input) this.$refs.input.style.width = '24px'\n    },\n    scroll() {\n      this.recalcMenuPos()\n    },\n    setListener() {\n      document.addEventListener('scroll', this.scroll, true)\n    },\n    removeListener() {\n      document.removeEventListener('scroll', this.scroll, true)\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    if (this.menu) this.menu.remove()\n  }\n}\n</script>\n\n<style scoped>\ninput {\n  border-style: inherit !important;\n}\ninput:read-only {\n  color: #aaa;\n  background-color: #444;\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/QueryInput.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <p class=\"px-1 text-sm font-semibold\" :class=\"disabled ? 'text-gray-400' : ''\">{{ label }}</p>\n    <div ref=\"wrapper\" class=\"relative\">\n      <form @submit.prevent=\"submitForm\">\n        <div ref=\"inputWrapper\" class=\"input-wrapper flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-2\" :class=\"disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'\">\n          <input ref=\"input\" v-model=\"textInput\" :disabled=\"disabled\" class=\"h-full w-full bg-transparent focus:outline-hidden px-1\" @keydown=\"keydownInput\" @focus=\"inputFocus\" @blur=\"inputBlur\" />\n        </div>\n      </form>\n\n      <ul ref=\"menu\" v-show=\"isFocused && currentSearch\" class=\"absolute z-60 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-sm py-1 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n        <template v-for=\"item in items\">\n          <li :key=\"item.id\" class=\"text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400\" role=\"option\" @click=\"clickedOption($event, item)\" @mouseup.stop.prevent @mousedown.prevent>\n            <div class=\"flex items-center\">\n              <span class=\"font-normal ml-3 block truncate\">{{ item.name }}</span>\n            </div>\n            <span v-if=\"isItemSelected(item)\" class=\"text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4\">\n              <span class=\"material-symbols text-xl\">check</span>\n            </span>\n          </li>\n        </template>\n        <li v-if=\"!items.length\" class=\"text-gray-50 select-none relative py-2 pr-9\" role=\"option\">\n          <div class=\"flex items-center justify-center\">\n            <span class=\"font-normal\">{{ $strings.MessageNoItems }}</span>\n          </div>\n        </li>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    disabled: Boolean,\n    label: String,\n    endpoint: String\n  },\n  data() {\n    return {\n      isFocused: false,\n      currentSearch: null,\n      typingTimeout: null,\n      textInput: null,\n      searching: false,\n      items: [],\n      selectedItemObject: null\n    }\n  },\n  watch: {\n    value: {\n      immediate: true,\n      handler(newVal) {\n        this.textInput = newVal\n      }\n    }\n  },\n  computed: {\n    input: {\n      get() {\n        return this.value || ''\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    isItemSelected(item) {\n      return !!this.input.toLowerCase() === item.name\n    },\n    async search() {\n      if (this.searching) return\n      this.currentSearch = this.textInput\n      this.searching = true\n      var results = await this.$axios.$gest(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {\n        console.error('Failed to get search results', error)\n        return []\n      })\n      this.items = results || []\n      this.searching = false\n    },\n    keydownInput() {\n      clearTimeout(this.typingTimeout)\n      this.typingTimeout = setTimeout(() => {\n        this.search()\n      }, 250)\n    },\n    inputFocus() {\n      this.isFocused = true\n    },\n    blur() {\n      // Handle blur immediately\n      this.isFocused = false\n      if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) {\n        var val = this.textInput ? this.textInput.trim() : null\n        if (val) {\n          this.submitForm()\n        }\n      }\n\n      if (this.$refs.input) {\n        this.$refs.input.blur()\n      }\n    },\n    inputBlur() {\n      if (!this.isFocused) return\n\n      setTimeout(() => {\n        if (document.activeElement === this.$refs.input) {\n          return\n        }\n        this.isFocused = false\n        if (this.input !== this.textInput) {\n          var val = this.textInput ? this.textInput.trim() : null\n          if (val) {\n            this.setItem(val)\n          }\n        }\n      }, 50)\n    },\n    submitForm() {\n      var val = this.textInput ? this.textInput.trim() : null\n      if (val) {\n        this.setItem(val)\n      }\n    },\n    setItem(itemText) {\n      if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) {\n        var newItem = {\n          id: `new-${Date.now()}`,\n          name: val\n        }\n        this.$emit('selected', newItem)\n        this.input = val\n      } else {\n        var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())\n        this.$emit('selected', item)\n        this.input = item.name\n      }\n      this.currentSearch = null\n    },\n    clickedOption(e, item) {\n      this.textInput = item.name\n      this.currentSearch = null\n      this.input = item.name\n      this.selectedItemObject = item\n      this.$emit('selected', item)\n\n      if (this.$refs.input) this.$refs.input.blur()\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/RangeInput.vue",
    "content": "<template>\n  <div class=\"inline-flex\">\n    <input v-model=\"input\" type=\"range\" :min=\"min\" :max=\"max\" :step=\"step\" />\n\n    <p class=\"text-sm ml-2\">{{ input }}%</p>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    min: Number,\n    max: Number,\n    step: Number\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    input: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style scoped>\ninput[type='range'] {\n  -webkit-appearance: none;\n  appearance: none;\n  background: transparent;\n  cursor: pointer;\n}\ninput[type='range']:focus {\n  outline: none;\n}\n\n/* chromium */\ninput[type='range']::-webkit-slider-runnable-track {\n  background-color: rgb(0 0 0 / 0.25);\n  border-radius: 9999px;\n  height: 0.75rem;\n}\ninput[type='range']::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  appearance: none;\n  margin-top: -0.25rem;\n  border-radius: 9999px;\n  background-color: rgb(255 255 255 / 0.7);\n  height: 1.25rem;\n  width: 1.25rem;\n}\ninput[type='range']:focus::-webkit-slider-thumb {\n  border: 1px solid #6b6b6b;\n  outline: 3px solid #6b6b6b;\n  outline-offset: 0.125rem;\n}\n\n/* firefox */\ninput[type='range']::-moz-range-track {\n  background-color: rgb(0 0 0 / 0.25);\n  border-radius: 9999px;\n  height: 0.75rem;\n}\ninput[type='range']::-moz-range-thumb {\n  border: none;\n  border-radius: 9999px;\n  margin-top: -0.25rem;\n  background-color: rgb(255 255 255 / 0.7);\n  height: 1.25rem;\n  width: 1.25rem;\n}\ninput[type='range']:focus::-moz-range-thumb {\n  border: 1px solid #6b6b6b;\n  outline: 3px solid #6b6b6b;\n  outline-offset: 0.125rem;\n}\n</style>"
  },
  {
    "path": "client/components/ui/ReadIconBtn.vue",
    "content": "<template>\n  <button :aria-label=\"isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" class=\"icon-btn rounded-md flex items-center justify-center h-9 w-9 relative\" :class=\"borderless ? '' : 'bg-primary border border-gray-600'\" @click=\"clickBtn\">\n    <div class=\"w-5 h-5 relative\">\n      <span v-if=\"isRead\" class=\"material-symbols fill text-xl text-success\">beenhere</span>\n      <span v-else class=\"material-symbols text-xl text-white\">beenhere</span>\n    </div>\n  </button>\n</template>\n\n<script>\nexport default {\n  props: {\n    isRead: Boolean,\n    disabled: Boolean,\n    borderless: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {\n    clickBtn(e) {\n      e.stopPropagation()\n      if (this.disabled) {\n        e.preventDefault()\n        return\n      }\n      this.$emit('click')\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style>\nbutton.icon-btn::before {\n  content: '';\n  position: absolute;\n  border-radius: 6px;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(255, 255, 255, 0);\n  transition: all 0.1s ease-in-out;\n}\nbutton.icon-btn:hover:not(:disabled)::before {\n  background-color: rgba(255, 255, 255, 0.1);\n}\nbutton.icon-btn:disabled::before {\n  background-color: rgba(0, 0, 0, 0.2);\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/RichTextEditor.vue",
    "content": "<template>\n  <div class=\"default-style\">\n    <p v-if=\"label\" class=\"px-1 text-sm font-semibold\" :class=\"{ 'text-gray-400': disabled }\" style=\"margin-top: 0; margin-bottom: 0.125em\">\n      {{ label }}\n    </p>\n    <ui-vue-trix ref=\"input\" v-model=\"content\" :disabled-editor=\"disabled\" @trix-file-accept=\"trixFileAccept\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: String,\n    label: String,\n    disabled: {\n      type: Boolean,\n      default: false\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    content: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    trixFileAccept(e) {\n      e.preventDefault()\n    },\n    blur() {\n      if (this.$refs.input && this.$refs.input.blur) {\n        this.$refs.input.blur()\n      }\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/SelectInput.vue",
    "content": "<template>\n  <div class=\"relative w-full\">\n    <p v-if=\"label && !labelHidden\" class=\"text-sm font-semibold px-1\" :class=\"disabled ? 'text-gray-300' : ''\">{{ label }}</p>\n    <button ref=\"buttonWrapper\" type=\"button\" :aria-label=\"longLabel\" :disabled=\"disabled\" class=\"relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm\" :class=\"buttonClass\" aria-haspopup=\"listbox\" aria-expanded=\"true\" @click.stop.prevent=\"clickShowMenu\">\n      <span class=\"flex items-center\">\n        <span class=\"block truncate font-sans\" :class=\"{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }\">{{ selectedText || placeholder }}</span>\n        <span v-if=\"selectedSubtext\">:&nbsp;</span>\n        <span v-if=\"selectedSubtext\" class=\"font-normal block truncate font-sans text-sm text-gray-400\">{{ selectedSubtext }}</span>\n      </span>\n      <span class=\"ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none\">\n        <span class=\"material-symbols text-2xl\">expand_more</span>\n      </span>\n    </button>\n\n    <transition name=\"menu\">\n      <ul ref=\"menu\" v-show=\"showMenu\" class=\"absolute z-60 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black/5 overflow-auto sm:text-sm\" tabindex=\"-1\" role=\"listbox\" :style=\"{ maxHeight: menuMaxHeight }\" v-click-outside=\"clickOutsideObj\">\n        <template v-for=\"item in itemsToShow\">\n          <li :key=\"item.value\" class=\"text-gray-100 relative py-2 cursor-pointer hover:bg-black-400\" :id=\"'listbox-option-' + item.value\" role=\"option\" tabindex=\"0\" @keyup.enter=\"clickedOption(item.value)\" @click.stop.prevent=\"clickedOption(item.value)\">\n            <div class=\"flex items-center\">\n              <span class=\"ml-3 block truncate font-sans text-sm\" :class=\"{ 'font-semibold': item.subtext }\">{{ item.text }}</span>\n              <span v-if=\"item.subtext\">:&nbsp;</span>\n              <span v-if=\"item.subtext\" class=\"font-normal block truncate font-sans text-sm text-gray-400\">{{ item.subtext }}</span>\n            </div>\n          </li>\n        </template>\n      </ul>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    label: {\n      type: String,\n      default: ''\n    },\n    labelHidden: Boolean,\n    items: {\n      type: Array,\n      default: () => []\n    },\n    placeholder: {\n      type: String,\n      default: ''\n    },\n    disabled: Boolean,\n    small: Boolean,\n    menuMaxHeight: {\n      type: String,\n      default: '224px'\n    }\n  },\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['click'],\n        isActive: true\n      },\n      menu: null,\n      showMenu: false\n    }\n  },\n  computed: {\n    selected: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    itemsToShow() {\n      return this.items.map((i) => {\n        if (typeof i === 'string' || typeof i === 'number') {\n          return {\n            text: i,\n            value: i\n          }\n        }\n        return i\n      })\n    },\n    selectedItem() {\n      return this.itemsToShow.find((i) => i.value === this.selected)\n    },\n    selectedText() {\n      return this.selectedItem ? this.selectedItem.text : ''\n    },\n    selectedSubtext() {\n      return this.selectedItem ? this.selectedItem.subtext : ''\n    },\n    buttonClass() {\n      var classes = []\n      if (this.small) classes.push('h-9')\n      else classes.push('h-10')\n\n      if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary/70 border-opacity-70 text-gray-400')\n      else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')\n\n      return classes.join(' ')\n    },\n    longLabel() {\n      let result = ''\n      if (this.label) result += this.label + ': '\n      if (this.selectedText) result += this.selectedText\n      if (this.selectedSubtext) result += ' ' + this.selectedSubtext\n      return result\n    }\n  },\n  methods: {\n    recalcMenuPos() {\n      if (!this.menu || !this.$refs.buttonWrapper) return\n      const boundingBox = this.$refs.buttonWrapper.getBoundingClientRect()\n      this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'\n      this.menu.style.left = boundingBox.x + 'px'\n      this.menu.style.width = boundingBox.width + 'px'\n    },\n    unmountMountMenu() {\n      if (!this.$refs.menu || !this.$refs.buttonWrapper) return\n      this.menu = this.$refs.menu\n      this.menu.remove()\n    },\n    clickShowMenu() {\n      if (this.disabled) return\n      if (!this.showMenu) this.handleShowMenu()\n      else this.handleCloseMenu()\n    },\n    handleShowMenu() {\n      if (!this.menu) {\n        this.unmountMountMenu()\n      }\n      document.body.appendChild(this.menu)\n      this.recalcMenuPos()\n      this.showMenu = true\n    },\n    handleCloseMenu() {\n      this.showMenu = false\n      if (this.menu) this.menu.remove()\n    },\n    clickedOutside() {\n      this.handleCloseMenu()\n    },\n    clickedOption(itemValue) {\n      this.selected = itemValue\n      this.handleCloseMenu()\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    if (this.menu) this.menu.remove()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/TextInput.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"relative\">\n    <input :id=\"inputId\" :name=\"inputName\" ref=\"input\" v-model=\"inputValue\" :type=\"actualType\" :step=\"step\" :min=\"min\" :readonly=\"readonly\" :disabled=\"disabled\" :placeholder=\"placeholder\" dir=\"auto\" class=\"rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full\" :class=\"classList\" @keyup=\"keyup\" @change=\"change\" @focus=\"focused\" @blur=\"blurred\" />\n    <div v-if=\"clearable && inputValue\" class=\"absolute top-0 right-0 h-full px-2 flex items-center justify-center\">\n      <span class=\"material-symbols text-gray-300 cursor-pointer\" style=\"font-size: 1.1rem\" @click.stop.prevent=\"clear\">close</span>\n    </div>\n    <div v-if=\"type === 'password' && isHovering\" class=\"absolute top-0 right-0 h-full px-4 flex items-center justify-center\">\n      <span class=\"material-symbols text-gray-400 cursor-pointer text-lg\" @click.stop.prevent=\"showPassword = !showPassword\">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>\n    </div>\n    <div v-else-if=\"showCopy\" class=\"absolute top-0 right-0 h-full px-2 flex items-center justify-center\">\n      <span class=\"material-symbols cursor-pointer text-lg\" :class=\"hasCopied ? 'text-success' : 'text-gray-400 hover:text-white'\" @click.stop.prevent=\"copyToClipboard\">{{ !hasCopied ? 'content_copy' : 'done' }}</span>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    placeholder: String,\n    readonly: Boolean,\n    type: {\n      type: String,\n      default: 'text'\n    },\n    disabled: Boolean,\n    paddingY: {\n      type: Number,\n      default: 2\n    },\n    paddingX: {\n      type: Number,\n      default: 3\n    },\n    noSpinner: Boolean,\n    textCenter: Boolean,\n    clearable: Boolean,\n    inputId: String,\n    inputName: String,\n    showCopy: Boolean,\n    step: [String, Number],\n    min: [String, Number],\n    customInputClass: String,\n    trimWhitespace: Boolean\n  },\n  data() {\n    return {\n      showPassword: false,\n      isHovering: false,\n      isFocused: false,\n      hasCopied: null,\n      isInvalidDate: false\n    }\n  },\n  computed: {\n    inputValue: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    classList() {\n      var _list = []\n      if (this.showCopy) {\n        _list.push('pl-3', 'pr-8')\n      } else {\n        _list.push(`px-${this.paddingX}`)\n      }\n\n      _list.push(`py-${this.paddingY}`)\n      if (this.noSpinner) _list.push('no-spinner')\n      if (this.textCenter) _list.push('text-center')\n      if (this.customInputClass) _list.push(this.customInputClass)\n\n      if (this.isInvalidDate) _list.push('border-error')\n      else _list.push('focus:border-gray-300 border-gray-600')\n\n      return _list.join(' ')\n    },\n    actualType() {\n      if (this.type === 'password' && this.showPassword) return 'text'\n      return this.type\n    }\n  },\n  methods: {\n    copyToClipboard() {\n      clearTimeout(this.hasCopied)\n      this.$copyToClipboard(this.inputValue).then((success) => {\n        this.hasCopied = setTimeout(() => {\n          this.hasCopied = null\n        }, 2000)\n      })\n    },\n    clear() {\n      this.inputValue = ''\n      this.$emit('clear')\n    },\n    focused() {\n      this.isFocused = true\n      this.$emit('focus')\n    },\n    blurred() {\n      if (this.trimWhitespace && typeof this.inputValue === 'string') {\n        this.inputValue = this.inputValue.trim()\n      }\n      this.isFocused = false\n      this.$emit('blur')\n    },\n\n    change(e) {\n      this.$emit('change', e.target.value)\n    },\n    keyup(e) {\n      this.$emit('keyup', e)\n\n      if (this.type === 'datetime-local') {\n        if (e.target.validity?.badInput) {\n          this.isInvalidDate = true\n        } else {\n          this.isInvalidDate = false\n        }\n      }\n    },\n    blur() {\n      if (this.$refs.input) this.$refs.input.blur()\n    },\n    setFocus() {\n      if (this.$refs.input) this.$refs.input.focus()\n    },\n    mouseover() {\n      this.isHovering = true\n    },\n    mouseleave() {\n      this.isHovering = false\n    }\n  },\n  mounted() {\n    if (this.type === 'password' && this.$refs.wrapper) {\n      this.$refs.wrapper.addEventListener('mouseover', this.mouseover)\n      this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)\n    }\n  }\n}\n</script>\n\n<style scoped>\ninput {\n  border-style: inherit !important;\n}\ninput:read-only {\n  color: #bbb;\n  background-color: #444;\n}\ninput::-webkit-calendar-picker-indicator {\n  filter: invert(1);\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/TextInputWithLabel.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <slot>\n      <label :for=\"identifier\" class=\"px-1 text-sm font-semibold\" :class=\"{ 'text-gray-400': disabled }\">\n        {{ label }}\n        <em v-if=\"note\" class=\"font-normal text-xs pl-2\">{{ note }}</em>\n      </label>\n    </slot>\n    <ui-text-input :placeholder=\"placeholder || label\" :inputId=\"identifier\" ref=\"input\" v-model=\"inputValue\" :disabled=\"disabled\" :readonly=\"readonly\" :type=\"type\" :min=\"min\" :show-copy=\"showCopy\" class=\"w-full\" :class=\"inputClass\" :trim-whitespace=\"trimWhitespace\" @blur=\"inputBlurred\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    label: String,\n    placeholder: String,\n    note: String,\n    type: {\n      type: String,\n      default: 'text'\n    },\n    min: [String, Number],\n    readonly: Boolean,\n    disabled: Boolean,\n    inputClass: String,\n    showCopy: Boolean,\n    trimWhitespace: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    inputValue: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    identifier() {\n      return Math.random().toString(36).substring(2)\n    }\n  },\n  methods: {\n    setFocus() {\n      if (this.$refs.input && this.$refs.input.setFocus) {\n        this.$refs.input.setFocus()\n      }\n    },\n    blur() {\n      if (this.$refs.input && this.$refs.input.blur) {\n        this.$refs.input.blur()\n      }\n    },\n    inputBlurred() {\n      this.$emit('blur')\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/TextareaInput.vue",
    "content": "<template>\n  <textarea ref=\"input\" v-model=\"inputValue\" :rows=\"rows\" :readonly=\"readonly\" :disabled=\"disabled\" :placeholder=\"placeholder\" dir=\"auto\" class=\"py-2 px-3 rounded-sm bg-primary text-gray-200 focus:border-gray-500 focus:outline-hidden\" :class=\"transparent ? '' : 'border border-gray-600'\" @change=\"change\" />\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    placeholder: String,\n    readonly: Boolean,\n    rows: {\n      type: Number,\n      default: 2\n    },\n    transparent: Boolean,\n    disabled: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    inputValue: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    change(e) {\n      this.$emit('change', e.target.value)\n    },\n    blur() {\n      if (this.$refs.input && this.$refs.input.blur) {\n        this.$refs.input.blur()\n      }\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\ntextarea {\n  border-style: inherit !important;\n}\ntextarea:read-only {\n  color: #aaa;\n  background-color: #444;\n}\n</style>"
  },
  {
    "path": "client/components/ui/TextareaWithLabel.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <p class=\"px-1 text-sm font-semibold\" :class=\"disabled ? 'text-gray-400' : ''\">{{ label }}</p>\n    <ui-textarea-input ref=\"input\" v-model=\"inputValue\" :disabled=\"disabled\" :readonly=\"readonly\" :rows=\"rows\" class=\"w-full\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    label: String,\n    disabled: Boolean,\n    readonly: Boolean,\n    rows: {\n      type: Number,\n      default: 2\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    inputValue: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    }\n  },\n  methods: {\n    blur() {\n      if (this.$refs.input && this.$refs.input.blur) {\n        this.$refs.input.blur()\n      }\n    }\n  },\n  mounted() {}\n}\n</script>"
  },
  {
    "path": "client/components/ui/TimePicker.vue",
    "content": "<template>\n  <div tabindex=\"0\" @focus=\"focusDigit('second0')\" class=\"relative\">\n    <div class=\"rounded-sm text-gray-200 border w-full px-3 py-2\" :class=\"focusedDigit ? 'bg-primary/50 border-gray-300' : 'bg-primary border-gray-600'\" @click=\"clickInput\" v-click-outside=\"clickOutsideObj\">\n      <div class=\"flex items-center\">\n        <template v-for=\"(digit, index) in digitDisplay\">\n          <div v-if=\"digit == ':'\" :key=\"index\" class=\"px-px\" @click.stop=\"clickMedian(index)\">:</div>\n          <div v-else :key=\"index\" class=\"px-px\" :class=\"{ 'digit-focused': focusedDigit == digit }\" @click.stop=\"focusDigit(digit)\">{{ digits[digit] }}</div>\n        </template>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    showThreeDigitHour: Boolean\n  },\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      digitDisplay: ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0'],\n      focusedDigit: null,\n      digits: {\n        hour2: 0,\n        hour1: 0,\n        hour0: 0,\n        minute1: 0,\n        minute0: 0,\n        second1: 0,\n        second0: 0\n      },\n      isOver99Hours: false\n    }\n  },\n  watch: {\n    value: {\n      immediate: true,\n      handler() {\n        this.initDigits()\n      }\n    }\n  },\n  computed: {},\n  methods: {\n    initDigits() {\n      var totalSeconds = !this.value || isNaN(this.value) ? 0 : Number(this.value)\n      totalSeconds = Math.round(totalSeconds)\n\n      var minutes = Math.floor(totalSeconds / 60)\n      var seconds = totalSeconds - minutes * 60\n      var hours = Math.floor(minutes / 60)\n      minutes -= hours * 60\n\n      this.digits.second1 = seconds <= 9 ? 0 : Number(String(seconds)[0])\n      this.digits.second0 = seconds <= 9 ? seconds : Number(String(seconds)[1])\n\n      this.digits.minute1 = minutes <= 9 ? 0 : Number(String(minutes)[0])\n      this.digits.minute0 = minutes <= 9 ? minutes : Number(String(minutes)[1])\n\n      if (hours > 99) {\n        this.digits.hour2 = Number(String(hours)[0])\n        this.digits.hour1 = Number(String(hours)[1])\n        this.digits.hour0 = Number(String(hours)[2])\n        this.isOver99Hours = true\n      } else {\n        this.digits.hour1 = hours <= 9 ? 0 : Number(String(hours)[0])\n        this.digits.hour0 = hours <= 9 ? hours : Number(String(hours)[1])\n        this.isOver99Hours = this.showThreeDigitHour\n      }\n\n      if (this.isOver99Hours) {\n        this.digitDisplay = ['hour2', 'hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']\n      } else {\n        this.digitDisplay = ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']\n      }\n    },\n    updateSeconds() {\n      var seconds = this.digits.second0 + this.digits.second1 * 10\n      seconds += this.digits.minute0 * 60 + this.digits.minute1 * 600\n      seconds += this.digits.hour0 * 3600 + this.digits.hour1 * 36000\n      if (this.isOver99Hours) seconds += this.digits.hour2 * 360000\n\n      if (Number(this.value) !== seconds) {\n        this.$emit('input', seconds)\n        this.$emit('change', seconds)\n      }\n    },\n    clickMedian(index) {\n      // Click colon select digit to right\n      if (index >= 5) {\n        this.focusedDigit = 'second1'\n      } else {\n        this.focusedDigit = 'minute1'\n      }\n    },\n    clickOutside() {\n      this.removeFocus()\n    },\n    removeFocus() {\n      this.focusedDigit = null\n      this.removeListeners()\n    },\n    focusDigit(digit) {\n      if (this.focusedDigit == null || isNaN(this.focusedDigit)) this.initListeners()\n      this.focusedDigit = digit\n    },\n    clickInput() {\n      if (this.focusedDigit) return\n      this.focusDigit('second0')\n    },\n    shiftFocusLeft() {\n      if (!this.focusedDigit) return\n      if (this.focusedDigit.endsWith('2')) return\n\n      const isDigit1 = this.focusedDigit.endsWith('1')\n      if (!isDigit1) {\n        const digit1Key = this.focusedDigit.replace('0', '1')\n        this.focusedDigit = digit1Key\n      } else if (this.focusedDigit.startsWith('second')) {\n        this.focusedDigit = 'minute0'\n      } else if (this.focusedDigit.startsWith('minute')) {\n        this.focusedDigit = 'hour0'\n      } else if (this.isOver99Hours && this.focusedDigit.startsWith('hour')) {\n        this.focusedDigit = 'hour2'\n      }\n    },\n    shiftFocusRight() {\n      if (!this.focusedDigit) return\n      if (this.focusedDigit.endsWith('2')) {\n        // Must be hour2\n        this.focusedDigit = 'hour1'\n        return\n      }\n      const isDigit1 = this.focusedDigit.endsWith('1')\n      if (isDigit1) {\n        const digit0Key = this.focusedDigit.replace('1', '0')\n        this.focusedDigit = digit0Key\n      } else if (this.focusedDigit.startsWith('hour')) {\n        this.focusedDigit = 'minute1'\n      } else if (this.focusedDigit.startsWith('minute')) {\n        this.focusedDigit = 'second1'\n      }\n    },\n    increaseFocused() {\n      if (!this.focusedDigit) return\n      const isDigit1 = this.focusedDigit.endsWith('1')\n      const digit = Number(this.digits[this.focusedDigit])\n      if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = (digit + 1) % 6\n      else this.digits[this.focusedDigit] = (digit + 1) % 10\n      this.updateSeconds()\n    },\n    decreaseFocused() {\n      if (!this.focusedDigit) return\n      const isDigit1 = this.focusedDigit.endsWith('1')\n      const digit = Number(this.digits[this.focusedDigit])\n      if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = digit - 1 < 0 ? 5 : digit - 1\n      else this.digits[this.focusedDigit] = digit - 1 < 0 ? 9 : digit - 1\n      this.updateSeconds()\n    },\n    keydown(evt) {\n      if (!this.focusedDigit || !evt.key) return\n\n      if (evt.key === 'ArrowLeft') {\n        return this.shiftFocusLeft()\n      } else if (evt.key === 'ArrowRight') {\n        return this.shiftFocusRight()\n      } else if (evt.key === 'ArrowUp') {\n        return this.increaseFocused()\n      } else if (evt.key === 'ArrowDown') {\n        return this.decreaseFocused()\n      } else if (evt.key === 'Enter' || evt.key === 'Escape' || evt.key === 'Tab') {\n        return this.removeFocus()\n      }\n\n      if (isNaN(evt.key)) return\n\n      var digit = Number(evt.key)\n      const isDigit1 = this.focusedDigit.endsWith('1')\n      if (isDigit1 && !this.focusedDigit.startsWith('hour') && digit >= 6) {\n        digit = 5\n      }\n\n      this.digits[this.focusedDigit] = digit\n\n      this.updateSeconds()\n      this.shiftFocusRight()\n    },\n    initListeners() {\n      window.addEventListener('keydown', this.keydown)\n    },\n    removeListeners() {\n      window.removeEventListener('keydown', this.keydown)\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    this.removeListeners()\n  }\n}\n</script>\n\n<style scoped>\n.digit-focused {\n  background-color: #555;\n}\n</style>\n"
  },
  {
    "path": "client/components/ui/ToggleBtns.vue",
    "content": "<template>\n  <div class=\"inline-flex toggle-btn-wrapper shadow-md\">\n    <button v-for=\"item in items\" :key=\"item.value\" type=\"button\" :disabled=\"disabled\" class=\"toggle-btn outline-hidden relative border border-gray-600 px-4 py-1\" :class=\"{ selected: item.value === value }\" @click.stop=\"clickBtn(item.value)\">\n      {{ item.text }}\n    </button>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: [String, Number],\n    /**\n     * [{ \"text\", \"\", \"value\": \"\" }]\n     */\n    items: {\n      type: Array,\n      default: Object\n    },\n    disabled: {\n      type: Boolean,\n      default: false\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {\n    clickBtn(value) {\n      this.$emit('input', value)\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style scoped>\n.toggle-btn-wrapper .toggle-btn:first-child {\n  border-top-left-radius: 0.375rem /* 6px */;\n  border-bottom-left-radius: 0.375rem /* 6px */;\n}\n.toggle-btn-wrapper .toggle-btn:last-child {\n  border-top-right-radius: 0.375rem /* 6px */;\n  border-bottom-right-radius: 0.375rem /* 6px */;\n}\n.toggle-btn-wrapper .toggle-btn:first-child::before {\n  border-top-left-radius: 0.375rem /* 6px */;\n  border-bottom-left-radius: 0.375rem /* 6px */;\n}\n.toggle-btn-wrapper .toggle-btn:last-child::before {\n  border-top-right-radius: 0.375rem /* 6px */;\n  border-bottom-right-radius: 0.375rem /* 6px */;\n}\n\n.toggle-btn-wrapper .toggle-btn:not(:first-child) {\n  margin-left: -1px;\n}\n\n.toggle-btn::before {\n  content: '';\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(255, 255, 255, 0);\n  transition: all 0.1s ease-in-out;\n}\n.toggle-btn:hover:not(:disabled)::before {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n.toggle-btn:hover:not(:disabled) {\n  color: white;\n}\n\n.toggle-btn {\n  color: rgba(255, 255, 255, 0.75);\n}\n.toggle-btn.selected {\n  color: white;\n}\n.toggle-btn.selected:disabled {\n  color: white;\n}\n.toggle-btn.selected::before {\n  background-color: rgba(255, 255, 255, 0.1);\n}\nbutton.toggle-btn.selected:disabled::before {\n  background-color: rgba(255, 255, 255, 0.05);\n}\nbutton.toggle-btn:disabled::before {\n  background-color: rgba(0, 0, 0, 0.2);\n}\nbutton.toggle-btn:disabled {\n  cursor: not-allowed;\n}\n</style>"
  },
  {
    "path": "client/components/ui/ToggleSwitch.vue",
    "content": "<template>\n  <div>\n    <button :aria-labelledby=\"labeledBy\" :aria-label=\"label\" role=\"checkbox\" type=\"button\" class=\"border rounded-full border-black-100 flex items-center cursor-pointer justify-start\" :style=\"{ width: buttonWidth + 'px' }\" :aria-checked=\"toggleValue\" :class=\"className\" @click=\"clickToggle\">\n      <span class=\"rounded-full border border-black-50 shadow-sm transform transition-transform duration-100\" :style=\"{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }\" :class=\"switchClassName\"></span>\n    </button>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean,\n    onColor: {\n      type: String,\n      default: 'success'\n    },\n    offColor: {\n      type: String,\n      default: 'primary'\n    },\n    disabled: Boolean,\n    labeledBy: String,\n    label: String,\n    size: {\n      type: String,\n      default: 'md'\n    }\n  },\n  computed: {\n    toggleValue: {\n      get() {\n        return this.value\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    className() {\n      if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`\n      return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`\n    },\n    switchClassName() {\n      var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'\n      return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor\n    },\n    cursorHeightWidth() {\n      if (this.size === 'sm') return 16\n      return 20\n    },\n    buttonWidth() {\n      return this.cursorHeightWidth * 2\n    }\n  },\n  methods: {\n    clickToggle() {\n      if (this.disabled) return\n      this.toggleValue = !this.toggleValue\n    }\n  }\n}\n</script>"
  },
  {
    "path": "client/components/ui/Tooltip.vue",
    "content": "<template>\n  <div ref=\"box\" @mouseover=\"mouseover\" @mouseleave=\"mouseleave\">\n    <slot />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    text: {\n      type: [String, Number],\n      required: true\n    },\n    direction: {\n      type: String,\n      default: 'right'\n    },\n    /**\n     * Delay showing the tooltip after X milliseconds of hovering\n     */\n    delayOnShow: {\n      type: Number,\n      default: 0\n    },\n    disabled: Boolean,\n    plaintext: Boolean\n  },\n  data() {\n    return {\n      tooltip: null,\n      tooltipId: null,\n      isShowing: false,\n      hideTimeout: null,\n      delayOnShowTimeout: null\n    }\n  },\n  watch: {\n    text() {\n      this.updateText()\n    },\n    disabled(newVal) {\n      if (newVal && this.isShowing) {\n        this.hideTooltip()\n      }\n    }\n  },\n  methods: {\n    updateText() {\n      if (this.tooltip) {\n        if (this.plaintext) {\n          this.tooltip.textContent = this.text\n        } else {\n          this.tooltip.innerHTML = this.text\n        }\n        this.setTooltipPosition(this.tooltip)\n      }\n    },\n    createTooltip() {\n      if (!this.$refs.box) return\n      var tooltip = document.createElement('div')\n      this.tooltipId = String(Math.floor(Math.random() * 10000))\n      tooltip.id = this.tooltipId\n      tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white text-xs rounded-sm shadow-lg max-w-xs text-center hidden sm:block'\n      tooltip.style.zIndex = 100\n      tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'\n      if (this.plaintext) {\n        tooltip.textContent = this.text\n      } else {\n        tooltip.innerHTML = this.text\n      }\n      tooltip.addEventListener('mouseover', this.cancelHide)\n      tooltip.addEventListener('mouseleave', this.hideTooltip)\n\n      this.setTooltipPosition(tooltip)\n\n      this.tooltip = tooltip\n    },\n    setTooltipPosition(tooltip) {\n      const boxRect = this.$refs.box.getBoundingClientRect()\n\n      const shouldMount = !tooltip.isConnected\n\n      // Calculate size of tooltip\n      if (shouldMount) document.body.appendChild(tooltip)\n      const tooltipRect = tooltip.getBoundingClientRect()\n      if (shouldMount) tooltip.remove()\n\n      // Subtracting scrollbar size\n      const windowHeight = window.innerHeight - 8\n      const windowWidth = window.innerWidth - 8\n\n      let top = 0\n      let left = 0\n      if (this.direction === 'right') {\n        top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)\n        left = Math.max(0, boxRect.left + boxRect.width + 4)\n      } else if (this.direction === 'bottom') {\n        top = Math.max(0, boxRect.top + boxRect.height + 4)\n        left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)\n      } else if (this.direction === 'top') {\n        top = Math.max(0, boxRect.top - tooltipRect.height - 4)\n        left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)\n      } else if (this.direction === 'left') {\n        top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)\n        left = Math.max(0, boxRect.left - tooltipRect.width - 4)\n      }\n\n      // Shift left if tooltip would overflow the window on the right\n      if (left + tooltipRect.width > windowWidth) {\n        left -= left + tooltipRect.width - windowWidth\n      }\n      // Shift up if tooltip would overflow the window on the bottom\n      if (top + tooltipRect.height > windowHeight) {\n        top -= top + tooltipRect.height - windowHeight\n      }\n\n      tooltip.style.top = top + 'px'\n      tooltip.style.left = left + 'px'\n    },\n    showTooltip() {\n      if (this.disabled) return\n      if (!this.tooltip) {\n        this.createTooltip()\n        if (!this.tooltip) return\n      }\n      if (!this.$refs.box) return // Ensure element is not destroyed\n      try {\n        document.body.appendChild(this.tooltip)\n        this.setTooltipPosition(this.tooltip)\n      } catch (error) {\n        console.error(error)\n      }\n\n      this.isShowing = true\n    },\n    hideTooltip() {\n      if (!this.tooltip) return\n      this.tooltip.remove()\n      this.isShowing = false\n    },\n    cancelHide() {\n      clearTimeout(this.hideTimeout)\n    },\n    mouseover() {\n      if (this.isShowing || this.disabled) return\n\n      if (this.delayOnShow) {\n        if (this.delayOnShowTimeout) {\n          // Delay already running\n          return\n        }\n\n        this.delayOnShowTimeout = setTimeout(() => {\n          this.showTooltip()\n          this.delayOnShowTimeout = null\n        }, this.delayOnShow)\n      } else {\n        this.showTooltip()\n      }\n    },\n    mouseleave() {\n      if (!this.isShowing) {\n        clearTimeout(this.delayOnShowTimeout)\n        this.delayOnShowTimeout = null\n        return\n      }\n\n      this.hideTimeout = setTimeout(this.hideTooltip, 100)\n    }\n  },\n  beforeDestroy() {\n    this.hideTooltip()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/ui/VueTrix.vue",
    "content": "<template>\n  <div>\n    <trix-toolbar :id=\"toolbarId\">\n      <div v-show=\"!disabledEditor\" class=\"trix-button-row\">\n        <span class=\"trix-button-group trix-button-group--text-tools\" data-trix-button-group=\"text-tools\">\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-bold\" data-trix-attribute=\"bold\" data-trix-key=\"b\" :title=\"$strings.LabelFontBold\" tabindex=\"-1\">{{ $strings.LabelFontBold }}</button>\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-italic\" data-trix-attribute=\"italic\" data-trix-key=\"i\" :title=\"$strings.LabelFontItalic\" tabindex=\"-1\">{{ $strings.LabelFontItalic }}</button>\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-strike\" data-trix-attribute=\"strike\" :title=\"$strings.LabelFontStrikethrough\" tabindex=\"-1\">{{ $strings.LabelFontStrikethrough }}</button>\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-link\" data-trix-attribute=\"href\" data-trix-action=\"link\" data-trix-key=\"k\" :title=\"$strings.LabelTextEditorLink\" tabindex=\"-1\">{{ $strings.LabelTextEditorLink }}</button>\n        </span>\n        <span class=\"trix-button-group trix-button-group--block-tools\" data-trix-button-group=\"block-tools\">\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-bullet-list\" data-trix-attribute=\"bullet\" :title=\"$strings.LabelTextEditorBulletedList\" tabindex=\"-1\">{{ $strings.LabelTextEditorBulletedList }}</button>\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-number-list\" data-trix-attribute=\"number\" :title=\"$strings.LabelTextEditorNumberedList\" tabindex=\"-1\">{{ $strings.LabelTextEditorNumberedList }}</button>\n        </span>\n\n        <span class=\"trix-button-group-spacer\"></span>\n        <span class=\"trix-button-group trix-button-group--history-tools\" data-trix-button-group=\"history-tools\">\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-undo\" data-trix-action=\"undo\" data-trix-key=\"z\" :title=\"$strings.LabelUndo\" tabindex=\"-1\">{{ $strings.LabelUndo }}</button>\n          <button type=\"button\" class=\"trix-button trix-button--icon trix-button--icon-redo\" data-trix-action=\"redo\" data-trix-key=\"shift+z\" :title=\"$strings.LabelRedo\" tabindex=\"-1\">{{ $strings.LabelRedo }}</button>\n        </span>\n      </div>\n      <div class=\"trix-dialogs\" data-trix-dialogs>\n        <div class=\"trix-dialog trix-dialog--link\" data-trix-dialog=\"href\" data-trix-dialog-attribute=\"href\">\n          <div class=\"trix-dialog__link-fields\">\n            <input type=\"url\" name=\"href\" class=\"trix-input trix-input--dialog\" placeholder=\"\" aria-label=\"URL\" required data-trix-input />\n            <div class=\"trix-button-group\">\n              <input type=\"button\" class=\"trix-button trix-button--dialog\" :value=\"$strings.LabelTextEditorLink\" data-trix-method=\"setAttribute\" />\n              <input type=\"button\" class=\"trix-button trix-button--dialog\" :value=\"$strings.LabelTextEditorUnlink\" data-trix-method=\"removeAttribute\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    </trix-toolbar>\n    <trix-editor :toolbar=\"toolbarId\" :contenteditable=\"!disabledEditor\" :class=\"['trix-content']\" ref=\"trix\" :input=\"computedId\" :placeholder=\"placeholder\" @trix-change=\"handleContentChange\" @trix-initialize=\"handleInitialize\" @trix-focus=\"processTrixFocus\" @trix-blur=\"processTrixBlur\" @trix-attachment-add=\"handleAttachmentAdd\" />\n    <input type=\"hidden\" :name=\"inputName\" :id=\"computedId\" :value=\"editorContent\" />\n  </div>\n</template>\n\n<script>\n/*\n  ORIGINAL SOURCE: https://github.com/hanhdt/vue-trix\n\n  modified for audiobookshelf\n*/\nimport Trix from 'trix'\nimport '@/assets/trix.css'\n\nfunction enableBreakParagraphOnReturn() {\n  // Trix works with divs by default, we want paragraphs instead\n  Trix.config.blockAttributes.default.tagName = 'p'\n  // Enable break paragraph on Enter (Shift + Enter will still create a line break)\n  Trix.config.blockAttributes.default.breakOnReturn = true\n\n  // Hack to fix buggy paragraph breaks\n  // Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942\n  Trix.Block.prototype.breaksOnReturn = function () {\n    const attr = this.getLastAttribute()\n    const config = Trix.getBlockConfig(attr ? attr : 'default')\n    return config ? config.breakOnReturn : false\n  }\n  Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {\n    if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {\n      return this.startLocation.offset > 0\n    } else {\n      return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false\n    }\n  }\n}\n\nenableBreakParagraphOnReturn()\n\nexport default {\n  name: 'vue-trix',\n  model: {\n    prop: 'srcContent',\n    event: 'update'\n  },\n  props: {\n    /**\n     * This prop will put the editor in read-only mode\n     */\n    disabledEditor: {\n      type: Boolean,\n      required: false,\n      default() {\n        return false\n      }\n    },\n    /**\n     * This is referenced `id` of the hidden input field defined.\n     * It is optional and will be a random string by default.\n     */\n    inputId: {\n      type: String,\n      required: false,\n      default() {\n        return ''\n      }\n    },\n    /**\n     * This is referenced `name` of the hidden input field defined,\n     * default value is `content`.\n     */\n    inputName: {\n      type: String,\n      required: false,\n      default() {\n        return 'content'\n      }\n    },\n    /**\n     * The placeholder attribute specifies a short hint\n     * that describes the expected value of a editor.\n     */\n    placeholder: {\n      type: String,\n      required: false,\n      default() {\n        return ''\n      }\n    },\n    /**\n     * The source content is associcated to v-model directive.\n     */\n    srcContent: {\n      type: String,\n      required: false,\n      default() {\n        return ''\n      }\n    },\n    /**\n     * The boolean attribute allows saving editor state into browser's localStorage\n     * (optional, default is `false`).\n     */\n    localStorage: {\n      type: Boolean,\n      required: false,\n      default() {\n        return false\n      }\n    },\n    /**\n     * Focuses cursor in the editor when attached to the DOM\n     * (optional, default is `false`).\n     */\n    autofocus: {\n      type: Boolean,\n      required: false,\n      default() {\n        return false\n      }\n    },\n    /**\n     * Object to override default editor configuration\n     */\n    config: {\n      type: Object,\n      required: false,\n      default() {\n        return {}\n      }\n    }\n  },\n  data() {\n    return {\n      editorContent: this.srcContent,\n      isActived: null\n    }\n  },\n  watch: {\n    editorContent: {\n      handler: 'emitEditorState'\n    },\n    initialContent: {\n      handler: 'handleInitialContentChange'\n    },\n    isDisabled: {\n      handler: 'decorateDisabledEditor'\n    },\n    config: {\n      handler: 'overrideConfig',\n      immediate: true,\n      deep: true\n    }\n  },\n  computed: {\n    /**\n     * Compute a random id of hidden input\n     * when it haven't been specified.\n     */\n    toolbarId() {\n      return `trix-toolbar-${this.generateId}`\n    },\n    generateId() {\n      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n        var r = (Math.random() * 16) | 0\n        var v = c === 'x' ? r : (r & 0x3) | 0x8\n        return v.toString(16)\n      })\n    },\n    computedId() {\n      return this.inputId || this.generateId\n    },\n    initialContent() {\n      return this.srcContent\n    },\n    isDisabled() {\n      return this.disabledEditor\n    }\n  },\n  methods: {\n    processTrixFocus(event) {\n      if (this.$refs.trix) {\n        this.isActived = true\n        this.$emit('trix-focus', this.$refs.trix.editor, event)\n      }\n    },\n    processTrixBlur(event) {\n      if (this.$refs.trix) {\n        this.isActived = false\n        this.$emit('trix-blur', this.$refs.trix.editor, event)\n      }\n    },\n    handleContentChange(event) {\n      this.editorContent = event.srcElement ? event.srcElement.value : event.target.value\n      this.$emit('input', this.editorContent)\n    },\n    handleInitialize(event) {\n      /**\n       * If autofocus is true, manually set focus to\n       * beginning of content (consistent with Trix behavior)\n       */\n      if (this.autofocus) {\n        this.$refs.trix.editor.setSelectedRange(0)\n      }\n      this.$emit('trix-initialize', this.emitInitialize)\n    },\n    handleInitialContentChange(newContent, oldContent) {\n      newContent = newContent === undefined ? '' : newContent\n      if (this.$refs.trix.editor && this.$refs.trix.editor.innerHTML !== newContent) {\n        /* Update editor's content when initial content changed */\n        this.editorContent = newContent\n        /**\n         *  If user are typing, then don't reload the editor,\n         *  hence keep cursor's position after typing.\n         */\n        if (!this.isActived) {\n          this.reloadEditorContent(this.editorContent)\n        }\n      }\n    },\n    emitEditorState(value) {\n      /**\n       * If localStorage is enabled,\n       * then save editor's content into storage\n       */\n      if (this.localStorage) {\n        localStorage.setItem(this.storageId('VueTrix'), JSON.stringify(this.$refs.trix.editor))\n      }\n      this.$emit('update', this.editorContent)\n    },\n    storageId(component) {\n      if (this.inputId) {\n        return `${component}.${this.inputId}.content`\n      } else {\n        return `${component}.content`\n      }\n    },\n    reloadEditorContent(newContent) {\n      // Reload HTML content\n      this.$refs.trix.editor.loadHTML(newContent)\n      // Move cursor to end of new content updated\n      if (this.autofocus) {\n        this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())\n      }\n    },\n    getContentEndPosition() {\n      return this.$refs.trix.editor.getDocument().toString().length - 1\n    },\n    decorateDisabledEditor(editorState) {\n      /** Disable toolbar and editor by pointer events styling */\n      if (editorState) {\n        this.$refs.trix.disabled = true\n        this.$refs.trix.contentEditable = false\n        this.$refs.trix.style['pointer-events'] = 'none'\n        this.$refs.trix.style['background-color'] = '#444'\n        this.$refs.trix.style['color'] = '#bbb'\n      } else {\n        this.$refs.trix.disabled = false\n        this.$refs.trix.contentEditable = true\n        this.$refs.trix.style['pointer-events'] = 'unset'\n        this.$refs.trix.style['background-color'] = ''\n        this.$refs.trix.style['color'] = ''\n      }\n    },\n    overrideConfig(config) {\n      Trix.config = this.deepMerge(Trix.config, config)\n    },\n    deepMerge(target, override) {\n      // deep merge the object into the target object\n      for (let prop in override) {\n        if (override.hasOwnProperty(prop)) {\n          if (Object.prototype.toString.call(override[prop]) === '[object Object]') {\n            // if the property is a nested object\n            target[prop] = this.deepMerge(target[prop], override[prop])\n          } else {\n            // for regular property\n            target[prop] = override[prop]\n          }\n        }\n      }\n      return target\n    },\n    blur() {\n      if (this.$refs.trix && this.$refs.trix.blur) {\n        this.$refs.trix.blur()\n      }\n    },\n    handleAttachmentAdd(event) {\n      // Prevent pasting in images/any files from the browser\n      event.attachment.remove()\n    }\n  },\n  mounted() {\n    /** Override editor configuration */\n    this.overrideConfig(this.config)\n    /** Check if editor read-only mode is required */\n    this.decorateDisabledEditor(this.disabledEditor)\n    this.$nextTick(() => {\n      /**\n       *  If localStorage is enabled,\n       *  then load editor's content from the beginning.\n       */\n      if (this.localStorage) {\n        const savedValue = localStorage.getItem(this.storageId('VueTrix'))\n        if (savedValue && !this.srcContent) {\n          this.$refs.trix.editor.loadJSON(JSON.parse(savedValue))\n        }\n      }\n    })\n  }\n}\n</script>\n\n<style lang=\"css\" module>\n.trix_container {\n  max-width: 100%;\n  height: auto;\n}\n.trix_container .trix-button-group {\n  background-color: white;\n}\n.trix_container .trix-content {\n  background-color: white;\n}\ntrix-editor {\n  height: calc(4 * 1lh);\n  min-height: calc(4 * 1lh);\n  overflow-y: auto;\n  resize: vertical;\n}\n\ntrix-editor * {\n  pointer-events: inherit;\n}\n</style>\n"
  },
  {
    "path": "client/components/widgets/AbridgedIndicator.vue",
    "content": "<template>\n  <ui-tooltip :text=\"$strings.LabelAbridged\" direction=\"top\">\n    <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12px\" height=\"12px\" viewBox=\"0 0 512 512\" class=\"ml-1\">\n      <path\n        fill=\"white\"\n        d=\"M 89.00,40.12\n           C 89.00,40.12 127.00,40.12 127.00,40.12\n             127.00,40.12 198.00,40.12 198.00,40.12\n             198.00,40.12 416.00,40.12 416.00,40.12\n             446.58,40.05 472.95,66.42 473.00,97.00\n             473.00,97.00 473.00,303.00 473.00,303.00\n             473.00,303.00 473.00,418.00 473.00,418.00\n             472.65,447.55 445.06,472.95 416.00,473.00\n             416.00,473.00 210.00,473.00 210.00,473.00\n             210.00,473.00 95.00,473.00 95.00,473.00\n             65.45,472.65 40.05,445.06 40.00,416.00\n             40.00,416.00 40.00,136.00 40.00,136.00\n             40.00,136.00 40.00,109.00 40.00,109.00\n             40.00,109.00 40.00,96.00 40.00,96.00\n             40.07,81.58 46.89,67.14 57.01,57.01\n             61.17,52.86 64.86,50.13 70.00,47.31\n             77.25,43.33 81.02,42.18 89.00,40.12 Z\n           M 372.00,392.00\n           C 372.00,392.00 364.02,364.00 364.02,364.00\n             364.02,364.00 350.72,319.00 350.72,319.00\n             350.72,319.00 310.42,183.00 310.42,183.00\n             310.42,183.00 296.86,137.00 296.86,137.00\n             296.86,137.00 291.30,121.99 291.30,121.99\n             291.30,121.99 284.00,121.00 284.00,121.00\n             284.00,121.00 230.00,121.00 230.00,121.00\n             230.00,121.00 222.51,122.02 222.51,122.02\n             222.51,122.02 216.86,137.00 216.86,137.00\n             216.86,137.00 203.28,183.00 203.28,183.00\n             203.28,183.00 163.28,318.00 163.28,318.00\n             163.28,318.00 148.71,367.00 148.71,367.00\n             148.71,367.00 142.00,392.00 142.00,392.00\n             142.00,392.00 183.00,392.00 183.00,392.00\n             183.00,392.00 190.86,390.43 190.86,390.43\n             190.86,390.43 195.86,375.00 195.86,375.00\n             195.86,375.00 206.00,338.00 206.00,338.00\n             206.00,338.00 293.00,338.00 293.00,338.00\n             295.64,338.01 299.26,337.65 301.30,339.60\n             303.23,341.43 304.80,348.22 305.58,351.00\n             305.58,351.00 313.00,378.00 313.00,378.00\n             316.91,391.63 315.20,391.98 325.00,392.00\n             325.00,392.00 372.00,392.00 372.00,392.00 Z\n           M 254.00,170.00\n           C 254.00,170.00 256.00,170.00 256.00,170.00\n             256.00,170.00 263.12,197.00 263.12,197.00\n             263.12,197.00 282.88,268.00 282.88,268.00\n             282.88,268.00 290.00,296.00 290.00,296.00\n             290.00,296.00 219.00,296.00 219.00,296.00\n             219.00,296.00 230.58,253.00 230.58,253.00\n             230.58,253.00 254.00,170.00 254.00,170.00 Z\"\n      />\n    </svg>\n  </ui-tooltip>\n</template>\n\n<script>\nexport default {\n  props: {},\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/Alert.vue",
    "content": "<template>\n  <div class=\"w-full border rounded-lg flex items-center relative py-4 pl-16\" :class=\"wrapperClass\">\n    <div class=\"absolute top-0 left-4 h-full flex items-center\">\n      <span class=\"material-symbols text-2xl\">{{ icon }}</span>\n    </div>\n    <slot />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    type: {\n      type: String,\n      default: 'error'\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    icon() {\n      if (this.type === 'error' || this.type === 'warning') return 'report'\n      return 'info'\n    },\n    wrapperClass() {\n      switch (this.type) {\n        case 'error':\n          return 'bg-error/5 border-error/60 text-error'\n        case 'warning':\n          return 'bg-warning/5 border-warning/60 text-warning'\n        case 'success':\n          return 'bg-success/5 border-success/60 text-success'\n        case 'info':\n          return 'bg-info/5 border-info/60 text-info'\n        default:\n          return 'bg-primary/5 border-primary/60 text-primary'\n      }\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/AlreadyInLibraryIndicator.vue",
    "content": "<template>\n  <ui-tooltip :text=\"$strings.LabelAlreadyInYourLibrary\" direction=\"top\" class=\"inline-flex\">\n    <span class=\"material-symbols ml-1 text-sm text-success\">check_circle</span>\n  </ui-tooltip>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/BookDetailsEdit.vue",
    "content": "<template>\n  <div class=\"w-full h-full relative\">\n    <form class=\"w-full h-full px-2 md:px-4 py-6\" @submit.prevent=\"submitForm\">\n      <div class=\"flex flex-wrap -mx-1\">\n        <div class=\"w-full md:w-1/2 px-1\">\n          <ui-text-input-with-label ref=\"titleInput\" v-model=\"details.title\" :label=\"$strings.LabelTitle\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1 mt-2 md:mt-0\">\n          <ui-text-input-with-label ref=\"subtitleInput\" v-model=\"details.subtitle\" :label=\"$strings.LabelSubtitle\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <div class=\"flex flex-wrap mt-2 -mx-1\">\n        <div class=\"w-full md:w-3/4 px-1\">\n          <!-- Authors filter only contains authors in this library, uses filter data -->\n          <ui-multi-select-query-input ref=\"authorsSelect\" v-model=\"details.authors\" :label=\"$strings.LabelAuthors\" filter-key=\"authors\" @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1 mt-2 md:mt-0\">\n          <ui-text-input-with-label ref=\"publishYearInput\" v-model=\"details.publishedYear\" type=\"number\" :label=\"$strings.LabelPublishYear\" @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <div class=\"flex mt-2 -mx-1\">\n        <div class=\"grow px-1\">\n          <widgets-series-input-widget v-model=\"details.series\" @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <ui-rich-text-editor ref=\"descriptionInput\" v-model=\"details.description\" :label=\"$strings.LabelDescription\" class=\"mt-2\" @input=\"handleInputChange\" />\n\n      <div class=\"flex flex-wrap mt-2 -mx-1\">\n        <div class=\"w-full md:w-1/2 px-1\">\n          <ui-multi-select ref=\"genresSelect\" v-model=\"details.genres\" :label=\"$strings.LabelGenres\" :items=\"genres\" @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1 mt-2 md:mt-0\">\n          <ui-multi-select ref=\"tagsSelect\" v-model=\"newTags\" :label=\"$strings.LabelTags\" :items=\"tags\" @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <div class=\"flex flex-wrap mt-2 -mx-1\">\n        <div class=\"w-full md:w-1/2 px-1\">\n          <ui-multi-select ref=\"narratorsSelect\" v-model=\"details.narrators\" :label=\"$strings.LabelNarrators\" :items=\"narrators\" @input=\"handleInputChange\" />\n        </div>\n        <div class=\"w-1/2 md:w-1/4 px-1 mt-2 md:mt-0\">\n          <ui-text-input-with-label ref=\"isbnInput\" v-model=\"details.isbn\" label=\"ISBN\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"w-1/2 md:w-1/4 px-1 mt-2 md:mt-0\">\n          <ui-text-input-with-label ref=\"asinInput\" v-model=\"details.asin\" label=\"ASIN\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <div class=\"flex flex-wrap mt-2 -mx-1\">\n        <div class=\"w-full md:w-1/4 px-1\">\n          <ui-text-input-with-label ref=\"publisherInput\" v-model=\"details.publisher\" :label=\"$strings.LabelPublisher\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"w-1/2 md:w-1/4 px-1 mt-2 md:mt-0\">\n          <ui-text-input-with-label ref=\"languageInput\" v-model=\"details.language\" :label=\"$strings.LabelLanguage\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1 pt-6 mt-2 md:mt-0\">\n          <div class=\"flex justify-center\">\n            <ui-checkbox v-model=\"details.explicit\" :label=\"$strings.LabelExplicit\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2 text-base font-semibold\" @input=\"handleInputChange\" />\n          </div>\n        </div>\n        <div class=\"grow px-1 pt-6 mt-2 md:mt-0\">\n          <div class=\"flex justify-center\">\n            <ui-checkbox v-model=\"details.abridged\" :label=\"$strings.LabelAbridged\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2 text-base font-semibold\" @input=\"handleInputChange\" />\n          </div>\n        </div>\n      </div>\n    </form>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      details: {\n        title: null,\n        subtitle: null,\n        description: null,\n        authors: [],\n        narrators: [],\n        series: [],\n        publishedYear: null,\n        publisher: null,\n        language: null,\n        isbn: null,\n        asin: null,\n        genres: [],\n        explicit: false,\n        abridged: false\n      },\n      newTags: []\n    }\n  },\n  watch: {\n    libraryItem: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  computed: {\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    genres() {\n      return this.filterData.genres || []\n    },\n    tags() {\n      return this.filterData.tags || []\n    },\n    series() {\n      return this.filterData.series || []\n    },\n    narrators() {\n      return this.filterData.narrators || []\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    }\n  },\n  methods: {\n    handleInputChange() {\n      this.$emit('change', {\n        libraryItemId: this.libraryItem.id,\n        hasChanges: this.checkForChanges().hasChanges\n      })\n    },\n    getDetails() {\n      this.forceBlur()\n      return this.checkForChanges()\n    },\n    getTitleAndAuthorName() {\n      this.forceBlur()\n      return {\n        title: this.details.title,\n        author: (this.details.authors || []).map((au) => au.name).join(', ')\n      }\n    },\n    mapBatchDetails(batchDetails, mapType = 'overwrite') {\n      for (const key in batchDetails) {\n        if (mapType === 'append') {\n          if (key === 'tags') {\n            // Concat and remove dupes\n            this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]\n          } else if (key === 'genres' || key === 'narrators') {\n            // Concat and remove dupes\n            this.details[key] = [...new Set(this.details[key].concat(batchDetails[key]))]\n          } else if (key === 'authors' || key === 'series') {\n            batchDetails[key].forEach((detail) => {\n              const existingDetail = this.details[key].find((_d) => _d.name.toLowerCase() == detail.name.toLowerCase().trim() || _d.id == detail.id)\n              if (!existingDetail) {\n                this.details[key].push({ ...detail })\n              }\n            })\n          }\n        } else {\n          if (key === 'tags') {\n            this.newTags = [...batchDetails.tags]\n          } else if (key === 'genres' || key === 'narrators') {\n            this.details[key] = [...batchDetails[key]]\n          } else if (key === 'authors' || key === 'series') {\n            this.details[key] = batchDetails[key].map((i) => ({ ...i }))\n          } else {\n            this.details[key] = batchDetails[key]\n          }\n        }\n      }\n      this.handleInputChange()\n    },\n    forceBlur() {\n      if (this.$refs.titleInput) this.$refs.titleInput.blur()\n      if (this.$refs.subtitleInput) this.$refs.subtitleInput.blur()\n      if (this.$refs.publishYearInput) this.$refs.publishYearInput.blur()\n      if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()\n      if (this.$refs.isbnInput) this.$refs.isbnInput.blur()\n      if (this.$refs.asinInput) this.$refs.asinInput.blur()\n      if (this.$refs.publisherInput) this.$refs.publisherInput.blur()\n      if (this.$refs.languageInput) this.$refs.languageInput.blur()\n\n      if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {\n        this.$refs.authorsSelect.forceBlur()\n      }\n      if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {\n        this.$refs.narratorsSelect.forceBlur()\n      }\n      if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {\n        this.$refs.genresSelect.forceBlur()\n      }\n      if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {\n        this.$refs.tagsSelect.forceBlur()\n      }\n    },\n    stringArrayEqual(array1, array2) {\n      // return false if different\n      if (array1.length !== array2.length) return false\n      for (var item of array1) {\n        if (!array2.includes(item)) return false\n      }\n      return true\n    },\n    objectArrayEqual(array1, array2) {\n      const isIterable = (value) => {\n        return Symbol.iterator in Object(value)\n      }\n      if (!isIterable(array1) || !isIterable(array2)) {\n        console.error(array1, array2)\n        throw new Error('Invalid arrays passed in')\n      }\n\n      // array of objects with id key\n      if (array1.length !== array2.length) return false\n\n      for (let i = 0; i < array1.length; i++) {\n        const item1 = array1[i]\n        const item2 = array2[i]\n        if (!item1 || !item2) return false\n\n        for (const key in item1) {\n          if (item1[key] !== item2[key]) {\n            // console.log('Object array item keys changed', key, item[key], matchingItem[key])\n            return false\n          }\n        }\n      }\n      return true\n    },\n    checkForChanges() {\n      var metadata = {}\n      for (const key in this.details) {\n        var newValue = this.details[key]\n        var oldValue = this.mediaMetadata[key]\n        // Key cleared out or key first populated\n        if ((!newValue && oldValue) || (newValue && !oldValue)) {\n          metadata[key] = newValue\n        } else if (key === 'narrators' || key === 'genres') {\n          // Check array of strings\n          if (!this.stringArrayEqual(newValue, oldValue)) {\n            metadata[key] = [...newValue]\n          }\n        } else if (key === 'authors' || key === 'series') {\n          if (!this.objectArrayEqual(newValue, oldValue)) {\n            metadata[key] = newValue.map((v) => ({ ...v }))\n          }\n        } else if (newValue && newValue != oldValue) {\n          // Intentional !=\n          metadata[key] = newValue\n        }\n      }\n      var updatePayload = {}\n      if (!!Object.keys(metadata).length) updatePayload.metadata = metadata\n\n      if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {\n        updatePayload.tags = [...this.newTags]\n      }\n      return {\n        updatePayload,\n        hasChanges: !!Object.keys(updatePayload).length\n      }\n    },\n    init() {\n      this.details.title = this.mediaMetadata.title\n      this.details.subtitle = this.mediaMetadata.subtitle\n      this.details.description = this.mediaMetadata.description\n      this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se }))\n      this.details.narrators = [...(this.mediaMetadata.narrators || [])]\n      this.details.genres = [...(this.mediaMetadata.genres || [])]\n      this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se }))\n      this.details.publishedYear = this.mediaMetadata.publishedYear\n      this.details.publisher = this.mediaMetadata.publisher || null\n      this.details.language = this.mediaMetadata.language || null\n      this.details.isbn = this.mediaMetadata.isbn || null\n      this.details.asin = this.mediaMetadata.asin || null\n      this.details.explicit = !!this.mediaMetadata.explicit\n      this.details.abridged = !!this.mediaMetadata.abridged\n      this.newTags = [...(this.media.tags || [])]\n    },\n    submitForm() {\n      this.$emit('submit')\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/CoverSizeWidget.vue",
    "content": "<template>\n  <div>\n    <div aria-hidden=\"true\" class=\"rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md\" @mousedown.prevent @mouseup.prevent>\n      <span class=\"material-symbols\" :class=\"selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'\" style=\"font-size: 0.9rem\" @mousedown.prevent @click=\"decreaseSize\" aria-label=\"Decrease Cover Size\" role=\"button\">&#xe15b;</span>\n      <p class=\"px-2 font-mono\" style=\"font-size: 1rem\">{{ bookCoverWidth }}</p>\n      <span class=\"material-symbols\" :class=\"selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'\" style=\"font-size: 0.9rem\" @mousedown.prevent @click=\"increaseSize\" aria-label=\"Increase Cover Size\" role=\"button\">&#xe145;</span>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      selectedSizeIndex: 3,\n      availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220]\n    }\n  },\n  watch: {\n    selectedSize: {\n      immediate: true,\n      handler() {\n        this.setSelectedIndex()\n      }\n    }\n  },\n  computed: {\n    selectedSize() {\n      return this.$store.getters['user/getUserSetting']('bookshelfCoverSize')\n    },\n    bookCoverWidth() {\n      return this.availableSizes[this.selectedSizeIndex]\n    }\n  },\n  methods: {\n    increaseSize() {\n      this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)\n      this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })\n    },\n    decreaseSize() {\n      this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)\n      this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })\n    },\n    setSelectedIndex() {\n      var sizeIndex = this.availableSizes.findIndex((s) => s === this.selectedSize)\n      if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/CronExpressionBuilder.vue",
    "content": "<template>\n  <div class=\"w-full py-2\">\n    <div class=\"flex -mb-px\">\n      <div class=\"w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer\" :class=\"!showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'\" @click=\"showAdvancedView = false\">\n        <p class=\"text-sm\">{{ $strings.HeaderSchedule }}</p>\n      </div>\n      <div class=\"w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer\" :class=\"showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'\" @click=\"showAdvancedView = true\">\n        <p class=\"text-sm\">{{ $strings.HeaderAdvanced }}</p>\n      </div>\n    </div>\n    <div class=\"px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px\" style=\"min-height: 280px\">\n      <template v-if=\"!showAdvancedView\">\n        <ui-dropdown v-model=\"selectedInterval\" @input=\"updateCron\" :label=\"$strings.LabelInterval\" :items=\"intervalOptions\" class=\"mb-2\" />\n\n        <ui-multi-select-dropdown v-if=\"selectedInterval === 'custom'\" v-model=\"selectedWeekdays\" @input=\"updateCron\" :label=\"$strings.LabelWeekdaysToRun\" :items=\"weekdays\" />\n\n        <div v-if=\"(selectedWeekdays.length && selectedInterval === 'custom') || selectedInterval === 'daily'\" class=\"flex items-center py-2\">\n          <ui-text-input-with-label v-model=\"selectedHour\" @input=\"updateCron\" @blur=\"hourBlur\" type=\"number\" :label=\"$strings.LabelHour\" class=\"max-w-20\" />\n          <p class=\"text-xl px-2 mt-4\">:</p>\n          <ui-text-input-with-label v-model=\"selectedMinute\" @input=\"updateCron\" @blur=\"minuteBlur\" type=\"number\" :label=\"$strings.LabelMinute\" class=\"max-w-20\" />\n        </div>\n\n        <div v-if=\"description\" class=\"w-full bg-primary/75 rounded-xl p-2 md:p-4 text-center mt-2\">\n          <p class=\"text-base md:text-lg text-gray-200\" v-html=\"description\" />\n        </div>\n      </template>\n      <template v-else>\n        <p class=\"px-1 text-sm font-semibold\">{{ $strings.LabelCronExpression }}</p>\n        <ui-text-input ref=\"customExpressionInput\" v-model=\"customCronExpression\" @blur=\"cronExpressionBlur\" :padding-y=\"2\" text-center class=\"w-full text-2xl md:text-4xl -tracking-widest mb-4 font-mono\" />\n\n        <div class=\"flex items-center justify-center\">\n          <widgets-loading-spinner v-if=\"isValidating\" class=\"mr-2\" />\n          <span v-else class=\"material-symbols mr-2 text-xl\" :class=\"isValid ? 'text-success' : 'text-error'\">{{ isValid ? 'check_circle_outline' : 'error_outline' }}</span>\n          <p v-if=\"isValidating\" class=\"text-gray-300 text-base md:text-lg text-center\">{{ $strings.MessageCheckingCron }}</p>\n          <p v-else-if=\"customCronError\" class=\"text-error text-base md:text-lg text-center\">{{ customCronError }}</p>\n          <p v-else class=\"text-success text-base md:text-lg text-center\">{{ $strings.MessageValidCronExpression }}</p>\n        </div>\n      </template>\n      <div v-if=\"cronExpression && isValid\" class=\"flex items-center justify-center text-yellow-400 mt-2\">\n        <span class=\"material-symbols mr-2 text-xl\">event</span>\n        <p>{{ $strings.LabelNextScheduledRun }}: {{ nextRun }}</p>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: {\n      type: String,\n      default: null\n    }\n  },\n  data() {\n    return {\n      selectedInterval: 'custom',\n      showAdvancedView: false,\n      selectedHour: 0,\n      selectedMinute: 0,\n      selectedWeekdays: [],\n      cronExpression: '0 0 * * *',\n      customCronExpression: '0 0 * * *',\n      customCronError: '',\n      isValidating: false,\n      validatedCron: null,\n      isValid: true\n    }\n  },\n  watch: {\n    value: {\n      immediate: true,\n      handler(newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    minuteIsValid() {\n      return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)\n    },\n    hourIsValid() {\n      return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)\n    },\n    nextRun() {\n      if (!this.cronExpression) return ''\n      const parsed = this.$getNextScheduledDate(this.cronExpression)\n      return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || ''\n    },\n    description() {\n      if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''\n\n      if (!this.hourIsValid) {\n        return `<span class=\"text-error\">Invalid hour must be 0-23 | ${this.selectedHour < 0 || this.selectedHour > 23}</span>`\n      }\n      if (!this.minuteIsValid) {\n        return `<span class=\"text-error\">Invalid minute must be 0-59</span>`\n      }\n\n      var description = 'Run every '\n      var weekdayTexts = ''\n      if (this.selectedWeekdays.length === 7 || this.selectedInterval === 'daily') {\n        weekdayTexts = 'day'\n      } else {\n        weekdayTexts = this.selectedWeekdays\n          .map((weekday) => {\n            return this.weekdays.find((w) => w.value === weekday).text\n          })\n          .join(', ')\n      }\n      description += `<span class=\"font-bold text-white\">${weekdayTexts}</span>`\n\n      const hourString = this.selectedHour.toString()\n      const minuteString = this.selectedMinute.toString().padStart(2, '0')\n      description += ` at <span class=\"font-bold text-white\">${hourString}:${minuteString}</span>`\n      return description\n    },\n    intervalOptions() {\n      return [\n        {\n          text: this.$strings.LabelIntervalCustomDailyWeekly,\n          value: 'custom'\n        },\n        {\n          text: this.$strings.LabelIntervalEveryDay,\n          value: 'daily'\n        },\n        {\n          text: this.$strings.LabelIntervalEvery12Hours,\n          value: '0 */12 * * *'\n        },\n        {\n          text: this.$strings.LabelIntervalEvery6Hours,\n          value: '0 */6 * * *'\n        },\n        {\n          text: this.$strings.LabelIntervalEvery2Hours,\n          value: '0 */2 * * *'\n        },\n        {\n          text: this.$strings.LabelIntervalEveryHour,\n          value: '0 * * * *'\n        },\n        {\n          text: this.$strings.LabelIntervalEvery30Minutes,\n          value: '*/30 * * * *'\n        },\n        {\n          text: this.$strings.LabelIntervalEvery15Minutes,\n          value: '*/15 * * * *'\n        }\n      ]\n    },\n    weekdays() {\n      return [\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 1), 'EEEE'),\n          value: 0\n        },\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 2), 'EEEE'),\n          value: 1\n        },\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 3), 'EEEE'),\n          value: 2\n        },\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 4), 'EEEE'),\n          value: 3\n        },\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 5), 'EEEE'),\n          value: 4\n        },\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 6), 'EEEE'),\n          value: 5\n        },\n        {\n          text: this.$formatJsDate(new Date(2023, 0, 7), 'EEEE'),\n          value: 6\n        }\n      ]\n    }\n  },\n  methods: {\n    checkBlurExpressionInput() {\n      if (!this.showAdvancedView || !this.$refs.customExpressionInput) return false\n      if (this.$refs.customExpressionInput.isFocused) {\n        this.$refs.customExpressionInput.blur()\n        return true\n      }\n      return false\n    },\n    updateCron() {\n      if (this.selectedInterval === 'custom') {\n        if (!this.minuteIsValid || !this.hourIsValid || !this.selectedWeekdays.length) {\n          this.cronExpression = null\n          return\n        }\n        this.selectedWeekdays.sort()\n\n        const daysOfWeekPiece = this.selectedWeekdays.length === 7 ? '*' : this.selectedWeekdays.join(',')\n        this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * ${daysOfWeekPiece}`\n      } else if (this.selectedInterval === 'daily') {\n        if (!this.minuteIsValid || !this.hourIsValid) {\n          this.cronExpression = null\n          return\n        }\n        this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * *`\n      } else {\n        this.cronExpression = this.selectedInterval\n      }\n\n      this.customCronExpression = this.cronExpression\n      this.validatedCron = this.cronExpression\n      this.isValid = true\n      this.customCronError = ''\n      this.$emit('input', this.cronExpression)\n    },\n    minuteBlur() {\n      const v = this.selectedMinute\n      if (v === '' || v === null || isNaN(v) || v < 0) {\n        this.selectedMinute = 0\n      } else if (v > 59) {\n        this.selectedMinute = 59\n      } else {\n        this.selectedMinute = Number(v)\n      }\n      this.updateCron()\n    },\n    hourBlur() {\n      const v = this.selectedHour\n      if (v === '' || v === null || isNaN(v) || v < 0) {\n        this.selectedHour = 0\n      } else if (v > 23) {\n        this.selectedHour = 23\n      } else {\n        this.selectedHour = Number(v)\n      }\n      this.updateCron()\n    },\n    async cronExpressionBlur() {\n      this.customCronError = ''\n      if (!this.customCronExpression || this.customCronExpression.split(' ').length !== 5) {\n        this.customCronError = 'Invalid cron expression'\n        this.isValid = false\n        return\n      }\n\n      if (this.customCronExpression !== this.cronExpression) {\n        this.selectedWeekdays = []\n        this.selectedHour = 0\n        this.selectedMinute = 0\n        this.cronExpression = this.customCronExpression\n      }\n\n      if (!this.validatedCron || this.validatedCron !== this.cronExpression) {\n        const validationPayload = await this.validateCron()\n        this.isValid = validationPayload.isValid\n        this.validatedCron = this.cronExpression\n        this.customCronError = validationPayload.error || ''\n      }\n\n      if (this.isValid) {\n        this.$emit('input', this.cronExpression)\n      }\n    },\n    validateCron() {\n      this.isValidating = true\n      return this.$axios\n        .$post('/api/validate-cron', { expression: this.customCronExpression })\n        .then(() => {\n          this.isValidating = false\n          return {\n            isValid: true\n          }\n        })\n        .catch((error) => {\n          console.error('Invalid cron', error)\n          var errMsg = error.response ? error.response.data || '' : ''\n          this.isValidating = false\n          return {\n            isValid: false,\n            error: errMsg || 'Invalid cron expression'\n          }\n        })\n    },\n    init() {\n      this.selectedInterval = 'custom'\n      this.selectedHour = 0\n      this.selectedMinute = 0\n      this.selectedWeekdays = []\n\n      if (!this.value) return\n      const pieces = this.value.split(' ')\n      if (pieces.length !== 5) {\n        console.error('Invalid cron expression input', this.value)\n        return\n      }\n\n      const intervalMatch = this.intervalOptions.find((opt) => opt.value === this.value)\n      if (intervalMatch) {\n        this.selectedInterval = this.value\n      } else {\n        var isCustomCron = false\n        if (isNaN(pieces[0]) || isNaN(pieces[1])) {\n          isCustomCron = true\n        } else if (pieces[2] !== '*' || pieces[3] !== '*') {\n          isCustomCron = true\n        } else if (pieces[4] !== '*' && pieces[4].split(',').some((num) => isNaN(num))) {\n          isCustomCron = true\n        }\n\n        if (isCustomCron) {\n          this.showAdvancedView = true\n        } else {\n          if (pieces[4] === '*') this.selectedInterval = 'daily'\n\n          this.selectedWeekdays = pieces[4] === '*' ? [0, 1, 2, 3, 4, 5, 6] : pieces[4].split(',').map((num) => Number(num))\n          this.selectedHour = pieces[1]\n          this.selectedMinute = pieces[0]\n        }\n      }\n      this.cronExpression = this.value\n      this.customCronExpression = this.value\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/EncoderOptionsCard.vue",
    "content": "<template>\n  <div class=\"w-full py-2\">\n    <div class=\"flex -mb-px\">\n      <button type=\"button\" :disabled=\"disabled\" class=\"w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center disabled:cursor-not-allowed\" :class=\"!showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'\" @click=\"showAdvancedView = false\">\n        <p class=\"text-sm\">{{ $strings.HeaderPresets }}</p>\n      </button>\n      <button type=\"button\" :disabled=\"disabled\" class=\"w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px disabled:cursor-not-allowed\" :class=\"showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'\" @click=\"showAdvancedView = true\">\n        <p class=\"text-sm\">{{ $strings.HeaderAdvanced }}</p>\n      </button>\n    </div>\n    <div class=\"p-4 md:p-8 border border-black-200 rounded-b-md mr-px bg-bg\">\n      <template v-if=\"!showAdvancedView\">\n        <div class=\"flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center\">\n          <div class=\"flex flex-col items-start gap-2\">\n            <p class=\"text-sm w-40\">{{ $strings.LabelCodec }}</p>\n            <ui-toggle-btns v-model=\"selectedCodec\" :items=\"codecItems\" :disabled=\"disabled\" />\n            <p class=\"text-xs text-gray-300\">\n              {{ $strings.LabelCurrently }} <span class=\"text-white\">{{ currentCodec }}</span> <span v-if=\"isCodecsDifferent\" class=\"text-warning\">(mixed)</span>\n            </p>\n          </div>\n          <div class=\"flex flex-col items-start gap-2\">\n            <p class=\"text-sm w-40\">{{ $strings.LabelBitrate }}</p>\n            <ui-toggle-btns v-model=\"selectedBitrate\" :items=\"bitrateItems\" :disabled=\"disabled\" />\n            <p class=\"text-xs text-gray-300\">\n              {{ $strings.LabelCurrently }} <span class=\"text-white\">{{ currentBitrate }} KB/s</span>\n            </p>\n          </div>\n          <div class=\"flex flex-col items-start gap-2\">\n            <p class=\"text-sm w-40\">{{ $strings.LabelChannels }}</p>\n            <ui-toggle-btns v-model=\"selectedChannels\" :items=\"channelsItems\" :disabled=\"disabled\" />\n            <p class=\"text-xs text-gray-300\">\n              {{ $strings.LabelCurrently }} <span class=\"text-white\">{{ currentChannels }} ({{ currentChanelLayout }})</span>\n            </p>\n          </div>\n        </div>\n      </template>\n      <template v-else>\n        <div>\n          <div class=\"flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center mb-4\">\n            <div class=\"w-40\">\n              <ui-text-input-with-label v-model=\"customCodec\" :label=\"$strings.LabelAudioCodec\" :disabled=\"disabled\" @input=\"customCodecChanged\" />\n            </div>\n            <div class=\"w-40\">\n              <ui-text-input-with-label v-model=\"customBitrate\" :label=\"$strings.LabelAudioBitrate\" :disabled=\"disabled\" @input=\"customBitrateChanged\" />\n            </div>\n            <div class=\"w-40\">\n              <ui-text-input-with-label v-model=\"customChannels\" :label=\"$strings.LabelAudioChannels\" type=\"number\" :disabled=\"disabled\" @input=\"customChannelsChanged\" />\n            </div>\n          </div>\n          <p class=\"text-xs sm:text-sm text-warning sm:text-center\">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>\n        </div>\n      </template>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    audioTracks: {\n      type: Array,\n      default: () => []\n    },\n    disabled: {\n      type: Boolean,\n      default: false\n    }\n  },\n  data() {\n    return {\n      showAdvancedView: false,\n      selectedCodec: 'aac',\n      selectedBitrate: '128k',\n      selectedChannels: 2,\n      customCodec: 'aac',\n      customBitrate: '128k',\n      customChannels: 2,\n      currentCodec: '',\n      currentBitrate: '',\n      currentChannels: '',\n      currentChanelLayout: '',\n      isCodecsDifferent: false\n    }\n  },\n  computed: {\n    codecItems() {\n      return [\n        {\n          text: 'Copy',\n          value: 'copy'\n        },\n        {\n          text: 'AAC',\n          value: 'aac'\n        },\n        {\n          text: 'OPUS',\n          value: 'opus'\n        }\n      ]\n    },\n    bitrateItems() {\n      return [\n        {\n          text: '32k',\n          value: '32k'\n        },\n        {\n          text: '64k',\n          value: '64k'\n        },\n        {\n          text: '128k',\n          value: '128k'\n        },\n        {\n          text: '192k',\n          value: '192k'\n        }\n      ]\n    },\n    channelsItems() {\n      return [\n        {\n          text: '1 (mono)',\n          value: 1\n        },\n        {\n          text: '2 (stereo)',\n          value: 2\n        }\n      ]\n    }\n  },\n  methods: {\n    customBitrateChanged(val) {\n      localStorage.setItem('embedMetadataBitrate', val)\n    },\n    customChannelsChanged(val) {\n      localStorage.setItem('embedMetadataChannels', val)\n    },\n    customCodecChanged(val) {\n      localStorage.setItem('embedMetadataCodec', val)\n    },\n    getEncodingOptions() {\n      if (this.showAdvancedView) {\n        return {\n          codec: this.customCodec || this.selectedCodec || 'aac',\n          bitrate: this.customBitrate || this.selectedBitrate || '128k',\n          channels: this.customChannels || this.selectedChannels || 2\n        }\n      } else {\n        return {\n          codec: this.selectedCodec || 'aac',\n          bitrate: this.selectedBitrate || '128k',\n          channels: this.selectedChannels || 2\n        }\n      }\n    },\n    setPreset() {\n      // If already AAC and not mixed, set copy\n      if (this.currentCodec === 'aac' && !this.isCodecsDifferent) {\n        this.selectedCodec = 'copy'\n      } else {\n        this.selectedCodec = 'aac'\n      }\n\n      if (!this.currentBitrate) {\n        this.selectedBitrate = '128k'\n      } else {\n        // Find closest bitrate rounding up\n        const bitratesToMatch = [32, 64, 128, 192]\n        const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) || 192\n        this.selectedBitrate = closestBitrate + 'k'\n      }\n\n      if (!this.currentChannels || isNaN(this.currentChannels)) {\n        this.selectedChannels = 2\n      } else {\n        // Either 1 or 2\n        this.selectedChannels = Math.max(Math.min(Number(this.currentChannels), 2), 1)\n      }\n    },\n    setCurrentValues() {\n      if (this.audioTracks.length === 0) return\n\n      this.currentChannels = this.audioTracks[0].channels\n      this.currentChanelLayout = this.audioTracks[0].channelLayout\n      this.currentCodec = this.audioTracks[0].codec\n\n      let totalBitrate = 0\n      for (const track of this.audioTracks) {\n        const trackBitrate = !isNaN(track.bitRate) ? track.bitRate : 0\n        totalBitrate += trackBitrate\n\n        if (track.channels > this.currentChannels) this.currentChannels = track.channels\n        if (track.codec !== this.currentCodec) {\n          console.warn('Audio track codec is different from the first track', track.codec)\n          this.isCodecsDifferent = true\n        }\n      }\n\n      this.currentBitrate = Math.round(totalBitrate / this.audioTracks.length / 1000)\n    },\n    init() {\n      this.customBitrate = localStorage.getItem('embedMetadataBitrate') || '128k'\n      this.customChannels = localStorage.getItem('embedMetadataChannels') || 2\n      this.customCodec = localStorage.getItem('embedMetadataCodec') || 'aac'\n\n      this.setCurrentValues()\n\n      this.setPreset()\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/ExplicitIndicator.vue",
    "content": "<template>\n  <ui-tooltip :text=\"$strings.LabelExplicit\" direction=\"top\">\n    <span class=\"material-symbols fill text-sm ml-1 !block\">explicit</span>\n  </ui-tooltip>\n</template>\n\n<script>\nexport default {\n  props: {},\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/ItemSlider.vue",
    "content": "<template>\n  <div class=\"w-full\">\n    <div class=\"flex items-center py-3e\">\n      <slot />\n      <div class=\"grow\" />\n      <button cy-id=\"leftScrollButton\" v-if=\"isScrollable\" :aria-label=\"$strings.ButtonScrollLeft\" class=\"w-8e h-8e mx-1e flex items-center justify-center rounded-full\" :class=\"canScrollLeft ? 'hover:bg-white/5 text-gray-300 hover:text-white' : 'text-white/40 cursor-text'\" @click=\"scrollLeft\">\n        <span class=\"material-symbols\" :style=\"{ fontSize: 1.5 + 'em' }\">chevron_left</span>\n      </button>\n      <button cy-id=\"rightScrollButton\" v-if=\"isScrollable\" :aria-label=\"$strings.ButtonScrollRight\" class=\"w-8e h-8e mx-1e flex items-center justify-center rounded-full\" :class=\"canScrollRight ? 'hover:bg-white/5 text-gray-300 hover:text-white' : 'text-white/40 cursor-text'\" @click=\"scrollRight\">\n        <span class=\"material-symbols\" :style=\"{ fontSize: 1.5 + 'em' }\">chevron_right</span>\n      </button>\n    </div>\n    <div cy-id=\"slider\" ref=\"slider\" class=\"w-full overflow-y-hidden overflow-x-auto no-scroll\" style=\"scroll-behavior: smooth\" @scroll=\"scrolled\">\n      <div class=\"flex space-x-4e\">\n        <template v-for=\"(item, index) in items\">\n          <div cy-id=\"item\" ref=\"item\" :key=\"itemKeyFunc(item)\">\n            <component :is=\"componentName\" :ref=\"itemRefFunc(item)\" :index=\"index\" :[itemPropName]=\"item\" :bookshelf-view=\"bookshelfView\" :continue-listening-shelf=\"continueListeningShelf\" class=\"relative\" @edit=\"editFunc\" @editPodcast=\"editItem\" @select=\"selectItem\" @hook:updated=\"setScrollVars\" />\n          </div>\n        </template>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    items: {\n      type: Array,\n      default: () => []\n    },\n    bookshelfView: {\n      type: Number,\n      default: 1\n    },\n    shelfId: {\n      type: String,\n      default: ''\n    },\n    continueListeningShelf: {\n      type: Boolean,\n      default: false\n    },\n    type: {\n      type: String,\n      default: 'book'\n    }\n  },\n  data() {\n    return {\n      isScrollable: false,\n      canScrollLeft: false,\n      canScrollRight: false,\n      clientWidth: 0,\n      shelfOptionsByType: {\n        episode: {\n          component: 'cards-lazy-book-card',\n          itemPropName: 'book-mount',\n          itemIdFunc: (item) => item.recentEpisode.id\n        },\n        series: {\n          component: 'cards-lazy-series-card',\n          itemPropName: 'series-mount',\n          itemIdFunc: (item) => item.id\n        },\n        authors: {\n          component: 'cards-author-card',\n          itemPropName: 'author-mount',\n          itemIdFunc: (item) => item.id\n        },\n        narrators: {\n          component: 'cards-narrator-card',\n          itemPropName: 'narrator',\n          itemIdFunc: (item) => item.name\n        },\n        book: {\n          component: 'cards-lazy-book-card',\n          itemPropName: 'book-mount',\n          itemIdFunc: (item) => item.id\n        },\n        podcast: {\n          component: 'cards-lazy-book-card',\n          itemPropName: 'book-mount',\n          itemIdFunc: (item) => item.id\n        }\n      }\n    }\n  },\n  computed: {\n    isSelectionMode() {\n      return this.$store.getters['globals/getIsBatchSelectingMediaItems']\n    },\n    options() {\n      return this.shelfOptionsByType[this.type]\n    },\n    itemIdFunc() {\n      return this.options.itemIdFunc\n    },\n    itemKeyFunc() {\n      return (item) => this.itemIdFunc(item) + this.shelfId\n    },\n    itemRefFunc() {\n      return (item) => `slider-item-${this.itemIdFunc(item)}`\n    },\n    componentName() {\n      return this.options.component\n    },\n    itemPropName() {\n      return this.options.itemPropName\n    },\n    editFunc() {\n      switch (this.type) {\n        case 'episode':\n          return this.editEpisode\n        case 'authors':\n          return this.editAuthor\n        default:\n          return this.editItem\n      }\n    }\n  },\n  methods: {\n    clearSelectedEntities() {\n      this.updateSelectionMode(false)\n    },\n    editEpisode({ libraryItem, episode }) {\n      this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])\n      this.$store.commit('setSelectedLibraryItem', libraryItem)\n      this.$store.commit('globals/setSelectedEpisode', episode)\n      this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)\n    },\n    editAuthor(author) {\n      this.$store.commit('globals/showEditAuthorModal', author)\n    },\n    editItem(libraryItem, tab = 'details') {\n      var itemIds = this.items.map((e) => e.id)\n      this.$store.commit('setBookshelfBookIds', itemIds)\n      this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' })\n    },\n    selectItem(payload) {\n      this.$emit('selectEntity', payload)\n    },\n    itemSelectedEvt() {\n      this.updateSelectionMode(this.isSelectionMode)\n    },\n    updateSelectionMode(val) {\n      const selectedMediaItems = this.$store.state.globals.selectedMediaItems\n      this.items.forEach((item) => {\n        let component = this.$refs[this.itemRefFunc(item)]\n        if (!component || !component.length) return\n        component = component[0]\n        component.setSelectionMode(val)\n        component.selected = selectedMediaItems.some((i) => i.id === item.id)\n      })\n    },\n    scrolled() {\n      this.setScrollVars()\n    },\n    scrollRight() {\n      if (!this.canScrollRight) return\n      const slider = this.$refs.slider\n      if (!slider) return\n      const scrollAmount = this.clientWidth\n      const maxScrollLeft = slider.scrollWidth - slider.clientWidth\n\n      const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)\n      slider.scrollLeft = newScrollLeft\n    },\n    scrollLeft() {\n      if (!this.canScrollLeft) return\n      const slider = this.$refs.slider\n      if (!slider) return\n\n      const scrollAmount = this.clientWidth\n\n      const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)\n      slider.scrollLeft = newScrollLeft\n    },\n    setScrollVars() {\n      const slider = this.$refs.slider\n      if (!slider) return\n      const { scrollLeft, scrollWidth, clientWidth } = slider\n      const scrollRemaining = Math.abs(scrollLeft + clientWidth - scrollWidth)\n\n      this.clientWidth = clientWidth\n      this.isScrollable = scrollWidth > clientWidth\n      this.canScrollRight = scrollRemaining >= 1\n      this.canScrollLeft = scrollLeft > 0\n    }\n  },\n  updated() {\n    this.setScrollVars()\n  },\n  mounted() {\n    this.setScrollVars()\n    if (['book', 'podcast', 'episode'].includes(this.type)) {\n      this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)\n      this.$eventBus.$on('item-selected', this.itemSelectedEvt)\n    }\n  },\n  beforeDestroy() {\n    if (['book', 'podcast', 'episode'].includes(this.type)) {\n      this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)\n      this.$eventBus.$off('item-selected', this.itemSelectedEvt)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/LoadingSpinner.vue",
    "content": "<template>\n  <div class=\"la-ball-spin-clockwise\" :class=\"`${size}`\">\n    <div></div>\n    <div></div>\n    <div></div>\n    <div></div>\n    <div></div>\n    <div></div>\n    <div></div>\n    <div></div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    size: {\n      type: String,\n      default: 'la-sm'\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n\n<style>\n/*!\n * Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)\n * Copyright 2015 Daniel Cardoso <@DanielCardoso>\n * Licensed under MIT\n */\n.la-ball-spin-clockwise,\n.la-ball-spin-clockwise > div {\n  position: relative;\n  -webkit-box-sizing: border-box;\n  -moz-box-sizing: border-box;\n  box-sizing: border-box;\n}\n.la-ball-spin-clockwise {\n  display: block;\n  font-size: 0;\n  color: #fff;\n}\n.la-ball-spin-clockwise.la-dark {\n  color: #262626;\n}\n.la-ball-spin-clockwise > div {\n  display: inline-block;\n  float: none;\n  background-color: currentColor;\n  border: 0 solid currentColor;\n}\n.la-ball-spin-clockwise {\n  width: 32px;\n  height: 32px;\n}\n.la-ball-spin-clockwise > div {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 8px;\n  height: 8px;\n  margin-top: -4px;\n  margin-left: -4px;\n  border-radius: 100%;\n  -webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;\n  -moz-animation: ball-spin-clockwise 1s infinite ease-in-out;\n  -o-animation: ball-spin-clockwise 1s infinite ease-in-out;\n  animation: ball-spin-clockwise 1s infinite ease-in-out;\n}\n.la-ball-spin-clockwise > div:nth-child(1) {\n  top: 5%;\n  left: 50%;\n  -webkit-animation-delay: -0.875s;\n  -moz-animation-delay: -0.875s;\n  -o-animation-delay: -0.875s;\n  animation-delay: -0.875s;\n}\n.la-ball-spin-clockwise > div:nth-child(2) {\n  top: 18.1801948466%;\n  left: 81.8198051534%;\n  -webkit-animation-delay: -0.75s;\n  -moz-animation-delay: -0.75s;\n  -o-animation-delay: -0.75s;\n  animation-delay: -0.75s;\n}\n.la-ball-spin-clockwise > div:nth-child(3) {\n  top: 50%;\n  left: 95%;\n  -webkit-animation-delay: -0.625s;\n  -moz-animation-delay: -0.625s;\n  -o-animation-delay: -0.625s;\n  animation-delay: -0.625s;\n}\n.la-ball-spin-clockwise > div:nth-child(4) {\n  top: 81.8198051534%;\n  left: 81.8198051534%;\n  -webkit-animation-delay: -0.5s;\n  -moz-animation-delay: -0.5s;\n  -o-animation-delay: -0.5s;\n  animation-delay: -0.5s;\n}\n.la-ball-spin-clockwise > div:nth-child(5) {\n  top: 94.9999999966%;\n  left: 50.0000000005%;\n  -webkit-animation-delay: -0.375s;\n  -moz-animation-delay: -0.375s;\n  -o-animation-delay: -0.375s;\n  animation-delay: -0.375s;\n}\n.la-ball-spin-clockwise > div:nth-child(6) {\n  top: 81.8198046966%;\n  left: 18.1801949248%;\n  -webkit-animation-delay: -0.25s;\n  -moz-animation-delay: -0.25s;\n  -o-animation-delay: -0.25s;\n  animation-delay: -0.25s;\n}\n.la-ball-spin-clockwise > div:nth-child(7) {\n  top: 49.9999750815%;\n  left: 5.0000051215%;\n  -webkit-animation-delay: -0.125s;\n  -moz-animation-delay: -0.125s;\n  -o-animation-delay: -0.125s;\n  animation-delay: -0.125s;\n}\n.la-ball-spin-clockwise > div:nth-child(8) {\n  top: 18.179464974%;\n  left: 18.1803700518%;\n  -webkit-animation-delay: 0s;\n  -moz-animation-delay: 0s;\n  -o-animation-delay: 0s;\n  animation-delay: 0s;\n}\n.la-ball-spin-clockwise.la-sm {\n  width: 16px;\n  height: 16px;\n}\n.la-ball-spin-clockwise.la-sm > div {\n  width: 4px;\n  height: 4px;\n  margin-top: -2px;\n  margin-left: -2px;\n}\n.la-ball-spin-clockwise.la-lg {\n  width: 32px;\n  height: 32px;\n}\n.la-ball-spin-clockwise.la-lg > div {\n  width: 8px;\n  height: 8px;\n  margin-top: -4px;\n  margin-left: -4px;\n}\n.la-ball-spin-clockwise.la-2x {\n  width: 64px;\n  height: 64px;\n}\n.la-ball-spin-clockwise.la-2x > div {\n  width: 16px;\n  height: 16px;\n  margin-top: -8px;\n  margin-left: -8px;\n}\n.la-ball-spin-clockwise.la-3x {\n  width: 96px;\n  height: 96px;\n}\n.la-ball-spin-clockwise.la-3x > div {\n  width: 24px;\n  height: 24px;\n  margin-top: -12px;\n  margin-left: -12px;\n}\n/*\n * Animation\n */\n@-webkit-keyframes ball-spin-clockwise {\n  0%,\n  100% {\n    opacity: 1;\n    -webkit-transform: scale(1);\n    transform: scale(1);\n  }\n  20% {\n    opacity: 1;\n  }\n  80% {\n    opacity: 0;\n    -webkit-transform: scale(0);\n    transform: scale(0);\n  }\n}\n@-moz-keyframes ball-spin-clockwise {\n  0%,\n  100% {\n    opacity: 1;\n    -moz-transform: scale(1);\n    transform: scale(1);\n  }\n  20% {\n    opacity: 1;\n  }\n  80% {\n    opacity: 0;\n    -moz-transform: scale(0);\n    transform: scale(0);\n  }\n}\n@-o-keyframes ball-spin-clockwise {\n  0%,\n  100% {\n    opacity: 1;\n    -o-transform: scale(1);\n    transform: scale(1);\n  }\n  20% {\n    opacity: 1;\n  }\n  80% {\n    opacity: 0;\n    -o-transform: scale(0);\n    transform: scale(0);\n  }\n}\n@keyframes ball-spin-clockwise {\n  0%,\n  100% {\n    opacity: 1;\n    -webkit-transform: scale(1);\n    -moz-transform: scale(1);\n    -o-transform: scale(1);\n    transform: scale(1);\n  }\n  20% {\n    opacity: 1;\n  }\n  80% {\n    opacity: 0;\n    -webkit-transform: scale(0);\n    -moz-transform: scale(0);\n    -o-transform: scale(0);\n    transform: scale(0);\n  }\n}\n</style>\n"
  },
  {
    "path": "client/components/widgets/MoreMenu.vue",
    "content": "<template>\n  <div ref=\"wrapper\" class=\"absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50\" v-click-outside=\"clickOutsideObj\" :style=\"{ width: menuWidth + 'px' }\">\n    <template v-for=\"(item, index) in items\">\n      <template v-if=\"item.subitems\">\n        <div :key=\"index\" class=\"flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default\" :class=\"{ 'bg-white/5': mouseoverItemIndex == index }\" @mouseover=\"mouseoverItem(index)\" @mouseleave=\"mouseleaveItem(index)\" @click.stop>\n          <p>{{ item.text }}</p>\n        </div>\n        <div v-if=\"mouseoverItemIndex === index\" :key=\"`subitems-${index}`\" @mouseover=\"mouseoverSubItemMenu(index)\" @mouseleave=\"mouseleaveSubItemMenu(index)\" class=\"absolute bg-bg rounded-b-md border border-black-200 py-1 shadow-lg z-50\" :class=\"openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'\" :style=\"{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }\">\n          <div v-for=\"(subitem, subitemindex) in item.subitems\" :key=\"`subitem-${subitemindex}`\" class=\"flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer\" @click.stop=\"clickAction(subitem.func, subitem.data)\">\n            <p>{{ subitem.text }}</p>\n          </div>\n        </div>\n      </template>\n      <div v-else :key=\"index\" class=\"flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer\" @mouseover=\"mouseoverItem(index)\" @mouseleave=\"mouseleaveItem(index)\" @click.stop=\"clickAction(item.func)\">\n        <p>{{ item.text }}</p>\n      </div>\n    </template>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    items: {\n      type: Array,\n      default: () => []\n    }\n  },\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      submenuWidth: 144,\n      menuWidth: 144,\n      mouseoverItemIndex: null,\n      isOverSubItemMenu: false,\n      openSubMenuLeft: false\n    }\n  },\n  computed: {\n    submenuLeftPos() {\n      return this.openSubMenuLeft ? -this.submenuWidth : this.menuWidth - 1.5\n    }\n  },\n  methods: {\n    mouseoverSubItemMenu(index) {\n      this.isOverSubItemMenu = true\n    },\n    mouseleaveSubItemMenu(index) {\n      setTimeout(() => {\n        if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null\n      }, 1)\n    },\n    mouseoverItem(index) {\n      this.isOverSubItemMenu = false\n      this.mouseoverItemIndex = index\n    },\n    mouseleaveItem(index) {\n      setTimeout(() => {\n        if (this.isOverSubItemMenu) return\n        if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null\n      }, 1)\n    },\n    clickAction(func, data) {\n      this.$emit('action', {\n        func,\n        data\n      })\n      this.close()\n    },\n    clickedOutside(e) {\n      this.close()\n    },\n    close() {\n      this.$emit('close')\n\n      // destroy the vue listeners, etc\n      this.$destroy()\n\n      // remove the element from the DOM\n      this.$el.parentNode.removeChild(this.$el)\n    }\n  },\n  mounted() {\n    this.$nextTick(() => {\n      const boundingRect = this.$refs.wrapper?.getBoundingClientRect()\n      if (boundingRect) {\n        this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5\n      }\n    })\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/NotificationWidget.vue",
    "content": "<template>\n  <div v-if=\"tasksToShow.length\" class=\"w-4 h-4 mx-3 relative\" v-click-outside=\"clickOutsideObj\">\n    <button type=\"button\" :disabled=\"disabled\" class=\"w-10 sm:w-full relative h-full cursor-pointer\" aria-haspopup=\"listbox\" :aria-expanded=\"showMenu\" @click.stop.prevent=\"clickShowMenu\">\n      <div class=\"flex h-full items-center justify-center\">\n        <ui-tooltip v-if=\"tasksRunning\" :text=\"$strings.LabelTasks\" direction=\"bottom\" class=\"flex items-center\">\n          <widgets-loading-spinner />\n        </ui-tooltip>\n        <ui-tooltip v-else :text=\"$strings.LabelActivities\" direction=\"bottom\" class=\"flex items-center\">\n          <span class=\"material-symbols text-1.5xl\" :aria-label=\"$strings.LabelActivities\" role=\"button\">notifications</span>\n        </ui-tooltip>\n      </div>\n      <div v-if=\"showUnseenSuccessIndicator\" class=\"w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5\" />\n      <div v-if=\"showUnseenSuccessIndicator\" class=\"w-2 h-2 rounded-full bg-success/50 pointer-events-none absolute animate-ping -top-1 -right-0.5\" />\n    </button>\n    <transition name=\"menu\">\n      <div class=\"sm:w-80 w-full relative\">\n        <div v-show=\"showMenu\" class=\"absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm globalTaskRunningMenu\">\n          <ul class=\"h-full w-full\" role=\"listbox\" aria-labelledby=\"listbox-label\">\n            <template v-if=\"tasksToShow.length\">\n              <template v-for=\"task in tasksToShow\">\n                <nuxt-link :key=\"task.id\" v-if=\"actionLink(task)\" :to=\"actionLink(task)\">\n                  <li class=\"text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer\">\n                    <cards-item-task-running-card :task=\"task\" />\n                  </li>\n                </nuxt-link>\n                <li v-else :key=\"task.id\" class=\"text-gray-50 select-none relative hover:bg-black-400 py-1\">\n                  <cards-item-task-running-card :task=\"task\" />\n                </li>\n              </template>\n            </template>\n            <li v-else class=\"py-2 px-2\">\n              <p>{{ $strings.MessageNoTasksRunning }}</p>\n            </li>\n          </ul>\n        </div>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      clickOutsideObj: {\n        handler: this.clickedOutside,\n        events: ['mousedown'],\n        isActive: true\n      },\n      showMenu: false,\n      disabled: false,\n      tasksSeen: []\n    }\n  },\n  computed: {\n    tasks() {\n      return this.$store.state.tasks.tasks\n    },\n    tasksRunning() {\n      return this.tasks.some((t) => !t.isFinished)\n    },\n    tasksToShow() {\n      // return just the tasks that are running or failed (or show success) in the last 1 minute\n      const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []\n      return tasks.sort((a, b) => b.startedAt - a.startedAt)\n    },\n    showUnseenSuccessIndicator() {\n      return this.tasksToShow.some((t) => t.isFinished && !t.isFailed && !this.tasksSeen.includes(t.id))\n    }\n  },\n  methods: {\n    clickShowMenu() {\n      if (this.disabled) return\n      this.showMenu = !this.showMenu\n      if (this.showMenu) {\n        this.tasksToShow.forEach((t) => {\n          if (!this.tasksSeen.includes(t.id)) this.tasksSeen.push(t.id)\n        })\n      }\n    },\n    clickedOutside() {\n      this.showMenu = false\n    },\n    actionLink(task) {\n      switch (task.action) {\n        case 'download-podcast-episode':\n          return `/library/${task.data.libraryId}/podcast/download-queue`\n        case 'encode-m4b':\n          return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`\n        case 'embed-metadata':\n          return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`\n        case 'scan-item':\n          return `/item/${task.data.libraryItemId}`\n        default:\n          return ''\n      }\n    },\n    taskFinished(task) {\n      // add task as seen if menu is open when it finished\n      if (this.showMenu && !this.tasksSeen.includes(task.id)) {\n        this.tasksSeen.push(task.id)\n      }\n    }\n  },\n  mounted() {\n    this.$root.socket?.on('task_finished', this.taskFinished)\n  },\n  beforeDestroy() {\n    this.$root.socket?.off('task_finished', this.taskFinished)\n  }\n}\n</script>\n\n<style>\n.globalTaskRunningMenu {\n  max-height: 80vh;\n}\n</style>\n"
  },
  {
    "path": "client/components/widgets/OnlineIndicator.vue",
    "content": "<template>\n  <div class=\"w-3 h-3\">\n    <div v-if=\"value\" class=\"w-full h-full text-sm mr-2 text-success animate-pulse\">\n      <svg viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z\" />\n      </svg>\n    </div>\n    <svg v-else class=\"w-full h-full mr-2 text-white/20\" viewBox=\"0 0 24 24\">\n      <path fill=\"currentColor\" d=\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z\" />\n    </svg>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: Boolean\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/PodcastDetailsEdit.vue",
    "content": "<template>\n  <div class=\"w-full h-full relative\">\n    <form class=\"w-full h-full px-4 py-6\" @submit.prevent=\"submitForm\">\n      <div class=\"flex -mx-1\">\n        <div class=\"w-1/2 px-1\">\n          <ui-text-input-with-label ref=\"titleInput\" v-model=\"details.title\" :label=\"$strings.LabelTitle\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1\">\n          <ui-text-input-with-label ref=\"authorInput\" v-model=\"details.author\" :label=\"$strings.LabelAuthor\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <ui-text-input-with-label ref=\"feedUrlInput\" v-model=\"details.feedUrl\" :label=\"$strings.LabelRSSFeedURL\" trim-whitespace class=\"mt-2\" @input=\"handleInputChange\" />\n\n      <ui-textarea-with-label ref=\"descriptionInput\" v-model=\"details.description\" :rows=\"3\" :label=\"$strings.LabelDescription\" class=\"mt-2\" @input=\"handleInputChange\" />\n\n      <div class=\"flex mt-2 -mx-1\">\n        <div class=\"w-1/2 px-1\">\n          <ui-multi-select ref=\"genresSelect\" v-model=\"details.genres\" :label=\"$strings.LabelGenres\" :items=\"genres\" @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1\">\n          <ui-multi-select ref=\"tagsSelect\" v-model=\"newTags\" :label=\"$strings.LabelTags\" :items=\"tags\" @input=\"handleInputChange\" />\n        </div>\n      </div>\n\n      <div class=\"flex mt-2 -mx-1\">\n        <div class=\"w-1/4 px-1\">\n          <ui-text-input-with-label ref=\"releaseDateInput\" v-model=\"details.releaseDate\" :label=\"$strings.LabelReleaseDate\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"w-1/4 px-1\">\n          <ui-text-input-with-label ref=\"itunesIdInput\" v-model=\"details.itunesId\" label=\"iTunes ID\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"w-1/4 px-1\">\n          <ui-text-input-with-label ref=\"languageInput\" v-model=\"details.language\" :label=\"$strings.LabelLanguage\" trim-whitespace @input=\"handleInputChange\" />\n        </div>\n        <div class=\"grow px-1 pt-6\">\n          <div class=\"flex justify-center\">\n            <ui-checkbox v-model=\"details.explicit\" :label=\"$strings.LabelExplicit\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2 text-base font-semibold\" @input=\"handleInputChange\" />\n          </div>\n        </div>\n      </div>\n      <div class=\"flex mt-2 -mx-1\">\n        <div class=\"w-1/4 px-1\">\n          <ui-dropdown :label=\"$strings.LabelPodcastType\" v-model=\"details.type\" :items=\"podcastTypes\" small class=\"max-w-52\" @input=\"handleInputChange\" />\n        </div>\n      </div>\n    </form>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    libraryItem: {\n      type: Object,\n      default: () => {}\n    }\n  },\n  data() {\n    return {\n      details: {\n        title: null,\n        author: null,\n        description: null,\n        releaseDate: null,\n        genres: [],\n        feedUrl: null,\n        imageUrl: null,\n        itunesPageUrl: null,\n        itunesId: null,\n        itunesArtistId: null,\n        explicit: false,\n        language: null,\n        type: null\n      },\n      newTags: []\n    }\n  },\n  watch: {\n    libraryItem: {\n      immediate: true,\n      handler(newVal) {\n        if (newVal) this.init()\n      }\n    }\n  },\n  computed: {\n    media() {\n      return this.libraryItem ? this.libraryItem.media || {} : {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    genres() {\n      return this.filterData.genres || []\n    },\n    tags() {\n      return this.filterData.tags || []\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    },\n    podcastTypes() {\n      return this.$store.state.globals.podcastTypes.map((e) => {\n        return {\n          text: this.$strings[e.descriptionKey] || e.text,\n          value: e.value\n        }\n      })\n    }\n  },\n  methods: {\n    handleInputChange() {\n      this.$emit('change', {\n        libraryItemId: this.libraryItem.id,\n        hasChanges: this.checkForChanges().hasChanges\n      })\n    },\n    getDetails() {\n      this.forceBlur()\n      return this.checkForChanges()\n    },\n    getTitleAndAuthorName() {\n      this.forceBlur()\n      return {\n        title: this.details.title,\n        author: this.details.author\n      }\n    },\n    mapBatchDetails(batchDetails, mapType = 'overwrite') {\n      for (const key in batchDetails) {\n        if (mapType === 'append') {\n          if (key === 'tags') {\n            // Concat and remove dupes\n            this.newTags = [...new Set(this.newTags.concat(batchDetails.tags))]\n          } else if (key === 'genres') {\n            // Concat and remove dupes\n            this.details[key] = [...new Set(this.details[key].concat(batchDetails[key]))]\n          }\n        } else {\n          if (key === 'tags') {\n            this.newTags = [...batchDetails.tags]\n          } else if (key === 'genres') {\n            this.details[key] = [...batchDetails[key]]\n          } else {\n            this.details[key] = batchDetails[key]\n          }\n        }\n      }\n\n      this.handleInputChange()\n    },\n    forceBlur() {\n      if (this.$refs.titleInput) this.$refs.titleInput.blur()\n      if (this.$refs.authorInput) this.$refs.authorInput.blur()\n      if (this.$refs.releaseDateInput) this.$refs.releaseDateInput.blur()\n      if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()\n      if (this.$refs.feedUrlInput) this.$refs.feedUrlInput.blur()\n      if (this.$refs.itunesIdInput) this.$refs.itunesIdInput.blur()\n      if (this.$refs.languageInput) this.$refs.languageInput.blur()\n\n      if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {\n        this.$refs.genresSelect.forceBlur()\n      }\n      if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {\n        this.$refs.tagsSelect.forceBlur()\n      }\n    },\n    stringArrayEqual(array1, array2) {\n      // return false if different\n      if (array1.length !== array2.length) return false\n      for (var item of array1) {\n        if (!array2.includes(item)) return false\n      }\n      return true\n    },\n    objectArrayEqual(array1, array2) {\n      const isIterable = (value) => {\n        return Symbol.iterator in Object(value)\n      }\n      if (!isIterable(array1) || !isIterable(array2)) {\n        console.error(array1, array2)\n        throw new Error('Invalid arrays passed in')\n      }\n\n      // array of objects with id key\n      if (array1.length !== array2.length) return false\n\n      for (var item of array1) {\n        var matchingItem = array2.find((a) => a.id === item.id)\n        if (!matchingItem) return false\n        for (var key in item) {\n          if (item[key] !== matchingItem[key]) {\n            // console.log('Object array item keys changed', key, item[key], matchingItem[key])\n            return false\n          }\n        }\n      }\n      return true\n    },\n    checkForChanges() {\n      var metadata = {}\n      for (const key in this.details) {\n        var newValue = this.details[key]\n        var oldValue = this.mediaMetadata[key]\n        // Key cleared out or key first populated\n        if ((!newValue && oldValue) || (newValue && !oldValue)) {\n          metadata[key] = newValue\n        } else if (key === 'genres') {\n          // Check array of strings\n          if (!this.stringArrayEqual(newValue, oldValue)) {\n            metadata[key] = [...newValue]\n          }\n        } else if (newValue && newValue != oldValue) {\n          // Intentional !=\n          metadata[key] = newValue\n        }\n      }\n      var updatePayload = {}\n      if (!!Object.keys(metadata).length) updatePayload.metadata = metadata\n\n      if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {\n        updatePayload.tags = [...this.newTags]\n      }\n\n      return {\n        updatePayload,\n        hasChanges: !!Object.keys(updatePayload).length\n      }\n    },\n    init() {\n      this.details.title = this.mediaMetadata.title\n      this.details.author = this.mediaMetadata.author || ''\n      this.details.description = this.mediaMetadata.description || ''\n      this.details.releaseDate = this.mediaMetadata.releaseDate || ''\n      this.details.genres = [...(this.mediaMetadata.genres || [])]\n      this.details.feedUrl = this.mediaMetadata.feedUrl || ''\n      this.details.imageUrl = this.mediaMetadata.imageUrl || ''\n      this.details.itunesPageUrl = this.mediaMetadata.itunesPageUrl || ''\n      this.details.itunesId = this.mediaMetadata.itunesId || ''\n      this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''\n      this.details.language = this.mediaMetadata.language || ''\n      this.details.explicit = !!this.mediaMetadata.explicit\n      this.details.type = this.mediaMetadata.type || 'episodic'\n\n      this.newTags = [...(this.media.tags || [])]\n    },\n    submitForm() {\n      this.$emit('submit')\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/PodcastTypeIndicator.vue",
    "content": "<template>\n  <div>\n    <template v-if=\"type == 'bonus'\">\n      <ui-tooltip text=\"Bonus\" direction=\"top\">\n        <span class=\"material-symbols ml-1\" style=\"font-size: 0.8rem\">local_play</span>\n      </ui-tooltip>\n    </template>\n    <template v-if=\"type == 'trailer'\">\n      <ui-tooltip text=\"Trailer\" direction=\"top\">\n        <span class=\"material-symbols ml-1\" style=\"font-size: 0.8rem\">local_movies</span>\n      </ui-tooltip>\n    </template>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    type: {\n      type: String,\n      default: 'full'\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/RssFeedMetadataBuilder.vue",
    "content": "<template>\n  <div class=\"w-full py-2\">\n    <div class=\"flex -mb-px\">\n      <div class=\"w-1/2 h-6 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer\" :class=\"!showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'\" @click=\"showAdvancedView = false\">\n        <p class=\"text-sm\">{{ $strings.HeaderRSSFeedGeneral }}</p>\n      </div>\n      <div class=\"w-1/2 h-6 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer\" :class=\"showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'\" @click=\"showAdvancedView = true\">\n        <p class=\"text-sm\">{{ $strings.HeaderAdvanced }}</p>\n      </div>\n    </div>\n    <div class=\"px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px\" style=\"min-height: 200px\">\n      <template v-if=\"!showAdvancedView\">\n        <div class=\"grow pt-2 mb-2\">\n          <ui-checkbox v-model=\"preventIndexing\" :label=\"$strings.LabelPreventIndexing\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2\" />\n        </div>\n      </template>\n      <template v-else>\n        <div class=\"grow pt-2 mb-2\">\n          <ui-checkbox v-model=\"preventIndexing\" :label=\"$strings.LabelPreventIndexing\" checkbox-bg=\"primary\" border-color=\"gray-600\" label-class=\"pl-2\" />\n        </div>\n        <div class=\"w-full relative mb-1\">\n          <ui-text-input-with-label v-model=\"ownerName\" :label=\"$strings.LabelRSSFeedCustomOwnerName\" />\n        </div>\n        <div class=\"w-full relative mb-1\">\n          <ui-text-input-with-label v-model=\"ownerEmail\" :label=\"$strings.LabelRSSFeedCustomOwnerEmail\" />\n        </div>\n      </template>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: {\n      type: Object,\n      default: () => {\n        return {\n          preventIndexing: true,\n          ownerName: '',\n          ownerEmail: ''\n        }\n      }\n    }\n  },\n  data() {\n    return {\n      showAdvancedView: false\n    }\n  },\n  watch: {},\n  computed: {\n    preventIndexing: {\n      get() {\n        return this.value.preventIndexing\n      },\n      set(value) {\n        this.$emit('input', {\n          ...this.value,\n          preventIndexing: value\n        })\n      }\n    },\n    ownerName: {\n      get() {\n        return this.value.ownerName\n      },\n      set(value) {\n        this.$emit('input', {\n          ...this.value,\n          ownerName: value\n        })\n      }\n    },\n    ownerEmail: {\n      get() {\n        return this.value.ownerEmail\n      },\n      set(value) {\n        this.$emit('input', {\n          ...this.value,\n          ownerEmail: value\n        })\n      }\n    }\n  },\n  methods: {},\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/components/widgets/SeriesInputWidget.vue",
    "content": "<template>\n  <div>\n    <ui-multi-select-query-input v-model=\"seriesItems\" text-key=\"displayName\" :label=\"$strings.LabelSeries\" :disabled=\"disabled\" readonly show-edit @edit=\"editSeriesItem\" @add=\"addNewSeries\" />\n\n    <modals-edit-series-input-inner-modal v-model=\"showSeriesForm\" :selected-series=\"selectedSeries\" :existing-series-names=\"existingSeriesNames\" :original-series-sequence=\"originalSeriesSequence\" @submit=\"submitSeriesForm\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  props: {\n    value: {\n      type: Array,\n      default: () => []\n    },\n    disabled: Boolean\n  },\n  data() {\n    return {\n      selectedSeries: null,\n      originalSeriesSequence: null,\n      showSeriesForm: false\n    }\n  },\n  computed: {\n    seriesItems: {\n      get() {\n        return (this.value || []).map((se) => {\n          return {\n            displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,\n            ...se\n          }\n        })\n      },\n      set(val) {\n        this.$emit('input', val)\n      }\n    },\n    series() {\n      return this.filterData.series || []\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    },\n    existingSeriesNames() {\n      // Only show series names not already selected\n      var alreadySelectedSeriesIds = (this.value || []).map((se) => se.id)\n      return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)\n    }\n  },\n  methods: {\n    cancelSeriesForm() {\n      this.showSeriesForm = false\n    },\n    editSeriesItem(series) {\n      var _series = this.seriesItems.find((se) => se.id === series.id)\n      if (!_series) return\n\n      this.selectedSeries = {\n        ..._series\n      }\n\n      this.originalSeriesSequence = _series.sequence\n      this.showSeriesForm = true\n    },\n    addNewSeries() {\n      this.selectedSeries = {\n        id: `new-${Date.now()}`,\n        name: '',\n        sequence: ''\n      }\n\n      this.originalSeriesSequence = null\n      this.showSeriesForm = true\n    },\n    submitSeriesForm() {\n      if (!this.selectedSeries.name) {\n        this.$toast.error('Must enter a series')\n        return\n      }\n\n      var existingSeriesIndex = this.seriesItems.findIndex((se) => se.id === this.selectedSeries.id)\n\n      var existingSeriesSameName = this.seriesItems.findIndex((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())\n      if (existingSeriesSameName >= 0 && existingSeriesIndex < 0) {\n        console.error('Attempt to add duplicate series')\n        this.$toast.error(this.$strings.ToastSeriesSubmitFailedSameName)\n        return\n      }\n\n      var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())\n      if (existingSeriesIndex < 0 && seriesSameName) {\n        this.selectedSeries.id = seriesSameName.id\n      }\n\n      var selectedSeriesCopy = { ...this.selectedSeries }\n      selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name\n\n      var seriesCopy = this.seriesItems.map((v) => ({ ...v }))\n      if (existingSeriesIndex >= 0) {\n        seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)\n        this.seriesItems = seriesCopy\n      } else {\n        seriesCopy.push(selectedSeriesCopy)\n        this.seriesItems = seriesCopy\n      }\n\n      this.showSeriesForm = false\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/cypress/support/commands.js",
    "content": "// ***********************************************\n// This example commands.js shows you how to\n// create various custom commands and overwrite\n// existing commands.\n//\n// For more comprehensive examples of custom\n// commands please read more here:\n// https://on.cypress.io/custom-commands\n// ***********************************************\n//\n//\n// -- This is a parent command --\n// Cypress.Commands.add('login', (email, password) => { ... })\n//\n//\n// -- This is a child command --\n// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })\n//\n//\n// -- This is a dual command --\n// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })\n//\n//\n// -- This will overwrite an existing command --\n// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })\nCypress.Commands.overwriteQuery('get', function (originalFn, ...args) {\n  if (args.length > 0 && typeof args[0] === 'string' && args[0].startsWith('&')) {\n    args[0] = `[cy-id=\"${args[0].substring(1)}\"]`\n  }\n  return originalFn.apply(this, args)\n})"
  },
  {
    "path": "client/cypress/support/component-index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n    <title>Components App</title>\n  </head>\n  <body class=\"text-white bg-bg\">\n    <div data-cy-root></div>\n  </body>\n</html>"
  },
  {
    "path": "client/cypress/support/component.js",
    "content": "// ***********************************************************\n// This example support/component.js is processed and\n// loaded automatically before your test files.\n//\n// This is a great place to put global configuration and\n// behavior that modifies Cypress.\n//\n// You can change the location of this file or turn off\n// automatically serving support files with the\n// 'supportFile' configuration option.\n//\n// You can read more here:\n// https://on.cypress.io/configuration\n// ***********************************************************\nimport '../../assets/app.css'\nimport './tailwind.compiled.css'\n// Import commands.js using ES2015 syntax:\nimport './commands'\nimport Vue from 'vue'\n\nimport { Constants } from '../../plugins/constants'\nimport Strings from '../../strings/en-us.json'\nimport '../../plugins/utils'\nimport '../../plugins/init.client'\n\nimport { mount } from 'cypress/vue2'\n\n//Cypress.Commands.add('mount', mount)\nCypress.Commands.add('mount', (component, options = {}) => {\n\n  Vue.prototype.$constants = Constants\n  Vue.prototype.$strings = Strings\n\n  return mount(component, options)\n})\n\n// Example use:\n// cy.mount(MyComponent)"
  },
  {
    "path": "client/cypress/tests/components/cards/AuthorCard.cy.js",
    "content": "// Import the necessary dependencies\nimport AuthorCard from '@/components/cards/AuthorCard.vue'\nimport AuthorImage from '@/components/covers/AuthorImage.vue'\nimport Tooltip from '@/components/ui/Tooltip.vue'\nimport LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'\n\ndescribe('AuthorCard', () => {\n  const authorMount = {\n    id: 1,\n    name: 'John Doe',\n    numBooks: 5\n  }\n\n  const propsData = {\n    authorMount,\n    nameBelow: false\n  }\n\n  const mocks = {\n    $strings: {\n      LabelBooks: 'Books',\n      ButtonQuickMatch: 'Quick Match',\n      ToastAuthorUpdateSuccess: 'Author updated',\n      ToastAuthorUpdateSuccessNoImageFound: 'Author updated (no image found)'\n    },\n    $store: {\n      getters: {\n        'user/getUserCanUpdate': true,\n        'libraries/getLibraryProvider': () => 'audible.us',\n        'user/getSizeMultiplier': 1\n      },\n      state: {\n        libraries: {\n          currentLibraryId: 'library-123'\n        }\n      }\n    },\n    $eventBus: {\n      $on: () => {},\n      $off: () => {}\n    }\n  }\n\n  const stubs = {\n    'covers-author-image': AuthorImage,\n    'ui-tooltip': Tooltip,\n    'widgets-loading-spinner': LoadingSpinner\n  }\n\n  const mountOptions = { propsData, mocks, stubs }\n\n  it('renders the component', () => {\n    cy.mount(AuthorCard, mountOptions)\n\n    cy.get('&textInline').should('be.visible')\n    cy.get('&match').should('be.hidden')\n    cy.get('&edit').should('be.hidden')\n    cy.get('&nameBelow').should('be.hidden')\n    cy.get('&card').should(($el) => {\n      const width = $el.width()\n      const height = $el.height()\n      const defaultHeight = 192\n      const defaultWidth = defaultHeight * 0.8\n      expect(width).to.be.closeTo(defaultWidth, 0.01)\n      expect(height).to.be.closeTo(defaultHeight, 0.01)\n    })\n  })\n\n  it('renders the component with the author name below', () => {\n    const updatedPropsData = { ...propsData, nameBelow: true }\n    cy.mount(AuthorCard, { ...mountOptions, propsData: updatedPropsData })\n\n    cy.get('&textInline').should('be.hidden')\n    cy.get('&match').should('be.hidden')\n    cy.get('&edit').should('be.hidden')\n    let nameBelowHeight\n    cy.get('&nameBelow')\n      .should('be.visible')\n      .and('have.text', 'John Doe')\n      .and(($el) => {\n        const height = $el.height()\n        const width = $el.width()\n        const sizeMultiplier = 1\n        const defaultFontSize = 16\n        const defaultLineHeight = 1.5\n        const fontSizeMultiplier = 0.75\n        const px2 = 16\n        const defaultHeight = 192\n        const defaultWidth = defaultHeight * 0.8\n        expect(height).to.be.closeTo(defaultFontSize * fontSizeMultiplier * sizeMultiplier * defaultLineHeight, 0.01)\n        nameBelowHeight = height\n        expect(width).to.be.closeTo(defaultWidth - px2, 0.01)\n      })\n    cy.get('&card').should(($el) => {\n      const width = $el.width()\n      const height = $el.height()\n      const py1 = 8\n      const defaultHeight = 192\n      const defaultWidth = defaultHeight * 0.8\n      expect(width).to.be.closeTo(defaultWidth, 0.01)\n      expect(height).to.be.closeTo(defaultHeight + nameBelowHeight + py1, 0.01)\n    })\n  })\n\n  it('renders quick-match and edit buttons on mouse hover', () => {\n    cy.mount(AuthorCard, mountOptions)\n\n    // before mouseover\n    cy.get('&match').should('be.hidden')\n    cy.get('&edit').should('be.hidden')\n    // after mouseover\n    cy.get('&card').trigger('mouseover')\n    cy.get('&match').should('be.visible')\n    cy.get('&edit').should('be.visible')\n    // after mouseleave\n    cy.get('&card').trigger('mouseleave')\n    cy.get('&match').should('be.hidden')\n    cy.get('&edit').should('be.hidden')\n  })\n\n  it('renders the component with spinner while searching', () => {\n    const data = () => {\n      return { searching: true, isHovering: false }\n    }\n    cy.mount(AuthorCard, { ...mountOptions, data })\n\n    cy.get('&textInline').should('be.hidden')\n    cy.get('&match').should('be.hidden')\n    cy.get('&edit').should('be.hidden')\n    cy.get('&spinner').should('be.visible')\n  })\n\n  it('toasts after quick match with no updates', () => {\n    const updatedMocks = {\n      ...mocks,\n      $axios: {\n        $post: cy.stub().resolves({ updated: false, author: { name: 'John Doe' } })\n      },\n      $toast: {\n        success: cy.spy().as('success'),\n        error: cy.spy().as('error'),\n        info: cy.spy().as('info')\n      }\n    }\n    cy.mount(AuthorCard, { ...mountOptions, mocks: updatedMocks })\n    cy.get('&card').trigger('mouseover')\n    cy.get('&match').click()\n\n    cy.get('&spinner').should('be.hidden')\n    cy.get('@success').should('not.have.been.called')\n    cy.get('@error').should('not.have.been.called')\n    cy.get('@info').should('have.been.called')\n  })\n\n  it('toasts after quick match with updates and no image', () => {\n    const updatedMocks = {\n      ...mocks,\n      $axios: {\n        $post: cy.stub().resolves({ updated: true, author: { name: 'John Doe' } })\n      },\n      $toast: {\n        success: cy.stub().as('success'),\n        error: cy.spy().as('error'),\n        info: cy.spy().as('info')\n      }\n    }\n    cy.mount(AuthorCard, { ...mountOptions, mocks: updatedMocks })\n    cy.get('&card').trigger('mouseover')\n    cy.get('&match').click()\n\n    cy.get('&spinner').should('be.hidden')\n    cy.get('@success').should('have.been.calledOnceWithExactly', 'Author updated (no image found)')\n    cy.get('@error').should('not.have.been.called')\n    cy.get('@info').should('not.have.been.called')\n  })\n\n  it('toasts after quick match with updates including image', () => {\n    const updatedMocks = {\n      ...mocks,\n      $axios: {\n        $post: cy.stub().resolves({ updated: true, author: { name: 'John Doe', imagePath: 'path/to/image' } })\n      },\n      $toast: {\n        success: cy.stub().as('success'),\n        error: cy.spy().as('error'),\n        info: cy.spy().as('info')\n      }\n    }\n    cy.mount(AuthorCard, { ...mountOptions, mocks: updatedMocks })\n    cy.get('&card').trigger('mouseover')\n    cy.get('&match').click()\n\n    cy.get('&spinner').should('be.hidden')\n    cy.get('@success').should('have.been.calledOnceWithExactly', 'Author updated')\n    cy.get('@error').should('not.have.been.called')\n    cy.get('@info').should('not.have.been.called')\n  })\n})\n"
  },
  {
    "path": "client/cypress/tests/components/cards/ItemSlider.cy.js",
    "content": "import ItemSlider from '@/components/widgets/ItemSlider.vue'\nimport NarratorCard from '@/components/cards/NarratorCard.vue'\nimport AuthorCard from '@/components/cards/AuthorCard.vue'\n\nfunction createMountOptions(shelftype) {\n  const items = {\n    narrators: [\n      { name: 'John Doe', numBooks: 5 },\n      { name: 'Jane Doe', numBooks: 3 },\n      { name: 'Jack Doe', numBooks: 1 },\n      { name: 'Jill Doe', numBooks: 7 }\n    ],\n    authors: [\n      { id: 1, name: 'John Doe', numBooks: 5 },\n      { id: 2, name: 'Jane Doe', numBooks: 3 },\n      { id: 3, name: 'Jack Doe', numBooks: 1 },\n      { id: 4, name: 'Jill Doe', numBooks: 7 }\n    ]\n  }\n  const propsData = {\n    items: items[shelftype],\n    shelfId: 'shelf-123',\n    type: shelftype\n  }\n  const stubs = {\n    'cards-narrator-card': NarratorCard,\n    'cards-author-card': AuthorCard\n  }\n  const mocks = {\n    $store: {\n      getters: {\n        'user/getUserCanUpdate': true,\n        'user/getSizeMultiplier': 1,\n        'globals/getIsBatchSelectingMediaItems': false\n      },\n      state: {\n        libraries: {\n          currentLibraryId: 'library-123'\n        }\n      }\n    },\n    $eventBus: {\n      $on: () => {},\n      $off: () => {}\n    }\n  }\n  const slots = {\n    default: `<p class=\"font-semibold text-gray-100\">${shelftype}</p>`\n  }\n\n  return { propsData, stubs, mocks, slots }\n}\n\ndescribe('ItemSlider', () => {\n  let mountOptions = null\n\n  beforeEach(() => {})\n\n  it('renders a narrators slider', () => {\n    mountOptions = createMountOptions('narrators')\n    cy.mount(ItemSlider, mountOptions)\n\n    cy.get('&item').should('have.length', 4)\n    cy.get('&leftScrollButton').should('be.visible').and('not.have.class', 'text-gray-300')\n    cy.get('&rightScrollButton').should('be.visible').and('have.class', 'text-gray-300')\n  })\n\n  it('renders an authors slider', () => {\n    mountOptions = createMountOptions('authors')\n    cy.mount(ItemSlider, mountOptions)\n\n    cy.get('&item').should('have.length', 4)\n    cy.get('&leftScrollButton').should('be.visible').and('not.have.class', 'text-gray-300')\n    cy.get('&rightScrollButton').should('be.visible').and('have.class', 'text-gray-300')\n  })\n\n  it('hides the scroll button when all items are visible', () => {\n    mountOptions = createMountOptions('narrators')\n    mountOptions.propsData.items = mountOptions.propsData.items.slice(0, 2)\n    cy.mount(ItemSlider, mountOptions)\n\n    cy.get('&leftScrollButton').should('not.exist')\n    cy.get('&rightScrollButton').should('not.exist')\n  })\n})\n"
  },
  {
    "path": "client/cypress/tests/components/cards/LazyBookCard.cy.js",
    "content": "import LazyBookCard from '@/components/cards/LazyBookCard'\nimport Tooltip from '@/components/ui/Tooltip.vue'\nimport ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'\nimport LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'\nimport { Constants } from '@/plugins/constants'\n\nfunction createMountOptions() {\n  const book = {\n    id: '1',\n    libraryId: 'library-123',\n    mediaType: 'book',\n    media: {\n      id: 'book1',\n      metadata: { title: 'The Fellowship of the Ring', titleIgnorePrefix: 'Fellowship of the Ring', authorName: 'J. R. R. Tolkien', subtitle: 'The Lord of the Rings, Book 1' },\n      numTracks: 1\n    }\n  }\n\n  const propsData = {\n    index: 0,\n    bookMount: book,\n    bookshelfView: Constants.BookshelfView.DETAIL,\n    continueListeningShelf: false,\n    filterBy: null,\n    sortingIgnorePrefix: false,\n    orderBy: null\n  }\n\n  const stubs = {\n    'ui-tooltip': Tooltip,\n    'widgets-explicit-indicator': ExplicitIndicator,\n    'widgets-loading-spinner': LoadingSpinner\n  }\n\n  const mocks = {\n    $config: {\n      routerBasePath: 'https://my.server.com'\n    },\n    $store: {\n      commit: () => {},\n      getters: {\n        'user/getUserCanUpdate': true,\n        'user/getUserCanDelete': true,\n        'user/getUserCanDownload': true,\n        'user/getIsAdminOrUp': true,\n        'user/getUserMediaProgress': (id) => null,\n        'user/getUserSetting': (settingName) => false,\n        'user/getSizeMultiplier': 1,\n        'libraries/getLibraryProvider': () => 'audible.us',\n        'libraries/getBookCoverAspectRatio': 1,\n        'globals/getLibraryItemCoverSrc': () => 'https://my.server.com/book_placeholder.jpg',\n        'globals/getPlaceholderCoverSrc': 'https://my.server.com/book_placeholder.jpg',\n        getLibraryItemsStreaming: () => null,\n        getIsMediaQueued: () => false,\n        getIsStreamingFromDifferentLibrary: () => false\n      },\n      state: {\n        libraries: {\n          currentLibraryId: 'library-123'\n        },\n        processingBatch: false,\n        serverSettings: {\n          dateFormat: 'MM/dd/yyyy'\n        }\n      }\n    }\n  }\n\n  return { propsData, stubs, mocks }\n}\n\ndescribe('LazyBookCard', () => {\n  let mountOptions = null\n  beforeEach(() => {\n    mountOptions = createMountOptions()\n  })\n\n  before(() => {\n    // Put placeholder image is in the browser cache\n    mountOptions = createMountOptions()\n    cy.intercept('https://my.server.com/book_placeholder.jpg', { fixture: 'images/book_placeholder.jpg' }).as('bookCover')\n    cy.mount(LazyBookCard, mountOptions)\n    cy.wait('@bookCover')\n\n    // Put cover1 (aspect ratio 1.6) image in the browser cache\n    mountOptions = createMountOptions()\n    mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'\n    cy.intercept('https://my.server.com/cover1.jpg', { fixture: 'images/cover1.jpg' }).as('bookCover1')\n    cy.mount(LazyBookCard, mountOptions)\n    cy.wait('@bookCover1')\n\n    // Put cover2 (aspect ratio 1) image in the browser cache\n    mountOptions = createMountOptions()\n    mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover2.jpg'\n    cy.intercept('https://my.server.com/cover2.jpg', { fixture: 'images/cover2.jpg' }).as('bookCover2')\n    cy.mount(LazyBookCard, mountOptions)\n    cy.wait('@bookCover2')\n  })\n\n  it('renders the component correctly', () => {\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&coverImage').should('have.css', 'opacity', '1')\n    cy.get('&coverBg').should('be.hidden')\n    cy.get('&overlay').should('be.hidden')\n    cy.get('&detailBottom').should('be.visible')\n    cy.get('&title').should('have.text', 'The Fellowship of the Ring')\n    cy.get('&explicitIndicator').should('not.exist')\n    cy.get('&line2').should('have.text', 'J. R. R. Tolkien')\n    cy.get('&line3').should('not.exist')\n    cy.get('seriesSequenceList').should('not.exist')\n    cy.get('&booksInSeries').should('not.exist')\n    cy.get('&placeholderTitle').should('be.visible')\n    cy.get('&placeholderTitleText').should('have.text', 'The Fellowship of the Ring')\n    cy.get('&placeholderAuthor').should('be.visible')\n    cy.get('&placeholderAuthorText').should('have.text', 'J. R. R. Tolkien')\n    cy.get('&progressBar').should('be.hidden')\n    cy.get('&finishedProgressBar').should('not.exist')\n    cy.get('&loadingSpinner').should('not.exist')\n    cy.get('&seriesNameOverlay').should('not.exist')\n    cy.get('&errorTooltip').should('not.exist')\n    cy.get('&rssFeed').should('not.exist')\n    cy.get('&seriesSequence').should('not.exist')\n    cy.get('&podcastEpisdeNumber').should('not.exist')\n\n    // this should actually fail, since the height does not cover\n    // the detailBottom element, currently rendered outside the card's area,\n    // and requires complex layout calculations outside of the component.\n    // todo: fix the component to render the detailBottom element inside the card's area\n    cy.get('#cover-area-0').should(($el) => {\n      const width = $el.width()\n      const height = $el.height()\n      const defaultHeight = 192\n      const defaultWidth = defaultHeight\n\n      expect(width).to.be.closeTo(defaultWidth, 0.01)\n      expect(height).to.be.closeTo(defaultHeight, 0.01)\n    })\n  })\n\n  it('shows subtitle when showSubtitles settings is true', () => {\n    mountOptions.mocks.$store.getters['user/getUserSetting'] = (settingName) => {\n      if (settingName === 'showSubtitles') return true\n    }\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&subtitle').should('be.visible').and('have.text', 'The Lord of the Rings, Book 1')\n  })\n\n  it('shows overlay on mouseover', () => {\n    cy.mount(LazyBookCard, mountOptions)\n    cy.get('#book-card-0').trigger('mouseover')\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&overlay').should('be.visible')\n    cy.get('&playButton').should('be.visible')\n    cy.get('&readButton').should('be.hidden')\n    cy.get('&editButton').should('be.visible')\n    cy.get('&selectedRadioButton').should('be.visible').and('have.text', 'radio_button_unchecked')\n    cy.get('&moreButton').should('be.visible')\n    cy.get('&ebookFormat').should('not.exist')\n  })\n\n  it('routes to item page when clicked', () => {\n    mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }\n    cy.mount(LazyBookCard, mountOptions)\n    cy.get('#book-card-0').click()\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/item/1')\n  })\n\n  it('shows titleImageNotReady and sets opacity 0 on coverImage when image not ready', () => {\n    mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/notfound.jpg'\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.visible')\n    cy.get('&coverImage').should('have.css', 'opacity', '0')\n  })\n\n  it('shows coverBg when coverImage has different aspect ratio', () => {\n    mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&coverBg').should('be.visible')\n    cy.get('&coverImage').should('have.class', 'object-contain')\n  })\n\n  it('hides coverBg when coverImage has same aspect ratio', () => {\n    mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover2.jpg'\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&coverBg').should('be.hidden')\n    cy.get('&coverImage').should('have.class', 'object-fill')\n  })\n\n  // The logic for displaying placeholder title and author seems incorrect.\n  // It is currently based on existence of coverPath, but should be based weater the actual cover image is placeholder or not.\n  // todo: fix the logic to display placeholder title and author based on the actual cover image.\n  it('hides placeholderTitle and placeholderAuthor when book has cover', () => {\n    mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'\n    mountOptions.propsData.bookMount.media.coverPath = 'cover1.jpg'\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&placeholderTitle').should('not.exist')\n    cy.get('&placeholderAuthor').should('not.exist')\n  })\n\n  it('hides detailBottom when bookShelfView is STANDARD', () => {\n    mountOptions.propsData.bookshelfView = Constants.BookshelfView.STANDARD\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&detailBottom').should('not.exist')\n  })\n\n  it('shows explicit indicator when book is explicit', () => {\n    mountOptions.propsData.bookMount.media.metadata.explicit = true\n    cy.mount(LazyBookCard, mountOptions)\n\n    cy.get('&titleImageNotReady').should('be.hidden')\n    cy.get('&explicitIndicator').should('be.visible')\n  })\n\n  describe('when collapsedSeries is present', () => {\n    beforeEach(() => {\n      mountOptions.propsData.bookMount.collapsedSeries = {\n        id: 'series-123',\n        name: 'The Lord of the Rings',\n        nameIgnorePrefix: 'Lord of the Rings',\n        numBooks: 3,\n        libraryItemIds: ['1', '2', '3']\n      }\n    })\n\n    it('shows the collpased series', () => {\n      cy.mount(LazyBookCard, mountOptions)\n\n      cy.get('&titleImageNotReady').should('be.hidden')\n      cy.get('&seriesSequenceList').should('not.exist')\n      cy.get('&booksInSeries').should('be.visible').and('have.text', '3')\n      cy.get('&title').should('be.visible').and('have.text', 'The Lord of the Rings')\n      cy.get('&line2').should('be.visible').and('have.text', '\\u00a0')\n      cy.get('&progressBar').should('be.hidden')\n    })\n\n    it('shows the seriesNameOverlay on mouseover', () => {\n      mountOptions.propsData.bookMount.media.metadata.series = {\n        id: 'series-456',\n        name: 'Middle Earth Chronicles',\n        sequence: 1\n      }\n      cy.mount(LazyBookCard, mountOptions)\n      cy.get('#book-card-0').trigger('mouseover')\n\n      cy.get('&titleImageNotReady').should('be.hidden')\n      cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'The Lord of the Rings')\n    })\n\n    it('shows the seriesSequenceList when collapsed series has a sequence list', () => {\n      mountOptions.propsData.bookMount.collapsedSeries.seriesSequenceList = '1-3'\n      cy.mount(LazyBookCard, mountOptions)\n\n      cy.get('&titleImageNotReady').should('be.hidden')\n      cy.get('&seriesSequenceList').should('be.visible').and('have.text', '#1-3')\n      cy.get('&booksInSeries').should('not.exist')\n    })\n\n    it('routes to the series page when clicked', () => {\n      mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }\n      cy.mount(LazyBookCard, mountOptions)\n      cy.get('#book-card-0').click()\n\n      cy.get('&titleImageNotReady').should('be.hidden')\n      cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/library/library-123/series/series-123')\n    })\n\n    it('shows the series progress bar when series has progress', () => {\n      mountOptions.mocks.$store.getters['user/getUserMediaProgress'] = (id) => {\n        switch (id) {\n          case '1':\n            return { isFinished: true }\n          case '2':\n            return { progress: 0.5 }\n          default:\n            return null\n        }\n      }\n      cy.mount(LazyBookCard, mountOptions)\n\n      cy.get('&titleImageNotReady').should('be.hidden')\n      cy.get('&progressBar')\n        .should('be.visible')\n        .and('have.class', 'bg-yellow-400')\n        .and(($el) => {\n          const width = $el.width()\n          const defaultHeight = 192\n          const defaultWidth = defaultHeight\n          expect(width).to.be.closeTo(((1 + 0.5) / 3) * defaultWidth, 0.01)\n        })\n    })\n\n    it('shows full green progress bar when all books are finished', () => {\n      mountOptions.mocks.$store.getters['user/getUserMediaProgress'] = (id) => {\n        return { isFinished: true }\n      }\n      cy.mount(LazyBookCard, mountOptions)\n\n      cy.get('&titleImageNotReady').should('be.hidden')\n      cy.get('&progressBar')\n        .should('be.visible')\n        .and('have.class', 'bg-success')\n        .and(($el) => {\n          const width = $el.width()\n          const defaultHeight = 192\n          const defaultWidth = defaultHeight\n          expect(width).to.be.equal(defaultWidth)\n        })\n    })\n  })\n})\n"
  },
  {
    "path": "client/cypress/tests/components/cards/LazySeriesCard.cy.js",
    "content": "import LazySeriesCard from '@/components/cards/LazySeriesCard.vue'\nimport GroupCover from '@/components/covers/GroupCover.vue'\n\ndescribe('LazySeriesCard', () => {\n  const series = {\n    id: 1,\n    name: 'The Lord of the Rings',\n    nameIgnorePrefix: 'Lord of the Rings',\n    books: [\n      { id: 1, updatedAt: /* 04/14/2024 */ 1713099600000, addedAt: 1713099600000, media: { coverPath: 'cover1.jpg' }, title: 'The Fellowship of the Ring' },\n      { id: 2, updatedAt: /* 04/15/2024 */ 1713186000000, addedAt: 1713186000000, media: { coverPath: 'cover2.jpg' }, title: 'The Two Towers' },\n      { id: 3, updatedAt: /* 04/16/2024 */ 1713272400000, addedAt: 1713272400000, media: { coverPath: 'cover3.jpg' }, title: 'The Return of the King' }\n    ],\n    addedAt: /* 04/17/2024 */ 1713358800000,\n    totalDuration: /* 7h 30m */ 3600 * 7 + 60 * 30,\n    rssFeed: 'https://example.com/feed.rss'\n  }\n\n  const propsData = {\n    index: 0,\n    bookshelfView: 1,\n    isCategorized: false,\n    seriesMount: series,\n    sortingIgnorePrefix: false,\n    orderBy: 'addedAt'\n  }\n\n  const stubs = {\n    'covers-group-cover': GroupCover\n  }\n\n  const mocks = {\n    $getString: (id, args) => {\n      switch (id) {\n        case 'LabelAddedDate':\n          return `Added ${args[0]}`\n        default:\n          return null\n      }\n    },\n    $store: {\n      getters: {\n        getServerSetting: () => 'MM/dd/yyyy',\n        'user/getUserCanUpdate': true,\n        'user/getUserMediaProgress': (id) => null,\n        'user/getSizeMultiplier': 1,\n        'libraries/getBookCoverAspectRatio': 1,\n        'libraries/getLibraryProvider': () => 'audible.us',\n        'globals/getLibraryItemCoverSrc': () => 'https://my.server.com/book_placeholder.jpg'\n      },\n      state: {\n        libraries: {\n          currentLibraryId: 'library-123'\n        },\n        serverSettings: {\n          dateFormat: 'MM/dd/yyyy'\n        }\n      }\n    }\n  }\n\n  before(() => {\n    cy.intercept('GET', 'https://my.server.com/book_placeholder.jpg', { fixture: 'images/book_placeholder.jpg' }).as('bookCover')\n    cy.mount(LazySeriesCard, { propsData, stubs, mocks })\n    cy.wait('@bookCover')\n    // Now the placeholder image is in the browser cache\n  })\n\n  it('renders the component', () => {\n    cy.mount(LazySeriesCard, { propsData, stubs, mocks })\n\n    cy.get('&covers-area').should(($el) => {\n      const width = $el.width()\n      const height = $el.height()\n      const defailtHeight = 192\n      const defaultWidth = defailtHeight * 2\n      expect(width).to.be.closeTo(defaultWidth, 0.01)\n      expect(height).to.be.closeTo(defailtHeight, 0.01)\n    })\n    cy.get('&seriesLengthMarker').should('be.visible').and('have.text', propsData.seriesMount.books.length)\n    cy.get('&seriesProgressBar').should('not.exist')\n    cy.get('&hoveringDisplayTitle').should('be.hidden')\n    cy.get('&rssFeedMarker').should('be.visible')\n    cy.get('&standardBottomDisplayTitle').should('not.exist')\n    cy.get('&detailBottomDisplayTitle').should('be.visible')\n    cy.get('&detailBottomDisplayTitle').should('have.text', 'The Lord of the Rings')\n    cy.get('&detailBottomSortLine').should('have.text', 'Added 04/17/2024')\n  })\n\n  it('shows series name and hides rss feed marker on mouseover', () => {\n    cy.mount(LazySeriesCard, { propsData, stubs, mocks })\n    cy.get('&card').trigger('mouseover')\n\n    cy.get('&hoveringDisplayTitle').should('be.visible').should('have.text', 'The Lord of the Rings')\n    cy.get('&rssFeedMarker').should('not.exist')\n  })\n\n  it('routes properly when clicked', () => {\n    const updatedMocks = {\n      ...mocks,\n      $router: {\n        push: cy.stub().as('routerPush')\n      }\n    }\n    cy.mount(LazySeriesCard, { propsData, stubs, mocks: updatedMocks })\n    cy.get('&card').click()\n\n    cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/library/library-123/series/1')\n  })\n\n  it('shows progress bar when progress is available', () => {\n    const updatedMocks = {\n      ...mocks,\n      $store: {\n        ...mocks.$store,\n        getters: {\n          ...mocks.$store.getters,\n          'user/getUserMediaProgress': (id) => {\n            switch (id) {\n              case 1:\n                return { isFinished: true }\n              case 2:\n                return { progress: 0.5 }\n              default:\n                return null\n            }\n          }\n        }\n      }\n    }\n    cy.mount(LazySeriesCard, { propsData, stubs, mocks: updatedMocks })\n\n    cy.get('&seriesProgressBar')\n      .should('be.visible')\n      .and('have.class', 'bg-yellow-400')\n      .and(($el) => {\n        const width = $el.width()\n        const defailtHeight = 192\n        const defaultWidth = defailtHeight * 2\n        expect(width).to.be.closeTo(((1 + 0.5) / 3) * defaultWidth, 0.01)\n      })\n  })\n\n  it('shows full green progress bar when all books are finished', () => {\n    const updatedMocks = {\n      ...mocks,\n      $store: {\n        ...mocks.$store,\n        getters: {\n          ...mocks.$store.getters,\n          'user/getUserMediaProgress': (id) => {\n            return { isFinished: true }\n          }\n        }\n      }\n    }\n    cy.mount(LazySeriesCard, { propsData, stubs, mocks: updatedMocks })\n\n    cy.get('&seriesProgressBar')\n      .should('be.visible')\n      .and('have.class', 'bg-success')\n      .and(($el) => {\n        const width = $el.width()\n        const defailtHeight = 192\n        const defaultWidth = defailtHeight * 2\n        expect(width).to.equal(defaultWidth)\n      })\n  })\n\n  it('hides the rss feed marker when there is no rss feed', () => {\n    const updatedPropsData = {\n      ...propsData,\n      seriesMount: { ...series, rssFeed: null }\n    }\n    cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })\n\n    cy.get('&rssFeedMarker').should('not.exist')\n  })\n\n  it('shows the standard bottom display when bookshelf view is 0', () => {\n    const updatedPropsData = {\n      ...propsData,\n      bookshelfView: 0\n    }\n    cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })\n\n    cy.get('&standardBottomDisplayTitle').should('be.visible')\n    cy.get('&detailBottomDisplayTitle').should('not.exist')\n  })\n\n  it('shows total duration in sort line when orderBy is totalDuration', () => {\n    const updatedPropsData = {\n      ...propsData,\n      orderBy: 'totalDuration'\n    }\n    cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })\n\n    cy.get('&detailBottomSortLine').should('have.text', 'Duration 7h 30m')\n  })\n\n  it('shows last book updated date in sort line when orderBy is lastBookUpdated', () => {\n    const updatedPropsData = {\n      ...propsData,\n      orderBy: 'lastBookUpdated'\n    }\n    cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })\n\n    cy.get('&detailBottomSortLine').should('have.text', 'Last Book Updated 04/16/2024')\n  })\n\n  it('shows last book added date in sort line when orderBy is lastBookAdded', () => {\n    const updatedPropsData = {\n      ...propsData,\n      orderBy: 'lastBookAdded'\n    }\n    cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })\n\n    cy.get('&detailBottomSortLine').should('have.text', 'Last Book Added 04/16/2024')\n  })\n\n  it('shows nameIgnorePrefix when sortingIgnorePrefix is true', () => {\n    const updatedPropsData = {\n      ...propsData,\n      sortingIgnorePrefix: true\n    }\n    cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })\n\n    cy.get('&detailBottomDisplayTitle').should('have.text', 'Lord of the Rings')\n  })\n})\n"
  },
  {
    "path": "client/cypress/tests/components/cards/NarratorCard.cy.js",
    "content": "import NarratorCard from '@/components/cards/NarratorCard.vue'\n\ndescribe('<NarratorCard />', () => {\n  const narrator = {\n    name: 'John Doe',\n    numBooks: 5\n  }\n  const propsData = {\n    narrator\n  }\n  const mocks = {\n    $store: {\n      getters: {\n        'user/getUserCanUpdate': true,\n        'user/getSizeMultiplier': 1\n      },\n      state: {\n        libraries: {\n          currentLibraryId: 'library-123'\n        }\n      }\n    },\n    $encode: (value) => value\n  }\n\n  it('renders the component', () => {\n    let mountOptions = { propsData, mocks }\n    // see: https://on.cypress.io/mounting-vue\n    cy.mount(NarratorCard, mountOptions)\n  })\n\n  it('renders the narrator name correctly', () => {\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n\n    cy.get('&name').should('have.text', 'John Doe')\n  })\n\n  it('renders the number of books correctly', () => {\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n\n    cy.get('&numBooks').should('have.text', '5 Books')\n  })\n\n  it('renders 1 book correctly', () => {\n    let propsData = { narrator: { name: 'John Doe', numBooks: 1 }, width: 200, height: 150 }\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n\n    cy.get('&numBooks').should('have.text', '1 Book')\n  })\n\n  it('renders the default name and num-books when narrator is not provided', () => {\n    let propsData = { width: 200, height: 150 }\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n    cy.get('&name').should('have.text', '')\n    cy.get('&numBooks').should('have.text', '0 Books')\n  })\n\n  it('has the correct width and height', () => {\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n    cy.get('&card').should('have.css', 'width', '150px')\n    cy.get('&card').should('have.css', 'height', '100px')\n  })\n\n  it('has the correct width and height when not provided', () => {\n    let propsData = { narrator }\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n    cy.get('&card').should('have.css', 'width', '150px')\n    cy.get('&card').should('have.css', 'height', '100px')\n  })\n\n  it('has the correct font sizes', () => {\n    let mountOptions = { propsData, mocks }\n    cy.mount(NarratorCard, mountOptions)\n    const defaultFontSize = 16\n    cy.get('&name').should('have.css', 'font-size', `${0.75 * defaultFontSize}px`)\n    cy.get('&numBooks').should('have.css', 'font-size', `${0.65 * defaultFontSize}px`)\n  })\n})\n"
  },
  {
    "path": "client/cypress/tests/utils/ElapsedPrettyExtended.cy.js",
    "content": "import Vue from 'vue'\nimport '@/plugins/utils'\n\n// This is the actual function that is being tested\nconst elapsedPrettyExtended = Vue.prototype.$elapsedPrettyExtended\n\n// Helper function to convert days, hours, minutes, seconds to total seconds\nfunction DHMStoSeconds(days, hours, minutes, seconds) {\n  return seconds + minutes * 60 + hours * 3600 + days * 86400\n}\n\ndescribe('$elapsedPrettyExtended', () => {\n  describe('function is on the Vue Prototype', () => {\n    it('exists as a function on Vue.prototype', () => {\n      expect(Vue.prototype.$elapsedPrettyExtended).to.exist\n      expect(Vue.prototype.$elapsedPrettyExtended).to.be.a('function')\n    })\n  })\n\n  describe('param default values', () => {\n    const testSeconds = DHMStoSeconds(0, 25, 1, 5) // 25h 1m 5s = 90065 seconds\n\n    it('uses useDays=true showSeconds=true by default', () => {\n      expect(elapsedPrettyExtended(testSeconds)).to.equal('1d 1h 1m 5s')\n    })\n\n    it('only useDays=false overrides useDays but keeps showSeconds=true', () => {\n      expect(elapsedPrettyExtended(testSeconds, false)).to.equal('25h 1m 5s')\n    })\n\n    it('explicit useDays=false showSeconds=false overrides both', () => {\n      expect(elapsedPrettyExtended(testSeconds, false, false)).to.equal('25h 1m')\n    })\n  })\n\n  describe('useDays=false showSeconds=true', () => {\n    const useDaysFalse = false\n    const showSecondsTrue = true\n    const testCases = [\n      [[0, 0, 0, 0], '', '0s -> \"\"'],\n      [[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],\n      [[0, 25, 0, 1], '25h 1s', '25h 1s -> 25h 1s']\n    ]\n\n    testCases.forEach(([dhms, expected, description]) => {\n      it(description, () => {\n        expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysFalse, showSecondsTrue)).to.equal(expected)\n      })\n    })\n  })\n\n  describe('useDays=true showSeconds=true', () => {\n    const useDaysTrue = true\n    const showSecondsTrue = true\n    const testCases = [\n      [[0, 0, 0, 0], '', '0s -> \"\"'],\n      [[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],\n      [[0, 25, 0, 1], '1d 1h 1s', '25h 1s -> 1d 1h 1s']\n    ]\n\n    testCases.forEach(([dhms, expected, description]) => {\n      it(description, () => {\n        expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)\n      })\n    })\n  })\n\n  describe('useDays=true showSeconds=false', () => {\n    const useDaysTrue = true\n    const showSecondsFalse = false\n    const testCases = [\n      [[0, 0, 0, 0], '', '0s -> \"\"'],\n      [[0, 1, 0, 0], '1h', '1h -> 1h'],\n      [[0, 1, 0, 1], '1h', '1h 1s -> 1h'],\n      [[0, 1, 1, 0], '1h 1m', '1h 1m -> 1h 1m'],\n      [[0, 25, 0, 0], '1d 1h', '25h -> 1d 1h'],\n      [[0, 25, 0, 1], '1d 1h', '25h 1s -> 1d 1h'],\n      [[2, 0, 0, 0], '2d', '2d -> 2d']\n    ]\n\n    testCases.forEach(([dhms, expected, description]) => {\n      it(description, () => {\n        expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)\n      })\n    })\n  })\n\n  describe('rounding useDays=true showSeconds=true', () => {\n    const useDaysTrue = true\n    const showSecondsTrue = true\n    const testCases = [\n      // Seconds rounding\n      [[0, 0, 0, 1], '1s', '1s -> 1s'],\n      [[0, 0, 0, 29.9], '30s', '29.9s -> 30s'],\n      [[0, 0, 0, 30], '30s', '30s -> 30s'],\n      [[0, 0, 0, 30.1], '30s', '30.1s -> 30s'],\n      [[0, 0, 0, 59.4], '59s', '59.4s -> 59s'],\n      [[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],\n\n      // Minutes rounding\n      [[0, 0, 59, 29], '59m 29s', '59m 29s -> 59m 29s'],\n      [[0, 0, 59, 30], '59m 30s', '59m 30s -> 59m 30s'],\n      [[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],\n\n      // Hours rounding\n      [[0, 23, 59, 29], '23h 59m 29s', '23h 59m 29s -> 23h 59m 29s'],\n      [[0, 23, 59, 30], '23h 59m 30s', '23h 59m 30s -> 23h 59m 30s'],\n      [[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],\n\n      // The actual bug case\n      [[44, 23, 59, 30], '44d 23h 59m 30s', '44d 23h 59m 30s -> 44d 23h 59m 30s']\n    ]\n\n    testCases.forEach(([dhms, expected, description]) => {\n      it(description, () => {\n        expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)\n      })\n    })\n  })\n\n  describe('rounding useDays=true showSeconds=false', () => {\n    const useDaysTrue = true\n    const showSecondsFalse = false\n    const testCases = [\n      // Seconds rounding - these cases changed behavior from original\n      [[0, 0, 0, 1], '', '1s -> \"\"'],\n      [[0, 0, 0, 29.9], '', '29.9s -> \"\"'],\n      [[0, 0, 0, 30], '', '30s -> \"\"'],\n      [[0, 0, 0, 30.1], '', '30.1s -> \"\"'],\n      [[0, 0, 0, 59.4], '', '59.4s -> \"\"'],\n      [[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],\n      // This is unexpected behavior, but it's consistent with the original behavior\n      // We preserved the test case, to document the current behavior\n      // - with showSeconds=false,\n      // one might expect: 1m 29.5s --round(1.4901m)-> 1m\n      // actual implementation: 1h 29.5s --roundSeconds-> 1h 30s --roundMinutes-> 2m\n      // So because of the separate rounding of seconds, and then minutes, it returns 2m\n      [[0, 0, 1, 29.5], '2m', '1m 29.5s -> 2m'],\n\n      // Minutes carry - actual bug fixes below\n      [[0, 0, 59, 29], '59m', '59m 29s -> 59m'],\n      [[0, 0, 59, 30], '1h', '59m 30s -> 1h'], // This was an actual bug, used to return 60m\n      [[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],\n\n      // Hours carry\n      [[0, 23, 59, 29], '23h 59m', '23h 59m 29s -> 23h 59m'],\n      [[0, 23, 59, 30], '1d', '23h 59m 30s -> 1d'], // This was an actual bug, used to return 23h 60m\n      [[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],\n\n      // The actual bug case\n      [[44, 23, 59, 30], '45d', '44d 23h 59m 30s -> 45d'] // This was an actual bug, used to return 44d 23h 60m\n    ]\n\n    testCases.forEach(([dhms, expected, description]) => {\n      it(description, () => {\n        expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)\n      })\n    })\n  })\n\n  describe('empty values', () => {\n    const paramCombos = [\n      // useDays, showSeconds, description\n      [true, true, 'with days and seconds'],\n      [true, false, 'with days, no seconds'],\n      [false, true, 'no days, with seconds'],\n      [false, false, 'no days, no seconds']\n    ]\n\n    const emptyInputs = [\n      // input, description\n      [null, 'null input'],\n      [undefined, 'undefined input'],\n      [0, 'zero'],\n      [0.49, 'rounds to zero'] // Just under rounding threshold\n    ]\n\n    paramCombos.forEach(([useDays, showSeconds, paramDesc]) => {\n      describe(paramDesc, () => {\n        emptyInputs.forEach(([input, desc]) => {\n          it(desc, () => {\n            expect(elapsedPrettyExtended(input, useDays, showSeconds)).to.equal('')\n          })\n        })\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "client/cypress.config.js",
    "content": "const { defineConfig } = require(\"cypress\")\n\nmodule.exports = defineConfig({\n  component: {\n    devServer: {\n      framework: \"nuxt\",\n      bundler: \"webpack\"\n    },\n    specPattern: \"cypress/tests/**/*.cy.js\"\n  }\n})\n"
  },
  {
    "path": "client/layouts/blank.vue",
    "content": "<template>\n  <div class=\"text-white max-h-screen h-screen overflow-hidden bg-bg\">\n    <Nuxt />\n  </div>\n</template>\n\n<script>\nexport default {\n  mounted() {\n    document.body.classList.remove('app-bar', 'app-bar-and-toolbar')\n    document.body.classList.add('no-bars')\n  }\n}\n</script>"
  },
  {
    "path": "client/layouts/default.vue",
    "content": "<template>\n  <div class=\"text-white max-h-screen h-screen overflow-hidden bg-bg\">\n    <app-appbar />\n\n    <app-side-rail v-if=\"isShowingSideRail\" class=\"hidden md:block\" />\n    <div id=\"app-content\" class=\"h-full\" :class=\"{ 'has-siderail': isShowingSideRail }\">\n      <Nuxt :key=\"currentLang\" />\n    </div>\n\n    <app-media-player-container ref=\"mediaPlayerContainer\" />\n\n    <modals-item-edit-modal />\n    <modals-collections-add-create-modal />\n    <modals-collections-edit-modal />\n    <modals-playlists-add-create-modal />\n    <modals-playlists-edit-modal />\n    <modals-podcast-edit-episode />\n    <modals-podcast-view-episode />\n    <modals-authors-edit-modal />\n    <modals-batch-quick-match-model />\n    <modals-rssfeed-open-close-modal />\n    <modals-raw-cover-preview-modal />\n    <modals-share-modal />\n    <prompt-confirm />\n    <readers-reader />\n  </div>\n</template>\n\n<script>\nexport default {\n  middleware: 'authenticated',\n  data() {\n    return {\n      socket: null,\n      isSocketConnected: false,\n      isSocketAuthenticated: false,\n      isFirstSocketConnection: true,\n      socketConnectionToastId: null,\n      currentLang: null,\n      multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast\n      multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast\n    }\n  },\n  watch: {\n    $route(newVal) {\n      if (this.$store.state.showEditModal) {\n        this.$store.commit('setShowEditModal', false)\n      }\n\n      this.$store.commit('globals/resetSelectedMediaItems', [])\n      this.updateBodyClass()\n    }\n  },\n  computed: {\n    user() {\n      return this.$store.state.user.user\n    },\n    isCasting() {\n      return this.$store.state.globals.isCasting\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    isShowingSideRail() {\n      if (!this.$route.name) return false\n      return !this.$route.name.startsWith('config') && this.currentLibraryId\n    },\n    isShowingToolbar() {\n      return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'\n    },\n    appContentMarginLeft() {\n      return this.isShowingSideRail ? 80 : 0\n    }\n  },\n  methods: {\n    updateBodyClass() {\n      if (this.isShowingToolbar) {\n        document.body.classList.remove('no-bars', 'app-bar')\n        document.body.classList.add('app-bar-and-toolbar')\n      } else {\n        document.body.classList.remove('no-bars', 'app-bar-and-toolbar')\n        document.body.classList.add('app-bar')\n      }\n    },\n    tokenRefreshed(newAccessToken) {\n      if (this.isSocketConnected && !this.isSocketAuthenticated) {\n        console.log('[SOCKET] Re-authenticating socket after token refresh')\n        this.socket.emit('auth', newAccessToken)\n      }\n    },\n    updateSocketConnectionToast(content, type, timeout) {\n      if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {\n        const toastUpdateOptions = {\n          content: content,\n          options: {\n            timeout: timeout,\n            type: type,\n            closeButton: false,\n            position: 'bottom-center',\n            onClose: () => {\n              this.socketConnectionToastId = null\n            },\n            closeOnClick: timeout !== null\n          }\n        }\n        this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false)\n      } else {\n        this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })\n      }\n    },\n    connect() {\n      console.log('[SOCKET] Connected')\n      var token = this.$store.getters['user/getToken']\n      this.socket.emit('auth', token)\n\n      if (!this.isFirstSocketConnection || this.socketConnectionToastId !== null) {\n        this.updateSocketConnectionToast(this.$strings.ToastSocketConnected, 'success', 5000)\n      }\n      this.isFirstSocketConnection = false\n      this.isSocketConnected = true\n    },\n    connectError() {\n      console.error('[SOCKET] connect error')\n      this.updateSocketConnectionToast(this.$strings.ToastSocketFailedToConnect, 'error', null)\n    },\n    disconnect() {\n      console.log('[SOCKET] Disconnected')\n      this.isSocketConnected = false\n      this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)\n    },\n    reconnect() {\n      console.log('[SOCKET] reconnected')\n    },\n    reconnectAttempt(val) {\n      console.log(`[SOCKET] reconnect attempt ${val}`)\n    },\n    reconnectError() {\n      // console.error('[SOCKET] reconnect error')\n    },\n    reconnectFailed() {\n      console.error('[SOCKET] reconnect failed')\n    },\n    authFailed(payload) {\n      console.error('[SOCKET] auth failed', payload.message)\n      this.isSocketAuthenticated = false\n    },\n    init(payload) {\n      console.log('Init Payload', payload)\n\n      if (payload.usersOnline) {\n        this.$store.commit('users/setUsersOnline', payload.usersOnline)\n      }\n\n      this.isSocketAuthenticated = true\n    },\n    streamOpen(stream) {\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)\n    },\n    streamClosed(streamId) {\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamClosed(streamId)\n    },\n    streamProgress(data) {\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamProgress(data)\n    },\n    streamReady() {\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReady()\n    },\n    streamReset(payload) {\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReset(payload)\n    },\n    streamError({ id, errorMessage }) {\n      this.$toast.error(`Stream Failed: ${errorMessage}`)\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamError(id)\n    },\n    libraryAdded(library) {\n      this.$store.commit('libraries/addUpdate', library)\n    },\n    libraryUpdated(library) {\n      this.$store.commit('libraries/addUpdate', library)\n    },\n    async libraryRemoved(library) {\n      console.log('Library removed', library)\n      this.$store.commit('libraries/remove', library)\n\n      // When removed currently selected library then set next accessible library\n      const currLibraryId = this.currentLibraryId\n      if (currLibraryId === library.id) {\n        var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']\n        if (nextLibrary) {\n          await this.$store.dispatch('libraries/fetch', nextLibrary.id)\n\n          if (this.$route.name.startsWith('config')) {\n            // No need to refresh\n          } else if (this.$route.name.startsWith('library')) {\n            var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)\n            this.$router.push(newRoute)\n          } else {\n            this.$router.push(`/library/${nextLibrary.id}`)\n          }\n        } else {\n          console.error('User has no more accessible libraries')\n          this.$store.commit('libraries/setCurrentLibrary', { id: null })\n        }\n      }\n    },\n    libraryItemAdded(libraryItem) {\n      this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)\n    },\n    libraryItemUpdated(libraryItem) {\n      if (this.$store.state.selectedLibraryItem?.id === libraryItem.id) {\n        this.$store.commit('setSelectedLibraryItem', libraryItem)\n        if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') {\n          const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id)\n          if (episode) {\n            this.$store.commit('globals/setSelectedEpisode', episode)\n          }\n        }\n      }\n      if (this.$store.state.streamLibraryItem?.id === libraryItem.id) {\n        this.$store.commit('updateStreamLibraryItem', libraryItem)\n      }\n      this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)\n      this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)\n    },\n    libraryItemRemoved(item) {\n      if (this.$route.name.startsWith('item')) {\n        if (this.$route.params.id === item.id) {\n          this.$router.replace(`/library/${this.currentLibraryId}`)\n        }\n      }\n    },\n    libraryItemsUpdated(libraryItems) {\n      libraryItems.forEach((li) => {\n        this.libraryItemUpdated(li)\n      })\n    },\n    libraryItemsAdded(libraryItems) {\n      libraryItems.forEach((ab) => {\n        this.libraryItemAdded(ab)\n      })\n    },\n    trackStarted(data) {\n      this.$store.commit('tasks/updateAudioFilesEncoding', { libraryItemId: data.libraryItemId, ino: data.ino, progress: '0%' })\n    },\n    trackProgress(data) {\n      this.$store.commit('tasks/updateAudioFilesEncoding', { libraryItemId: data.libraryItemId, ino: data.ino, progress: `${Math.round(data.progress)}%` })\n    },\n    trackFinished(data) {\n      this.$store.commit('tasks/updateAudioFilesEncoding', { libraryItemId: data.libraryItemId, ino: data.ino, progress: '100%' })\n      this.$store.commit('tasks/updateAudioFilesFinished', { libraryItemId: data.libraryItemId, ino: data.ino, finished: true })\n    },\n    taskStarted(task) {\n      console.log('Task started', task)\n      this.$store.commit('tasks/addUpdateTask', task)\n    },\n    taskFinished(task) {\n      console.log('Task finished', task)\n      this.$store.commit('tasks/addUpdateTask', task)\n    },\n    taskProgress(data) {\n      this.$store.commit('tasks/updateTaskProgress', { libraryItemId: data.libraryItemId, progress: `${Math.round(data.progress)}%` })\n    },\n    metadataEmbedQueueUpdate(data) {\n      if (data.queued) {\n        this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)\n      } else {\n        this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)\n      }\n    },\n    userUpdated(user) {\n      if (this.$store.state.user.user.id === user.id) {\n        this.$store.commit('user/setUser', user)\n      }\n    },\n    userOnline(user) {\n      this.$store.commit('users/updateUserOnline', user)\n    },\n    userOffline(user) {\n      this.$store.commit('users/removeUserOnline', user)\n    },\n    userStreamUpdate(user) {\n      this.$store.commit('users/updateUserOnline', user)\n    },\n    userSessionClosed(sessionId) {\n      // If this session or other session is closed then dismiss multiple sessions warning toast\n      if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {\n        this.multiSessionOtherSessionId = null\n        this.multiSessionCurrentSessionId = null\n        this.$toast.dismiss('multiple-sessions')\n      }\n      if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.sessionClosedEvent(sessionId)\n    },\n    userMediaProgressUpdate(payload) {\n      this.$store.commit('user/updateMediaProgress', payload)\n\n      if (payload.data) {\n        if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {\n          this.multiSessionOtherSessionId = payload.sessionId\n          this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId\n          console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)\n          if (this.$store.state.streamIsPlaying) {\n            this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)\n          } else {\n            this.$eventBus.$emit('playback-time-update', payload.data.currentTime)\n          }\n        }\n      }\n    },\n    collectionAdded(collection) {\n      if (this.currentLibraryId !== collection.libraryId) return\n      this.$store.commit('libraries/addUpdateCollection', collection)\n    },\n    collectionUpdated(collection) {\n      if (this.currentLibraryId !== collection.libraryId) return\n      this.$store.commit('libraries/addUpdateCollection', collection)\n    },\n    collectionRemoved(collection) {\n      if (this.currentLibraryId !== collection.libraryId) return\n      if (this.$route.name.startsWith('collection')) {\n        if (this.$route.params.id === collection.id) {\n          this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/collections`)\n        }\n      }\n      this.$store.commit('libraries/removeCollection', collection)\n    },\n    seriesRemoved({ id, libraryId }) {\n      if (this.currentLibraryId !== libraryId) return\n      this.$store.commit('libraries/removeSeriesFromFilterData', id)\n    },\n    playlistAdded(playlist) {\n      if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return\n      this.$store.commit('libraries/addUpdateUserPlaylist', playlist)\n    },\n    playlistUpdated(playlist) {\n      if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return\n      this.$store.commit('libraries/addUpdateUserPlaylist', playlist)\n    },\n    playlistRemoved(playlist) {\n      if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return\n\n      if (this.$route.name.startsWith('playlist')) {\n        if (this.$route.params.id === playlist.id) {\n          this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/playlists`)\n        }\n      }\n      this.$store.commit('libraries/removeUserPlaylist', playlist)\n    },\n    backupApplied() {\n      // Force refresh\n      location.reload()\n    },\n    batchQuickMatchComplete(result) {\n      var success = result.success || false\n      var toast = 'Batch quick match complete!\\n' + result.updates + ' Updated'\n      if (result.unmatched && result.unmatched > 0) {\n        toast += '\\n' + result.unmatched + ' with no matches'\n      }\n      if (success) {\n        this.$toast.success(toast)\n      } else {\n        this.$toast.info(toast)\n      }\n    },\n    adminMessageEvt(message) {\n      this.$toast.info(message)\n    },\n    ereaderDevicesUpdated(data) {\n      if (!data?.ereaderDevices) return\n\n      this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)\n    },\n    customMetadataProviderAdded(provider) {\n      if (!provider?.id) return\n      // Refresh providers cache\n      this.$store.dispatch('scanners/refreshProviders')\n    },\n    customMetadataProviderRemoved(provider) {\n      if (!provider?.id) return\n      // Refresh providers cache\n      this.$store.dispatch('scanners/refreshProviders')\n    },\n    initializeSocket() {\n      if (this.$root.socket) {\n        // Can happen in dev due to hot reload\n        console.warn('Socket already initialized')\n        this.socket = this.$root.socket\n        this.isSocketConnected = this.$root.socket?.connected\n        this.isFirstSocketConnection = false\n        this.socketConnectionToastId = null\n        return\n      }\n      this.socket = this.$nuxtSocket({\n        name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',\n        persist: 'main',\n        teardown: false,\n        transports: ['websocket'],\n        upgrade: false,\n        reconnection: true,\n        path: `${this.$config.routerBasePath}/socket.io`\n      })\n      this.$root.socket = this.socket\n      this.isSocketAuthenticated = false\n      console.log('Socket initialized')\n\n      // Pre-defined socket events\n      this.socket.on('connect', this.connect)\n      this.socket.on('connect_error', this.connectError)\n      this.socket.on('disconnect', this.disconnect)\n      this.socket.io.on('reconnect_attempt', this.reconnectAttempt)\n      this.socket.io.on('reconnect', this.reconnect)\n      this.socket.io.on('reconnect_error', this.reconnectError)\n      this.socket.io.on('reconnect_failed', this.reconnectFailed)\n\n      // Event received after authorizing socket\n      this.socket.on('init', this.init)\n      this.socket.on('auth_failed', this.authFailed)\n\n      // Stream Listeners\n      this.socket.on('stream_open', this.streamOpen)\n      this.socket.on('stream_closed', this.streamClosed)\n      this.socket.on('stream_progress', this.streamProgress)\n      this.socket.on('stream_ready', this.streamReady)\n      this.socket.on('stream_reset', this.streamReset)\n      this.socket.on('stream_error', this.streamError)\n\n      // Library Listeners\n      this.socket.on('library_updated', this.libraryUpdated)\n      this.socket.on('library_added', this.libraryAdded)\n      this.socket.on('library_removed', this.libraryRemoved)\n\n      // Library Item Listeners\n      this.socket.on('item_added', this.libraryItemAdded)\n      this.socket.on('item_updated', this.libraryItemUpdated)\n      this.socket.on('item_removed', this.libraryItemRemoved)\n      this.socket.on('items_updated', this.libraryItemsUpdated)\n      this.socket.on('items_added', this.libraryItemsAdded)\n\n      // User Listeners\n      this.socket.on('user_updated', this.userUpdated)\n      this.socket.on('user_online', this.userOnline)\n      this.socket.on('user_offline', this.userOffline)\n      this.socket.on('user_stream_update', this.userStreamUpdate)\n      this.socket.on('user_session_closed', this.userSessionClosed)\n      this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)\n\n      // Collection Listeners\n      this.socket.on('collection_added', this.collectionAdded)\n      this.socket.on('collection_updated', this.collectionUpdated)\n      this.socket.on('collection_removed', this.collectionRemoved)\n\n      // Series Listeners\n      this.socket.on('series_removed', this.seriesRemoved)\n\n      // User Playlist Listeners\n      this.socket.on('playlist_added', this.playlistAdded)\n      this.socket.on('playlist_updated', this.playlistUpdated)\n      this.socket.on('playlist_removed', this.playlistRemoved)\n\n      // Task Listeners\n      this.socket.on('task_started', this.taskStarted)\n      this.socket.on('task_finished', this.taskFinished)\n      this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)\n      this.socket.on('track_started', this.trackStarted)\n      this.socket.on('track_finished', this.trackFinished)\n      this.socket.on('track_progress', this.trackProgress)\n      this.socket.on('task_progress', this.taskProgress)\n\n      // EReader Device Listeners\n      this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)\n\n      this.socket.on('backup_applied', this.backupApplied)\n\n      this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)\n\n      this.socket.on('admin_message', this.adminMessageEvt)\n\n      // Custom metadata provider Listeners\n      this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)\n      this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)\n    },\n    showUpdateToast(versionData) {\n      var ignoreVersion = localStorage.getItem('ignoreVersion')\n      var latestVersion = versionData.latestVersion\n\n      if (!ignoreVersion || ignoreVersion !== latestVersion) {\n        this.$toast.info(`Update is available!\\nCheck release notes for v${versionData.latestVersion}`, {\n          position: 'top-center',\n          toastClassName: 'cursor-pointer',\n          bodyClassName: 'custom-class-1',\n          timeout: 20000,\n          closeOnClick: false,\n          draggable: false,\n          hideProgressBar: false,\n          onClick: () => {\n            window.open(versionData.githubTagUrl, '_blank')\n          },\n          onClose: () => {\n            localStorage.setItem('ignoreVersion', versionData.latestVersion)\n          }\n        })\n      } else {\n        console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)\n      }\n    },\n    checkActiveElementIsInput() {\n      const activeElement = document.activeElement\n      const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']\n      return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())\n    },\n    getHotkeyName(e) {\n      var keyCode = e.keyCode || e.which\n      if (!this.$keynames[keyCode]) {\n        // Unused hotkey\n        return null\n      }\n\n      var keyName = this.$keynames[keyCode]\n      var name = keyName\n      if (e.shiftKey) name = 'Shift-' + keyName\n      if (process.env.NODE_ENV !== 'production') {\n        console.log('Hotkey command', name)\n      }\n      return name\n    },\n    keyDown(e) {\n      var name = this.getHotkeyName(e)\n      if (!name) return\n\n      // Input is focused then ignore key press\n      if (this.checkActiveElementIsInput()) {\n        return\n      }\n\n      // Modal is open\n      if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {\n        this.$eventBus.$emit('modal-hotkey', name)\n        e.preventDefault()\n        return\n      }\n\n      // EReader is open\n      if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {\n        this.$eventBus.$emit('reader-hotkey', name)\n        e.preventDefault()\n        return\n      }\n\n      // Batch selecting\n      if (this.$store.getters['globals/getIsBatchSelectingMediaItems'] && name === 'Escape') {\n        // ESCAPE key cancels batch selection\n        this.$store.commit('globals/resetSelectedMediaItems', [])\n        this.$eventBus.$emit('bookshelf_clear_selection')\n        e.preventDefault()\n        return\n      }\n\n      // Playing audiobook\n      if (this.$store.state.streamLibraryItem && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {\n        this.$eventBus.$emit('player-hotkey', name)\n        e.preventDefault()\n      }\n    },\n    resize() {\n      this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })\n    },\n    checkVersionUpdate() {\n      this.$store\n        .dispatch('checkForUpdate')\n        .then((res) => {\n          if (res && res.hasUpdate) this.showUpdateToast(res)\n        })\n        .catch((err) => console.error(err))\n    },\n    initLocalStorage() {\n      // Queue auto play\n      var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')\n      this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')\n    },\n    loadTasks() {\n      this.$axios\n        .$get('/api/tasks?include=queue')\n        .then((payload) => {\n          console.log('Fetched tasks', payload)\n          if (payload.tasks) {\n            this.$store.commit('tasks/setTasks', payload.tasks)\n          }\n          if (payload.queuedTaskData?.embedMetadata?.length) {\n            this.$store.commit(\n              'tasks/setQueuedEmbedLIds',\n              payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)\n            )\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to load tasks', error)\n        })\n    },\n    changeLanguage(code) {\n      console.log('Changed lang', code)\n      this.currentLang = code\n      document.documentElement.lang = code\n    }\n  },\n  beforeMount() {\n    this.initializeSocket()\n  },\n  mounted() {\n    this.updateBodyClass()\n    this.resize()\n    this.$eventBus.$on('change-lang', this.changeLanguage)\n    this.$eventBus.$on('token_refreshed', this.tokenRefreshed)\n    window.addEventListener('resize', this.resize)\n    window.addEventListener('keydown', this.keyDown)\n\n    this.$store.dispatch('libraries/load')\n\n    this.initLocalStorage()\n\n    this.checkVersionUpdate()\n\n    this.loadTasks()\n\n    if (this.$route.query.error) {\n      this.$toast.error(this.$route.query.error)\n      this.$router.replace(this.$route.path)\n    }\n\n    // Set lang on HTML tag\n    if (this.$languageCodes?.current) {\n      document.documentElement.lang = this.$languageCodes.current\n    }\n  },\n  beforeDestroy() {\n    this.$eventBus.$off('change-lang', this.changeLanguage)\n    this.$eventBus.$off('token_refreshed', this.tokenRefreshed)\n    window.removeEventListener('resize', this.resize)\n    window.removeEventListener('keydown', this.keyDown)\n  }\n}\n</script>\n\n<style>\n.Vue-Toastification__toast-body.custom-class-1 {\n  font-size: 14px;\n}\n\n#app-content {\n  width: 100%;\n}\n#app-content.has-siderail {\n  width: calc(100% - 80px);\n  max-width: calc(100% - 80px);\n  margin-left: 80px;\n}\n@media (max-width: 768px) {\n  #app-content.has-siderail {\n    width: 100%;\n    max-width: 100%;\n    margin-left: 0px;\n  }\n}\n</style>\n"
  },
  {
    "path": "client/layouts/error.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"text-white max-h-screen h-screen overflow-hidden\">\n    <div class=\"absolute z-0 top-0 left-0 px-6 py-3\">\n      <div class=\"flex items-center\">\n        <nuxt-link to=\"/\">\n          <img src=\"~static/icon.svg\" alt=\"Audiobookshelf Logo\" class=\"w-10 min-w-10 h-10\" />\n        </nuxt-link>\n        <nuxt-link to=\"/\">\n          <h1 class=\"text-xl ml-4 hover:underline\">audiobookshelf</h1>\n        </nuxt-link>\n      </div>\n    </div>\n\n    <div class=\"w-full h-full flex items-center justify-center\">\n      <div class=\"w-full p-2 sm:p-4 md:p-8\">\n        <div class=\"w-full p-4\">\n          <div class=\"text-center\">\n            <h1 class=\"text-4xl font-semibold text-red-500 mb-4\">{{ statusCode }}</h1>\n            <p class=\"text-xl font-semibold\">{{ message }}</p>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  layout: 'blank',\n  props: {\n    error: {\n      type: Object,\n      default: null\n    }\n  },\n  computed: {\n    statusCode() {\n      return (this.error && this.error.statusCode) || 500\n    },\n    message() {\n      return this.error.message || 'Unknown error'\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/middleware/authenticated.js",
    "content": "export default function ({ store, redirect, route, app }) {\n  // If the user is not authenticated\n  if (!store.state.user.user) {\n    if (route.name === 'batch' || route.name === 'index') {\n      return redirect('/login')\n    }\n    return redirect(`/login?redirect=${encodeURIComponent(route.fullPath)}`)\n  }\n}"
  },
  {
    "path": "client/mixins/bookshelfCardsHelpers.js",
    "content": "import Vue from 'vue'\nimport LazyBookCard from '@/components/cards/LazyBookCard'\nimport LazySeriesCard from '@/components/cards/LazySeriesCard'\nimport LazyCollectionCard from '@/components/cards/LazyCollectionCard'\nimport LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'\nimport AuthorCard from '@/components/cards/AuthorCard'\n\nexport default {\n  data() {\n    return {\n      cardsHelpers: {\n        mountEntityCard: this.mountEntityCard,\n        setCardSize: this.setCardSize\n      }\n    }\n  },\n  methods: {\n    getComponentClass() {\n      if (this.entityName === 'series') return Vue.extend(LazySeriesCard)\n      if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)\n      if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)\n      if (this.entityName === 'authors') return Vue.extend(AuthorCard)\n      return Vue.extend(LazyBookCard)\n    },\n    getComponentName() {\n      if (this.entityName === 'series') return 'cards-lazy-series-card'\n      if (this.entityName === 'collections') return 'cards-lazy-collection-card'\n      if (this.entityName === 'playlists') return 'cards-lazy-playlist-card'\n      if (this.entityName === 'authors') return 'cards-author-card'\n      return 'cards-lazy-book-card'\n    },\n    async setCardSize() {\n      this.cardWidth = 0\n      this.cardHeight = 0\n      // load a dummy card to get the its width and height\n      const ComponentClass = this.getComponentClass()\n      const props = {\n        index: -1,\n        bookshelfView: this.bookshelfView,\n        sortingIgnorePrefix: !!this.sortingIgnorePrefix\n      }\n      if (this.entityName === 'items') {\n        props.filterBy = this.filterBy\n        props.orderBy = this.orderBy\n      } else if (this.entityName === 'series') {\n        props.orderBy = this.seriesSortBy\n      }\n      const instance = new ComponentClass({\n        propsData: props,\n        parent: this\n      })\n      instance.$mount()\n      this.resizeObserver = new ResizeObserver((entries) => {\n        for (let entry of entries) {\n          this.cardWidth = entry.borderBoxSize[0].inlineSize\n          this.cardHeight = entry.borderBoxSize[0].blockSize\n        }\n        this.coverHeight = instance.coverHeight\n        this.resizeObserver.disconnect()\n        this.$refs.bookshelf.removeChild(instance.$el)\n      })\n      instance.$el.style.visibility = 'hidden'\n      instance.$el.style.position = 'absolute'\n      this.$refs.bookshelf.appendChild(instance.$el)\n      this.resizeObserver.observe(instance.$el)\n      const timeBefore = performance.now()\n      await new Promise((resolve) => {\n        const unwatch = this.$watch('cardWidth', (value) => {\n          if (value) {\n            unwatch()\n            resolve()\n          }\n        })\n      })\n      const timeAfter = performance.now()\n    },\n    mountEntityCard(index) {\n      var shelf = Math.floor(index / this.entitiesPerShelf)\n      var shelfEl = document.getElementById(`shelf-${shelf}`)\n      if (!shelfEl) {\n        console.error('invalid shelf', shelf, 'book index', index)\n        return\n      }\n      this.entityIndexesMounted.push(index)\n      if (this.entityComponentRefs[index]) {\n        const bookComponent = this.entityComponentRefs[index]\n        shelfEl.appendChild(bookComponent.$el)\n        if (this.isSelectionMode) {\n          bookComponent.setSelectionMode(true)\n          if (this.selectedMediaItems.some((i) => i.id === bookComponent.libraryItemId) || this.isSelectAll) {\n            bookComponent.selected = true\n          } else {\n            bookComponent.selected = false\n          }\n        } else {\n          bookComponent.setSelectionMode(false)\n        }\n        bookComponent.isHovering = false\n        return\n      }\n      const ComponentClass = this.getComponentClass()\n\n      const props = {\n        index,\n        bookshelfView: this.bookshelfView,\n        sortingIgnorePrefix: !!this.sortingIgnorePrefix\n      }\n\n      if (this.entityName === 'items') {\n        props.filterBy = this.filterBy\n        props.orderBy = this.orderBy\n      } else if (this.entityName === 'series') {\n        props.orderBy = this.seriesSortBy\n      }\n\n      const _this = this\n      const instance = new ComponentClass({\n        propsData: props,\n        parent: this,\n        created() {\n          this.$on('edit', (entity, tab) => {\n            if (_this.editEntity) _this.editEntity(entity, tab)\n          })\n          this.$on('select', ({ entity, shiftKey }) => {\n            if (_this.selectEntity) _this.selectEntity(entity, shiftKey)\n          })\n        }\n      })\n      this.entityComponentRefs[index] = instance\n\n      instance.$mount()\n      instance.$el.style.transform = this.entityTransform((index % this.entitiesPerShelf) + 1)\n      instance.$el.classList.add('absolute', 'top-0', 'left-0', 'z-10')\n      shelfEl.appendChild(instance.$el)\n\n      if (this.entities[index]) {\n        instance.setEntity(this.entities[index])\n      }\n      if (this.isSelectionMode) {\n        instance.setSelectionMode(true)\n        if ((instance.libraryItemId && this.selectedMediaItems.some((i) => i.id === instance.libraryItemId)) || this.isSelectAll) {\n          instance.selected = true\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "client/mixins/menuKeyboardNavigation.js",
    "content": "/**\n * Mixin for keyboard navigation in dropdown menus.\n * This can be used in any component that has a dropdown menu with <li> items.\n * The following example shows how to use this mixin in your component:\n * <template>\n *   <div>\n *     <input type=\"text\" @keydown=\"menuNavigationHandler\">\n *     <ul ref=\"menu\">\n *       <li v-for=\"(item, index) in itemsToShow\" :key=\"index\" :class=\"isMenuItemSelected(item) ? ... : ''\" @click=\"clickedOption($event, item)\">\n *         {{ item }}\n *       </li>\n *     </ul>\n *   </div>\n * </template>\n *\n * This mixin assumes the following are defined in your component:\n * itemsToShow: Array of items to show in the dropdown\n * clickedOption: Event handler for when an item is clicked\n * submitForm: Event handler for when the form is submitted\n *\n * It also assumes you have a ref=\"menu\" on the menu element.\n */\nexport default {\n  data() {\n    return {\n      selectedMenuItemIndex: null\n    }\n  },\n  methods: {\n    menuNavigationHandler(event) {\n      let items = this.itemsToShow\n      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n        event.preventDefault()\n        if (!items.length) return\n        if (event.key === 'ArrowDown') {\n          if (this.selectedMenuItemIndex === null) {\n            this.selectedMenuItemIndex = 0\n          } else {\n            this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)\n          }\n        } else if (event.key === 'ArrowUp') {\n          if (this.selectedMenuItemIndex === null) {\n            this.selectedMenuItemIndex = items.length - 1\n          } else {\n            this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)\n          }\n        }\n        this.recalcScroll()\n      } else if (event.key === 'Enter') {\n        event.preventDefault()\n        if (this.selectedMenuItemIndex !== null) {\n          this.clickedOption(event, items[this.selectedMenuItemIndex])\n        } else {\n          this.submitForm()\n        }\n      } else {\n        this.selectedMenuItemIndex = null\n      }\n    },\n    recalcScroll() {\n      const menu = this.$refs.menu\n      if (!menu) return\n      var menuItems = menu.querySelectorAll('li')\n      if (!menuItems.length) return\n      var selectedItem = menuItems[this.selectedMenuItemIndex]\n      if (!selectedItem) return\n      var menuHeight = menu.offsetHeight\n      var itemHeight = selectedItem.offsetHeight\n      var itemTop = selectedItem.offsetTop\n      var itemBottom = itemTop + itemHeight\n      if (itemBottom > menu.scrollTop + menuHeight) {\n        let menuPaddingBottom = parseFloat(window.getComputedStyle(menu).paddingBottom)\n        menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom\n      } else if (itemTop < menu.scrollTop) {\n        let menuPaddingTop = parseFloat(window.getComputedStyle(menu).paddingTop)\n        menu.scrollTop = itemTop - menuPaddingTop\n      }\n    },\n    isMenuItemSelected(item) {\n      return this.selectedMenuItemIndex !== null && this.itemsToShow[this.selectedMenuItemIndex] === item\n    }\n  }\n}\n"
  },
  {
    "path": "client/mixins/uploadHelpers.js",
    "content": "import Path from 'path'\n\nexport default {\n  data() {\n    return {\n      uploadHelpers: {\n        getItemsFromDrop: this.getItemsFromDataTransferItems,\n        getItemsFromPicker: this.getItemsFromFilelist\n      }\n    }\n  },\n  methods: {\n    checkFileType(filename) {\n      var ext = Path.extname(filename)\n      if (!ext) return false\n      if (ext.startsWith('.')) ext = ext.slice(1)\n      ext = ext.toLowerCase()\n\n      for (const filetype in this.$constants.SupportedFileTypes) {\n        if (this.$constants.SupportedFileTypes[filetype].includes(ext)) {\n          return filetype\n        }\n      }\n      return false\n    },\n    filterItemFiles(files, mediaType) {\n      var validItemFiles = []\n      var validOtherFiles = []\n      var ignoredFiles = []\n      files.forEach((file) => {\n        if (!file.filetype) ignoredFiles.push(file)\n        else {\n          if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)\n          else validOtherFiles.push(file)\n        }\n      })\n\n      return {\n        itemFiles: validItemFiles,\n        otherFiles: validOtherFiles,\n        ignoredFiles\n      }\n    },\n    itemFromTreeItems(items, mediaType) {\n      var { itemFiles, otherFiles, ignoredFiles } = this.filterItemFiles(items, mediaType)\n      if (!itemFiles.length) {\n        ignoredFiles = ignoredFiles.concat(otherFiles)\n        otherFiles = []\n      }\n      return [\n        {\n          itemFiles,\n          otherFiles,\n          ignoredFiles\n        }\n      ]\n    },\n    traverseForItem(folder, mediaType, depth = 1) {\n      if (folder.items.some((f) => f.isDirectory)) {\n        var items = []\n        folder.items.forEach((file) => {\n          if (file.isDirectory) {\n            var itemResults = this.traverseForItem(file, mediaType, ++depth)\n            items = items.concat(itemResults)\n          }\n        })\n        return items\n      } else {\n        return this.itemFromTreeItems(folder.items, mediaType)\n      }\n    },\n    fileTreeToItems(filetree, mediaType) {\n      // Has directores - Is Multi Book Drop\n      if (filetree.some((f) => f.isDirectory)) {\n        var ignoredFilesInRoot = filetree.filter((f) => !f.isDirectory)\n        if (ignoredFilesInRoot.length) filetree = filetree.filter((f) => f.isDirectory)\n\n        var itemResults = this.traverseForItem({ items: filetree }, mediaType)\n        return {\n          items: itemResults,\n          ignoredFiles: ignoredFilesInRoot\n        }\n      } else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {\n        // Single Book drop\n        return {\n          items: this.itemFromTreeItems(filetree, mediaType),\n          ignoredFiles: []\n        }\n      } else {\n        // Only audio files dropped so treat each one as an audiobook\n        return {\n          items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),\n          ignoredFiles: []\n        }\n      }\n    },\n    getFilesDropped(dataTransferItems) {\n      var treemap = {\n        path: '/',\n        items: []\n      }\n      function traverseFileTreePromise(item, currtreemap, checkFileType) {\n        return new Promise((resolve) => {\n          if (item.isFile) {\n            item.file((file) => {\n              file.filepath = currtreemap.path + file.name //save full path\n              file.filetype = checkFileType(file.name)\n              currtreemap.items.push(file)\n              resolve(file)\n            })\n          } else if (item.isDirectory) {\n            let dirReader = item.createReader()\n            currtreemap.items.push({\n              isDirectory: true,\n              dirname: item.name,\n              path: currtreemap.path + item.name + '/',\n              items: []\n            })\n            var newtreemap = currtreemap.items[currtreemap.items.length - 1]\n\n            let entriesPromises = []\n            // readEntries returns 100 items max, continue calling readEntries until empty\n            function readEntries() {\n              dirReader.readEntries((entries) => {\n                if (entries.length > 0) {\n                  for (let entr of entries) {\n                    entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))\n                  }\n                  readEntries()\n                } else {\n                  resolve(Promise.all(entriesPromises))\n                }\n              })\n            }\n            readEntries()\n          }\n        })\n      }\n\n      return new Promise((resolve, reject) => {\n        let entriesPromises = []\n        for (let it of dataTransferItems) {\n          var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)\n          entriesPromises.push(filetree)\n        }\n        Promise.all(entriesPromises).then(() => {\n          resolve(treemap.items)\n        })\n      })\n    },\n    cleanBook(book, index) {\n      var audiobook = {\n        index,\n        title: '',\n        author: '',\n        series: '',\n        ...book\n      }\n      var firstBookFile = book.itemFiles[0]\n      if (!firstBookFile.filepath) {\n        return audiobook // No path\n      }\n\n      var firstBookPath = Path.dirname(firstBookFile.filepath)\n\n      var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.')\n      if (dirs.length) {\n        audiobook.title = dirs.pop()\n        if (dirs.length > 1) {\n          audiobook.series = dirs.pop()\n        }\n        if (dirs.length) {\n          audiobook.author = dirs.pop()\n        }\n      } else {\n        // Use file basename as title\n        audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))\n      }\n      return audiobook\n    },\n    cleanPodcast(item, index) {\n      var podcast = {\n        index,\n        title: '',\n        ...item\n      }\n      var firstAudioFile = podcast.itemFiles[0]\n      if (!firstAudioFile.filepath) return podcast // No path\n      var firstPath = Path.dirname(firstAudioFile.filepath)\n      var dirs = firstPath.split('/').filter((d) => !!d && d !== '.')\n      if (dirs.length) {\n        podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]\n      } else {\n        podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))\n      }\n\n      return podcast\n    },\n    cleanItem(item, mediaType, index) {\n      if (mediaType === 'podcast') return this.cleanPodcast(item, index)\n      return this.cleanBook(item, index)\n    },\n    async getItemsFromDataTransferItems(dataTransferItems, mediaType) {\n      var files = await this.getFilesDropped(dataTransferItems)\n      if (!files || !files.length) return { error: 'No files found ' }\n\n      var itemData = this.fileTreeToItems(files, mediaType)\n      if (!itemData.items.length && !itemData.ignoredFiles.length) {\n        return { error: 'Invalid file drop' }\n      }\n      var ignoredFiles = itemData.ignoredFiles\n      var index = 1\n      var items = itemData.items\n        .filter((ab) => {\n          if (!ab.itemFiles.length) {\n            if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)\n            if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)\n          }\n          return ab.itemFiles.length\n        })\n        .map((ab) => this.cleanItem(ab, mediaType, index++))\n      return {\n        items,\n        ignoredFiles\n      }\n    },\n    getItemsFromFilelist(filelist, mediaType) {\n      var ignoredFiles = []\n      var otherFiles = []\n\n      var itemMap = {}\n\n      filelist.forEach((file) => {\n        var filetype = this.checkFileType(file.name)\n        if (!filetype) ignoredFiles.push(file)\n        else {\n          file.filetype = filetype\n          if (file.webkitRelativePath) file.filepath = file.webkitRelativePath\n          else file.filepath = file.name\n\n          if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {\n            var dir = file.filepath ? Path.dirname(file.filepath) : ''\n            if (dir === '.') dir = ''\n\n            if (!itemMap[dir]) {\n              itemMap[dir] = {\n                path: dir,\n                ignoredFiles: [],\n                itemFiles: [],\n                otherFiles: []\n              }\n            }\n            itemMap[dir].itemFiles.push(file)\n          } else {\n            otherFiles.push(file)\n          }\n        }\n      })\n\n      otherFiles.forEach((file) => {\n        var dir = Path.dirname(file.filepath)\n        var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path))\n        if (findItem) {\n          findItem.otherFiles.push(file)\n        } else {\n          ignoredFiles.push(file)\n        }\n      })\n\n      var items = []\n      var index = 1\n      // If book media type and all files are audio files then treat each one as an audiobook\n      if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) {\n        items = itemMap[''].itemFiles.map((audioFile) => {\n          return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)\n        })\n      } else {\n        items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++))\n      }\n\n      return {\n        items,\n        ignoredFiles: ignoredFiles\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "client/nuxt.config.js",
    "content": "const pkg = require('./package.json')\n\nconst routerBasePath = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'\nconst serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333'\nconst serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init']\nconst proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))\n\nmodule.exports = {\n  // Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode\n  ssr: false,\n  target: 'static',\n  dev: process.env.NODE_ENV !== 'production',\n  env: {\n    serverUrl: serverHostUrl + routerBasePath,\n    chromecastReceiver: 'FD1F76C5'\n  },\n  telemetry: false,\n\n  publicRuntimeConfig: {\n    version: pkg.version,\n    routerBasePath\n  },\n\n  // Global page headers: https://go.nuxtjs.dev/config-head\n  head: {\n    title: 'Audiobookshelf',\n    htmlAttrs: {\n      lang: 'en'\n    },\n    meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { hid: 'description', name: 'description', content: '' }, { hid: 'robots', name: 'robots', content: 'noindex' }],\n    script: [],\n    link: [\n      { rel: 'icon', type: 'image/x-icon', href: routerBasePath + '/favicon.ico' },\n      { rel: 'apple-touch-icon', href: routerBasePath + '/ios_icon.png' }\n    ]\n  },\n\n  router: {\n    base: routerBasePath\n  },\n\n  // Global CSS: https://go.nuxtjs.dev/config-css\n  css: ['@/assets/tailwind.css', '@/assets/app.css'],\n\n  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins\n  plugins: ['@/plugins/constants.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/toast.js', '@/plugins/utils.js', '@/plugins/i18n.js'],\n\n  // Auto import components: https://go.nuxtjs.dev/config-components\n  components: true,\n\n  // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules\n  buildModules: [\n    // https://go.nuxtjs.dev/tailwindcss\n    '@nuxtjs/pwa'\n  ],\n\n  // Modules: https://go.nuxtjs.dev/config-modules\n  modules: ['nuxt-socket-io', '@nuxtjs/axios', '@nuxtjs/proxy'],\n\n  proxy,\n\n  io: {\n    sockets: [\n      {\n        name: 'dev',\n        url: serverHostUrl\n      },\n      {\n        name: 'prod'\n      }\n    ]\n  },\n\n  // Axios module configuration: https://go.nuxtjs.dev/config-axios\n  axios: {\n    baseURL: routerBasePath,\n    progress: false\n  },\n\n  // nuxt/pwa https://pwa.nuxtjs.org\n  pwa: {\n    icon: false,\n    meta: {\n      appleStatusBarStyle: 'black',\n      name: 'Audiobookshelf',\n      theme_color: '#232323',\n      mobileAppIOS: true,\n      nativeUI: true\n    },\n    manifest: {\n      name: 'Audiobookshelf',\n      short_name: 'Audiobookshelf',\n      display: 'standalone',\n      background_color: '#232323',\n      icons: [\n        {\n          src: routerBasePath + '/icon.svg',\n          sizes: 'any'\n        },\n        {\n          src: routerBasePath + '/icon192.png',\n          type: 'image/png',\n          sizes: 'any'\n        }\n      ]\n    },\n    workbox: {\n      offline: false,\n      cacheAssets: false,\n      preCaching: [],\n      runtimeCaching: []\n    }\n  },\n\n  // Build Configuration: https://go.nuxtjs.dev/config-build\n  build: {},\n  watchers: {\n    webpack: {\n      aggregateTimeout: 300,\n      poll: 1000\n    }\n  },\n  server: {\n    port: process.env.NODE_ENV === 'production' ? 80 : 3000,\n    host: '0.0.0.0'\n  },\n\n  /**\n   * Temporary workaround for @nuxt-community/tailwindcss-module.\n   *\n   * Reported: 2022-05-23\n   * See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)\n   */\n  devServerHandlers: [],\n\n  ignore: ['**/*.test.*', '**/*.cy.*']\n}\n"
  },
  {
    "path": "client/package.json",
    "content": "{\n  \"name\": \"audiobookshelf-client\",\n  \"version\": \"2.33.1\",\n  \"buildNumber\": 1,\n  \"description\": \"Self-hosted audiobook and podcast client\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"dev\": \"nuxt\",\n    \"dev2\": \"nuxt --hostname localhost --port 1337\",\n    \"build\": \"nuxt build\",\n    \"start\": \"nuxt start\",\n    \"generate\": \"nuxt generate\",\n    \"test\": \"npm run compile-tailwind && cypress run --component --browser chrome\",\n    \"test-visually\": \"npm run compile-tailwind && cypress open --component --browser chrome\",\n    \"compile-tailwind\": \"npx @tailwindcss/cli -i ./assets/tailwind.css -o ./cypress/support/tailwind.compiled.css\"\n  },\n  \"author\": \"advplyr\",\n  \"license\": \"ISC\",\n  \"dependencies\": {\n    \"@nuxtjs/axios\": \"^5.13.6\",\n    \"@nuxtjs/proxy\": \"^2.1.0\",\n    \"@tailwindcss/postcss\": \"^4.0.13\",\n    \"@teckel/vue-pdf\": \"^4.3.5\",\n    \"core-js\": \"^3.16.0\",\n    \"cron-parser\": \"^4.7.1\",\n    \"date-fns\": \"^2.25.0\",\n    \"epubjs\": \"^0.3.88\",\n    \"fast-average-color\": \"^9.4.0\",\n    \"hls.js\": \"^1.5.7\",\n    \"libarchive.js\": \"^1.3.0\",\n    \"nuxt\": \"^2.18.1\",\n    \"nuxt-socket-io\": \"^1.1.18\",\n    \"trix\": \"^1.3.1\",\n    \"v-click-outside\": \"^3.1.2\",\n    \"vue-toastification\": \"^1.7.11\",\n    \"vuedraggable\": \"^2.24.3\"\n  },\n  \"devDependencies\": {\n    \"@nuxtjs/pwa\": \"^3.3.5\",\n    \"@tailwindcss/cli\": \"^4.0.14\",\n    \"postcss\": \"^8.3.6\",\n    \"tailwindcss\": \"^4.0.13\"\n  },\n  \"optionalDependencies\": {\n    \"cypress\": \"^13.7.3\"\n  }\n}\n"
  },
  {
    "path": "client/pages/account.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"page p-6 overflow-y-auto relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"w-full max-w-xl mx-auto\">\n      <h1 class=\"text-2xl\">{{ $strings.HeaderAccount }}</h1>\n\n      <div class=\"my-4\">\n        <div class=\"flex -mx-2\">\n          <div class=\"w-2/3 px-2\">\n            <ui-text-input-with-label disabled :value=\"username\" :label=\"$strings.LabelUsername\" />\n          </div>\n          <div class=\"w-1/3 px-2\">\n            <ui-text-input-with-label disabled :value=\"usertype\" :label=\"$strings.LabelAccountType\" />\n          </div>\n        </div>\n        <div class=\"py-4\">\n          <p class=\"px-1 text-sm font-semibold\">{{ $strings.LabelLanguage }}</p>\n          <ui-dropdown v-model=\"selectedLanguage\" :items=\"$languageCodeOptions\" small class=\"max-w-48\" @input=\"updateLocalLanguage\" />\n        </div>\n\n        <div class=\"w-full h-px bg-white/10 my-4\" />\n\n        <p v-if=\"showChangePasswordForm\" class=\"mb-4 text-lg\">{{ $strings.HeaderChangePassword }}</p>\n        <form v-if=\"showChangePasswordForm\" @submit.prevent=\"submitChangePassword\">\n          <ui-text-input-with-label v-model=\"password\" :disabled=\"changingPassword\" type=\"password\" :label=\"$strings.LabelPassword\" class=\"my-2\" />\n          <ui-text-input-with-label v-model=\"newPassword\" :disabled=\"changingPassword\" type=\"password\" :label=\"$strings.LabelNewPassword\" class=\"my-2\" />\n          <ui-text-input-with-label v-model=\"confirmPassword\" :disabled=\"changingPassword\" type=\"password\" :label=\"$strings.LabelConfirmPassword\" class=\"my-2\" />\n          <div class=\"flex items-center py-2\">\n            <p v-if=\"isRoot\" class=\"text-error py-2 text-xs\">* {{ $strings.NoteChangeRootPassword }}</p>\n            <div class=\"grow\" />\n            <ui-btn v-show=\"(password && newPassword && confirmPassword) || isRoot\" type=\"submit\" :loading=\"changingPassword\" color=\"bg-success\">{{ $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </form>\n      </div>\n\n      <div v-if=\"showEreaderTable\">\n        <div class=\"w-full h-px bg-white/10 my-4\" />\n\n        <app-settings-content :header-text=\"$strings.HeaderEreaderDevices\">\n          <template #header-items>\n            <div class=\"grow\" />\n\n            <ui-btn color=\"bg-primary\" small @click=\"addNewDeviceClick\">{{ $strings.ButtonAddDevice }}</ui-btn>\n          </template>\n\n          <table v-if=\"ereaderDevices.length\" class=\"tracksTable mt-4\">\n            <tr>\n              <th class=\"text-left\">{{ $strings.LabelName }}</th>\n              <th class=\"text-left\">{{ $strings.LabelEmail }}</th>\n              <th class=\"w-40\"></th>\n            </tr>\n            <tr v-for=\"device in ereaderDevices\" :key=\"device.name\">\n              <td>\n                <p class=\"text-sm md:text-base text-gray-100\">{{ device.name }}</p>\n              </td>\n              <td class=\"text-left\">\n                <p class=\"text-sm md:text-base text-gray-100\">{{ device.email }}</p>\n              </td>\n              <td class=\"w-40\">\n                <div class=\"flex justify-end items-center h-10\">\n                  <ui-icon-btn icon=\"edit\" borderless :size=\"8\" icon-font-size=\"1.1rem\" :disabled=\"deletingDeviceName === device.name || device.users?.length !== 1\" class=\"mx-1\" @click=\"editDeviceClick(device)\" />\n                  <ui-icon-btn icon=\"delete\" borderless :size=\"8\" icon-font-size=\"1.1rem\" :disabled=\"deletingDeviceName === device.name || device.users?.length !== 1\" @click=\"deleteDeviceClick(device)\" />\n                </div>\n              </td>\n            </tr>\n          </table>\n          <div v-else-if=\"!loading\" class=\"text-center py-4\">\n            <p class=\"text-lg text-gray-100\">{{ $strings.MessageNoDevices }}</p>\n          </div>\n        </app-settings-content>\n      </div>\n\n      <div class=\"py-4 mt-8 flex\">\n        <ui-btn color=\"bg-primary flex items-center text-lg\" @click=\"logout\"><span class=\"material-symbols mr-4 icon-text\">logout</span>{{ $strings.ButtonLogout }}</ui-btn>\n      </div>\n\n      <modals-emails-user-e-reader-device-modal v-model=\"showEReaderDeviceModal\" :existing-devices=\"revisedEreaderDevices\" :ereader-device=\"selectedEReaderDevice\" @update=\"ereaderDevicesUpdated\" />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      loading: false,\n      password: null,\n      newPassword: null,\n      confirmPassword: null,\n      changingPassword: false,\n      selectedLanguage: '',\n      newEReaderDevice: {\n        name: '',\n        email: ''\n      },\n      ereaderDevices: [],\n      deletingDeviceName: null,\n      selectedEReaderDevice: null,\n      showEReaderDeviceModal: false\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    user() {\n      return this.$store.state.user.user || null\n    },\n    username() {\n      return this.user.username\n    },\n    usertype() {\n      return this.user.type\n    },\n    isRoot() {\n      return this.usertype === 'root'\n    },\n    isGuest() {\n      return this.usertype === 'guest'\n    },\n    isPasswordAuthEnabled() {\n      const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []\n      return activeAuthMethods.includes('local')\n    },\n    showChangePasswordForm() {\n      return !this.isGuest && this.isPasswordAuthEnabled\n    },\n    showEreaderTable() {\n      return this.usertype !== 'root' && this.usertype !== 'admin' && this.user.permissions?.createEreader\n    },\n    revisedEreaderDevices() {\n      return this.ereaderDevices.filter((device) => device.users?.length === 1)\n    }\n  },\n  methods: {\n    updateLocalLanguage(lang) {\n      this.$setLanguageCode(lang)\n    },\n    logout() {\n      // Disconnect from socket\n      if (this.$root.socket) {\n        console.log('Disconnecting from socket', this.$root.socket.id)\n        this.$root.socket.removeAllListeners()\n        this.$root.socket.disconnect()\n      }\n\n      if (localStorage.getItem('token')) {\n        localStorage.removeItem('token')\n      }\n      this.$store.commit('libraries/setUserPlaylists', [])\n      this.$store.commit('libraries/setCollections', [])\n\n      this.$axios\n        .$post('/logout')\n        .then((logoutPayload) => {\n          const redirect_url = logoutPayload.redirect_url\n\n          if (redirect_url) {\n            window.location.href = redirect_url\n          } else {\n            this.$router.push('/login')\n          }\n        })\n        .catch((error) => {\n          console.error(error)\n        })\n    },\n    resetForm() {\n      this.password = null\n      this.newPassword = null\n      this.confirmPassword = null\n    },\n    submitChangePassword() {\n      if (this.newPassword !== this.confirmPassword) {\n        return this.$toast.error(this.$strings.ToastUserPasswordMismatch)\n      }\n      if (this.password === this.newPassword) {\n        return this.$toast.error(this.$strings.ToastUserPasswordMustChange)\n      }\n      this.changingPassword = true\n      this.$axios\n        .$patch('/api/me/password', {\n          password: this.password,\n          newPassword: this.newPassword\n        })\n        .then(() => {\n          this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)\n          this.resetForm()\n        })\n        .catch((error) => {\n          console.error('Failed to change password', error)\n          let errorMessage = this.$strings.ToastUnknownError\n          if (error.response?.data && typeof error.response.data === 'string') {\n            errorMessage = error.response.data\n          }\n          this.$toast.error(errorMessage)\n        })\n        .finally(() => {\n          this.changingPassword = false\n        })\n    },\n    addNewDeviceClick() {\n      this.selectedEReaderDevice = null\n      this.showEReaderDeviceModal = true\n    },\n    editDeviceClick(device) {\n      this.selectedEReaderDevice = device\n      this.showEReaderDeviceModal = true\n    },\n    deleteDeviceClick(device) {\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteDevice', [device.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteDevice(device)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteDevice(device) {\n      const payload = {\n        ereaderDevices: this.revisedEreaderDevices.filter((d) => d.name !== device.name)\n      }\n      this.deletingDeviceName = device.name\n      this.$axios\n        .$post(`/api/me/ereader-devices`, payload)\n        .then((data) => {\n          this.ereaderDevicesUpdated(data.ereaderDevices)\n        })\n        .catch((error) => {\n          console.error('Failed to delete device', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.deletingDeviceName = null\n        })\n    },\n    ereaderDevicesUpdated(ereaderDevices) {\n      this.ereaderDevices = ereaderDevices\n    }\n  },\n  mounted() {\n    this.selectedLanguage = this.$languageCodes.current\n    this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/audiobook/_id/chapters.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page overflow-y-auto relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"flex items-center py-4 px-4 max-w-7xl mx-auto\">\n      <nuxt-link :to=\"`/item/${libraryItem.id}`\" class=\"hover:underline\">\n        <h1 class=\"text-lg lg:text-xl\">{{ title }}</h1>\n      </nuxt-link>\n      <button class=\"w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white\" @click=\"editItem\">\n        <span class=\"material-symbols text-base\">edit</span>\n      </button>\n      <div class=\"grow hidden md:block\" />\n      <p class=\"text-base hidden md:block\">{{ $strings.LabelDuration }}:</p>\n      <p class=\"text-base font-mono ml-4 hidden md:block\">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>\n    </div>\n\n    <div class=\"flex flex-wrap-reverse min-[1120px]:flex-nowrap justify-center py-4 px-4\">\n      <div class=\"w-full max-w-3xl py-4\">\n        <div class=\"flex items-center\">\n          <div class=\"w-12 hidden min-w-[1120px]:block\" />\n          <p class=\"text-lg mb-4 font-semibold\">{{ $strings.HeaderChapters }}</p>\n          <div class=\"grow\" />\n          <ui-checkbox v-model=\"showSecondInputs\" checkbox-bg=\"primary\" small label-class=\"text-sm text-gray-200 pl-1\" :label=\"$strings.LabelShowSeconds\" class=\"mx-2\" />\n          <div class=\"w-32 hidden min-[1120px]:block\" />\n        </div>\n        <div class=\"flex items-center mb-3 py-1 -mx-1\">\n          <div class=\"w-12 hidden min-[1120px]:block\" />\n          <ui-btn v-if=\"chapters.length\" color=\"bg-primary\" small class=\"mx-1 whitespace-nowrap\" @click.stop=\"removeAllChaptersClick\">{{ $strings.ButtonRemoveAll }}</ui-btn>\n          <ui-btn v-if=\"newChapters.length > 1\" :color=\"showShiftTimes ? 'bg-bg' : 'bg-primary'\" class=\"mx-1 whitespace-nowrap\" small @click=\"showShiftTimes = !showShiftTimes\">{{ $strings.ButtonShiftTimes }}</ui-btn>\n          <ui-btn color=\"bg-primary\" small :class=\"{ 'mx-1': newChapters.length > 1 }\" @click=\"showFindChaptersModal = true\">{{ $strings.ButtonLookup }}</ui-btn>\n          <div class=\"grow\" />\n          <ui-btn v-if=\"hasChanges\" small class=\"mx-1\" @click.stop=\"resetChapters\">{{ $strings.ButtonReset }}</ui-btn>\n          <ui-btn v-if=\"hasChanges\" color=\"bg-success\" class=\"mx-1\" :disabled=\"!hasChanges\" small @click=\"saveChapters\">{{ $strings.ButtonSave }}</ui-btn>\n          <div class=\"w-32 hidden min-[1120px]:block\" />\n        </div>\n\n        <div class=\"overflow-hidden\">\n          <transition name=\"slide\">\n            <div v-if=\"showShiftTimes\" class=\"flex mb-4\">\n              <div class=\"w-12 hidden lg:block\" />\n              <div class=\"grow\">\n                <div class=\"flex items-center\">\n                  <p class=\"text-sm mb-1 font-semibold pr-2\">{{ $strings.LabelTimeToShift }}</p>\n                  <ui-text-input v-model=\"shiftAmount\" type=\"number\" class=\"max-w-20\" style=\"height: 30px\" />\n                  <ui-btn color=\"bg-primary\" class=\"mx-1\" small @click=\"shiftChapterTimes\">{{ $strings.ButtonAdd }}</ui-btn>\n                  <div class=\"grow\" />\n                  <span class=\"material-symbols text-gray-200 hover:text-white cursor-pointer\" @click=\"showShiftTimes = false\">expand_less</span>\n                </div>\n                <p class=\"text-xs py-1.5 text-gray-300 max-w-md\">{{ $strings.NoteChapterEditorTimes }}</p>\n              </div>\n              <div class=\"w-32 hidden lg:block\" />\n            </div>\n          </transition>\n        </div>\n\n        <div class=\"flex text-xs uppercase text-gray-300 font-semibold mb-2\">\n          <div class=\"w-8 min-w-8 md:w-12 md:min-w-12\"></div>\n          <div class=\"w-38 min-w-38 md:w-40 md:min-w-40 px-1 pl-8\">{{ $strings.LabelStart }}</div>\n          <div class=\"grow px-1 min-w-54\">{{ $strings.LabelTitle }}</div>\n          <div class=\"w-7 min-w-7 px-1 flex items-center justify-center\">\n            <ui-tooltip :text=\"allChaptersLocked ? $strings.TooltipUnlockAllChapters : $strings.TooltipLockAllChapters\" direction=\"bottom\">\n              <button class=\"w-7 h-7 rounded-full flex items-center justify-center cursor-pointer transition-colors duration-150\" :class=\"allChaptersLocked ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'\" @click=\"toggleAllChaptersLock\">\n                <span class=\"material-symbols text-xl\">{{ allChaptersLocked ? 'lock' : 'lock_open' }}</span>\n              </button>\n            </ui-tooltip>\n          </div>\n          <div class=\"w-32\"></div>\n        </div>\n        <div v-for=\"chapter in newChapters\" :key=\"chapter.id\" class=\"flex py-1\">\n          <div class=\"w-8 min-w-8 md:w-12 md:min-w-12\">#{{ chapter.id + 1 }}</div>\n          <div class=\"w-38 min-w-38 md:w-40 md:min-w-40 px-1\">\n            <div class=\"flex items-center gap-1\">\n              <ui-tooltip :text=\"$strings.TooltipSubtractOneSecond\" direction=\"bottom\">\n                <button\n                  class=\"w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0\"\n                  :class=\"{ 'opacity-50 cursor-not-allowed': chapter.id === 0 && chapter.start - timeIncrementAmount < 0 }\"\n                  @click=\"incrementChapterTime(chapter, -timeIncrementAmount)\"\n                  :disabled=\"chapter.id === 0 && chapter.start - timeIncrementAmount < 0\"\n                >\n                  <span class=\"material-symbols text-sm\">remove</span>\n                </button>\n              </ui-tooltip>\n\n              <div class=\"flex-1 min-w-0\">\n                <ui-text-input v-if=\"showSecondInputs\" v-model=\"chapter.start\" type=\"number\" class=\"text-xs\" @change=\"checkChapters\" />\n                <ui-time-picker v-else class=\"text-xs\" v-model=\"chapter.start\" :show-three-digit-hour=\"mediaDuration >= 360000\" @change=\"checkChapters\" />\n              </div>\n\n              <ui-tooltip :text=\"$strings.TooltipAddOneSecond\" direction=\"bottom\">\n                <button class=\"w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0\" :class=\"{ 'opacity-50 cursor-not-allowed': chapter.start + timeIncrementAmount >= mediaDuration }\" @click=\"incrementChapterTime(chapter, timeIncrementAmount)\" :disabled=\"chapter.start + timeIncrementAmount >= mediaDuration\">\n                  <span class=\"material-symbols text-sm\">add</span>\n                </button>\n              </ui-tooltip>\n            </div>\n          </div>\n          <div class=\"grow px-1\">\n            <ui-text-input v-model=\"chapter.title\" @change=\"checkChapters\" class=\"text-xs min-w-52\" />\n          </div>\n          <div class=\"w-7 min-w-7 px-1 py-1\">\n            <div class=\"flex items-center justify-center\">\n              <ui-tooltip :text=\"lockedChapters.has(chapter.id) ? $strings.TooltipUnlockChapter : $strings.TooltipLockChapter\" direction=\"bottom\">\n                <button class=\"w-7 h-7 rounded-full flex items-center justify-center transform hover:scale-110 duration-150 flex-shrink-0\" :class=\"lockedChapters.has(chapter.id) ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'\" @click=\"toggleChapterLock(chapter, $event)\">\n                  <span class=\"material-symbols text-base\">{{ lockedChapters.has(chapter.id) ? 'lock' : 'lock_open' }}</span>\n                </button>\n              </ui-tooltip>\n            </div>\n          </div>\n          <div class=\"w-32 min-w-32 px-2 py-1\">\n            <div class=\"flex items-center\">\n              <ui-tooltip :text=\"$strings.MessageRemoveChapter\" direction=\"bottom\">\n                <button v-if=\"newChapters.length > 1\" class=\"w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150\" @click=\"removeChapter(chapter)\">\n                  <span class=\"material-symbols text-base\">delete</span>\n                </button>\n              </ui-tooltip>\n\n              <ui-tooltip :text=\"$strings.MessageInsertChapterBelow\" direction=\"bottom\">\n                <button class=\"w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150\" @click=\"addChapter(chapter)\">\n                  <span class=\"material-symbols text-lg\">add_row_below</span>\n                </button>\n              </ui-tooltip>\n              <ui-tooltip :text=\"selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter\" direction=\"bottom\">\n                <button :disabled=\"!getAudioTrackForTime(chapter.start)\" class=\"w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 disabled:opacity-50 disabled:cursor-not-allowed\" @click=\"playChapter(chapter)\">\n                  <widgets-loading-spinner v-if=\"selectedChapterId === chapter.id && isLoadingChapter\" />\n                  <span v-else-if=\"selectedChapterId === chapter.id && isPlayingChapter\" class=\"material-symbols text-base\">pause</span>\n                  <span v-else class=\"material-symbols text-xl\">play_arrow</span>\n                </button>\n              </ui-tooltip>\n              <ui-tooltip v-if=\"selectedChapterId === chapter.id && (isPlayingChapter || isLoadingChapter)\" :text=\"$strings.TooltipAdjustChapterStart\" direction=\"bottom\">\n                <div class=\"ml-2 text-xs text-gray-300 font-mono min-w-10 cursor-pointer hover:text-white transition-colors duration-150\" @click=\"adjustChapterStartTime(chapter)\">{{ elapsedTime }}s</div>\n              </ui-tooltip>\n              <ui-tooltip v-if=\"chapter.error\" :text=\"chapter.error\" plaintext direction=\"left\">\n                <button class=\"w-7 h-7 rounded-full flex items-center justify-center text-error\">\n                  <span class=\"material-symbols text-lg\">error_outline</span>\n                </button>\n              </ui-tooltip>\n            </div>\n          </div>\n        </div>\n        <div class=\"flex items-center mt-4 mb-2\">\n          <div class=\"w-8 min-w-8 md:w-12 md:min-w-12\"></div>\n          <div class=\"w-38 min-w-38 md:w-40 md:min-w-40 px-1\"></div>\n          <div class=\"flex items-center gap-2 grow px-1\">\n            <ui-text-input v-model=\"bulkChapterInput\" :placeholder=\"$strings.PlaceholderBulkChapterInput\" class=\"text-xs grow min-w-52\" @keyup.enter=\"handleBulkChapterAdd\" />\n          </div>\n          <div class=\"w-39 min-w-39 px-1 py-1\">\n            <ui-tooltip :text=\"$strings.TooltipAddChapters\" direction=\"bottom\" class=\"inline-block align-middle\">\n              <button class=\"w-5 h-5 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150 flex-shrink-0\" :aria-label=\"$strings.TooltipAddChapters\" :class=\"{ 'opacity-50 cursor-not-allowed': !bulkChapterInput.trim() }\" :disabled=\"!bulkChapterInput.trim()\" @click=\"handleBulkChapterAdd\">\n                <span class=\"material-symbols text-lg\">add</span>\n              </button>\n            </ui-tooltip>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"w-full max-w-3xl min-[1120px]:max-w-xl py-4 px-2\">\n        <div class=\"flex items-center mb-4 py-1\">\n          <p class=\"text-lg font-semibold\">{{ $strings.HeaderAudioTracks }}</p>\n          <div class=\"grow\" />\n          <ui-btn small @click=\"setChaptersFromTracks\">{{ $strings.ButtonSetChaptersFromTracks }}</ui-btn>\n          <ui-tooltip :text=\"$strings.MessageSetChaptersFromTracksDescription\" direction=\"top\" class=\"flex items-center mx-1 cursor-default\">\n            <span class=\"material-symbols text-xl text-gray-200\">info</span>\n          </ui-tooltip>\n        </div>\n        <div class=\"flex text-xs uppercase text-gray-300 font-semibold mb-2\">\n          <div class=\"grow min-[1120px]:max-w-64 xl:max-w-sm\">{{ $strings.LabelFilename }}</div>\n          <div class=\"w-20\">{{ $strings.LabelDuration }}</div>\n          <div class=\"w-20 hidden md:block text-center\">{{ $strings.HeaderChapters }}</div>\n        </div>\n        <div v-for=\"track in audioTracks\" :key=\"track.ino\" class=\"flex items-center py-2\" :class=\"currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''\">\n          <div class=\"pr-2 grow min-[1120px]:max-w-64 xl:max-w-sm\">\n            <p class=\"text-xs truncate\">{{ track.metadata.filename }}</p>\n          </div>\n          <div class=\"w-20\" style=\"min-width: 80px\">\n            <p class=\"text-xs font-mono text-gray-200\">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>\n          </div>\n          <div class=\"w-20 hidden md:flex justify-center\" style=\"min-width: 80px\"><span v-if=\"(track.chapters || []).length\" class=\"material-symbols text-success text-sm\">check</span></div>\n        </div>\n      </div>\n    </div>\n\n    <div v-if=\"saving\" class=\"w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black/25 flex items-center justify-center\">\n      <ui-loading-indicator />\n    </div>\n\n    <!-- audible chapter lookup modal -->\n    <modals-modal v-model=\"showFindChaptersModal\" name=\"edit-book\" :width=\"500\" :processing=\"findingChapters\">\n      <template #outer>\n        <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none\">\n          <p class=\"text-3xl text-white truncate pointer-events-none\">{{ $strings.HeaderFindChapters }}</p>\n        </div>\n      </template>\n      <div class=\"w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative\">\n        <div v-if=\"!chapterData\" class=\"flex flex-col items-center justify-center p-20\">\n          <div class=\"relative\">\n            <div class=\"flex items-end space-x-2\">\n              <ui-text-input-with-label v-model.trim=\"asinInput\" label=\"ASIN\" class=\"flex-grow\" />\n              <ui-dropdown v-model=\"regionInput\" :label=\"$strings.LabelRegion\" small :items=\"audibleRegions\" class=\"w-20 max-w-20\" />\n              <ui-btn color=\"bg-primary\" @click=\"findChapters\">{{ $strings.ButtonSearch }}</ui-btn>\n            </div>\n            <div class=\"mt-4\">\n              <ui-checkbox v-model=\"removeBranding\" :label=\"$strings.LabelRemoveAudibleBranding\" small checkbox-bg=\"bg\" label-class=\"pl-2 text-base text-sm\" @click=\"toggleRemoveBranding\" />\n            </div>\n            <div class=\"absolute left-0 mt-1.5 text-error text-s h-5\">\n              <p v-if=\"asinError\">{{ asinError }}</p>\n              <p v-if=\"asinError\">{{ $strings.MessageAsinCheck }}</p>\n            </div>\n            <div class=\"invisible mt-1 text-xs\"></div>\n          </div>\n        </div>\n        <div v-else class=\"w-full p-4\">\n          <div class=\"flex mb-4\">\n            <button class=\"w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white flex-shrink-0\" :aria-label=\"$strings.ButtonBack\" @click=\"resetChapterLookupData\">\n              <span class=\"material-symbols text-lg\">arrow_back</span>\n            </button>\n            <p>\n              {{ $strings.LabelDurationFound }} <span class=\"font-semibold\">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span>\n              <br />\n              <span class=\"font-semibold\" :class=\"{ 'text-warning': chapters.length !== chapterData.chapters.length }\">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }}\n            </p>\n            <div class=\"grow\" />\n            <p>\n              {{ $strings.LabelYourAudiobookDuration }}: <span class=\"font-semibold\">{{ $secondsToTimestamp(mediaDurationRounded) }}</span\n              ><br />\n              Your audiobook has <span class=\"font-semibold\" :class=\"{ 'text-warning': chapters.length !== chapterData.chapters.length }\">{{ chapters.length }}</span> chapters\n            </p>\n          </div>\n          <widgets-alert v-if=\"chapterData.runtimeLengthSec > mediaDurationRounded\" type=\"warning\" class=\"mb-2\"> {{ $strings.MessageYourAudiobookDurationIsShorter }} </widgets-alert>\n          <widgets-alert v-else-if=\"chapterData.runtimeLengthSec < mediaDurationRounded\" type=\"warning\" class=\"mb-2\"> {{ $strings.MessageYourAudiobookDurationIsLonger }} </widgets-alert>\n\n          <div class=\"flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1\">\n            <div class=\"w-24 px-2\">{{ $strings.LabelStart }}</div>\n            <div class=\"grow px-2\">{{ $strings.LabelTitle }}</div>\n          </div>\n          <div class=\"w-full max-h-80 overflow-y-auto my-2\">\n            <div v-for=\"(chapter, index) in chapterData.chapters\" :key=\"index\" class=\"flex py-0.5 text-xs\" :class=\"chapter.startOffsetSec > mediaDuration ? 'bg-error/20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning/20' : index % 2 === 0 ? 'bg-primary/30' : ''\">\n              <div class=\"w-24 min-w-24 px-2\">\n                <p class=\"font-mono\">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>\n              </div>\n              <div class=\"grow px-2\">\n                <p class=\"truncate max-w-sm\">{{ chapter.title }}</p>\n              </div>\n            </div>\n          </div>\n          <div v-if=\"chapterData.runtimeLengthSec > mediaDurationRounded\" class=\"w-full pt-2\">\n            <div class=\"flex items-center\">\n              <div class=\"w-2 h-2 bg-warning/50\" />\n              <p class=\"pl-2\">{{ $strings.MessageChapterEndIsAfter }}</p>\n            </div>\n            <div class=\"flex items-center\">\n              <div class=\"w-2 h-2 bg-error/50\" />\n              <p class=\"pl-2\">{{ $strings.MessageChapterStartIsAfter }}</p>\n            </div>\n          </div>\n          <div class=\"flex items-center pt-2 justify-between\">\n            <div class=\"flex items-center gap-2\">\n              <ui-btn small color=\"bg-primary\" @click=\"applyChapterNamesOnly\">{{ $strings.ButtonMapChapterTitles }}</ui-btn>\n              <ui-tooltip :text=\"$strings.MessageMapChapterTitles\" direction=\"top\" class=\"flex items-center\">\n                <span class=\"material-symbols text-xl text-gray-200\">info</span>\n              </ui-tooltip>\n            </div>\n            <ui-btn small color=\"bg-success\" @click=\"applyChapterData\">{{ $strings.ButtonApplyChapters }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </modals-modal>\n\n    <!-- create bulk chapters modal -->\n    <modals-modal v-model=\"showBulkChapterModal\" name=\"bulk-chapters\" :width=\"400\">\n      <template #outer>\n        <div class=\"absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none\">\n          <p class=\"text-3xl text-white truncate pointer-events-none\">{{ $strings.HeaderBulkChapterModal }}</p>\n        </div>\n      </template>\n      <div class=\"w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative p-6\">\n        <div class=\"flex flex-col space-y-8\">\n          <p class=\"text-base\">{{ $strings.MessageBulkChapterPattern }}</p>\n\n          <div v-if=\"detectedPattern\" class=\"text-sm text-gray-400 bg-gray-800 p-2 rounded\">\n            <strong>{{ $strings.LabelDetectedPattern }}</strong> \"{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber, detectedPattern) }}{{ detectedPattern.after }}\"\n            <br />\n            <strong>{{ $strings.LabelNextChapters }}</strong>\n            \"{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 1, detectedPattern) }}{{ detectedPattern.after }}\", \"{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 2, detectedPattern) }}{{ detectedPattern.after }}\", etc.\n          </div>\n          <div class=\"flex px-1 items-center\">\n            <label class=\"text-base font-medium\">{{ $strings.LabelNumberOfChapters }}</label>\n            <div class=\"grow\" />\n            <ui-text-input v-model=\"bulkChapterCount\" type=\"number\" min=\"1\" max=\"50\" class=\"w-14\" :style=\"{ height: `2em` }\" @keyup.enter=\"addBulkChapters\" />\n          </div>\n          <div class=\"flex px-1 items-center\">\n            <ui-btn small @click=\"showBulkChapterModal = false\">{{ $strings.ButtonCancel }}</ui-btn>\n            <div class=\"grow\" />\n            <ui-btn small color=\"bg-success\" @click=\"addBulkChapters\">{{ $strings.ButtonAddChapters }}</ui-btn>\n          </div>\n        </div>\n      </div>\n    </modals-modal>\n  </div>\n</template>\n\n<script>\nimport path from 'path'\n\nexport default {\n  async asyncData({ store, params, app, redirect, from }) {\n    if (!store.getters['user/getUserCanUpdate']) {\n      return redirect('/?error=unauthorized')\n    }\n    var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!libraryItem) {\n      console.error('Not found...', params.id)\n      return redirect('/')\n    }\n    if (libraryItem.mediaType != 'book') {\n      console.error('Invalid media type')\n      return redirect('/')\n    }\n\n    // Fetch and set library if this items library does not match the current\n    if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {\n      await store.dispatch('libraries/fetch', libraryItem.libraryId)\n    }\n\n    var previousRoute = from ? from.fullPath : null\n    if (from && from.path === '/login') previousRoute = null\n    return {\n      libraryItem,\n      previousRoute\n    }\n  },\n  data() {\n    return {\n      newChapters: [],\n      selectedChapter: null,\n      showShiftTimes: false,\n      shiftAmount: 0,\n      audioEl: null,\n      isPlayingChapter: false,\n      isLoadingChapter: false,\n      currentTrackIndex: 0,\n      saving: false,\n      asinInput: null,\n      regionInput: 'US',\n      findingChapters: false,\n      showFindChaptersModal: false,\n      chapterData: null,\n      asinError: null,\n      removeBranding: false,\n      showSecondInputs: false,\n      audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],\n      hasChanges: false,\n      timeIncrementAmount: 1,\n      elapsedTime: 0,\n      playStartTime: null,\n      elapsedTimeInterval: null,\n      lockedChapters: new Set(),\n      lastSelectedLockIndex: null,\n      bulkChapterInput: '',\n      showBulkChapterModal: false,\n      bulkChapterCount: 1,\n      detectedPattern: null\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    media() {\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    title() {\n      return this.mediaMetadata.title\n    },\n    mediaDuration() {\n      return this.media.duration\n    },\n    mediaDurationRounded() {\n      return Math.round(this.mediaDuration)\n    },\n    chapters() {\n      return this.media.chapters || []\n    },\n    tracks() {\n      return this.media.tracks || []\n    },\n    audioFiles() {\n      return this.media.audioFiles || []\n    },\n    audioTracks() {\n      return this.audioFiles.filter((af) => !af.exclude)\n    },\n    selectedChapterId() {\n      return this.selectedChapter ? this.selectedChapter.id : null\n    },\n    allChaptersLocked() {\n      return this.newChapters.length > 0 && this.newChapters.every((chapter) => this.lockedChapters.has(chapter.id))\n    }\n  },\n  methods: {\n    formatNumberWithPadding(number, pattern) {\n      if (!pattern || !pattern.hasLeadingZeros || !pattern.originalPadding) {\n        return number.toString()\n      }\n      return number.toString().padStart(pattern.originalPadding, '0')\n    },\n    setChaptersFromTracks() {\n      let currentStartTime = 0\n      let index = 0\n      const chapters = []\n      for (const track of this.audioTracks) {\n        chapters.push({\n          id: index++,\n          title: path.basename(track.metadata.filename, path.extname(track.metadata.filename)),\n          start: currentStartTime,\n          end: currentStartTime + track.duration\n        })\n        currentStartTime += track.duration\n      }\n      this.newChapters = chapters\n      this.lockedChapters = new Set()\n      this.checkChapters()\n    },\n    toggleRemoveBranding() {\n      this.removeBranding = !this.removeBranding\n    },\n    shiftChapterTimes() {\n      if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {\n        return\n      }\n\n      const amount = Number(this.shiftAmount)\n\n      // Check if any unlocked chapters would be affected negatively\n      const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id))\n\n      if (unlockedChapters.length === 0) {\n        this.$toast.warning(this.$strings.ToastChaptersAllLocked)\n        return\n      }\n\n      for (let i = 0; i < this.newChapters.length; i++) {\n        const chap = this.newChapters[i]\n\n        // Skip locked chapters\n        if (this.lockedChapters.has(chap.id)) {\n          continue\n        }\n\n        chap.end = Math.min(chap.end + amount, this.mediaDuration)\n        if (i > 0) {\n          chap.start = Math.max(0, chap.start + amount)\n        }\n      }\n      this.checkChapters()\n    },\n    incrementChapterTime(chapter, amount) {\n      if (chapter.id === 0 && chapter.start + amount < 0) {\n        return\n      }\n      if (chapter.start + amount >= this.mediaDuration) {\n        return\n      }\n\n      chapter.start = Math.max(0, chapter.start + amount)\n      this.checkChapters()\n    },\n    adjustChapterStartTime(chapter) {\n      const newStartTime = chapter.start + this.elapsedTime\n      chapter.start = newStartTime\n      this.checkChapters()\n      this.$toast.success(this.$strings.ToastChapterStartTimeAdjusted.replace('{0}', this.elapsedTime))\n\n      this.destroyAudioEl()\n    },\n    startElapsedTimeTracking() {\n      this.elapsedTime = 0\n      this.playStartTime = Date.now()\n      this.elapsedTimeInterval = setInterval(() => {\n        this.elapsedTime = Math.floor((Date.now() - this.playStartTime) / 1000)\n      }, 100)\n    },\n    stopElapsedTimeTracking() {\n      if (this.elapsedTimeInterval) {\n        clearInterval(this.elapsedTimeInterval)\n        this.elapsedTimeInterval = null\n      }\n      this.elapsedTime = 0\n      this.playStartTime = null\n    },\n    toggleChapterLock(chapter, event) {\n      const chapterId = chapter.id\n\n      if (event.shiftKey && this.lastSelectedLockIndex !== null) {\n        const startIndex = Math.min(this.lastSelectedLockIndex, chapterId)\n        const endIndex = Math.max(this.lastSelectedLockIndex, chapterId)\n        const shouldLock = !this.lockedChapters.has(chapterId)\n\n        for (let i = startIndex; i <= endIndex; i++) {\n          if (shouldLock) {\n            this.lockedChapters.add(i)\n          } else {\n            this.lockedChapters.delete(i)\n          }\n        }\n      } else {\n        if (this.lockedChapters.has(chapterId)) {\n          this.lockedChapters.delete(chapterId)\n        } else {\n          this.lockedChapters.add(chapterId)\n        }\n      }\n\n      this.lastSelectedLockIndex = chapterId\n      this.lockedChapters = new Set(this.lockedChapters)\n    },\n    lockAllChapters() {\n      this.newChapters.forEach((chapter) => {\n        this.lockedChapters.add(chapter.id)\n      })\n      this.lockedChapters = new Set(this.lockedChapters)\n    },\n    unlockAllChapters() {\n      this.lockedChapters.clear()\n      this.lockedChapters = new Set(this.lockedChapters)\n    },\n    toggleAllChaptersLock() {\n      if (this.allChaptersLocked) {\n        this.unlockAllChapters()\n      } else {\n        this.lockAllChapters()\n      }\n    },\n    editItem() {\n      this.$store.commit('showEditModal', this.libraryItem)\n    },\n    addChapter(chapter) {\n      const newChapter = {\n        id: chapter.id + 1,\n        start: chapter.start,\n        end: chapter.end,\n        title: ''\n      }\n      this.newChapters.splice(chapter.id + 1, 0, newChapter)\n      this.checkChapters()\n    },\n    removeChapter(chapter) {\n      if (this.lockedChapters.has(chapter.id)) {\n        this.$toast.warning(this.$strings.ToastChapterLocked)\n        return\n      }\n      this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)\n      this.checkChapters()\n    },\n    checkChapters() {\n      let previousStart = 0\n      let hasChanges = this.newChapters.length !== this.chapters.length\n\n      for (let i = 0; i < this.newChapters.length; i++) {\n        this.newChapters[i].id = i\n        this.newChapters[i].start = Number(this.newChapters[i].start)\n        this.newChapters[i].title = (this.newChapters[i].title || '').trim()\n\n        if (i === 0 && this.newChapters[i].start !== 0) {\n          this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero\n        } else if (this.newChapters[i].start <= previousStart && i > 0) {\n          this.newChapters[i].error = this.$strings.MessageChapterErrorStartLtPrev\n        } else if (this.newChapters[i].start >= this.mediaDuration) {\n          this.newChapters[i].error = this.$strings.MessageChapterErrorStartGteDuration\n        } else {\n          this.newChapters[i].error = null\n        }\n        previousStart = this.newChapters[i].start\n\n        if (hasChanges) {\n          continue\n        }\n\n        const existingChapter = this.chapters[i]\n        if (existingChapter) {\n          const { start, end, title } = this.newChapters[i]\n          if (start !== existingChapter.start || end !== existingChapter.end || title !== existingChapter.title) {\n            hasChanges = true\n          }\n        } else {\n          hasChanges = true\n        }\n      }\n\n      this.hasChanges = hasChanges\n    },\n    getAudioTrackForTime(time) {\n      if (typeof time !== 'number') {\n        return null\n      }\n      return this.tracks.find((at) => {\n        return time >= at.startOffset && time < at.startOffset + at.duration\n      })\n    },\n    playChapter(chapter) {\n      console.log('Play Chapter', chapter.id)\n      if (this.selectedChapterId === chapter.id) {\n        console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)\n        if (this.isLoadingChapter) return\n        if (this.isPlayingChapter) {\n          this.destroyAudioEl()\n          return\n        }\n      }\n      if (this.selectedChapterId) {\n        this.destroyAudioEl()\n      }\n\n      const audioTrack = this.getAudioTrackForTime(chapter.start)\n      if (!audioTrack) {\n        console.error('No audio track found for chapter', chapter)\n        return\n      }\n\n      this.selectedChapter = chapter\n      this.isLoadingChapter = true\n\n      const trackOffset = chapter.start - audioTrack.startOffset\n      this.playTrackAtTime(audioTrack, trackOffset)\n    },\n    playTrackAtTime(audioTrack, trackOffset) {\n      this.currentTrackIndex = audioTrack.index\n\n      const audioEl = this.audioEl || document.createElement('audio')\n      var src = audioTrack.contentUrl + `?token=${this.userToken}`\n\n      audioEl.src = `${process.env.serverUrl}${src}`\n      audioEl.id = 'chapter-audio'\n      document.body.appendChild(audioEl)\n\n      audioEl.addEventListener('loadeddata', () => {\n        console.log('Audio loaded data', audioEl.duration)\n        audioEl.currentTime = trackOffset\n        audioEl.play()\n        console.log('Playing audio at current time', trackOffset)\n      })\n      audioEl.addEventListener('play', () => {\n        console.log('Audio playing')\n        this.isLoadingChapter = false\n        this.isPlayingChapter = true\n        this.startElapsedTimeTracking()\n      })\n      audioEl.addEventListener('ended', () => {\n        console.log('Audio ended')\n        const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)\n        if (nextTrack) {\n          console.log('Playing next track', nextTrack.index)\n          this.currentTrackIndex = nextTrack.index\n          this.playTrackAtTime(nextTrack, 0)\n        } else {\n          console.log('No next track')\n          this.destroyAudioEl()\n        }\n      })\n      this.audioEl = audioEl\n    },\n    destroyAudioEl() {\n      if (!this.audioEl) return\n      this.audioEl.remove()\n      this.audioEl = null\n      this.selectedChapter = null\n      this.isPlayingChapter = false\n      this.isLoadingChapter = false\n      this.stopElapsedTimeTracking()\n    },\n    resetChapterLookupData() {\n      this.chapterData = null\n    },\n    saveChapters() {\n      this.checkChapters()\n\n      for (let i = 0; i < this.newChapters.length; i++) {\n        if (this.newChapters[i].error) {\n          this.$toast.error(this.$strings.ToastChaptersHaveErrors)\n          return\n        }\n        if (!this.newChapters[i].title) {\n          this.$toast.error(this.$strings.ToastChaptersMustHaveTitles)\n          return\n        }\n\n        const nextChapter = this.newChapters[i + 1]\n        if (nextChapter) {\n          this.newChapters[i].end = nextChapter.start\n        } else {\n          this.newChapters[i].end = this.mediaDuration\n        }\n      }\n\n      this.saving = true\n\n      const payload = {\n        chapters: this.newChapters\n      }\n      this.$axios\n        .$post(`/api/items/${this.libraryItem.id}/chapters`, payload)\n        .then((data) => {\n          this.saving = false\n          if (data.updated) {\n            this.$toast.success(this.$strings.ToastChaptersUpdated)\n            this.reloadLibraryItem()\n          } else {\n            this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n          }\n        })\n        .catch((error) => {\n          this.saving = false\n          console.error('Failed to update chapters', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n    },\n    applyChapterNamesOnly() {\n      this.newChapters.forEach((chapter, index) => {\n        if (this.chapterData.chapters[index] && !this.lockedChapters.has(chapter.id)) {\n          chapter.title = this.chapterData.chapters[index].title\n        }\n      })\n\n      this.showFindChaptersModal = false\n      this.chapterData = null\n\n      this.checkChapters()\n    },\n    applyChapterData() {\n      let index = 0\n      const audibleChapters = this.chapterData.chapters\n        .filter((chap) => chap.startOffsetSec < this.mediaDuration)\n        .map((chap) => {\n          return {\n            id: index++,\n            start: chap.startOffsetMs / 1000,\n            end: Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000),\n            title: chap.title\n          }\n        })\n\n      const merged = []\n      let audibleIdx = 0\n      for (let i = 0; i < Math.max(this.newChapters.length, audibleChapters.length); i++) {\n        const isLocked = this.lockedChapters.has(i)\n        if (isLocked && this.newChapters[i]) {\n          merged.push({ ...this.newChapters[i], id: i })\n        } else if (audibleChapters[audibleIdx]) {\n          merged.push({ ...audibleChapters[audibleIdx], id: i })\n          audibleIdx++\n        } else if (this.newChapters[i]) {\n          merged.push({ ...this.newChapters[i], id: i })\n        }\n      }\n      this.newChapters = merged\n      this.showFindChaptersModal = false\n      this.chapterData = null\n\n      this.checkChapters()\n    },\n    findChapters() {\n      if (!this.asinInput) {\n        this.$toast.error(this.$strings.ToastAsinRequired)\n        return\n      }\n\n      // Update local storage region\n      if (this.regionInput !== localStorage.getItem('audibleRegion')) {\n        localStorage.setItem('audibleRegion', this.regionInput)\n      }\n\n      this.findingChapters = true\n      this.chapterData = null\n      this.asinError = null // used to show warning about audible vs amazon ASIN\n      this.$axios\n        .$get(`/api/search/chapters?asin=${this.asinInput}&region=${this.regionInput}`)\n        .then((data) => {\n          this.findingChapters = false\n\n          if (data.error) {\n            this.asinError = this.$getString(data.stringKey)\n          } else {\n            console.log('Chapter data', { ...data })\n            this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data\n          }\n        })\n        .catch((error) => {\n          this.findingChapters = false\n          console.error('Failed to get chapter data', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n          this.showFindChaptersModal = false\n        })\n    },\n    removeBrandingFromData(data) {\n      if (!data) return data\n      try {\n        const introDuration = data.brandIntroDurationMs\n        const outroDuration = data.brandOutroDurationMs\n\n        for (let i = 0; i < data.chapters.length; i++) {\n          const chapter = data.chapters[i]\n          if (chapter.startOffsetMs < introDuration) {\n            // This should never happen, as the intro is not longer than the first chapter\n            // If this happens set to the next second\n            // Will be 0 for the first chapter anayways\n            chapter.startOffsetMs = i * 1000\n            chapter.startOffsetSec = i\n          } else {\n            chapter.startOffsetMs -= introDuration\n            chapter.startOffsetSec = Math.floor(chapter.startOffsetMs / 1000)\n          }\n        }\n\n        const lastChapter = data.chapters[data.chapters.length - 1]\n        // If there is an outro that's in the outro duration, remove it\n        if (lastChapter && lastChapter.lengthMs <= outroDuration) {\n          data.chapters.pop()\n        }\n\n        // Remove Branding durations from Runtime totals\n        data.runtimeLengthMs -= introDuration + outroDuration\n        data.runtimeLengthSec = Math.floor(data.runtimeLengthMs / 1000)\n        console.log('Brandless Chapter data', data)\n\n        return data\n      } catch {\n        return data\n      }\n    },\n    resetChapters() {\n      const payload = {\n        message: this.$strings.MessageResetChaptersConfirm,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.initChapters()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    initChapters() {\n      this.newChapters = this.chapters.map((c) => ({ ...c }))\n      if (!this.newChapters.length) {\n        this.newChapters = [\n          {\n            id: 0,\n            start: 0,\n            end: this.mediaDuration,\n            title: ''\n          }\n        ]\n      }\n      this.lockedChapters = new Set()\n      this.checkChapters()\n    },\n    removeAllChaptersClick() {\n      const payload = {\n        message: this.$strings.MessageConfirmRemoveAllChapters,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removeAllChapters()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    removeAllChapters() {\n      this.saving = true\n      const payload = {\n        chapters: []\n      }\n      this.$axios\n        .$post(`/api/items/${this.libraryItem.id}/chapters`, payload)\n        .then((data) => {\n          if (data.updated) {\n            this.$toast.success(this.$strings.ToastChaptersRemoved)\n            this.reloadLibraryItem()\n          } else {\n            this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to remove chapters', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.saving = false\n        })\n    },\n    handleBulkChapterAdd() {\n      const input = this.bulkChapterInput.trim()\n      if (!input) return\n\n      const numberMatch = input.match(/(\\d+)/)\n\n      if (numberMatch) {\n        // Extract the base pattern and number, preserving zero-padding\n        const originalNumberString = numberMatch[1]\n        const foundNumber = parseInt(originalNumberString)\n        const numberIndex = numberMatch.index\n        const beforeNumber = input.substring(0, numberIndex)\n        const afterNumber = input.substring(numberIndex + originalNumberString.length)\n\n        this.detectedPattern = {\n          before: beforeNumber,\n          after: afterNumber,\n          startingNumber: foundNumber,\n          originalPadding: originalNumberString.length,\n          hasLeadingZeros: originalNumberString.length > 1 && originalNumberString.startsWith('0')\n        }\n\n        this.bulkChapterCount = 1\n        this.showBulkChapterModal = true\n      } else {\n        this.addSingleChapterFromInput(input)\n      }\n    },\n    addSingleChapterFromInput(title) {\n      // Find the last chapter to determine where to add the new one\n      const lastChapter = this.newChapters[this.newChapters.length - 1]\n      const newStart = lastChapter ? lastChapter.end : 0\n      const newEnd = Math.min(newStart + 300, this.mediaDuration)\n\n      const newChapter = {\n        id: this.newChapters.length,\n        start: newStart,\n        end: newEnd,\n        title: title\n      }\n\n      this.newChapters.push(newChapter)\n      this.bulkChapterInput = ''\n      this.checkChapters()\n    },\n\n    addBulkChapters() {\n      const count = parseInt(this.bulkChapterCount)\n      if (!count || count < 1 || count > 150) {\n        this.$toast.error(this.$strings.ToastBulkChapterInvalidCount)\n        return\n      }\n\n      const { before, after, startingNumber, originalPadding, hasLeadingZeros } = this.detectedPattern\n      const lastChapter = this.newChapters[this.newChapters.length - 1]\n      const baseStart = lastChapter ? lastChapter.start + 1 : 0\n\n      // Add multiple chapters with the detected pattern\n      for (let i = 0; i < count; i++) {\n        const chapterNumber = startingNumber + i\n        let formattedNumber = chapterNumber.toString()\n\n        // Apply zero-padding if the original had leading zeros\n        if (hasLeadingZeros && originalPadding > 1) {\n          formattedNumber = chapterNumber.toString().padStart(originalPadding, '0')\n        }\n\n        const newStart = baseStart + i\n        const newEnd = Math.min(newStart + i + i, this.mediaDuration)\n\n        const newChapter = {\n          id: this.newChapters.length,\n          start: newStart,\n          end: newEnd,\n          title: `${before}${formattedNumber}${after}`\n        }\n\n        this.newChapters.push(newChapter)\n      }\n\n      this.bulkChapterInput = ''\n      this.showBulkChapterModal = false\n      this.detectedPattern = null\n      this.checkChapters()\n    },\n    libraryItemUpdated(libraryItem) {\n      if (libraryItem.id === this.libraryItem.id) {\n        if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) {\n          this.asinInput = libraryItem.media.metadata.asin\n        }\n        this.libraryItem = libraryItem\n      }\n    },\n    reloadLibraryItem() {\n      this.$axios\n        .$get(`/api/items/${this.libraryItem.id}?expanded=1`)\n        .then((data) => {\n          this.libraryItem = data\n          this.initChapters()\n        })\n        .catch((error) => {\n          console.error('Failed to reload library item', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n    }\n  },\n  mounted() {\n    this.regionInput = localStorage.getItem('audibleRegion') || 'US'\n    this.asinInput = this.mediaMetadata.asin || null\n    this.initChapters()\n\n    this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n  },\n  beforeDestroy() {\n    this.destroyAudioEl()\n\n    this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/audiobook/_id/edit.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page overflow-hidden relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div v-show=\"saving\" class=\"absolute z-20 w-full h-full flex items-center justify-center\">\n      <ui-loading-indicator />\n    </div>\n    <div class=\"w-full h-full overflow-y-auto p-8\">\n      <div class=\"w-full flex justify-between items-center pb-6 pt-2\">\n        <p class=\"text-lg\">{{ $strings.MessageDragFilesIntoTrackOrder }}</p>\n        <ui-btn color=\"bg-success\" @click=\"saveTracklist\">{{ $strings.ButtonSaveTracklist }}</ui-btn>\n      </div>\n      <div class=\"w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600\">\n        <div class=\"text-center px-4 w-12\">{{ $strings.LabelNew }}</div>\n        <div class=\"text-center px-4 w-24 flex items-center cursor-pointer text-white/40 hover:text-white/100\" @click=\"sortByCurrent\" @mousedown.prevent>\n          <span class=\"text-white\">{{ $strings.LabelCurrent }}</span>\n          <span class=\"material-symbols ml-1\" :class=\"currentSort === 'current' ? 'text-white/100 text-lg' : 'text-sm'\">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>\n        </div>\n        <div class=\"text-center px-4 w-32 flex items-center cursor-pointer text-white/40 hover:text-white/100\" @click=\"sortByFilenameTrack\" @mousedown.prevent>\n          <span class=\"text-white\">{{ $strings.LabelTrackFromFilename }}</span>\n          <span class=\"material-symbols ml-1\" :class=\"currentSort === 'track-filename' ? 'text-white/100 text-lg' : 'text-sm'\">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>\n        </div>\n        <div class=\"text-center px-4 w-32 flex items-center cursor-pointer text-white/40 hover:text-white/100\" @click=\"sortByMetadataTrack\" @mousedown.prevent>\n          <span class=\"text-white\">{{ $strings.LabelTrackFromMetadata }}</span>\n          <span class=\"material-symbols ml-1\" :class=\"currentSort === 'metadata' ? 'text-white/100 text-lg' : 'text-sm'\">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>\n        </div>\n        <div class=\"w-20 text-center\">{{ $strings.LabelDiscFromFilename }}</div>\n        <div class=\"w-20 text-center\">{{ $strings.LabelDiscFromMetadata }}</div>\n        <div class=\"text-center px-4 grow flex items-center cursor-pointer text-white/40 hover:text-white/100\" @click=\"sortByFilename\" @mousedown.prevent>\n          <span class=\"text-white\">{{ $strings.LabelFilename }}</span>\n          <span class=\"material-symbols ml-1\" :class=\"currentSort === 'filename' ? 'text-white/100 text-lg' : 'text-sm'\">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>\n        </div>\n\n        <div class=\"w-20 text-center\">{{ $strings.LabelSize }}</div>\n        <div class=\"w-20 text-center\">{{ $strings.LabelDuration }}</div>\n        <div class=\"w-56\">{{ $strings.LabelNotes }}</div>\n        <div class=\"w-40\">{{ $strings.LabelIncludeInTracklist }}</div>\n      </div>\n      <draggable v-model=\"files\" v-bind=\"dragOptions\" class=\"list-group border border-gray-600\" draggable=\".item\" tag=\"ul\" @start=\"drag = true\" @end=\"drag = false\" @update=\"draggableUpdate\">\n        <transition-group type=\"transition\" :name=\"!drag ? 'flip-list' : null\">\n          <li v-for=\"(audio, index) in files\" :key=\"audio.ino\" :class=\"audio.include ? 'item' : 'exclude'\" class=\"w-full list-group-item flex items-center relative\">\n            <div class=\"text-center px-4 py-1 w-12 min-w-12\">\n              {{ audio.include ? index - numExcluded + 1 : -1 }}\n            </div>\n            <div class=\"text-center px-4 w-24 min-w-24\">{{ audio.index }}</div>\n            <div class=\"text-center px-2 w-32 min-w-32\">\n              {{ audio.trackNumFromFilename }}\n            </div>\n            <div class=\"text-center w-32 min-w-32\">\n              {{ audio.trackNumFromMeta }}\n            </div>\n            <div class=\"truncate px-4 w-20 min-w-20\">\n              {{ audio.discNumFromFilename }}\n            </div>\n            <div class=\"truncate px-4 w-20 min-w-20\">\n              {{ audio.discNumFromMeta }}\n            </div>\n            <div class=\"truncate px-4 grow\">\n              {{ audio.metadata.filename }}\n            </div>\n\n            <div class=\"font-mono w-20 min-w-20 text-center text-xs\">\n              {{ $bytesPretty(audio.metadata.size) }}\n            </div>\n            <div class=\"font-mono w-20 min-w-20 text-center text-xs\">\n              {{ $secondsToTimestamp(audio.duration) }}\n            </div>\n            <div class=\"font-sans text-xs font-normal w-56 min-w-[224px]\">\n              {{ audio.error }}\n            </div>\n            <div class=\"font-sans text-xs font-normal w-40 min-w-[160px] flex items-center justify-center\">\n              <ui-toggle-switch v-model=\"audio.include\" :off-color=\"'error'\" @input=\"includeToggled(audio)\" />\n            </div>\n          </li>\n        </transition-group>\n      </draggable>\n    </div>\n  </div>\n</template>\n\n<script>\nimport draggable from 'vuedraggable'\n\nexport default {\n  components: {\n    draggable\n  },\n  async asyncData({ store, params, app, redirect, route }) {\n    if (!store.getters['user/getUserCanUpdate']) {\n      return redirect('/?error=unauthorized')\n    }\n    var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!libraryItem) {\n      console.error('Not found...', params.id)\n      return redirect('/')\n    }\n    if (libraryItem.mediaType != 'book') {\n      console.error('Invalid media type')\n      return redirect('/')\n    }\n    if (libraryItem.isFile) {\n      console.error('No need to edit library item that is 1 file...')\n      return redirect('/')\n    }\n\n    // Fetch and set library if this items library does not match the current\n    if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {\n      await store.dispatch('libraries/fetch', libraryItem.libraryId)\n    }\n\n    return {\n      libraryItem,\n      files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []\n    }\n  },\n  data() {\n    return {\n      drag: false,\n      dragOptions: {\n        animation: 200,\n        group: 'description',\n        ghostClass: 'ghost'\n      },\n      saving: false,\n      currentSort: 'current'\n    }\n  },\n  computed: {\n    media() {\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || []\n    },\n    audioFiles() {\n      return this.media.audioFiles || []\n    },\n    numExcluded() {\n      var count = 0\n      this.files.forEach((file) => {\n        if (!file.include) count++\n      })\n      return count\n    },\n    libraryItemId() {\n      return this.libraryItem.id\n    },\n    title() {\n      return this.mediaMetadata.title || 'No Title'\n    },\n    author() {\n      return this.mediaMetadata.authorName || 'Unknown'\n    },\n    tracks() {\n      return this.media.tracks\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {\n    draggableUpdate(e) {\n      this.currentSort = ''\n    },\n    sortByCurrent() {\n      this.files.sort((a, b) => {\n        if (a.index === null) return 1\n        return a.index - b.index\n      })\n      this.currentSort = 'current'\n    },\n    sortByMetadataTrack() {\n      this.files.sort((a, b) => {\n        if (a.trackNumFromMeta === null) return 1\n        return a.trackNumFromMeta - b.trackNumFromMeta\n      })\n      this.currentSort = 'metadata'\n    },\n    sortByFilenameTrack() {\n      this.files.sort((a, b) => {\n        if (a.trackNumFromFilename === null) return 1\n        return a.trackNumFromFilename - b.trackNumFromFilename\n      })\n      this.currentSort = 'track-filename'\n    },\n    sortByFilename() {\n      this.files.sort((a, b) => {\n        return (a.metadata.filename || '').toLowerCase().localeCompare((b.metadata.filename || '').toLowerCase())\n      })\n      this.currentSort = 'filename'\n    },\n    includeToggled(audio) {\n      var new_index = 0\n      if (audio.include) {\n        new_index = this.numExcluded\n      }\n      var old_index = this.files.findIndex((f) => f.ino === audio.ino)\n      if (new_index >= this.files.length) {\n        var k = new_index - this.files.length + 1\n        while (k--) {\n          this.files.push(undefined)\n        }\n      }\n      this.files.splice(new_index, 0, this.files.splice(old_index, 1)[0])\n    },\n    saveTracklist() {\n      var orderedFileData = this.files.map((file) => {\n        return {\n          index: file.index,\n          filename: file.metadata.filename,\n          ino: file.ino,\n          exclude: !file.include\n        }\n      })\n\n      this.saving = true\n      this.$axios\n        .$patch(`/api/items/${this.libraryItem.id}/tracks`, { orderedFileData })\n        .then((data) => {\n          console.log('Finished patching files', data)\n          this.saving = false\n          this.$toast.success('Tracks Updated')\n          this.$router.push(`/item/${this.libraryItemId}`)\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.saving = false\n        })\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/audiobook/_id/manage.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page p-8 overflow-auto relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"flex items-center justify-center mb-6\">\n      <div class=\"w-full max-w-2xl\">\n        <div class=\"flex items-center mb-4\">\n          <nuxt-link :to=\"`/item/${libraryItem.id}`\" class=\"hover:underline\">\n            <h1 class=\"text-lg lg:text-xl\">{{ mediaMetadata.title }}</h1>\n          </nuxt-link>\n          <button class=\"w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white\" @click=\"editItem\">\n            <span class=\"material-symbols text-base\">edit</span>\n          </button>\n        </div>\n      </div>\n      <div class=\"w-full max-w-2xl\">\n        <div class=\"flex justify-end\">\n          <ui-dropdown v-model=\"selectedTool\" :items=\"availableTools\" :disabled=\"processing\" class=\"max-w-sm\" @input=\"selectedToolUpdated\" />\n        </div>\n      </div>\n    </div>\n\n    <div class=\"flex justify-center mb-2\">\n      <div class=\"w-full max-w-2xl\">\n        <p class=\"text-lg\">{{ $strings.HeaderMetadataToEmbed }}</p>\n      </div>\n      <div class=\"w-full max-w-2xl\"></div>\n    </div>\n\n    <div class=\"flex justify-center flex-wrap lg:flex-nowrap gap-4\">\n      <div class=\"w-full max-w-2xl border border-white/10 bg-bg\">\n        <div class=\"flex py-2 px-4\">\n          <div class=\"w-28 min-w-28 text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelMetaTag }}</div>\n          <div class=\"grow text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelValue }}</div>\n        </div>\n        <div class=\"w-full max-h-72 overflow-auto\">\n          <template v-for=\"(value, key, index) in metadataObject\">\n            <div :key=\"key\" class=\"flex py-1 px-4 text-sm\" :class=\"index % 2 === 0 ? 'bg-primary/25' : ''\">\n              <div class=\"w-28 min-w-28 font-semibold\">{{ key }}</div>\n              <div class=\"grow\">\n                {{ value }}\n              </div>\n            </div>\n          </template>\n        </div>\n      </div>\n      <div class=\"w-full max-w-2xl border border-white/10 bg-bg\">\n        <div class=\"flex py-2 px-4 bg-primary/25\">\n          <div class=\"grow text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelChapterTitle }}</div>\n          <div class=\"w-16 min-w-16 text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelStart }}</div>\n          <div class=\"w-16 min-w-16 text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelEnd }}</div>\n        </div>\n        <div class=\"w-full max-h-72 overflow-auto\">\n          <p v-if=\"!metadataChapters.length\" class=\"py-5 text-center text-gray-200\">{{ $strings.MessageNoChapters }}</p>\n          <template v-for=\"(chapter, index) in metadataChapters\">\n            <div :key=\"index\" class=\"flex py-1 px-4 text-sm\" :class=\"index % 2 === 1 ? 'bg-primary/25' : ''\">\n              <div class=\"grow font-semibold\">{{ chapter.title }}</div>\n              <div class=\"w-16 min-w-16\">\n                {{ $secondsToTimestamp(chapter.start) }}\n              </div>\n              <div class=\"w-16 min-w-16\">\n                {{ $secondsToTimestamp(chapter.end) }}\n              </div>\n            </div>\n          </template>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"w-full h-px bg-white/10 my-8\" />\n\n    <div class=\"w-full max-w-4xl mx-auto\">\n      <!-- queued alert -->\n      <widgets-alert v-if=\"isMetadataEmbedQueued\" type=\"warning\" class=\"mb-4\">\n        <p class=\"text-lg\">{{ $getString('MessageEmbedQueue', [queuedEmbedLIds.length]) }}</p>\n      </widgets-alert>\n      <!-- metadata embed action buttons -->\n      <div v-else-if=\"isEmbedTool\" class=\"w-full flex justify-end items-center mb-4\">\n        <ui-checkbox v-if=\"!isTaskFinished\" v-model=\"shouldBackupAudioFiles\" :disabled=\"processing\" :label=\"$strings.LabelBackupAudioFiles\" medium checkbox-bg=\"bg\" label-class=\"pl-2 text-base md:text-lg\" @input=\"toggleBackupAudioFiles\" />\n\n        <div class=\"grow\" />\n\n        <ui-btn v-if=\"!isTaskFinished\" color=\"bg-primary\" :loading=\"processing\" :progress=\"progress\" @click.stop=\"embedClick\">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>\n        <p v-else-if=\"taskFailed\" class=\"text-error text-lg font-semibold\">{{ $strings.MessageEmbedFailed }} {{ taskError }}</p>\n        <p v-else class=\"text-success text-lg font-semibold\">{{ $strings.MessageEmbedFinished }}</p>\n      </div>\n      <!-- m4b embed action buttons -->\n      <div v-else class=\"w-full flex items-center mb-4\">\n        <div class=\"grow\" />\n\n        <ui-btn v-if=\"!isTaskFinished && processing\" color=\"bg-error\" :loading=\"isCancelingEncode\" class=\"mr-2\" @click.stop=\"cancelEncodeClick\">{{ $strings.ButtonCancelEncode }}</ui-btn>\n        <ui-btn v-if=\"!isTaskFinished\" color=\"bg-primary\" :loading=\"processing\" :progress=\"progress\" @click.stop=\"encodeM4bClick\">{{ $strings.ButtonStartM4BEncode }}</ui-btn>\n        <p v-else-if=\"taskFailed\" class=\"text-error text-lg font-semibold\">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>\n        <p v-else class=\"text-success text-lg font-semibold\">{{ $strings.MessageM4BFinished }}</p>\n      </div>\n\n      <!-- show encoding options for running task -->\n      <div v-if=\"encodeTaskHasEncodingOptions\" class=\"mb-4 pb-4 border-b border-white/10\">\n        <div class=\"flex flex-wrap -mx-2\">\n          <ui-text-input-with-label ref=\"bitrateInput\" v-model=\"encodingOptions.bitrate\" readonly :label=\"$strings.LabelAudioBitrate\" class=\"m-2 max-w-40\" @input=\"bitrateChanged\" />\n          <ui-text-input-with-label ref=\"channelsInput\" v-model=\"encodingOptions.channels\" readonly :label=\"$strings.LabelAudioChannels\" class=\"m-2 max-w-40\" @input=\"channelsChanged\" />\n          <ui-text-input-with-label ref=\"codecInput\" v-model=\"encodingOptions.codec\" readonly :label=\"$strings.LabelAudioCodec\" class=\"m-2 max-w-40\" @input=\"codecChanged\" />\n        </div>\n      </div>\n      <div v-else-if=\"isM4BTool\" class=\"mb-4\">\n        <widgets-encoder-options-card ref=\"encoderOptionsCard\" :audio-tracks=\"audioFiles\" :disabled=\"processing || isTaskFinished\" />\n      </div>\n\n      <div class=\"mb-4\">\n        <div v-if=\"isEmbedTool\" class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">{{ $strings.LabelEncodingInfoEmbedded }}</p>\n        </div>\n        <div v-else class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">\n            {{ $strings.LabelEncodingFinishedM4B }} <span class=\"rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono\">.../{{ libraryItemRelPath }}/</span>.\n          </p>\n        </div>\n\n        <div v-if=\"shouldBackupAudioFiles || isM4BTool\" class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">\n            {{ $strings.LabelEncodingBackupLocation }} <span class=\"rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono\">/metadata/cache/items/{{ libraryItemId }}/</span>. {{ $strings.LabelEncodingClearItemCache }}\n          </p>\n        </div>\n        <div v-if=\"isEmbedTool && audioFiles.length > 1\" class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">{{ $strings.LabelEncodingChaptersNotEmbedded }}</p>\n        </div>\n        <div v-if=\"isM4BTool\" class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">{{ $strings.LabelEncodingTimeWarning }}</p>\n        </div>\n        <div v-if=\"isM4BTool\" class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">{{ $strings.LabelEncodingWatcherDisabled }}</p>\n        </div>\n        <div class=\"flex items-start mb-2\">\n          <span class=\"material-symbols text-base text-warning pt-1\">star</span>\n          <p class=\"text-gray-200 ml-2\">{{ $strings.LabelEncodingStartedNavigation }}</p>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"w-full max-w-4xl mx-auto\">\n      <p class=\"mb-2 font-semibold\">{{ $strings.HeaderAudioTracks }}</p>\n      <div class=\"w-full mx-auto border border-white/10 bg-bg\">\n        <div class=\"flex py-2 px-4 bg-primary/25\">\n          <div class=\"w-10 text-xs font-semibold text-gray-200\">#</div>\n          <div class=\"grow text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelFilename }}</div>\n          <div class=\"w-20 text-xs font-semibold uppercase text-gray-200 hidden lg:block\">{{ $strings.LabelChannels }}</div>\n          <div class=\"w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block\">{{ $strings.LabelCodec }}</div>\n          <div class=\"w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block\">{{ $strings.LabelBitrate }}</div>\n          <div class=\"w-16 text-xs font-semibold uppercase text-gray-200\">{{ $strings.LabelSize }}</div>\n          <div class=\"w-24\"></div>\n        </div>\n        <template v-for=\"file in audioFiles\">\n          <div :key=\"file.index\" class=\"flex py-2 px-4 text-xs sm:text-sm\" :class=\"file.index % 2 === 0 ? 'bg-primary/25' : ''\">\n            <div class=\"w-10 min-w-10\">{{ file.index }}</div>\n            <div class=\"grow\">\n              {{ file.metadata.filename }}\n            </div>\n            <div class=\"w-20 min-w-20 text-gray-200 hidden lg:block\">{{ file.channels || 'unknown' }} ({{ file.channelLayout || 'unknown' }})</div>\n            <div class=\"w-16 min-w-16 text-gray-200 hidden md:block\">\n              {{ file.codec || 'unknown' }}\n            </div>\n            <div class=\"w-16 min-w-16 text-gray-200 hidden md:block\">\n              {{ $bytesPretty(file.bitRate || 0, 0) }}\n            </div>\n            <div class=\"w-16 min-w-16 text-gray-200\">\n              {{ $bytesPretty(file.metadata.size) }}\n            </div>\n            <div class=\"w-24 min-w-24\">\n              <div class=\"flex justify-center\">\n                <span v-if=\"audioFilesFinished[file.ino]\" class=\"material-symbols text-xl text-success leading-none\">check_circle</span>\n                <div v-else-if=\"audioFilesEncoding[file.ino]\">\n                  <span class=\"font-mono text-success leading-none\">{{ audioFilesEncoding[file.ino] }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </template>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, app, redirect, route }) {\n    if (!store.state.user.user) {\n      return redirect(`/login?redirect=${route.path}`)\n    }\n    if (!store.getters['user/getIsAdminOrUp']) {\n      return redirect('/?error=unauthorized')\n    }\n    const libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!libraryItem) {\n      console.error('Not found...', params.id)\n      return redirect('/?error=not found')\n    }\n    if (libraryItem.mediaType !== 'book') {\n      console.error('Invalid media type')\n      return redirect('/?error=invalid media type')\n    }\n    if (!libraryItem.media.audioFiles.length) {\n      console.error('No audio files')\n      return redirect('/?error=no audio files')\n    }\n\n    // Fetch and set library if this items library does not match the current\n    if (store.state.libraries.currentLibraryId !== libraryItem.libraryId || !store.state.libraries.filterData) {\n      await store.dispatch('libraries/fetch', libraryItem.libraryId)\n    }\n\n    return {\n      libraryItem\n    }\n  },\n  data() {\n    return {\n      processing: false,\n      metadataObject: null,\n      selectedTool: 'embed',\n      isCancelingEncode: false,\n      shouldBackupAudioFiles: true,\n      encodingOptions: {\n        bitrate: '128k',\n        channels: '2',\n        codec: 'aac'\n      }\n    }\n  },\n  watch: {\n    task: {\n      handler(newVal) {\n        if (newVal) {\n          this.taskUpdated(newVal)\n        }\n      }\n    }\n  },\n  computed: {\n    audioFilesEncoding() {\n      return this.$store.getters['tasks/getAudioFilesEncoding'](this.libraryItemId) || {}\n    },\n    audioFilesFinished() {\n      return this.$store.getters['tasks/getAudioFilesFinished'](this.libraryItemId) || {}\n    },\n    progress() {\n      return this.$store.getters['tasks/getTaskProgress'](this.libraryItemId) || '0%'\n    },\n    isEmbedTool() {\n      return this.selectedTool === 'embed'\n    },\n    isM4BTool() {\n      return this.selectedTool === 'm4b'\n    },\n    libraryItemId() {\n      return this.libraryItem.id\n    },\n    libraryItemRelPath() {\n      return this.libraryItem.relPath\n    },\n    media() {\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    audioFiles() {\n      return (this.media.audioFiles || []).filter((af) => !af.exclude)\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    metadataChapters() {\n      return this.media.chapters || []\n    },\n    availableTools() {\n      return [\n        { value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },\n        { value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }\n      ]\n    },\n    taskFailed() {\n      return this.isTaskFinished && this.task.isFailed\n    },\n    taskError() {\n      return this.taskFailed ? this.task.error || 'Unknown Error' : null\n    },\n    isTaskFinished() {\n      return this.task && this.task.isFinished\n    },\n    tasks() {\n      return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)\n    },\n    embedTask() {\n      return this.tasks.find((t) => t.action === 'embed-metadata')\n    },\n    encodeTask() {\n      return this.tasks.find((t) => t.action === 'encode-m4b')\n    },\n    task() {\n      if (this.isEmbedTool) return this.embedTask\n      else if (this.isM4BTool) return this.encodeTask\n      return null\n    },\n    taskRunning() {\n      return this.task && !this.task.isFinished\n    },\n    queuedEmbedLIds() {\n      return this.$store.state.tasks.queuedEmbedLIds || []\n    },\n    isMetadataEmbedQueued() {\n      return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)\n    },\n    encodeTaskHasEncodingOptions() {\n      return this.isM4BTool && !!this.encodeTask?.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0\n    }\n  },\n  methods: {\n    toggleBackupAudioFiles(val) {\n      localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0)\n    },\n    bitrateChanged(val) {\n      localStorage.setItem('embedMetadataBitrate', val)\n    },\n    channelsChanged(val) {\n      localStorage.setItem('embedMetadataChannels', val)\n    },\n    codecChanged(val) {\n      localStorage.setItem('embedMetadataCodec', val)\n    },\n    cancelEncodeClick() {\n      this.isCancelingEncode = true\n      this.$axios\n        .$delete(`/api/tools/item/${this.libraryItemId}/encode-m4b`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastEncodeCancelSucces)\n        })\n        .catch((error) => {\n          console.error('Failed to cancel encode', error)\n          this.$toast.error(this.$strings.ToastEncodeCancelFailed)\n        })\n        .finally(() => {\n          this.isCancelingEncode = false\n        })\n    },\n    encodeM4bClick() {\n      if (this.$refs.bitrateInput) this.$refs.bitrateInput.blur()\n      if (this.$refs.channelsInput) this.$refs.channelsInput.blur()\n      if (this.$refs.codecInput) this.$refs.codecInput.blur()\n\n      const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()\n\n      this.encodingOptions = encodeOptions\n\n      const queryParams = new URLSearchParams(encodeOptions)\n\n      this.processing = true\n      this.$axios\n        .$post(`/api/tools/item/${this.libraryItemId}/encode-m4b?${queryParams.toString()}`)\n        .then(() => {\n          console.log('Ab m4b merge started')\n        })\n        .catch((error) => {\n          var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'\n          this.$toast.error(errorMsg)\n          this.processing = false\n        })\n    },\n    embedClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmEmbedMetadataInAudioFiles', [this.audioFiles.length]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.updateAudioFileMetadata()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    updateAudioFileMetadata() {\n      this.processing = true\n      this.$axios\n        .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?backup=${this.shouldBackupAudioFiles ? 1 : 0}`)\n        .then(() => {\n          console.log('Audio metadata encode started')\n        })\n        .catch((error) => {\n          console.error('Audio metadata encode failed', error)\n          this.processing = false\n        })\n    },\n    selectedToolUpdated() {\n      let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`\n      window.history.replaceState({ path: newurl }, '', newurl)\n    },\n    init() {\n      this.fetchMetadataEmbedObject()\n      if (this.$route.query.tool === 'm4b') {\n        if (this.availableTools.some((t) => t.value === 'm4b')) {\n          this.selectedTool = 'm4b'\n        } else {\n          this.selectedToolUpdated()\n        }\n      }\n\n      if (this.task) this.taskUpdated(this.task)\n\n      const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')\n      this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0\n\n      if (this.encodeTaskHasEncodingOptions) {\n        if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate\n        if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels\n        if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec\n      }\n    },\n    fetchMetadataEmbedObject() {\n      this.$axios\n        .$get(`/api/items/${this.libraryItemId}/metadata-object`)\n        .then((metadataObject) => {\n          this.metadataObject = metadataObject\n        })\n        .catch((error) => {\n          console.error('Failed to fetch metadata object', error)\n        })\n    },\n    taskUpdated(task) {\n      this.processing = !task.isFinished\n    },\n    editItem() {\n      this.$store.commit('showEditModal', this.libraryItem)\n    },\n    libraryItemUpdated(libraryItem) {\n      if (libraryItem.id === this.libraryItem.id) {\n        this.libraryItem = libraryItem\n        this.fetchMetadataEmbedObject()\n      }\n    }\n  },\n  mounted() {\n    this.init()\n\n    this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/author/_id.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page overflow-y-auto p-4 md:p-8\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"max-w-6xl mx-auto\">\n      <div class=\"flex flex-wrap sm:flex-nowrap justify-center mb-6\">\n        <div class=\"w-48 min-w-48\">\n          <div class=\"w-full h-60\">\n            <covers-author-image :author=\"author\" rounded-sm=\"0\" />\n          </div>\n        </div>\n        <div class=\"grow py-4 sm:py-0 px-4 md:px-8\">\n          <div class=\"flex items-center mb-8\">\n            <h1 class=\"text-2xl\">{{ author.name }}</h1>\n\n            <button v-if=\"userCanUpdate\" class=\"w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100\" @click=\"editAuthor\">\n              <span class=\"material-symbols text-base\">edit</span>\n            </button>\n          </div>\n\n          <p v-if=\"author.description\" class=\"text-white/60 uppercase text-xs mb-2\">{{ $strings.LabelDescription }}</p>\n          <p ref=\"description\" id=\"author-description\" class=\"text-white max-w-3xl text-base whitespace-pre-wrap\" :class=\"{ 'show-full': showFullDescription }\">{{ author.description }}</p>\n          <button v-if=\"isDescriptionClamped\" class=\"py-0.5 flex items-center text-slate-300 hover:text-white\" @click=\"showFullDescription = !showFullDescription\">\n            {{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class=\"material-symbols text-xl pl-1\">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>\n          </button>\n        </div>\n      </div>\n\n      <div class=\"py-4\">\n        <widgets-item-slider :items=\"libraryItems\" shelf-id=\"author-books\" :bookshelf-view=\"$constants.BookshelfView.AUTHOR\">\n          <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`\" class=\"hover:underline\">\n            <h2 class=\"text-lg\">{{ libraryItems.length }} {{ $strings.LabelBooks }}</h2>\n          </nuxt-link>\n        </widgets-item-slider>\n      </div>\n\n      <div v-for=\"series in authorSeries\" :key=\"series.id\" class=\"py-4\">\n        <widgets-item-slider :items=\"series.items\" :shelf-id=\"series.id\" :bookshelf-view=\"$constants.BookshelfView.AUTHOR\">\n          <nuxt-link :to=\"`/library/${currentLibraryId}/series/${series.id}`\" class=\"hover:underline\">\n            <h2 class=\"text-lg\">{{ series.name }}</h2>\n          </nuxt-link>\n          <p class=\"text-white/40 text-base px-2\">{{ $strings.LabelSeries }}</p>\n        </widgets-item-slider>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, app, params, redirect, query }) {\n    const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {\n      console.error('Failed to get author', error)\n      return null\n    })\n\n    if (!author) {\n      return redirect(`/library/${store.state.libraries.currentLibraryId}/bookshelf/authors`)\n    }\n\n    if (store.state.libraries.currentLibraryId !== author.libraryId || !store.state.libraries.filterData) {\n      await store.dispatch('libraries/fetch', author.libraryId)\n    }\n\n    return {\n      author\n    }\n  },\n  data() {\n    return {\n      isDescriptionClamped: false,\n      showFullDescription: false\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    libraryItems() {\n      return this.author.libraryItems || []\n    },\n    authorSeries() {\n      return this.author.series || []\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    }\n  },\n  methods: {\n    checkDescriptionClamped() {\n      if (!this.$refs.description) return\n      this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight\n    },\n    editAuthor() {\n      this.$store.commit('globals/showEditAuthorModal', this.author)\n    },\n    authorUpdated(author) {\n      if (author.id === this.author.id) {\n        console.log('Author was updated', author)\n        this.author = {\n          ...author,\n          series: this.authorSeries,\n          libraryItems: this.libraryItems\n        }\n        this.$nextTick(this.checkDescriptionClamped)\n      }\n    },\n    authorRemoved(author) {\n      if (author.id === this.author.id) {\n        console.warn('Author was removed')\n        this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/authors`)\n      }\n    }\n  },\n  mounted() {\n    if (!this.author) this.$router.replace('/')\n    this.checkDescriptionClamped()\n\n    this.$root.socket.on('author_updated', this.authorUpdated)\n    this.$root.socket.on('author_removed', this.authorRemoved)\n  },\n  beforeDestroy() {\n    this.$root.socket.off('author_updated', this.authorUpdated)\n    this.$root.socket.off('author_removed', this.authorRemoved)\n  }\n}\n</script>\n\n<style scoped>\n#author-description {\n  overflow: hidden;\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 4;\n  max-height: 6.25rem;\n  transition: all 0.3s ease-in-out;\n}\n#author-description.show-full {\n  -webkit-line-clamp: unset;\n  max-height: 999rem;\n}\n</style>\n"
  },
  {
    "path": "client/pages/batch/index.vue",
    "content": "<template>\n  <div ref=\"page\" id=\"page-wrapper\" class=\"page px-6 pt-6 pb-52 overflow-y-auto\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"border border-white/10 max-w-7xl mx-auto mb-10 mt-5\">\n      <div class=\"flex items-center px-4 py-4 cursor-pointer\" @click=\"openMapOptions = !openMapOptions\" @mousedown.prevent @mouseup.prevent>\n        <span class=\"material-symbols text-2xl\">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>\n\n        <p class=\"ml-4 text-gray-200 text-lg\">{{ $strings.HeaderMapDetails }}</p>\n\n        <div class=\"grow\" />\n\n        <div class=\"w-64 flex\">\n          <button class=\"w-32 h-8 rounded-l-md shadow-md border border-gray-600\" :class=\"!isMapOverwrite ? 'bg-bg text-white/30' : 'bg-primary'\" @click.stop.prevent=\"mapDetailsType = 'overwrite'\">\n            <p class=\"text-sm\">{{ $strings.LabelOverwrite }}</p>\n          </button>\n          <button class=\"w-32 h-8 rounded-r-md shadow-md border border-gray-600\" :class=\"!isMapAppend ? 'bg-bg text-white/30' : 'bg-primary'\" @click.stop.prevent=\"mapDetailsType = 'append'\">\n            <p class=\"text-sm\">{{ $strings.LabelAppend }}</p>\n          </button>\n        </div>\n      </div>\n      <div class=\"overflow-hidden\">\n        <transition name=\"slide\">\n          <div v-if=\"openMapOptions\" class=\"flex flex-wrap\">\n            <div v-if=\"!isPodcastLibrary && !isMapAppend\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.subtitle\" />\n              <ui-text-input-with-label ref=\"subtitleInput\" v-model=\"batchDetails.subtitle\" :disabled=\"!selectedBatchUsage.subtitle\" :label=\"$strings.LabelSubtitle\" trim-whitespace class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isPodcastLibrary\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.authors\" />\n              <!-- Authors filter only contains authors in this library, uses filter data -->\n              <ui-multi-select-query-input ref=\"authorsSelect\" v-model=\"batchDetails.authors\" :disabled=\"!selectedBatchUsage.authors\" :label=\"$strings.LabelAuthors\" filter-key=\"authors\" class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isPodcastLibrary && !isMapAppend\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.publishedYear\" />\n              <ui-text-input-with-label ref=\"publishedYearInput\" v-model=\"batchDetails.publishedYear\" :disabled=\"!selectedBatchUsage.publishedYear\" :label=\"$strings.LabelPublishYear\" trim-whitespace class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isPodcastLibrary\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.series\" />\n              <ui-multi-select ref=\"seriesSelect\" v-model=\"batchDetails.series\" :disabled=\"!selectedBatchUsage.series\" :label=\"$strings.LabelSeries\" :items=\"existingSeriesNames\" @newItem=\"newSeriesItem\" @removedItem=\"removedSeriesItem\" class=\"mb-5 ml-4\" />\n            </div>\n            <div class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.genres\" />\n              <ui-multi-select ref=\"genresSelect\" v-model=\"batchDetails.genres\" :disabled=\"!selectedBatchUsage.genres\" :label=\"$strings.LabelGenres\" :items=\"genreItems\" @newItem=\"newGenreItem\" @removedItem=\"removedGenreItem\" class=\"mb-5 ml-4\" />\n            </div>\n            <div class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.tags\" />\n              <ui-multi-select ref=\"tagsSelect\" v-model=\"batchDetails.tags\" :label=\"$strings.LabelTags\" :disabled=\"!selectedBatchUsage.tags\" :items=\"tagItems\" @newItem=\"newTagItem\" @removedItem=\"removedTagItem\" class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isPodcastLibrary\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.narrators\" />\n              <ui-multi-select ref=\"narratorsSelect\" v-model=\"batchDetails.narrators\" :disabled=\"!selectedBatchUsage.narrators\" :label=\"$strings.LabelNarrators\" :items=\"narratorItems\" @newItem=\"newNarratorItem\" @removedItem=\"removedNarratorItem\" class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isPodcastLibrary && !isMapAppend\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.publisher\" />\n              <ui-text-input-with-label ref=\"publisherInput\" v-model=\"batchDetails.publisher\" :disabled=\"!selectedBatchUsage.publisher\" :label=\"$strings.LabelPublisher\" trim-whitespace class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isMapAppend\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.language\" />\n              <ui-text-input-with-label ref=\"languageInput\" v-model=\"batchDetails.language\" :disabled=\"!selectedBatchUsage.language\" :label=\"$strings.LabelLanguage\" trim-whitespace class=\"mb-5 ml-4\" />\n            </div>\n            <div v-if=\"!isMapAppend\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.explicit\" />\n              <div class=\"ml-4\">\n                <ui-checkbox\n                  v-model=\"batchDetails.explicit\"\n                  :label=\"$strings.LabelExplicit\"\n                  :disabled=\"!selectedBatchUsage.explicit\"\n                  :checkbox-bg=\"!selectedBatchUsage.explicit ? 'bg' : 'primary'\"\n                  :check-color=\"!selectedBatchUsage.explicit ? 'gray-600' : 'green-500'\"\n                  border-color=\"gray-600\"\n                  :label-class=\"!selectedBatchUsage.explicit ? 'pl-2 text-base text-gray-400 font-semibold' : 'pl-2 text-base font-semibold'\"\n                />\n              </div>\n            </div>\n            <div v-if=\"!isPodcastLibrary && !isMapAppend\" class=\"flex items-center px-4 h-18 w-1/2\">\n              <ui-checkbox v-model=\"selectedBatchUsage.abridged\" />\n              <div class=\"ml-4\">\n                <ui-checkbox\n                  v-model=\"batchDetails.abridged\"\n                  :label=\"$strings.LabelAbridged\"\n                  :disabled=\"!selectedBatchUsage.abridged\"\n                  :checkbox-bg=\"!selectedBatchUsage.abridged ? 'bg' : 'primary'\"\n                  :check-color=\"!selectedBatchUsage.abridged ? 'gray-600' : 'green-500'\"\n                  border-color=\"gray-600\"\n                  :label-class=\"!selectedBatchUsage.abridged ? 'pl-2 text-base text-gray-400 font-semibold' : 'pl-2 text-base font-semibold'\"\n                />\n              </div>\n            </div>\n\n            <div class=\"w-full flex items-center p-4 space-x-2\">\n              <ui-btn small @click.stop=\"resetMapDetails\">{{ $strings.ButtonReset }}</ui-btn>\n              <ui-tooltip direction=\"bottom\" :text=\"$strings.MessageBatchEditPopulateMapDetailsAllHelp\">\n                <ui-btn small :disabled=\"!hasSelectedBatchUsage\" @click.stop=\"populateFromExisting()\">{{ $strings.ButtonBatchEditPopulateFromExisting }}</ui-btn>\n              </ui-tooltip>\n              <div class=\"grow\" />\n              <ui-btn color=\"bg-success\" :disabled=\"!hasSelectedBatchUsage\" :padding-x=\"8\" small class=\"text-base\" :loading=\"isProcessing\" @click=\"mapBatchDetails\">{{ $strings.ButtonApply }}</ui-btn>\n            </div>\n          </div>\n        </transition>\n      </div>\n    </div>\n\n    <div class=\"flex justify-center flex-wrap\">\n      <template v-for=\"libraryItem in libraryItemCopies\">\n        <div :key=\"libraryItem.id\" class=\"w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px\">\n          <div class=\"flex items-center justify-end\">\n            <ui-tooltip direction=\"bottom\" :text=\"$strings.MessageBatchEditPopulateMapDetailsItemHelp\">\n              <ui-btn small :disabled=\"!hasSelectedBatchUsage\" @click=\"populateFromExisting(libraryItem.id)\">{{ $strings.ButtonBatchEditPopulateMapDetails }}</ui-btn>\n            </ui-tooltip>\n          </div>\n          <widgets-book-details-edit v-if=\"libraryItem.mediaType === 'book'\" :ref=\"`itemForm-${libraryItem.id}`\" :library-item=\"libraryItem\" @change=\"handleItemChange\" />\n          <widgets-podcast-details-edit v-else :ref=\"`itemForm-${libraryItem.id}`\" :library-item=\"libraryItem\" @change=\"handleItemChange\" />\n        </div>\n      </template>\n    </div>\n    <div v-show=\"isProcessing\" class=\"fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black/60\">\n      <ui-loading-indicator />\n    </div>\n\n    <div :class=\"isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''\" class=\"w-full h-20 px-4 flex items-center border-t border-bg z-40\" :style=\"{ bottom: streamLibraryItem ? '165px' : '0px' }\">\n      <div class=\"grow\" />\n      <ui-btn color=\"bg-success\" :padding-x=\"8\" class=\"text-lg\" :loading=\"isProcessing\" :disabled=\"!hasChanges\" @click.prevent=\"saveClick\">{{ $strings.ButtonSave }}</ui-btn>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, redirect, app }) {\n    if (!store.state.globals.selectedMediaItems.length) {\n      return redirect('/')\n    }\n\n    const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)\n    const libraryItems = await app.$axios\n      .$post(`/api/items/batch/get`, { libraryItemIds })\n      .then((res) => res.libraryItems)\n      .catch((error) => {\n        const errorMsg = error.response.data || 'Failed to get items'\n        console.error(errorMsg, error)\n        return []\n      })\n    return {\n      mediaType: libraryItems[0].mediaType,\n      libraryItems\n    }\n  },\n  data() {\n    return {\n      isProcessing: false,\n      libraryItemCopies: [],\n      isScrollable: false,\n      newTagItems: [],\n      newGenreItems: [],\n      newNarratorItems: [],\n      mapDetailsType: 'overwrite',\n      batchDetails: {\n        subtitle: null,\n        authors: null,\n        publishedYear: null,\n        series: [],\n        genres: [],\n        tags: [],\n        narrators: [],\n        publisher: null,\n        language: null,\n        explicit: false,\n        abridged: false\n      },\n      selectedBatchUsage: {\n        subtitle: false,\n        authors: false,\n        publishedYear: false,\n        series: false,\n        genres: false,\n        tags: false,\n        narrators: false,\n        publisher: false,\n        language: false,\n        explicit: false,\n        abridged: false\n      },\n      appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],\n      openMapOptions: false,\n      itemsWithChanges: []\n    }\n  },\n  computed: {\n    isMapOverwrite() {\n      return this.mapDetailsType === 'overwrite'\n    },\n    isMapAppend() {\n      return this.mapDetailsType === 'append'\n    },\n    isPodcastLibrary() {\n      return this.mediaType === 'podcast'\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    genreItems() {\n      return this.genres.concat(this.newGenreItems)\n    },\n    tagItems() {\n      return this.tags.concat(this.newTagItems)\n    },\n    narratorItems() {\n      return [...this.narrators, ...this.newNarratorItems]\n    },\n    genres() {\n      return this.filterData.genres || []\n    },\n    tags() {\n      return this.filterData.tags || []\n    },\n    series() {\n      return this.filterData.series || []\n    },\n    narrators() {\n      return this.filterData.narrators || []\n    },\n    authors() {\n      return this.filterData.authors || []\n    },\n    existingSeriesNames() {\n      return this.series.map((se) => se.name)\n    },\n    filterData() {\n      return this.$store.state.libraries.filterData || {}\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    hasSelectedBatchUsage() {\n      return Object.values(this.selectedBatchUsage).some((b) => !!b)\n    },\n    hasChanges() {\n      return this.itemsWithChanges.length > 0\n    }\n  },\n  methods: {\n    resetMapDetails() {\n      this.blurBatchForm()\n      this.batchDetails = {\n        subtitle: null,\n        authors: null,\n        publishedYear: null,\n        series: [],\n        genres: [],\n        tags: [],\n        narrators: [],\n        publisher: null,\n        language: null,\n        explicit: false,\n        abridged: false\n      }\n      this.selectedBatchUsage = {\n        subtitle: false,\n        authors: false,\n        publishedYear: false,\n        series: false,\n        genres: false,\n        tags: false,\n        narrators: false,\n        publisher: false,\n        language: false,\n        explicit: false,\n        abridged: false\n      }\n    },\n    populateFromExisting(libraryItemId) {\n      this.blurBatchForm()\n\n      let libraryItemsToMap = this.libraryItemCopies\n      if (libraryItemId) {\n        libraryItemsToMap = this.libraryItemCopies.filter((li) => li.id === libraryItemId)\n      }\n\n      for (const key in this.selectedBatchUsage) {\n        if (!this.selectedBatchUsage[key]) continue\n        if (this.isMapAppend && !this.appendableKeys.includes(key)) continue\n\n        let existingValues = undefined\n        libraryItemsToMap.forEach((li) => {\n          if (key === 'tags') {\n            if (!existingValues) existingValues = []\n            li.media.tags.forEach((tag) => {\n              if (!existingValues.includes(tag)) {\n                existingValues.push(tag)\n              }\n            })\n          } else if (key === 'authors') {\n            if (!existingValues) existingValues = []\n            li.media.metadata[key].forEach((entity) => {\n              if (!existingValues.some((au) => au.id === entity.id)) {\n                existingValues.push({\n                  id: entity.id,\n                  name: entity.name\n                })\n              }\n            })\n          } else if (key === 'series') {\n            if (!existingValues) existingValues = []\n            li.media.metadata[key].forEach((entity) => {\n              if (!existingValues.includes(entity.name)) {\n                existingValues.push(entity.name)\n              }\n            })\n          } else if (key === 'genres' || key === 'narrators') {\n            if (!existingValues) existingValues = []\n            li.media.metadata[key].forEach((item) => {\n              if (!existingValues.includes(item)) {\n                existingValues.push(item)\n              }\n            })\n          } else if (existingValues === undefined) {\n            existingValues = li.media.metadata[key]\n          }\n        })\n\n        this.batchDetails[key] = existingValues\n      }\n    },\n    handleItemChange(itemChange) {\n      if (!itemChange.hasChanges) {\n        this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId)\n      } else if (!this.itemsWithChanges.includes(itemChange.libraryItemId)) {\n        this.itemsWithChanges.push(itemChange.libraryItemId)\n      }\n    },\n    blurBatchForm() {\n      if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) {\n        this.$refs.seriesSelect.forceBlur()\n      }\n      if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {\n        this.$refs.authorsSelect.forceBlur()\n      }\n      if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {\n        this.$refs.narratorsSelect.forceBlur()\n      }\n      if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {\n        this.$refs.genresSelect.forceBlur()\n      }\n      if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {\n        this.$refs.tagsSelect.forceBlur()\n      }\n\n      for (const key in this.batchDetails) {\n        if (this.$refs[`${key}Input`] && this.$refs[`${key}Input`].blur) {\n          this.$refs[`${key}Input`].blur()\n        }\n      }\n    },\n    mapBatchDetails() {\n      this.blurBatchForm()\n\n      const batchMapPayload = {}\n      for (const key in this.selectedBatchUsage) {\n        if (!this.selectedBatchUsage[key]) continue\n        if (this.isMapAppend && !this.appendableKeys.includes(key)) continue\n\n        if (key === 'series') {\n          // Map string of series to series objects\n          batchMapPayload[key] = this.batchDetails[key].map((seItem) => {\n            const existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())\n            if (existingSeries) {\n              return existingSeries\n            } else {\n              return {\n                id: `new-${Math.floor(Math.random() * 10000)}`,\n                name: seItem\n              }\n            }\n          })\n        } else {\n          batchMapPayload[key] = this.batchDetails[key]\n        }\n      }\n\n      this.libraryItemCopies.forEach((li) => {\n        const ref = this.getEditFormRef(li.id)\n        ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)\n      })\n      this.$toast.success(this.$strings.ToastBatchApplyDetailsToItemsSuccess)\n    },\n    newSeriesItem(item) {},\n    removedSeriesItem(item) {},\n    newNarratorItem(item) {},\n    removedNarratorItem(item) {},\n    newTagItem(item) {},\n    removedTagItem(item) {},\n    newGenreItem(item) {},\n    removedGenreItem(item) {},\n    init() {\n      // TODO: Better deep cloning of library items\n      this.libraryItemCopies = this.libraryItems.map((li) => {\n        var copy = {\n          ...li\n        }\n        copy.media = { ...li.media }\n        if (copy.media.tags) copy.media.tags = [...copy.media.tags]\n        copy.media.metadata = { ...copy.media.metadata }\n        if (copy.media.metadata.authors) {\n          copy.media.metadata.authors = copy.media.metadata.authors.map((au) => ({ ...au }))\n        }\n        if (copy.media.metadata.series) {\n          copy.media.metadata.series = copy.media.metadata.series.map((se) => ({ ...se }))\n        }\n        if (copy.media.metadata.narrators) {\n          copy.media.metadata.narrators = [...copy.media.metadata.narrators]\n        }\n        if (copy.media.metadata.genres) {\n          copy.media.metadata.genres = [...copy.media.metadata.genres]\n        }\n        copy.originalLibraryItem = li\n        return copy\n      })\n      this.$nextTick(() => {\n        if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) {\n          this.isScrollable = true\n        }\n      })\n    },\n    getEditFormRef(itemId) {\n      var refs = this.$refs[`itemForm-${itemId}`]\n      if (refs && refs.length) return refs[0]\n      return null\n    },\n    saveClick() {\n      var updates = []\n      for (let i = 0; i < this.libraryItemCopies.length; i++) {\n        var editForm = this.getEditFormRef(this.libraryItemCopies[i].id)\n        if (!editForm) {\n          throw new Error('Invalid edit form ref not found')\n        }\n        var details = editForm.getDetails()\n        if (details.hasChanges) {\n          updates.push({\n            id: this.libraryItemCopies[i].id,\n            mediaPayload: details.updatePayload\n          })\n        }\n      }\n      if (!updates.length) {\n        return this.$toast.warning(this.$strings.ToastNoUpdatesNecessary)\n      }\n\n      console.log('Pushing updates', updates)\n      this.isProcessing = true\n      this.$axios\n        .$post('/api/items/batch/update', updates)\n        .then((data) => {\n          this.isProcessing = false\n          if (data.updates) {\n            this.itemsWithChanges = []\n            this.$toast.success(this.$getString('MessageItemsUpdated', [data.updates]))\n            this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)\n          } else {\n            this.$toast.warning(this.$strings.MessageNoUpdatesWereNecessary)\n          }\n        })\n        .catch((error) => {\n          console.error('failed to batch update', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n          this.isProcessing = false\n        })\n    },\n    beforeUnload(e) {\n      if (!e || !this.hasChanges) return\n      e.preventDefault()\n      e.returnValue = ''\n    }\n  },\n  beforeRouteLeave(to, from, next) {\n    if (this.hasChanges) {\n      next(false)\n      window.location = to.path\n    } else {\n      next()\n    }\n  },\n  mounted() {\n    this.init()\n\n    window.addEventListener('beforeunload', this.beforeUnload)\n  },\n  beforeDestroy() {\n    window.removeEventListener('beforeunload', this.beforeUnload)\n  }\n}\n</script>\n\n<style>\n.slide-enter-active,\n.slide-leave-active {\n  transition: transform 0.2s ease;\n}\n\n.slide-enter,\n.slide-leave-to {\n  transform: translateY(-100%);\n  transition: all 150ms ease-in 0s;\n}\n</style>\n"
  },
  {
    "path": "client/pages/collection/_id.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page overflow-hidden\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"w-full h-full overflow-y-auto px-2 py-6 md:p-8\">\n      <div class=\"flex flex-col sm:flex-row max-w-6xl mx-auto\">\n        <div class=\"w-full flex justify-center md:block sm:w-32 md:w-52\" style=\"min-width: 240px\">\n          <div class=\"relative\" style=\"height: fit-content\">\n            <covers-collection-cover :book-items=\"bookItems\" :width=\"240\" :height=\"120 * bookCoverAspectRatio\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n          </div>\n        </div>\n        <div class=\"grow px-2 py-6 md:py-0 md:px-10\">\n          <div class=\"flex items-end flex-row flex-wrap md:flex-nowrap\">\n            <h1 class=\"text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0\">\n              {{ collectionName }}\n            </h1>\n            <div class=\"grow\" />\n\n            <ui-btn v-if=\"showPlayButton\" :disabled=\"streaming\" color=\"bg-success\" :padding-x=\"4\" small class=\"flex items-center h-9 mr-2\" @click=\"clickPlay\">\n              <span v-show=\"!streaming\" class=\"material-symbols fill text-2xl -ml-2 pr-1 text-white\">play_arrow</span>\n              {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }}\n            </ui-btn>\n\n            <!-- RSS feed -->\n            <ui-tooltip v-if=\"rssFeed\" :text=\"$strings.LabelOpenRSSFeed\" direction=\"top\">\n              <ui-icon-btn icon=\"rss_feed\" class=\"mx-0.5\" :bg-color=\"rssFeed ? 'bg-success' : 'bg-primary'\" outlined @click=\"showRSSFeedModal\" />\n            </ui-tooltip>\n\n            <button type=\"button\" class=\"h-9 w-9 flex items-center justify-center shadow-xs pl-3 pr-3 text-left focus:outline-hidden cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px\" @click.stop.prevent=\"editClick\">\n              <span class=\"material-symbols text-xl\">edit</span>\n            </button>\n\n            <ui-context-menu-dropdown :items=\"contextMenuItems\" class=\"mx-px\" @action=\"contextMenuAction\" />\n          </div>\n\n          <div class=\"my-8 max-w-2xl\">\n            <p class=\"text-base text-gray-100\">{{ description }}</p>\n          </div>\n\n          <tables-collection-books-table :books=\"bookItems\" :collection-id=\"collection.id\" />\n        </div>\n      </div>\n    </div>\n    <div v-show=\"processing\" class=\"absolute top-0 left-0 w-full h-full z-10 bg-black/40 flex items-center justify-center\">\n      <ui-loading-indicator />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, app, redirect, route }) {\n    if (!store.state.user.user) {\n      return redirect(`/login?redirect=${route.path}`)\n    }\n    const collection = await app.$axios.$get(`/api/collections/${params.id}?include=rssfeed`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!collection) {\n      return redirect('/')\n    }\n\n    // If collection is a different library then set library as current\n    if (collection.libraryId !== store.state.libraries.currentLibraryId) {\n      await store.dispatch('libraries/fetch', collection.libraryId)\n    }\n\n    store.commit('libraries/addUpdateCollection', collection)\n    return {\n      collectionId: collection.id,\n      rssFeed: collection.rssFeed || null\n    }\n  },\n  data() {\n    return {\n      processing: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    bookItems() {\n      return this.collection.books || []\n    },\n    collectionName() {\n      return this.collection.name || ''\n    },\n    description() {\n      return this.collection.description || ''\n    },\n    collection() {\n      return this.$store.getters['libraries/getCollection'](this.collectionId) || {}\n    },\n    playableBooks() {\n      return this.bookItems.filter((book) => {\n        return !book.isMissing && !book.isInvalid && book.media.tracks.length\n      })\n    },\n    streaming() {\n      return !!this.playableBooks.some((b) => b.id === this.$store.getters['getLibraryItemIdStreaming'])\n    },\n    showPlayButton() {\n      return this.playableBooks.length\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    contextMenuItems() {\n      const items = [\n        {\n          text: this.$strings.MessagePlaylistCreateFromCollection,\n          action: 'create-playlist'\n        }\n      ]\n      if (this.userIsAdminOrUp || this.rssFeed) {\n        items.push({\n          text: this.$strings.LabelOpenRSSFeed,\n          action: 'open-rss-feed'\n        })\n      }\n      if (this.userCanDelete) {\n        items.push({\n          text: this.$strings.ButtonDelete,\n          action: 'delete'\n        })\n      }\n      return items\n    }\n  },\n  methods: {\n    showRSSFeedModal() {\n      this.$store.commit('globals/setRSSFeedOpenCloseModal', {\n        id: this.collectionId,\n        name: this.collectionName,\n        type: 'collection',\n        feed: this.rssFeed\n      })\n    },\n    contextMenuAction({ action }) {\n      if (action === 'delete') {\n        this.removeClick()\n      } else if (action === 'create-playlist') {\n        this.createPlaylistFromCollection()\n      } else if (action === 'open-rss-feed') {\n        this.showRSSFeedModal()\n      }\n    },\n    createPlaylistFromCollection() {\n      this.processing = true\n      this.$axios\n        .$post(`/api/playlists/collection/${this.collectionId}`)\n        .then((playlist) => {\n          if (playlist) {\n            this.$toast.success(this.$strings.ToastPlaylistCreateSuccess)\n            this.$router.push(`/playlist/${playlist.id}`)\n          }\n        })\n        .catch((error) => {\n          const errMsg = error.response ? error.response.data || '' : ''\n          this.$toast.error(errMsg || this.$strings.ToastPlaylistCreateFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    editClick() {\n      this.$store.commit('globals/setEditCollection', this.collection)\n    },\n    removeClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteCollection()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteCollection() {\n      this.processing = true\n      this.$axios\n        .$delete(`/api/collections/${this.collection.id}`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to remove collection', error)\n          this.$toast.error(this.$strings.ToastCollectionRemoveFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    clickPlay() {\n      const queueItems = []\n\n      // Collection queue will start at the first unfinished book\n      //   if all books are finished then entire collection is queued\n      const itemsWithProgress = this.playableBooks.map((item) => {\n        return {\n          ...item,\n          progress: this.$store.getters['user/getUserMediaProgress'](item.id)\n        }\n      })\n\n      const hasUnfinishedItems = itemsWithProgress.some((i) => !i.progress || !i.progress.isFinished)\n      if (!hasUnfinishedItems) {\n        console.warn('All items in collection are finished - starting at first item')\n      }\n\n      for (let i = 0; i < itemsWithProgress.length; i++) {\n        const libraryItem = itemsWithProgress[i]\n        if (!hasUnfinishedItems || !libraryItem.progress || !libraryItem.progress.isFinished) {\n          queueItems.push({\n            libraryItemId: libraryItem.id,\n            libraryId: libraryItem.libraryId,\n            episodeId: null,\n            title: libraryItem.media.metadata.title,\n            subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),\n            caption: '',\n            duration: libraryItem.media.duration || null,\n            coverPath: libraryItem.media.coverPath || null\n          })\n        }\n      }\n\n      if (queueItems.length >= 0) {\n        this.$eventBus.$emit('play-item', {\n          libraryItemId: queueItems[0].libraryItemId,\n          queueItems\n        })\n      }\n    },\n    rssFeedOpen(data) {\n      if (data.entityId === this.collectionId) {\n        console.log('RSS Feed Opened', data)\n        this.rssFeed = data\n      }\n    },\n    rssFeedClosed(data) {\n      if (data.entityId === this.collectionId) {\n        console.log('RSS Feed Closed', data)\n        this.rssFeed = null\n      }\n    }\n  },\n  mounted() {\n    this.$root.socket.on('rss_feed_open', this.rssFeedOpen)\n    this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)\n  },\n  beforeDestroy() {\n    this.$root.socket.off('rss_feed_open', this.rssFeedOpen)\n    this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/api-keys/index.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderApiKeys\">\n      <template #header-items>\n        <div v-if=\"numApiKeys\" class=\"mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center\">\n          <span>{{ numApiKeys }}</span>\n        </div>\n\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/api-keys\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n\n        <div class=\"grow\" />\n\n        <ui-btn color=\"bg-primary\" :disabled=\"loadingUsers || users.length === 0\" small @click=\"setShowApiKeyModal()\">{{ $strings.ButtonAddApiKey }}</ui-btn>\n      </template>\n\n      <tables-api-keys-table ref=\"apiKeysTable\" class=\"pt-2\" @edit=\"setShowApiKeyModal\" @numApiKeys=\"(count) => (numApiKeys = count)\" />\n    </app-settings-content>\n    <modals-api-key-modal ref=\"apiKeyModal\" v-model=\"showApiKeyModal\" :api-key=\"selectedApiKey\" :users=\"users\" @created=\"apiKeyCreated\" @updated=\"apiKeyUpdated\" />\n    <modals-api-key-created-modal ref=\"apiKeyCreatedModal\" v-model=\"showApiKeyCreatedModal\" :api-key=\"selectedApiKey\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      loadingUsers: false,\n      selectedApiKey: null,\n      showApiKeyModal: false,\n      showApiKeyCreatedModal: false,\n      numApiKeys: 0,\n      users: []\n    }\n  },\n  methods: {\n    apiKeyCreated(apiKey) {\n      this.numApiKeys++\n      this.selectedApiKey = apiKey\n      this.showApiKeyCreatedModal = true\n      if (this.$refs.apiKeysTable) {\n        this.$refs.apiKeysTable.addApiKey(apiKey)\n      }\n    },\n    apiKeyUpdated(apiKey) {\n      if (this.$refs.apiKeysTable) {\n        this.$refs.apiKeysTable.updateApiKey(apiKey)\n      }\n    },\n    setShowApiKeyModal(selectedApiKey) {\n      this.selectedApiKey = selectedApiKey\n      this.showApiKeyModal = true\n    },\n    loadUsers() {\n      this.loadingUsers = true\n      this.$axios\n        .$get('/api/users')\n        .then((res) => {\n          this.users = res.users.sort((a, b) => {\n            return a.createdAt - b.createdAt\n          })\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n        })\n        .finally(() => {\n          this.loadingUsers = false\n        })\n    }\n  },\n  mounted() {\n    this.loadUsers()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/authentication.vue",
    "content": "<template>\n  <div id=\"authentication-settings\">\n    <app-settings-content :header-text=\"$strings.HeaderAuthentication\">\n      <div class=\"w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25\">\n        <div class=\"flex items-center\">\n          <ui-checkbox v-model=\"showCustomLoginMessage\" checkbox-bg=\"bg\" />\n          <p class=\"text-lg pl-4\">{{ $strings.HeaderCustomMessageOnLogin }}</p>\n        </div>\n        <transition name=\"slide\">\n          <div v-if=\"showCustomLoginMessage\" class=\"w-full pt-4\">\n            <ui-rich-text-editor v-model=\"newAuthSettings.authLoginCustomMessage\" />\n          </div>\n        </transition>\n      </div>\n\n      <div class=\"w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25\">\n        <div class=\"flex items-center\">\n          <ui-checkbox v-model=\"enableLocalAuth\" checkbox-bg=\"bg\" />\n          <p class=\"text-lg pl-4\">{{ $strings.HeaderPasswordAuthentication }}</p>\n        </div>\n      </div>\n      <div class=\"w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25\">\n        <div class=\"flex items-center\">\n          <ui-checkbox v-model=\"enableOpenIDAuth\" checkbox-bg=\"bg\" />\n          <p class=\"text-lg pl-4\">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>\n          <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n            <a href=\"https://www.audiobookshelf.org/guides/oidc_authentication\" target=\"_blank\" class=\"inline-flex\">\n              <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n            </a>\n          </ui-tooltip>\n        </div>\n\n        <transition name=\"slide\">\n          <div v-if=\"enableOpenIDAuth\" class=\"flex flex-wrap pt-4\">\n            <div class=\"w-full flex items-center mb-2\">\n              <div class=\"grow\">\n                <ui-text-input-with-label ref=\"issuerUrl\" v-model=\"newAuthSettings.authOpenIDIssuerURL\" :disabled=\"savingSettings\" :label=\"'Issuer URL'\" />\n              </div>\n              <div class=\"w-36 mx-1 mt-[1.375rem]\">\n                <ui-btn class=\"h-[2.375rem] text-sm inline-flex items-center justify-center w-full\" type=\"button\" :padding-y=\"0\" :padding-x=\"4\" @click.stop=\"autoPopulateOIDCClick\">\n                  <span class=\"material-symbols text-base\">auto_fix_high</span>\n                  <span class=\"whitespace-nowrap break-keep pl-1\">Auto-populate</span></ui-btn\n                >\n              </div>\n            </div>\n\n            <ui-text-input-with-label ref=\"authorizationUrl\" v-model=\"newAuthSettings.authOpenIDAuthorizationURL\" :disabled=\"savingSettings\" :label=\"'Authorize URL'\" class=\"mb-2\" />\n\n            <ui-text-input-with-label ref=\"tokenUrl\" v-model=\"newAuthSettings.authOpenIDTokenURL\" :disabled=\"savingSettings\" :label=\"'Token URL'\" class=\"mb-2\" />\n\n            <ui-text-input-with-label ref=\"userInfoUrl\" v-model=\"newAuthSettings.authOpenIDUserInfoURL\" :disabled=\"savingSettings\" :label=\"'Userinfo URL'\" class=\"mb-2\" />\n\n            <ui-text-input-with-label ref=\"jwksUrl\" v-model=\"newAuthSettings.authOpenIDJwksURL\" :disabled=\"savingSettings\" :label=\"'JWKS URL'\" class=\"mb-2\" />\n\n            <ui-text-input-with-label ref=\"logoutUrl\" v-model=\"newAuthSettings.authOpenIDLogoutURL\" :disabled=\"savingSettings\" :label=\"'Logout URL'\" class=\"mb-2\" />\n\n            <ui-text-input-with-label ref=\"openidClientId\" v-model=\"newAuthSettings.authOpenIDClientID\" :disabled=\"savingSettings\" :label=\"'Client ID'\" class=\"mb-2\" />\n\n            <ui-text-input-with-label ref=\"openidClientSecret\" v-model=\"newAuthSettings.authOpenIDClientSecret\" :disabled=\"savingSettings\" :label=\"'Client Secret'\" class=\"mb-2\" />\n\n            <ui-dropdown v-if=\"openIdSigningAlgorithmsSupportedByIssuer.length\" v-model=\"newAuthSettings.authOpenIDTokenSigningAlgorithm\" :items=\"openIdSigningAlgorithmsSupportedByIssuer\" :label=\"'Signing Algorithm'\" :disabled=\"savingSettings\" class=\"mb-2\" />\n            <ui-text-input-with-label v-else ref=\"openidTokenSigningAlgorithm\" v-model=\"newAuthSettings.authOpenIDTokenSigningAlgorithm\" :disabled=\"savingSettings\" :label=\"'Signing Algorithm'\" class=\"mb-2\" />\n\n            <ui-multi-select ref=\"redirectUris\" v-model=\"newAuthSettings.authOpenIDMobileRedirectURIs\" :items=\"newAuthSettings.authOpenIDMobileRedirectURIs\" :label=\"$strings.LabelMobileRedirectURIs\" class=\"mb-2\" :menuDisabled=\"true\" :disabled=\"savingSettings\" />\n            <p class=\"sm:pl-4 text-sm text-gray-300 mb-2\" v-html=\"$strings.LabelMobileRedirectURIsDescription\" />\n\n            <div class=\"flex sm:items-center flex-col sm:flex-row pt-1 mb-2\">\n              <div class=\"w-44\">\n                <ui-dropdown v-model=\"newAuthSettings.authOpenIDSubfolderForRedirectURLs\" small :items=\"subfolderOptions\" :label=\"$strings.LabelWebRedirectURLsSubfolder\" :disabled=\"savingSettings\" />\n              </div>\n              <div class=\"mt-2 sm:mt-5\">\n                <p class=\"sm:pl-4 text-sm text-gray-300\">{{ $strings.LabelWebRedirectURLsDescription }}</p>\n                <p class=\"sm:pl-4 text-sm text-gray-300 mb-2\">\n                  <code>{{ webCallbackURL }}</code>\n                  <br />\n                  <code>{{ mobileAppCallbackURL }}</code>\n                </p>\n              </div>\n            </div>\n\n            <ui-text-input-with-label ref=\"buttonTextInput\" v-model=\"newAuthSettings.authOpenIDButtonText\" :disabled=\"savingSettings\" :label=\"$strings.LabelButtonText\" class=\"mb-2\" />\n\n            <div class=\"flex sm:items-center flex-col sm:flex-row pt-1 mb-2\">\n              <div class=\"w-44\">\n                <ui-dropdown v-model=\"newAuthSettings.authOpenIDMatchExistingBy\" small :items=\"matchingExistingOptions\" :label=\"$strings.LabelMatchExistingUsersBy\" :disabled=\"savingSettings\" />\n              </div>\n              <p class=\"sm:pl-4 text-sm text-gray-300 mt-2 sm:mt-5\">{{ $strings.LabelMatchExistingUsersByDescription }}</p>\n            </div>\n\n            <div class=\"flex items-center py-4 px-1 w-full\">\n              <ui-toggle-switch labeledBy=\"auto-redirect-toggle\" v-model=\"newAuthSettings.authOpenIDAutoLaunch\" :disabled=\"savingSettings\" />\n              <p id=\"auto-redirect-toggle\" class=\"pl-4 whitespace-nowrap\">{{ $strings.LabelAutoLaunch }}</p>\n              <p class=\"pl-4 text-sm text-gray-300\" v-html=\"$strings.LabelAutoLaunchDescription\" />\n            </div>\n\n            <div class=\"flex items-center py-4 px-1 w-full\">\n              <ui-toggle-switch labeledBy=\"auto-register-toggle\" v-model=\"newAuthSettings.authOpenIDAutoRegister\" :disabled=\"savingSettings\" />\n              <p id=\"auto-register-toggle\" class=\"pl-4 whitespace-nowrap\">{{ $strings.LabelAutoRegister }}</p>\n              <p class=\"pl-4 text-sm text-gray-300\">{{ $strings.LabelAutoRegisterDescription }}</p>\n            </div>\n\n            <p class=\"pt-6 mb-4 px-1\">{{ $strings.LabelOpenIDClaims }}</p>\n\n            <div class=\"flex flex-col sm:flex-row mb-4\">\n              <div class=\"w-44 min-w-44\">\n                <ui-text-input-with-label ref=\"openidGroupClaim\" v-model=\"newAuthSettings.authOpenIDGroupClaim\" :disabled=\"savingSettings\" :placeholder=\"'groups'\" :label=\"'Group Claim'\" />\n              </div>\n              <p class=\"sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300\" v-html=\"$strings.LabelOpenIDGroupClaimDescription\"></p>\n            </div>\n\n            <div class=\"flex flex-col sm:flex-row mb-4\">\n              <div class=\"w-44 min-w-44\">\n                <ui-text-input-with-label ref=\"openidAdvancedPermsClaim\" v-model=\"newAuthSettings.authOpenIDAdvancedPermsClaim\" :disabled=\"savingSettings\" :placeholder=\"'abspermissions'\" :label=\"'Advanced Permission Claim'\" />\n              </div>\n              <div class=\"sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300\">\n                <p v-html=\"$strings.LabelOpenIDAdvancedPermsClaimDescription\"></p>\n                <pre class=\"text-pre-wrap mt-2\"\n                  >{{ newAuthSettings.authOpenIDSamplePermissions }}\n                </pre>\n              </div>\n            </div>\n          </div>\n        </transition>\n      </div>\n      <div class=\"w-full flex items-center justify-between p-4\">\n        <p v-if=\"enableOpenIDAuth\" class=\"text-sm text-warning\">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>\n        <ui-btn color=\"bg-success\" :padding-x=\"8\" small class=\"text-base\" :loading=\"savingSettings\" @click=\"saveSettings\">{{ $strings.ButtonSave }}</ui-btn>\n      </div>\n    </app-settings-content>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, redirect, app }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n      return\n    }\n\n    const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {\n      console.error('Failed', error)\n      return null\n    })\n    if (!authSettings) {\n      redirect('/config')\n      return\n    }\n    return {\n      authSettings\n    }\n  },\n  data() {\n    return {\n      enableLocalAuth: false,\n      enableOpenIDAuth: false,\n      showCustomLoginMessage: false,\n      savingSettings: false,\n      openIdSigningAlgorithmsSupportedByIssuer: [],\n      newAuthSettings: {}\n    }\n  },\n  computed: {\n    authMethods() {\n      return this.authSettings.authActiveAuthMethods || []\n    },\n    matchingExistingOptions() {\n      return [\n        {\n          text: 'Do not match',\n          value: null\n        },\n        {\n          text: 'Match by email',\n          value: 'email'\n        },\n        {\n          text: 'Match by username',\n          value: 'username'\n        }\n      ]\n    },\n    subfolderOptions() {\n      const options = [\n        {\n          text: 'None',\n          value: ''\n        }\n      ]\n      if (this.$config.routerBasePath) {\n        options.push({\n          text: this.$config.routerBasePath,\n          value: this.$config.routerBasePath\n        })\n      }\n      return options\n    },\n    webCallbackURL() {\n      return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`\n    },\n    mobileAppCallbackURL() {\n      return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`\n    }\n  },\n  methods: {\n    autoPopulateOIDCClick() {\n      if (!this.newAuthSettings.authOpenIDIssuerURL) {\n        this.$toast.error('Issuer URL required')\n        return\n      }\n      // Remove trailing slash\n      let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL\n      if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)\n\n      // If the full config path is on the issuer url then remove it\n      if (issuerUrl.endsWith('/.well-known/openid-configuration')) {\n        issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '')\n        this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')\n      }\n\n      const setSupportedSigningAlgorithms = (algorithms) => {\n        if (!algorithms?.length || !Array.isArray(algorithms)) {\n          console.warn('Invalid id_token_signing_alg_values_supported from openid-configuration', algorithms)\n          this.openIdSigningAlgorithmsSupportedByIssuer = []\n          return\n        }\n        this.openIdSigningAlgorithmsSupportedByIssuer = algorithms\n\n        // If a signing algorithm is already selected, then keep it, when it is still supported.\n        // But if it is not supported, then select one of the supported ones.\n        let currentAlgorithm = this.newAuthSettings.authOpenIDTokenSigningAlgorithm\n        if (!algorithms.includes(currentAlgorithm)) {\n          this.newAuthSettings.authOpenIDTokenSigningAlgorithm = algorithms[0]\n        }\n      }\n\n      this.$axios\n        .$get(`/auth/openid/config?issuer=${issuerUrl}`)\n        .then((data) => {\n          if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer\n          if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint\n          if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint\n          if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint\n          if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint\n          if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri\n          if (data.id_token_signing_alg_values_supported) setSupportedSigningAlgorithms(data.id_token_signing_alg_values_supported)\n        })\n        .catch((error) => {\n          console.error('Failed to receive data', error)\n          const errorMsg = error.response?.data || 'Unknown error'\n          this.$toast.error(errorMsg)\n        })\n    },\n    validateOpenID() {\n      let isValid = true\n      if (!this.newAuthSettings.authOpenIDIssuerURL) {\n        this.$toast.error('Issuer URL required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDAuthorizationURL) {\n        this.$toast.error('Authorize URL required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDTokenURL) {\n        this.$toast.error('Token URL required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDUserInfoURL) {\n        this.$toast.error('Userinfo URL required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDJwksURL) {\n        this.$toast.error('JWKS URL required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDClientID) {\n        this.$toast.error('Client ID required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDClientSecret) {\n        this.$toast.error('Client Secret required')\n        isValid = false\n      }\n      if (!this.newAuthSettings.authOpenIDTokenSigningAlgorithm) {\n        this.$toast.error('Signing Algorithm required')\n        isValid = false\n      }\n\n      function isValidRedirectURI(uri) {\n        // Check for somestring://someother/string\n        const pattern = new RegExp('^\\\\w+://[\\\\w\\\\.-]+(/[\\\\w\\\\./-]*)*$', 'i')\n        return pattern.test(uri)\n      }\n\n      const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs\n      if (uris.includes('*') && uris.length > 1) {\n        this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used')\n        isValid = false\n      } else {\n        uris.forEach((uri) => {\n          if (uri !== '*' && !isValidRedirectURI(uri)) {\n            this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`)\n            isValid = false\n          }\n        })\n      }\n\n      function isValidClaim(claim) {\n        if (claim === '') return true\n\n        const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i')\n        return pattern.test(claim)\n      }\n      if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) {\n        this.$toast.error('Group Claim: Invalid claim name')\n        isValid = false\n      }\n      if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) {\n        this.$toast.error('Advanced Permission Claim: Invalid claim name')\n        isValid = false\n      }\n\n      return isValid\n    },\n    async saveSettings() {\n      if (!this.enableLocalAuth && !this.enableOpenIDAuth) {\n        this.$toast.error('Must have at least one authentication method enabled')\n        return\n      }\n\n      if (this.enableOpenIDAuth && !this.validateOpenID()) {\n        return\n      }\n\n      if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {\n        this.newAuthSettings.authLoginCustomMessage = null\n      }\n\n      this.newAuthSettings.authActiveAuthMethods = []\n      if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')\n      if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')\n\n      this.savingSettings = true\n      this.$axios\n        .$patch('/api/auth-settings', this.newAuthSettings)\n        .then((data) => {\n          this.$store.commit('setServerSettings', data.serverSettings)\n          if (data.updated) {\n            this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)\n          } else {\n            this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to update server settings', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.savingSettings = false\n        })\n    },\n    init() {\n      this.newAuthSettings = {\n        ...this.authSettings,\n        authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs\n      }\n      this.enableLocalAuth = this.authMethods.includes('local')\n      this.enableOpenIDAuth = this.authMethods.includes('openid')\n      this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style>\n#authentication-settings code {\n  font-size: 0.8rem;\n  border-radius: 6px;\n  background-color: rgb(82, 82, 82);\n  color: white;\n  padding: 2px 4px;\n  white-space: nowrap;\n}\n</style>\n"
  },
  {
    "path": "client/pages/config/backups.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderBackups\" :description=\"$strings.MessageBackupsDescription\">\n      <div v-if=\"backupLocation\" class=\"mb-4 max-w-full overflow-hidden\">\n        <div class=\"flex items-center mb-0.5\">\n          <span class=\"material-symbols text-2xl text-black-50 mr-2\">folder</span>\n          <span class=\"text-white/60 uppercase text-sm whitespace-nowrap\">{{ $strings.LabelBackupLocation }}:</span>\n        </div>\n        <div v-if=\"!showEditBackupPath\" class=\"inline-flex items-center w-full overflow-hidden\">\n          <p class=\"text-gray-100 max-w-[calc(100%-40px)] text-sm sm:text-base break-words\">{{ backupLocation }}</p>\n          <div class=\"w-10 min-w-10 flex items-center justify-center\">\n            <button class=\"text-black-50 hover:text-yellow-500 inline-flex\" type=\"button\" @click=\"showEditBackupPath = !showEditBackupPath\">\n              <span class=\"material-symbols text-lg\">edit</span>\n            </button>\n          </div>\n        </div>\n        <div v-else>\n          <form class=\"flex items-center w-full space-x-1\" @submit.prevent=\"saveBackupPath\">\n            <ui-text-input v-model=\"newBackupLocation\" :disabled=\"savingBackupPath || !canEditBackup\" class=\"w-full max-w-[calc(100%-50px)] text-sm h-8\" />\n            <ui-btn v-if=\"canEditBackup\" small :loading=\"savingBackupPath\" color=\"bg-success\" type=\"submit\" class=\"h-8\">{{ $strings.ButtonSave }}</ui-btn>\n            <ui-btn small :disabled=\"savingBackupPath\" type=\"button\" class=\"h-8\" @click=\"cancelEditBackupPath\">{{ $strings.ButtonCancel }}</ui-btn>\n          </form>\n          <p class=\"text-sm text-warning/80 pt-1\">{{ canEditBackup ? $strings.MessageBackupsLocationEditNote : $strings.MessageBackupsLocationNoEditNote }}</p>\n        </div>\n      </div>\n\n      <div class=\"flex items-center py-2\">\n        <ui-toggle-switch v-model=\"enableBackups\" small :disabled=\"updatingServerSettings\" @input=\"updateBackupsSettings\" />\n        <ui-tooltip :text=\"$strings.LabelBackupsEnableAutomaticBackupsHelp\">\n          <p class=\"pl-4 text-lg\">{{ $strings.LabelBackupsEnableAutomaticBackups }} <span class=\"material-symbols icon-text\">info</span></p>\n        </ui-tooltip>\n      </div>\n\n      <div v-if=\"enableBackups\" class=\"mb-6\">\n        <div class=\"flex items-center pl-0 sm:pl-6 mb-2\">\n          <span class=\"material-symbols text-xl sm:text-2xl text-black-50 mr-2\">schedule</span>\n          <div class=\"w-32 min-w-32 sm:w-40 sm:min-w-40\">\n            <span class=\"text-white/60 uppercase text-sm\">{{ $strings.HeaderSchedule }}:</span>\n          </div>\n          <div class=\"text-gray-100 text-sm sm:text-base\">{{ scheduleDescription }}</div>\n          <button class=\"ml-2 text-black-50 hover:text-yellow-500 inline-flex\" type=\"button\" @click=\"showCronBuilder = !showCronBuilder\">\n            <span class=\"material-symbols text-lg\">edit</span>\n          </button>\n        </div>\n\n        <div v-if=\"nextBackupDate\" class=\"flex items-center pl-0 sm:pl-6 py-0.5\">\n          <span class=\"material-symbols text-xl sm:text-2xl text-black-50 mr-2\">event</span>\n          <div class=\"w-32 min-w-32 sm:w-40 sm:min-w-40\">\n            <span class=\"text-white/60 uppercase text-sm\">{{ $strings.LabelNextBackupDate }}:</span>\n          </div>\n          <div class=\"text-gray-100 text-sm sm:text-base\">{{ nextBackupDate }}</div>\n        </div>\n      </div>\n\n      <div class=\"flex items-center py-2\">\n        <ui-text-input type=\"number\" v-model=\"backupsToKeep\" no-spinner :disabled=\"updatingServerSettings\" :padding-x=\"1\" text-center class=\"w-10\" @change=\"updateBackupsSettings\" />\n\n        <ui-tooltip :text=\"$strings.LabelBackupsNumberToKeepHelp\">\n          <p class=\"pl-4 text-lg\">{{ $strings.LabelBackupsNumberToKeep }} <span class=\"material-symbols icon-text\">info</span></p>\n        </ui-tooltip>\n      </div>\n\n      <div class=\"flex items-center py-2\">\n        <ui-text-input type=\"number\" v-model=\"maxBackupSize\" no-spinner :disabled=\"updatingServerSettings\" :padding-x=\"1\" text-center class=\"w-10\" @change=\"updateBackupsSettings\" />\n\n        <ui-tooltip :text=\"$strings.LabelBackupsMaxBackupSizeHelp\">\n          <p class=\"pl-4 text-lg\">{{ $strings.LabelBackupsMaxBackupSize }} <span class=\"material-symbols icon-text\">info</span></p>\n        </ui-tooltip>\n      </div>\n\n      <tables-backups-table ref=\"backupsTable\" @loaded=\"backupsLoaded\" />\n\n      <modals-backup-schedule-modal v-model=\"showCronBuilder\" :cron-expression.sync=\"cronExpression\" />\n    </app-settings-content>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      updatingServerSettings: false,\n      enableBackups: true,\n      backupsToKeep: 2,\n      maxBackupSize: 1,\n      cronExpression: '',\n      newServerSettings: {},\n      showCronBuilder: false,\n      showEditBackupPath: false,\n      backupPathEnvSet: false,\n      backupLocation: '',\n      newBackupLocation: '',\n      savingBackupPath: false\n    }\n  },\n  watch: {\n    serverSettings(newVal, oldVal) {\n      if (newVal && !oldVal) {\n        this.newServerSettings = { ...this.serverSettings }\n        this.initServerSettings()\n      }\n    }\n  },\n  computed: {\n    serverSettings() {\n      return this.$store.state.serverSettings\n    },\n    dateFormat() {\n      return this.serverSettings.dateFormat\n    },\n    timeFormat() {\n      return this.serverSettings.timeFormat\n    },\n    canEditBackup() {\n      // Prevent editing of backup path if an environment variable is set\n      return !this.backupPathEnvSet\n    },\n    scheduleDescription() {\n      if (!this.cronExpression) return ''\n      const parsed = this.$parseCronExpression(this.cronExpression, this)\n      return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`\n    },\n    nextBackupDate() {\n      if (!this.cronExpression) return ''\n      const parsed = this.$getNextScheduledDate(this.cronExpression)\n      return this.$formatJsDatetime(parsed, this.dateFormat, this.timeFormat) || ''\n    }\n  },\n  methods: {\n    backupsLoaded(data) {\n      this.backupLocation = data.backupLocation\n      this.newBackupLocation = data.backupLocation\n      this.backupPathEnvSet = data.backupPathEnvSet\n    },\n    cancelEditBackupPath() {\n      this.newBackupLocation = this.backupLocation\n      this.showEditBackupPath = false\n    },\n    saveBackupPath() {\n      if (!this.newBackupLocation?.trim()) {\n        this.$toast.error(this.$strings.MessageBackupsLocationPathEmpty)\n        return\n      }\n      this.newBackupLocation = this.newBackupLocation.trim()\n      if (this.newBackupLocation === this.backupLocation) {\n        this.showEditBackupPath = false\n        return\n      }\n\n      this.savingBackupPath = true\n      this.$axios\n        .patch('/api/backups/path', { path: this.newBackupLocation })\n        .then(() => {\n          this.backupLocation = this.newBackupLocation\n          this.showEditBackupPath = false\n          this.$refs.backupsTable.loadBackups()\n        })\n        .catch((error) => {\n          console.error('Failed to save backup path', error)\n          const errorMsg = error.response?.data || this.$strings.ToastFailedToUpdate\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.savingBackupPath = false\n        })\n    },\n    updateBackupsSettings() {\n      if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {\n        this.$toast.error(this.$strings.ToastBackupInvalidMaxSize)\n        return\n      }\n      if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {\n        this.$toast.error(this.$strings.ToastBackupInvalidMaxKeep)\n        return\n      }\n      const updatePayload = {\n        backupSchedule: this.enableBackups ? this.cronExpression : false,\n        backupsToKeep: Number(this.backupsToKeep),\n        maxBackupSize: Number(this.maxBackupSize)\n      }\n      this.updateServerSettings(updatePayload)\n    },\n    updateServerSettings(payload) {\n      this.updatingServerSettings = true\n      this.$store\n        .dispatch('updateServerSettings', payload)\n        .then((success) => {\n          console.log('Updated Server Settings', success)\n          this.updatingServerSettings = false\n        })\n        .catch((error) => {\n          console.error('Failed to update server settings', error)\n          this.updatingServerSettings = false\n        })\n    },\n    initServerSettings() {\n      this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}\n      this.backupsToKeep = this.newServerSettings.backupsToKeep || 2\n      this.enableBackups = !!this.newServerSettings.backupSchedule\n      this.maxBackupSize = this.newServerSettings.maxBackupSize === 0 ? 0 : this.newServerSettings.maxBackupSize || 1\n      this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'\n    }\n  },\n  mounted() {\n    this.initServerSettings()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/email.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderEmailSettings\" :description=\"''\">\n      <template #header-items>\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/send_to_ereader\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n      </template>\n\n      <form @submit.prevent=\"submitForm\">\n        <div class=\"flex items-center -mx-1 mb-2\">\n          <div class=\"w-full md:w-3/4 px-1\">\n            <ui-text-input-with-label ref=\"hostInput\" v-model=\"newSettings.host\" :disabled=\"savingSettings\" :label=\"$strings.LabelHost\" />\n          </div>\n          <div class=\"w-full md:w-1/4 px-1\">\n            <ui-text-input-with-label ref=\"portInput\" v-model=\"newSettings.port\" type=\"number\" :disabled=\"savingSettings\" :label=\"$strings.LabelPort\" />\n          </div>\n        </div>\n\n        <div class=\"flex items-center mb-2 py-3\">\n          <div class=\"w-full md:w-1/2 px-1\">\n            <!-- secure toggle -->\n            <div class=\"flex items-center\">\n              <ui-toggle-switch labeledBy=\"email-settings-secure\" v-model=\"newSettings.secure\" :disabled=\"savingSettings\" />\n              <ui-tooltip :text=\"$strings.LabelEmailSettingsSecureHelp\">\n                <div class=\"pl-4 flex items-center\">\n                  <span id=\"email-settings-secure\">{{ $strings.LabelEmailSettingsSecure }}</span>\n                  <span class=\"material-symbols text-lg pl-1\">info</span>\n                </div>\n              </ui-tooltip>\n            </div>\n          </div>\n          <div class=\"w-full md:w-1/2 px-1\">\n            <!-- reject unauthorized toggle -->\n            <div class=\"flex items-center\">\n              <ui-toggle-switch labeledBy=\"email-settings-reject-unauthorized\" v-model=\"newSettings.rejectUnauthorized\" :disabled=\"savingSettings\" />\n              <ui-tooltip :text=\"$strings.LabelEmailSettingsRejectUnauthorizedHelp\">\n                <div class=\"pl-4 flex items-center\">\n                  <span id=\"email-settings-reject-unauthorized\">{{ $strings.LabelEmailSettingsRejectUnauthorized }}</span>\n                  <span class=\"material-symbols text-lg pl-1\">info</span>\n                </div>\n              </ui-tooltip>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"flex items-center -mx-1 mb-2\">\n          <div class=\"w-full md:w-1/2 px-1\">\n            <ui-text-input-with-label ref=\"userInput\" v-model=\"newSettings.user\" :disabled=\"savingSettings\" :label=\"$strings.LabelUsername\" />\n          </div>\n          <div class=\"w-full md:w-1/2 px-1\">\n            <ui-text-input-with-label ref=\"passInput\" v-model=\"newSettings.pass\" type=\"password\" :disabled=\"savingSettings\" :label=\"$strings.LabelPassword\" />\n          </div>\n        </div>\n\n        <div class=\"flex items-center -mx-1 mb-2\">\n          <div class=\"w-full md:w-1/2 px-1\">\n            <ui-text-input-with-label ref=\"fromInput\" v-model=\"newSettings.fromAddress\" :disabled=\"savingSettings\" :label=\"$strings.LabelEmailSettingsFromAddress\" />\n          </div>\n          <div class=\"w-full md:w-1/2 px-1\">\n            <ui-text-input-with-label ref=\"testInput\" v-model=\"newSettings.testAddress\" :disabled=\"savingSettings\" :label=\"$strings.LabelEmailSettingsTestAddress\" />\n          </div>\n        </div>\n\n        <div class=\"flex items-center justify-between pt-4\">\n          <ui-btn v-if=\"hasUpdates\" :disabled=\"savingSettings\" type=\"button\" @click=\"resetChanges\">{{ $strings.ButtonReset }}</ui-btn>\n          <ui-btn v-else :loading=\"sendingTest\" :disabled=\"savingSettings || !newSettings.host\" type=\"button\" @click=\"sendTestClick\">{{ $strings.ButtonTest }}</ui-btn>\n          <ui-btn :loading=\"savingSettings\" :disabled=\"!hasUpdates\" type=\"submit\">{{ $strings.ButtonSave }}</ui-btn>\n        </div>\n      </form>\n\n      <div v-show=\"loading\" class=\"absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center\">\n        <ui-loading-indicator />\n      </div>\n    </app-settings-content>\n\n    <app-settings-content :header-text=\"$strings.HeaderEreaderDevices\" :description=\"$strings.MessageEreaderDevices\">\n      <template #header-items>\n        <div class=\"grow\" />\n\n        <ui-btn color=\"bg-primary\" small @click=\"addNewDeviceClick\">{{ $strings.ButtonAddDevice }}</ui-btn>\n      </template>\n\n      <table v-if=\"existingEReaderDevices.length\" class=\"tracksTable mt-4\">\n        <tr>\n          <th class=\"text-left\">{{ $strings.LabelName }}</th>\n          <th class=\"text-left\">{{ $strings.LabelEmail }}</th>\n          <th class=\"text-left\">{{ $strings.LabelAccessibleBy }}</th>\n          <th class=\"w-40\"></th>\n        </tr>\n        <tr v-for=\"device in existingEReaderDevices\" :key=\"device.name\">\n          <td>\n            <p class=\"text-sm md:text-base text-gray-100\">{{ device.name }}</p>\n          </td>\n          <td class=\"text-left\">\n            <p class=\"text-sm md:text-base text-gray-100\">{{ device.email }}</p>\n          </td>\n          <td class=\"text-left\">\n            <p class=\"text-sm md:text-base text-gray-100\">{{ getAccessibleBy(device) }}</p>\n          </td>\n          <td class=\"w-40\">\n            <div class=\"flex justify-end items-center h-10\">\n              <ui-icon-btn icon=\"edit\" borderless :size=\"8\" icon-font-size=\"1.1rem\" :disabled=\"deletingDeviceName === device.name\" class=\"mx-1\" @click=\"editDeviceClick(device)\" />\n              <ui-icon-btn icon=\"delete\" borderless :size=\"8\" icon-font-size=\"1.1rem\" :disabled=\"deletingDeviceName === device.name\" @click=\"deleteDeviceClick(device)\" />\n            </div>\n          </td>\n        </tr>\n      </table>\n      <div v-else-if=\"!loading\" class=\"text-center py-4\">\n        <p class=\"text-lg text-gray-100\">{{ $strings.MessageNoDevices }}</p>\n      </div>\n    </app-settings-content>\n\n    <modals-emails-e-reader-device-modal v-model=\"showEReaderDeviceModal\" :users=\"users\" :existing-devices=\"existingEReaderDevices\" :ereader-device=\"selectedEReaderDevice\" @update=\"ereaderDevicesUpdated\" :loadUsers=\"loadUsers\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      users: [],\n      loading: false,\n      savingSettings: false,\n      sendingTest: false,\n      deletingDeviceName: null,\n      settings: null,\n      newSettings: {\n        host: null,\n        port: 465,\n        secure: true,\n        rejectUnauthorized: true,\n        user: null,\n        pass: null,\n        testAddress: null,\n        fromAddress: null\n      },\n      newEReaderDevice: {\n        name: '',\n        email: ''\n      },\n      selectedEReaderDevice: null,\n      showEReaderDeviceModal: false\n    }\n  },\n  computed: {\n    hasUpdates() {\n      if (!this.settings) return true\n      for (const key in this.newSettings) {\n        if (key === 'ereaderDevices') continue\n        if (this.newSettings[key] !== this.settings[key]) return true\n      }\n      return false\n    },\n    existingEReaderDevices() {\n      return this.settings?.ereaderDevices || []\n    }\n  },\n  methods: {\n    resetChanges() {\n      this.newSettings = {\n        ...this.settings\n      }\n    },\n    async loadUsers() {\n      if (this.users.length) return\n      this.users = await this.$axios\n        .$get('/api/users')\n        .then((res) => {\n          return res.users.sort((a, b) => {\n            return a.createdAt - b.createdAt\n          })\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n          return []\n        })\n    },\n    getAccessibleBy(device) {\n      const user = device.availabilityOption\n      if (user === 'userOrUp') return 'Users (excluding Guests)'\n      if (user === 'guestOrUp') return 'Users (including Guests)'\n      if (user === 'specificUsers') {\n        return device.users.map((id) => this.users.find((u) => u.id === id)?.username).join(', ')\n      }\n      return 'Admins Only'\n    },\n    editDeviceClick(device) {\n      this.selectedEReaderDevice = device\n      this.showEReaderDeviceModal = true\n    },\n    deleteDeviceClick(device) {\n      const payload = {\n        message: this.$getString('MessageConfirmDeleteDevice', [device.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteDevice(device)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteDevice(device) {\n      const payload = {\n        ereaderDevices: this.existingEReaderDevices.filter((d) => d.name !== device.name)\n      }\n      this.deletingDeviceName = device.name\n      this.$axios\n        .$post(`/api/emails/ereader-devices`, payload)\n        .then((data) => {\n          this.ereaderDevicesUpdated(data.ereaderDevices)\n        })\n        .catch((error) => {\n          console.error('Failed to delete device', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.deletingDeviceName = null\n        })\n    },\n    ereaderDevicesUpdated(ereaderDevices) {\n      this.settings.ereaderDevices = ereaderDevices\n      this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))\n\n      // Load users if a device has availability set to specific users\n      if (ereaderDevices.some((device) => device.availabilityOption === 'specificUsers')) {\n        this.loadUsers()\n      }\n    },\n    addNewDeviceClick() {\n      this.selectedEReaderDevice = null\n      this.showEReaderDeviceModal = true\n    },\n    sendTestClick() {\n      this.sendingTest = true\n      this.$axios\n        .$post('/api/emails/test')\n        .then(() => {\n          this.$toast.success(this.$strings.ToastDeviceTestEmailSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to send test email', error)\n          const errorMsg = error.response.data || this.$strings.ToastDeviceTestEmailFailed\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.sendingTest = false\n        })\n    },\n    validateForm() {\n      for (const ref of [this.$refs.hostInput, this.$refs.portInput, this.$refs.userInput, this.$refs.passInput, this.$refs.fromInput]) {\n        if (ref?.blur) ref.blur()\n      }\n\n      if (this.newSettings.port) {\n        this.newSettings.port = Number(this.newSettings.port)\n      }\n\n      return true\n    },\n    submitForm() {\n      if (!this.validateForm()) return\n\n      const updatePayload = {\n        host: this.newSettings.host,\n        port: this.newSettings.port,\n        secure: this.newSettings.secure,\n        rejectUnauthorized: this.newSettings.rejectUnauthorized,\n        user: this.newSettings.user,\n        pass: this.newSettings.pass,\n        testAddress: this.newSettings.testAddress,\n        fromAddress: this.newSettings.fromAddress\n      }\n      this.savingSettings = true\n      this.$axios\n        .$patch('/api/emails/settings', updatePayload)\n        .then((data) => {\n          this.settings = data.settings\n          this.newSettings = {\n            ...data.settings\n          }\n          this.$toast.success(this.$strings.ToastEmailSettingsUpdateSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to update email settings', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.savingSettings = false\n        })\n    },\n    init() {\n      this.loading = true\n\n      this.$axios\n        .$get(`/api/emails/settings`)\n        .then(async (data) => {\n          // Load users if a device has availability set to specific users\n          if (data.settings.ereaderDevices.some((device) => device.availabilityOption === 'specificUsers')) {\n            await this.loadUsers()\n          }\n\n          this.settings = data.settings\n          this.newSettings = {\n            ...this.settings\n          }\n        })\n        .catch((error) => {\n          console.error('Failed to get email settings', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    }\n  },\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/index.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderSettings\">\n      <div class=\"lg:flex\">\n        <div class=\"flex-1\">\n          <div class=\"pt-4\">\n            <h2 class=\"font-semibold\">{{ $strings.HeaderSettingsGeneral }}</h2>\n          </div>\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsStoreCoversWithItemHelp\" class=\"flex items-end py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsStoreCoversWithItem\" v-model=\"newServerSettings.storeCoverWithItem\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('storeCoverWithItem', val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsStoreCoversWithItemHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-store-cover-with-items\">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsStoreMetadataWithItemHelp\" class=\"flex items-center py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsStoreMetadataWithItem\" v-model=\"newServerSettings.storeMetadataWithItem\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('storeMetadataWithItem', val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsStoreMetadataWithItemHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-store-metadata-with-items\">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsSortingIgnorePrefixesHelp\" class=\"flex items-center py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsSortingIgnorePrefixes\" v-model=\"newServerSettings.sortingIgnorePrefix\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('sortingIgnorePrefix', val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsSortingIgnorePrefixesHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-sorting-ignore-prefixes\">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n          <div v-if=\"newServerSettings.sortingIgnorePrefix\" class=\"w-72 ml-14 mb-2\">\n            <ui-multi-select v-model=\"newServerSettings.sortingPrefixes\" small :items=\"newServerSettings.sortingPrefixes\" :label=\"$strings.LabelPrefixesToIgnore\" @input=\"sortingPrefixesUpdated\" :disabled=\"savingPrefixes\" />\n            <div class=\"flex justify-end py-1\">\n              <ui-btn v-if=\"hasPrefixesChanged\" color=\"bg-success\" :loading=\"savingPrefixes\" small @click=\"updateSortingPrefixes\">Save</ui-btn>\n            </div>\n          </div>\n\n          <div class=\"pt-4\">\n            <h2 class=\"font-semibold\">{{ $strings.HeaderSettingsScanner }}</h2>\n          </div>\n\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsParseSubtitlesHelp\" class=\"flex items-center py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsParseSubtitles\" v-model=\"newServerSettings.scannerParseSubtitle\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('scannerParseSubtitle', val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsParseSubtitlesHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-parse-subtitles\">{{ $strings.LabelSettingsParseSubtitles }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsFindCoversHelp\" class=\"flex items-center py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsFindCovers\" v-model=\"newServerSettings.scannerFindCovers\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('scannerFindCovers', val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsFindCoversHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-find-covers\">{{ $strings.LabelSettingsFindCovers }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n            <div class=\"grow\" />\n          </div>\n          <div v-if=\"newServerSettings.scannerFindCovers\" class=\"w-44 ml-14 mb-2\">\n            <ui-dropdown v-model=\"newServerSettings.scannerCoverProvider\" small :items=\"providers\" :label=\"$strings.LabelCoverProvider\" @input=\"updateScannerCoverProvider\" :disabled=\"updatingServerSettings\" />\n          </div>\n\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsPreferMatchedMetadataHelp\" class=\"flex items-center py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsPreferMatchedMetadata\" v-model=\"newServerSettings.scannerPreferMatchedMetadata\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsPreferMatchedMetadataHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-prefer-matched-metadata\">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div role=\"article\" :aria-label=\"$strings.LabelSettingsEnableWatcherHelp\" class=\"flex items-center py-2\">\n            <ui-toggle-switch :label=\"$strings.LabelSettingsEnableWatcher\" v-model=\"scannerEnableWatcher\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('scannerDisableWatcher', !val)\" />\n            <ui-tooltip aria-hidden=\"true\" :text=\"$strings.LabelSettingsEnableWatcherHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-disable-watcher\">{{ $strings.LabelSettingsEnableWatcher }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div class=\"pt-4\">\n            <h2 class=\"font-semibold\">{{ $strings.HeaderSettingsWebClient }}</h2>\n          </div>\n\n          <div class=\"flex items-center py-2\">\n            <ui-toggle-switch v-model=\"newServerSettings.chromecastEnabled\" :label=\"$strings.LabelSettingsChromecastSupport\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('chromecastEnabled', val)\" />\n            <p aria-hidden=\"true\" class=\"pl-4\">{{ $strings.LabelSettingsChromecastSupport }}</p>\n          </div>\n\n          <div class=\"flex items-center py-2 mb-2\">\n            <ui-toggle-switch v-model=\"newServerSettings.allowIframe\" :label=\"$strings.LabelSettingsAllowIframe\" :disabled=\"updatingServerSettings\" @input=\"(val) => updateSettingsKey('allowIframe', val)\" />\n            <p aria-hidden=\"true\" class=\"pl-4\">{{ $strings.LabelSettingsAllowIframe }}</p>\n          </div>\n        </div>\n\n        <div class=\"flex-1\">\n          <div class=\"pt-4\">\n            <h2 class=\"font-semibold\">{{ $strings.HeaderSettingsDisplay }}</h2>\n          </div>\n\n          <div class=\"flex items-center py-2\">\n            <ui-toggle-switch labeledBy=\"settings-home-page-uses-bookshelf\" v-model=\"homepageUseBookshelfView\" :disabled=\"updatingServerSettings\" @input=\"updateHomeUseBookshelfView\" />\n            <ui-tooltip :text=\"$strings.LabelSettingsBookshelfViewHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-home-page-uses-bookshelf\">{{ $strings.LabelSettingsHomePageBookshelfView }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div class=\"flex items-center py-2\">\n            <ui-toggle-switch labeledBy=\"settings-library-uses-bookshelf\" v-model=\"useBookshelfView\" :disabled=\"updatingServerSettings\" @input=\"updateUseBookshelfView\" />\n            <ui-tooltip :text=\"$strings.LabelSettingsBookshelfViewHelp\">\n              <p class=\"pl-4\">\n                <span id=\"settings-library-uses-bookshelf\">{{ $strings.LabelSettingsLibraryBookshelfView }}</span>\n                <span class=\"material-symbols icon-text\">info</span>\n              </p>\n            </ui-tooltip>\n          </div>\n\n          <div class=\"grow py-2\">\n            <ui-dropdown :label=\"$strings.LabelSettingsDateFormat\" v-model=\"newServerSettings.dateFormat\" :items=\"dateFormats\" small class=\"max-w-72\" @input=\"(val) => updateSettingsKey('dateFormat', val)\" />\n            <p class=\"text-xs ml-1 text-white/60\">{{ $strings.LabelExample }}: {{ dateExample }}</p>\n          </div>\n\n          <div class=\"grow py-2\">\n            <ui-dropdown :label=\"$strings.LabelSettingsTimeFormat\" v-model=\"newServerSettings.timeFormat\" :items=\"timeFormats\" small class=\"max-w-72\" @input=\"(val) => updateSettingsKey('timeFormat', val)\" />\n            <p class=\"text-xs ml-1 text-white/60\">{{ $strings.LabelExample }}: {{ timeExample }}</p>\n          </div>\n\n          <div class=\"py-2\">\n            <ui-dropdown :label=\"$strings.LabelLanguageDefaultServer\" ref=\"langDropdown\" v-model=\"newServerSettings.language\" :items=\"$languageCodeOptions\" small class=\"max-w-72\" @input=\"updateServerLanguage\" />\n          </div>\n\n          <div class=\"pt-4\">\n            <h2 class=\"font-semibold\">{{ $strings.HeaderSettingsSecurity }}</h2>\n          </div>\n\n          <div class=\"py-2\">\n            <ui-multi-select v-model=\"newServerSettings.allowedOrigins\" :items=\"newServerSettings.allowedOrigins\" :label=\"$strings.LabelCorsAllowed\" class=\"max-w-72\" @input=\"updateCorsOrigins\" />\n          </div>\n        </div>\n      </div>\n    </app-settings-content>\n\n    <div class=\"h-0.5 bg-primary/30 w-full\" />\n\n    <div class=\"flex items-center py-4\">\n      <div class=\"grow\" />\n      <ui-btn color=\"bg-bg\" small :padding-x=\"4\" class=\"mr-2 text-xs md:text-sm\" :loading=\"isPurgingCache\" @click.stop=\"purgeCache\">{{ $strings.ButtonPurgeAllCache }}</ui-btn>\n      <ui-btn color=\"bg-bg\" small :padding-x=\"4\" class=\"mr-2 text-xs md:text-sm\" :loading=\"isPurgingCache\" @click.stop=\"purgeItemsCache\">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>\n    </div>\n\n    <div class=\"flex items-center py-4\">\n      <div class=\"grow\" />\n      <p class=\"pr-2 text-sm text-yellow-400\">\n        {{ $strings.MessageReportBugsAndContribute }}\n        <a class=\"underline\" href=\"https://github.com/advplyr/audiobookshelf\" target=\"_blank\">github</a>\n      </p>\n      <a href=\"https://github.com/advplyr/audiobookshelf\" target=\"_blank\" class=\"text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500\">\n        <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\">\n          <path\n            d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"\n          />\n        </svg>\n      </a>\n      <p class=\"pl-4 pr-2 text-sm text-yellow-400\">\n        {{ $strings.MessageJoinUsOn }}\n        <a class=\"underline\" href=\"https://discord.gg/HQgCbd6E75\" target=\"_blank\">discord</a>\n      </p>\n      <a href=\"https://discord.gg/HQgCbd6E75\" target=\"_blank\" class=\"text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500\">\n        <svg width=\"31\" height=\"24\" viewBox=\"0 0 71 55\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <g clip-path=\"url(#clip0)\">\n            <path\n              d=\"M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z\"\n              fill=\"#ffffff\"\n            />\n          </g>\n          <defs>\n            <clipPath id=\"clip0\">\n              <rect width=\"71\" height=\"55\" fill=\"white\" />\n            </clipPath>\n          </defs>\n        </svg>\n      </a>\n    </div>\n\n    <div class=\"h-0.5 bg-primary/30 w-full\" />\n\n    <!-- confirm cache purge dialog -->\n    <prompt-dialog v-model=\"showConfirmPurgeCache\" :width=\"675\">\n      <div class=\"px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300\">\n        <p class=\"text-error font-semibold\">{{ $strings.MessageImportantNotice }}</p>\n        <p class=\"my-8 text-center\" v-html=\"$strings.MessageConfirmPurgeCache\" />\n        <div class=\"flex px-1 items-center\">\n          <ui-btn color=\"bg-primary\" @click=\"showConfirmPurgeCache = false\">{{ $strings.ButtonNevermind }}</ui-btn>\n          <div class=\"grow\" />\n          <ui-btn color=\"bg-success\" @click=\"confirmPurge\">{{ $strings.ButtonYes }}</ui-btn>\n        </div>\n      </div>\n    </prompt-dialog>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      isResettingLibraryItems: false,\n      updatingServerSettings: false,\n      homepageUseBookshelfView: false,\n      useBookshelfView: false,\n      scannerEnableWatcher: false,\n      isPurgingCache: false,\n      hasPrefixesChanged: false,\n      newServerSettings: {},\n      showConfirmPurgeCache: false,\n      savingPrefixes: false\n    }\n  },\n  watch: {\n    serverSettings(newVal, oldVal) {\n      if (newVal && !oldVal) {\n        this.initServerSettings()\n      }\n    }\n  },\n  computed: {\n    serverSettings() {\n      return this.$store.state.serverSettings\n    },\n    providers() {\n      // Use book cover providers for the cover provider dropdown\n      return this.$store.state.scanners.bookCoverProviders || []\n    },\n    dateFormats() {\n      return this.$store.state.globals.dateFormats\n    },\n    timeFormats() {\n      return this.$store.state.globals.timeFormats\n    },\n    dateExample() {\n      const date = new Date(2014, 2, 25)\n      return this.$formatJsDate(date, this.newServerSettings.dateFormat)\n    },\n    timeExample() {\n      const date = new Date(2014, 2, 25, 17, 30, 0)\n      return this.$formatJsTime(date, this.newServerSettings.timeFormat)\n    }\n  },\n  methods: {\n    sortingPrefixesUpdated(val) {\n      const prefixes = [...new Set(val?.map((prefix) => prefix.trim().toLowerCase()) || [])]\n      this.newServerSettings.sortingPrefixes = prefixes\n      const serverPrefixes = this.serverSettings.sortingPrefixes || []\n      this.hasPrefixesChanged = prefixes.some((p) => !serverPrefixes.includes(p)) || serverPrefixes.some((p) => !prefixes.includes(p))\n    },\n    updateSortingPrefixes() {\n      const prefixes = [...new Set(this.newServerSettings.sortingPrefixes.map((prefix) => prefix.trim().toLowerCase()) || [])]\n      if (!prefixes.length) {\n        this.$toast.error(this.$strings.ToastSortingPrefixesEmptyError)\n        return\n      }\n\n      this.savingPrefixes = true\n      this.$axios\n        .$patch(`/api/sorting-prefixes`, { sortingPrefixes: prefixes })\n        .then((data) => {\n          this.$toast.success(this.$getString('ToastSortingPrefixesUpdateSuccess', [data.rowsUpdated]))\n          if (data.serverSettings) {\n            this.$store.commit('setServerSettings', data.serverSettings)\n          }\n          this.hasPrefixesChanged = false\n        })\n        .catch((error) => {\n          console.error('Failed to update prefixes', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.savingPrefixes = false\n        })\n    },\n    updateScannerCoverProvider(val) {\n      this.updateServerSettings({\n        scannerCoverProvider: val\n      })\n    },\n    updateHomeUseBookshelfView(val) {\n      this.updateServerSettings({\n        homeBookshelfView: !val ? this.$constants.BookshelfView.DETAIL : this.$constants.BookshelfView.STANDARD\n      })\n    },\n    updateUseBookshelfView(val) {\n      this.updateServerSettings({\n        bookshelfView: !val ? this.$constants.BookshelfView.DETAIL : this.$constants.BookshelfView.STANDARD\n      })\n    },\n    updateServerLanguage(val) {\n      this.updateSettingsKey('language', val)\n    },\n    updateCorsOrigins(val) {\n      const validOrigins = []\n      const invalidOrigins = []\n\n      val.forEach((origin) => {\n        const trimmedOrigin = origin.trim().toLowerCase()\n        try {\n          new URL(trimmedOrigin)\n          validOrigins.push(trimmedOrigin)\n        } catch {\n          invalidOrigins.push(trimmedOrigin)\n        }\n      })\n\n      if (invalidOrigins.length > 0) {\n        this.$toast.error(this.$strings.ToastInvalidUrls)\n      }\n\n      this.newServerSettings.allowedOrigins = validOrigins\n      this.updateSettingsKey('allowedOrigins', validOrigins)\n    },\n    updateSettingsKey(key, val) {\n      if (key === 'scannerDisableWatcher') {\n        this.newServerSettings.scannerDisableWatcher = val\n      }\n      this.updateServerSettings({\n        [key]: val\n      })\n    },\n    updateServerSettings(payload) {\n      this.updatingServerSettings = true\n      this.$store.dispatch('updateServerSettings', payload).then((response) => {\n        this.updatingServerSettings = false\n\n        if (response.error) {\n          console.error('Failed to update server settins', response.error)\n          this.$toast.error(response.error)\n          this.initServerSettings()\n          return\n        }\n\n        if (payload.language) {\n          // Updating language after save allows for re-rendering\n          this.$setLanguageCode(payload.language)\n        }\n      })\n    },\n    initServerSettings() {\n      this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}\n      this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]\n      this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]\n      this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher\n\n      this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL\n      this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL\n    },\n    purgeCache() {\n      this.showConfirmPurgeCache = true\n    },\n    async confirmPurge() {\n      this.showConfirmPurgeCache = false\n      this.isPurgingCache = true\n      await this.$axios\n        .$post('/api/cache/purge')\n        .then(() => {\n          this.$toast.success(this.$strings.ToastCachePurgeSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to purge cache', error)\n          this.$toast.error(this.$strings.ToastCachePurgeFailed)\n        })\n      this.isPurgingCache = false\n    },\n    purgeItemsCache() {\n      const payload = {\n        message: this.$strings.MessageConfirmPurgeItemsCache,\n        allowHtml: true,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.sendPurgeItemsCache()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    async sendPurgeItemsCache() {\n      this.isPurgingCache = true\n      await this.$axios\n        .$post('/api/cache/items/purge')\n        .then(() => {\n          this.$toast.success(this.$strings.ToastCachePurgeSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to purge items cache', error)\n          this.$toast.error(this.$strings.ToastCachePurgeFailed)\n        })\n      this.isPurgingCache = false\n    }\n  },\n  mounted() {\n    this.initServerSettings()\n    // Fetch providers if not already loaded (for cover provider dropdown)\n    this.$store.dispatch('scanners/fetchProviders')\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/item-metadata-utils/custom-metadata-providers.vue",
    "content": "<template>\n  <div class=\"relative\">\n    <app-settings-content :header-text=\"$strings.HeaderCustomMetadataProviders\">\n      <template #header-prefix>\n        <nuxt-link to=\"/config/item-metadata-utils\" class=\"w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white/10 text-center mr-2\">\n          <span class=\"material-symbols text-2xl\">arrow_back</span>\n        </nuxt-link>\n      </template>\n      <template #header-items>\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/custom-metadata-providers\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n        <div class=\"grow\" />\n\n        <ui-btn color=\"bg-primary\" small @click=\"setShowAddModal\">{{ $strings.ButtonAdd }}</ui-btn>\n      </template>\n\n      <tables-custom-metadata-provider-table :providers=\"providers\" :processing.sync=\"processing\" class=\"pt-2\" @removed=\"providerRemoved\" />\n      <modals-add-custom-metadata-provider-modal ref=\"addModal\" v-model=\"showAddModal\" @added=\"providerAdded\" />\n    </app-settings-content>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n      return\n    }\n    return {}\n  },\n  data() {\n    return {\n      showAddModal: false,\n      processing: false,\n      providers: []\n    }\n  },\n  methods: {\n    providerRemoved(providerId) {\n      this.providers = this.providers.filter((p) => p.id !== providerId)\n    },\n    providerAdded(provider) {\n      this.providers.push(provider)\n    },\n    setShowAddModal() {\n      this.showAddModal = true\n    },\n    loadProviders() {\n      this.processing = true\n      this.$axios\n        .$get('/api/custom-metadata-providers')\n        .then((res) => {\n          this.providers = res.providers\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.$toast.error(this.$strings.ToastFailedToLoadData)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    }\n  },\n  mounted() {\n    this.loadProviders()\n  }\n}\n</script>\n\n<style></style>\n"
  },
  {
    "path": "client/pages/config/item-metadata-utils/genres.vue",
    "content": "<template>\n  <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-4 mb-8 relative\" style=\"min-height: 200px\">\n    <div class=\"flex items-center mb-4\">\n      <nuxt-link to=\"/config/item-metadata-utils\" class=\"w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white/10 text-center\">\n        <span class=\"material-symbols text-2xl\">arrow_back</span>\n      </nuxt-link>\n\n      <h1 class=\"text-xl mx-2\">{{ $strings.HeaderManageGenres }}</h1>\n    </div>\n\n    <p v-if=\"!genres.length && !loading\" class=\"text-center py-8 text-lg\">{{ $strings.MessageNoGenres }}</p>\n\n    <div class=\"border border-white/10\">\n      <template v-for=\"(genre, index) in genres\">\n        <div :key=\"genre\" class=\"w-full p-2 flex items-center text-gray-400 hover:text-white\" :class=\"{ 'bg-primary/20': index % 2 === 0 }\">\n          <p v-if=\"editingGenre !== genre\" class=\"text-sm md:text-base text-gray-100\">{{ genre }}</p>\n          <ui-text-input v-else v-model=\"newGenreName\" />\n          <div class=\"grow\" />\n          <template v-if=\"editingGenre !== genre\">\n            <ui-icon-btn v-if=\"editingGenre !== genre\" icon=\"edit\" borderless :size=\"8\" icon-font-size=\"1.1rem\" class=\"mx-1\" @click=\"editClick(genre)\" />\n            <ui-icon-btn v-if=\"editingGenre !== genre\" icon=\"delete\" borderless :size=\"8\" icon-font-size=\"1.1rem\" @click=\"removeClick(genre)\" />\n          </template>\n          <template v-else>\n            <ui-btn color=\"bg-success\" small class=\"mx-2\" @click.stop=\"saveClick\">{{ $strings.ButtonSave }}</ui-btn>\n            <ui-btn small @click.stop=\"cancelEditClick\">{{ $strings.ButtonCancel }}</ui-btn>\n          </template>\n        </div>\n      </template>\n    </div>\n\n    <div v-if=\"loading\" class=\"absolute top-0 left-0 w-full h-full bg-black/25 rounded-md\">\n      <div class=\"sticky top-0 left-0 w-full h-full flex items-center justify-center\" style=\"max-height: 80vh\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      loading: false,\n      genres: [],\n      editingGenre: null,\n      newGenreName: ''\n    }\n  },\n  watch: {},\n  computed: {},\n  methods: {\n    cancelEditClick() {\n      this.newGenreName = ''\n      this.editingGenre = null\n    },\n    removeClick(genre) {\n      const payload = {\n        message: `Are you sure you want to remove genre \"${genre}\" from all items?`,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removeGenre(genre)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    editClick(genre) {\n      this.newGenreName = genre\n      this.editingGenre = genre\n    },\n    saveClick() {\n      this.newGenreName = this.newGenreName.trim()\n      if (!this.newGenreName) {\n        return\n      }\n\n      if (this.editingGenre === this.newGenreName) {\n        this.cancelEditClick()\n        return\n      }\n\n      const genreNameExists = this.genres.find((g) => g !== this.editingGenre && g === this.newGenreName)\n      const genreNameExistsOfDifferentCase = !genreNameExists ? this.genres.find((g) => g !== this.editingGenre && g.toLowerCase() === this.newGenreName.toLowerCase()) : null\n\n      let message = this.$getString('MessageConfirmRenameGenre', [this.editingGenre, this.newGenreName])\n      if (genreNameExists) {\n        message += ` ${this.$strings.MessageConfirmRenameGenreMergeNote}`\n      } else if (genreNameExistsOfDifferentCase) {\n        message += ` ${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}`\n      }\n\n      const payload = {\n        message,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.renameGenre()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    renameGenre() {\n      this.loading = true\n      let _newGenreName = this.newGenreName\n      let _editingGenre = this.editingGenre\n\n      const payload = {\n        genre: _editingGenre,\n        newGenre: _newGenreName\n      }\n      this.$axios\n        .$post('/api/genres/rename', payload)\n        .then((res) => {\n          this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))\n          if (res.genreMerged) {\n            this.genres = this.genres.filter((g) => g !== _newGenreName)\n          }\n          this.genres = this.genres.map((g) => {\n            if (g === _editingGenre) return _newGenreName\n            return g\n          })\n          this.cancelEditClick()\n        })\n        .catch((error) => {\n          console.error('Failed to rename genre', error)\n          this.$toast.error(this.$strings.ToastRenameFailed)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    },\n    removeGenre(genre) {\n      this.loading = true\n\n      this.$axios\n        .$delete(`/api/genres/${this.$encode(genre)}`)\n        .then((res) => {\n          this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))\n          this.genres = this.genres.filter((g) => g !== genre)\n        })\n        .catch((error) => {\n          console.error('Failed to remove genre', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    },\n    init() {\n      this.loading = true\n      this.$axios\n        .$get('/api/genres')\n        .then((data) => {\n          this.genres = (data.genres || []).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))\n        })\n        .catch((error) => {\n          console.error('Failed to load genres', error)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    }\n  },\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/item-metadata-utils/index.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderItemMetadataUtils\">\n      <nuxt-link to=\"/config/item-metadata-utils/tags\" class=\"block w-full rounded-sm bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2\">\n        <div class=\"flex justify-between\">\n          <p>{{ $strings.HeaderManageTags }}</p>\n          <span class=\"material-symbols\">arrow_forward</span>\n        </div>\n      </nuxt-link>\n      <nuxt-link to=\"/config/item-metadata-utils/genres\" class=\"block w-full rounded-sm bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2\">\n        <div class=\"flex justify-between\">\n          <p>{{ $strings.HeaderManageGenres }}</p>\n          <span class=\"material-symbols\">arrow_forward</span>\n        </div>\n      </nuxt-link>\n      <nuxt-link to=\"/config/item-metadata-utils/custom-metadata-providers\" class=\"block w-full rounded-sm bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2\">\n        <div class=\"flex justify-between\">\n          <p>{{ $strings.HeaderCustomMetadataProviders }}</p>\n          <span class=\"material-symbols\">arrow_forward</span>\n        </div>\n      </nuxt-link>\n    </app-settings-content>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {}\n  },\n  watch: {},\n  computed: {},\n  methods: {\n    init() {}\n  },\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/item-metadata-utils/tags.vue",
    "content": "<template>\n  <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-4 mb-8 relative\" style=\"min-height: 200px\">\n    <div class=\"flex items-center mb-4\">\n      <nuxt-link to=\"/config/item-metadata-utils\" class=\"w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white/10 text-center\">\n        <span class=\"material-symbols text-2xl\">arrow_back</span>\n      </nuxt-link>\n\n      <h1 class=\"text-xl mx-2\">{{ $strings.HeaderManageTags }}</h1>\n    </div>\n\n    <p v-if=\"!tags.length && !loading\" class=\"text-center py-8 text-lg\">{{ $strings.MessageNoTags }}</p>\n\n    <div class=\"border border-white/10\">\n      <template v-for=\"(tag, index) in tags\">\n        <div :key=\"tag\" class=\"w-full p-2 flex items-center text-gray-400 hover:text-white\" :class=\"{ 'bg-primary/20': index % 2 === 0 }\">\n          <p v-if=\"editingTag !== tag\" class=\"text-sm md:text-base text-gray-100\">{{ tag }}</p>\n          <ui-text-input v-else v-model=\"newTagName\" />\n          <div class=\"grow\" />\n          <template v-if=\"editingTag !== tag\">\n            <ui-icon-btn icon=\"edit\" borderless :size=\"8\" icon-font-size=\"1.1rem\" class=\"mx-1\" @click=\"editTagClick(tag)\" />\n            <ui-icon-btn icon=\"delete\" borderless :size=\"8\" icon-font-size=\"1.1rem\" @click=\"removeTagClick(tag)\" />\n          </template>\n          <template v-else>\n            <ui-btn color=\"bg-success\" small class=\"mx-2\" @click.stop=\"saveTagClick\">{{ $strings.ButtonSave }}</ui-btn>\n            <ui-btn small @click.stop=\"cancelEditClick\">{{ $strings.ButtonCancel }}</ui-btn>\n          </template>\n        </div>\n      </template>\n    </div>\n\n    <div v-if=\"loading\" class=\"absolute top-0 left-0 w-full h-full bg-black/25 rounded-md\">\n      <div class=\"sticky top-0 left-0 w-full h-full flex items-center justify-center\" style=\"max-height: 80vh\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      loading: false,\n      tags: [],\n      editingTag: null,\n      newTagName: ''\n    }\n  },\n  watch: {},\n  computed: {},\n  methods: {\n    cancelEditClick() {\n      this.newTagName = ''\n      this.editingTag = null\n    },\n    removeTagClick(tag) {\n      const payload = {\n        message: `Are you sure you want to remove tag \"${tag}\" from all items?`,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removeTag(tag)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    saveTagClick() {\n      this.newTagName = this.newTagName.trim()\n      if (!this.newTagName) {\n        return\n      }\n\n      if (this.editingTag === this.newTagName) {\n        this.cancelEditClick()\n        return\n      }\n\n      const tagNameExists = this.tags.find((t) => t !== this.editingTag && t === this.newTagName)\n      const tagNameExistsOfDifferentCase = !tagNameExists ? this.tags.find((t) => t !== this.editingTag && t.toLowerCase() === this.newTagName.toLowerCase()) : null\n\n      let message = this.$getString('MessageConfirmRenameTag', [this.editingTag, this.newTagName])\n      if (tagNameExists) {\n        message += ` ${this.$strings.MessageConfirmRenameTagMergeNote}`\n      } else if (tagNameExistsOfDifferentCase) {\n        message += ` ${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}`\n      }\n\n      const payload = {\n        message,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.renameTag()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    renameTag() {\n      this.loading = true\n      let _newTagName = this.newTagName\n      let _editingTag = this.editingTag\n\n      const payload = {\n        tag: _editingTag,\n        newTag: _newTagName\n      }\n      this.$axios\n        .$post('/api/tags/rename', payload)\n        .then((res) => {\n          this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))\n          if (res.tagMerged) {\n            this.tags = this.tags.filter((t) => t !== _newTagName)\n          }\n          this.tags = this.tags.map((t) => {\n            if (t === _editingTag) return _newTagName\n            return t\n          })\n          this.cancelEditClick()\n        })\n        .catch((error) => {\n          console.error('Failed to rename tag', error)\n          this.$toast.error(this.$strings.ToastRenameFailed)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    },\n    removeTag(tag) {\n      this.loading = true\n\n      this.$axios\n        .$delete(`/api/tags/${this.$encode(tag)}`)\n        .then((res) => {\n          this.$toast.success(this.$getString('MessageItemsUpdated', [res.numItemsUpdated]))\n          this.tags = this.tags.filter((t) => t !== tag)\n        })\n        .catch((error) => {\n          console.error('Failed to remove tag', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    },\n    editTagClick(tag) {\n      this.newTagName = tag\n      this.editingTag = tag\n    },\n    init() {\n      this.loading = true\n      this.$axios\n        .$get('/api/tags')\n        .then((data) => {\n          this.tags = (data.tags || []).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))\n        })\n        .catch((error) => {\n          console.error('Failed to load tags', error)\n        })\n        .finally(() => {\n          this.loading = false\n        })\n    }\n  },\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/libraries.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderLibraries\">\n      <template #header-items>\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/library_creation\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n\n        <div class=\"grow\" />\n\n        <ui-btn color=\"bg-primary\" small @click=\"setShowLibraryModal()\">{{ $strings.ButtonAddLibrary }}</ui-btn>\n      </template>\n      <tables-library-libraries-table @showLibraryModal=\"setShowLibraryModal\" class=\"pt-2\" />\n    </app-settings-content>\n    <modals-libraries-edit-modal v-model=\"showLibraryModal\" :library=\"selectedLibrary\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      showLibraryModal: false,\n      selectedLibrary: null\n    }\n  },\n  computed: {},\n  methods: {\n    setShowLibraryModal(selectedLibrary) {\n      this.selectedLibrary = selectedLibrary\n      this.showLibraryModal = true\n    }\n  },\n  mounted() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/log.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderLogs\" :description=\"$strings.MessageLogsDescription\">\n      <template #header-items>\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/server_logs\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n      </template>\n\n      <div class=\"flex justify-between mb-2 place-items-end\">\n        <ui-text-input ref=\"input\" v-model=\"search\" :placeholder=\"$strings.PlaceholderSearch\" @input=\"inputUpdate\" clearable class=\"w-full sm:w-40 h-8 text-sm sm:mb-0\" />\n\n        <ui-dropdown v-model=\"newServerSettings.logLevel\" :label=\"$strings.LabelServerLogLevel\" :items=\"logLevelItems\" @input=\"logLevelUpdated\" class=\"w-full sm:w-44\" />\n      </div>\n\n      <div class=\"relative\">\n        <div ref=\"container\" id=\"log-container\" class=\"relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md\" style=\"min-height: 550px\">\n          <template v-for=\"(log, index) in logs\">\n            <div :key=\"index\" class=\"flex flex-nowrap px-2 py-1 items-start text-sm\" :class=\"`${bgColors[log.level]}`\">\n              <p class=\"text-gray-400 w-36 font-mono text-xs\">{{ log.timestamp }}</p>\n              <p class=\"font-semibold w-12 text-right text-sm\" :class=\"`${textColors[log.level]}`\">{{ log.levelName }}</p>\n              <p class=\"px-4 logmessage\">{{ log.message }}</p>\n            </div>\n          </template>\n        </div>\n\n        <div v-if=\"!logs.length\" class=\"absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center\">\n          <p class=\"text-xl text-gray-200 mb-2\">{{ $strings.MessageNoLogs }}</p>\n        </div>\n      </div>\n    </app-settings-content>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      search: null,\n      searchTimeout: null,\n      searchText: null,\n      newServerSettings: {},\n      textColors: ['text-yellow-200', 'text-gray-400', 'text-info', 'text-warning', 'text-error', 'text-red-800', 'text-blue-400'],\n      bgColors: ['bg-yellow-200/10', 'bg-gray-400/10', 'bg-info/10', 'bg-warning/10', 'bg-error/10', 'bg-red-800/10', 'bg-blue-400/10'],\n      loadedLogs: []\n    }\n  },\n  watch: {\n    serverSettings(newVal, oldVal) {\n      if (newVal && !oldVal) {\n        this.newServerSettings = { ...this.serverSettings }\n      }\n    },\n    logs() {\n      this.updateScroll()\n    }\n  },\n  computed: {\n    logLevels() {\n      return [\n        {\n          value: 1,\n          text: this.$strings.LabelLogLevelDebug\n        },\n        {\n          value: 2,\n          text: this.$strings.LabelLogLevelInfo\n        },\n        {\n          value: 3,\n          text: this.$strings.LabelLogLevelWarn\n        }\n      ]\n    },\n    logLevelItems() {\n      if (process.env.NODE_ENV === 'production') return this.logLevels\n      this.logLevels.unshift({ text: 'Trace', value: 0 })\n      return this.logLevels\n    },\n    logs() {\n      return this.loadedLogs.filter((log) => {\n        if (log.level >= this.newServerSettings.logLevel) {\n          if (this.searchText) {\n            return log.message.toLowerCase().includes(this.searchText)\n          }\n          return true\n        }\n        return false\n      })\n    },\n    serverSettings() {\n      return this.$store.state.serverSettings\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {\n    inputUpdate() {\n      clearTimeout(this.searchTimeout)\n      this.searchTimeout = setTimeout(() => {\n        if (!this.search || !this.search.trim()) {\n          this.searchText = ''\n          return\n        }\n        this.searchText = this.search.toLowerCase().trim()\n      }, 500)\n    },\n    updateScroll() {\n      if (this.$refs.container) {\n        this.$refs.container.scrollTop = this.$refs.container.scrollHeight - this.$refs.container.clientHeight\n      }\n    },\n    logLevelUpdated(val) {\n      var payload = {\n        logLevel: Number(val)\n      }\n      this.updateServerSettings(payload)\n\n      this.$root.socket.emit('set_log_listener', this.newServerSettings.logLevel)\n      this.$nextTick(this.updateScroll)\n    },\n    updateServerSettings(payload) {\n      this.$store\n        .dispatch('updateServerSettings', payload)\n        .then((success) => {\n          console.log('Updated Server Settings', success)\n        })\n        .catch((error) => {\n          console.error('Failed to update server settings', error)\n        })\n    },\n    logEvtReceived(payload) {\n      this.loadedLogs.push(payload)\n\n      // Dont let logs get too large\n      if (this.loadedLogs.length > 5050) {\n        this.loadedLogs = this.loadedLogs.slice(-5000)\n      }\n    },\n    async loadLoggerData() {\n      const loggerData = await this.$axios.$get('/api/logger-data').catch((error) => {\n        console.error('Failed to load logger data', error)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n      })\n\n      this.loadedLogs = loggerData?.currentDailyLogs || []\n    },\n    async init(attempts = 0) {\n      if (!this.$root.socket) {\n        if (attempts > 10) {\n          return console.error('Failed to setup socket listeners')\n        }\n        setTimeout(() => {\n          this.init(++attempts)\n        }, 250)\n        return\n      }\n\n      await this.loadLoggerData()\n\n      this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}\n      this.$root.socket.on('log', this.logEvtReceived)\n      this.$root.socket.emit('set_log_listener', this.newServerSettings.logLevel)\n    }\n  },\n  updated() {\n    this.$nextTick(this.updateScroll)\n  },\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {\n    if (!this.$root.socket) return\n    this.$root.socket.emit('remove_log_listener')\n    this.$root.socket.off('log', this.logEvtReceived)\n  }\n}\n</script>\n\n<style scoped>\n#log-container {\n  height: calc(100vh - 285px);\n}\n.logmessage {\n  width: calc(100% - 208px);\n}\n</style>\n"
  },
  {
    "path": "client/pages/config/notifications.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderAppriseNotificationSettings\" :description=\"$strings.MessageAppriseDescription\">\n      <form @submit.prevent=\"submitForm\">\n        <ui-text-input-with-label ref=\"apiUrlInput\" v-model=\"appriseApiUrl\" :disabled=\"savingSettings\" label=\"Apprise API Url\" class=\"mb-2\" />\n\n        <div class=\"flex items-center py-2\">\n          <ui-text-input ref=\"maxNotificationQueueInput\" type=\"number\" v-model=\"maxNotificationQueue\" no-spinner :disabled=\"savingSettings\" :padding-x=\"1\" text-center class=\"w-10\" />\n\n          <ui-tooltip :text=\"$strings.LabelNotificationsMaxQueueSizeHelp\" direction=\"right\">\n            <p class=\"pl-2 md:pl-4 text-base md:text-lg\">{{ $strings.LabelNotificationsMaxQueueSize }}<span class=\"material-symbols icon-text ml-1\">info</span></p>\n          </ui-tooltip>\n        </div>\n\n        <div class=\"flex items-center py-2\">\n          <ui-text-input ref=\"maxFailedAttemptsInput\" type=\"number\" v-model=\"maxFailedAttempts\" no-spinner :disabled=\"savingSettings\" :padding-x=\"1\" text-center class=\"w-10\" />\n\n          <ui-tooltip :text=\"$strings.LabelNotificationsMaxFailedAttemptsHelp\" direction=\"right\">\n            <p class=\"pl-2 md:pl-4 text-base md:text-lg\">{{ $strings.LabelNotificationsMaxFailedAttempts }}<span class=\"material-symbols icon-text ml-1\">info</span></p>\n          </ui-tooltip>\n        </div>\n\n        <div class=\"flex items-center justify-end pt-4\">\n          <ui-btn :loading=\"savingSettings\" type=\"submit\">{{ $strings.ButtonSave }}</ui-btn>\n        </div>\n      </form>\n\n      <div class=\"w-full h-px bg-white/10 my-6\" />\n\n      <div class=\"flex items-center justify-between mb-6\">\n        <h2 class=\"text-xl font-semibold\">{{ $strings.HeaderNotifications }}</h2>\n        <ui-btn small color=\"bg-success\" class=\"flex items-center\" @click=\"clickCreate\">{{ $strings.ButtonCreate }} <span class=\"material-symbols text-lg pl-2\">add</span></ui-btn>\n      </div>\n\n      <div v-if=\"!notifications.length\" class=\"flex justify-center text-center\">\n        <p class=\"text-lg text-gray-200\">{{ $strings.MessageNoNotifications }}</p>\n      </div>\n      <template v-for=\"notification in notifications\">\n        <cards-notification-card :key=\"notification.id\" :notification=\"notification\" @update=\"updateSettings\" @edit=\"editNotification\" />\n      </template>\n    </app-settings-content>\n\n    <modals-notification-edit-modal v-model=\"showEditModal\" :notification=\"selectedNotification\" :notification-data=\"notificationData\" @update=\"updateSettings\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      loading: false,\n      savingSettings: false,\n      appriseApiUrl: null,\n      maxNotificationQueue: 0,\n      maxFailedAttempts: 0,\n      notifications: [],\n      notificationSettings: null,\n      notificationData: null,\n      showEditModal: false,\n      selectedNotification: null,\n      sendingTest: false\n    }\n  },\n  computed: {},\n  methods: {\n    updateSettings(settings) {\n      this.notificationSettings = settings\n      this.notifications = settings.notifications\n    },\n    editNotification(notification) {\n      this.selectedNotification = notification\n      this.showEditModal = true\n    },\n    clickCreate() {\n      this.selectedNotification = null\n      this.showEditModal = true\n    },\n    validateAppriseApiUrl() {\n      try {\n        return new URL(this.appriseApiUrl)\n      } catch (error) {\n        console.log('URL error', error)\n        this.$toast.error(error.message)\n        return false\n      }\n    },\n    validateForm() {\n      if (this.$refs.apiUrlInput) {\n        this.$refs.apiUrlInput.blur()\n      }\n      if (this.$refs.maxNotificationQueueInput) {\n        this.$refs.maxNotificationQueueInput.blur()\n      }\n      if (this.$refs.maxFailedAttemptsInput) {\n        this.$refs.maxFailedAttemptsInput.blur()\n      }\n\n      if (!this.validateAppriseApiUrl()) {\n        return false\n      }\n\n      if (isNaN(this.maxNotificationQueue) || this.maxNotificationQueue <= 0) {\n        this.$toast.error(this.$strings.ToastNotificationQueueMaximum)\n        return false\n      }\n\n      if (isNaN(this.maxFailedAttempts) || this.maxFailedAttempts <= 0) {\n        this.$toast.error(this.$strings.ToastNotificationFailedMaximum)\n        return false\n      }\n\n      return true\n    },\n    submitForm() {\n      if (!this.validateForm()) return\n\n      const updatePayload = {\n        appriseApiUrl: this.appriseApiUrl || null,\n        maxNotificationQueue: Number(this.maxNotificationQueue),\n        maxFailedAttempts: Number(this.maxFailedAttempts)\n      }\n      this.savingSettings = true\n      this.$axios\n        .$patch('/api/notifications', updatePayload)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastNotificationSettingsUpdateSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to update notification settings', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n        })\n        .finally(() => {\n          this.savingSettings = false\n        })\n    },\n    async init() {\n      this.loading = true\n      const notificationResponse = await this.$axios.$get('/api/notifications').catch((error) => {\n        console.error('Failed to get notification settings', error)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return null\n      })\n      this.loading = false\n      if (!notificationResponse) {\n        return\n      }\n      this.notificationData = notificationResponse.data\n      this.setNotificationSettings(notificationResponse.settings)\n    },\n    setNotificationSettings(notificationSettings) {\n      this.notificationSettings = notificationSettings\n      this.appriseApiUrl = notificationSettings.appriseApiUrl\n      this.maxNotificationQueue = notificationSettings.maxNotificationQueue\n      this.maxFailedAttempts = notificationSettings.maxFailedAttempts\n      this.notifications = notificationSettings.notifications || []\n    },\n    notificationsUpdated(notificationSettings) {\n      console.log('Notifications updated', notificationSettings)\n      this.setNotificationSettings(notificationSettings)\n    }\n  },\n  mounted() {\n    this.init()\n    this.$root.socket.on('notifications_updated', this.notificationsUpdated)\n  },\n  beforeDestroy() {\n    this.$root.socket.off('notifications_updated', this.notificationsUpdated)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/rss-feeds.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderRSSFeeds\">\n      <template #header-items>\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/rss_feeds\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n      </template>\n\n      <div v-if=\"feeds.length\" class=\"block max-w-full pt-2\">\n        <table class=\"rssFeedsTable text-xs\">\n          <tr class=\"bg-primary/40 h-12\">\n            <th class=\"w-16 min-w-16\"></th>\n            <th class=\"w-48 max-w-64 min-w-24 text-left truncate\">{{ $strings.LabelTitle }}</th>\n            <th class=\"w-48 min-w-24 text-left hidden xl:table-cell\">{{ $strings.LabelSlug }}</th>\n            <th class=\"w-24 min-w-16 text-left hidden md:table-cell\">{{ $strings.LabelType }}</th>\n            <th class=\"w-16 min-w-16 text-center\">{{ $strings.HeaderEpisodes }}</th>\n            <th class=\"w-16 min-w-16 text-center hidden lg:table-cell\">{{ $strings.LabelRSSFeedPreventIndexing }}</th>\n            <th class=\"w-48 min-w-24 grow hidden md:table-cell\">{{ $strings.LabelLastUpdate }}</th>\n            <th class=\"w-16 text-left\"></th>\n          </tr>\n\n          <tr v-for=\"feed in feeds\" :key=\"feed.id\" class=\"cursor-pointer h-12\" @click=\"showFeed(feed)\">\n            <!--  -->\n            <td>\n              <img :src=\"coverUrl(feed)\" class=\"h-auto w-full\" />\n            </td>\n            <!--  -->\n            <td class=\"w-48 max-w-64 min-w-24 text-left truncate\">\n              <p class=\"truncate\">{{ feed.meta.title }}</p>\n            </td>\n            <!--  -->\n            <td class=\"hidden xl:table-cell max-w-48\">\n              <p class=\"truncate\">{{ feed.slug }}</p>\n            </td>\n            <!--  -->\n            <td class=\"hidden md:table-cell\">\n              <p class=\"\">{{ getEntityType(feed.entityType) }}</p>\n            </td>\n            <!--  -->\n            <td class=\"text-center\">\n              <p class=\"\">{{ feed.episodes.length }}</p>\n            </td>\n            <!--  -->\n            <td class=\"text-center leading-none hidden lg:table-cell\">\n              <p v-if=\"feed.meta.preventIndexing\" class=\"\">\n                <span class=\"material-symbols text-2xl\">check</span>\n              </p>\n            </td>\n            <!--  -->\n            <td class=\"text-center hidden md:table-cell\">\n              <ui-tooltip v-if=\"feed.updatedAt\" direction=\"top\" :text=\"$formatDatetime(feed.updatedAt, dateFormat, timeFormat)\">\n                <p class=\"text-gray-200\">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>\n              </ui-tooltip>\n            </td>\n            <!--  -->\n            <td class=\"text-center\">\n              <ui-icon-btn icon=\"delete\" class=\"mx-0.5 text-white/70\" borderless :size=\"7\" iconFontSize=\"1.25rem\" outlined @click.stop=\"deleteFeedClick(feed)\" />\n            </td>\n          </tr>\n        </table>\n      </div>\n    </app-settings-content>\n    <modals-rssfeed-view-feed-modal v-model=\"showFeedModal\" :feed=\"selectedFeed\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      showFeedModal: false,\n      selectedFeed: null,\n      feeds: []\n    }\n  },\n  computed: {\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    showFeed(feed) {\n      this.selectedFeed = feed\n      this.showFeedModal = true\n    },\n    deleteFeedClick(feed) {\n      const payload = {\n        message: this.$strings.MessageConfirmCloseFeed,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.deleteFeed(feed)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    deleteFeed(feed) {\n      this.processing = true\n      this.$axios\n        .$post(`/api/feeds/${feed.id}/close`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)\n          this.show = false\n          this.loadFeeds()\n        })\n        .catch((error) => {\n          console.error('Failed to close RSS feed', error)\n          this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    getEntityType(entityType) {\n      if (entityType === 'libraryItem') return this.$strings.LabelItem\n      else if (entityType === 'series') return this.$strings.LabelSeries\n      else if (entityType === 'collection') return this.$strings.LabelCollection\n      return this.$strings.LabelUnknown\n    },\n    coverUrl(feed) {\n      if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`\n      return `${this.$config.routerBasePath}${feed.feedUrl}/cover`\n    },\n    async loadFeeds() {\n      const data = await this.$axios.$get(`/api/feeds`).catch((err) => {\n        console.error('Failed to load RSS feeds', err)\n        return null\n      })\n      if (!data) {\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return\n      }\n      this.feeds = data.feeds.map((feed) => ({\n        ...feed,\n        episodes: [...feed.episodes].sort((a, b) => {\n          if (!a.pubDate) return 1 // null dates sort to end\n          if (!b.pubDate) return -1\n          const dateA = new Date(a.pubDate)\n          const dateB = new Date(b.pubDate)\n          return dateA - dateB\n        })\n      }))\n    },\n    init() {\n      this.loadFeeds()\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style scoped>\n.rssFeedsTable {\n  border-collapse: collapse;\n  width: 100%;\n  max-width: 100%;\n  border: 1px solid #474747;\n}\n\n.rssFeedsTable tr:first-child {\n  background-color: #272727;\n}\n\n.rssFeedsTable tr:not(:first-child) {\n  background-color: #373838;\n}\n\n.rssFeedsTable tr:not(:first-child):nth-child(odd) {\n  background-color: #2f2f2f;\n}\n\n.rssFeedsTable tr:hover:not(:first-child) {\n  background-color: #474747;\n}\n\n.rssFeedsTable td {\n  padding: 4px 8px;\n}\n\n.rssFeedsTable th {\n  padding: 4px 8px;\n  font-size: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "client/pages/config/sessions.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderListeningSessions\">\n      <div class=\"flex justify-end mb-2\">\n        <ui-dropdown v-model=\"selectedUser\" :items=\"userItems\" :label=\"$strings.LabelFilterByUser\" small class=\"max-w-48\" @input=\"updateUserFilter\" />\n      </div>\n\n      <div v-if=\"listeningSessions.length\" class=\"block max-w-full relative\">\n        <div class=\"overflow-x-auto\">\n          <table class=\"userSessionsTable\">\n            <tr class=\"bg-primary/40\">\n              <th class=\"w-6 min-w-6 text-left hidden md:table-cell h-11\">\n                <ui-checkbox v-model=\"isAllSelected\" :partial=\"numSelected > 0 && !isAllSelected\" small checkbox-bg=\"bg\" />\n              </th>\n              <th v-if=\"numSelected\" class=\"grow text-left\" :colspan=\"7\">\n                <div class=\"flex items-center\">\n                  <p>{{ $getString('MessageSelected', [numSelected]) }}</p>\n                  <div class=\"grow\" />\n                  <ui-btn small color=\"bg-error\" :loading=\"deletingSessions\" @click.stop=\"removeSessionsClick\">{{ $strings.ButtonRemove }}</ui-btn>\n                </div>\n              </th>\n              <th v-if=\"!numSelected\" class=\"grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer\" @click.stop=\"sortColumn('displayTitle')\">\n                <div class=\"inline-flex items-center\">\n                  {{ $strings.LabelItem }} <span :class=\"{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }\" class=\"material-symbols text-base pl-px\">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>\n                </div>\n              </th>\n              <th v-if=\"!numSelected\" class=\"w-20 min-w-20 text-left hidden md:table-cell\">{{ $strings.LabelUser }}</th>\n              <th v-if=\"!numSelected\" class=\"w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer\" @click.stop=\"sortColumn('playMethod')\">\n                <div class=\"inline-flex items-center\">\n                  {{ $strings.LabelPlayMethod }} <span :class=\"{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }\" class=\"material-symbols text-base pl-px\">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>\n                </div>\n              </th>\n              <th v-if=\"!numSelected\" class=\"w-32 min-w-32 text-left hidden sm:table-cell\">{{ $strings.LabelDeviceInfo }}</th>\n              <th v-if=\"!numSelected\" class=\"w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer\" @click.stop=\"sortColumn('timeListening')\">\n                <div class=\"inline-flex items-center\">\n                  {{ $strings.LabelTimeListened }} <span :class=\"{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }\" class=\"material-symbols text-base pl-px hidden sm:inline-block\">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>\n                </div>\n              </th>\n              <th v-if=\"!numSelected\" class=\"w-24 min-w-24 group cursor-pointer\" @click.stop=\"sortColumn('currentTime')\">\n                <div class=\"inline-flex items-center\">\n                  {{ $strings.LabelLastTime }} <span :class=\"{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }\" class=\"material-symbols text-base pl-px hidden sm:inline-block\">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>\n                </div>\n              </th>\n              <th v-if=\"!numSelected\" class=\"grow hidden sm:table-cell cursor-pointer group\" @click.stop=\"sortColumn('updatedAt')\">\n                <div class=\"inline-flex items-center\">\n                  {{ $strings.LabelLastUpdate }} <span :class=\"{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }\" class=\"material-symbols text-base pl-px\">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>\n                </div>\n              </th>\n            </tr>\n\n            <tr v-for=\"session in listeningSessions\" :key=\"session.id\" :class=\"{ selected: session.selected }\" class=\"cursor-pointer\" @click=\"clickSessionRow(session)\">\n              <td class=\"hidden md:table-cell py-1 max-w-6 relative\">\n                <ui-checkbox v-model=\"session.selected\" small checkbox-bg=\"bg\" />\n                <!-- overlay of the checkbox so that the entire box is clickable -->\n                <div class=\"absolute inset-0 w-full h-full\" @click.stop=\"session.selected = !session.selected\" />\n              </td>\n              <td class=\"py-1 grow sm:grow-0 sm:w-48 sm:max-w-48\">\n                <p class=\"text-xs text-gray-200 truncate\">{{ session.displayTitle }}</p>\n                <p class=\"text-xs text-gray-400 truncate\">{{ session.displayAuthor }}</p>\n              </td>\n              <td class=\"hidden md:table-cell w-20 min-w-20\">\n                <p v-if=\"filteredUserUsername\" class=\"text-xs\">{{ filteredUserUsername }}</p>\n                <p v-else class=\"text-xs\">{{ session.user ? session.user.username : 'N/A' }}</p>\n              </td>\n              <td class=\"hidden md:table-cell w-26 min-w-26\">\n                <p class=\"text-xs\">{{ getPlayMethodName(session.playMethod) }}</p>\n              </td>\n              <td class=\"hidden sm:table-cell max-w-32 min-w-32\">\n                <p class=\"text-xs truncate\">\n                  <template v-for=\"(line, index) in getDeviceInfoLines(session.deviceInfo)\">\n                    <br v-if=\"index > 0\" :key=\"'br-' + index\" />{{ line }}\n                  </template>\n                </p>\n              </td>\n              <td class=\"text-center w-24 min-w-24 sm:w-32 sm:min-w-32\">\n                <p class=\"text-xs font-mono\">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>\n              </td>\n              <td class=\"text-center hover:underline w-24 min-w-24\" @click.stop=\"clickCurrentTime(session)\">\n                <p class=\"text-xs font-mono\">{{ $secondsToTimestamp(session.currentTime) }}</p>\n              </td>\n              <td class=\"text-center hidden sm:table-cell\">\n                <ui-tooltip v-if=\"session.updatedAt\" direction=\"top\" :text=\"$formatDatetime(session.updatedAt, dateFormat, timeFormat)\">\n                  <p class=\"text-xs text-gray-200\">{{ $dateDistanceFromNow(session.updatedAt) }}</p>\n                </ui-tooltip>\n              </td>\n            </tr>\n          </table>\n        </div>\n        <!-- table bottom options -->\n        <div class=\"flex items-center my-2\">\n          <div class=\"grow\" />\n          <div class=\"hidden sm:inline-flex items-center\">\n            <p class=\"text-sm whitespace-nowrap\">{{ $strings.LabelRowsPerPage }}</p>\n            <ui-dropdown v-model=\"itemsPerPage\" :items=\"itemsPerPageOptions\" small class=\"w-24 mx-2\" @input=\"updatedItemsPerPage\" />\n          </div>\n          <div class=\"inline-flex items-center\">\n            <p class=\"text-sm mx-2\">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>\n            <ui-icon-btn icon=\"arrow_back_ios_new\" :size=\"9\" icon-font-size=\"1rem\" class=\"mx-1\" :disabled=\"currentPage === 0\" @click=\"prevPage\" />\n            <ui-icon-btn icon=\"arrow_forward_ios\" :size=\"9\" icon-font-size=\"1rem\" class=\"mx-1\" :disabled=\"currentPage >= numPages - 1\" @click=\"nextPage\" />\n          </div>\n        </div>\n\n        <div v-if=\"deletingSessions || loading\" class=\"absolute inset-0 w-full h-full flex items-center justify-center\">\n          <ui-loading-indicator />\n        </div>\n      </div>\n      <p v-else class=\"text-white/50\">{{ $strings.MessageNoListeningSessions }}</p>\n\n      <div v-if=\"openListeningSessions.length\" class=\"w-full my-8 h-px bg-white/10\" />\n\n      <!-- open listening sessions table -->\n      <p v-if=\"openListeningSessions.length\" class=\"text-lg my-4\">{{ $strings.HeaderOpenListeningSessions }}</p>\n      <div v-if=\"openListeningSessions.length\" class=\"block max-w-full\">\n        <table class=\"userSessionsTable\">\n          <tr class=\"bg-primary/40\">\n            <th class=\"w-48 min-w-48 text-left\">{{ $strings.LabelItem }}</th>\n            <th class=\"w-20 min-w-20 text-left hidden md:table-cell\">{{ $strings.LabelUser }}</th>\n            <th class=\"w-32 min-w-32 text-left hidden md:table-cell\">{{ $strings.LabelPlayMethod }}</th>\n            <th class=\"w-32 min-w-32 text-left hidden sm:table-cell\">{{ $strings.LabelDeviceInfo }}</th>\n            <th class=\"w-32 min-w-32\">{{ $strings.LabelTimeListened }}</th>\n            <th class=\"w-16 min-w-16\">{{ $strings.LabelLastTime }}</th>\n            <th class=\"grow hidden sm:table-cell\">{{ $strings.LabelLastUpdate }}</th>\n          </tr>\n\n          <tr v-for=\"session in openListeningSessions\" :key=\"`open-${session.id}`\" class=\"cursor-pointer\" @click=\"showSession(session)\">\n            <td class=\"py-1 max-w-48\">\n              <p class=\"text-xs text-gray-200 truncate\">{{ session.displayTitle }}</p>\n              <p class=\"text-xs text-gray-400 truncate\">{{ session.displayAuthor }}</p>\n            </td>\n            <td class=\"hidden md:table-cell\">\n              <p class=\"text-xs\">{{ session.user ? session.user.username : 'N/A' }}</p>\n            </td>\n            <td class=\"hidden md:table-cell\">\n              <p class=\"text-xs\">{{ getPlayMethodName(session.playMethod) }}</p>\n            </td>\n            <td class=\"hidden sm:table-cell max-w-32 min-w-32\">\n              <p class=\"text-xs truncate\">\n                <template v-for=\"(line, index) in getDeviceInfoLines(session.deviceInfo)\">\n                  <br v-if=\"index > 0\" :key=\"'br-' + index\" />{{ line }}\n                </template>\n              </p>\n            </td>\n            <td class=\"text-center\">\n              <p class=\"text-xs font-mono\">{{ $elapsedPretty(session.timeListening) }}</p>\n            </td>\n            <td class=\"text-center hover:underline\" @click.stop=\"clickCurrentTime(session)\">\n              <p class=\"text-xs font-mono\">{{ $secondsToTimestamp(session.currentTime) }}</p>\n            </td>\n            <td class=\"text-center hidden sm:table-cell\">\n              <ui-tooltip v-if=\"session.updatedAt\" direction=\"top\" :text=\"$formatDatetime(session.updatedAt, dateFormat, timeFormat)\">\n                <p class=\"text-xs text-gray-200\">{{ $dateDistanceFromNow(session.updatedAt) }}</p>\n              </ui-tooltip>\n            </td>\n          </tr>\n        </table>\n      </div>\n\n      <div v-if=\"openShareListeningSessions.length\" class=\"w-full my-8 h-px bg-white/10\" />\n\n      <!-- open share listening sessions table -->\n      <p v-if=\"openShareListeningSessions.length\" class=\"text-lg my-4\">Open Share Listening Sessions</p>\n      <div v-if=\"openShareListeningSessions.length\" class=\"block max-w-full\">\n        <table class=\"userSessionsTable\">\n          <tr class=\"bg-primary/40\">\n            <th class=\"w-48 min-w-48 text-left\">{{ $strings.LabelItem }}</th>\n            <th class=\"w-20 min-w-20 text-left hidden md:table-cell\">{{ $strings.LabelUser }}</th>\n            <th class=\"w-32 min-w-32 text-left hidden md:table-cell\">{{ $strings.LabelPlayMethod }}</th>\n            <th class=\"w-32 min-w-32 text-left hidden sm:table-cell\">{{ $strings.LabelDeviceInfo }}</th>\n            <th class=\"w-16 min-w-16\">{{ $strings.LabelLastTime }}</th>\n            <th class=\"grow hidden sm:table-cell\">{{ $strings.LabelLastUpdate }}</th>\n          </tr>\n\n          <tr v-for=\"session in openShareListeningSessions\" :key=\"`open-${session.id}`\" class=\"cursor-pointer\" @click=\"showSession(session)\">\n            <td class=\"py-1 max-w-48\">\n              <p class=\"text-xs text-gray-200 truncate\">{{ session.displayTitle }}</p>\n              <p class=\"text-xs text-gray-400 truncate\">{{ session.displayAuthor }}</p>\n            </td>\n            <td class=\"hidden md:table-cell\"></td>\n            <td class=\"hidden md:table-cell\">\n              <p class=\"text-xs\">{{ getPlayMethodName(session.playMethod) }}</p>\n            </td>\n            <td class=\"hidden sm:table-cell max-w-32 min-w-32\">\n              <p class=\"text-xs truncate\">\n                <template v-for=\"(line, index) in getDeviceInfoLines(session.deviceInfo)\">\n                  <br v-if=\"index > 0\" :key=\"'br-' + index\" />{{ line }}\n                </template>\n              </p>\n            </td>\n            <td class=\"text-center hover:underline\" @click.stop=\"clickCurrentTime(session)\">\n              <p class=\"text-xs font-mono\">{{ $secondsToTimestamp(session.currentTime) }}</p>\n            </td>\n            <td class=\"text-center hidden sm:table-cell\">\n              <ui-tooltip v-if=\"session.updatedAt\" direction=\"top\" :text=\"$formatDatetime(session.updatedAt, dateFormat, timeFormat)\">\n                <p class=\"text-xs text-gray-200\">{{ $dateDistanceFromNow(session.updatedAt) }}</p>\n              </ui-tooltip>\n            </td>\n          </tr>\n        </table>\n      </div>\n    </app-settings-content>\n\n    <modals-listening-session-modal v-model=\"showSessionModal\" :session=\"selectedSession\" @removedSession=\"removedSession\" @closedSession=\"closedSession\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, redirect, app }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n      return\n    }\n\n    const users = await app.$axios\n      .$get('/api/users')\n      .then((res) => {\n        return res.users.sort((a, b) => {\n          return a.createdAt - b.createdAt\n        })\n      })\n      .catch((error) => {\n        console.error('Failed', error)\n        return []\n      })\n    return {\n      users\n    }\n  },\n  data() {\n    return {\n      loading: false,\n      showSessionModal: false,\n      selectedSession: null,\n      listeningSessions: [],\n      openListeningSessions: [],\n      openShareListeningSessions: [],\n      numPages: 0,\n      total: 0,\n      currentPage: 0,\n      itemsPerPage: 10,\n      userFilter: null,\n      selectedUser: '',\n      sortBy: 'updatedAt',\n      sortDesc: true,\n      processingGoToTimestamp: false,\n      deletingSessions: false,\n      itemsPerPageOptions: [10, 25, 50, 100]\n    }\n  },\n  computed: {\n    username() {\n      return this.user.username\n    },\n    userOnline() {\n      return this.$store.getters['users/getIsUserOnline'](this.user.id)\n    },\n    userItems() {\n      var userItems = [{ value: '', text: this.$strings.LabelAllUsers }]\n      return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))\n    },\n    filteredUserUsername() {\n      if (!this.userFilter) return null\n      const user = this.users.find((u) => u.id === this.userFilter)\n      return user?.username || null\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    },\n    numSelected() {\n      return this.listeningSessions.filter((s) => s.selected).length\n    },\n    isAllSelected: {\n      get() {\n        return this.numSelected === this.listeningSessions.length\n      },\n      set(val) {\n        this.setSelectionForAll(val)\n      }\n    }\n  },\n  methods: {\n    isSortSelected(column) {\n      return this.sortBy === column\n    },\n    sortColumn(column) {\n      if (this.sortBy === column) {\n        this.sortDesc = !this.sortDesc\n      } else {\n        this.sortBy = column\n      }\n      this.loadSessions(this.currentPage)\n    },\n    removeSelectedSessions() {\n      if (!this.numSelected) return\n      this.deletingSessions = true\n\n      let isAllSessions = this.isAllSelected\n      const payload = {\n        sessions: this.listeningSessions.filter((s) => s.selected).map((s) => s.id)\n      }\n      this.$axios\n        .$post(`/api/sessions/batch/delete`, payload)\n        .then(() => {\n          if (isAllSessions) {\n            // If all sessions were removed from the current page then go to the previous page\n            if (this.currentPage > 0) {\n              this.currentPage--\n            }\n            this.loadSessions(this.currentPage)\n          } else {\n            // Filter out the deleted sessions\n            this.listeningSessions = this.listeningSessions.filter((ls) => !payload.sessions.includes(ls.id))\n          }\n        })\n        .catch((error) => {\n          const errorMsg = error.response?.data || this.$strings.ToastRemoveFailed\n          this.$toast.error(errorMsg)\n        })\n        .finally(() => {\n          this.deletingSessions = false\n        })\n    },\n    removeSessionsClick() {\n      if (!this.numSelected) return\n      const payload = {\n        message: this.$getString('MessageConfirmRemoveListeningSessions', [this.numSelected]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removeSelectedSessions()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    setSelectionForAll(val) {\n      this.listeningSessions = this.listeningSessions.map((s) => {\n        s.selected = val\n        return s\n      })\n    },\n    updatedItemsPerPage() {\n      this.currentPage = 0\n      this.loadSessions(this.currentPage)\n    },\n    closedSession() {\n      this.loadOpenSessions()\n    },\n    removedSession() {\n      // If on last page and this was the last session then load prev page\n      if (this.currentPage == this.numPages - 1) {\n        const newTotal = this.total - 1\n        const newNumPages = Math.ceil(newTotal / this.itemsPerPage)\n        if (newNumPages < this.numPages) {\n          this.prevPage()\n          return\n        }\n      }\n\n      this.loadSessions(this.currentPage)\n    },\n    async clickCurrentTime(session) {\n      if (this.processingGoToTimestamp) return\n      this.processingGoToTimestamp = true\n      const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {\n        console.error('Failed to get library item', error)\n        return null\n      })\n\n      if (!libraryItem) {\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        this.processingGoToTimestamp = false\n        return\n      }\n      if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {\n        console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        this.processingGoToTimestamp = false\n        return\n      }\n\n      var queueItem = {}\n      if (session.episodeId) {\n        var episode = libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)\n        queueItem = {\n          libraryItemId: libraryItem.id,\n          libraryId: libraryItem.libraryId,\n          episodeId: episode.id,\n          title: episode.title,\n          subtitle: libraryItem.media.metadata.title,\n          caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n          duration: episode.audioFile.duration || null,\n          coverPath: libraryItem.media.coverPath || null\n        }\n      } else {\n        queueItem = {\n          libraryItemId: libraryItem.id,\n          libraryId: libraryItem.libraryId,\n          episodeId: null,\n          title: libraryItem.media.metadata.title,\n          subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),\n          caption: '',\n          duration: libraryItem.media.duration || null,\n          coverPath: libraryItem.media.coverPath || null\n        }\n      }\n\n      const payload = {\n        message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$eventBus.$emit('play-item', {\n              libraryItemId: libraryItem.id,\n              episodeId: session.episodeId || null,\n              startTime: session.currentTime,\n              queueItems: [queueItem]\n            })\n          }\n          this.processingGoToTimestamp = false\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    updateUserFilter() {\n      this.loadSessions(0)\n    },\n    prevPage() {\n      this.loadSessions(this.currentPage - 1)\n    },\n    nextPage() {\n      this.loadSessions(this.currentPage + 1)\n    },\n    clickSessionRow(session) {\n      if (this.numSelected > 0) {\n        session.selected = !session.selected\n      } else {\n        this.showSession(session)\n      }\n    },\n    showSession(session) {\n      this.selectedSession = session\n      this.showSessionModal = true\n    },\n    getDeviceInfoLines(deviceInfo) {\n      if (!deviceInfo) return []\n      const lines = []\n      if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`)\n      if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)\n      if (deviceInfo.browserName) lines.push(deviceInfo.browserName)\n\n      if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)\n      if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)\n      return lines\n    },\n    getPlayMethodName(playMethod) {\n      if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'\n      else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'\n      else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'\n      else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'\n      return 'Unknown'\n    },\n    async loadSessions(page) {\n      this.loading = true\n      const urlSearchParams = new URLSearchParams()\n      urlSearchParams.set('page', page)\n      urlSearchParams.set('itemsPerPage', this.itemsPerPage)\n      urlSearchParams.set('sort', this.sortBy)\n      urlSearchParams.set('desc', this.sortDesc ? '1' : '0')\n      if (this.selectedUser) {\n        urlSearchParams.set('user', this.selectedUser)\n      }\n\n      const data = await this.$axios.$get(`/api/sessions?${urlSearchParams.toString()}`).catch((err) => {\n        console.error('Failed to load listening sessions', err)\n        return null\n      })\n      this.loading = false\n      if (!data) {\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return\n      }\n\n      this.numPages = data.numPages\n      this.total = data.total\n      this.currentPage = data.page\n      this.listeningSessions = data.sessions.map((ls) => {\n        return {\n          ...ls,\n          selected: false\n        }\n      })\n      this.userFilter = data.userId\n    },\n    async loadOpenSessions() {\n      const data = await this.$axios.$get('/api/sessions/open').catch((err) => {\n        console.error('Failed to load open sessions', err)\n        return null\n      })\n      if (!data) {\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return\n      }\n\n      this.openListeningSessions = (data.sessions || []).map((s) => {\n        s.open = true\n        return s\n      })\n      this.openShareListeningSessions = data.shareSessions || []\n    },\n    init() {\n      this.loadSessions(0)\n      this.loadOpenSessions()\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style scoped>\n.userSessionsTable {\n  border-collapse: collapse;\n  width: 100%;\n  max-width: 100%;\n  border: 1px solid #474747;\n}\n.userSessionsTable tr:first-child {\n  background-color: #272727;\n}\n.userSessionsTable tr:not(:first-child):not(.selected) {\n  background-color: #373838;\n}\n.userSessionsTable tr:not(:first-child):nth-child(odd):not(.selected):not(:hover) {\n  background-color: #2f2f2f;\n}\n.userSessionsTable tr:hover:not(:first-child) {\n  background-color: #474747;\n}\n.userSessionsTable tr.selected {\n  background-color: #474747;\n}\n.userSessionsTable td {\n  padding: 4px 8px;\n}\n.userSessionsTable th {\n  padding: 4px 8px;\n  font-size: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "client/pages/config/stats.vue",
    "content": "<template>\n  <div>\n    <!-- Year in review banner shown at the top in December and January -->\n    <stats-year-in-review-banner v-if=\"showYearInReviewBanner\" />\n\n    <app-settings-content :header-text=\"$strings.HeaderYourStats\" class=\"mb-4!\">\n      <div class=\"flex justify-center\">\n        <div class=\"flex p-2\">\n          <div class=\"hidden sm:block\">\n            <span class=\"hidden sm:block material-symbols text-5xl lg:text-6xl\">auto_stories</span>\n          </div>\n          <div class=\"px-3\">\n            <p class=\"text-4xl md:text-5xl font-bold\">{{ $formatNumber(userItemsFinished.length) }}</p>\n            <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelStatsItemsFinished }}</p>\n          </div>\n        </div>\n\n        <div class=\"flex p-2\">\n          <div class=\"hidden sm:block\">\n            <span class=\"hidden sm:block material-symbols text-5xl lg:text-6xl\">event</span>\n          </div>\n          <div class=\"px-1\">\n            <p class=\"text-4xl md:text-5xl font-bold\">{{ $formatNumber(totalDaysListened) }}</p>\n            <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelStatsDaysListened }}</p>\n          </div>\n        </div>\n\n        <div class=\"flex p-2\">\n          <div class=\"hidden sm:block\">\n            <span class=\"material-symbols text-5xl lg:text-6xl\">watch_later</span>\n          </div>\n          <div class=\"px-1\">\n            <p class=\"text-4xl md:text-5xl font-bold\">{{ $formatNumber(totalMinutesListening) }}</p>\n            <p class=\"text-xs md:text-sm text-white/80\">{{ $strings.LabelStatsMinutesListening }}</p>\n          </div>\n        </div>\n      </div>\n      <div class=\"flex flex-col md:flex-row overflow-hidden max-w-full\">\n        <stats-daily-listening-chart :listening-stats=\"listeningStats\" class=\"origin-top-left transform scale-75 lg:scale-100\" />\n        <div class=\"w-80 my-6 mx-auto\">\n          <div class=\"flex mb-4 items-center\">\n            <h1 class=\"text-2xl\">{{ $strings.HeaderStatsRecentSessions }}</h1>\n            <div class=\"grow\" />\n            <ui-btn v-if=\"isAdminOrUp\" :to=\"`/config/users/${user.id}/sessions`\" class=\"text-xs\" :padding-x=\"1.5\" :padding-y=\"1\">{{ $strings.ButtonViewAll }}</ui-btn>\n          </div>\n          <p v-if=\"!mostRecentListeningSessions.length\">{{ $strings.MessageNoListeningSessions }}</p>\n          <template v-for=\"(item, index) in mostRecentListeningSessions\">\n            <div :key=\"item.id\" class=\"w-full py-0.5\">\n              <div class=\"flex items-center mb-1\">\n                <p class=\"text-sm text-white/70 w-8\">{{ index + 1 }}.&nbsp;</p>\n                <div class=\"w-56\">\n                  <p class=\"text-sm text-white/80 truncate\">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>\n                  <p class=\"text-xs text-white/50\">{{ $dateDistanceFromNow(item.updatedAt) }}</p>\n                </div>\n                <div class=\"grow\" />\n                <div class=\"w-18 text-right\">\n                  <p class=\"text-sm font-bold\">{{ $elapsedPretty(item.timeListening) }}</p>\n                </div>\n              </div>\n            </div>\n          </template>\n        </div>\n      </div>\n      <stats-heatmap v-if=\"listeningStats\" :days-listening=\"listeningStats.days\" class=\"my-2\" />\n    </app-settings-content>\n\n    <!-- Year in review banner shown at the bottom Feb - Nov -->\n    <stats-year-in-review-banner v-if=\"!showYearInReviewBanner\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  data() {\n    return {\n      listeningStats: null,\n      windowWidth: 0,\n      showYearInReviewBanner: false\n    }\n  },\n  watch: {\n    currentLibraryId(newVal, oldVal) {\n      if (newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    isAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    username() {\n      return this.user.username\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    userMediaProgress() {\n      return this.user.mediaProgress || []\n    },\n    userItemsFinished() {\n      return this.userMediaProgress.filter((lip) => !!lip.isFinished)\n    },\n    mostRecentListeningSessions() {\n      if (!this.listeningStats) return []\n      return this.listeningStats.recentSessions || []\n    },\n    totalMinutesListening() {\n      if (!this.listeningStats) return 0\n      return Math.round(this.listeningStats.totalTime / 60)\n    },\n    totalDaysListened() {\n      if (!this.listeningStats) return 0\n      return Object.values(this.listeningStats.days).length\n    }\n  },\n  methods: {\n    async init() {\n      this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {\n        console.error('Failed to load listening sesions', err)\n        return []\n      })\n\n      let month = new Date().getMonth()\n      // January and December show year in review banner\n      if (month === 11 || month === 0) {\n        this.showYearInReviewBanner = true\n      }\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/config/users/_id/index.vue",
    "content": "<template>\n  <div class=\"w-full h-full\">\n    <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-0 sm:p-4 mb-8\">\n      <nuxt-link to=\"/config/users\" class=\"text-white/70 hover:text-white/100 hover:bg-white/5 cursor-pointer rounded-full px-2 sm:px-0\">\n        <div class=\"flex items-center\">\n          <div class=\"h-10 w-10 flex items-center justify-center\">\n            <span class=\"material-symbols text-2xl\">arrow_back</span>\n          </div>\n          <p class=\"pl-1\">{{ $strings.LabelAllUsers }}</p>\n        </div>\n      </nuxt-link>\n      <div class=\"flex items-center mb-2 mt-4 px-2 sm:px-0\">\n        <widgets-online-indicator :value=\"!!userOnline\" />\n        <h1 class=\"text-xl pl-2\">{{ username }}</h1>\n      </div>\n      <div v-if=\"legacyToken\" class=\"text-xs space-y-2 mt-4\">\n        <ui-text-input-with-label label=\"Legacy API Token\" :value=\"legacyToken\" readonly show-copy />\n\n        <p class=\"text-warning\" v-html=\"$strings.MessageAuthenticationLegacyTokenWarning\" />\n      </div>\n      <div class=\"w-full h-px bg-white/10 my-2\" />\n      <div class=\"py-2\">\n        <h1 class=\"text-lg mb-2 text-white/90 px-2 sm:px-0\">{{ $strings.HeaderListeningStats }}</h1>\n        <div class=\"flex items-center\">\n          <p class=\"text-sm text-gray-300\">{{ listeningSessions.total }} {{ $strings.HeaderListeningSessions }}</p>\n          <ui-btn :to=\"`/config/users/${user.id}/sessions`\" class=\"text-xs mx-2\" :padding-x=\"1.5\" :padding-y=\"1\">{{ $strings.ButtonViewAll }}</ui-btn>\n        </div>\n        <p class=\"text-sm text-gray-300\">\n          {{ $strings.LabelTotalTimeListened }}:&nbsp;\n          <span class=\"font-mono text-base\">{{ listeningTimePretty }}</span>\n        </p>\n        <p v-if=\"timeListenedToday\" class=\"text-sm text-gray-300\">\n          {{ $strings.LabelTimeListenedToday }}:&nbsp;\n          <span class=\"font-mono text-base\">{{ $elapsedPrettyExtended(timeListenedToday) }}</span>\n        </p>\n\n        <div v-if=\"latestSession\" class=\"mt-4\">\n          <h1 class=\"text-lg mb-2 text-white/90 px-2 sm:px-0\">{{ $strings.HeaderLastListeningSession }}</h1>\n          <p class=\"text-sm text-gray-300\">\n            <strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class=\"font-mono text-base\">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>\n          </p>\n        </div>\n      </div>\n      <div class=\"w-full h-px bg-white/10 my-2\" />\n      <div class=\"py-2\">\n        <h1 class=\"text-lg mb-2 text-white/90 px-2 sm:px-0\">{{ $strings.HeaderSavedMediaProgress }}</h1>\n\n        <table v-if=\"mediaProgress.length\" class=\"userAudiobooksTable\">\n          <tr class=\"bg-primary/40\">\n            <th class=\"w-16 text-left\">{{ $strings.LabelItem }}</th>\n            <th class=\"text-left\"></th>\n            <th class=\"w-32\">{{ $strings.LabelProgress }}</th>\n            <th class=\"w-40 hidden sm:table-cell\">{{ $strings.LabelStartedAt }}</th>\n            <th class=\"w-40 hidden sm:table-cell\">{{ $strings.LabelLastUpdate }}</th>\n          </tr>\n          <tr v-for=\"item in mediaProgress\" :key=\"item.id\" :class=\"!item.isFinished ? '' : 'isFinished'\">\n            <td>\n              <covers-preview-cover v-if=\"item.coverPath\" :width=\"50\" :src=\"$store.getters['globals/getLibraryItemCoverSrcById'](item.libraryItemId, item.mediaUpdatedAt)\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" :show-resolution=\"false\" />\n              <div v-else class=\"bg-primary flex items-center justify-center text-center text-xs text-gray-400 p-1\" :style=\"{ width: '50px', height: 50 * bookCoverAspectRatio + 'px' }\">No Cover</div>\n            </td>\n            <td>\n              <p>{{ item.displayTitle || 'Unknown' }}</p>\n              <p v-if=\"item.displaySubtitle\" class=\"text-white/50 text-sm font-sans\">{{ item.displaySubtitle }}</p>\n            </td>\n            <td class=\"text-center\">\n              <p class=\"text-sm\">{{ Math.floor(item.progress * 100) }}%</p>\n            </td>\n            <td class=\"text-center hidden sm:table-cell\">\n              <ui-tooltip v-if=\"item.startedAt\" direction=\"top\" :text=\"$formatDatetime(item.startedAt, dateFormat, timeFormat)\">\n                <p class=\"text-sm\">{{ $dateDistanceFromNow(item.startedAt) }}</p>\n              </ui-tooltip>\n            </td>\n            <td class=\"text-center hidden sm:table-cell\">\n              <ui-tooltip v-if=\"item.lastUpdate\" direction=\"top\" :text=\"$formatDatetime(item.lastUpdate, dateFormat, timeFormat)\">\n                <p class=\"text-sm\">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>\n              </ui-tooltip>\n            </td>\n          </tr>\n        </table>\n        <p v-else class=\"text-white/50\">{{ $strings.MessageNoMediaProgress }}</p>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ params, redirect, app }) {\n    var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {\n      console.error('Failed to get user', error)\n      return null\n    })\n    if (!user) return redirect('/config/users')\n    return {\n      user\n    }\n  },\n  data() {\n    return {\n      listeningSessions: {},\n      listeningStats: {}\n    }\n  },\n  computed: {\n    legacyToken() {\n      return this.user.token\n    },\n    userToken() {\n      return this.user.accessToken\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    username() {\n      return this.user.username\n    },\n    userOnline() {\n      return this.$store.getters['users/getIsUserOnline'](this.user.id)\n    },\n    mediaProgress() {\n      return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)\n    },\n    totalListeningTime() {\n      return this.listeningStats.totalTime || 0\n    },\n    listeningTimePretty() {\n      return this.$elapsedPrettyExtended(this.totalListeningTime)\n    },\n    timeListenedToday() {\n      return this.listeningStats.today || 0\n    },\n    latestSession() {\n      if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null\n      return this.listeningSessions.sessions[0]\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    async init() {\n      this.listeningSessions = await this.$axios\n        .$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)\n        .then((data) => {\n          return data || {}\n        })\n        .catch((err) => {\n          console.error('Failed to load listening sesions', err)\n          return {}\n        })\n      this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {\n        console.error('Failed to load listening sesions', err)\n        return []\n      })\n      console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style>\n.userAudiobooksTable {\n  border-collapse: collapse;\n  width: 100%;\n  border: 1px solid #474747;\n}\n.userAudiobooksTable tr:nth-child(even) {\n  background-color: #2e2e2e;\n}\n.userAudiobooksTable tr:not(:first-child) {\n  background-color: #373838;\n}\n.userAudiobooksTable tr:hover:not(:first-child) {\n  background-color: #474747;\n}\n.userAudiobooksTable tr.isFinished {\n  background-color: rgba(76, 175, 80, 0.1);\n}\n.userAudiobooksTable td {\n  padding: 4px 8px;\n}\n.userAudiobooksTable th {\n  padding: 4px 8px;\n  font-size: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "client/pages/config/users/_id/sessions.vue",
    "content": "<template>\n  <div class=\"w-full h-full\">\n    <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-0 sm:p-4 mb-8\">\n      <nuxt-link :to=\"`/config/users/${user.id}`\" class=\"text-white/70 hover:text-white/100 hover:bg-white/5 cursor-pointer rounded-full px-2 sm:px-0\">\n        <div class=\"flex items-center\">\n          <div class=\"h-10 w-10 flex items-center justify-center\">\n            <span class=\"material-symbols text-2xl\">arrow_back</span>\n          </div>\n          <p class=\"pl-1\">{{ $strings.LabelBackToUser }}</p>\n        </div>\n      </nuxt-link>\n      <div class=\"flex items-center mb-2 mt-4 px-2 sm:px-0\">\n        <widgets-online-indicator :value=\"!!userOnline\" />\n        <h1 class=\"text-xl pl-2\">{{ username }}</h1>\n      </div>\n\n      <div class=\"w-full h-px bg-white/10 my-2\" />\n\n      <div class=\"py-2\">\n        <h1 class=\"text-lg mb-2 text-white/90 px-2 sm:px-0\">{{ $strings.HeaderListeningSessions }}</h1>\n        <div v-if=\"listeningSessions.length\">\n          <div class=\"overflow-x-auto\">\n            <table class=\"userSessionsTable\">\n              <tr class=\"bg-primary/40\">\n                <th class=\"w-48 min-w-48 text-left\">{{ $strings.LabelItem }}</th>\n                <th class=\"w-32 min-w-32 text-left hidden md:table-cell\">{{ $strings.LabelPlayMethod }}</th>\n                <th class=\"w-32 min-w-32 text-left hidden sm:table-cell\">{{ $strings.LabelDeviceInfo }}</th>\n                <th class=\"w-32 min-w-32\">{{ $strings.LabelTimeListened }}</th>\n                <th class=\"w-16 min-w-16\">{{ $strings.LabelLastTime }}</th>\n                <th class=\"grow hidden sm:table-cell\">{{ $strings.LabelLastUpdate }}</th>\n              </tr>\n              <tr v-for=\"session in listeningSessions\" :key=\"session.id\" class=\"cursor-pointer\" @click=\"showSession(session)\">\n                <td class=\"py-1 max-w-48\">\n                  <p class=\"text-xs text-gray-200 truncate\">{{ session.displayTitle }}</p>\n                  <p class=\"text-xs text-gray-400 truncate\">{{ session.displayAuthor }}</p>\n                </td>\n                <td class=\"hidden md:table-cell\">\n                  <p class=\"text-xs\">{{ getPlayMethodName(session.playMethod) }}</p>\n                </td>\n                <td class=\"hidden sm:table-cell min-w-32 max-w-32\">\n                <p class=\"text-xs truncate\">\n                  <template v-for=\"(line, index) in getDeviceInfoLines(session.deviceInfo)\">\n                    <br v-if=\"index > 0\" :key=\"'br-' + index\" />{{ line }}\n                  </template>\n                </p>\n              </td>\n                <td class=\"text-center\">\n                  <p class=\"text-xs font-mono\">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>\n                </td>\n                <td class=\"text-center hover:underline\" @click.stop=\"clickCurrentTime(session)\">\n                  <p class=\"text-xs font-mono\">{{ $secondsToTimestamp(session.currentTime) }}</p>\n                </td>\n                <td class=\"text-center hidden sm:table-cell\">\n                  <ui-tooltip v-if=\"session.updatedAt\" direction=\"top\" :text=\"$formatDatetime(session.updatedAt, dateFormat, timeFormat)\">\n                    <p class=\"text-xs text-gray-200\">{{ $dateDistanceFromNow(session.updatedAt) }}</p>\n                  </ui-tooltip>\n                </td>\n              </tr>\n            </table>\n          </div>\n          <div class=\"flex items-center justify-end py-1\">\n            <ui-icon-btn icon=\"arrow_back_ios_new\" :size=\"7\" icon-font-size=\"1rem\" class=\"mx-1\" :disabled=\"currentPage === 0\" @click=\"prevPage\" />\n            <p class=\"text-sm mx-1\">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>\n            <ui-icon-btn icon=\"arrow_forward_ios\" :size=\"7\" icon-font-size=\"1rem\" class=\"mx-1\" :disabled=\"currentPage >= numPages - 1\" @click=\"nextPage\" />\n          </div>\n        </div>\n        <p v-else class=\"text-white/50\">No sessions yet...</p>\n      </div>\n    </div>\n\n    <modals-listening-session-modal v-model=\"showSessionModal\" :session=\"selectedSession\" @removedSession=\"removedSession\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ params, redirect, app }) {\n    var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {\n      console.error('Failed to get user', error)\n      return null\n    })\n    if (!user) return redirect('/config/users')\n    return {\n      user\n    }\n  },\n  data() {\n    return {\n      showSessionModal: false,\n      selectedSession: null,\n      listeningSessions: [],\n      numPages: 0,\n      total: 0,\n      currentPage: 0,\n      itemsPerPage: 10,\n      processingGoToTimestamp: false\n    }\n  },\n  computed: {\n    username() {\n      return this.user.username\n    },\n    userOnline() {\n      return this.$store.getters['users/getIsUserOnline'](this.user.id)\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    timeFormat() {\n      return this.$store.getters['getServerSetting']('timeFormat')\n    }\n  },\n  methods: {\n    removedSession() {\n      // If on last page and this was the last session then load prev page\n      if (this.currentPage == this.numPages - 1) {\n        const newTotal = this.total - 1\n        const newNumPages = Math.ceil(newTotal / this.itemsPerPage)\n        if (newNumPages < this.numPages) {\n          this.prevPage()\n          return\n        }\n      }\n\n      this.loadSessions(this.currentPage)\n    },\n    async clickCurrentTime(session) {\n      if (this.processingGoToTimestamp) return\n      this.processingGoToTimestamp = true\n      const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {\n        console.error('Failed to get library item', error)\n        return null\n      })\n\n      if (!libraryItem) {\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        this.processingGoToTimestamp = false\n        return\n      }\n      if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {\n        console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        this.processingGoToTimestamp = false\n        return\n      }\n\n      var queueItem = {}\n      if (session.episodeId) {\n        var episode = libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)\n        queueItem = {\n          libraryItemId: libraryItem.id,\n          libraryId: libraryItem.libraryId,\n          episodeId: episode.id,\n          title: episode.title,\n          subtitle: libraryItem.media.metadata.title,\n          caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n          duration: episode.audioFile.duration || null,\n          coverPath: libraryItem.media.coverPath || null\n        }\n      } else {\n        queueItem = {\n          libraryItemId: libraryItem.id,\n          libraryId: libraryItem.libraryId,\n          episodeId: null,\n          title: libraryItem.media.metadata.title,\n          subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),\n          caption: '',\n          duration: libraryItem.media.duration || null,\n          coverPath: libraryItem.media.coverPath || null\n        }\n      }\n\n      const payload = {\n        message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.$eventBus.$emit('play-item', {\n              libraryItemId: libraryItem.id,\n              episodeId: session.episodeId || null,\n              startTime: session.currentTime,\n              queueItems: [queueItem]\n            })\n          }\n          this.processingGoToTimestamp = false\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    prevPage() {\n      this.loadSessions(this.currentPage - 1)\n    },\n    nextPage() {\n      this.loadSessions(this.currentPage + 1)\n    },\n    showSession(session) {\n      this.selectedSession = session\n      this.showSessionModal = true\n    },\n    getDeviceInfoLines(deviceInfo) {\n      if (!deviceInfo) return []\n      const lines = []\n      if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`)\n      if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)\n      if (deviceInfo.browserName) lines.push(deviceInfo.browserName)\n\n      if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)\n      if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)\n      return lines\n    },\n    getPlayMethodName(playMethod) {\n      if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'\n      else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'\n      else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'\n      else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'\n      return 'Unknown'\n    },\n    async loadSessions(page) {\n      const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=${this.itemsPerPage}`).catch((err) => {\n        console.error('Failed to load listening sesions', err)\n        return null\n      })\n      if (!data) {\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return\n      }\n\n      this.numPages = data.numPages\n      this.total = data.total\n      this.currentPage = data.page\n      this.listeningSessions = data.sessions\n    },\n    init() {\n      this.loadSessions(0)\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n\n<style scoped>\n.userSessionsTable {\n  border-collapse: collapse;\n  width: 100%;\n  max-width: 100%;\n  border: 1px solid #474747;\n}\n.userSessionsTable tr:first-child {\n  background-color: #272727;\n}\n.userSessionsTable tr:not(:first-child) {\n  background-color: #373838;\n}\n.userSessionsTable tr:not(:first-child):nth-child(odd) {\n  background-color: #2f2f2f;\n}\n.userSessionsTable tr:hover:not(:first-child) {\n  background-color: #474747;\n}\n.userSessionsTable td {\n  padding: 4px 8px;\n}\n.userSessionsTable th {\n  padding: 4px 8px;\n  font-size: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "client/pages/config/users/index.vue",
    "content": "<template>\n  <div>\n    <app-settings-content :header-text=\"$strings.HeaderUsers\">\n      <template #header-items>\n        <div v-if=\"numUsers\" class=\"mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center\">\n          <span>{{ numUsers }}</span>\n        </div>\n\n        <ui-tooltip :text=\"$strings.LabelClickForMoreInfo\" class=\"inline-flex ml-2\">\n          <a href=\"https://www.audiobookshelf.org/guides/users\" target=\"_blank\" class=\"inline-flex\">\n            <span class=\"material-symbols text-xl w-5 text-gray-200\">help_outline</span>\n          </a>\n        </ui-tooltip>\n\n        <div class=\"grow\" />\n\n        <ui-btn color=\"bg-primary\" small @click=\"setShowUserModal()\">{{ $strings.ButtonAddUser }}</ui-btn>\n      </template>\n\n      <tables-users-table class=\"pt-2\" @edit=\"setShowUserModal\" @numUsers=\"(count) => (numUsers = count)\" />\n    </app-settings-content>\n    <modals-account-modal ref=\"accountModal\" v-model=\"showAccountModal\" :account=\"selectedAccount\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n    }\n  },\n  data() {\n    return {\n      selectedAccount: null,\n      showAccountModal: false,\n      numUsers: 0\n    }\n  },\n  computed: {},\n  methods: {\n    setShowUserModal(selectedAccount) {\n      this.selectedAccount = selectedAccount\n      this.showAccountModal = true\n    }\n  },\n  mounted() {},\n  beforeDestroy() {\n    if (this.$refs.accountModal) {\n      this.$refs.accountModal.close()\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/config.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"page p-2 md:p-6 overflow-y-auto relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-config-side-nav :is-open.sync=\"sideDrawerOpen\" />\n    <div class=\"configContent\" :class=\"`page-${currentPage}`\">\n      <div v-show=\"isMobilePortrait\" class=\"w-full pb-4 px-2 flex border-b border-white/10 mb-2 cursor-pointer\" @click.stop.prevent=\"toggleShowMore\">\n        <span class=\"material-symbols text-2xl cursor-pointer\">arrow_forward</span>\n        <p class=\"pl-3 capitalize\">{{ currentPage }}</p>\n      </div>\n      <nuxt-child />\n    </div>\n    <div class=\"fixed bottom-0 right-0 w-10 h-10\" @dblclick=\"setDeveloperMode\"></div>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ store, redirect, route }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      // Non-Root user only has access to the listening stats page\n      if (route.name !== 'config-stats') {\n        redirect('/config/stats')\n      }\n    }\n  },\n  data() {\n    return {\n      sideDrawerOpen: false\n    }\n  },\n  watch: {\n    currentPage: {\n      handler() {\n        this.sideDrawerOpen = false\n      }\n    }\n  },\n  computed: {\n    isMobilePortrait() {\n      return this.$store.state.globals.isMobilePortrait\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    currentPage() {\n      if (!this.$route.name) return this.$strings.HeaderSettings\n      var routeName = this.$route.name.split('-')\n      if (routeName.length > 0) {\n        const pageName = routeName.slice(1).join('-')\n        if (pageName === 'log') return this.$strings.HeaderLogs\n        else if (pageName === 'backups') return this.$strings.HeaderBackups\n        else if (pageName === 'libraries') return this.$strings.HeaderLibraries\n        else if (pageName === 'notifications') return this.$strings.HeaderNotifications\n        else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions\n        else if (pageName === 'stats') return this.$strings.HeaderYourStats\n        else if (pageName === 'users') return this.$strings.HeaderUsers\n        else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys\n        else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils\n        else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds\n        else if (pageName === 'email') return this.$strings.HeaderEmail\n        else if (pageName === 'authentication') return this.$strings.HeaderAuthentication\n      }\n      return this.$strings.HeaderSettings\n    }\n  },\n  methods: {\n    toggleShowMore() {\n      this.sideDrawerOpen = !this.sideDrawerOpen\n    },\n    setDeveloperMode() {\n      var value = !this.$store.state.developerMode\n      this.$store.commit('setDeveloperMode', value)\n      this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)\n    }\n  },\n  mounted() {}\n}\n</script>\n\n<style>\n.configContent {\n  margin: auto;\n  width: 900px;\n  max-width: calc(100% - 176px);\n}\n@media (max-width: 1240px) {\n  .configContent {\n    margin-left: 176px;\n  }\n}\n@media (max-width: 640px) {\n  .configContent {\n    margin-left: 0px;\n    width: 100%;\n    max-width: 100%;\n  }\n}\n</style>\n"
  },
  {
    "path": "client/pages/index.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\"></div>\n</template>\n\n<script>\nexport default {\n  asyncData({ redirect, store }) {\n    if (!store.state.libraries.currentLibraryId) {\n      return redirect('/oops?message=No libraries')\n    }\n    redirect(`/library/${store.state.libraries.currentLibraryId}`)\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {},\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/item/_id/index.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page overflow-hidden\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div id=\"item-page-wrapper\" class=\"w-full h-full overflow-y-auto px-2 py-6 lg:p-8\">\n      <div class=\"flex flex-col lg:flex-row max-w-6xl mx-auto\">\n        <div class=\"w-full flex justify-center lg:block lg:w-52\" style=\"min-width: 208px\">\n          <div class=\"relative group\" style=\"height: fit-content\">\n            <covers-book-cover class=\"relative group-hover:brightness-75 transition cursor-pointer\" expand-on-click :library-item=\"libraryItem\" :width=\"bookCoverWidth\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" />\n\n            <!-- Item Progress Bar -->\n            <div v-if=\"!isPodcast\" class=\"absolute bottom-0 left-0 h-1.5 shadow-xs z-10\" :class=\"userIsFinished ? 'bg-success' : 'bg-yellow-400'\" :style=\"{ width: 208 * progressPercent + 'px' }\"></div>\n\n            <!-- Item Cover Overlay -->\n            <div class=\"absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none\">\n              <div v-show=\"showPlayButton && !isStreaming\" class=\"h-full flex items-center justify-center pointer-events-none\">\n                <button class=\"hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer\" :aria-label=\"$strings.ButtonPlay\" @click.stop.prevent=\"playItem\">\n                  <span class=\"material-symbols fill text-4xl\">play_arrow</span>\n                </button>\n              </div>\n\n              <button class=\"absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white/75 hover:text-white/100 hover:scale-110 transform duration-200 pointer-events-auto\" :aria-label=\"$strings.ButtonEdit\" @click=\"showEditCover\">edit</button>\n            </div>\n          </div>\n        </div>\n        <div class=\"grow px-2 py-6 lg:py-0 md:px-10\">\n          <div class=\"flex justify-center\">\n            <div class=\"mb-4\">\n              <h1 class=\"text-2xl md:text-3xl font-semibold\">\n                <div class=\"flex items-center\">\n                  {{ title }}\n                  <widgets-explicit-indicator v-if=\"isExplicit\" />\n                  <widgets-abridged-indicator v-if=\"isAbridged\" />\n                </div>\n              </h1>\n\n              <p v-if=\"bookSubtitle\" class=\"text-gray-200 text-xl md:text-2xl\">{{ bookSubtitle }}</p>\n\n              <template v-for=\"(_series, index) in seriesList\">\n                <nuxt-link :key=\"_series.id\" :to=\"`/library/${libraryId}/series/${_series.id}`\" class=\"hover:underline font-sans text-gray-300 text-lg leading-7\">{{ _series.text }}</nuxt-link\n                ><span :key=\"index\" v-if=\"index < seriesList.length - 1\">, </span>\n              </template>\n\n              <p v-if=\"isPodcast\" class=\"mb-2 mt-0.5 text-gray-200 text-lg md:text-xl\">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>\n              <p v-else-if=\"authors.length\" class=\"mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden text-ellipsis\">\n                {{ $getString('LabelByAuthor', ['']) }}<nuxt-link v-for=\"(author, index) in authors\" :key=\"index\" :to=\"`/author/${author.id}`\" class=\"hover:underline\">{{ author.name }}<span v-if=\"index < authors.length - 1\">,&nbsp;</span></nuxt-link>\n              </p>\n              <p v-else class=\"mb-2 mt-0.5 text-gray-200 text-xl\">by Unknown</p>\n\n              <content-library-item-details :library-item=\"libraryItem\" />\n            </div>\n            <div class=\"hidden md:block grow\" />\n          </div>\n\n          <!-- Podcast episode downloads queue -->\n          <div v-if=\"episodeDownloadsQueued.length\" class=\"px-4 py-2 mt-4 bg-info/40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0\">\n            <div class=\"flex items-center\">\n              <p class=\"text-sm py-1\">{{ $getString('MessageEpisodesQueuedForDownload', [episodeDownloadsQueued.length]) }}</p>\n\n              <span v-if=\"userIsAdminOrUp\" class=\"material-symbols hover:text-error text-xl ml-3 cursor-pointer\" @click=\"clearDownloadQueue\">close</span>\n            </div>\n          </div>\n\n          <!-- Podcast episodes currently downloading -->\n          <div v-if=\"episodesDownloading.length\" class=\"px-4 py-2 mt-4 bg-success/20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0\">\n            <div v-for=\"episode in episodesDownloading\" :key=\"episode.id\" class=\"flex items-center\">\n              <widgets-loading-spinner />\n              <p class=\"text-sm py-1 pl-4\">{{ $strings.MessageDownloadingEpisode }} \"{{ episode.episodeDisplayTitle }}\"</p>\n            </div>\n          </div>\n\n          <!-- Progress -->\n          <div v-if=\"!isPodcast && progressPercent > 0\" class=\"px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0\" :class=\"resettingProgress ? 'opacity-25' : ''\">\n            <p v-if=\"progressPercent < 1\" class=\"leading-6\">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>\n            <p v-else class=\"text-xs\">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>\n            <p v-if=\"progressPercent < 1 && !useEBookProgress\" class=\"text-gray-200 text-xs\">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>\n            <p class=\"text-gray-400 text-xs pt-1\">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>\n\n            <div v-if=\"!resettingProgress\" class=\"absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer\" @click.stop=\"clearProgressClick\">\n              <span class=\"material-symbols text-sm\">&#xe5cd;</span>\n            </div>\n          </div>\n\n          <!-- Icon buttons -->\n          <div class=\"flex items-center justify-center md:justify-start pt-4\">\n            <ui-btn v-if=\"showPlayButton\" :disabled=\"isStreaming\" color=\"bg-success\" :padding-x=\"4\" small class=\"flex items-center h-9 mr-2\" @click=\"playItem\">\n              <span v-show=\"!isStreaming\" class=\"material-symbols fill text-2xl -ml-2 pr-1 text-white\">&#xe037;</span>\n              {{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}\n            </ui-btn>\n\n            <ui-btn v-else-if=\"isMissing || isInvalid\" color=\"bg-error\" :padding-x=\"4\" small class=\"flex items-center h-9 mr-2\">\n              <span class=\"material-symbols text-2xl -ml-2 pr-1 text-white\">error</span>\n              {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}\n            </ui-btn>\n\n            <ui-btn v-if=\"showReadButton\" color=\"bg-info\" :padding-x=\"4\" small class=\"flex items-center h-9 mr-2\" @click=\"openEbook\">\n              <span class=\"material-symbols text-2xl -ml-2 pr-2 text-white\" aria-hidden=\"true\">auto_stories</span>\n              {{ $strings.ButtonRead }}\n            </ui-btn>\n\n            <ui-tooltip v-if=\"showQueueBtn\" :text=\"isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem\" direction=\"top\">\n              <ui-icon-btn :icon=\"isQueued ? 'playlist_add_check' : 'playlist_play'\" :bg-color=\"isQueued ? 'bg-primary' : 'bg-success/60'\" class=\"mx-0.5\" :class=\"isQueued ? 'text-success' : ''\" @click=\"queueBtnClick\" />\n            </ui-tooltip>\n\n            <ui-tooltip v-if=\"userCanUpdate\" :text=\"$strings.LabelEdit\" direction=\"top\">\n              <ui-icon-btn icon=\"&#xe3c9;\" outlined class=\"mx-0.5\" :aria-label=\"$strings.LabelEdit\" @click=\"editClick\" />\n            </ui-tooltip>\n\n            <ui-tooltip v-if=\"!isPodcast\" :text=\"userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"top\">\n              <ui-read-icon-btn :disabled=\"isProcessingReadUpdate\" :is-read=\"userIsFinished\" class=\"mx-0.5\" @click=\"toggleFinished\" />\n            </ui-tooltip>\n\n            <!-- Only admin or root user can download new episodes -->\n            <ui-tooltip v-if=\"isPodcast && userIsAdminOrUp\" :text=\"$strings.LabelFindEpisodes\" direction=\"top\">\n              <ui-icon-btn icon=\"search\" class=\"mx-0.5\" :aria-label=\"$strings.LabelFindEpisodes\" :loading=\"fetchingRSSFeed\" outlined @click=\"findEpisodesClick\" />\n            </ui-tooltip>\n\n            <ui-context-menu-dropdown v-if=\"contextMenuItems.length\" :items=\"contextMenuItems\" :menu-width=\"148\" @action=\"contextMenuAction\">\n              <template #default=\"{ showMenu, clickShowMenu, disabled }\">\n                <button type=\"button\" :disabled=\"disabled\" class=\"mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative\" aria-haspopup=\"listbox\" :aria-expanded=\"showMenu\" :aria-label=\"$strings.LabelMore\" @click.stop.prevent=\"clickShowMenu\">\n                  <span class=\"material-symbols text-2xl\">&#xe5d3;</span>\n                </button>\n              </template>\n            </ui-context-menu-dropdown>\n          </div>\n\n          <div class=\"my-4 w-full\">\n            <div ref=\"description\" id=\"item-description\" dir=\"auto\" role=\"paragraph\" class=\"default-style less-spacing text-base text-gray-100 whitespace-pre-line mb-1\" :class=\"{ 'show-full': showFullDescription }\" v-html=\"description\" />\n\n            <button v-if=\"isDescriptionClamped\" class=\"py-0.5 flex items-center text-slate-300 hover:text-white\" @click=\"showFullDescription = !showFullDescription\">{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class=\"material-symbols text-xl pl-1\" v-html=\"showFullDescription ? 'expand_less' : '&#xe313;'\" /></button>\n          </div>\n\n          <tables-chapters-table v-if=\"chapters.length\" :library-item=\"libraryItem\" class=\"mt-6\" />\n\n          <tables-tracks-table v-if=\"tracks.length\" :title=\"$strings.LabelStatsAudioTracks\" :tracks=\"tracksWithAudioFile\" :is-file=\"isFile\" :library-item-id=\"libraryItemId\" class=\"mt-6\" />\n\n          <tables-podcast-lazy-episodes-table ref=\"episodesTable\" v-if=\"isPodcast\" :library-item=\"libraryItem\" />\n\n          <tables-ebook-files-table v-if=\"ebookFiles.length\" :library-item=\"libraryItem\" class=\"mt-6\" />\n\n          <tables-library-files-table v-if=\"libraryFiles.length\" :library-item=\"libraryItem\" class=\"mt-6\" />\n        </div>\n      </div>\n    </div>\n\n    <modals-podcast-episode-feed v-model=\"showPodcastEpisodeFeed\" :library-item=\"libraryItem\" :episodes=\"podcastFeedEpisodes\" :download-queue=\"episodeDownloadsQueued\" :episodes-downloading=\"episodesDownloading\" />\n    <modals-bookmarks-modal v-model=\"showBookmarksModal\" :bookmarks=\"bookmarks\" :playback-rate=\"1\" :library-item-id=\"libraryItemId\" hide-create @select=\"selectBookmark\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, app, redirect, route }) {\n    if (!store.state.user.user) {\n      return redirect(`/login?redirect=${route.path}`)\n    }\n\n    // Include episode downloads for podcasts\n    var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!item) {\n      console.error('No item...', params.id)\n      return redirect('/')\n    }\n    if (store.state.libraries.currentLibraryId !== item.libraryId || !store.state.libraries.filterData) {\n      await store.dispatch('libraries/fetch', item.libraryId)\n    }\n    return {\n      libraryItem: item,\n      rssFeed: item.rssFeed || null,\n      mediaItemShare: item.mediaItemShare || null\n    }\n  },\n  data() {\n    return {\n      resettingProgress: false,\n      isProcessingReadUpdate: false,\n      fetchingRSSFeed: false,\n      showPodcastEpisodeFeed: false,\n      podcastFeedEpisodes: [],\n      episodesDownloading: [],\n      episodeDownloadsQueued: [],\n      showBookmarksModal: false,\n      isDescriptionClamped: false,\n      showFullDescription: false\n    }\n  },\n  computed: {\n    userToken() {\n      return this.$store.getters['user/getToken']\n    },\n    downloadUrl() {\n      return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    },\n    userIsAdminOrUp() {\n      return this.$store.getters['user/getIsAdminOrUp']\n    },\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    bookCoverWidth() {\n      return 208\n    },\n    isDeveloperMode() {\n      return this.$store.state.developerMode\n    },\n    isFile() {\n      return this.libraryItem.isFile\n    },\n    isBook() {\n      return this.libraryItem.mediaType === 'book'\n    },\n    isPodcast() {\n      return this.libraryItem.mediaType === 'podcast'\n    },\n    isMissing() {\n      return this.libraryItem.isMissing\n    },\n    isInvalid() {\n      return this.libraryItem.isInvalid\n    },\n    isExplicit() {\n      return !!this.mediaMetadata.explicit\n    },\n    isAbridged() {\n      return !!this.mediaMetadata.abridged\n    },\n    showPlayButton() {\n      if (this.isMissing || this.isInvalid) return false\n      if (this.isPodcast) return this.podcastEpisodes.length\n      return this.tracks.length\n    },\n    showReadButton() {\n      return this.ebookFile\n    },\n    libraryId() {\n      return this.libraryItem.libraryId\n    },\n    libraryItemId() {\n      return this.libraryItem.id\n    },\n    media() {\n      return this.libraryItem.media || {}\n    },\n    mediaMetadata() {\n      return this.media.metadata || {}\n    },\n    chapters() {\n      return this.media.chapters || []\n    },\n    bookmarks() {\n      if (this.isPodcast) return []\n      return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)\n    },\n    tracks() {\n      return this.media.tracks || []\n    },\n    tracksWithAudioFile() {\n      return this.tracks.map((track) => {\n        track.audioFile = this.media.audioFiles?.find((af) => af.metadata.path === track.metadata.path)\n        return track\n      })\n    },\n    podcastEpisodes() {\n      return this.media.episodes || []\n    },\n    title() {\n      return this.mediaMetadata.title || 'No Title'\n    },\n    bookSubtitle() {\n      if (this.isPodcast) return null\n      return this.mediaMetadata.subtitle\n    },\n    podcastAuthor() {\n      return this.mediaMetadata.author || 'Unknown'\n    },\n    authors() {\n      return this.mediaMetadata.authors || []\n    },\n    series() {\n      return this.mediaMetadata.series || []\n    },\n    seriesList() {\n      return this.series.map((se) => {\n        let text = se.name\n        if (se.sequence) text += ` #${se.sequence}`\n        return {\n          ...se,\n          text\n        }\n      })\n    },\n    duration() {\n      if (!this.tracks.length) return 0\n      return this.media.duration\n    },\n    libraryFiles() {\n      return this.libraryItem.libraryFiles || []\n    },\n    ebookFiles() {\n      return this.libraryFiles.filter((lf) => lf.fileType === 'ebook')\n    },\n    ebookFile() {\n      return this.media.ebookFile\n    },\n    description() {\n      return this.mediaMetadata.description || ''\n    },\n    userMediaProgress() {\n      return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)\n    },\n    userIsFinished() {\n      return this.userMediaProgress ? !!this.userMediaProgress.isFinished : false\n    },\n    userTimeRemaining() {\n      if (!this.userMediaProgress) return 0\n      const duration = this.userMediaProgress.duration || this.duration\n      return duration - this.userMediaProgress.currentTime\n    },\n    useEBookProgress() {\n      if (!this.userMediaProgress || this.userMediaProgress.progress) return false\n      return this.userMediaProgress.ebookProgress > 0\n    },\n    progressPercent() {\n      if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)\n      return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0\n    },\n    userProgressStartedAt() {\n      return this.userMediaProgress ? this.userMediaProgress.startedAt : 0\n    },\n    userProgressFinishedAt() {\n      return this.userMediaProgress ? this.userMediaProgress.finishedAt : 0\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    isStreaming() {\n      return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId\n    },\n    isQueued() {\n      return this.$store.getters['getIsMediaQueued'](this.libraryItemId)\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    },\n    userCanDownload() {\n      return this.$store.getters['user/getUserCanDownload']\n    },\n    showRssFeedBtn() {\n      if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks\n\n      // If rss feed is open then show feed url to users otherwise just show to admins\n      return this.userIsAdminOrUp || this.rssFeed\n    },\n    showQueueBtn() {\n      if (!this.isBook) return false\n      return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem\n    },\n    showCollectionsButton() {\n      return this.isBook && this.userCanUpdate\n    },\n    contextMenuItems() {\n      const items = []\n\n      if (this.showCollectionsButton) {\n        items.push({\n          text: this.$strings.LabelCollections,\n          action: 'collections'\n        })\n      }\n\n      if (!this.isPodcast && this.tracks.length) {\n        items.push({\n          text: this.$strings.LabelYourPlaylists,\n          action: 'playlists'\n        })\n      }\n\n      if (this.bookmarks.length) {\n        items.push({\n          text: this.$strings.LabelYourBookmarks,\n          action: 'bookmarks'\n        })\n      }\n\n      if (this.showRssFeedBtn) {\n        items.push({\n          text: this.$strings.LabelOpenRSSFeed,\n          action: 'rss-feeds'\n        })\n      }\n\n      if (this.userCanDownload) {\n        items.push({\n          text: this.$strings.LabelDownload,\n          action: 'download'\n        })\n      }\n\n      if (this.ebookFile && this.$store.state.libraries.ereaderDevices?.length) {\n        items.push({\n          text: this.$strings.LabelSendEbookToDevice,\n          subitems: this.$store.state.libraries.ereaderDevices.map((d) => {\n            return {\n              text: d.name,\n              action: 'sendToDevice',\n              data: d.name\n            }\n          })\n        })\n      }\n\n      if (this.userIsAdminOrUp && !this.isPodcast && this.tracks.length) {\n        items.push({\n          text: this.$strings.LabelShare,\n          action: 'share'\n        })\n      }\n\n      if (this.userCanDelete) {\n        items.push({\n          text: this.$strings.ButtonDelete,\n          action: 'delete'\n        })\n      }\n\n      return items\n    }\n  },\n  methods: {\n    selectBookmark(bookmark) {\n      if (!bookmark) return\n      if (this.isStreaming) {\n        this.$eventBus.$emit('playback-seek', bookmark.time)\n      } else if (this.streamLibraryItem) {\n        this.showBookmarksModal = false\n        console.log('Already streaming library item so ask about it')\n        const payload = {\n          message: `Start playback for \"${this.title}\" at ${this.$secondsToTimestamp(bookmark.time)}?`,\n          callback: (confirmed) => {\n            if (confirmed) {\n              this.playItem(bookmark.time)\n            }\n          },\n          type: 'yesNo'\n        }\n        this.$store.commit('globals/setConfirmPrompt', payload)\n      } else {\n        this.playItem(bookmark.time)\n      }\n      this.showBookmarksModal = false\n    },\n    clearDownloadQueue() {\n      if (confirm('Are you sure you want to clear episode download queue?')) {\n        this.$axios\n          .$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)\n          .then(() => {\n            this.$toast.success(this.$strings.ToastEpisodeDownloadQueueClearSuccess)\n            this.episodeDownloadQueued = []\n          })\n          .catch((error) => {\n            console.error('Failed to clear queue', error)\n            this.$toast.error(this.$strings.ToastEpisodeDownloadQueueClearFailed)\n          })\n      }\n    },\n    async findEpisodesClick() {\n      if (!this.mediaMetadata.feedUrl) {\n        return this.$toast.error(this.$strings.ToastNoRSSFeed)\n      }\n      this.fetchingRSSFeed = true\n      var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {\n        console.error('Failed to get feed', error)\n        this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)\n        return null\n      })\n      this.fetchingRSSFeed = false\n      if (!payload) return\n\n      console.log('Podcast feed', payload)\n      const podcastfeed = payload.podcast\n      if (!podcastfeed.episodes || !podcastfeed.episodes.length) {\n        this.$toast.info(this.$strings.ToastPodcastNoEpisodesInFeed)\n        return\n      }\n\n      this.podcastFeedEpisodes = podcastfeed.episodes\n      this.showPodcastEpisodeFeed = true\n    },\n    showEditCover() {\n      this.$store.commit('setBookshelfBookIds', [])\n      this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' })\n    },\n    openEbook() {\n      this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: true })\n    },\n    toggleFinished(confirmed = false) {\n      if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {\n        const payload = {\n          message: this.$getString('MessageConfirmMarkItemFinished', [this.title]),\n          callback: (confirmed) => {\n            if (confirmed) {\n              this.toggleFinished(true)\n            }\n          },\n          type: 'yesNo'\n        }\n        this.$store.commit('globals/setConfirmPrompt', payload)\n        return\n      }\n\n      var updatePayload = {\n        isFinished: !this.userIsFinished\n      }\n      this.isProcessingReadUpdate = true\n      this.$axios\n        .$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)\n        .then(() => {\n          this.isProcessingReadUpdate = false\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.isProcessingReadUpdate = false\n          this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)\n        })\n    },\n    playItem(startTime = null) {\n      let episodeId = null\n      const queueItems = []\n      if (this.isPodcast) {\n        // Uses the sorting and filtering from the episode table component\n        const episodesInListeningOrder = this.$refs.episodesTable?.episodesList || []\n\n        // Find the first unplayed episode from the table\n        let episodeIndex = episodesInListeningOrder.findIndex((ep) => {\n          const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)\n          return !podcastProgress || !podcastProgress.isFinished\n        })\n        // If all episodes are played, use the first episode\n        if (episodeIndex < 0) episodeIndex = 0\n\n        episodeId = episodesInListeningOrder[episodeIndex].id\n\n        for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {\n          const episode = episodesInListeningOrder[i]\n          const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)\n          if (!podcastProgress || !podcastProgress.isFinished) {\n            queueItems.push({\n              libraryItemId: this.libraryItemId,\n              libraryId: this.libraryId,\n              episodeId: episode.id,\n              title: episode.title,\n              subtitle: this.title,\n              caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n              duration: episode.audioFile.duration || null,\n              coverPath: this.libraryItem.media.coverPath || null\n            })\n          }\n        }\n      } else {\n        const queueItem = {\n          libraryItemId: this.libraryItemId,\n          libraryId: this.libraryId,\n          episodeId: null,\n          title: this.title,\n          subtitle: this.authors.map((au) => au.name).join(', '),\n          caption: '',\n          duration: this.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n        queueItems.push(queueItem)\n      }\n\n      this.$eventBus.$emit('play-item', {\n        libraryItemId: this.libraryItem.id,\n        episodeId,\n        startTime,\n        queueItems\n      })\n    },\n    editClick() {\n      this.$store.commit('setBookshelfBookIds', [])\n      this.$store.commit('showEditModal', this.libraryItem)\n    },\n    checkDescriptionClamped() {\n      if (!this.$refs.description) return\n      this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight\n    },\n    libraryItemUpdated(libraryItem) {\n      if (libraryItem.id === this.libraryItemId) {\n        console.log('Item was updated', libraryItem)\n        this.libraryItem = libraryItem\n        this.$nextTick(this.checkDescriptionClamped)\n      }\n    },\n    clearProgressClick() {\n      if (!this.userMediaProgress) return\n\n      const payload = {\n        message: this.$strings.MessageConfirmResetProgress,\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.clearProgress()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    clearProgress() {\n      this.resettingProgress = true\n      this.$axios\n        .$delete(`/api/me/progress/${this.userMediaProgress.id}`)\n        .then(() => {\n          console.log('Progress reset complete')\n        })\n        .catch((error) => {\n          console.error('Progress reset failed', error)\n        })\n        .finally(() => {\n          this.resettingProgress = false\n        })\n    },\n    clickRSSFeed() {\n      this.$store.commit('globals/setRSSFeedOpenCloseModal', {\n        id: this.libraryItemId,\n        name: this.title,\n        type: 'item',\n        feed: this.rssFeed,\n        hasEpisodesWithoutPubDate: this.podcastEpisodes.some((ep) => !ep.pubDate)\n      })\n    },\n    episodeDownloadQueued(episodeDownload) {\n      if (episodeDownload.libraryItemId === this.libraryItemId) {\n        this.episodeDownloadsQueued.push(episodeDownload)\n      }\n    },\n    episodeDownloadStarted(episodeDownload) {\n      if (episodeDownload.libraryItemId === this.libraryItemId) {\n        this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)\n        this.episodesDownloading.push(episodeDownload)\n      }\n    },\n    episodeDownloadFinished(episodeDownload) {\n      if (episodeDownload.libraryItemId === this.libraryItemId) {\n        this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)\n        this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)\n      }\n    },\n    episodeDownloadQueueCleared(libraryItemId) {\n      if (libraryItemId === this.libraryItemId) {\n        this.episodeDownloadsQueued = []\n      }\n    },\n    rssFeedOpen(data) {\n      if (data.entityId === this.libraryItemId) {\n        this.rssFeed = data\n      }\n    },\n    rssFeedClosed(data) {\n      if (data.entityId === this.libraryItemId) {\n        this.rssFeed = null\n      }\n    },\n    shareOpen(mediaItemShare) {\n      if (mediaItemShare.mediaItemId === this.media.id) {\n        this.mediaItemShare = mediaItemShare\n      }\n    },\n    shareClosed(mediaItemShare) {\n      if (mediaItemShare.mediaItemId === this.media.id) {\n        this.mediaItemShare = null\n      }\n    },\n    queueBtnClick() {\n      if (this.isQueued) {\n        // Remove from queue\n        this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId })\n      } else {\n        // Add to queue\n\n        const queueItem = {\n          libraryItemId: this.libraryItemId,\n          libraryId: this.libraryId,\n          episodeId: null,\n          title: this.title,\n          subtitle: this.authors.map((au) => au.name).join(', '),\n          caption: '',\n          duration: this.duration || null,\n          coverPath: this.media.coverPath || null\n        }\n        this.$store.commit('addItemToQueue', queueItem)\n      }\n    },\n    downloadLibraryItem() {\n      this.$downloadFile(this.downloadUrl)\n    },\n    deleteLibraryItem() {\n      const payload = {\n        message: this.$strings.MessageConfirmDeleteLibraryItem,\n        checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,\n        yesButtonText: this.$strings.ButtonDelete,\n        yesButtonColor: 'error',\n        checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),\n        callback: (confirmed, hardDelete) => {\n          if (confirmed) {\n            localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)\n\n            this.$axios\n              .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)\n              .then(() => {\n                this.$toast.success(this.$strings.ToastItemDeletedSuccess)\n                this.$router.replace(`/library/${this.libraryId}`)\n              })\n              .catch((error) => {\n                console.error('Failed to delete item', error)\n                this.$toast.error(this.$strings.ToastItemDeleteFailed)\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    sendToDevice(deviceName) {\n      const payload = {\n        message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFile.ebookFormat, this.title, deviceName]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            const payload = {\n              libraryItemId: this.libraryItemId,\n              deviceName\n            }\n            this.processing = true\n            this.$axios\n              .$post(`/api/emails/send-ebook-to-device`, payload)\n              .then(() => {\n                this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))\n              })\n              .catch((error) => {\n                console.error('Failed to send ebook to device', error)\n                this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)\n              })\n              .finally(() => {\n                this.processing = false\n              })\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    contextMenuAction({ action, data }) {\n      if (action === 'collections') {\n        this.$store.commit('setSelectedLibraryItem', this.libraryItem)\n        this.$store.commit('globals/setShowCollectionsModal', true)\n      } else if (action === 'playlists') {\n        this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])\n        this.$store.commit('globals/setShowPlaylistsModal', true)\n      } else if (action === 'bookmarks') {\n        this.showBookmarksModal = true\n      } else if (action === 'rss-feeds') {\n        this.clickRSSFeed()\n      } else if (action === 'download') {\n        this.downloadLibraryItem()\n      } else if (action === 'delete') {\n        this.deleteLibraryItem()\n      } else if (action === 'sendToDevice') {\n        this.sendToDevice(data)\n      } else if (action === 'share') {\n        this.$store.commit('setSelectedLibraryItem', this.libraryItem)\n        this.$store.commit('globals/setShareModal', this.mediaItemShare)\n      }\n    }\n  },\n  mounted() {\n    this.checkDescriptionClamped()\n\n    this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []\n    this.episodesDownloading = this.libraryItem.episodesDownloading || []\n\n    this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n    this.$root.socket.on('item_updated', this.libraryItemUpdated)\n    this.$root.socket.on('rss_feed_open', this.rssFeedOpen)\n    this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)\n    this.$root.socket.on('share_open', this.shareOpen)\n    this.$root.socket.on('share_closed', this.shareClosed)\n    this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)\n    this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)\n    this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)\n    this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)\n  },\n  beforeDestroy() {\n    this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)\n    this.$root.socket.off('item_updated', this.libraryItemUpdated)\n    this.$root.socket.off('rss_feed_open', this.rssFeedOpen)\n    this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)\n    this.$root.socket.off('share_open', this.shareOpen)\n    this.$root.socket.off('share_closed', this.shareClosed)\n    this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)\n    this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)\n    this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)\n    this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)\n  }\n}\n</script>\n\n<style scoped>\n#item-description {\n  overflow: hidden;\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 4;\n  max-height: calc(6 * 1lh);\n}\n\n/* Safari-specific fix for the description clamping */\n@supports (-webkit-touch-callout: none) {\n  #item-description {\n    position: relative;\n    display: block;\n    overflow: hidden;\n    max-height: calc(6 * 1lh);\n  }\n}\n\n#item-description.show-full {\n  -webkit-line-clamp: unset;\n  max-height: 999rem;\n}\n</style>\n"
  },
  {
    "path": "client/pages/library/_library/bookshelf/_id.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar :page=\"id || ''\" />\n    <app-lazy-bookshelf :page=\"id || ''\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ params, query, store, redirect }) {\n    var libraryId = params.library\n    var libraryData = await store.dispatch('libraries/fetch', libraryId)\n    if (!libraryData) {\n      return redirect('/oops?message=Library not found')\n    }\n\n    // Set series sort by\n    if (query.filter || query.sort || query.desc) {\n      const isSeries = params.id === 'series'\n      const settingsUpdate = {\n        [isSeries ? 'seriesFilterBy' : 'filterBy']: query.filter || undefined,\n        [isSeries ? 'seriesSortBy' : 'orderBy']: query.sort || undefined,\n        [isSeries ? 'seriesSortDesc' : 'orderDesc']: query.desc == '0' ? false : query.desc == '1' ? true : undefined\n      }\n      store.dispatch('user/updateUserSettings', settingsUpdate)\n    }\n\n    // Redirect podcast libraries\n    const library = libraryData.library\n    if (library.mediaType === 'podcast' && (params.id === 'collections' || params.id === 'series' || params.id === 'authors')) {\n      return redirect(`/library/${libraryId}`)\n    }\n\n    return {\n      id: params.id || '',\n      libraryId\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/index.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar is-home />\n    <app-book-shelf-categorized />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, redirect }) {\n    const libraryId = params.library\n    const library = await store.dispatch('libraries/fetch', libraryId)\n    if (!library) {\n      return redirect(`/oops?message=Library \"${libraryId}\" not found`)\n    }\n    return {\n      library\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {},\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/narrators.vue",
    "content": "<template>\n  <div class=\"page relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar page=\"narrators\" is-home />\n    <div id=\"bookshelf\" class=\"w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto\">\n      <table class=\"tracksTable max-w-2xl mx-auto\">\n        <tr>\n          <th class=\"text-left\">{{ $strings.LabelName }}</th>\n          <th class=\"text-center w-24\">{{ $strings.LabelBooks }}</th>\n          <th v-if=\"userCanUpdate\" class=\"w-40\"></th>\n        </tr>\n        <tr v-for=\"narrator in narrators\" :key=\"narrator.id\">\n          <td>\n            <nuxt-link v-if=\"selectedNarrator?.id !== narrator.id\" :to=\"`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`\" class=\"text-sm md:text-base text-gray-100 hover:underline\">{{ narrator.name }}</nuxt-link>\n            <form v-else @submit.prevent=\"saveClick\">\n              <ui-text-input v-model=\"newNarratorName\" />\n            </form>\n          </td>\n          <td class=\"text-center w-24\">\n            <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`\" class=\"hover:underline\">{{ narrator.numBooks }}</nuxt-link>\n          </td>\n          <td v-if=\"userCanUpdate\" class=\"w-40\">\n            <div class=\"flex justify-end items-center h-10\">\n              <template v-if=\"selectedNarrator?.id !== narrator.id\">\n                <ui-icon-btn icon=\"edit\" borderless :size=\"8\" icon-font-size=\"1.1rem\" class=\"mx-1\" @click=\"editClick(narrator)\" />\n                <ui-icon-btn icon=\"delete\" borderless :size=\"8\" icon-font-size=\"1.1rem\" @click=\"removeClick(narrator)\" />\n              </template>\n              <template v-else>\n                <ui-btn color=\"bg-success\" small class=\"mr-2\" @click.stop=\"saveClick\">{{ $strings.ButtonSave }}</ui-btn>\n                <ui-btn small @click.stop=\"cancelEditClick\">{{ $strings.ButtonCancel }}</ui-btn>\n              </template>\n            </div>\n          </td>\n        </tr>\n      </table>\n    </div>\n\n    <div v-if=\"loading\" class=\"absolute top-0 left-0 w-full h-[calc(100%-40px)] mt-10 flex items-center justify-center bg-black/25\">\n      <ui-loading-indicator />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, redirect, query, app }) {\n    const libraryId = params.library\n    const libraryData = await store.dispatch('libraries/fetch', libraryId)\n    if (!libraryData) {\n      return redirect('/oops?message=Library not found')\n    }\n\n    const library = libraryData.library\n    if (library.mediaType === 'podcast') {\n      return redirect(`/library/${libraryId}`)\n    }\n\n    return {\n      libraryId\n    }\n  },\n  data() {\n    return {\n      loading: true,\n      narrators: [],\n      selectedNarrator: null,\n      newNarratorName: null\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    }\n  },\n  methods: {\n    removeClick(narrator) {\n      const payload = {\n        message: this.$getString('MessageConfirmRemoveNarrator', [narrator.name]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removeNarrator(narrator.id)\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    editClick(narrator) {\n      this.selectedNarrator = narrator\n      this.newNarratorName = narrator.name\n    },\n    cancelEditClick() {\n      this.selectedNarrator = null\n      this.newNarratorName = null\n    },\n    saveClick() {\n      if (!this.selectedNarrator) return\n      this.newNarratorName = this.newNarratorName?.trim() || ''\n      if (!this.newNarratorName || this.newNarratorName === this.selectedNarrator.name) {\n        this.cancelEditClick()\n        return\n      }\n\n      this.loading = true\n      this.$axios\n        .$patch(`/api/libraries/${this.currentLibraryId}/narrators/${this.selectedNarrator.id}`, { name: this.newNarratorName })\n        .then((data) => {\n          if (data.updated) {\n            this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))\n          } else {\n            this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n          }\n          this.cancelEditClick()\n          this.init()\n        })\n        .catch((error) => {\n          console.error('Failed to updated narrator', error)\n          this.$toast.error(this.$strings.ToastFailedToUpdate)\n          this.loading = false\n        })\n    },\n    removeNarrator(id) {\n      this.loading = true\n      this.$axios\n        .$delete(`/api/libraries/${this.currentLibraryId}/narrators/${id}`)\n        .then((data) => {\n          if (data.updated) {\n            this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))\n          } else {\n            this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)\n          }\n          this.init()\n        })\n        .catch((error) => {\n          console.error('Failed to remove narrator', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n          this.loading = false\n        })\n    },\n    async init() {\n      this.narrators = await this.$axios\n        .$get(`/api/libraries/${this.currentLibraryId}/narrators`)\n        .then((response) => response.narrators)\n        .catch((error) => {\n          console.error('Failed to load narrators', error)\n          return []\n        })\n      this.loading = false\n    }\n  },\n  mounted() {\n    this.init()\n  },\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/podcast/download-queue.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar page=\"podcast-search\" />\n\n    <div id=\"bookshelf\" class=\"w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative\">\n      <div class=\"w-full max-w-5xl mx-auto py-4\">\n        <p class=\"text-xl mb-2 font-semibold px-4 md:px-0\">{{ $strings.HeaderCurrentDownloads }}</p>\n        <p v-if=\"!episodesDownloading.length\" class=\"text-lg py-4\">{{ $strings.MessageNoDownloadsInProgress }}</p>\n        <template v-for=\"episode in episodesDownloading\">\n          <div :key=\"episode.id\" class=\"flex py-5 relative\">\n            <covers-preview-cover :src=\"$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)\" :width=\"96\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" :show-resolution=\"false\" class=\"hidden md:block\" />\n            <div class=\"grow pl-4 max-w-2xl\">\n              <!-- mobile -->\n              <div class=\"flex md:hidden mb-2\">\n                <covers-preview-cover :src=\"$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)\" :width=\"48\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" :show-resolution=\"false\" class=\"md:hidden\" />\n                <div class=\"grow px-2\">\n                  <div class=\"flex items-center\">\n                    <nuxt-link :to=\"`/item/${episode.libraryItemId}`\" class=\"text-sm text-gray-200 hover:underline\">{{ episode.podcastTitle }}</nuxt-link>\n                    <widgets-explicit-indicator v-if=\"episode.podcastExplicit\" />\n                  </div>\n                  <p class=\"text-xs text-gray-300 mb-1\">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>\n                </div>\n              </div>\n              <!-- desktop -->\n              <div class=\"hidden md:block\">\n                <div class=\"flex items-center\">\n                  <nuxt-link :to=\"`/item/${episode.libraryItemId}`\" class=\"text-sm text-gray-200 hover:underline\">{{ episode.podcastTitle }}</nuxt-link>\n                  <widgets-explicit-indicator v-if=\"episode.podcastExplicit\" />\n                </div>\n                <p class=\"text-xs text-gray-300 mb-1\">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>\n              </div>\n\n              <div class=\"flex items-center font-semibold text-gray-200\">\n                <div v-if=\"episode.season || episode.episode\">#</div>\n                <div v-if=\"episode.season\">{{ episode.season }}x</div>\n                <div v-if=\"episode.episode\">{{ episode.episode }}</div>\n              </div>\n\n              <div class=\"flex items-center mb-2\">\n                <span class=\"font-semibold text-sm md:text-base\">{{ episode.episodeDisplayTitle }}</span>\n                <widgets-podcast-type-indicator :type=\"episode.episodeType\" />\n              </div>\n\n              <p class=\"text-sm text-gray-200 mb-4\">{{ episode.subtitle }}</p>\n            </div>\n          </div>\n        </template>\n\n        <tables-podcast-download-queue-table v-if=\"episodeDownloadsQueued.length\" :queue=\"episodeDownloadsQueued\"></tables-podcast-download-queue-table>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ params, redirect, store }) {\n    var libraryId = params.library\n    var libraryData = await store.dispatch('libraries/fetch', libraryId)\n    if (!libraryData) {\n      return redirect('/oops?message=Library not found')\n    }\n\n    // Redirect book libraries\n    const library = libraryData.library\n    if (library.mediaType === 'book') {\n      return redirect(`/library/${libraryId}`)\n    }\n\n    return {\n      libraryId: params.library\n    }\n  },\n  data() {\n    return {\n      episodesDownloading: [],\n      episodeDownloadsQueued: [],\n      processing: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {\n    episodeDownloadQueued(episodeDownload) {\n      if (episodeDownload.libraryId === this.libraryId) {\n        this.episodeDownloadsQueued.push(episodeDownload)\n      }\n    },\n    episodeDownloadStarted(episodeDownload) {\n      if (episodeDownload.libraryId === this.libraryId) {\n        this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)\n        this.episodesDownloading.push(episodeDownload)\n      }\n    },\n    episodeDownloadFinished(episodeDownload) {\n      if (episodeDownload.libraryId === this.libraryId) {\n        this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)\n        this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)\n      }\n    },\n    async loadInitialDownloadQueue() {\n      this.processing = true\n      const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {\n        console.error('Failed to get download queue', error)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return null\n      })\n      this.processing = false\n      this.episodeDownloadsQueued = queuePayload?.queue || []\n\n      if (queuePayload?.currentDownload) {\n        this.episodesDownloading.push(queuePayload.currentDownload)\n      }\n\n      // Initialize listeners after load to prevent event race conditions\n      this.initListeners()\n    },\n    initListeners() {\n      this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)\n      this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)\n      this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)\n    }\n  },\n  mounted() {\n    this.loadInitialDownloadQueue()\n  },\n  beforeDestroy() {\n    this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)\n    this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)\n    this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/podcast/latest.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"libraryItemIdStreaming ? 'streaming' : ''\">\n    <app-book-shelf-toolbar page=\"recent-episodes\" />\n\n    <div id=\"bookshelf\" class=\"w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative\">\n      <div class=\"w-full max-w-3xl mx-auto py-4\">\n        <p class=\"text-xl mb-2 font-semibold px-4 md:px-0\">{{ $strings.HeaderLatestEpisodes }}</p>\n        <p v-if=\"!recentEpisodes.length && !processing\" class=\"text-center text-xl\">{{ $strings.MessageNoEpisodes }}</p>\n        <template v-for=\"(episode, index) in episodesMapped\">\n          <div :key=\"episode.id\" class=\"flex py-5 cursor-pointer relative\" @click.stop=\"clickEpisode(episode)\">\n            <covers-preview-cover :src=\"$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)\" :width=\"96\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" :show-resolution=\"false\" class=\"hidden md:block\" />\n            <div class=\"grow pl-4 max-w-2xl\">\n              <!-- mobile -->\n              <div class=\"flex md:hidden mb-2\">\n                <covers-preview-cover :src=\"$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)\" :width=\"48\" :book-cover-aspect-ratio=\"bookCoverAspectRatio\" :show-resolution=\"false\" class=\"md:hidden\" />\n                <div class=\"grow px-2\">\n                  <div class=\"flex items-center\">\n                    <div class=\"flex\" @click.stop>\n                      <nuxt-link :to=\"`/item/${episode.libraryItemId}`\" class=\"text-sm text-gray-200 hover:underline\">{{ episode.podcast.metadata.title }}</nuxt-link>\n                    </div>\n                    <widgets-explicit-indicator v-if=\"episode.podcast.metadata.explicit\" />\n                  </div>\n                  <p class=\"text-xs text-gray-300 mb-1\">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>\n                </div>\n              </div>\n              <!-- desktop -->\n              <div class=\"hidden md:block\">\n                <div class=\"flex items-center\">\n                  <div class=\"flex\" @click.stop>\n                    <nuxt-link :to=\"`/item/${episode.libraryItemId}`\" class=\"text-sm text-gray-200 hover:underline\">{{ episode.podcast.metadata.title }}</nuxt-link>\n                  </div>\n                  <widgets-explicit-indicator v-if=\"episode.podcast.metadata.explicit\" />\n                </div>\n                <p class=\"text-xs text-gray-300 mb-1\">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>\n              </div>\n\n              <div class=\"flex items-center font-semibold text-gray-200\">\n                <div v-if=\"episode.season || episode.episode\">#</div>\n                <div v-if=\"episode.season\">{{ episode.season }}x</div>\n                <div v-if=\"episode.episode\">{{ episode.episode }}</div>\n              </div>\n\n              <div dir=\"auto\" class=\"flex items-center mb-2\">\n                <div class=\"font-semibold text-sm md:text-base\">{{ episode.title }}</div>\n                <widgets-podcast-type-indicator :type=\"episode.episodeType\" />\n              </div>\n\n              <p dir=\"auto\" class=\"text-sm text-gray-200 mb-4 line-clamp-4\" v-html=\"episode.subtitle || episode.description\" />\n\n              <div class=\"flex items-center\">\n                <button class=\"h-8 px-4 border border-white/20 hover:bg-white/10 rounded-full flex items-center justify-center cursor-pointer focus:outline-hidden\" :class=\"episode.progress?.isFinished ? 'text-white/40' : ''\" @click.stop=\"playClick(episode)\">\n                  <span v-if=\"episodeIdStreaming === episode.id\" class=\"material-symbols text-2xl\" :class=\"streamIsPlaying ? '' : 'text-success'\">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>\n                  <span v-else class=\"material-symbols fill text-2xl text-success\">play_arrow</span>\n                  <p class=\"pl-2 pr-1 text-sm font-semibold\">{{ getButtonText(episode) }}</p>\n                </button>\n\n                <ui-tooltip v-if=\"libraryItemIdStreaming && !isStreamingFromDifferentLibrary\" :text=\"playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue\" :class=\"playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''\" direction=\"top\">\n                  <ui-icon-btn :icon=\"playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'\" borderless @click=\"queueBtnClick(episode)\" />\n                </ui-tooltip>\n\n                <ui-tooltip :text=\"!!episode.progress?.isFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished\" direction=\"top\">\n                  <ui-read-icon-btn :disabled=\"episodesProcessingMap[episode.id]\" :is-read=\"!!episode.progress?.isFinished\" borderless class=\"mx-1 mt-0.5\" @click=\"toggleEpisodeFinished(episode)\" />\n                </ui-tooltip>\n\n                <ui-tooltip :text=\"$strings.LabelYourPlaylists\" direction=\"top\">\n                  <ui-icon-btn icon=\"playlist_add\" borderless @click=\"clickAddToPlaylist(episode)\" />\n                </ui-tooltip>\n              </div>\n            </div>\n\n            <div v-if=\"episode.progress\" class=\"absolute bottom-0 left-0 h-0.5 pointer-events-none bg-warning\" :style=\"{ width: episode.progress.progress * 100 + '%' }\" />\n          </div>\n          <div :key=\"index\" v-if=\"index !== recentEpisodes.length\" class=\"w-full h-px bg-white/10\" />\n        </template>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ params, query, store, app, redirect }) {\n    var libraryId = params.library\n    var libraryData = await store.dispatch('libraries/fetch', libraryId)\n    if (!libraryData) {\n      return redirect('/oops?message=Library not found')\n    }\n\n    // Redirect book libraries\n    const library = libraryData.library\n    if (library.mediaType === 'book') {\n      return redirect(`/library/${libraryId}`)\n    }\n\n    return {\n      libraryId\n    }\n  },\n  data() {\n    return {\n      recentEpisodes: [],\n      episodesProcessingMap: {},\n      totalEpisodes: 0,\n      currentPage: 0,\n      processing: false,\n      openingItem: false\n    }\n  },\n  computed: {\n    bookCoverAspectRatio() {\n      return this.$store.getters['libraries/getBookCoverAspectRatio']\n    },\n    libraryItemIdStreaming() {\n      return this.$store.getters['getLibraryItemIdStreaming']\n    },\n    episodeIdStreaming() {\n      return this.$store.state.streamEpisodeId\n    },\n    streamIsPlaying() {\n      return this.$store.state.streamIsPlaying\n    },\n    isStreamingFromDifferentLibrary() {\n      return this.$store.getters['getIsStreamingFromDifferentLibrary']\n    },\n    episodesMapped() {\n      return this.recentEpisodes.map((ep) => {\n        return {\n          ...ep,\n          progress: this.$store.getters['user/getUserMediaProgress'](ep.libraryItemId, ep.id)\n        }\n      })\n    },\n    playerQueueItems() {\n      return this.$store.state.playerQueueItems || []\n    },\n    playerQueueEpisodeIdMap() {\n      const episodeIds = {}\n      this.playerQueueItems.forEach((i) => {\n        if (i.episodeId) episodeIds[i.episodeId] = true\n      })\n      return episodeIds\n    },\n    dateFormat() {\n      return this.$store.getters['getServerSetting']('dateFormat')\n    }\n  },\n  methods: {\n    async toggleEpisodeFinished(episode, confirmed = false) {\n      if (this.episodesProcessingMap[episode.id]) {\n        console.warn('Episode is already processing')\n        return\n      }\n\n      const isFinished = !!episode.progress?.isFinished\n      const itemProgressPercent = episode.progress?.progress || 0\n      if (!isFinished && itemProgressPercent > 0 && !confirmed) {\n        const payload = {\n          message: this.$getString('MessageConfirmMarkItemFinished', [episode.title]),\n          callback: (confirmed) => {\n            if (confirmed) {\n              this.toggleEpisodeFinished(episode, true)\n            }\n          },\n          type: 'yesNo'\n        }\n        this.$store.commit('globals/setConfirmPrompt', payload)\n        return\n      }\n\n      const updatePayload = {\n        isFinished: !isFinished\n      }\n\n      this.$set(this.episodesProcessingMap, episode.id, true)\n\n      this.$axios\n        .$patch(`/api/me/progress/${episode.libraryItemId}/${episode.id}`, updatePayload)\n        .catch((error) => {\n          console.error('Failed to update progress', error)\n          this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)\n        })\n        .finally(() => {\n          this.$set(this.episodesProcessingMap, episode.id, false)\n        })\n    },\n    clickAddToPlaylist(episode) {\n      // Makeshift libraryItem\n      const libraryItem = {\n        id: episode.libraryItemId,\n        media: episode.podcast\n      }\n      this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: libraryItem, episode }])\n      this.$store.commit('globals/setShowPlaylistsModal', true)\n    },\n    async clickEpisode(episode) {\n      if (this.openingItem) return\n      this.openingItem = true\n      const fullLibraryItem = await this.$axios.$get(`/api/items/${episode.libraryItemId}`).catch((error) => {\n        var errMsg = error.response ? error.response.data || '' : ''\n        this.$toast.error(errMsg || 'Failed to get library item')\n        return null\n      })\n      this.openingItem = false\n      if (!fullLibraryItem) return\n\n      this.$store.commit('setSelectedLibraryItem', fullLibraryItem)\n      this.$store.commit('globals/setSelectedEpisode', episode)\n      this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)\n    },\n    getButtonText(episode) {\n      if (this.episodeIdStreaming === episode.id) return this.streamIsPlaying ? 'Streaming' : 'Play'\n      if (!episode.progress) return this.$elapsedPretty(episode.duration)\n      if (episode.progress.isFinished) return 'Finished'\n\n      const duration = episode.progress.duration || episode.duration\n      const remaining = Math.floor(duration - episode.progress.currentTime)\n      return `${this.$elapsedPretty(remaining)} left`\n    },\n    playClick(episodeToPlay) {\n      if (episodeToPlay.id === this.episodeIdStreaming && this.streamIsPlaying) {\n        return this.$eventBus.$emit('pause-item')\n      }\n\n      // Queue up more recent items\n      const queueItems = []\n      const episodeIndex = this.episodesMapped.findIndex((e) => e.id === episodeToPlay.id)\n      const indexFromBack = this.episodesMapped.length - episodeIndex - 1\n      for (let i = this.episodesMapped.length - 1 - indexFromBack; i >= 0; i--) {\n        const episode = this.episodesMapped[i]\n        if (!episode.progress || !episode.isFinished) {\n          queueItems.push({\n            libraryItemId: episode.libraryItemId,\n            libraryId: episode.libraryId,\n            episodeId: episode.id,\n            title: episode.title,\n            subtitle: episode.podcast.metadata.title,\n            caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n            duration: episode.duration || null,\n            coverPath: episode.podcast.coverPath || null\n          })\n        }\n      }\n\n      this.$eventBus.$emit('play-item', {\n        libraryItemId: episodeToPlay.libraryItemId,\n        episodeId: episodeToPlay.id,\n        queueItems\n      })\n    },\n    async loadRecentEpisodes(page = 0) {\n      this.processing = true\n      const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => {\n        console.error('Failed to get recent episodes', error)\n        this.$toast.error(this.$strings.ToastFailedToLoadData)\n        return null\n      })\n      this.processing = false\n      this.recentEpisodes = episodePayload.episodes || []\n      this.totalEpisodes = episodePayload.total\n      this.currentPage = page\n    },\n    queueBtnClick(episode) {\n      if (this.playerQueueEpisodeIdMap[episode.id]) {\n        // Remove from queue\n        this.$store.commit('removeItemFromQueue', { libraryItemId: episode.libraryItemId, episodeId: episode.id })\n      } else {\n        // Add to queue\n        const queueItem = {\n          libraryItemId: episode.libraryItemId,\n          libraryId: episode.libraryId,\n          episodeId: episode.id,\n          title: episode.title,\n          subtitle: episode.podcast.metadata.title,\n          caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,\n          duration: episode.duration || null,\n          coverPath: episode.podcast.coverPath || null\n        }\n        this.$store.commit('addItemToQueue', queueItem)\n      }\n    }\n  },\n  mounted() {\n    this.loadRecentEpisodes()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/podcast/search.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar page=\"podcast-search\" />\n\n    <div id=\"bookshelf\" class=\"w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative\">\n      <div class=\"w-full max-w-4xl mx-auto flex\">\n        <form @submit.prevent=\"submit\" class=\"flex grow\">\n          <ui-text-input v-model=\"searchInput\" type=\"search\" :disabled=\"processing\" :placeholder=\"$strings.MessagePodcastSearchField\" class=\"grow mr-2 text-sm md:text-base\" />\n          <ui-btn type=\"submit\" :disabled=\"processing\" class=\"hidden md:block\">{{ $strings.ButtonSubmit }}</ui-btn>\n          <ui-btn type=\"submit\" :disabled=\"processing\" class=\"block md:hidden\" small>{{ $strings.ButtonSubmit }}</ui-btn>\n        </form>\n        <ui-file-input ref=\"fileInput\" :accept=\"'.opml, .txt'\" class=\"ml-2\" @change=\"opmlFileUpload\">{{ $strings.ButtonUploadOPMLFile }}</ui-file-input>\n      </div>\n      <div class=\"w-full max-w-3xl mx-auto py-4\">\n        <p v-if=\"termSearched && !results.length && !processing\" class=\"text-center text-xl\">{{ $strings.MessageNoPodcastsFound }}</p>\n        <template v-for=\"podcast in results\">\n          <div :key=\"podcast.id\" class=\"flex p-1 hover:bg-primary/25 cursor-pointer\" @click=\"selectPodcast(podcast)\">\n            <div class=\"w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary\">\n              <img v-if=\"podcast.cover\" :src=\"podcast.cover\" class=\"h-full w-full\" />\n            </div>\n            <div class=\"grow pl-4 max-w-2xl\">\n              <div class=\"flex items-center\">\n                <a :href=\"podcast.pageUrl\" class=\"text-base md:text-lg text-gray-200 hover:underline\" target=\"_blank\" @click.stop>{{ podcast.title }}</a>\n                <widgets-explicit-indicator v-if=\"podcast.explicit\" />\n                <widgets-already-in-library-indicator v-if=\"podcast.alreadyInLibrary\" />\n              </div>\n              <p class=\"text-sm md:text-base text-gray-300 whitespace-nowrap truncate\">{{ $getString('LabelByAuthor', [podcast.artistName]) }}</p>\n              <p class=\"text-xs text-gray-400 leading-5\">{{ podcast.genres.join(', ') }}</p>\n              <p class=\"text-xs text-gray-400 leading-5\">{{ podcast.trackCount }} {{ $strings.HeaderEpisodes }}</p>\n            </div>\n          </div>\n        </template>\n      </div>\n\n      <div v-show=\"processing\" class=\"absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black/25 z-40\">\n        <ui-loading-indicator />\n      </div>\n    </div>\n\n    <modals-podcast-new-modal v-model=\"showNewPodcastModal\" :podcast-data=\"selectedPodcast\" :podcast-feed-data=\"selectedPodcastFeed\" />\n    <modals-podcast-opml-feeds-modal v-model=\"showOPMLFeedsModal\" :feeds=\"opmlFeeds\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ params, query, store, app, redirect }) {\n    // Podcast search/add page is restricted to admins\n    if (!store.getters['user/getIsAdminOrUp']) {\n      return redirect(`/library/${params.library}`)\n    }\n\n    var libraryId = params.library\n    var libraryData = await store.dispatch('libraries/fetch', libraryId)\n    if (!libraryData) {\n      return redirect('/oops?message=Library not found')\n    }\n\n    // Redirect book libraries\n    const library = libraryData.library\n    if (library.mediaType === 'book') {\n      return redirect(`/library/${libraryId}`)\n    }\n\n    return {\n      libraryId\n    }\n  },\n  data() {\n    return {\n      searchInput: '',\n      results: [],\n      termSearched: '',\n      processing: false,\n      showNewPodcastModal: false,\n      selectedPodcast: null,\n      selectedPodcastFeed: null,\n      showOPMLFeedsModal: false,\n      opmlFeeds: [],\n      existentPodcasts: []\n    }\n  },\n  computed: {\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    librarySettings() {\n      return this.$store.getters['libraries/getCurrentLibrarySettings']\n    }\n  },\n  methods: {\n    async opmlFileUpload(file) {\n      this.processing = true\n      var txt = await new Promise((resolve) => {\n        const reader = new FileReader()\n        reader.onload = () => {\n          resolve(reader.result)\n        }\n        reader.readAsText(file)\n      })\n\n      if (this.$refs.fileInput) {\n        this.$refs.fileInput.reset()\n      }\n\n      if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) {\n        // Quick lazy check for valid OPML\n        this.$toast.error(this.$strings.MessageTaskOpmlParseFastFail)\n        this.processing = false\n        return\n      }\n\n      this.$axios\n        .$post(`/api/podcasts/opml/parse`, { opmlText: txt })\n        .then((data) => {\n          if (!data.feeds?.length) {\n            this.$toast.error(this.$strings.MessageTaskOpmlParseNoneFound)\n          } else {\n            this.opmlFeeds = data.feeds || []\n            this.showOPMLFeedsModal = true\n          }\n        })\n        .catch((error) => {\n          console.error('Failed', error)\n          this.$toast.error(this.$strings.MessageTaskOpmlParseFailed)\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    submit() {\n      if (!this.searchInput) return\n\n      if (this.searchInput.startsWith('http:') || this.searchInput.startsWith('https:')) {\n        this.termSearched = ''\n        this.results = []\n        this.checkRSSFeed(this.searchInput)\n      } else {\n        this.submitSearch(this.searchInput)\n      }\n    },\n    async checkRSSFeed(rssFeed) {\n      this.processing = true\n      var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed }).catch((error) => {\n        console.error('Failed to get feed', error)\n        this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)\n        return null\n      })\n      this.processing = false\n      if (!payload) return\n\n      this.selectedPodcastFeed = payload.podcast\n      this.selectedPodcast = null\n      this.showNewPodcastModal = true\n    },\n    async submitSearch(term) {\n      this.processing = true\n      this.termSearched = ''\n\n      const searchParams = new URLSearchParams({\n        term,\n        country: this.librarySettings?.podcastSearchRegion || 'us'\n      })\n      let results = await this.$axios.$get(`/api/search/podcast?${searchParams.toString()}`).catch((error) => {\n        console.error('Search request failed', error)\n        return []\n      })\n      console.log('Got results', results)\n\n      // Filter out podcasts without an RSS feed\n      results = results.filter((r) => r.feedUrl)\n\n      for (let result of results) {\n        let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())\n        if (podcast) {\n          result.alreadyInLibrary = true\n          result.existentId = podcast.id\n        }\n      }\n      this.results = results\n      this.termSearched = term\n      this.processing = false\n    },\n    async selectPodcast(podcast) {\n      console.log('Selected podcast', podcast)\n      if (podcast.existentId) {\n        this.$router.push(`/item/${podcast.existentId}`)\n        return\n      }\n      if (!podcast.feedUrl) {\n        this.$toast.error(this.$strings.MessageNoPodcastFeed)\n        return\n      }\n      this.processing = true\n      const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {\n        console.error('Failed to get feed', error)\n        this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)\n        return null\n      })\n      this.processing = false\n      if (!payload) return\n\n      this.selectedPodcastFeed = payload.podcast\n      this.selectedPodcast = podcast\n      this.showNewPodcastModal = true\n      console.log('Got podcast feed', payload.podcast)\n    },\n    async fetchExistentPodcastsInYourLibrary() {\n      this.processing = true\n\n      const podcastsResponse = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/podcast-titles`).catch((error) => {\n        console.error('Failed to fetch podcasts', error)\n        return []\n      })\n      this.existentPodcasts = podcastsResponse.podcasts.map((p) => {\n        return {\n          title: p.title.toLowerCase(),\n          itunesId: p.itunesId,\n          id: p.libraryItemId\n        }\n      })\n      this.processing = false\n    }\n  },\n  mounted() {\n    this.fetchExistentPodcastsInYourLibrary()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/search.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar is-home page=\"search\" :search-query=\"query\" />\n    <app-book-shelf-categorized v-if=\"hasResults\" ref=\"bookshelf\" search :results=\"results\" />\n    <div v-else class=\"w-full py-16\">\n      <p class=\"text-xl text-center\">{{ $getString('MessageNoSearchResultsFor', [query]) }}</p>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, redirect, query, app }) {\n    const libraryId = params.library\n    const library = await store.dispatch('libraries/fetch', libraryId)\n    if (!library) {\n      return redirect('/oops?message=Library not found')\n    }\n    let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${encodeURIComponent(query.q)}`).catch((error) => {\n      console.error('Failed to search library', error)\n      return null\n    })\n    results = {\n      podcasts: results?.podcast || [],\n      episodes: results?.episodes || [],\n      books: results?.book || [],\n      authors: results?.authors || [],\n      series: results?.series || [],\n      tags: results?.tags || [],\n      narrators: results?.narrators || []\n    }\n    return {\n      libraryId,\n      results,\n      query: query.q\n    }\n  },\n  data() {\n    return {}\n  },\n  watch: {\n    '$route.query'(newVal, oldVal) {\n      if (newVal && newVal.q && newVal.q !== this.query) {\n        this.query = newVal.q\n        this.search()\n      }\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    hasResults() {\n      return Object.values(this.results).find((r) => !!r && r.length)\n    }\n  },\n  methods: {\n    async search() {\n      const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${encodeURIComponent(this.query)}`).catch((error) => {\n        console.error('Failed to search library', error)\n        return null\n      })\n      this.results = {\n        podcasts: results?.podcast || [],\n        episodes: results?.episodes || [],\n        books: results?.book || [],\n        authors: results?.authors || [],\n        series: results?.series || [],\n        tags: results?.tags || [],\n        narrators: results?.narrators || []\n      }\n      this.$nextTick(() => {\n        if (this.$refs.bookshelf) {\n          this.$refs.bookshelf.setShelvesFromSearch()\n        }\n      })\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/series/_id.vue",
    "content": "<template>\n  <div class=\"page\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar :selected-series=\"series\" />\n    <app-lazy-bookshelf page=\"series-books\" :series-id=\"seriesId\" />\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, redirect, query, app }) {\n    const libraryId = params.library\n    const libraryData = await store.dispatch('libraries/fetch', libraryId)\n    if (!libraryData) {\n      return redirect('/oops?message=Library not found')\n    }\n\n    const library = libraryData.library\n    if (library.mediaType === 'podcast') {\n      return redirect(`/library/${libraryId}`)\n    }\n\n    const series = await app.$axios.$get(`/api/libraries/${library.id}/series/${params.id}?include=progress,rssfeed`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!series) {\n      return redirect('/oops?message=Series not found')\n    }\n\n    return {\n      series,\n      seriesId: params.id\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    }\n  },\n  methods: {\n    seriesUpdated(series) {\n      this.series = series\n    }\n  },\n  mounted() {\n    if (this.$root.socket) {\n      this.$root.socket.on('series_updated', this.seriesUpdated)\n    }\n  },\n  beforeDestroy() {\n    if (this.$root.socket) {\n      this.$root.socket.off('series_updated', this.seriesUpdated)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/library/_library/stats.vue",
    "content": "<template>\n  <div class=\"page relative\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <app-book-shelf-toolbar page=\"library-stats\" is-home />\n    <div id=\"bookshelf\" class=\"w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto\">\n      <div class=\"w-full max-w-4xl mx-auto\">\n        <stats-preview-icons v-if=\"totalItems\" :library-stats=\"libraryStats\" />\n\n        <div class=\"flex lg:flex-row flex-wrap justify-between flex-col mt-8\">\n          <div class=\"w-80 my-6 mx-auto\">\n            <h1 class=\"text-2xl mb-4\">{{ $strings.HeaderStatsTop5Genres }}</h1>\n            <p v-if=\"!top5Genres.length\">{{ $strings.MessageNoGenres }}</p>\n            <template v-for=\"genre in top5Genres\">\n              <div :key=\"genre.genre\" class=\"w-full py-2\">\n                <div class=\"flex items-end mb-1\">\n                  <p class=\"text-2xl font-bold\">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>\n                  <div class=\"grow\" />\n                  <nuxt-link :to=\"`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`\" class=\"text-base text-white/70 hover:underline\">\n                    {{ genre.genre }}\n                  </nuxt-link>\n                </div>\n                <div class=\"w-full rounded-full h-3 bg-primary/50 overflow-hidden\">\n                  <div class=\"bg-yellow-400 h-full rounded-full\" :style=\"{ width: Math.round((100 * genre.count) / totalItems) + '%' }\" />\n                </div>\n              </div>\n            </template>\n          </div>\n          <div v-if=\"isBookLibrary\" class=\"w-80 my-6 mx-auto\">\n            <h1 class=\"text-2xl mb-4\">{{ $strings.HeaderStatsTop10Authors }}</h1>\n            <p v-if=\"!top10Authors.length\">{{ $strings.MessageNoAuthors }}</p>\n            <template v-for=\"(author, index) in top10Authors\">\n              <div :key=\"author.id\" class=\"w-full py-2\">\n                <div class=\"flex items-center mb-1\">\n                  <p class=\"text-sm text-white/70 w-36 pr-2 truncate\">\n                    {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to=\"`/author/${author.id}`\" class=\"hover:underline\">{{ author.name }}</nuxt-link>\n                  </p>\n                  <div class=\"grow rounded-full h-2.5 bg-primary/0 overflow-hidden\">\n                    <div class=\"bg-yellow-400 h-full rounded-full\" :style=\"{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }\" />\n                  </div>\n                  <div class=\"w-4 ml-3\">\n                    <p class=\"text-sm font-bold\">{{ author.count }}</p>\n                  </div>\n                </div>\n              </div>\n            </template>\n          </div>\n          <div class=\"w-80 my-6 mx-auto\">\n            <h1 class=\"text-2xl mb-4\">{{ $strings.HeaderStatsLongestItems }}</h1>\n            <p v-if=\"!top10LongestItems.length\">{{ $strings.MessageNoItems }}</p>\n            <template v-for=\"(ab, index) in top10LongestItems\">\n              <div :key=\"index\" class=\"w-full py-2\">\n                <div class=\"flex items-center mb-1\">\n                  <p class=\"text-sm text-white/70 w-44 pr-2 truncate\">\n                    {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to=\"`/item/${ab.id}`\" class=\"hover:underline\">{{ ab.title }}</nuxt-link>\n                  </p>\n                  <div class=\"grow rounded-full h-2.5 bg-primary/0 overflow-hidden\">\n                    <div class=\"bg-yellow-400 h-full rounded-full\" :style=\"{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }\" />\n                  </div>\n                  <div class=\"w-4 ml-3\">\n                    <p class=\"text-sm font-bold\">{{ (ab.duration / 3600).toFixed(1) }}</p>\n                  </div>\n                </div>\n              </div>\n            </template>\n          </div>\n          <div class=\"w-80 my-6 mx-auto\">\n            <h1 class=\"text-2xl mb-4\">{{ $strings.HeaderStatsLargestItems }}</h1>\n            <p v-if=\"!top10LargestItems.length\">{{ $strings.MessageNoItems }}</p>\n            <template v-for=\"(ab, index) in top10LargestItems\">\n              <div :key=\"index\" class=\"w-full py-2\">\n                <div class=\"flex items-center mb-1\">\n                  <p class=\"text-sm text-white/70 w-44 pr-2 truncate\">\n                    {{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to=\"`/item/${ab.id}`\" class=\"hover:underline\">{{ ab.title }}</nuxt-link>\n                  </p>\n                  <div class=\"grow rounded-full h-2.5 bg-primary/0 overflow-hidden\">\n                    <div class=\"bg-yellow-400 h-full rounded-full\" :style=\"{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }\" />\n                  </div>\n                  <div class=\"w-4 ml-3\">\n                    <p class=\"text-sm font-bold whitespace-nowrap\">{{ $bytesPretty(ab.size) }}</p>\n                  </div>\n                </div>\n              </div>\n            </template>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ redirect, store, params }) {\n    if (!store.getters['user/getIsAdminOrUp']) {\n      redirect('/')\n      return\n    }\n\n    const libraryId = params.library\n    const library = await store.dispatch('libraries/fetch', libraryId)\n    if (!library) {\n      return redirect(`/oops?message=Library \"${libraryId}\" not found`)\n    }\n    return {}\n  },\n  data() {\n    return {\n      libraryStats: null\n    }\n  },\n  watch: {\n    currentLibraryId(newVal, oldVal) {\n      if (newVal) {\n        this.init()\n      }\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    user() {\n      return this.$store.state.user.user\n    },\n    totalItems() {\n      return this.libraryStats?.totalItems || 0\n    },\n    genresWithCount() {\n      return this.libraryStats?.genresWithCount || []\n    },\n    top5Genres() {\n      return this.genresWithCount?.slice(0, 5) || []\n    },\n    top10LongestItems() {\n      return this.libraryStats?.longestItems || []\n    },\n    longestItemDuration() {\n      if (!this.top10LongestItems.length) return 0\n      return this.top10LongestItems[0].duration\n    },\n    top10LargestItems() {\n      return this.libraryStats?.largestItems || []\n    },\n    largestItemSize() {\n      if (!this.top10LargestItems.length) return 0\n      return this.top10LargestItems[0].size\n    },\n    authorsWithCount() {\n      return this.libraryStats?.authorsWithCount || []\n    },\n    mostUsedAuthorCount() {\n      if (!this.authorsWithCount.length) return 0\n      return this.authorsWithCount[0].count\n    },\n    top10Authors() {\n      return this.authorsWithCount?.slice(0, 10) || []\n    },\n    currentLibraryId() {\n      return this.$store.state.libraries.currentLibraryId\n    },\n    currentLibraryName() {\n      return this.$store.getters['libraries/getCurrentLibraryName']\n    },\n    currentLibraryMediaType() {\n      return this.$store.getters['libraries/getCurrentLibraryMediaType']\n    },\n    isBookLibrary() {\n      return this.currentLibraryMediaType === 'book'\n    }\n  },\n  methods: {\n    async init() {\n      this.libraryStats = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/stats`).catch((err) => {\n        console.error('Failed to get library stats', err)\n        var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'\n        this.$toast.error(`Failed to get library stats: ${errorMsg}`)\n      })\n    }\n  },\n  mounted() {\n    this.init()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/login.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"w-full h-screen overflow-y-auto\">\n    <div class=\"absolute z-0 top-0 left-0 px-6 py-3\">\n      <div class=\"flex items-center\">\n        <img src=\"~static/icon.svg\" alt=\"Audiobookshelf Logo\" class=\"w-10 min-w-10 h-10\" />\n        <h1 class=\"text-xl ml-4 hidden lg:block hover:underline\">audiobookshelf</h1>\n      </div>\n    </div>\n\n    <div class=\"relative z-10 w-full flex h-full items-center justify-center\">\n      <div v-if=\"criticalError\" class=\"w-full max-w-md rounded-sm border border-error/25 bg-error/10 p-4\">\n        <p class=\"text-center text-lg font-semibold\">{{ $strings.MessageServerCouldNotBeReached }}</p>\n      </div>\n      <div v-else-if=\"showInitScreen\" class=\"w-full max-w-lg px-4 md:px-8 pb-8 pt-4\">\n        <p class=\"text-3xl text-white text-center mb-4\">Initial Server Setup</p>\n        <div class=\"w-full h-px bg-white/10 my-4\" />\n\n        <form @submit.prevent=\"submitServerSetup\">\n          <p class=\"text-lg font-semibold mb-2 pl-1 text-center\">Create Root User</p>\n          <ui-text-input-with-label v-model.trim=\"newRoot.username\" label=\"Username\" :disabled=\"processing\" class=\"w-full mb-3 text-sm\" />\n          <ui-text-input-with-label v-model=\"newRoot.password\" label=\"Password\" type=\"password\" :disabled=\"processing\" class=\"w-full mb-3 text-sm\" />\n          <ui-text-input-with-label v-model=\"confirmPassword\" label=\"Confirm Password\" type=\"password\" :disabled=\"processing\" class=\"w-full mb-3 text-sm\" />\n\n          <p class=\"text-lg font-semibold mt-6 mb-2 pl-1 text-center\">Directory Paths</p>\n          <ui-text-input-with-label v-model=\"ConfigPath\" label=\"Config Path\" disabled class=\"w-full mb-3 text-sm\" />\n          <ui-text-input-with-label v-model=\"MetadataPath\" label=\"Metadata Path\" disabled class=\"w-full mb-3 text-sm\" />\n\n          <div class=\"w-full flex justify-end py-3\">\n            <ui-btn type=\"submit\" :disabled=\"processing\" color=\"bg-primary\" class=\"leading-none\">{{ processing ? 'Initializing...' : $strings.ButtonSubmit }}</ui-btn>\n          </div>\n        </form>\n      </div>\n      <div v-else-if=\"isInit\" class=\"w-full max-w-md px-8 pb-8 pt-4 lg:-mt-40\">\n        <div class=\"bg-bg rounded-md shadow-lg border border-white/5 p-4\">\n          <p class=\"text-2xl font-semibold text-center text-white mb-4\">{{ $strings.HeaderLogin }}</p>\n\n          <div class=\"w-full h-px bg-white/10 my-4\" />\n\n          <p v-if=\"loginCustomMessage\" class=\"py-2 default-style mb-2\" v-html=\"loginCustomMessage\"></p>\n\n          <p v-if=\"error\" class=\"text-error text-center py-2\">{{ error }}</p>\n\n          <div v-if=\"showNewAuthSystemMessage\" class=\"mb-4\">\n            <widgets-alert type=\"warning\">\n              <div>\n                <p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>\n                <a v-if=\"showNewAuthSystemAdminMessage\" href=\"https://github.com/advplyr/audiobookshelf/discussions/4460\" target=\"_blank\" class=\"underline\">{{ $strings.LabelMoreInfo }}</a>\n              </div>\n            </widgets-alert>\n          </div>\n\n          <form v-show=\"login_local\" @submit.prevent=\"submitForm\">\n            <label class=\"text-xs text-gray-300 uppercase\">{{ $strings.LabelUsername }}</label>\n            <ui-text-input v-model.trim=\"username\" :disabled=\"processing\" class=\"mb-3 w-full\" inputName=\"username\" />\n\n            <label class=\"text-xs text-gray-300 uppercase\">{{ $strings.LabelPassword }}</label>\n            <ui-text-input v-model.trim=\"password\" type=\"password\" :disabled=\"processing\" class=\"w-full mb-3\" inputName=\"password\" />\n            <div class=\"w-full flex justify-end py-3\">\n              <ui-btn type=\"submit\" :disabled=\"processing\" color=\"bg-primary\" class=\"leading-none\">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>\n            </div>\n          </form>\n\n          <div v-if=\"login_local && login_openid\" class=\"w-full h-px bg-white/10 my-4\" />\n\n          <div class=\"w-full flex py-3\">\n            <a v-if=\"login_openid\" :href=\"openidAuthUri\" class=\"w-full abs-btn outline-hidden rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none\">\n              {{ openIDButtonText }}\n            </a>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  layout: 'blank',\n  data() {\n    return {\n      error: null,\n      criticalError: null,\n      processing: false,\n      username: '',\n      password: null,\n      showInitScreen: false,\n      isInit: false,\n      newRoot: {\n        username: 'root',\n        password: ''\n      },\n      confirmPassword: '',\n      ConfigPath: '',\n      MetadataPath: '',\n      login_local: true,\n      login_openid: false,\n      authFormData: null,\n      // New JWT auth system re-login flags\n      showNewAuthSystemMessage: false,\n      showNewAuthSystemAdminMessage: false\n    }\n  },\n  watch: {\n    user(newVal) {\n      if (newVal) {\n        if (!this.$store.state.libraries.currentLibraryId) {\n          // No libraries available to this user\n          if (this.$store.getters['user/getIsRoot']) {\n            // If root user go to config/libraries\n            this.$router.replace('/config/libraries')\n          } else {\n            this.$router.replace('/oops?message=No libraries available')\n          }\n        } else {\n          if (this.$route.query.redirect) {\n            const isAdminUser = this.$store.getters['user/getIsAdminOrUp']\n            const redirect = this.$route.query.redirect\n            // If not admin user then do not redirect to config pages other than your stats\n            if (isAdminUser || !redirect.startsWith('/config/') || redirect === '/config/stats') {\n              this.$router.replace(redirect)\n              return\n            }\n          }\n\n          this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)\n        }\n      }\n    }\n  },\n  computed: {\n    user() {\n      return this.$store.state.user.user\n    },\n    openidAuthUri() {\n      return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}`\n    },\n    openIDButtonText() {\n      return this.authFormData?.authOpenIDButtonText || 'Login with OpenId'\n    },\n    loginCustomMessage() {\n      return this.authFormData?.authLoginCustomMessage || null\n    }\n  },\n  methods: {\n    async submitServerSetup() {\n      if (!this.newRoot.username || !this.newRoot.username.trim()) {\n        this.$toast.error(this.$strings.ToastUserRootRequireName)\n        return\n      }\n      if (this.newRoot.password !== this.confirmPassword) {\n        this.$toast.error(this.$strings.ToastUserPasswordMismatch)\n        return\n      }\n      if (!this.newRoot.password) {\n        if (!confirm('Are you sure you want to create the root user with no password?')) {\n          return\n        }\n      }\n      this.processing = true\n\n      const payload = {\n        newRoot: { ...this.newRoot }\n      }\n      const success = await this.$axios\n        .$post('/init', payload)\n        .then(() => true)\n        .catch((error) => {\n          console.error('Failed', error.response)\n          const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'\n          this.$toast.error(errorMsg)\n          return false\n        })\n\n      if (!success) {\n        this.processing = false\n        return\n      }\n\n      location.reload()\n    },\n    setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {\n      this.$store.commit('setServerSettings', serverSettings)\n      this.$store.commit('setSource', Source)\n      this.$store.commit('libraries/setEReaderDevices', ereaderDevices)\n      this.$setServerLanguageCode(serverSettings.language)\n\n      if (serverSettings.chromecastEnabled) {\n        console.log('Chromecast enabled import script')\n        require('@/plugins/chromecast.js').default(this)\n      }\n\n      this.$store.commit('libraries/setLastLoad', 0) // Ensure libraries get loaded again when switching users\n      this.$store.commit('libraries/setCurrentLibrary', { id: userDefaultLibraryId })\n      this.$store.commit('user/setUser', user)\n      // Access token only returned from login, not authorize\n      if (user.accessToken) {\n        this.$store.commit('user/setAccessToken', user.accessToken)\n      }\n\n      this.$store.dispatch('user/loadUserSettings')\n    },\n    async submitForm() {\n      this.error = null\n      this.showNewAuthSystemMessage = false\n      this.showNewAuthSystemAdminMessage = false\n      this.processing = true\n\n      const payload = {\n        username: this.username,\n        password: this.password || ''\n      }\n      const authRes = await this.$axios.$post('/login', payload).catch((error) => {\n        console.error('Failed', error.response)\n        if (error.response) this.error = error.response.data\n        else this.error = 'Unknown Error'\n        return false\n      })\n\n      if (authRes?.error) {\n        this.error = authRes.error\n      } else if (authRes) {\n        this.setUser(authRes)\n      }\n      this.processing = false\n    },\n    checkAuth() {\n      const token = localStorage.getItem('token')\n      if (!token) return false\n\n      this.processing = true\n\n      this.$store.commit('user/setAccessToken', token)\n\n      return this.$axios\n        .$post('/api/authorize', null, {\n          headers: {\n            Authorization: `Bearer ${token}`\n          }\n        })\n        .then((res) => {\n          // Force re-login if user is using an old token with no expiration\n          if (res.user.isOldToken) {\n            this.username = res.user.username\n            this.showNewAuthSystemMessage = true\n            // Admin user sees link to github discussion\n            this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'\n            return false\n          }\n\n          this.setUser(res)\n          return true\n        })\n        .catch((error) => {\n          console.error('Authorize error', error)\n          return false\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    checkStatus() {\n      this.processing = true\n      this.$axios\n        .$get('/status')\n        .then((data) => {\n          this.isInit = data.isInit\n          this.showInitScreen = !data.isInit\n          this.$setServerLanguageCode(data.language)\n          if (this.showInitScreen) {\n            this.ConfigPath = data.ConfigPath || ''\n            this.MetadataPath = data.MetadataPath || ''\n          } else {\n            this.authFormData = data.authFormData\n            this.updateLoginVisibility(data.authMethods || [])\n          }\n        })\n        .catch((error) => {\n          console.error('Status check failed', error)\n          this.criticalError = 'Status check failed'\n        })\n        .finally(() => {\n          this.processing = false\n        })\n    },\n    updateLoginVisibility(authMethods) {\n      if (this.$route.query?.error) {\n        this.error = this.$route.query.error\n\n        // Remove error query string\n        const newurl = new URL(location.href)\n        newurl.searchParams.delete('error')\n        window.history.replaceState({ path: newurl.href }, '', newurl.href)\n      }\n\n      if (authMethods.includes('local') || !authMethods.length) {\n        this.login_local = true\n      } else {\n        this.login_local = false\n      }\n\n      if (authMethods.includes('openid')) {\n        // Auto redirect unless query string ?autoLaunch=0 OR when explicity requested through ?autoLaunch=1\n        if ((this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') || this.$route.query?.autoLaunch == '1') {\n          window.location.href = this.openidAuthUri\n        }\n\n        this.login_openid = true\n      } else {\n        this.login_openid = false\n      }\n    }\n  },\n  async mounted() {\n    // Token passed as query parameter after successful oidc login\n    if (this.$route.query?.accessToken) {\n      localStorage.setItem('token', this.$route.query.accessToken)\n    }\n    if (localStorage.getItem('token')) {\n      if (await this.checkAuth()) return // if valid user no need to check status\n    }\n\n    this.checkStatus()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/oops.vue",
    "content": "<template>\n  <div class=\"w-screen h-screen overflow-hidden page\">\n    <div class=\"flex h-1/3 items-center justify-center\">\n      <h1 class=\"text-2xl\">Oops... {{ message }}</h1>\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  asyncData({ query }) {\n    return {\n      message: query.message || ''\n    }\n  },\n  data() {\n    return {}\n  },\n  computed: {},\n  methods: {},\n  mounted() {}\n}\n</script>"
  },
  {
    "path": "client/pages/playlist/_id.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"bg-bg page overflow-hidden\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"w-full h-full overflow-y-auto px-2 py-6 md:p-8\">\n      <div class=\"flex flex-col sm:flex-row max-w-6xl mx-auto\">\n        <div class=\"w-full flex justify-center md:block sm:w-32 md:w-52\" style=\"min-width: 200px\">\n          <div class=\"relative\" style=\"height: fit-content\">\n            <covers-playlist-cover :items=\"playlistItems\" :width=\"200\" :height=\"200\" />\n          </div>\n        </div>\n        <div class=\"grow px-2 py-6 md:py-0 md:px-10\">\n          <div class=\"flex items-end flex-row flex-wrap md:flex-nowrap\">\n            <h1 class=\"text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0\">\n              {{ playlistName }}\n            </h1>\n            <div class=\"grow\" />\n\n            <ui-btn v-if=\"showPlayButton\" :disabled=\"streaming\" color=\"bg-success\" :padding-x=\"4\" small class=\"flex items-center h-9 mr-2\" @click=\"clickPlay\">\n              <span v-show=\"!streaming\" class=\"material-symbols fill text-2xl -ml-2 pr-1 text-white\">play_arrow</span>\n              {{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlayAll }}\n            </ui-btn>\n\n            <ui-icon-btn icon=\"edit\" class=\"mx-0.5\" @click=\"editClick\" />\n\n            <ui-icon-btn icon=\"delete\" class=\"mx-0.5\" @click=\"removeClick\" />\n          </div>\n\n          <div class=\"my-8 max-w-2xl\">\n            <p class=\"text-base text-gray-100\">{{ description }}</p>\n          </div>\n\n          <tables-playlist-items-table :items=\"playlistItems\" :playlist-id=\"playlistId\" />\n        </div>\n      </div>\n    </div>\n    <div v-show=\"processingRemove\" class=\"absolute top-0 left-0 w-full h-full z-10 bg-black/40 flex items-center justify-center\">\n      <ui-loading-indicator />\n    </div>\n  </div>\n</template>\n\n<script>\nexport default {\n  async asyncData({ store, params, app, redirect, route }) {\n    if (!store.state.user.user) {\n      return redirect(`/login?redirect=${route.path}`)\n    }\n    var playlist = await app.$axios.$get(`/api/playlists/${params.id}`).catch((error) => {\n      console.error('Failed', error)\n      return false\n    })\n    if (!playlist) {\n      return redirect('/')\n    }\n\n    // If playlist is a different library then set library as current\n    if (playlist.libraryId !== store.state.libraries.currentLibraryId) {\n      await store.dispatch('libraries/fetch', playlist.libraryId)\n    }\n\n    store.commit('libraries/addUpdateUserPlaylist', playlist)\n    return {\n      playlistId: playlist.id\n    }\n  },\n  data() {\n    return {\n      processingRemove: false\n    }\n  },\n  computed: {\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    playlistItems() {\n      return this.playlist.items || []\n    },\n    playlistName() {\n      return this.playlist.name || ''\n    },\n    description() {\n      return this.playlist.description || ''\n    },\n    playlist() {\n      return this.$store.getters['libraries/getPlaylist'](this.playlistId) || {}\n    },\n    playableItems() {\n      return this.playlistItems.filter((item) => {\n        const libraryItem = item.libraryItem\n        if (libraryItem.isMissing || libraryItem.isInvalid) return false\n        if (item.episode) return item.episode.audioFile\n        return libraryItem.media.tracks.length\n      })\n    },\n    streaming() {\n      return !!this.playableItems.find((i) => this.$store.getters['getIsMediaStreaming'](i.libraryItemId, i.episodeId))\n    },\n    showPlayButton() {\n      return this.playableItems.length\n    },\n    userCanUpdate() {\n      return this.$store.getters['user/getUserCanUpdate']\n    },\n    userCanDelete() {\n      return this.$store.getters['user/getUserCanDelete']\n    }\n  },\n  methods: {\n    editClick() {\n      this.$store.commit('globals/setEditPlaylist', this.playlist)\n    },\n    removeClick() {\n      const payload = {\n        message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]),\n        callback: (confirmed) => {\n          if (confirmed) {\n            this.removePlaylist()\n          }\n        },\n        type: 'yesNo'\n      }\n      this.$store.commit('globals/setConfirmPrompt', payload)\n    },\n    removePlaylist() {\n      this.processingRemove = true\n      this.$axios\n        .$delete(`/api/playlists/${this.playlist.id}`)\n        .then(() => {\n          this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)\n        })\n        .catch((error) => {\n          console.error('Failed to remove playlist', error)\n          this.$toast.error(this.$strings.ToastRemoveFailed)\n        })\n        .finally(() => {\n          this.processingRemove = false\n        })\n    },\n    clickPlay() {\n      const queueItems = []\n\n      // Playlist queue will start at the first unfinished item\n      //   if all items are finished then entire playlist is queued\n      const itemsWithProgress = this.playableItems.map((item) => {\n        return {\n          ...item,\n          progress: this.$store.getters['user/getUserMediaProgress'](item.libraryItemId, item.episodeId)\n        }\n      })\n\n      const hasUnfinishedItems = itemsWithProgress.some((i) => !i.progress || !i.progress.isFinished)\n      if (!hasUnfinishedItems) {\n        console.warn('All items in playlist are finished - starting at first item')\n      }\n\n      for (let i = 0; i < itemsWithProgress.length; i++) {\n        const playlistItem = itemsWithProgress[i]\n        if (!hasUnfinishedItems || !playlistItem.progress || !playlistItem.progress.isFinished) {\n          const libraryItem = playlistItem.libraryItem\n          if (playlistItem.episode) {\n            queueItems.push({\n              libraryItemId: libraryItem.id,\n              libraryId: libraryItem.libraryId,\n              episodeId: playlistItem.episode.id,\n              title: playlistItem.episode.title,\n              subtitle: libraryItem.media.metadata.title,\n              caption: '',\n              duration: playlistItem.episode.duration || null,\n              coverPath: libraryItem.media.coverPath || null\n            })\n          } else {\n            queueItems.push({\n              libraryItemId: libraryItem.id,\n              libraryId: libraryItem.libraryId,\n              episodeId: null,\n              title: libraryItem.media.metadata.title,\n              subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),\n              caption: '',\n              duration: libraryItem.media.duration || null,\n              coverPath: libraryItem.media.coverPath || null\n            })\n          }\n        }\n      }\n\n      if (queueItems.length >= 0) {\n        this.$eventBus.$emit('play-item', {\n          libraryItemId: queueItems[0].libraryItemId,\n          episodeId: queueItems[0].episodeId,\n          queueItems\n        })\n      }\n    }\n  },\n  mounted() {},\n  beforeDestroy() {}\n}\n</script>\n"
  },
  {
    "path": "client/pages/share/_slug.vue",
    "content": "<template>\n  <div class=\"w-full max-w-full h-dvh max-h-dvh overflow-hidden\" :style=\"{ backgroundColor: coverRgb }\">\n    <div class=\"w-screen h-screen absolute inset-0 pointer-events-none\" style=\"background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)\"></div>\n    <div class=\"absolute inset-0 w-screen h-dvh flex items-center justify-center z-10\">\n      <div class=\"w-full p-2 sm:p-4 md:p-8\">\n        <div v-if=\"!isMobileLandscape\" :style=\"{ width: coverWidth + 'px', height: coverHeight + 'px' }\" class=\"mx-auto overflow-hidden rounded-xl my-2\">\n          <img ref=\"coverImg\" :src=\"coverUrl\" class=\"object-contain w-full h-full\" @load=\"coverImageLoaded\" />\n        </div>\n        <p class=\"text-2xl lg:text-3xl font-semibold text-center mb-1 line-clamp-2\">{{ mediaItemShare.playbackSession.displayTitle || 'No title' }}</p>\n        <p v-if=\"mediaItemShare.playbackSession.displayAuthor\" class=\"text-lg lg:text-xl text-slate-400 font-semibold text-center mb-1 truncate\">{{ mediaItemShare.playbackSession.displayAuthor }}</p>\n\n        <div class=\"w-full pt-16\">\n          <player-ui ref=\"audioPlayer\" :chapters=\"chapters\" :current-chapter=\"currentChapter\" :paused=\"isPaused\" :loading=\"!hasLoaded\" :is-podcast=\"false\" hide-bookmarks hide-sleep-timer @playPause=\"playPause\" @jumpForward=\"jumpForward\" @jumpBackward=\"jumpBackward\" @setVolume=\"setVolume\" @setPlaybackRate=\"setPlaybackRate\" @seek=\"seek\" />\n        </div>\n\n        <ui-tooltip v-if=\"mediaItemShare.isDownloadable\" direction=\"bottom\" :text=\"$strings.LabelDownload\" class=\"absolute top-0 left-0 m-4\">\n          <button aria-label=\"Download\" class=\"text-gray-300 hover:text-white\" @click=\"downloadShareItem\"><span class=\"material-symbols text-2xl sm:text-3xl\">download</span></button>\n        </ui-tooltip>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport LocalAudioPlayer from '../../players/LocalAudioPlayer'\nimport { FastAverageColor } from 'fast-average-color'\n\nexport default {\n  layout: 'blank',\n  async asyncData({ params, error, app, query }) {\n    let endpoint = `/public/share/${params.slug}`\n    if (query.t && !isNaN(query.t)) {\n      endpoint += `?t=${query.t}`\n    }\n    const mediaItemShare = await app.$axios.$get(endpoint, { timeout: 10000 }).catch((error) => {\n      console.error('Failed', error)\n      return null\n    })\n    if (!mediaItemShare) {\n      return error({ statusCode: 404, message: 'Media item not found or expired' })\n    }\n\n    return {\n      mediaItemShare: mediaItemShare\n    }\n  },\n  data() {\n    return {\n      localAudioPlayer: new LocalAudioPlayer(),\n      playerState: null,\n      playInterval: null,\n      hasLoaded: false,\n      totalDuration: 0,\n      windowWidth: 0,\n      windowHeight: 0,\n      listeningTimeSinceSync: 0,\n      coverRgb: null,\n      coverBgIsLight: false,\n      currentTime: 0\n    }\n  },\n  computed: {\n    playbackSession() {\n      return this.mediaItemShare.playbackSession\n    },\n    coverUrl() {\n      if (!this.playbackSession.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc']\n      return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`\n    },\n    downloadUrl() {\n      return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`\n    },\n    audioTracks() {\n      return (this.playbackSession.audioTracks || []).map((track) => {\n        track.relativeContentUrl = track.contentUrl\n        return track\n      })\n    },\n    isPlaying() {\n      return this.playerState === 'PLAYING'\n    },\n    isPaused() {\n      return !this.isPlaying\n    },\n    chapters() {\n      return this.playbackSession.chapters || []\n    },\n    currentChapter() {\n      return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)\n    },\n    coverAspectRatio() {\n      const coverAspectRatio = this.playbackSession.coverAspectRatio\n      return coverAspectRatio === this.$constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1\n    },\n    isMobileLandscape() {\n      return this.windowWidth > this.windowHeight && this.windowHeight < 450\n    },\n    coverWidth() {\n      const availableCoverWidth = Math.min(450, this.windowWidth - 32)\n      const availableCoverHeight = Math.min(450, this.windowHeight - 250)\n\n      const mostCoverHeight = availableCoverWidth * this.coverAspectRatio\n      if (mostCoverHeight > availableCoverHeight) {\n        return availableCoverHeight / this.coverAspectRatio\n      }\n      return availableCoverWidth\n    },\n    coverHeight() {\n      return this.coverWidth * this.coverAspectRatio\n    }\n  },\n  methods: {\n    mediaSessionPlay() {\n      console.log('Media session play')\n      this.play()\n    },\n    mediaSessionPause() {\n      console.log('Media session pause')\n      this.pause()\n    },\n    mediaSessionStop() {\n      console.log('Media session stop')\n      this.pause()\n    },\n    mediaSessionSeekBackward() {\n      console.log('Media session seek backward')\n      this.jumpBackward()\n    },\n    mediaSessionSeekForward() {\n      console.log('Media session seek forward')\n      this.jumpForward()\n    },\n    mediaSessionSeekTo(e) {\n      console.log('Media session seek to', e)\n      if (e.seekTime !== null && !isNaN(e.seekTime)) {\n        this.seek(e.seekTime)\n      }\n    },\n    mediaSessionPreviousTrack() {\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.prevChapter()\n      }\n    },\n    mediaSessionNextTrack() {\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.nextChapter()\n      }\n    },\n    updateMediaSessionPlaybackState() {\n      if ('mediaSession' in navigator) {\n        navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'\n      }\n    },\n    setMediaSession() {\n      // https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API\n      if ('mediaSession' in navigator) {\n        const chapterInfo = []\n        if (this.chapters.length > 0) {\n          this.chapters.forEach((chapter) => {\n            chapterInfo.push({\n              title: chapter.title,\n              startTime: chapter.start\n            })\n          })\n        }\n\n        navigator.mediaSession.metadata = new MediaMetadata({\n          title: this.mediaItemShare.playbackSession.displayTitle || 'No title',\n          artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',\n          artwork: [\n            {\n              src: this.coverUrl\n            }\n          ],\n          chapterInfo\n        })\n        console.log('Set media session metadata', navigator.mediaSession.metadata)\n\n        navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)\n        navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)\n        navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)\n        navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)\n        navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)\n        navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)\n        navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)\n        navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)\n      } else {\n        console.warn('Media session not available')\n      }\n    },\n    async coverImageLoaded(e) {\n      if (!this.playbackSession.coverPath) return\n      const fac = new FastAverageColor()\n      fac\n        .getColorAsync(e.target)\n        .then((color) => {\n          this.coverRgb = color.rgba\n          this.coverBgIsLight = color.isLight\n\n          document.body.style.backgroundColor = color.hex\n        })\n        .catch((e) => {\n          console.log(e)\n        })\n    },\n    playPause() {\n      if (this.isPlaying) {\n        this.pause()\n      } else {\n        this.play()\n      }\n    },\n    play() {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n      this.localAudioPlayer.play()\n    },\n    pause() {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n      this.localAudioPlayer.pause()\n    },\n    jumpForward() {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n      const currentTime = this.localAudioPlayer.getCurrentTime()\n      const duration = this.localAudioPlayer.getDuration()\n      const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10\n      this.seek(Math.min(currentTime + jumpForwardAmount, duration))\n    },\n    jumpBackward() {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n      const currentTime = this.localAudioPlayer.getCurrentTime()\n      const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10\n      this.seek(Math.max(currentTime - jumpBackwardAmount, 0))\n    },\n    setVolume(volume) {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n      this.localAudioPlayer.setVolume(volume)\n    },\n    setPlaybackRate(playbackRate) {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n      this.localAudioPlayer.setPlaybackRate(playbackRate)\n    },\n    seek(time) {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n\n      this.localAudioPlayer.seek(time, this.isPlaying)\n      this.setCurrentTime(time)\n    },\n    setCurrentTime(time) {\n      if (!this.$refs.audioPlayer) return\n\n      // Update UI\n      this.$refs.audioPlayer.setCurrentTime(time)\n      this.currentTime = time\n    },\n    setDuration() {\n      if (!this.localAudioPlayer) return\n      this.totalDuration = this.localAudioPlayer.getDuration()\n      if (this.$refs.audioPlayer) {\n        this.$refs.audioPlayer.setDuration(this.totalDuration)\n      }\n    },\n    sendProgressSync(currentTime) {\n      console.log('Sending progress sync for time', currentTime)\n      const progress = {\n        currentTime\n      }\n      this.$axios.$patch(`/public/share/${this.mediaItemShare.slug}/progress`, progress, { progress: false }).catch((error) => {\n        console.error('Failed to send progress sync', error)\n      })\n    },\n    startPlayInterval() {\n      let lastTick = Date.now()\n      clearInterval(this.playInterval)\n      this.playInterval = setInterval(() => {\n        if (!this.localAudioPlayer) return\n\n        const currentTime = this.localAudioPlayer.getCurrentTime()\n        this.setCurrentTime(currentTime)\n        const exactTimeElapsed = (Date.now() - lastTick) / 1000\n        lastTick = Date.now()\n        this.listeningTimeSinceSync += exactTimeElapsed\n        if (this.listeningTimeSinceSync >= 30) {\n          this.listeningTimeSinceSync = 0\n          this.sendProgressSync(currentTime)\n        }\n      }, 1000)\n    },\n    stopPlayInterval() {\n      clearInterval(this.playInterval)\n      this.playInterval = null\n    },\n    playerStateChange(state) {\n      this.playerState = state\n      if (state === 'LOADED' || state === 'PLAYING') {\n        this.setDuration()\n      }\n      if (state === 'LOADED') {\n        this.hasLoaded = true\n      }\n      if (state === 'PLAYING') {\n        this.startPlayInterval()\n      } else {\n        this.stopPlayInterval()\n      }\n      this.updateMediaSessionPlaybackState()\n    },\n    playerTimeUpdate(time) {\n      this.setCurrentTime(time)\n    },\n    getHotkeyName(e) {\n      var keyCode = e.keyCode || e.which\n      if (!this.$keynames[keyCode]) {\n        // Unused hotkey\n        return null\n      }\n\n      var keyName = this.$keynames[keyCode]\n      var name = keyName\n      if (e.shiftKey) name = 'Shift-' + keyName\n      if (process.env.NODE_ENV !== 'production') {\n        console.log('Hotkey command', name)\n      }\n      return name\n    },\n    keyDown(e) {\n      if (!this.localAudioPlayer || !this.hasLoaded) return\n\n      var name = this.getHotkeyName(e)\n      if (!name) return\n\n      // Playing audiobook\n      if (Object.values(this.$hotkeys.AudioPlayer).includes(name)) {\n        this.$eventBus.$emit('player-hotkey', name)\n        e.preventDefault()\n      }\n    },\n    resize() {\n      setTimeout(() => {\n        this.windowWidth = window.innerWidth\n        this.windowHeight = window.innerHeight\n        this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })\n      }, 100)\n    },\n    playerError(error) {\n      console.error('Player error', error)\n      this.$toast.error('Failed to play audio on device')\n    },\n    playerFinished() {\n      console.log('Player finished')\n    },\n    downloadShareItem() {\n      this.$downloadFile(this.downloadUrl)\n    }\n  },\n  mounted() {\n    this.$store.dispatch('user/loadUserSettings')\n\n    this.resize()\n    window.addEventListener('resize', this.resize)\n    window.addEventListener('keydown', this.keyDown)\n\n    if (process.env.NODE_ENV === 'development') {\n      console.log('Loaded media item share', this.mediaItemShare)\n    }\n\n    const startTime = this.playbackSession.currentTime || 0\n    this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)\n    this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))\n    this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))\n    this.localAudioPlayer.on('error', this.playerError.bind(this))\n    this.localAudioPlayer.on('finished', this.playerFinished.bind(this))\n\n    this.setMediaSession()\n  },\n  beforeDestroy() {\n    window.removeEventListener('resize', this.resize)\n    window.removeEventListener('keydown', this.keyDown)\n\n    this.localAudioPlayer.off('stateChange', this.playerStateChange.bind(this))\n    this.localAudioPlayer.off('timeupdate', this.playerTimeUpdate.bind(this))\n    this.localAudioPlayer.off('error', this.playerError.bind(this))\n    this.localAudioPlayer.off('finished', this.playerFinished.bind(this))\n    this.localAudioPlayer.destroy()\n  }\n}\n</script>\n"
  },
  {
    "path": "client/pages/upload/index.vue",
    "content": "<template>\n  <div id=\"page-wrapper\" class=\"page p-1 sm:p-6 overflow-y-auto\" :class=\"streamLibraryItem ? 'streaming' : ''\">\n    <div class=\"w-full max-w-6xl mx-auto\">\n      <!-- Library & folder picker -->\n      <div class=\"flex flex-wrap my-6 md:-mx-2\">\n        <div class=\"w-full md:w-1/5 px-2\">\n          <ui-dropdown v-model=\"selectedLibraryId\" :items=\"libraryItems\" :label=\"$strings.LabelLibrary\" :disabled=\"!!items.length\" @input=\"libraryChanged\" />\n        </div>\n        <div class=\"w-full md:w-3/5 px-2\">\n          <ui-dropdown v-model=\"selectedFolderId\" :items=\"folderItems\" :disabled=\"!selectedLibraryId || !!items.length\" :label=\"$strings.LabelFolder\" />\n        </div>\n        <div class=\"w-full md:w-1/5 px-2\">\n          <ui-text-input-with-label :value=\"selectedLibraryMediaType\" readonly :label=\"$strings.LabelMediaType\" />\n        </div>\n      </div>\n\n      <div v-if=\"!selectedLibraryIsPodcast\" class=\"flex items-center mb-6 px-2 md:px-0\">\n        <label class=\"flex cursor-pointer pt-4\">\n          <ui-toggle-switch v-model=\"fetchMetadata.enabled\" class=\"inline-flex\" />\n          <span class=\"pl-2 text-base\">{{ $strings.LabelAutoFetchMetadata }}</span>\n        </label>\n        <ui-tooltip :text=\"$strings.LabelAutoFetchMetadataHelp\" class=\"inline-flex pt-4\">\n          <span class=\"pl-1 material-symbols icon-text text-sm cursor-pointer\">info</span>\n        </ui-tooltip>\n\n        <div class=\"grow ml-4\">\n          <ui-dropdown v-model=\"fetchMetadata.provider\" :items=\"providers\" :label=\"$strings.LabelProvider\" />\n        </div>\n      </div>\n\n      <widgets-alert v-if=\"error\" type=\"error\">\n        <p class=\"text-lg\">{{ error }}</p>\n      </widgets-alert>\n\n      <!-- Picker display -->\n      <div v-if=\"!items.length && !ignoredFiles.length\" class=\"w-full mx-auto border border-white/20 px-4 md:px-12 pt-12 pb-4 my-12 relative\" :class=\"isDragging ? 'bg-primary/40' : 'border-dashed'\">\n        <p class=\"text-2xl text-center\">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>\n        <p class=\"text-center text-sm my-5\">{{ $strings.MessageOr }}</p>\n        <div class=\"w-full max-w-xl mx-auto\">\n          <div class=\"flex\">\n            <ui-btn class=\"w-full mx-1\" @click=\"openFilePicker\">{{ $strings.ButtonChooseFiles }}</ui-btn>\n            <ui-btn v-if=\"!isIOS\" class=\"w-full mx-1\" @click=\"openFolderPicker\">{{ $strings.ButtonChooseAFolder }} </ui-btn>\n          </div>\n        </div>\n        <div class=\"pt-8 text-center\">\n          <p class=\"text-xs text-white/50 font-mono mb-4\">\n            <strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }}\n          </p>\n\n          <p class=\"text-sm text-white/70\">\n            <span v-if=\"!isIOS\">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if=\"selectedLibraryMediaType === 'book'\">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>\n          </p>\n        </div>\n      </div>\n      <!-- Item list header -->\n      <div v-else class=\"w-full flex items-center pb-4 border-b border-white/10\">\n        <p class=\"text-lg lowercase\">{{ items.length === 1 ? `1 ${$strings.LabelItem}` : $getString('LabelXItems', [items.length]) }}</p>\n        <div class=\"grow\" />\n        <ui-btn :disabled=\"processing\" small @click=\"reset\">{{ $strings.ButtonReset }}</ui-btn>\n      </div>\n\n      <!-- Alerts -->\n      <widgets-alert v-if=\"!items.length && !uploadReady\" type=\"error\" class=\"my-4\">\n        <p class=\"text-lg\">{{ $strings.MessageNoItemsFound }}</p>\n      </widgets-alert>\n      <widgets-alert v-if=\"ignoredFiles.length\" type=\"warning\" class=\"my-4\">\n        <div class=\"w-full pr-12\">\n          <p class=\"text-base mb-1\">{{ $strings.NoteUploaderUnsupportedFiles }}</p>\n          <tables-uploaded-files-table :files=\"ignoredFiles\" :title=\"$strings.HeaderIgnoredFiles\" class=\"text-white\" />\n          <p class=\"text-xs text-white/50 font-mono pt-1\">\n            <strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }}\n          </p>\n        </div>\n      </widgets-alert>\n\n      <!-- Item Upload cards -->\n      <cards-item-upload-card v-for=\"item in items\" :key=\"item.index\" :ref=\"`itemCard-${item.index}`\" :media-type=\"selectedLibraryMediaType\" :item=\"item\" :provider=\"fetchMetadata.provider\" :processing=\"processing\" @remove=\"removeItem(item)\" />\n\n      <!-- Upload/Reset btns -->\n      <div v-show=\"items.length\" class=\"flex justify-end pb-8 pt-4\">\n        <ui-btn v-if=\"!uploadFinished\" color=\"bg-success\" :loading=\"processing\" @click=\"submit\">{{ $strings.ButtonUpload }}</ui-btn>\n        <ui-btn v-else @click=\"reset\">{{ $strings.ButtonReset }}</ui-btn>\n      </div>\n    </div>\n\n    <input ref=\"fileInput\" type=\"file\" multiple :accept=\"isIOS ? '' : inputAccept\" class=\"hidden\" @change=\"inputChanged\" />\n    <input ref=\"fileFolderInput\" type=\"file\" webkitdirectory multiple :accept=\"inputAccept\" class=\"hidden\" @change=\"inputChanged\" v-if=\"!isIOS\" />\n  </div>\n</template>\n\n<script>\nimport Path from 'path'\nimport uploadHelpers from '@/mixins/uploadHelpers'\n\nexport default {\n  mixins: [uploadHelpers],\n  data() {\n    return {\n      isDragging: false,\n      error: '',\n      items: [],\n      ignoredFiles: [],\n      selectedLibraryId: null,\n      selectedFolderId: null,\n      processing: false,\n      uploadFinished: false,\n      fetchMetadata: {\n        enabled: false,\n        provider: null\n      }\n    }\n  },\n  watch: {\n    selectedLibrary(newVal) {\n      if (newVal && !this.selectedFolderId) {\n        this.setDefaultFolder()\n        this.setMetadataProvider()\n      }\n    }\n  },\n  computed: {\n    inputAccept() {\n      var extensions = []\n      Object.values(this.$constants.SupportedFileTypes).forEach((types) => {\n        extensions = extensions.concat(types.map((t) => `.${t}`))\n      })\n      return extensions\n    },\n    isIOS() {\n      const ua = window.navigator.userAgent\n      return /iPad|iPhone|iPod/.test(ua) && !window.MSStream\n    },\n    streamLibraryItem() {\n      return this.$store.state.streamLibraryItem\n    },\n    libraries() {\n      return this.$store.state.libraries.libraries\n    },\n    libraryItems() {\n      return this.libraries.map((lib) => {\n        return {\n          value: lib.id,\n          text: lib.name\n        }\n      })\n    },\n    selectedLibrary() {\n      return this.libraries.find((lib) => lib.id === this.selectedLibraryId)\n    },\n    selectedLibraryMediaType() {\n      return this.selectedLibrary ? this.selectedLibrary.mediaType : null\n    },\n    selectedLibraryIsPodcast() {\n      return this.selectedLibraryMediaType === 'podcast'\n    },\n    providers() {\n      if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders\n      return this.$store.state.scanners.bookProviders\n    },\n    canFetchMetadata() {\n      return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled\n    },\n    selectedFolder() {\n      if (!this.selectedLibrary) return null\n      return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)\n    },\n    folderItems() {\n      if (!this.selectedLibrary) return []\n      return this.selectedLibrary.folders.map((fold) => {\n        return {\n          value: fold.id,\n          text: fold.fullPath\n        }\n      })\n    },\n    uploadReady() {\n      return !this.items.length && !this.ignoredFiles.length && !this.uploadFinished\n    }\n  },\n  methods: {\n    libraryChanged() {\n      if (!this.selectedLibrary && this.selectedFolderId) {\n        this.selectedFolderId = null\n      } else if (this.selectedFolderId) {\n        if (!this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)) {\n          this.selectedFolderId = null\n        }\n      }\n      this.setDefaultFolder()\n      this.setMetadataProvider()\n    },\n    setDefaultFolder() {\n      if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {\n        this.selectedFolderId = this.selectedLibrary.folders[0].id\n      }\n    },\n    setMetadataProvider() {\n      this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)\n    },\n    removeItem(item) {\n      this.items = this.items.filter((b) => b.index !== item.index)\n      if (!this.items.length) {\n        this.reset()\n      }\n    },\n    reset() {\n      this.error = ''\n      this.items = []\n      this.ignoredFiles = []\n      this.uploadFinished = false\n      if (this.$refs.fileInput) this.$refs.fileInput.value = ''\n      if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.value = ''\n    },\n    openFilePicker() {\n      if (this.$refs.fileInput) this.$refs.fileInput.click()\n    },\n    openFolderPicker() {\n      if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.click()\n    },\n    isDraggingFile(e) {\n      // Checks dragging file or folder and not an element on the page\n      var dt = e.dataTransfer || {}\n      return dt.types && dt.types.indexOf('Files') >= 0\n    },\n    dragenter(e) {\n      e.preventDefault()\n      if (this.uploadReady && this.isDraggingFile(e) && !this.isDragging) {\n        this.isDragging = true\n      }\n    },\n    dragleave(e) {\n      e.preventDefault()\n      if (!e.fromElement && this.isDragging) {\n        this.isDragging = false\n      }\n    },\n    dragover(e) {\n      // This is required to catch the drop event\n      e.preventDefault()\n    },\n    async drop(e) {\n      e.preventDefault()\n      this.isDragging = false\n      var items = e.dataTransfer.items || []\n\n      var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)\n      this.onItemsSelected(itemResults)\n    },\n    inputChanged(e) {\n      if (!e.target || !e.target.files) return\n      var _files = Array.from(e.target.files)\n      if (_files && _files.length) {\n        var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)\n        this.onItemsSelected(itemResults)\n      }\n    },\n    onItemsSelected(itemResults) {\n      if (this.itemSelectionSuccessful(itemResults)) {\n        // setTimeout ensures the new item ref is attached before this method is called\n        setTimeout(this.attemptMetadataFetch, 0)\n      }\n    },\n    itemSelectionSuccessful(itemResults) {\n      console.log('Upload results', itemResults)\n\n      if (itemResults.error) {\n        this.error = itemResults.error\n        this.items = []\n        this.ignoredFiles = []\n        return false\n      }\n\n      this.error = ''\n      this.items = itemResults.items\n      this.ignoredFiles = itemResults.ignoredFiles\n      return true\n    },\n    attemptMetadataFetch() {\n      if (!this.canFetchMetadata) {\n        return false\n      }\n\n      this.items.forEach((item) => {\n        let itemRef = this.$refs[`itemCard-${item.index}`]\n\n        if (itemRef?.length) {\n          itemRef[0].fetchMetadata(this.fetchMetadata.provider)\n        }\n      })\n    },\n    updateItemCardStatus(index, status) {\n      var ref = this.$refs[`itemCard-${index}`]\n      if (ref && ref.length) ref = ref[0]\n      if (!ref) {\n        console.error('Book card ref not found', index, this.$refs)\n      } else {\n        ref.setUploadStatus(status)\n      }\n    },\n    updateItemCardProgress(index, progress) {\n      var ref = this.$refs[`itemCard-${index}`]\n      if (ref && ref.length) ref = ref[0]\n      if (!ref) {\n        console.error('Book card ref not found', index, this.$refs)\n      } else {\n        ref.setUploadProgress(progress)\n      }\n    },\n    async uploadItem(item) {\n      var form = new FormData()\n      form.set('title', item.title)\n      if (!this.selectedLibraryIsPodcast) {\n        form.set('author', item.author || '')\n        form.set('series', item.series || '')\n      }\n      form.set('library', this.selectedLibraryId)\n      form.set('folder', this.selectedFolderId)\n\n      var index = 0\n      item.files.forEach((file) => {\n        form.set(`${index++}`, file)\n      })\n\n      const config = {\n        onUploadProgress: (progressEvent) => {\n          if (progressEvent.lengthComputable) {\n            const progress = {\n              loaded: progressEvent.loaded,\n              total: progressEvent.total\n            }\n            this.updateItemCardProgress(item.index, progress)\n          }\n        }\n      }\n\n      return this.$axios\n        .$post('/api/upload', form, config)\n        .then(() => true)\n        .catch((error) => {\n          console.error('Failed to upload item', error)\n          this.$toast.error(error.response?.data || 'Oops, something went wrong...')\n          return false\n        })\n    },\n    validateItems() {\n      var itemData = []\n      for (var item of this.items) {\n        var itemref = this.$refs[`itemCard-${item.index}`]\n        if (itemref && itemref.length) itemref = itemref[0]\n\n        if (!itemref) {\n          console.error('Invalid item index no ref', item.index, this.$refs.itemCard)\n          return false\n        } else {\n          var data = itemref.getData()\n          if (!data) {\n            return false\n          }\n          itemData.push(data)\n        }\n      }\n      return itemData\n    },\n    async submit() {\n      if (!this.selectedFolderId || !this.selectedLibraryId) {\n        this.$toast.error('Must select library and folder')\n        document.getElementById('page-wrapper').scroll({ top: 0, left: 0, behavior: 'smooth' })\n        return\n      }\n\n      const items = this.validateItems()\n      if (!items) {\n        this.$toast.error('Some invalid items')\n        return\n      }\n      this.processing = true\n\n      const itemsToUpload = []\n\n      // Check if path already exists before starting upload\n      //  uploading fails if path already exists\n      for (const item of items) {\n        const exists = await this.$axios\n          .$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })\n          .then((data) => {\n            if (data.exists) {\n              if (data.libraryItemTitle) {\n                this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle]))\n              } else {\n                this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [Path.join(this.selectedFolder.fullPath, item.directory)]))\n              }\n            }\n            return data.exists\n          })\n          .catch((error) => {\n            console.error('Failed to check if filepath exists', error)\n            return false\n          })\n        if (!exists) {\n          itemsToUpload.push(item)\n        }\n      }\n\n      for (const item of itemsToUpload) {\n        this.updateItemCardStatus(item.index, 'uploading')\n        const result = await this.uploadItem(item)\n        this.updateItemCardStatus(item.index, result ? 'success' : 'failed')\n      }\n      this.processing = false\n      this.uploadFinished = true\n    }\n  },\n  mounted() {\n    this.selectedLibraryId = this.$store.state.libraries.currentLibraryId\n    this.setMetadataProvider()\n\n    this.setDefaultFolder()\n    // Fetch providers if not already loaded\n    this.$store.dispatch('scanners/fetchProviders')\n    window.addEventListener('dragenter', this.dragenter)\n    window.addEventListener('dragleave', this.dragleave)\n    window.addEventListener('dragover', this.dragover)\n    window.addEventListener('drop', this.drop)\n  },\n  beforeDestroy() {\n    window.removeEventListener('dragenter', this.dragenter)\n    window.removeEventListener('dragleave', this.dragleave)\n    window.removeEventListener('dragover', this.dragover)\n    window.removeEventListener('drop', this.drop)\n  }\n}\n</script>\n"
  },
  {
    "path": "client/players/AudioTrack.js",
    "content": "export default class AudioTrack {\n  constructor(track, sessionId, routerBasePath) {\n    this.index = track.index || 0\n    this.startOffset = track.startOffset || 0 // Total time of all previous tracks\n    this.duration = track.duration || 0\n    this.title = track.title || ''\n    this.contentUrl = track.contentUrl || null\n    this.mimeType = track.mimeType\n    this.metadata = track.metadata || {}\n\n    this.sessionId = sessionId\n    this.routerBasePath = routerBasePath || ''\n    if (this.contentUrl?.startsWith('/hls')) {\n      this.sessionTrackUrl = this.contentUrl\n    } else {\n      this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`\n    }\n  }\n\n  /**\n   * Used for CastPlayer\n   */\n  get fullContentUrl() {\n    if (process.env.NODE_ENV === 'development') {\n      return `${process.env.serverUrl}${this.sessionTrackUrl}`\n    }\n    return `${window.location.origin}${this.routerBasePath}${this.sessionTrackUrl}`\n  }\n\n  /**\n   * Used for LocalPlayer\n   */\n  get relativeContentUrl() {\n    return `${this.routerBasePath}${this.sessionTrackUrl}`\n  }\n}\n"
  },
  {
    "path": "client/players/CastPlayer.js",
    "content": "import { buildCastLoadRequest, castLoadMedia } from \"./castUtils\"\nimport EventEmitter from 'events'\n\nexport default class CastPlayer extends EventEmitter {\n  constructor(ctx) {\n    super()\n\n    this.ctx = ctx\n    this.player = null\n    this.playerController = null\n\n    this.libraryItem = null\n    this.audioTracks = []\n    this.currentTrackIndex = 0\n    this.isHlsTranscode = null\n    this.currentTime = 0\n    this.playWhenReady = false\n    this.defaultPlaybackRate = 1\n\n    // TODO: Use canDisplayType on receiver to check mime types\n    this.playableMimeTypes = []\n\n    this.coverUrl = ''\n    this.castPlayerState = 'IDLE'\n\n    // Supported audio codecs for chromecast\n\n    this.supportedAudioCodecs = ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']\n\n    this.initialize()\n  }\n\n  get currentTrack() {\n    return this.audioTracks[this.currentTrackIndex] || {}\n  }\n\n  initialize() {\n    this.player = this.ctx.$root.castPlayer\n    this.playerController = this.ctx.$root.castPlayerController\n    this.playerController.addEventListener(\n      cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED, this.evtMediaInfoChanged.bind(this))\n  }\n\n  evtMediaInfoChanged() {\n    // Use the current session to get an up to date media status.\n    let session = cast.framework.CastContext.getInstance().getCurrentSession()\n    if (!session) {\n      return\n    }\n    let media = session.getMediaSession()\n    if (!media) {\n      return\n    }\n\n    var currentItemId = media.media.itemId\n    if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {\n      this.currentTrackIndex = currentItemId - 1\n    }\n\n    // TODO: Emit finished event\n    if (media.playerState !== this.castPlayerState) {\n      this.emit('stateChange', media.playerState)\n      this.castPlayerState = media.playerState\n    }\n  }\n\n  destroy() {\n    if (this.playerController) {\n      this.playerController.stop()\n    }\n  }\n\n  async set(libraryItem, tracks, isHlsTranscode, startTime, playWhenReady = false) {\n    this.libraryItem = libraryItem\n    this.audioTracks = tracks\n    this.isHlsTranscode = isHlsTranscode\n    this.playWhenReady = playWhenReady\n\n    this.currentTime = startTime\n\n    var coverImg = this.ctx.$store.getters['globals/getLibraryItemCoverSrc'](libraryItem)\n    if (process.env.NODE_ENV === 'development') {\n      this.coverUrl = coverImg\n    } else {\n      this.coverUrl = `${window.location.origin}${coverImg}`\n    }\n\n    var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, this.currentTime, playWhenReady, this.defaultPlaybackRate)\n\n    var castSession = cast.framework.CastContext.getInstance().getCurrentSession()\n    await castLoadMedia(castSession, request)\n  }\n\n  resetStream(startTime) {\n    // Cast only direct play for now\n  }\n\n  playPause() {\n    if (this.playerController) this.playerController.playOrPause()\n  }\n\n  play() {\n    if (this.playerController) this.playerController.playOrPause()\n  }\n\n  pause() {\n    if (this.playerController) this.playerController.playOrPause()\n  }\n\n  getCurrentTime() {\n    var currentTrackOffset = this.currentTrack.startOffset || 0\n    return this.player ? currentTrackOffset + this.player.currentTime : 0\n  }\n\n  getDuration() {\n    if (!this.audioTracks.length) return 0\n    var lastTrack = this.audioTracks[this.audioTracks.length - 1]\n    return lastTrack.startOffset + lastTrack.duration\n  }\n\n  setPlaybackRate(playbackRate) {\n    this.defaultPlaybackRate = playbackRate\n  }\n\n  async seek(time, playWhenReady) {\n    if (!this.player) return\n\n    this.playWhenReady = playWhenReady\n    if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {\n      // Change Track\n      var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)\n      var castSession = cast.framework.CastContext.getInstance().getCurrentSession()\n      await castLoadMedia(castSession, request)\n    } else {\n      var offsetTime = time - (this.currentTrack.startOffset || 0)\n      this.player.currentTime = Math.max(0, offsetTime)\n      this.playerController.seek()\n    }\n  }\n\n  setVolume(volume) {\n    if (!this.player) return\n    this.player.volumeLevel = volume\n    this.playerController.setVolumeLevel()\n  }\n}"
  },
  {
    "path": "client/players/LocalAudioPlayer.js",
    "content": "import Hls from 'hls.js'\nimport EventEmitter from 'events'\n\nexport default class LocalAudioPlayer extends EventEmitter {\n  constructor(ctx) {\n    super()\n\n    this.ctx = ctx\n    this.player = null\n\n    this.libraryItem = null\n    this.audioTracks = []\n    this.currentTrackIndex = 0\n    this.isHlsTranscode = null\n    this.hlsInstance = null\n    this.usingNativeplayer = false\n    this.startTime = 0\n    this.trackStartTime = 0\n    this.playWhenReady = false\n    this.defaultPlaybackRate = 1\n\n    this.playableMimeTypes = []\n\n    this.initialize()\n  }\n\n  get currentTrack() {\n    return this.audioTracks[this.currentTrackIndex] || {}\n  }\n\n  initialize() {\n    if (document.getElementById('audio-player')) {\n      document.getElementById('audio-player').remove()\n    }\n    var audioEl = document.createElement('audio')\n    audioEl.id = 'audio-player'\n    audioEl.style.display = 'none'\n    document.body.appendChild(audioEl)\n    this.player = audioEl\n\n    this.player.addEventListener('play', this.evtPlay.bind(this))\n    this.player.addEventListener('pause', this.evtPause.bind(this))\n    this.player.addEventListener('progress', this.evtProgress.bind(this))\n    this.player.addEventListener('ended', this.evtEnded.bind(this))\n    this.player.addEventListener('error', this.evtError.bind(this))\n    this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))\n    this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))\n\n    var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm']\n    var mimeTypeCanPlayMap = {}\n    mimeTypes.forEach((mt) => {\n      var canPlay = this.player.canPlayType(mt)\n      mimeTypeCanPlayMap[mt] = canPlay\n      if (canPlay) this.playableMimeTypes.push(mt)\n    })\n    console.log(`[LocalPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)\n  }\n\n  evtPlay() {\n    this.emit('stateChange', 'PLAYING')\n  }\n  evtPause() {\n    this.emit('stateChange', 'PAUSED')\n  }\n  evtProgress() {\n    var lastBufferTime = this.getLastBufferedTime()\n    this.emit('buffertimeUpdate', lastBufferTime)\n  }\n  evtEnded() {\n    if (this.currentTrackIndex < this.audioTracks.length - 1) {\n      console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)\n      // Has next track\n      this.currentTrackIndex++\n      this.startTime = this.currentTrack.startOffset\n      this.loadCurrentTrack()\n    } else {\n      console.log(`[LocalPlayer] Ended`)\n      this.emit('finished')\n    }\n  }\n  evtError(error) {\n    console.error('Player error', error)\n    this.emit('error', error)\n  }\n  evtLoadedMetadata(data) {\n    if (!this.isHlsTranscode) {\n      this.player.currentTime = this.trackStartTime\n    }\n\n    this.emit('stateChange', 'LOADED')\n\n    if (this.playWhenReady) {\n      this.playWhenReady = false\n      this.play()\n    }\n  }\n  evtTimeupdate() {\n    if (this.player.paused) {\n      this.emit('timeupdate', this.getCurrentTime())\n    }\n  }\n\n  destroy() {\n    this.destroyHlsInstance()\n    if (this.player) {\n      this.player.remove()\n    }\n  }\n\n  set(libraryItem, tracks, isHlsTranscode, startTime, playWhenReady = false) {\n    this.libraryItem = libraryItem\n    this.audioTracks = tracks\n    this.isHlsTranscode = isHlsTranscode\n    this.playWhenReady = playWhenReady\n    this.startTime = startTime\n\n    if (this.hlsInstance) {\n      this.destroyHlsInstance()\n    }\n\n    if (this.isHlsTranscode) {\n      this.setHlsStream()\n    } else {\n      this.setDirectPlay()\n    }\n  }\n\n  setHlsStream() {\n    this.trackStartTime = 0\n    this.currentTrackIndex = 0\n\n    // iOS does not support Media Elements but allows for HLS in the native audio player\n    if (!Hls.isSupported()) {\n      console.warn('HLS is not supported - fallback to using audio element')\n      this.usingNativeplayer = true\n      this.player.src = this.currentTrack.relativeContentUrl\n      this.player.currentTime = this.startTime\n      return\n    }\n\n    var hlsOptions = {\n      startPosition: this.startTime || -1,\n      fragLoadPolicy: {\n        default: {\n          maxTimeToFirstByteMs: 10000,\n          maxLoadTimeMs: 120000,\n          timeoutRetry: {\n            maxNumRetry: 4,\n            retryDelayMs: 0,\n            maxRetryDelayMs: 0\n          },\n          errorRetry: {\n            maxNumRetry: 8,\n            retryDelayMs: 1000,\n            maxRetryDelayMs: 8000,\n            shouldRetry: (retryConfig, retryCount, isTimeout, httpStatus, retry) => {\n              if (httpStatus?.code === 404 && retryConfig?.maxNumRetry > retryCount) {\n                console.log(`[HLS] Server 404 for fragment retry ${retryCount} of ${retryConfig.maxNumRetry}`)\n                return true\n              }\n              return retry\n            }\n          }\n        }\n      }\n    }\n    this.hlsInstance = new Hls(hlsOptions)\n\n    this.hlsInstance.attachMedia(this.player)\n    this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {\n      this.hlsInstance.loadSource(this.currentTrack.relativeContentUrl)\n\n      this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {\n        console.log('[HLS] Manifest Parsed')\n      })\n\n      this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {\n        if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {\n          console.error('[HLS] BUFFER STALLED ERROR')\n        } else if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR) {\n          // Only show error if the fragment is not being retried\n          if (data.errorAction?.action !== 5) {\n            console.error('[HLS] FRAG LOAD ERROR', data)\n          }\n        } else {\n          console.error('[HLS] Error', data.type, data.details, data)\n        }\n      })\n      this.hlsInstance.on(Hls.Events.DESTROYING, () => {\n        console.log('[HLS] Destroying HLS Instance')\n      })\n    })\n  }\n\n  setDirectPlay() {\n    // Set initial track and track time offset\n    var trackIndex = this.audioTracks.findIndex((t) => this.startTime >= t.startOffset && this.startTime < t.startOffset + t.duration)\n    this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0\n\n    this.loadCurrentTrack()\n  }\n\n  loadCurrentTrack() {\n    if (!this.currentTrack) return\n    // When direct play track is loaded current time needs to be set\n    this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))\n    this.player.src = this.currentTrack.relativeContentUrl\n    console.log(`[LocalPlayer] Loading track src ${this.currentTrack.relativeContentUrl}`)\n    this.player.load()\n  }\n\n  destroyHlsInstance() {\n    if (!this.hlsInstance) return\n    if (this.hlsInstance.destroy) {\n      var temp = this.hlsInstance\n      temp.destroy()\n    }\n    this.hlsInstance = null\n  }\n\n  async resetStream(startTime) {\n    this.destroyHlsInstance()\n    await new Promise((resolve) => setTimeout(resolve, 1000))\n    this.set(this.libraryItem, this.audioTracks, this.isHlsTranscode, startTime, true)\n  }\n\n  playPause() {\n    if (!this.player) return\n    if (this.player.paused) this.play()\n    else this.pause()\n  }\n\n  play() {\n    this.playWhenReady = true\n    if (this.player) this.player.play()\n  }\n\n  pause() {\n    this.playWhenReady = false\n    if (this.player) this.player.pause()\n  }\n\n  getCurrentTime() {\n    var currentTrackOffset = this.currentTrack.startOffset || 0\n    return this.player ? currentTrackOffset + this.player.currentTime : 0\n  }\n\n  getDuration() {\n    if (!this.audioTracks.length) return 0\n    var lastTrack = this.audioTracks[this.audioTracks.length - 1]\n    return lastTrack.startOffset + lastTrack.duration\n  }\n\n  setPlaybackRate(playbackRate) {\n    if (!this.player) return\n    this.defaultPlaybackRate = playbackRate\n    this.player.playbackRate = playbackRate\n  }\n\n  seek(time, playWhenReady) {\n    if (!this.player) return\n\n    this.playWhenReady = playWhenReady\n\n    if (this.isHlsTranscode) {\n      // Seeking HLS stream\n      var offsetTime = time - (this.currentTrack.startOffset || 0)\n      this.player.currentTime = Math.max(0, offsetTime)\n    } else {\n      // Seeking Direct play\n      if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {\n        // Change Track\n        var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)\n        if (trackIndex >= 0) {\n          this.startTime = time\n          this.currentTrackIndex = trackIndex\n\n          if (!this.player.paused) {\n            // audio player playing so play when track loads\n            this.playWhenReady = true\n          }\n          this.loadCurrentTrack()\n        }\n      } else {\n        var offsetTime = time - (this.currentTrack.startOffset || 0)\n        this.player.currentTime = Math.max(0, offsetTime)\n      }\n    }\n  }\n\n  setVolume(volume) {\n    if (!this.player) return\n    this.player.volume = volume\n  }\n\n  // Utils\n  isValidDuration(duration) {\n    if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {\n      return true\n    }\n    return false\n  }\n\n  getBufferedRanges() {\n    if (!this.player) return []\n    const ranges = []\n    const seekable = this.player.buffered || []\n\n    let offset = 0\n\n    for (let i = 0, length = seekable.length; i < length; i++) {\n      let start = seekable.start(i)\n      let end = seekable.end(i)\n      if (!this.isValidDuration(start)) {\n        start = 0\n      }\n      if (!this.isValidDuration(end)) {\n        end = 0\n        continue\n      }\n\n      ranges.push({\n        start: start + offset,\n        end: end + offset\n      })\n    }\n    return ranges\n  }\n\n  getLastBufferedTime() {\n    var bufferedRanges = this.getBufferedRanges()\n    if (!bufferedRanges.length) return 0\n\n    var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)\n    if (buff) return buff.end\n\n    var last = bufferedRanges[bufferedRanges.length - 1]\n    return last.end\n  }\n}\n"
  },
  {
    "path": "client/players/PlayerHandler.js",
    "content": "import LocalAudioPlayer from './LocalAudioPlayer'\nimport CastPlayer from './CastPlayer'\nimport AudioTrack from './AudioTrack'\n\nexport default class PlayerHandler {\n  constructor(ctx) {\n    this.ctx = ctx\n    this.libraryItem = null\n    this.episodeId = null\n    this.displayTitle = null\n    this.displayAuthor = null\n    this.playWhenReady = false\n    this.initialPlaybackRate = 1\n    this.player = null\n    this.playerState = 'IDLE'\n    this.isHlsTranscode = false\n    this.currentSessionId = null\n    this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)\n    this.startTime = 0\n\n    this.failedProgressSyncs = 0\n    this.lastSyncTime = 0\n    this.listeningTimeSinceSync = 0\n\n    this.playInterval = null\n  }\n\n  get isCasting() {\n    return this.ctx.$store.state.globals.isCasting\n  }\n  get libraryItemId() {\n    return this.libraryItem ? this.libraryItem.id : null\n  }\n  get isPlayingCastedItem() {\n    return this.libraryItem && this.player instanceof CastPlayer\n  }\n  get isPlayingLocalItem() {\n    return this.libraryItem && this.player instanceof LocalAudioPlayer\n  }\n  get playerPlaying() {\n    return this.playerState === 'PLAYING'\n  }\n  get episode() {\n    if (!this.episodeId) return null\n    return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)\n  }\n  get jumpForwardAmount() {\n    return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')\n  }\n  get jumpBackwardAmount() {\n    return this.ctx.$store.getters['user/getUserSetting']('jumpBackwardAmount')\n  }\n\n  setSessionId(sessionId) {\n    this.currentSessionId = sessionId\n    this.ctx.$store.commit('setPlaybackSessionId', sessionId)\n  }\n\n  load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {\n    this.libraryItem = libraryItem\n\n    this.episodeId = episodeId\n    this.playWhenReady = playWhenReady\n    this.initialPlaybackRate = playbackRate\n\n    this.startTimeOverride = startTimeOverride == null || isNaN(startTimeOverride) ? undefined : Number(startTimeOverride)\n\n    if (!this.player) this.switchPlayer(playWhenReady)\n    else this.prepare()\n  }\n\n  switchPlayer(playWhenReady) {\n    if (this.isCasting && !(this.player instanceof CastPlayer)) {\n      console.log('[PlayerHandler] Switching to cast player')\n\n      this.stopPlayInterval()\n      this.playerStateChange('LOADING')\n\n      this.startTime = this.player ? this.player.getCurrentTime() : this.startTime\n      if (this.player) {\n        this.player.destroy()\n      }\n      this.player = new CastPlayer(this.ctx)\n      this.setPlayerListeners()\n\n      if (this.libraryItem) {\n        // libraryItem was already loaded - prepare for cast\n        this.playWhenReady = playWhenReady\n        this.prepare()\n      }\n    } else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer)) {\n      console.log('[PlayerHandler] Switching to local player')\n\n      this.stopPlayInterval()\n      this.playerStateChange('LOADING')\n\n      if (this.player) {\n        this.player.destroy()\n      }\n\n      this.player = new LocalAudioPlayer(this.ctx)\n\n      this.setPlayerListeners()\n\n      if (this.libraryItem) {\n        // libraryItem was already loaded - prepare for local play\n        this.playWhenReady = playWhenReady\n        this.prepare()\n      }\n    }\n  }\n\n  setPlayerListeners() {\n    this.player.on('stateChange', this.playerStateChange.bind(this))\n    this.player.on('timeupdate', this.playerTimeupdate.bind(this))\n    this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))\n    this.player.on('error', this.playerError.bind(this))\n    this.player.on('finished', this.playerFinished.bind(this))\n  }\n\n  playerError() {\n    // Switch to HLS stream on error\n    if (!this.isCasting && this.player instanceof LocalAudioPlayer) {\n      console.log(`[PlayerHandler] Audio player error switching to HLS stream`)\n      this.prepare(true)\n    }\n  }\n\n  playerFinished() {\n    this.stopPlayInterval()\n\n    var currentTime = this.player.getCurrentTime()\n    this.ctx.setCurrentTime(currentTime)\n\n    // TODO: Add listening time between last sync and now?\n    this.sendProgressSync(currentTime)\n\n    this.ctx.mediaFinished(this.libraryItemId, this.episodeId)\n  }\n\n  playerStateChange(state) {\n    console.log('[PlayerHandler] Player state change', state)\n    this.playerState = state\n\n    if (this.playerState === 'PLAYING') {\n      this.setPlaybackRate(this.initialPlaybackRate)\n      this.startPlayInterval()\n    } else {\n      this.stopPlayInterval()\n    }\n\n    if (this.player) {\n      if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {\n        this.ctx.setDuration(this.getDuration())\n      }\n      if (this.playerState !== 'LOADING') {\n        this.ctx.setCurrentTime(this.player.getCurrentTime())\n      }\n    }\n\n    this.ctx.setPlaying(this.playerState === 'PLAYING')\n    this.ctx.playerLoading = this.playerState === 'LOADING'\n  }\n\n  playerTimeupdate(time) {\n    this.ctx.setCurrentTime(time)\n  }\n\n  playerBufferTimeUpdate(buffertime) {\n    this.ctx.setBufferTime(buffertime)\n  }\n\n  getDeviceId() {\n    let deviceId = localStorage.getItem('absDeviceId')\n    if (!deviceId) {\n      deviceId = this.ctx.$randomId()\n      localStorage.setItem('absDeviceId', deviceId)\n    }\n    return deviceId\n  }\n\n  async prepare(forceTranscode = false) {\n    this.setSessionId(null) // Reset session\n\n    const payload = {\n      deviceInfo: {\n        clientName: 'Abs Web',\n        deviceId: this.getDeviceId()\n      },\n      supportedMimeTypes: this.player.playableMimeTypes,\n      mediaPlayer: this.isCasting ? 'chromecast' : 'html5',\n      forceTranscode,\n      forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast\n    }\n\n    const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`\n    const session = await this.ctx.$axios.$post(path, payload).catch((error) => {\n      console.error('Failed to start stream', error)\n    })\n    this.prepareSession(session)\n  }\n\n  prepareOpenSession(session, playbackRate) {\n    // Session opened on init socket\n    if (!this.player) this.switchPlayer() // Must set player first for open sessions\n\n    this.libraryItem = session.libraryItem\n    this.playWhenReady = false\n    this.initialPlaybackRate = playbackRate\n    this.startTimeOverride = undefined\n    this.lastSyncTime = 0\n    this.listeningTimeSinceSync = 0\n\n    this.prepareSession(session)\n  }\n\n  prepareSession(session) {\n    this.failedProgressSyncs = 0\n    this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime\n    this.setSessionId(session.id)\n    this.displayTitle = session.displayTitle\n    this.displayAuthor = session.displayAuthor\n\n    console.log('[PlayerHandler] Preparing Session', session)\n\n    var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, session.id, this.ctx.$config.routerBasePath))\n\n    this.ctx.playerLoading = true\n    this.isHlsTranscode = true\n    if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {\n      this.isHlsTranscode = false\n    }\n\n    this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)\n\n    // browser media session api\n    this.ctx.setMediaSession()\n  }\n\n  closePlayer() {\n    console.log('[PlayerHandler] Close Player')\n    this.sendCloseSession()\n    this.resetPlayer()\n  }\n\n  resetPlayer() {\n    if (this.player) {\n      this.player.destroy()\n    }\n    this.player = null\n    this.playerState = 'IDLE'\n    this.libraryItem = null\n    this.setSessionId(null)\n    this.startTime = 0\n    this.stopPlayInterval()\n  }\n\n  resetStream(startTime, streamId) {\n    if (this.isHlsTranscode && this.currentSessionId === streamId) {\n      this.player.resetStream(startTime)\n    } else {\n      console.warn('resetStream mismatch streamId', this.currentSessionId, streamId)\n    }\n  }\n\n  /**\n   * First sync happens after 20 seconds\n   * subsequent syncs happen every 10 seconds\n   */\n  startPlayInterval() {\n    clearInterval(this.playInterval)\n    let lastTick = Date.now()\n    this.playInterval = setInterval(() => {\n      // Update UI\n      if (!this.player) return\n      const currentTime = this.player.getCurrentTime()\n      this.ctx.setCurrentTime(currentTime)\n\n      const exactTimeElapsed = (Date.now() - lastTick) / 1000\n      lastTick = Date.now()\n      this.listeningTimeSinceSync += exactTimeElapsed\n      const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20\n      if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {\n        this.sendProgressSync(currentTime)\n      }\n    }, 1000)\n  }\n\n  sendCloseSession() {\n    let syncData = null\n    if (this.player) {\n      const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))\n      // When opening player and quickly closing dont save progress\n      if (listeningTimeToAdd > 20) {\n        syncData = {\n          timeListened: listeningTimeToAdd,\n          currentTime: this.getCurrentTime()\n        }\n      }\n    }\n    this.listeningTimeSinceSync = 0\n    this.lastSyncTime = 0\n    return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000, progress: false }).catch((error) => {\n      console.error('Failed to close session', error)\n    })\n  }\n\n  sendProgressSync(currentTime) {\n    const diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)\n    if (diffSinceLastSync < 1) return\n\n    this.lastSyncTime = currentTime\n    const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))\n    const syncData = {\n      timeListened: listeningTimeToAdd,\n      currentTime\n    }\n\n    this.listeningTimeSinceSync = 0\n    this.ctx.$axios\n      .$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })\n      .then(() => {\n        this.failedProgressSyncs = 0\n      })\n      .catch((error) => {\n        console.error('Failed to update session progress', error)\n        // After 4 failed sync attempts show an alert toast\n        this.failedProgressSyncs++\n        if (this.failedProgressSyncs >= 4) {\n          this.ctx.showFailedProgressSyncs()\n          this.failedProgressSyncs = 0\n        }\n      })\n  }\n\n  stopPlayInterval() {\n    clearInterval(this.playInterval)\n    this.playInterval = null\n  }\n\n  playPause() {\n    if (this.player) this.player.playPause()\n  }\n\n  play() {\n    if (this.player) this.player.play()\n  }\n\n  pause() {\n    if (this.player) this.player.pause()\n  }\n\n  getCurrentTime() {\n    return this.player ? this.player.getCurrentTime() : 0\n  }\n\n  getDuration() {\n    return this.player ? this.player.getDuration() : 0\n  }\n\n  jumpBackward() {\n    if (!this.player) return\n    var currentTime = this.getCurrentTime()\n    const jumpAmount = this.jumpBackwardAmount\n    this.seek(Math.max(0, currentTime - jumpAmount))\n  }\n\n  jumpForward() {\n    if (!this.player) return\n    var currentTime = this.getCurrentTime()\n    const jumpAmount = this.jumpForwardAmount\n    this.seek(Math.min(currentTime + jumpAmount, this.getDuration()))\n  }\n\n  setVolume(volume) {\n    if (!this.player) return\n    this.player.setVolume(volume)\n  }\n\n  setPlaybackRate(playbackRate) {\n    this.initialPlaybackRate = playbackRate // Might be loaded from settings before player is started\n    if (!this.player) return\n    this.player.setPlaybackRate(playbackRate)\n  }\n\n  seek(time, shouldSync = true) {\n    if (!this.player) return\n    this.player.seek(time, this.playerPlaying)\n    this.ctx.setCurrentTime(time)\n\n    // Update progress if paused\n    if (!this.playerPlaying && shouldSync) {\n      this.sendProgressSync(time)\n    }\n  }\n}\n"
  },
  {
    "path": "client/players/castUtils.js",
    "content": "function getMediaInfoFromTrack(libraryItem, castImage, track) {\n  let metadata = null\n  if (libraryItem.mediaType === 'podcast') {\n    metadata = new chrome.cast.media.MusicTrackMediaMetadata()\n    metadata.albumArtist = libraryItem.media.metadata.author\n    metadata.artist = libraryItem.media.metadata.author\n    metadata.title = track.title\n    metadata.albumName = libraryItem.media.metadata.title\n    metadata.images = [castImage]\n  } else {\n    // https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.AudiobookChapterMediaMetadata\n    metadata = new chrome.cast.media.AudiobookChapterMediaMetadata()\n    metadata.bookTitle = libraryItem.media.metadata.title\n    metadata.chapterNumber = track.index\n    metadata.chapterTitle = track.title\n    metadata.images = [castImage]\n    metadata.title = track.title\n    metadata.subtitle = libraryItem.media.metadata.title\n  }\n\n  var trackurl = track.fullContentUrl\n  var mimeType = track.mimeType\n  var mediainfo = new chrome.cast.media.MediaInfo(trackurl, mimeType)\n  mediainfo.metadata = metadata\n  mediainfo.itemId = track.index\n  mediainfo.duration = track.duration\n  return mediainfo\n}\n\nfunction buildCastMediaInfo(libraryItem, coverUrl, tracks) {\n  const castImage = new chrome.cast.Image(coverUrl)\n  return tracks.map((t) => getMediaInfoFromTrack(libraryItem, castImage, t))\n}\n\nfunction buildCastQueueRequest(libraryItem, coverUrl, tracks, startTime) {\n  var mediaInfoItems = buildCastMediaInfo(libraryItem, coverUrl, tracks)\n\n  let containerMetadata = null\n  let queueType = chrome.cast.media.QueueType.AUDIOBOOK\n  if (libraryItem.mediaType === 'podcast') {\n    queueType = chrome.cast.media.QueueType.PODCAST_SERIES\n    containerMetadata = new chrome.cast.media.ContainerMetadata(chrome.cast.media.ContainerType.GENERIC_CONTAINER)\n    containerMetadata.title = libraryItem.media.metadata.title\n  } else {\n    containerMetadata = new chrome.cast.media.AudiobookContainerMetadata()\n    containerMetadata.authors = libraryItem.media.metadata.authors?.map((a) => a.name)\n    containerMetadata.narrators = libraryItem.media.metadata.narrators || []\n    containerMetadata.publisher = libraryItem.media.metadata.publisher || undefined\n    containerMetadata.title = libraryItem.media.metadata.title\n  }\n\n  var mediaQueueItems = mediaInfoItems.map((mi) => {\n    var queueItem = new chrome.cast.media.QueueItem(mi)\n    return queueItem\n  })\n\n  // Find track to start playback and calculate track start offset\n  var track = tracks.find((at) => at.startOffset <= startTime && at.startOffset + at.duration > startTime)\n  var trackStartIndex = track ? track.index - 1 : 0\n  var trackStartTime = Math.floor(track ? startTime - track.startOffset : 0)\n\n  var queueData = new chrome.cast.media.QueueData(libraryItem.id, libraryItem.media.metadata.title, '', false, mediaQueueItems, trackStartIndex, trackStartTime)\n  queueData.containerMetadata = containerMetadata\n  queueData.queueType = queueType\n  return queueData\n}\n\nfunction castLoadMedia(castSession, request) {\n  return new Promise((resolve) => {\n    castSession.loadMedia(request).then(\n      () => resolve(true),\n      (reason) => {\n        console.error('Load media failed', reason)\n        resolve(false)\n      }\n    )\n  })\n}\n\nfunction buildCastLoadRequest(libraryItem, coverUrl, tracks, startTime, autoplay, playbackRate) {\n  var request = new chrome.cast.media.LoadRequest()\n\n  request.queueData = buildCastQueueRequest(libraryItem, coverUrl, tracks, startTime)\n  request.currentTime = request.queueData.startTime\n\n  request.autoplay = autoplay\n  request.playbackRate = playbackRate\n  return request\n}\n\nexport { buildCastLoadRequest, castLoadMedia }\n"
  },
  {
    "path": "client/plugins/axios.js",
    "content": "export default function ({ $axios, store, $root, app }) {\n  // Track if we're currently refreshing to prevent multiple refresh attempts\n  let isRefreshing = false\n  let failedQueue = []\n\n  const processQueue = (error, token = null) => {\n    failedQueue.forEach(({ resolve, reject }) => {\n      if (error) {\n        reject(error)\n      } else {\n        resolve(token)\n      }\n    })\n    failedQueue = []\n  }\n\n  $axios.onRequest((config) => {\n    if (!config.url) {\n      console.error('Axios request invalid config', config)\n      return\n    }\n    if (config.url.startsWith('http:') || config.url.startsWith('https:')) {\n      return\n    }\n    const bearerToken = store.getters['user/getToken']\n    if (bearerToken) {\n      config.headers.common['Authorization'] = `Bearer ${bearerToken}`\n    }\n\n    if (process.env.NODE_ENV === 'development') {\n      console.log('Making request to ' + config.url)\n    }\n  })\n\n  $axios.onError(async (error) => {\n    const originalRequest = error.config\n    const code = parseInt(error.response && error.response.status)\n    const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'\n\n    console.error('Axios error', code, message)\n\n    // Handle 401 Unauthorized (token expired)\n    if (code === 401 && !originalRequest._retry) {\n      // Skip refresh for auth endpoints to prevent infinite loops\n      if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') {\n        // Refresh failed or login failed, redirect to login\n        store.commit('user/setUser', null)\n        store.commit('user/setAccessToken', null)\n        app.router.push('/login')\n        return Promise.reject(error)\n      }\n\n      if (isRefreshing) {\n        // If already refreshing, queue this request\n        return new Promise((resolve, reject) => {\n          failedQueue.push({ resolve, reject })\n        })\n          .then((token) => {\n            if (!originalRequest.headers) {\n              originalRequest.headers = {}\n            }\n            originalRequest.headers['Authorization'] = `Bearer ${token}`\n            return $axios(originalRequest)\n          })\n          .catch((err) => {\n            return Promise.reject(err)\n          })\n      }\n\n      originalRequest._retry = true\n      isRefreshing = true\n\n      try {\n        // Attempt to refresh the token\n        // Updates store if successful, otherwise clears store and throw error\n        const newAccessToken = await store.dispatch('user/refreshToken')\n        if (!newAccessToken) {\n          console.error('No new access token received')\n          return Promise.reject(error)\n        }\n\n        // Update the original request with new token\n        if (!originalRequest.headers) {\n          originalRequest.headers = {}\n        }\n        originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`\n\n        // Process any queued requests\n        processQueue(null, newAccessToken)\n\n        // Retry the original request\n        return $axios(originalRequest)\n      } catch (refreshError) {\n        console.error('Token refresh failed:', refreshError)\n\n        // Process queued requests with error\n        processQueue(refreshError, null)\n\n        // Redirect to login\n        app.router.push('/login')\n\n        return Promise.reject(refreshError)\n      } finally {\n        isRefreshing = false\n      }\n    }\n\n    return Promise.reject(error)\n  })\n}\n"
  },
  {
    "path": "client/plugins/chromecast.js",
    "content": "export default (ctx) => {\n  var sendInit = async (castContext) => {\n    // Fetch background covers for chromecast (temp)\n    var covers = await ctx.$axios.$get(`/api/libraries/${ctx.$store.state.libraries.currentLibraryId}/items?limit=40&minified=1`).then((data) => {\n      return data.results.filter((b) => b.media.coverPath).map((libraryItem) => {\n        var coverUrl = ctx.$store.getters['globals/getLibraryItemCoverSrc'](libraryItem)\n        if (process.env.NODE_ENV === 'development') return coverUrl\n        return `${window.location.origin}${coverUrl}`\n      })\n    }).catch((error) => {\n      console.error('failed to fetch books', error)\n      return null\n    })\n\n    // Custom message to receiver\n    var castSession = castContext.getCurrentSession()\n    castSession.sendMessage('urn:x-cast:com.audiobookshelf.cast', {\n      covers\n    })\n  }\n\n  var initializeCastApi = () => {\n    var castContext = cast.framework.CastContext.getInstance()\n    castContext.setOptions({\n      receiverApplicationId: process.env.chromecastReceiver,\n      autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null\n    });\n\n    castContext.addEventListener(\n      cast.framework.CastContextEventType.SESSION_STATE_CHANGED,\n      (event) => {\n        console.log('Session state changed event', event)\n\n        switch (event.sessionState) {\n          case cast.framework.SessionState.SESSION_STARTED:\n            console.log('[chromecast] CAST SESSION STARTED')\n\n            ctx.$store.commit('globals/setCasting', true)\n            sendInit(castContext)\n\n            setTimeout(() => {\n              ctx.$eventBus.$emit('cast-session-active', true)\n            }, 500)\n\n            break;\n          case cast.framework.SessionState.SESSION_RESUMED:\n            console.log('[chromecast] CAST SESSION RESUMED')\n\n            setTimeout(() => {\n              ctx.$eventBus.$emit('cast-session-active', true)\n            }, 500)\n            break;\n          case cast.framework.SessionState.SESSION_ENDED:\n            console.log('[chromecast] CAST SESSION DISCONNECTED')\n\n            ctx.$store.commit('globals/setCasting', false)\n            ctx.$eventBus.$emit('cast-session-active', false)\n            break;\n        }\n      })\n\n    ctx.$store.commit('globals/setChromecastInitialized', true)\n\n    var player = new cast.framework.RemotePlayer()\n    var playerController = new cast.framework.RemotePlayerController(player)\n    ctx.$root.castPlayer = player\n    ctx.$root.castPlayerController = playerController\n  }\n\n  window['__onGCastApiAvailable'] = function (isAvailable) {\n    if (isAvailable) {\n      initializeCastApi()\n    }\n  }\n\n  var script = document.createElement('script')\n  script.type = 'text/javascript'\n  script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'\n  document.head.appendChild(script)\n}"
  },
  {
    "path": "client/plugins/constants.js",
    "content": "const SupportedFileTypes = {\n  image: ['png', 'jpg', 'jpeg', 'webp'],\n  audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'aif','wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'],\n  ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],\n  info: ['nfo'],\n  text: ['txt'],\n  metadata: ['opf', 'abs', 'xml', 'json']\n}\n\nconst DownloadStatus = {\n  PENDING: 0,\n  READY: 1,\n  EXPIRED: 2,\n  FAILED: 3\n}\n\nconst BookCoverAspectRatio = {\n  STANDARD: 0,\n  SQUARE: 1\n}\n\nconst BookshelfView = {\n  STANDARD: 0,\n  DETAIL: 1,\n  AUTHOR: 2 // Books shown on author page\n}\n\nconst PlayMethod = {\n  DIRECTPLAY: 0,\n  DIRECTSTREAM: 1,\n  TRANSCODE: 2,\n  LOCAL: 3\n}\n\nconst SleepTimerTypes = {\n  COUNTDOWN: 'countdown',\n  CHAPTER: 'chapter'\n}\n\nconst Constants = {\n  SupportedFileTypes,\n  DownloadStatus,\n  BookCoverAspectRatio,\n  BookshelfView,\n  PlayMethod,\n  SleepTimerTypes\n}\n\nconst KeyNames = {\n  27: 'Escape',\n  32: 'Space',\n  37: 'ArrowLeft',\n  38: 'ArrowUp',\n  39: 'ArrowRight',\n  40: 'ArrowDown',\n  76: 'KeyL',\n  77: 'KeyM'\n}\nconst Hotkeys = {\n  AudioPlayer: {\n    PLAY_PAUSE: 'Space',\n    JUMP_FORWARD: 'ArrowRight',\n    JUMP_BACKWARD: 'ArrowLeft',\n    VOLUME_UP: 'ArrowUp',\n    VOLUME_DOWN: 'ArrowDown',\n    MUTE_UNMUTE: 'KeyM',\n    SHOW_CHAPTERS: 'KeyL',\n    INCREASE_PLAYBACK_RATE: 'Shift-ArrowUp',\n    DECREASE_PLAYBACK_RATE: 'Shift-ArrowDown',\n    CLOSE: 'Escape'\n  },\n  EReader: {\n    NEXT_PAGE: 'ArrowRight',\n    PREV_PAGE: 'ArrowLeft',\n    CLOSE: 'Escape'\n  },\n  Modal: {\n    NEXT_PAGE: 'ArrowRight',\n    PREV_PAGE: 'ArrowLeft',\n    CLOSE: 'Escape'\n  }\n}\n\nexport { Constants }\nexport default ({ app }, inject) => {\n  inject('constants', Constants)\n  inject('keynames', KeyNames)\n  inject('hotkeys', Hotkeys)\n}\n"
  },
  {
    "path": "client/plugins/i18n.js",
    "content": "import Vue from 'vue'\nimport enUsStrings from '../strings/en-us.json'\nimport { supplant } from './utils'\n\nconst defaultCode = 'en-us'\n\nconst languageCodeMap = {\n  ar: { label: 'عربي', dateFnsLocale: 'ar' },\n  be: { label: 'Беларуская', dateFnsLocale: 'be' },\n  bg: { label: 'Български', dateFnsLocale: 'bg' },\n  bn: { label: 'বাংলা', dateFnsLocale: 'bn' },\n  ca: { label: 'Català', dateFnsLocale: 'ca' },\n  cs: { label: 'Čeština', dateFnsLocale: 'cs' },\n  da: { label: 'Dansk', dateFnsLocale: 'da' },\n  de: { label: 'Deutsch', dateFnsLocale: 'de' },\n  'en-us': { label: 'English', dateFnsLocale: 'enUS' },\n  es: { label: 'Español', dateFnsLocale: 'es' },\n  et: { label: 'Eesti', dateFnsLocale: 'et' },\n  fi: { label: 'Suomi', dateFnsLocale: 'fi' },\n  fr: { label: 'Français', dateFnsLocale: 'fr' },\n  he: { label: 'עברית', dateFnsLocale: 'he' },\n  hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },\n  it: { label: 'Italiano', dateFnsLocale: 'it' },\n  lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },\n  hu: { label: 'Magyar', dateFnsLocale: 'hu' },\n  ko: { label: '한국어', dateFnsLocale: 'ko' },\n  nl: { label: 'Nederlands', dateFnsLocale: 'nl' },\n  no: { label: 'Norsk', dateFnsLocale: 'no' },\n  pl: { label: 'Polski', dateFnsLocale: 'pl' },\n  'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' },\n  ru: { label: 'Русский', dateFnsLocale: 'ru' },\n  sk: { label: 'Slovenčina', dateFnsLocale: 'sk' },\n  sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },\n  sv: { label: 'Svenska', dateFnsLocale: 'sv' },\n  tr: { label: 'Türkçe', dateFnsLocale: 'tr' },\n  uk: { label: 'Українська', dateFnsLocale: 'uk' },\n  'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },\n  'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },\n  'zh-tw': { label: '正體中文 (Traditional Chinese)', dateFnsLocale: 'zhTW' }\n}\nVue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) => {\n  return {\n    text: languageCodeMap[code].label,\n    value: code\n  }\n})\n\n// iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2\nconst podcastSearchRegionMap = {\n  au: { label: 'Australia' },\n  br: { label: 'Brasil' },\n  be: { label: 'België / Belgique / Belgien' },\n  by: { label: 'Беларусь' },\n  cz: { label: 'Česko' },\n  dk: { label: 'Danmark' },\n  de: { label: 'Deutschland' },\n  ee: { label: 'Eesti' },\n  es: { label: 'España / Espanya / Espainia' },\n  fr: { label: 'France' },\n  hr: { label: 'Hrvatska' },\n  il: { label: 'ישראל / إسرائيل' },\n  it: { label: 'Italia' },\n  lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' },\n  hu: { label: 'Magyarország' },\n  nl: { label: 'Nederland' },\n  no: { label: 'Norge' },\n  nz: { label: 'New Zealand' },\n  at: { label: 'Österreich' },\n  pl: { label: 'Polska' },\n  pt: { label: 'Portugal' },\n  ru: { label: 'Россия' },\n  ch: { label: 'Schweiz / Suisse / Svizzera' },\n  sk: { label: 'Slovensko' },\n  se: { label: 'Sverige' },\n  vn: { label: 'Việt Nam' },\n  ua: { label: 'Україна' },\n  gb: { label: 'United Kingdom' },\n  us: { label: 'United States' },\n  cn: { label: '中国' }\n}\nVue.prototype.$podcastSearchRegionOptions = Object.keys(podcastSearchRegionMap).map((code) => {\n  return {\n    text: podcastSearchRegionMap[code].label,\n    value: code\n  }\n})\n\nVue.prototype.$languageCodes = {\n  default: defaultCode, // en-us\n  current: defaultCode, // Current language code in use\n  local: null, // Language code set at user level\n  server: null // Language code set at server level\n}\n\n// Currently loaded strings (default enUS)\nVue.prototype.$strings = { ...enUsStrings }\n\n/**\n * Get string and substitute\n *\n * @param {string} key\n * @param {string[]} [subs=[]]\n * @returns {string}\n */\nVue.prototype.$getString = (key, subs = []) => {\n  if (!Vue.prototype.$strings[key]) return ''\n  if (subs?.length && Array.isArray(subs)) {\n    return supplant(Vue.prototype.$strings[key], subs)\n  }\n  return Vue.prototype.$strings[key]\n}\n\nVue.prototype.$formatNumber = (num) => {\n  return Intl.NumberFormat(Vue.prototype.$languageCodes.current).format(num)\n}\n\n/**\n * Get the days of the week for the current language\n * Starts with Sunday\n * @returns {string[]}\n */\nVue.prototype.$getDaysOfWeek = () => {\n  const days = []\n  for (let i = 0; i < 7; i++) {\n    days.push(new Date(2025, 0, 5 + i).toLocaleString(Vue.prototype.$languageCodes.current, { weekday: 'long' }))\n  }\n  return days\n}\n\nconst translations = {\n  [defaultCode]: enUsStrings\n}\n\nfunction loadTranslationStrings(code) {\n  return new Promise((resolve) => {\n    import(`../strings/${code}`)\n      .then((fileContents) => {\n        resolve(fileContents.default)\n      })\n      .catch((error) => {\n        console.error('Failed to load i18n strings', code, error)\n        resolve(null)\n      })\n  })\n}\n\nasync function loadi18n(code) {\n  if (!code) return false\n  if (Vue.prototype.$languageCodes.current == code) {\n    // already set\n    return false\n  }\n\n  const strings = translations[code] || (await loadTranslationStrings(code))\n  if (!strings) {\n    console.warn(`Invalid lang code ${code}`)\n    return false\n  }\n\n  translations[code] = strings\n  Vue.prototype.$languageCodes.current = code\n  localStorage.setItem('lang', code)\n\n  for (const key in Vue.prototype.$strings) {\n    Vue.prototype.$strings[key] = strings[key] || translations[defaultCode][key]\n  }\n\n  Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)\n\n  this?.$eventBus?.$emit('change-lang', code)\n\n  return true\n}\n\nVue.prototype.$setLanguageCode = loadi18n\n\n// Set the servers default language code, does not override users local language code\nVue.prototype.$setServerLanguageCode = (code) => {\n  if (!code) return\n\n  if (!languageCodeMap[code]) {\n    console.warn('invalid server language in', code)\n  } else {\n    Vue.prototype.$languageCodes.server = code\n    if (!Vue.prototype.$languageCodes.local && code !== defaultCode) {\n      loadi18n(code)\n    }\n  }\n}\n\n// Initialize with language code in localStorage if valid\nasync function initialize() {\n  const localLanguage = localStorage.getItem('lang')\n  if (!localLanguage) return\n\n  if (!languageCodeMap[localLanguage]) {\n    console.warn('Invalid local language code', localLanguage)\n    localStorage.setItem('lang', defaultCode)\n  } else {\n    Vue.prototype.$languageCodes.local = localLanguage\n    loadi18n(localLanguage)\n  }\n}\ninitialize()\n"
  },
  {
    "path": "client/plugins/init.client.js",
    "content": "import Vue from 'vue'\nimport Path from 'path'\nimport vClickOutside from 'v-click-outside'\nimport { formatDistance, format, addDays, isDate, setDefaultOptions } from 'date-fns'\nimport * as locale from 'date-fns/locale'\n\nVue.directive('click-outside', vClickOutside.directive)\n\nVue.prototype.$setDateFnsLocale = (localeString) => {\n  if (!locale[localeString]) return 0\n  return setDefaultOptions({ locale: locale[localeString] })\n}\nVue.prototype.$dateDistanceFromNow = (unixms) => {\n  if (!unixms) return ''\n  return formatDistance(unixms, Date.now(), { addSuffix: true })\n}\nVue.prototype.$formatDate = (unixms, fnsFormat = 'MM/dd/yyyy HH:mm') => {\n  if (!unixms) return ''\n  return format(unixms, fnsFormat)\n}\nVue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {\n  if (!jsdate || !isDate(jsdate)) return ''\n  return format(jsdate, fnsFormat)\n}\nVue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {\n  if (!unixms) return ''\n  return format(unixms, fnsFormat)\n}\nVue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {\n  if (!jsdate || !isDate(jsdate)) return ''\n  return format(jsdate, fnsFormat)\n}\nVue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {\n  if (!unixms) return ''\n  return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)\n}\nVue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {\n  if (!jsdate || !isDate(jsdate)) return ''\n  return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)\n}\nVue.prototype.$addDaysToToday = (daysToAdd) => {\n  var date = addDays(new Date(), daysToAdd)\n  if (!date || !isDate(date)) return null\n  return date\n}\nVue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {\n  var date = addDays(jsdate, daysToAdd)\n  if (!date || !isDate(date)) return null\n  return date\n}\n\nVue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {\n  if (typeof filename !== 'string') {\n    return false\n  }\n\n  // Most file systems use number of bytes for max filename\n  //   to support most filesystems we will use max of 255 bytes in utf-16\n  //   Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html\n  //   Issue: https://github.com/advplyr/audiobookshelf/issues/1261\n  const MAX_FILENAME_BYTES = 255\n\n  const replacement = ''\n  const illegalRe = /[\\/\\?<>\\\\:\\*\\|\"]/g\n  const controlRe = /[\\x00-\\x1f\\x80-\\x9f]/g\n  const reservedRe = /^\\.+$/\n  const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\\..*)?$/i\n  const windowsTrailingRe = /[\\. ]+$/\n  const lineBreaks = /[\\n\\r]/g\n\n  let sanitized = filename\n    .replace(':', colonReplacement) // Replace first occurrence of a colon\n    .replace(illegalRe, replacement)\n    .replace(controlRe, replacement)\n    .replace(reservedRe, replacement)\n    .replace(lineBreaks, replacement)\n    .replace(windowsReservedRe, replacement)\n    .replace(windowsTrailingRe, replacement)\n    .replace(/\\s+/g, ' ') // Replace consecutive spaces with a single space\n\n  // Check if basename is too many bytes\n  const ext = Path.extname(sanitized) // separate out file extension\n  const basename = Path.basename(sanitized, ext)\n  const extByteLength = Buffer.byteLength(ext, 'utf16le')\n  const basenameByteLength = Buffer.byteLength(basename, 'utf16le')\n  if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {\n    const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength\n    let totalBytes = 0\n    let trimmedBasename = ''\n\n    // Add chars until max bytes is reached\n    for (const char of basename) {\n      totalBytes += Buffer.byteLength(char, 'utf16le')\n      if (totalBytes > MaxBytesForBasename) break\n      else trimmedBasename += char\n    }\n\n    trimmedBasename = trimmedBasename.trim()\n    sanitized = trimmedBasename + ext\n  }\n\n  return sanitized\n}\n\n// SOURCE: https://gist.github.com/spyesx/561b1d65d4afb595f295\n//   modified: allowed underscores\nVue.prototype.$sanitizeSlug = (str) => {\n  if (!str) return ''\n\n  str = str.replace(/^\\s+|\\s+$/g, '') // trim\n  str = str.toLowerCase()\n\n  // remove accents, swap ñ for n, etc\n  var from = 'àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;'\n  var to = 'aaaaeeeeiiiioooouuuuncescrzyuudtn-----'\n\n  for (var i = 0, l = from.length; i < l; i++) {\n    str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))\n  }\n\n  str = str\n    .replace('.', '-') // replace a dot by a dash\n    .replace(/[^a-z0-9 -_]/g, '') // remove invalid chars\n    .replace(/\\s+/g, '-') // collapse whitespace and replace by a dash\n    .replace(/-+/g, '-') // collapse dashes\n    .replace(/\\//g, '') // collapse all forward-slashes\n\n  return str\n}\n\nVue.prototype.$copyToClipboard = (str) => {\n  return new Promise((resolve) => {\n    if (navigator.clipboard) {\n      navigator.clipboard.writeText(str).then(\n        () => {\n          resolve(true)\n        },\n        (err) => {\n          console.error('Clipboard copy failed', str, err)\n          resolve(false)\n        }\n      )\n    } else {\n      const el = document.createElement('textarea')\n      el.value = str\n      el.setAttribute('readonly', '')\n      el.style.position = 'absolute'\n      el.style.left = '-9999px'\n      document.body.appendChild(el)\n      el.select()\n      document.execCommand('copy')\n      document.body.removeChild(el)\n\n      resolve(true)\n    }\n  })\n}\n\nfunction xmlToJson(xml) {\n  const json = {}\n  for (const res of xml.matchAll(/(?:<(\\w*)(?:\\s[^>]*)*>)((?:(?!<\\1).)*)(?:<\\/\\1>)|<(\\w*)(?:\\s*)*\\/>/gm)) {\n    const key = res[1] || res[3]\n    const value = res[2] && xmlToJson(res[2])\n    json[key] = (value && Object.keys(value).length ? value : res[2]) || null\n  }\n  return json\n}\nVue.prototype.$xmlToJson = xmlToJson\n\nconst encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))\nVue.prototype.$encode = encode\nconst decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()\nVue.prototype.$decode = decode\n\nexport { encode, decode }\nexport default ({ app, store }, inject) => {\n  app.$decode = decode\n  app.$encode = encode\n  inject('eventBus', new Vue())\n  inject('isDev', process.env.NODE_ENV !== 'production')\n\n  store.commit('setRouterBasePath', app.$config.routerBasePath)\n}\n"
  },
  {
    "path": "client/plugins/toast.js",
    "content": "import Vue from \"vue\"\nimport Toast from \"vue-toastification\"\nimport \"vue-toastification/dist/index.css\"\n\nconst options = {\n  hideProgressBar: true,\n  draggable: false\n}\n\nVue.use(Toast, options)\n"
  },
  {
    "path": "client/plugins/utils.js",
    "content": "import Vue from 'vue'\nimport cronParser from 'cron-parser'\nimport { nanoid } from 'nanoid'\n\nVue.prototype.$randomId = (len = null) => {\n  if (len && !isNaN(len)) return nanoid(len)\n  return nanoid()\n}\n\nVue.prototype.$bytesPretty = (bytes, decimals = 2) => {\n  if (isNaN(bytes) || bytes == 0) {\n    return '0 Bytes'\n  }\n  const k = 1000\n  const dm = decimals < 0 ? 0 : decimals\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]\n}\n\nVue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds = false) => {\n  if (useMilliseconds && seconds > 0 && seconds < 1) {\n    return `${Math.floor(seconds * 1000)} ms`\n  }\n  if (seconds < 60) {\n    return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`\n  }\n  var minutes = Math.floor(seconds / 60)\n  if (minutes < 70) {\n    return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`\n  }\n  var hours = Math.floor(minutes / 60)\n  minutes -= hours * 60\n  if (!minutes) {\n    return `${hours} ${useFullNames ? 'hours' : 'hr'}`\n  }\n  return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`\n}\n\nVue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => {\n  if (isNaN(seconds) || seconds === null) return ''\n\n  try {\n    const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, {\n      style: useFullNames ? 'long' : 'short'\n    })\n\n    const duration = {}\n\n    if (seconds < 60) {\n      if (useMilliseconds && seconds < 1) {\n        duration.milliseconds = Math.floor(seconds * 1000)\n      } else {\n        duration.seconds = Math.floor(seconds)\n      }\n    } else if (seconds < 3600) {\n      // 1 hour\n      duration.minutes = Math.floor(seconds / 60)\n    } else if (seconds < 86400) {\n      // 1 day\n      duration.hours = Math.floor(seconds / 3600)\n      const minutes = Math.floor((seconds % 3600) / 60)\n      if (minutes > 0) {\n        duration.minutes = minutes\n      }\n    } else {\n      duration.days = Math.floor(seconds / 86400)\n      const hours = Math.floor((seconds % 86400) / 3600)\n      if (hours > 0) {\n        duration.hours = hours\n      }\n    }\n\n    return df.format(duration)\n  } catch (error) {\n    // Handle not supported\n    console.warn('Intl.DurationFormat not supported, not localizing duration')\n    return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds)\n  }\n}\n\nVue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {\n  if (!seconds) {\n    return alwaysIncludeHours ? '00:00:00' : '0:00'\n  }\n  var _seconds = seconds\n  var _minutes = Math.floor(seconds / 60)\n  _seconds -= _minutes * 60\n  var _hours = Math.floor(_minutes / 60)\n  _minutes -= _hours * 60\n\n  var ms = _seconds - Math.floor(seconds)\n  _seconds = Math.floor(_seconds)\n\n  var msString = includeMs ? '.' + ms.toFixed(3).split('.')[1] : ''\n  if (alwaysIncludeHours) {\n    return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`\n  }\n  if (!_hours) {\n    return `${_minutes}:${_seconds.toString().padStart(2, '0')}${msString}`\n  }\n  return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`\n}\n\nVue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = true) => {\n  if (isNaN(seconds) || seconds === null) return ''\n  seconds = Math.round(seconds)\n\n  let minutes = Math.floor(seconds / 60)\n  seconds -= minutes * 60\n  let hours = Math.floor(minutes / 60)\n  minutes -= hours * 60\n\n  // Handle rollovers before days calculation\n  if (minutes && seconds && !showSeconds) {\n    if (seconds >= 30) minutes++\n    if (minutes >= 60) {\n      hours++ // Increment hours if minutes roll over\n      minutes -= 60 // adjust minutes\n    }\n  }\n\n  // Now calculate days with the final hours value\n  let days = 0\n  if (useDays || Math.floor(hours / 24) >= 100) {\n    days = Math.floor(hours / 24)\n    hours -= days * 24\n  }\n\n  const strs = []\n  if (days) strs.push(`${days}d`)\n  if (hours) strs.push(`${hours}h`)\n  if (minutes) strs.push(`${minutes}m`)\n  if (seconds && showSeconds) strs.push(`${seconds}s`)\n  return strs.join(' ')\n}\n\nVue.prototype.$parseCronExpression = (expression, context) => {\n  if (!expression) return null\n  const pieces = expression.split(' ')\n  if (pieces.length !== 5) {\n    return null\n  }\n\n  const commonPatterns = [\n    {\n      text: context.$strings.LabelIntervalEvery12Hours,\n      value: '0 */12 * * *'\n    },\n    {\n      text: context.$strings.LabelIntervalEvery6Hours,\n      value: '0 */6 * * *'\n    },\n    {\n      text: context.$strings.LabelIntervalEvery2Hours,\n      value: '0 */2 * * *'\n    },\n    {\n      text: context.$strings.LabelIntervalEveryHour,\n      value: '0 * * * *'\n    },\n    {\n      text: context.$strings.LabelIntervalEvery30Minutes,\n      value: '*/30 * * * *'\n    },\n    {\n      text: context.$strings.LabelIntervalEvery15Minutes,\n      value: '*/15 * * * *'\n    },\n    {\n      text: context.$strings.LabelIntervalEveryMinute,\n      value: '* * * * *'\n    }\n  ]\n  const patternMatch = commonPatterns.find((p) => p.value === expression)\n  if (patternMatch) {\n    return {\n      description: patternMatch.text\n    }\n  }\n\n  if (isNaN(pieces[0]) || isNaN(pieces[1])) {\n    return null\n  }\n  if (pieces[2] !== '*' || pieces[3] !== '*') {\n    return null\n  }\n  if (pieces[4] !== '*' && pieces[4].split(',').some((p) => isNaN(p))) {\n    return null\n  }\n\n  const weekdays = context.$getDaysOfWeek()\n  var weekdayText = 'day'\n  if (pieces[4] !== '*')\n    weekdayText = pieces[4]\n      .split(',')\n      .map((p) => weekdays[p])\n      .join(', ')\n\n  return {\n    description: context.$getString('MessageScheduleRunEveryWeekdayAtTime', [weekdayText, `${pieces[1]}:${pieces[0].padStart(2, '0')}`])\n  }\n}\n\nVue.prototype.$getNextScheduledDate = (expression) => {\n  const interval = cronParser.parseExpression(expression)\n  return interval.next().toDate()\n}\n\nVue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {\n  const a = document.createElement('a')\n  a.style.display = 'none'\n  a.href = url\n\n  if (filename) {\n    a.download = filename\n  }\n  if (openInNewTab) {\n    a.target = '_blank'\n  }\n\n  document.body.appendChild(a)\n  a.click()\n  setTimeout(() => {\n    a.remove()\n  })\n}\n\nexport function supplant(str, subs) {\n  // source: http://crockford.com/javascript/remedial.html\n  return str.replace(/{([^{}]*)}/g, function (a, b) {\n    var r = subs[b]\n    return typeof r === 'string' || typeof r === 'number' ? r : a\n  })\n}\n"
  },
  {
    "path": "client/plugins/version.js",
    "content": "import packagejson from '../package.json'\nimport axios from 'axios'\n\nfunction parseSemver(ver) {\n  if (!ver) return null\n  var groups = ver.match(/^v((([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)$/)\n  if (groups && groups.length > 6) {\n    var total = Number(groups[3]) * 10000 + Number(groups[4]) * 100 + Number(groups[5])\n    if (isNaN(total)) {\n      console.warn('Invalid version total', groups[3], groups[4], groups[5])\n      return null\n    }\n    return {\n      name: ver,\n      total,\n      version: groups[2],\n      major: Number(groups[3]),\n      minor: Number(groups[4]),\n      patch: Number(groups[5]),\n      preRelease: groups[6] || null\n    }\n  } else {\n    console.warn('Invalid semver string', ver)\n  }\n  return null\n}\n\nfunction getReleases() {\n  return axios\n    .get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`)\n    .then((res) => {\n      return res.data\n        .map((release) => {\n          const tagName = release.tag_name\n          const verObj = parseSemver(tagName)\n          if (verObj) {\n            verObj.pubdate = new Date(release.published_at)\n            verObj.changelog = release.body\n            return verObj\n          }\n          return null\n        })\n        .filter((verObj) => verObj)\n    })\n    .catch((error) => {\n      console.error('Failed to get releases', error)\n      return []\n    })\n}\n\nexport const currentVersion = packagejson.version\n\nexport async function checkForUpdate() {\n  if (!packagejson.version) {\n    return null\n  }\n\n  const releases = await getReleases()\n  if (!releases.length) {\n    console.error('No releases found')\n    return null\n  }\n\n  const currentVersion = releases.find((release) => release.version == packagejson.version)\n  if (!currentVersion) {\n    console.error('Current version not found in releases')\n    return null\n  }\n\n  const latestVersion = releases[0]\n  const currentVersionMinor = currentVersion.minor\n  const currentVersionMajor = currentVersion.major\n  // Show all releases with the same minor version and lower or equal total version\n  const releasesToShow = releases.filter((release) => {\n    return release.major == currentVersionMajor && release.minor == currentVersionMinor && release.total <= currentVersion.total\n  })\n\n  return {\n    hasUpdate: latestVersion.total > currentVersion.total,\n    latestVersion: latestVersion.version,\n    githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${latestVersion.version}`,\n    currentVersion: currentVersion.version,\n    releasesToShow\n  }\n}\n"
  },
  {
    "path": "client/postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    '@tailwindcss/postcss': {}\n  }\n}\n"
  },
  {
    "path": "client/static/fonts/Source_Sans_Pro/OFL.txt",
    "content": "Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name ‘Source’.\n\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at:\nhttp://scripts.sil.org/OFL\n\n\n-----------------------------------------------------------\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\n-----------------------------------------------------------\n\nPREAMBLE\nThe goals of the Open Font License (OFL) are to stimulate worldwide\ndevelopment of collaborative font projects, to support the font creation\nefforts of academic and linguistic communities, and to provide a free and\nopen framework in which fonts may be shared and improved in partnership\nwith others.\n\nThe OFL allows the licensed fonts to be used, studied, modified and\nredistributed freely as long as they are not sold by themselves. The\nfonts, including any derivative works, can be bundled, embedded, \nredistributed and/or sold with any software provided that any reserved\nnames are not used by derivative works. The fonts and derivatives,\nhowever, cannot be released under any other type of license. The\nrequirement for fonts to remain under this license does not apply\nto any document created using the fonts or their derivatives.\n\nDEFINITIONS\n\"Font Software\" refers to the set of files released by the Copyright\nHolder(s) under this license and clearly marked as such. This may\ninclude source files, build scripts and documentation.\n\n\"Reserved Font Name\" refers to any names specified as such after the\ncopyright statement(s).\n\n\"Original Version\" refers to the collection of Font Software components as\ndistributed by the Copyright Holder(s).\n\n\"Modified Version\" refers to any derivative made by adding to, deleting,\nor substituting -- in part or in whole -- any of the components of the\nOriginal Version, by changing formats or by porting the Font Software to a\nnew environment.\n\n\"Author\" refers to any designer, engineer, programmer, technical\nwriter or other person who contributed to the Font Software.\n\nPERMISSION & CONDITIONS\nPermission is hereby granted, free of charge, to any person obtaining\na copy of the Font Software, to use, study, copy, merge, embed, modify,\nredistribute, and sell modified and unmodified copies of the Font\nSoftware, subject to the following conditions:\n\n1) Neither the Font Software nor any of its individual components,\nin Original or Modified Versions, may be sold by itself.\n\n2) Original or Modified Versions of the Font Software may be bundled,\nredistributed and/or sold with any software, provided that each copy\ncontains the above copyright notice and this license. These can be\nincluded either as stand-alone text files, human-readable headers or\nin the appropriate machine-readable metadata fields within text or\nbinary files as long as those fields can be easily viewed by the user.\n\n3) No Modified Version of the Font Software may use the Reserved Font\nName(s) unless explicit written permission is granted by the corresponding\nCopyright Holder. This restriction only applies to the primary font name as\npresented to the users.\n\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\nSoftware shall not be used to promote, endorse or advertise any\nModified Version, except to acknowledge the contribution(s) of the\nCopyright Holder(s) and the Author(s) or with their explicit written\npermission.\n\n5) The Font Software, modified or unmodified, in part or in whole,\nmust be distributed entirely under this license, and must not be\ndistributed under any other license. The requirement for fonts to\nremain under this license does not apply to any document created\nusing the Font Software.\n\nTERMINATION\nThis license becomes null and void if any of the above conditions are\nnot met.\n\nDISCLAIMER\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\nOTHER DEALINGS IN THE FONT SOFTWARE.\n"
  },
  {
    "path": "client/static/fonts/Ubuntu_Mono/UFL.txt",
    "content": "-------------------------------\nUBUNTU FONT LICENCE Version 1.0\n-------------------------------\n\nPREAMBLE\nThis licence allows the licensed fonts to be used, studied, modified and\nredistributed freely. The fonts, including any derivative works, can be\nbundled, embedded, and redistributed provided the terms of this licence\nare met. The fonts and derivatives, however, cannot be released under\nany other licence. The requirement for fonts to remain under this\nlicence does not require any document created using the fonts or their\nderivatives to be published under this licence, as long as the primary\npurpose of the document is not to be a vehicle for the distribution of\nthe fonts.\n\nDEFINITIONS\n\"Font Software\" refers to the set of files released by the Copyright\nHolder(s) under this licence and clearly marked as such. This may\ninclude source files, build scripts and documentation.\n\n\"Original Version\" refers to the collection of Font Software components\nas received under this licence.\n\n\"Modified Version\" refers to any derivative made by adding to, deleting,\nor substituting -- in part or in whole -- any of the components of the\nOriginal Version, by changing formats or by porting the Font Software to\na new environment.\n\n\"Copyright Holder(s)\" refers to all individuals and companies who have a\ncopyright ownership of the Font Software.\n\n\"Substantially Changed\" refers to Modified Versions which can be easily\nidentified as dissimilar to the Font Software by users of the Font\nSoftware comparing the Original Version with the Modified Version.\n\nTo \"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 and with or without charging\na redistribution fee), making available to the public, and in some\ncountries other activities as well.\n\nPERMISSION & CONDITIONS\nThis licence does not grant any rights under trademark law and all such\nrights are reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of the Font Software, to propagate the Font Software, subject to\nthe below conditions:\n\n1) Each copy of the Font Software must contain the above copyright\nnotice and this licence. These can be included either as stand-alone\ntext files, human-readable headers or in the appropriate machine-\nreadable metadata fields within text or binary files as long as those\nfields can be easily viewed by the user.\n\n2) The font name complies with the following:\n(a) The Original Version must retain its name, unmodified.\n(b) Modified Versions which are Substantially Changed must be renamed to\navoid use of the name of the Original Version or similar names entirely.\n(c) Modified Versions which are not Substantially Changed must be\nrenamed to both (i) retain the name of the Original Version and (ii) add\nadditional naming elements to distinguish the Modified Version from the\nOriginal Version. The name of such Modified Versions must be the name of\nthe Original Version, with \"derivative X\" where X represents the name of\nthe new work, appended to that name.\n\n3) The name(s) of the Copyright Holder(s) and any contributor to the\nFont Software shall not be used to promote, endorse or advertise any\nModified Version, except (i) as required by this licence, (ii) to\nacknowledge the contribution(s) of the Copyright Holder(s) or (iii) with\ntheir explicit written permission.\n\n4) The Font Software, modified or unmodified, in part or in whole, must\nbe distributed entirely under this licence, and must not be distributed\nunder any other licence. The requirement for fonts to remain under this\nlicence does not affect any document created using the Font Software,\nexcept any version of the Font Software extracted from a document\ncreated using the Font Software may only be distributed under this\nlicence.\n\nTERMINATION\nThis licence becomes null and void if any of the above conditions are\nnot met.\n\nDISCLAIMER\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nCOPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER\nDEALINGS IN THE FONT SOFTWARE.\n"
  },
  {
    "path": "client/static/libarchive/wasm-gen/libarchive.js",
    "content": "\nvar libarchive = (function () {\n  var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;\n  return (\n    function (libarchive) {\n      libarchive = libarchive || {};\n\n      var Module = typeof libarchive !== \"undefined\" ? libarchive : {}; var moduleOverrides = {}; var key; for (key in Module) { if (Module.hasOwnProperty(key)) { moduleOverrides[key] = Module[key] } } Module[\"arguments\"] = []; Module[\"thisProgram\"] = \"./this.program\"; Module[\"quit\"] = function (status, toThrow) { throw toThrow }; Module[\"preRun\"] = []; Module[\"postRun\"] = []; var ENVIRONMENT_IS_WEB = false; var ENVIRONMENT_IS_WORKER = false; var ENVIRONMENT_IS_NODE = false; var ENVIRONMENT_IS_SHELL = false; ENVIRONMENT_IS_WEB = typeof window === \"object\"; ENVIRONMENT_IS_WORKER = typeof importScripts === \"function\"; ENVIRONMENT_IS_NODE = typeof process === \"object\" && typeof require === \"function\" && !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_WORKER; ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER; var scriptDirectory = \"\"; function locateFile(path) { if (Module[\"locateFile\"]) { return Module[\"locateFile\"](path, scriptDirectory) } else { return scriptDirectory + path } } if (ENVIRONMENT_IS_NODE) { scriptDirectory = __dirname + \"/\"; var nodeFS; var nodePath; Module[\"read\"] = function shell_read(filename, binary) { var ret; if (!nodeFS) nodeFS = require(\"fs\"); if (!nodePath) nodePath = require(\"path\"); filename = nodePath[\"normalize\"](filename); ret = nodeFS[\"readFileSync\"](filename); return binary ? ret : ret.toString() }; Module[\"readBinary\"] = function readBinary(filename) { var ret = Module[\"read\"](filename, true); if (!ret.buffer) { ret = new Uint8Array(ret) } assert(ret.buffer); return ret }; if (process[\"argv\"].length > 1) { Module[\"thisProgram\"] = process[\"argv\"][1].replace(/\\\\/g, \"/\") } Module[\"arguments\"] = process[\"argv\"].slice(2); process[\"on\"](\"uncaughtException\", function (ex) { if (!(ex instanceof ExitStatus)) { throw ex } }); process[\"on\"](\"unhandledRejection\", abort); Module[\"quit\"] = function (status) { process[\"exit\"](status) }; Module[\"inspect\"] = function () { return \"[Emscripten Module object]\" } } else if (ENVIRONMENT_IS_SHELL) { if (typeof read != \"undefined\") { Module[\"read\"] = function shell_read(f) { return read(f) } } Module[\"readBinary\"] = function readBinary(f) { var data; if (typeof readbuffer === \"function\") { return new Uint8Array(readbuffer(f)) } data = read(f, \"binary\"); assert(typeof data === \"object\"); return data }; if (typeof scriptArgs != \"undefined\") { Module[\"arguments\"] = scriptArgs } else if (typeof arguments != \"undefined\") { Module[\"arguments\"] = arguments } if (typeof quit === \"function\") { Module[\"quit\"] = function (status) { quit(status) } } } else if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { if (ENVIRONMENT_IS_WORKER) { scriptDirectory = self.location.href } else if (document.currentScript) { scriptDirectory = document.currentScript.src } if (_scriptDir) { scriptDirectory = _scriptDir } if (scriptDirectory.indexOf(\"blob:\") !== 0) { scriptDirectory = scriptDirectory.substr(0, scriptDirectory.lastIndexOf(\"/\") + 1) } else { scriptDirectory = \"\" } Module[\"read\"] = function shell_read(url) { var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, false); xhr.send(null); return xhr.responseText }; if (ENVIRONMENT_IS_WORKER) { Module[\"readBinary\"] = function readBinary(url) { var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, false); xhr.responseType = \"arraybuffer\"; xhr.send(null); return new Uint8Array(xhr.response) } } Module[\"readAsync\"] = function readAsync(url, onload, onerror) { var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, true); xhr.responseType = \"arraybuffer\"; xhr.onload = function xhr_onload() { if (xhr.status == 200 || xhr.status == 0 && xhr.response) { onload(xhr.response); return } onerror() }; xhr.onerror = onerror; xhr.send(null) }; Module[\"setWindowTitle\"] = function (title) { document.title = title } } else { } var out = Module[\"print\"] || (typeof console !== \"undefined\" ? console.log.bind(console) : typeof print !== \"undefined\" ? print : null); var err = Module[\"printErr\"] || (typeof printErr !== \"undefined\" ? printErr : typeof console !== \"undefined\" && console.warn.bind(console) || out); for (key in moduleOverrides) { if (moduleOverrides.hasOwnProperty(key)) { Module[key] = moduleOverrides[key] } } moduleOverrides = undefined; function dynamicAlloc(size) { var ret = HEAP32[DYNAMICTOP_PTR >> 2]; var end = ret + size + 15 & -16; if (end <= _emscripten_get_heap_size()) { HEAP32[DYNAMICTOP_PTR >> 2] = end } else { var success = _emscripten_resize_heap(end); if (!success) return 0 } return ret } function getNativeTypeSize(type) { switch (type) { case \"i1\": case \"i8\": return 1; case \"i16\": return 2; case \"i32\": return 4; case \"i64\": return 8; case \"float\": return 4; case \"double\": return 8; default: { if (type[type.length - 1] === \"*\") { return 4 } else if (type[0] === \"i\") { var bits = parseInt(type.substr(1)); assert(bits % 8 === 0, \"getNativeTypeSize invalid bits \" + bits + \", type \" + type); return bits / 8 } else { return 0 } } } } var asm2wasmImports = { \"f64-rem\": function (x, y) { return x % y }, \"debugger\": function () { debugger } }; var functionPointers = new Array(0); var tempRet0 = 0; var setTempRet0 = function (value) { tempRet0 = value }; if (typeof WebAssembly !== \"object\") { err(\"no native wasm support detected\") } var wasmMemory; var wasmTable; var ABORT = false; var EXITSTATUS = 0; function assert(condition, text) { if (!condition) { abort(\"Assertion failed: \" + text) } } function getCFunc(ident) { var func = Module[\"_\" + ident]; assert(func, \"Cannot call unknown function \" + ident + \", make sure it is exported\"); return func } function ccall(ident, returnType, argTypes, args, opts) { var toC = { \"string\": function (str) { var ret = 0; if (str !== null && str !== undefined && str !== 0) { var len = (str.length << 2) + 1; ret = stackAlloc(len); stringToUTF8(str, ret, len) } return ret }, \"array\": function (arr) { var ret = stackAlloc(arr.length); writeArrayToMemory(arr, ret); return ret } }; function convertReturnValue(ret) { if (returnType === \"string\") return UTF8ToString(ret); if (returnType === \"boolean\") return Boolean(ret); return ret } var func = getCFunc(ident); var cArgs = []; var stack = 0; if (args) { for (var i = 0; i < args.length; i++) { var converter = toC[argTypes[i]]; if (converter) { if (stack === 0) stack = stackSave(); cArgs[i] = converter(args[i]) } else { cArgs[i] = args[i] } } } var ret = func.apply(null, cArgs); ret = convertReturnValue(ret); if (stack !== 0) stackRestore(stack); return ret } function cwrap(ident, returnType, argTypes, opts) { argTypes = argTypes || []; var numericArgs = argTypes.every(function (type) { return type === \"number\" }); var numericRet = returnType !== \"string\"; if (numericRet && numericArgs && !opts) { return getCFunc(ident) } return function () { return ccall(ident, returnType, argTypes, arguments, opts) } } function setValue(ptr, value, type, noSafe) { type = type || \"i8\"; if (type.charAt(type.length - 1) === \"*\") type = \"i32\"; switch (type) { case \"i1\": HEAP8[ptr >> 0] = value; break; case \"i8\": HEAP8[ptr >> 0] = value; break; case \"i16\": HEAP16[ptr >> 1] = value; break; case \"i32\": HEAP32[ptr >> 2] = value; break; case \"i64\": tempI64 = [value >>> 0, (tempDouble = value, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[ptr >> 2] = tempI64[0], HEAP32[ptr + 4 >> 2] = tempI64[1]; break; case \"float\": HEAPF32[ptr >> 2] = value; break; case \"double\": HEAPF64[ptr >> 3] = value; break; default: abort(\"invalid type for setValue: \" + type) } } var ALLOC_NORMAL = 0; var ALLOC_NONE = 3; function allocate(slab, types, allocator, ptr) { var zeroinit, size; if (typeof slab === \"number\") { zeroinit = true; size = slab } else { zeroinit = false; size = slab.length } var singleType = typeof types === \"string\" ? types : null; var ret; if (allocator == ALLOC_NONE) { ret = ptr } else { ret = [_malloc, stackAlloc, dynamicAlloc][allocator](Math.max(size, singleType ? 1 : types.length)) } if (zeroinit) { var stop; ptr = ret; assert((ret & 3) == 0); stop = ret + (size & ~3); for (; ptr < stop; ptr += 4) { HEAP32[ptr >> 2] = 0 } stop = ret + size; while (ptr < stop) { HEAP8[ptr++ >> 0] = 0 } return ret } if (singleType === \"i8\") { if (slab.subarray || slab.slice) { HEAPU8.set(slab, ret) } else { HEAPU8.set(new Uint8Array(slab), ret) } return ret } var i = 0, type, typeSize, previousType; while (i < size) { var curr = slab[i]; type = singleType || types[i]; if (type === 0) { i++; continue } if (type == \"i64\") type = \"i32\"; setValue(ret + i, curr, type); if (previousType !== type) { typeSize = getNativeTypeSize(type); previousType = type } i += typeSize } return ret } function getMemory(size) { if (!runtimeInitialized) return dynamicAlloc(size); return _malloc(size) } var UTF8Decoder = typeof TextDecoder !== \"undefined\" ? new TextDecoder(\"utf8\") : undefined; function UTF8ArrayToString(u8Array, idx, maxBytesToRead) { var endIdx = idx + maxBytesToRead; var endPtr = idx; while (u8Array[endPtr] && !(endPtr >= endIdx)) ++endPtr; if (endPtr - idx > 16 && u8Array.subarray && UTF8Decoder) { return UTF8Decoder.decode(u8Array.subarray(idx, endPtr)) } else { var str = \"\"; while (idx < endPtr) { var u0 = u8Array[idx++]; if (!(u0 & 128)) { str += String.fromCharCode(u0); continue } var u1 = u8Array[idx++] & 63; if ((u0 & 224) == 192) { str += String.fromCharCode((u0 & 31) << 6 | u1); continue } var u2 = u8Array[idx++] & 63; if ((u0 & 240) == 224) { u0 = (u0 & 15) << 12 | u1 << 6 | u2 } else { u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | u8Array[idx++] & 63 } if (u0 < 65536) { str += String.fromCharCode(u0) } else { var ch = u0 - 65536; str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023) } } } return str } function UTF8ToString(ptr, maxBytesToRead) { return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : \"\" } function stringToUTF8Array(str, outU8Array, outIdx, maxBytesToWrite) { if (!(maxBytesToWrite > 0)) return 0; var startIdx = outIdx; var endIdx = outIdx + maxBytesToWrite - 1; for (var i = 0; i < str.length; ++i) { var u = str.charCodeAt(i); if (u >= 55296 && u <= 57343) { var u1 = str.charCodeAt(++i); u = 65536 + ((u & 1023) << 10) | u1 & 1023 } if (u <= 127) { if (outIdx >= endIdx) break; outU8Array[outIdx++] = u } else if (u <= 2047) { if (outIdx + 1 >= endIdx) break; outU8Array[outIdx++] = 192 | u >> 6; outU8Array[outIdx++] = 128 | u & 63 } else if (u <= 65535) { if (outIdx + 2 >= endIdx) break; outU8Array[outIdx++] = 224 | u >> 12; outU8Array[outIdx++] = 128 | u >> 6 & 63; outU8Array[outIdx++] = 128 | u & 63 } else { if (outIdx + 3 >= endIdx) break; outU8Array[outIdx++] = 240 | u >> 18; outU8Array[outIdx++] = 128 | u >> 12 & 63; outU8Array[outIdx++] = 128 | u >> 6 & 63; outU8Array[outIdx++] = 128 | u & 63 } } outU8Array[outIdx] = 0; return outIdx - startIdx } function stringToUTF8(str, outPtr, maxBytesToWrite) { return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite) } function lengthBytesUTF8(str) { var len = 0; for (var i = 0; i < str.length; ++i) { var u = str.charCodeAt(i); if (u >= 55296 && u <= 57343) u = 65536 + ((u & 1023) << 10) | str.charCodeAt(++i) & 1023; if (u <= 127) ++len; else if (u <= 2047) len += 2; else if (u <= 65535) len += 3; else len += 4 } return len } var UTF16Decoder = typeof TextDecoder !== \"undefined\" ? new TextDecoder(\"utf-16le\") : undefined; function writeArrayToMemory(array, buffer) { HEAP8.set(array, buffer) } function writeAsciiToMemory(str, buffer, dontAddNull) { for (var i = 0; i < str.length; ++i) { HEAP8[buffer++ >> 0] = str.charCodeAt(i) } if (!dontAddNull) HEAP8[buffer >> 0] = 0 } function demangle(func) { return func } function demangleAll(text) { var regex = /__Z[\\w\\d_]+/g; return text.replace(regex, function (x) { var y = demangle(x); return x === y ? x : y + \" [\" + x + \"]\" }) } function jsStackTrace() { var err = new Error; if (!err.stack) { try { throw new Error(0) } catch (e) { err = e } if (!err.stack) { return \"(no stack trace available)\" } } return err.stack.toString() } function stackTrace() { var js = jsStackTrace(); if (Module[\"extraStackTrace\"]) js += \"\\n\" + Module[\"extraStackTrace\"](); return demangleAll(js) } var WASM_PAGE_SIZE = 65536; function alignUp(x, multiple) { if (x % multiple > 0) { x += multiple - x % multiple } return x } var buffer, HEAP8, HEAPU8, HEAP16, HEAPU16, HEAP32, HEAPU32, HEAPF32, HEAPF64; function updateGlobalBufferViews() { Module[\"HEAP8\"] = HEAP8 = new Int8Array(buffer); Module[\"HEAP16\"] = HEAP16 = new Int16Array(buffer); Module[\"HEAP32\"] = HEAP32 = new Int32Array(buffer); Module[\"HEAPU8\"] = HEAPU8 = new Uint8Array(buffer); Module[\"HEAPU16\"] = HEAPU16 = new Uint16Array(buffer); Module[\"HEAPU32\"] = HEAPU32 = new Uint32Array(buffer); Module[\"HEAPF32\"] = HEAPF32 = new Float32Array(buffer); Module[\"HEAPF64\"] = HEAPF64 = new Float64Array(buffer) } var DYNAMIC_BASE = 5520464, DYNAMICTOP_PTR = 277552; var TOTAL_STACK = 5242880; var INITIAL_TOTAL_MEMORY = Module[\"TOTAL_MEMORY\"] || 16777216; if (INITIAL_TOTAL_MEMORY < TOTAL_STACK) err(\"TOTAL_MEMORY should be larger than TOTAL_STACK, was \" + INITIAL_TOTAL_MEMORY + \"! (TOTAL_STACK=\" + TOTAL_STACK + \")\"); if (Module[\"buffer\"]) { buffer = Module[\"buffer\"] } else { if (typeof WebAssembly === \"object\" && typeof WebAssembly.Memory === \"function\") { wasmMemory = new WebAssembly.Memory({ \"initial\": INITIAL_TOTAL_MEMORY / WASM_PAGE_SIZE }); buffer = wasmMemory.buffer } else { buffer = new ArrayBuffer(INITIAL_TOTAL_MEMORY) } } updateGlobalBufferViews(); HEAP32[DYNAMICTOP_PTR >> 2] = DYNAMIC_BASE; function callRuntimeCallbacks(callbacks) { while (callbacks.length > 0) { var callback = callbacks.shift(); if (typeof callback == \"function\") { callback(); continue } var func = callback.func; if (typeof func === \"number\") { if (callback.arg === undefined) { Module[\"dynCall_v\"](func) } else { Module[\"dynCall_vi\"](func, callback.arg) } } else { func(callback.arg === undefined ? null : callback.arg) } } } var __ATPRERUN__ = []; var __ATINIT__ = []; var __ATMAIN__ = []; var __ATPOSTRUN__ = []; var runtimeInitialized = false; var runtimeExited = false; function preRun() { if (Module[\"preRun\"]) { if (typeof Module[\"preRun\"] == \"function\") Module[\"preRun\"] = [Module[\"preRun\"]]; while (Module[\"preRun\"].length) { addOnPreRun(Module[\"preRun\"].shift()) } } callRuntimeCallbacks(__ATPRERUN__) } function ensureInitRuntime() { if (runtimeInitialized) return; runtimeInitialized = true; if (!Module[\"noFSInit\"] && !FS.init.initialized) FS.init(); TTY.init(); PIPEFS.root = FS.mount(PIPEFS, {}, null); callRuntimeCallbacks(__ATINIT__) } function preMain() { FS.ignorePermissions = false; callRuntimeCallbacks(__ATMAIN__) } function exitRuntime() { runtimeExited = true } function postRun() { if (Module[\"postRun\"]) { if (typeof Module[\"postRun\"] == \"function\") Module[\"postRun\"] = [Module[\"postRun\"]]; while (Module[\"postRun\"].length) { addOnPostRun(Module[\"postRun\"].shift()) } } callRuntimeCallbacks(__ATPOSTRUN__) } function addOnPreRun(cb) { __ATPRERUN__.unshift(cb) } function addOnPostRun(cb) { __ATPOSTRUN__.unshift(cb) } var Math_abs = Math.abs; var Math_ceil = Math.ceil; var Math_floor = Math.floor; var Math_min = Math.min; var runDependencies = 0; var runDependencyWatcher = null; var dependenciesFulfilled = null; function getUniqueRunDependency(id) { return id } function addRunDependency(id) { runDependencies++; if (Module[\"monitorRunDependencies\"]) { Module[\"monitorRunDependencies\"](runDependencies) } } function removeRunDependency(id) { runDependencies--; if (Module[\"monitorRunDependencies\"]) { Module[\"monitorRunDependencies\"](runDependencies) } if (runDependencies == 0) { if (runDependencyWatcher !== null) { clearInterval(runDependencyWatcher); runDependencyWatcher = null } if (dependenciesFulfilled) { var callback = dependenciesFulfilled; dependenciesFulfilled = null; callback() } } } Module[\"preloadedImages\"] = {}; Module[\"preloadedAudios\"] = {}; var dataURIPrefix = \"data:application/octet-stream;base64,\"; function isDataURI(filename) { return String.prototype.startsWith ? filename.startsWith(dataURIPrefix) : filename.indexOf(dataURIPrefix) === 0 } var wasmBinaryFile = \"libarchive.wasm\"; if (!isDataURI(wasmBinaryFile)) { wasmBinaryFile = locateFile(wasmBinaryFile) } function getBinary() { try { if (Module[\"wasmBinary\"]) { return new Uint8Array(Module[\"wasmBinary\"]) } if (Module[\"readBinary\"]) { return Module[\"readBinary\"](wasmBinaryFile) } else { throw \"both async and sync fetching of the wasm failed\" } } catch (err) { abort(err) } } function getBinaryPromise() { if (!Module[\"wasmBinary\"] && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) && typeof fetch === \"function\") { return fetch(wasmBinaryFile, { credentials: \"same-origin\" }).then(function (response) { if (!response[\"ok\"]) { throw \"failed to load wasm binary file at '\" + wasmBinaryFile + \"'\" } return response[\"arrayBuffer\"]() }).catch(function () { return getBinary() }) } return new Promise(function (resolve, reject) { resolve(getBinary()) }) } function createWasm(env) { var info = { \"env\": env, \"global\": { \"NaN\": NaN, Infinity: Infinity }, \"global.Math\": Math, \"asm2wasm\": asm2wasmImports }; function receiveInstance(instance, module) { var exports = instance.exports; Module[\"asm\"] = exports; removeRunDependency(\"wasm-instantiate\") } addRunDependency(\"wasm-instantiate\"); if (Module[\"instantiateWasm\"]) { try { return Module[\"instantiateWasm\"](info, receiveInstance) } catch (e) { err(\"Module.instantiateWasm callback failed with error: \" + e); return false } } function receiveInstantiatedSource(output) { receiveInstance(output[\"instance\"]) } function instantiateArrayBuffer(receiver) { getBinaryPromise().then(function (binary) { return WebAssembly.instantiate(binary, info) }).then(receiver, function (reason) { err(\"failed to asynchronously prepare wasm: \" + reason); abort(reason) }) } if (!Module[\"wasmBinary\"] && typeof WebAssembly.instantiateStreaming === \"function\" && !isDataURI(wasmBinaryFile) && typeof fetch === \"function\") { WebAssembly.instantiateStreaming(fetch(wasmBinaryFile, { credentials: \"same-origin\" }), info).then(receiveInstantiatedSource, function (reason) { err(\"wasm streaming compile failed: \" + reason); err(\"falling back to ArrayBuffer instantiation\"); instantiateArrayBuffer(receiveInstantiatedSource) }) } else { instantiateArrayBuffer(receiveInstantiatedSource) } return {} } Module[\"asm\"] = function (global, env, providedBuffer) { env[\"memory\"] = wasmMemory; env[\"table\"] = wasmTable = new WebAssembly.Table({ \"initial\": 507, \"maximum\": 507, \"element\": \"anyfunc\" }); env[\"__memory_base\"] = 1024; env[\"__table_base\"] = 0; var exports = createWasm(env); return exports }; __ATINIT__.push({ func: function () { ___emscripten_environ_constructor() } }); var ENV = {}; function ___buildEnvironment(environ) { var MAX_ENV_VALUES = 64; var TOTAL_ENV_SIZE = 1024; var poolPtr; var envPtr; if (!___buildEnvironment.called) { ___buildEnvironment.called = true; ENV[\"USER\"] = ENV[\"LOGNAME\"] = \"web_user\"; ENV[\"PATH\"] = \"/\"; ENV[\"PWD\"] = \"/\"; ENV[\"HOME\"] = \"/home/web_user\"; ENV[\"LANG\"] = \"C.UTF-8\"; ENV[\"_\"] = Module[\"thisProgram\"]; poolPtr = getMemory(TOTAL_ENV_SIZE); envPtr = getMemory(MAX_ENV_VALUES * 4); HEAP32[envPtr >> 2] = poolPtr; HEAP32[environ >> 2] = envPtr } else { envPtr = HEAP32[environ >> 2]; poolPtr = HEAP32[envPtr >> 2] } var strings = []; var totalSize = 0; for (var key in ENV) { if (typeof ENV[key] === \"string\") { var line = key + \"=\" + ENV[key]; strings.push(line); totalSize += line.length } } if (totalSize > TOTAL_ENV_SIZE) { throw new Error(\"Environment size exceeded TOTAL_ENV_SIZE!\") } var ptrSize = 4; for (var i = 0; i < strings.length; i++) { var line = strings[i]; writeAsciiToMemory(line, poolPtr); HEAP32[envPtr + i * ptrSize >> 2] = poolPtr; poolPtr += line.length + 1 } HEAP32[envPtr + strings.length * ptrSize >> 2] = 0 } var PATH = { splitPath: function (filename) { var splitPathRe = /^(\\/?|)([\\s\\S]*?)((?:\\.{1,2}|[^\\/]+?|)(\\.[^.\\/]*|))(?:[\\/]*)$/; return splitPathRe.exec(filename).slice(1) }, normalizeArray: function (parts, allowAboveRoot) { var up = 0; for (var i = parts.length - 1; i >= 0; i--) { var last = parts[i]; if (last === \".\") { parts.splice(i, 1) } else if (last === \"..\") { parts.splice(i, 1); up++ } else if (up) { parts.splice(i, 1); up-- } } if (allowAboveRoot) { for (; up; up--) { parts.unshift(\"..\") } } return parts }, normalize: function (path) { var isAbsolute = path.charAt(0) === \"/\", trailingSlash = path.substr(-1) === \"/\"; path = PATH.normalizeArray(path.split(\"/\").filter(function (p) { return !!p }), !isAbsolute).join(\"/\"); if (!path && !isAbsolute) { path = \".\" } if (path && trailingSlash) { path += \"/\" } return (isAbsolute ? \"/\" : \"\") + path }, dirname: function (path) { var result = PATH.splitPath(path), root = result[0], dir = result[1]; if (!root && !dir) { return \".\" } if (dir) { dir = dir.substr(0, dir.length - 1) } return root + dir }, basename: function (path) { if (path === \"/\") return \"/\"; var lastSlash = path.lastIndexOf(\"/\"); if (lastSlash === -1) return path; return path.substr(lastSlash + 1) }, extname: function (path) { return PATH.splitPath(path)[3] }, join: function () { var paths = Array.prototype.slice.call(arguments, 0); return PATH.normalize(paths.join(\"/\")) }, join2: function (l, r) { return PATH.normalize(l + \"/\" + r) } }; function ___setErrNo(value) { if (Module[\"___errno_location\"]) HEAP32[Module[\"___errno_location\"]() >> 2] = value; return value } var PATH_FS = { resolve: function () { var resolvedPath = \"\", resolvedAbsolute = false; for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { var path = i >= 0 ? arguments[i] : FS.cwd(); if (typeof path !== \"string\") { throw new TypeError(\"Arguments to path.resolve must be strings\") } else if (!path) { return \"\" } resolvedPath = path + \"/\" + resolvedPath; resolvedAbsolute = path.charAt(0) === \"/\" } resolvedPath = PATH.normalizeArray(resolvedPath.split(\"/\").filter(function (p) { return !!p }), !resolvedAbsolute).join(\"/\"); return (resolvedAbsolute ? \"/\" : \"\") + resolvedPath || \".\" }, relative: function (from, to) { from = PATH_FS.resolve(from).substr(1); to = PATH_FS.resolve(to).substr(1); function trim(arr) { var start = 0; for (; start < arr.length; start++) { if (arr[start] !== \"\") break } var end = arr.length - 1; for (; end >= 0; end--) { if (arr[end] !== \"\") break } if (start > end) return []; return arr.slice(start, end - start + 1) } var fromParts = trim(from.split(\"/\")); var toParts = trim(to.split(\"/\")); var length = Math.min(fromParts.length, toParts.length); var samePartsLength = length; for (var i = 0; i < length; i++) { if (fromParts[i] !== toParts[i]) { samePartsLength = i; break } } var outputParts = []; for (var i = samePartsLength; i < fromParts.length; i++) { outputParts.push(\"..\") } outputParts = outputParts.concat(toParts.slice(samePartsLength)); return outputParts.join(\"/\") } }; var TTY = { ttys: [], init: function () { }, shutdown: function () { }, register: function (dev, ops) { TTY.ttys[dev] = { input: [], output: [], ops: ops }; FS.registerDevice(dev, TTY.stream_ops) }, stream_ops: { open: function (stream) { var tty = TTY.ttys[stream.node.rdev]; if (!tty) { throw new FS.ErrnoError(19) } stream.tty = tty; stream.seekable = false }, close: function (stream) { stream.tty.ops.flush(stream.tty) }, flush: function (stream) { stream.tty.ops.flush(stream.tty) }, read: function (stream, buffer, offset, length, pos) { if (!stream.tty || !stream.tty.ops.get_char) { throw new FS.ErrnoError(6) } var bytesRead = 0; for (var i = 0; i < length; i++) { var result; try { result = stream.tty.ops.get_char(stream.tty) } catch (e) { throw new FS.ErrnoError(5) } if (result === undefined && bytesRead === 0) { throw new FS.ErrnoError(11) } if (result === null || result === undefined) break; bytesRead++; buffer[offset + i] = result } if (bytesRead) { stream.node.timestamp = Date.now() } return bytesRead }, write: function (stream, buffer, offset, length, pos) { if (!stream.tty || !stream.tty.ops.put_char) { throw new FS.ErrnoError(6) } try { for (var i = 0; i < length; i++) { stream.tty.ops.put_char(stream.tty, buffer[offset + i]) } } catch (e) { throw new FS.ErrnoError(5) } if (length) { stream.node.timestamp = Date.now() } return i } }, default_tty_ops: { get_char: function (tty) { if (!tty.input.length) { var result = null; if (ENVIRONMENT_IS_NODE) { var BUFSIZE = 256; var buf = new Buffer(BUFSIZE); var bytesRead = 0; var isPosixPlatform = process.platform != \"win32\"; var fd = process.stdin.fd; if (isPosixPlatform) { var usingDevice = false; try { fd = fs.openSync(\"/dev/stdin\", \"r\"); usingDevice = true } catch (e) { } } try { bytesRead = fs.readSync(fd, buf, 0, BUFSIZE, null) } catch (e) { if (e.toString().indexOf(\"EOF\") != -1) bytesRead = 0; else throw e } if (usingDevice) { fs.closeSync(fd) } if (bytesRead > 0) { result = buf.slice(0, bytesRead).toString(\"utf-8\") } else { result = null } } else if (typeof window != \"undefined\" && typeof window.prompt == \"function\") { result = window.prompt(\"Input: \"); if (result !== null) { result += \"\\n\" } } else if (typeof readline == \"function\") { result = readline(); if (result !== null) { result += \"\\n\" } } if (!result) { return null } tty.input = intArrayFromString(result, true) } return tty.input.shift() }, put_char: function (tty, val) { if (val === null || val === 10) { out(UTF8ArrayToString(tty.output, 0)); tty.output = [] } else { if (val != 0) tty.output.push(val) } }, flush: function (tty) { if (tty.output && tty.output.length > 0) { out(UTF8ArrayToString(tty.output, 0)); tty.output = [] } } }, default_tty1_ops: { put_char: function (tty, val) { if (val === null || val === 10) { err(UTF8ArrayToString(tty.output, 0)); tty.output = [] } else { if (val != 0) tty.output.push(val) } }, flush: function (tty) { if (tty.output && tty.output.length > 0) { err(UTF8ArrayToString(tty.output, 0)); tty.output = [] } } } }; var MEMFS = { ops_table: null, mount: function (mount) { return MEMFS.createNode(null, \"/\", 16384 | 511, 0) }, createNode: function (parent, name, mode, dev) { if (FS.isBlkdev(mode) || FS.isFIFO(mode)) { throw new FS.ErrnoError(1) } if (!MEMFS.ops_table) { MEMFS.ops_table = { dir: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr, lookup: MEMFS.node_ops.lookup, mknod: MEMFS.node_ops.mknod, rename: MEMFS.node_ops.rename, unlink: MEMFS.node_ops.unlink, rmdir: MEMFS.node_ops.rmdir, readdir: MEMFS.node_ops.readdir, symlink: MEMFS.node_ops.symlink }, stream: { llseek: MEMFS.stream_ops.llseek } }, file: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr }, stream: { llseek: MEMFS.stream_ops.llseek, read: MEMFS.stream_ops.read, write: MEMFS.stream_ops.write, allocate: MEMFS.stream_ops.allocate, mmap: MEMFS.stream_ops.mmap, msync: MEMFS.stream_ops.msync } }, link: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr, readlink: MEMFS.node_ops.readlink }, stream: {} }, chrdev: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr }, stream: FS.chrdev_stream_ops } } } var node = FS.createNode(parent, name, mode, dev); if (FS.isDir(node.mode)) { node.node_ops = MEMFS.ops_table.dir.node; node.stream_ops = MEMFS.ops_table.dir.stream; node.contents = {} } else if (FS.isFile(node.mode)) { node.node_ops = MEMFS.ops_table.file.node; node.stream_ops = MEMFS.ops_table.file.stream; node.usedBytes = 0; node.contents = null } else if (FS.isLink(node.mode)) { node.node_ops = MEMFS.ops_table.link.node; node.stream_ops = MEMFS.ops_table.link.stream } else if (FS.isChrdev(node.mode)) { node.node_ops = MEMFS.ops_table.chrdev.node; node.stream_ops = MEMFS.ops_table.chrdev.stream } node.timestamp = Date.now(); if (parent) { parent.contents[name] = node } return node }, getFileDataAsRegularArray: function (node) { if (node.contents && node.contents.subarray) { var arr = []; for (var i = 0; i < node.usedBytes; ++i)arr.push(node.contents[i]); return arr } return node.contents }, getFileDataAsTypedArray: function (node) { if (!node.contents) return new Uint8Array; if (node.contents.subarray) return node.contents.subarray(0, node.usedBytes); return new Uint8Array(node.contents) }, expandFileStorage: function (node, newCapacity) { var prevCapacity = node.contents ? node.contents.length : 0; if (prevCapacity >= newCapacity) return; var CAPACITY_DOUBLING_MAX = 1024 * 1024; newCapacity = Math.max(newCapacity, prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2 : 1.125) | 0); if (prevCapacity != 0) newCapacity = Math.max(newCapacity, 256); var oldContents = node.contents; node.contents = new Uint8Array(newCapacity); if (node.usedBytes > 0) node.contents.set(oldContents.subarray(0, node.usedBytes), 0); return }, resizeFileStorage: function (node, newSize) { if (node.usedBytes == newSize) return; if (newSize == 0) { node.contents = null; node.usedBytes = 0; return } if (!node.contents || node.contents.subarray) { var oldContents = node.contents; node.contents = new Uint8Array(new ArrayBuffer(newSize)); if (oldContents) { node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes))) } node.usedBytes = newSize; return } if (!node.contents) node.contents = []; if (node.contents.length > newSize) node.contents.length = newSize; else while (node.contents.length < newSize) node.contents.push(0); node.usedBytes = newSize }, node_ops: { getattr: function (node) { var attr = {}; attr.dev = FS.isChrdev(node.mode) ? node.id : 1; attr.ino = node.id; attr.mode = node.mode; attr.nlink = 1; attr.uid = 0; attr.gid = 0; attr.rdev = node.rdev; if (FS.isDir(node.mode)) { attr.size = 4096 } else if (FS.isFile(node.mode)) { attr.size = node.usedBytes } else if (FS.isLink(node.mode)) { attr.size = node.link.length } else { attr.size = 0 } attr.atime = new Date(node.timestamp); attr.mtime = new Date(node.timestamp); attr.ctime = new Date(node.timestamp); attr.blksize = 4096; attr.blocks = Math.ceil(attr.size / attr.blksize); return attr }, setattr: function (node, attr) { if (attr.mode !== undefined) { node.mode = attr.mode } if (attr.timestamp !== undefined) { node.timestamp = attr.timestamp } if (attr.size !== undefined) { MEMFS.resizeFileStorage(node, attr.size) } }, lookup: function (parent, name) { throw FS.genericErrors[2] }, mknod: function (parent, name, mode, dev) { return MEMFS.createNode(parent, name, mode, dev) }, rename: function (old_node, new_dir, new_name) { if (FS.isDir(old_node.mode)) { var new_node; try { new_node = FS.lookupNode(new_dir, new_name) } catch (e) { } if (new_node) { for (var i in new_node.contents) { throw new FS.ErrnoError(39) } } } delete old_node.parent.contents[old_node.name]; old_node.name = new_name; new_dir.contents[new_name] = old_node; old_node.parent = new_dir }, unlink: function (parent, name) { delete parent.contents[name] }, rmdir: function (parent, name) { var node = FS.lookupNode(parent, name); for (var i in node.contents) { throw new FS.ErrnoError(39) } delete parent.contents[name] }, readdir: function (node) { var entries = [\".\", \"..\"]; for (var key in node.contents) { if (!node.contents.hasOwnProperty(key)) { continue } entries.push(key) } return entries }, symlink: function (parent, newname, oldpath) { var node = MEMFS.createNode(parent, newname, 511 | 40960, 0); node.link = oldpath; return node }, readlink: function (node) { if (!FS.isLink(node.mode)) { throw new FS.ErrnoError(22) } return node.link } }, stream_ops: { read: function (stream, buffer, offset, length, position) { var contents = stream.node.contents; if (position >= stream.node.usedBytes) return 0; var size = Math.min(stream.node.usedBytes - position, length); if (size > 8 && contents.subarray) { buffer.set(contents.subarray(position, position + size), offset) } else { for (var i = 0; i < size; i++)buffer[offset + i] = contents[position + i] } return size }, write: function (stream, buffer, offset, length, position, canOwn) { canOwn = false; if (!length) return 0; var node = stream.node; node.timestamp = Date.now(); if (buffer.subarray && (!node.contents || node.contents.subarray)) { if (canOwn) { node.contents = buffer.subarray(offset, offset + length); node.usedBytes = length; return length } else if (node.usedBytes === 0 && position === 0) { node.contents = new Uint8Array(buffer.subarray(offset, offset + length)); node.usedBytes = length; return length } else if (position + length <= node.usedBytes) { node.contents.set(buffer.subarray(offset, offset + length), position); return length } } MEMFS.expandFileStorage(node, position + length); if (node.contents.subarray && buffer.subarray) node.contents.set(buffer.subarray(offset, offset + length), position); else { for (var i = 0; i < length; i++) { node.contents[position + i] = buffer[offset + i] } } node.usedBytes = Math.max(node.usedBytes, position + length); return length }, llseek: function (stream, offset, whence) { var position = offset; if (whence === 1) { position += stream.position } else if (whence === 2) { if (FS.isFile(stream.node.mode)) { position += stream.node.usedBytes } } if (position < 0) { throw new FS.ErrnoError(22) } return position }, allocate: function (stream, offset, length) { MEMFS.expandFileStorage(stream.node, offset + length); stream.node.usedBytes = Math.max(stream.node.usedBytes, offset + length) }, mmap: function (stream, buffer, offset, length, position, prot, flags) { if (!FS.isFile(stream.node.mode)) { throw new FS.ErrnoError(19) } var ptr; var allocated; var contents = stream.node.contents; if (!(flags & 2) && (contents.buffer === buffer || contents.buffer === buffer.buffer)) { allocated = false; ptr = contents.byteOffset } else { if (position > 0 || position + length < stream.node.usedBytes) { if (contents.subarray) { contents = contents.subarray(position, position + length) } else { contents = Array.prototype.slice.call(contents, position, position + length) } } allocated = true; ptr = _malloc(length); if (!ptr) { throw new FS.ErrnoError(12) } buffer.set(contents, ptr) } return { ptr: ptr, allocated: allocated } }, msync: function (stream, buffer, offset, length, mmapFlags) { if (!FS.isFile(stream.node.mode)) { throw new FS.ErrnoError(19) } if (mmapFlags & 2) { return 0 } var bytesWritten = MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false); return 0 } } }; var IDBFS = { dbs: {}, indexedDB: function () { if (typeof indexedDB !== \"undefined\") return indexedDB; var ret = null; if (typeof window === \"object\") ret = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; assert(ret, \"IDBFS used, but indexedDB not supported\"); return ret }, DB_VERSION: 21, DB_STORE_NAME: \"FILE_DATA\", mount: function (mount) { return MEMFS.mount.apply(null, arguments) }, syncfs: function (mount, populate, callback) { IDBFS.getLocalSet(mount, function (err, local) { if (err) return callback(err); IDBFS.getRemoteSet(mount, function (err, remote) { if (err) return callback(err); var src = populate ? remote : local; var dst = populate ? local : remote; IDBFS.reconcile(src, dst, callback) }) }) }, getDB: function (name, callback) { var db = IDBFS.dbs[name]; if (db) { return callback(null, db) } var req; try { req = IDBFS.indexedDB().open(name, IDBFS.DB_VERSION) } catch (e) { return callback(e) } if (!req) { return callback(\"Unable to connect to IndexedDB\") } req.onupgradeneeded = function (e) { var db = e.target.result; var transaction = e.target.transaction; var fileStore; if (db.objectStoreNames.contains(IDBFS.DB_STORE_NAME)) { fileStore = transaction.objectStore(IDBFS.DB_STORE_NAME) } else { fileStore = db.createObjectStore(IDBFS.DB_STORE_NAME) } if (!fileStore.indexNames.contains(\"timestamp\")) { fileStore.createIndex(\"timestamp\", \"timestamp\", { unique: false }) } }; req.onsuccess = function () { db = req.result; IDBFS.dbs[name] = db; callback(null, db) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, getLocalSet: function (mount, callback) { var entries = {}; function isRealDir(p) { return p !== \".\" && p !== \"..\" } function toAbsolute(root) { return function (p) { return PATH.join2(root, p) } } var check = FS.readdir(mount.mountpoint).filter(isRealDir).map(toAbsolute(mount.mountpoint)); while (check.length) { var path = check.pop(); var stat; try { stat = FS.stat(path) } catch (e) { return callback(e) } if (FS.isDir(stat.mode)) { check.push.apply(check, FS.readdir(path).filter(isRealDir).map(toAbsolute(path))) } entries[path] = { timestamp: stat.mtime } } return callback(null, { type: \"local\", entries: entries }) }, getRemoteSet: function (mount, callback) { var entries = {}; IDBFS.getDB(mount.mountpoint, function (err, db) { if (err) return callback(err); try { var transaction = db.transaction([IDBFS.DB_STORE_NAME], \"readonly\"); transaction.onerror = function (e) { callback(this.error); e.preventDefault() }; var store = transaction.objectStore(IDBFS.DB_STORE_NAME); var index = store.index(\"timestamp\"); index.openKeyCursor().onsuccess = function (event) { var cursor = event.target.result; if (!cursor) { return callback(null, { type: \"remote\", db: db, entries: entries }) } entries[cursor.primaryKey] = { timestamp: cursor.key }; cursor.continue() } } catch (e) { return callback(e) } }) }, loadLocalEntry: function (path, callback) { var stat, node; try { var lookup = FS.lookupPath(path); node = lookup.node; stat = FS.stat(path) } catch (e) { return callback(e) } if (FS.isDir(stat.mode)) { return callback(null, { timestamp: stat.mtime, mode: stat.mode }) } else if (FS.isFile(stat.mode)) { node.contents = MEMFS.getFileDataAsTypedArray(node); return callback(null, { timestamp: stat.mtime, mode: stat.mode, contents: node.contents }) } else { return callback(new Error(\"node type not supported\")) } }, storeLocalEntry: function (path, entry, callback) { try { if (FS.isDir(entry.mode)) { FS.mkdir(path, entry.mode) } else if (FS.isFile(entry.mode)) { FS.writeFile(path, entry.contents, { canOwn: true }) } else { return callback(new Error(\"node type not supported\")) } FS.chmod(path, entry.mode); FS.utime(path, entry.timestamp, entry.timestamp) } catch (e) { return callback(e) } callback(null) }, removeLocalEntry: function (path, callback) { try { var lookup = FS.lookupPath(path); var stat = FS.stat(path); if (FS.isDir(stat.mode)) { FS.rmdir(path) } else if (FS.isFile(stat.mode)) { FS.unlink(path) } } catch (e) { return callback(e) } callback(null) }, loadRemoteEntry: function (store, path, callback) { var req = store.get(path); req.onsuccess = function (event) { callback(null, event.target.result) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, storeRemoteEntry: function (store, path, entry, callback) { var req = store.put(entry, path); req.onsuccess = function () { callback(null) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, removeRemoteEntry: function (store, path, callback) { var req = store.delete(path); req.onsuccess = function () { callback(null) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, reconcile: function (src, dst, callback) { var total = 0; var create = []; Object.keys(src.entries).forEach(function (key) { var e = src.entries[key]; var e2 = dst.entries[key]; if (!e2 || e.timestamp > e2.timestamp) { create.push(key); total++ } }); var remove = []; Object.keys(dst.entries).forEach(function (key) { var e = dst.entries[key]; var e2 = src.entries[key]; if (!e2) { remove.push(key); total++ } }); if (!total) { return callback(null) } var errored = false; var completed = 0; var db = src.type === \"remote\" ? src.db : dst.db; var transaction = db.transaction([IDBFS.DB_STORE_NAME], \"readwrite\"); var store = transaction.objectStore(IDBFS.DB_STORE_NAME); function done(err) { if (err) { if (!done.errored) { done.errored = true; return callback(err) } return } if (++completed >= total) { return callback(null) } } transaction.onerror = function (e) { done(this.error); e.preventDefault() }; create.sort().forEach(function (path) { if (dst.type === \"local\") { IDBFS.loadRemoteEntry(store, path, function (err, entry) { if (err) return done(err); IDBFS.storeLocalEntry(path, entry, done) }) } else { IDBFS.loadLocalEntry(path, function (err, entry) { if (err) return done(err); IDBFS.storeRemoteEntry(store, path, entry, done) }) } }); remove.sort().reverse().forEach(function (path) { if (dst.type === \"local\") { IDBFS.removeLocalEntry(path, done) } else { IDBFS.removeRemoteEntry(store, path, done) } }) } }; var NODEFS = { isWindows: false, staticInit: function () { NODEFS.isWindows = !!process.platform.match(/^win/); var flags = process[\"binding\"](\"constants\"); if (flags[\"fs\"]) { flags = flags[\"fs\"] } NODEFS.flagsForNodeMap = { 1024: flags[\"O_APPEND\"], 64: flags[\"O_CREAT\"], 128: flags[\"O_EXCL\"], 0: flags[\"O_RDONLY\"], 2: flags[\"O_RDWR\"], 4096: flags[\"O_SYNC\"], 512: flags[\"O_TRUNC\"], 1: flags[\"O_WRONLY\"] } }, bufferFrom: function (arrayBuffer) { return Buffer.alloc ? Buffer.from(arrayBuffer) : new Buffer(arrayBuffer) }, mount: function (mount) { assert(ENVIRONMENT_IS_NODE); return NODEFS.createNode(null, \"/\", NODEFS.getMode(mount.opts.root), 0) }, createNode: function (parent, name, mode, dev) { if (!FS.isDir(mode) && !FS.isFile(mode) && !FS.isLink(mode)) { throw new FS.ErrnoError(22) } var node = FS.createNode(parent, name, mode); node.node_ops = NODEFS.node_ops; node.stream_ops = NODEFS.stream_ops; return node }, getMode: function (path) { var stat; try { stat = fs.lstatSync(path); if (NODEFS.isWindows) { stat.mode = stat.mode | (stat.mode & 292) >> 2 } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } return stat.mode }, realPath: function (node) { var parts = []; while (node.parent !== node) { parts.push(node.name); node = node.parent } parts.push(node.mount.opts.root); parts.reverse(); return PATH.join.apply(null, parts) }, flagsForNode: function (flags) { flags &= ~2097152; flags &= ~2048; flags &= ~32768; flags &= ~524288; var newFlags = 0; for (var k in NODEFS.flagsForNodeMap) { if (flags & k) { newFlags |= NODEFS.flagsForNodeMap[k]; flags ^= k } } if (!flags) { return newFlags } else { throw new FS.ErrnoError(22) } }, node_ops: { getattr: function (node) { var path = NODEFS.realPath(node); var stat; try { stat = fs.lstatSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } if (NODEFS.isWindows && !stat.blksize) { stat.blksize = 4096 } if (NODEFS.isWindows && !stat.blocks) { stat.blocks = (stat.size + stat.blksize - 1) / stat.blksize | 0 } return { dev: stat.dev, ino: stat.ino, mode: stat.mode, nlink: stat.nlink, uid: stat.uid, gid: stat.gid, rdev: stat.rdev, size: stat.size, atime: stat.atime, mtime: stat.mtime, ctime: stat.ctime, blksize: stat.blksize, blocks: stat.blocks } }, setattr: function (node, attr) { var path = NODEFS.realPath(node); try { if (attr.mode !== undefined) { fs.chmodSync(path, attr.mode); node.mode = attr.mode } if (attr.timestamp !== undefined) { var date = new Date(attr.timestamp); fs.utimesSync(path, date, date) } if (attr.size !== undefined) { fs.truncateSync(path, attr.size) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, lookup: function (parent, name) { var path = PATH.join2(NODEFS.realPath(parent), name); var mode = NODEFS.getMode(path); return NODEFS.createNode(parent, name, mode) }, mknod: function (parent, name, mode, dev) { var node = NODEFS.createNode(parent, name, mode, dev); var path = NODEFS.realPath(node); try { if (FS.isDir(node.mode)) { fs.mkdirSync(path, node.mode) } else { fs.writeFileSync(path, \"\", { mode: node.mode }) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } return node }, rename: function (oldNode, newDir, newName) { var oldPath = NODEFS.realPath(oldNode); var newPath = PATH.join2(NODEFS.realPath(newDir), newName); try { fs.renameSync(oldPath, newPath) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, unlink: function (parent, name) { var path = PATH.join2(NODEFS.realPath(parent), name); try { fs.unlinkSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, rmdir: function (parent, name) { var path = PATH.join2(NODEFS.realPath(parent), name); try { fs.rmdirSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, readdir: function (node) { var path = NODEFS.realPath(node); try { return fs.readdirSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, symlink: function (parent, newName, oldPath) { var newPath = PATH.join2(NODEFS.realPath(parent), newName); try { fs.symlinkSync(oldPath, newPath) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, readlink: function (node) { var path = NODEFS.realPath(node); try { path = fs.readlinkSync(path); path = NODEJS_PATH.relative(NODEJS_PATH.resolve(node.mount.opts.root), path); return path } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } } }, stream_ops: { open: function (stream) { var path = NODEFS.realPath(stream.node); try { if (FS.isFile(stream.node.mode)) { stream.nfd = fs.openSync(path, NODEFS.flagsForNode(stream.flags)) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, close: function (stream) { try { if (FS.isFile(stream.node.mode) && stream.nfd) { fs.closeSync(stream.nfd) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, read: function (stream, buffer, offset, length, position) { if (length === 0) return 0; try { return fs.readSync(stream.nfd, NODEFS.bufferFrom(buffer.buffer), offset, length, position) } catch (e) { throw new FS.ErrnoError(-e.errno) } }, write: function (stream, buffer, offset, length, position) { try { return fs.writeSync(stream.nfd, NODEFS.bufferFrom(buffer.buffer), offset, length, position) } catch (e) { throw new FS.ErrnoError(-e.errno) } }, llseek: function (stream, offset, whence) { var position = offset; if (whence === 1) { position += stream.position } else if (whence === 2) { if (FS.isFile(stream.node.mode)) { try { var stat = fs.fstatSync(stream.nfd); position += stat.size } catch (e) { throw new FS.ErrnoError(-e.errno) } } } if (position < 0) { throw new FS.ErrnoError(22) } return position } } }; var WORKERFS = { DIR_MODE: 16895, FILE_MODE: 33279, reader: null, mount: function (mount) { assert(ENVIRONMENT_IS_WORKER); if (!WORKERFS.reader) WORKERFS.reader = new FileReaderSync; var root = WORKERFS.createNode(null, \"/\", WORKERFS.DIR_MODE, 0); var createdParents = {}; function ensureParent(path) { var parts = path.split(\"/\"); var parent = root; for (var i = 0; i < parts.length - 1; i++) { var curr = parts.slice(0, i + 1).join(\"/\"); if (!createdParents[curr]) { createdParents[curr] = WORKERFS.createNode(parent, parts[i], WORKERFS.DIR_MODE, 0) } parent = createdParents[curr] } return parent } function base(path) { var parts = path.split(\"/\"); return parts[parts.length - 1] } Array.prototype.forEach.call(mount.opts[\"files\"] || [], function (file) { WORKERFS.createNode(ensureParent(file.name), base(file.name), WORKERFS.FILE_MODE, 0, file, file.lastModifiedDate) }); (mount.opts[\"blobs\"] || []).forEach(function (obj) { WORKERFS.createNode(ensureParent(obj[\"name\"]), base(obj[\"name\"]), WORKERFS.FILE_MODE, 0, obj[\"data\"]) }); (mount.opts[\"packages\"] || []).forEach(function (pack) { pack[\"metadata\"].files.forEach(function (file) { var name = file.filename.substr(1); WORKERFS.createNode(ensureParent(name), base(name), WORKERFS.FILE_MODE, 0, pack[\"blob\"].slice(file.start, file.end)) }) }); return root }, createNode: function (parent, name, mode, dev, contents, mtime) { var node = FS.createNode(parent, name, mode); node.mode = mode; node.node_ops = WORKERFS.node_ops; node.stream_ops = WORKERFS.stream_ops; node.timestamp = (mtime || new Date).getTime(); assert(WORKERFS.FILE_MODE !== WORKERFS.DIR_MODE); if (mode === WORKERFS.FILE_MODE) { node.size = contents.size; node.contents = contents } else { node.size = 4096; node.contents = {} } if (parent) { parent.contents[name] = node } return node }, node_ops: { getattr: function (node) { return { dev: 1, ino: undefined, mode: node.mode, nlink: 1, uid: 0, gid: 0, rdev: undefined, size: node.size, atime: new Date(node.timestamp), mtime: new Date(node.timestamp), ctime: new Date(node.timestamp), blksize: 4096, blocks: Math.ceil(node.size / 4096) } }, setattr: function (node, attr) { if (attr.mode !== undefined) { node.mode = attr.mode } if (attr.timestamp !== undefined) { node.timestamp = attr.timestamp } }, lookup: function (parent, name) { throw new FS.ErrnoError(2) }, mknod: function (parent, name, mode, dev) { throw new FS.ErrnoError(1) }, rename: function (oldNode, newDir, newName) { throw new FS.ErrnoError(1) }, unlink: function (parent, name) { throw new FS.ErrnoError(1) }, rmdir: function (parent, name) { throw new FS.ErrnoError(1) }, readdir: function (node) { var entries = [\".\", \"..\"]; for (var key in node.contents) { if (!node.contents.hasOwnProperty(key)) { continue } entries.push(key) } return entries }, symlink: function (parent, newName, oldPath) { throw new FS.ErrnoError(1) }, readlink: function (node) { throw new FS.ErrnoError(1) } }, stream_ops: { read: function (stream, buffer, offset, length, position) { if (position >= stream.node.size) return 0; var chunk = stream.node.contents.slice(position, position + length); var ab = WORKERFS.reader.readAsArrayBuffer(chunk); buffer.set(new Uint8Array(ab), offset); return chunk.size }, write: function (stream, buffer, offset, length, position) { throw new FS.ErrnoError(5) }, llseek: function (stream, offset, whence) { var position = offset; if (whence === 1) { position += stream.position } else if (whence === 2) { if (FS.isFile(stream.node.mode)) { position += stream.node.size } } if (position < 0) { throw new FS.ErrnoError(22) } return position } } }; var FS = { root: null, mounts: [], devices: {}, streams: [], nextInode: 1, nameTable: null, currentPath: \"/\", initialized: false, ignorePermissions: true, trackingDelegate: {}, tracking: { openFlags: { READ: 1, WRITE: 2 } }, ErrnoError: null, genericErrors: {}, filesystems: null, syncFSRequests: 0, handleFSError: function (e) { if (!(e instanceof FS.ErrnoError)) throw e + \" : \" + stackTrace(); return ___setErrNo(e.errno) }, lookupPath: function (path, opts) { path = PATH_FS.resolve(FS.cwd(), path); opts = opts || {}; if (!path) return { path: \"\", node: null }; var defaults = { follow_mount: true, recurse_count: 0 }; for (var key in defaults) { if (opts[key] === undefined) { opts[key] = defaults[key] } } if (opts.recurse_count > 8) { throw new FS.ErrnoError(40) } var parts = PATH.normalizeArray(path.split(\"/\").filter(function (p) { return !!p }), false); var current = FS.root; var current_path = \"/\"; for (var i = 0; i < parts.length; i++) { var islast = i === parts.length - 1; if (islast && opts.parent) { break } current = FS.lookupNode(current, parts[i]); current_path = PATH.join2(current_path, parts[i]); if (FS.isMountpoint(current)) { if (!islast || islast && opts.follow_mount) { current = current.mounted.root } } if (!islast || opts.follow) { var count = 0; while (FS.isLink(current.mode)) { var link = FS.readlink(current_path); current_path = PATH_FS.resolve(PATH.dirname(current_path), link); var lookup = FS.lookupPath(current_path, { recurse_count: opts.recurse_count }); current = lookup.node; if (count++ > 40) { throw new FS.ErrnoError(40) } } } } return { path: current_path, node: current } }, getPath: function (node) { var path; while (true) { if (FS.isRoot(node)) { var mount = node.mount.mountpoint; if (!path) return mount; return mount[mount.length - 1] !== \"/\" ? mount + \"/\" + path : mount + path } path = path ? node.name + \"/\" + path : node.name; node = node.parent } }, hashName: function (parentid, name) { var hash = 0; for (var i = 0; i < name.length; i++) { hash = (hash << 5) - hash + name.charCodeAt(i) | 0 } return (parentid + hash >>> 0) % FS.nameTable.length }, hashAddNode: function (node) { var hash = FS.hashName(node.parent.id, node.name); node.name_next = FS.nameTable[hash]; FS.nameTable[hash] = node }, hashRemoveNode: function (node) { var hash = FS.hashName(node.parent.id, node.name); if (FS.nameTable[hash] === node) { FS.nameTable[hash] = node.name_next } else { var current = FS.nameTable[hash]; while (current) { if (current.name_next === node) { current.name_next = node.name_next; break } current = current.name_next } } }, lookupNode: function (parent, name) { var err = FS.mayLookup(parent); if (err) { throw new FS.ErrnoError(err, parent) } var hash = FS.hashName(parent.id, name); for (var node = FS.nameTable[hash]; node; node = node.name_next) { var nodeName = node.name; if (node.parent.id === parent.id && nodeName === name) { return node } } return FS.lookup(parent, name) }, createNode: function (parent, name, mode, rdev) { if (!FS.FSNode) { FS.FSNode = function (parent, name, mode, rdev) { if (!parent) { parent = this } this.parent = parent; this.mount = parent.mount; this.mounted = null; this.id = FS.nextInode++; this.name = name; this.mode = mode; this.node_ops = {}; this.stream_ops = {}; this.rdev = rdev }; FS.FSNode.prototype = {}; var readMode = 292 | 73; var writeMode = 146; Object.defineProperties(FS.FSNode.prototype, { read: { get: function () { return (this.mode & readMode) === readMode }, set: function (val) { val ? this.mode |= readMode : this.mode &= ~readMode } }, write: { get: function () { return (this.mode & writeMode) === writeMode }, set: function (val) { val ? this.mode |= writeMode : this.mode &= ~writeMode } }, isFolder: { get: function () { return FS.isDir(this.mode) } }, isDevice: { get: function () { return FS.isChrdev(this.mode) } } }) } var node = new FS.FSNode(parent, name, mode, rdev); FS.hashAddNode(node); return node }, destroyNode: function (node) { FS.hashRemoveNode(node) }, isRoot: function (node) { return node === node.parent }, isMountpoint: function (node) { return !!node.mounted }, isFile: function (mode) { return (mode & 61440) === 32768 }, isDir: function (mode) { return (mode & 61440) === 16384 }, isLink: function (mode) { return (mode & 61440) === 40960 }, isChrdev: function (mode) { return (mode & 61440) === 8192 }, isBlkdev: function (mode) { return (mode & 61440) === 24576 }, isFIFO: function (mode) { return (mode & 61440) === 4096 }, isSocket: function (mode) { return (mode & 49152) === 49152 }, flagModes: { \"r\": 0, \"rs\": 1052672, \"r+\": 2, \"w\": 577, \"wx\": 705, \"xw\": 705, \"w+\": 578, \"wx+\": 706, \"xw+\": 706, \"a\": 1089, \"ax\": 1217, \"xa\": 1217, \"a+\": 1090, \"ax+\": 1218, \"xa+\": 1218 }, modeStringToFlags: function (str) { var flags = FS.flagModes[str]; if (typeof flags === \"undefined\") { throw new Error(\"Unknown file open mode: \" + str) } return flags }, flagsToPermissionString: function (flag) { var perms = [\"r\", \"w\", \"rw\"][flag & 3]; if (flag & 512) { perms += \"w\" } return perms }, nodePermissions: function (node, perms) { if (FS.ignorePermissions) { return 0 } if (perms.indexOf(\"r\") !== -1 && !(node.mode & 292)) { return 13 } else if (perms.indexOf(\"w\") !== -1 && !(node.mode & 146)) { return 13 } else if (perms.indexOf(\"x\") !== -1 && !(node.mode & 73)) { return 13 } return 0 }, mayLookup: function (dir) { var err = FS.nodePermissions(dir, \"x\"); if (err) return err; if (!dir.node_ops.lookup) return 13; return 0 }, mayCreate: function (dir, name) { try { var node = FS.lookupNode(dir, name); return 17 } catch (e) { } return FS.nodePermissions(dir, \"wx\") }, mayDelete: function (dir, name, isdir) { var node; try { node = FS.lookupNode(dir, name) } catch (e) { return e.errno } var err = FS.nodePermissions(dir, \"wx\"); if (err) { return err } if (isdir) { if (!FS.isDir(node.mode)) { return 20 } if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) { return 16 } } else { if (FS.isDir(node.mode)) { return 21 } } return 0 }, mayOpen: function (node, flags) { if (!node) { return 2 } if (FS.isLink(node.mode)) { return 40 } else if (FS.isDir(node.mode)) { if (FS.flagsToPermissionString(flags) !== \"r\" || flags & 512) { return 21 } } return FS.nodePermissions(node, FS.flagsToPermissionString(flags)) }, MAX_OPEN_FDS: 4096, nextfd: function (fd_start, fd_end) { fd_start = fd_start || 0; fd_end = fd_end || FS.MAX_OPEN_FDS; for (var fd = fd_start; fd <= fd_end; fd++) { if (!FS.streams[fd]) { return fd } } throw new FS.ErrnoError(24) }, getStream: function (fd) { return FS.streams[fd] }, createStream: function (stream, fd_start, fd_end) { if (!FS.FSStream) { FS.FSStream = function () { }; FS.FSStream.prototype = {}; Object.defineProperties(FS.FSStream.prototype, { object: { get: function () { return this.node }, set: function (val) { this.node = val } }, isRead: { get: function () { return (this.flags & 2097155) !== 1 } }, isWrite: { get: function () { return (this.flags & 2097155) !== 0 } }, isAppend: { get: function () { return this.flags & 1024 } } }) } var newStream = new FS.FSStream; for (var p in stream) { newStream[p] = stream[p] } stream = newStream; var fd = FS.nextfd(fd_start, fd_end); stream.fd = fd; FS.streams[fd] = stream; return stream }, closeStream: function (fd) { FS.streams[fd] = null }, chrdev_stream_ops: { open: function (stream) { var device = FS.getDevice(stream.node.rdev); stream.stream_ops = device.stream_ops; if (stream.stream_ops.open) { stream.stream_ops.open(stream) } }, llseek: function () { throw new FS.ErrnoError(29) } }, major: function (dev) { return dev >> 8 }, minor: function (dev) { return dev & 255 }, makedev: function (ma, mi) { return ma << 8 | mi }, registerDevice: function (dev, ops) { FS.devices[dev] = { stream_ops: ops } }, getDevice: function (dev) { return FS.devices[dev] }, getMounts: function (mount) { var mounts = []; var check = [mount]; while (check.length) { var m = check.pop(); mounts.push(m); check.push.apply(check, m.mounts) } return mounts }, syncfs: function (populate, callback) { if (typeof populate === \"function\") { callback = populate; populate = false } FS.syncFSRequests++; if (FS.syncFSRequests > 1) { console.log(\"warning: \" + FS.syncFSRequests + \" FS.syncfs operations in flight at once, probably just doing extra work\") } var mounts = FS.getMounts(FS.root.mount); var completed = 0; function doCallback(err) { FS.syncFSRequests--; return callback(err) } function done(err) { if (err) { if (!done.errored) { done.errored = true; return doCallback(err) } return } if (++completed >= mounts.length) { doCallback(null) } } mounts.forEach(function (mount) { if (!mount.type.syncfs) { return done(null) } mount.type.syncfs(mount, populate, done) }) }, mount: function (type, opts, mountpoint) { var root = mountpoint === \"/\"; var pseudo = !mountpoint; var node; if (root && FS.root) { throw new FS.ErrnoError(16) } else if (!root && !pseudo) { var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); mountpoint = lookup.path; node = lookup.node; if (FS.isMountpoint(node)) { throw new FS.ErrnoError(16) } if (!FS.isDir(node.mode)) { throw new FS.ErrnoError(20) } } var mount = { type: type, opts: opts, mountpoint: mountpoint, mounts: [] }; var mountRoot = type.mount(mount); mountRoot.mount = mount; mount.root = mountRoot; if (root) { FS.root = mountRoot } else if (node) { node.mounted = mount; if (node.mount) { node.mount.mounts.push(mount) } } return mountRoot }, unmount: function (mountpoint) { var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); if (!FS.isMountpoint(lookup.node)) { throw new FS.ErrnoError(22) } var node = lookup.node; var mount = node.mounted; var mounts = FS.getMounts(mount); Object.keys(FS.nameTable).forEach(function (hash) { var current = FS.nameTable[hash]; while (current) { var next = current.name_next; if (mounts.indexOf(current.mount) !== -1) { FS.destroyNode(current) } current = next } }); node.mounted = null; var idx = node.mount.mounts.indexOf(mount); node.mount.mounts.splice(idx, 1) }, lookup: function (parent, name) { return parent.node_ops.lookup(parent, name) }, mknod: function (path, mode, dev) { var lookup = FS.lookupPath(path, { parent: true }); var parent = lookup.node; var name = PATH.basename(path); if (!name || name === \".\" || name === \"..\") { throw new FS.ErrnoError(22) } var err = FS.mayCreate(parent, name); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.mknod) { throw new FS.ErrnoError(1) } return parent.node_ops.mknod(parent, name, mode, dev) }, create: function (path, mode) { mode = mode !== undefined ? mode : 438; mode &= 4095; mode |= 32768; return FS.mknod(path, mode, 0) }, mkdir: function (path, mode) { mode = mode !== undefined ? mode : 511; mode &= 511 | 512; mode |= 16384; return FS.mknod(path, mode, 0) }, mkdirTree: function (path, mode) { var dirs = path.split(\"/\"); var d = \"\"; for (var i = 0; i < dirs.length; ++i) { if (!dirs[i]) continue; d += \"/\" + dirs[i]; try { FS.mkdir(d, mode) } catch (e) { if (e.errno != 17) throw e } } }, mkdev: function (path, mode, dev) { if (typeof dev === \"undefined\") { dev = mode; mode = 438 } mode |= 8192; return FS.mknod(path, mode, dev) }, symlink: function (oldpath, newpath) { if (!PATH_FS.resolve(oldpath)) { throw new FS.ErrnoError(2) } var lookup = FS.lookupPath(newpath, { parent: true }); var parent = lookup.node; if (!parent) { throw new FS.ErrnoError(2) } var newname = PATH.basename(newpath); var err = FS.mayCreate(parent, newname); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.symlink) { throw new FS.ErrnoError(1) } return parent.node_ops.symlink(parent, newname, oldpath) }, rename: function (old_path, new_path) { var old_dirname = PATH.dirname(old_path); var new_dirname = PATH.dirname(new_path); var old_name = PATH.basename(old_path); var new_name = PATH.basename(new_path); var lookup, old_dir, new_dir; try { lookup = FS.lookupPath(old_path, { parent: true }); old_dir = lookup.node; lookup = FS.lookupPath(new_path, { parent: true }); new_dir = lookup.node } catch (e) { throw new FS.ErrnoError(16) } if (!old_dir || !new_dir) throw new FS.ErrnoError(2); if (old_dir.mount !== new_dir.mount) { throw new FS.ErrnoError(18) } var old_node = FS.lookupNode(old_dir, old_name); var relative = PATH_FS.relative(old_path, new_dirname); if (relative.charAt(0) !== \".\") { throw new FS.ErrnoError(22) } relative = PATH_FS.relative(new_path, old_dirname); if (relative.charAt(0) !== \".\") { throw new FS.ErrnoError(39) } var new_node; try { new_node = FS.lookupNode(new_dir, new_name) } catch (e) { } if (old_node === new_node) { return } var isdir = FS.isDir(old_node.mode); var err = FS.mayDelete(old_dir, old_name, isdir); if (err) { throw new FS.ErrnoError(err) } err = new_node ? FS.mayDelete(new_dir, new_name, isdir) : FS.mayCreate(new_dir, new_name); if (err) { throw new FS.ErrnoError(err) } if (!old_dir.node_ops.rename) { throw new FS.ErrnoError(1) } if (FS.isMountpoint(old_node) || new_node && FS.isMountpoint(new_node)) { throw new FS.ErrnoError(16) } if (new_dir !== old_dir) { err = FS.nodePermissions(old_dir, \"w\"); if (err) { throw new FS.ErrnoError(err) } } try { if (FS.trackingDelegate[\"willMovePath\"]) { FS.trackingDelegate[\"willMovePath\"](old_path, new_path) } } catch (e) { console.log(\"FS.trackingDelegate['willMovePath']('\" + old_path + \"', '\" + new_path + \"') threw an exception: \" + e.message) } FS.hashRemoveNode(old_node); try { old_dir.node_ops.rename(old_node, new_dir, new_name) } catch (e) { throw e } finally { FS.hashAddNode(old_node) } try { if (FS.trackingDelegate[\"onMovePath\"]) FS.trackingDelegate[\"onMovePath\"](old_path, new_path) } catch (e) { console.log(\"FS.trackingDelegate['onMovePath']('\" + old_path + \"', '\" + new_path + \"') threw an exception: \" + e.message) } }, rmdir: function (path) { var lookup = FS.lookupPath(path, { parent: true }); var parent = lookup.node; var name = PATH.basename(path); var node = FS.lookupNode(parent, name); var err = FS.mayDelete(parent, name, true); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.rmdir) { throw new FS.ErrnoError(1) } if (FS.isMountpoint(node)) { throw new FS.ErrnoError(16) } try { if (FS.trackingDelegate[\"willDeletePath\"]) { FS.trackingDelegate[\"willDeletePath\"](path) } } catch (e) { console.log(\"FS.trackingDelegate['willDeletePath']('\" + path + \"') threw an exception: \" + e.message) } parent.node_ops.rmdir(parent, name); FS.destroyNode(node); try { if (FS.trackingDelegate[\"onDeletePath\"]) FS.trackingDelegate[\"onDeletePath\"](path) } catch (e) { console.log(\"FS.trackingDelegate['onDeletePath']('\" + path + \"') threw an exception: \" + e.message) } }, readdir: function (path) { var lookup = FS.lookupPath(path, { follow: true }); var node = lookup.node; if (!node.node_ops.readdir) { throw new FS.ErrnoError(20) } return node.node_ops.readdir(node) }, unlink: function (path) { var lookup = FS.lookupPath(path, { parent: true }); var parent = lookup.node; var name = PATH.basename(path); var node = FS.lookupNode(parent, name); var err = FS.mayDelete(parent, name, false); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.unlink) { throw new FS.ErrnoError(1) } if (FS.isMountpoint(node)) { throw new FS.ErrnoError(16) } try { if (FS.trackingDelegate[\"willDeletePath\"]) { FS.trackingDelegate[\"willDeletePath\"](path) } } catch (e) { console.log(\"FS.trackingDelegate['willDeletePath']('\" + path + \"') threw an exception: \" + e.message) } parent.node_ops.unlink(parent, name); FS.destroyNode(node); try { if (FS.trackingDelegate[\"onDeletePath\"]) FS.trackingDelegate[\"onDeletePath\"](path) } catch (e) { console.log(\"FS.trackingDelegate['onDeletePath']('\" + path + \"') threw an exception: \" + e.message) } }, readlink: function (path) { var lookup = FS.lookupPath(path); var link = lookup.node; if (!link) { throw new FS.ErrnoError(2) } if (!link.node_ops.readlink) { throw new FS.ErrnoError(22) } return PATH_FS.resolve(FS.getPath(link.parent), link.node_ops.readlink(link)) }, stat: function (path, dontFollow) { var lookup = FS.lookupPath(path, { follow: !dontFollow }); var node = lookup.node; if (!node) { throw new FS.ErrnoError(2) } if (!node.node_ops.getattr) { throw new FS.ErrnoError(1) } return node.node_ops.getattr(node) }, lstat: function (path) { return FS.stat(path, true) }, chmod: function (path, mode, dontFollow) { var node; if (typeof path === \"string\") { var lookup = FS.lookupPath(path, { follow: !dontFollow }); node = lookup.node } else { node = path } if (!node.node_ops.setattr) { throw new FS.ErrnoError(1) } node.node_ops.setattr(node, { mode: mode & 4095 | node.mode & ~4095, timestamp: Date.now() }) }, lchmod: function (path, mode) { FS.chmod(path, mode, true) }, fchmod: function (fd, mode) { var stream = FS.getStream(fd); if (!stream) { throw new FS.ErrnoError(9) } FS.chmod(stream.node, mode) }, chown: function (path, uid, gid, dontFollow) { var node; if (typeof path === \"string\") { var lookup = FS.lookupPath(path, { follow: !dontFollow }); node = lookup.node } else { node = path } if (!node.node_ops.setattr) { throw new FS.ErrnoError(1) } node.node_ops.setattr(node, { timestamp: Date.now() }) }, lchown: function (path, uid, gid) { FS.chown(path, uid, gid, true) }, fchown: function (fd, uid, gid) { var stream = FS.getStream(fd); if (!stream) { throw new FS.ErrnoError(9) } FS.chown(stream.node, uid, gid) }, truncate: function (path, len) { if (len < 0) { throw new FS.ErrnoError(22) } var node; if (typeof path === \"string\") { var lookup = FS.lookupPath(path, { follow: true }); node = lookup.node } else { node = path } if (!node.node_ops.setattr) { throw new FS.ErrnoError(1) } if (FS.isDir(node.mode)) { throw new FS.ErrnoError(21) } if (!FS.isFile(node.mode)) { throw new FS.ErrnoError(22) } var err = FS.nodePermissions(node, \"w\"); if (err) { throw new FS.ErrnoError(err) } node.node_ops.setattr(node, { size: len, timestamp: Date.now() }) }, ftruncate: function (fd, len) { var stream = FS.getStream(fd); if (!stream) { throw new FS.ErrnoError(9) } if ((stream.flags & 2097155) === 0) { throw new FS.ErrnoError(22) } FS.truncate(stream.node, len) }, utime: function (path, atime, mtime) { var lookup = FS.lookupPath(path, { follow: true }); var node = lookup.node; node.node_ops.setattr(node, { timestamp: Math.max(atime, mtime) }) }, open: function (path, flags, mode, fd_start, fd_end) { if (path === \"\") { throw new FS.ErrnoError(2) } flags = typeof flags === \"string\" ? FS.modeStringToFlags(flags) : flags; mode = typeof mode === \"undefined\" ? 438 : mode; if (flags & 64) { mode = mode & 4095 | 32768 } else { mode = 0 } var node; if (typeof path === \"object\") { node = path } else { path = PATH.normalize(path); try { var lookup = FS.lookupPath(path, { follow: !(flags & 131072) }); node = lookup.node } catch (e) { } } var created = false; if (flags & 64) { if (node) { if (flags & 128) { throw new FS.ErrnoError(17) } } else { node = FS.mknod(path, mode, 0); created = true } } if (!node) { throw new FS.ErrnoError(2) } if (FS.isChrdev(node.mode)) { flags &= ~512 } if (flags & 65536 && !FS.isDir(node.mode)) { throw new FS.ErrnoError(20) } if (!created) { var err = FS.mayOpen(node, flags); if (err) { throw new FS.ErrnoError(err) } } if (flags & 512) { FS.truncate(node, 0) } flags &= ~(128 | 512); var stream = FS.createStream({ node: node, path: FS.getPath(node), flags: flags, seekable: true, position: 0, stream_ops: node.stream_ops, ungotten: [], error: false }, fd_start, fd_end); if (stream.stream_ops.open) { stream.stream_ops.open(stream) } if (Module[\"logReadFiles\"] && !(flags & 1)) { if (!FS.readFiles) FS.readFiles = {}; if (!(path in FS.readFiles)) { FS.readFiles[path] = 1; console.log(\"FS.trackingDelegate error on read file: \" + path) } } try { if (FS.trackingDelegate[\"onOpenFile\"]) { var trackingFlags = 0; if ((flags & 2097155) !== 1) { trackingFlags |= FS.tracking.openFlags.READ } if ((flags & 2097155) !== 0) { trackingFlags |= FS.tracking.openFlags.WRITE } FS.trackingDelegate[\"onOpenFile\"](path, trackingFlags) } } catch (e) { console.log(\"FS.trackingDelegate['onOpenFile']('\" + path + \"', flags) threw an exception: \" + e.message) } return stream }, close: function (stream) { if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if (stream.getdents) stream.getdents = null; try { if (stream.stream_ops.close) { stream.stream_ops.close(stream) } } catch (e) { throw e } finally { FS.closeStream(stream.fd) } stream.fd = null }, isClosed: function (stream) { return stream.fd === null }, llseek: function (stream, offset, whence) { if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if (!stream.seekable || !stream.stream_ops.llseek) { throw new FS.ErrnoError(29) } if (whence != 0 && whence != 1 && whence != 2) { throw new FS.ErrnoError(22) } stream.position = stream.stream_ops.llseek(stream, offset, whence); stream.ungotten = []; return stream.position }, read: function (stream, buffer, offset, length, position) { if (length < 0 || position < 0) { throw new FS.ErrnoError(22) } if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if ((stream.flags & 2097155) === 1) { throw new FS.ErrnoError(9) } if (FS.isDir(stream.node.mode)) { throw new FS.ErrnoError(21) } if (!stream.stream_ops.read) { throw new FS.ErrnoError(22) } var seeking = typeof position !== \"undefined\"; if (!seeking) { position = stream.position } else if (!stream.seekable) { throw new FS.ErrnoError(29) } var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position); if (!seeking) stream.position += bytesRead; return bytesRead }, write: function (stream, buffer, offset, length, position, canOwn) { if (length < 0 || position < 0) { throw new FS.ErrnoError(22) } if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if ((stream.flags & 2097155) === 0) { throw new FS.ErrnoError(9) } if (FS.isDir(stream.node.mode)) { throw new FS.ErrnoError(21) } if (!stream.stream_ops.write) { throw new FS.ErrnoError(22) } if (stream.flags & 1024) { FS.llseek(stream, 0, 2) } var seeking = typeof position !== \"undefined\"; if (!seeking) { position = stream.position } else if (!stream.seekable) { throw new FS.ErrnoError(29) } var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn); if (!seeking) stream.position += bytesWritten; try { if (stream.path && FS.trackingDelegate[\"onWriteToFile\"]) FS.trackingDelegate[\"onWriteToFile\"](stream.path) } catch (e) { console.log(\"FS.trackingDelegate['onWriteToFile']('\" + stream.path + \"') threw an exception: \" + e.message) } return bytesWritten }, allocate: function (stream, offset, length) { if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if (offset < 0 || length <= 0) { throw new FS.ErrnoError(22) } if ((stream.flags & 2097155) === 0) { throw new FS.ErrnoError(9) } if (!FS.isFile(stream.node.mode) && !FS.isDir(stream.node.mode)) { throw new FS.ErrnoError(19) } if (!stream.stream_ops.allocate) { throw new FS.ErrnoError(95) } stream.stream_ops.allocate(stream, offset, length) }, mmap: function (stream, buffer, offset, length, position, prot, flags) { if ((stream.flags & 2097155) === 1) { throw new FS.ErrnoError(13) } if (!stream.stream_ops.mmap) { throw new FS.ErrnoError(19) } return stream.stream_ops.mmap(stream, buffer, offset, length, position, prot, flags) }, msync: function (stream, buffer, offset, length, mmapFlags) { if (!stream || !stream.stream_ops.msync) { return 0 } return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags) }, munmap: function (stream) { return 0 }, ioctl: function (stream, cmd, arg) { if (!stream.stream_ops.ioctl) { throw new FS.ErrnoError(25) } return stream.stream_ops.ioctl(stream, cmd, arg) }, readFile: function (path, opts) { opts = opts || {}; opts.flags = opts.flags || \"r\"; opts.encoding = opts.encoding || \"binary\"; if (opts.encoding !== \"utf8\" && opts.encoding !== \"binary\") { throw new Error('Invalid encoding type \"' + opts.encoding + '\"') } var ret; var stream = FS.open(path, opts.flags); var stat = FS.stat(path); var length = stat.size; var buf = new Uint8Array(length); FS.read(stream, buf, 0, length, 0); if (opts.encoding === \"utf8\") { ret = UTF8ArrayToString(buf, 0) } else if (opts.encoding === \"binary\") { ret = buf } FS.close(stream); return ret }, writeFile: function (path, data, opts) { opts = opts || {}; opts.flags = opts.flags || \"w\"; var stream = FS.open(path, opts.flags, opts.mode); if (typeof data === \"string\") { var buf = new Uint8Array(lengthBytesUTF8(data) + 1); var actualNumBytes = stringToUTF8Array(data, buf, 0, buf.length); FS.write(stream, buf, 0, actualNumBytes, undefined, opts.canOwn) } else if (ArrayBuffer.isView(data)) { FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn) } else { throw new Error(\"Unsupported data type\") } FS.close(stream) }, cwd: function () { return FS.currentPath }, chdir: function (path) { var lookup = FS.lookupPath(path, { follow: true }); if (lookup.node === null) { throw new FS.ErrnoError(2) } if (!FS.isDir(lookup.node.mode)) { throw new FS.ErrnoError(20) } var err = FS.nodePermissions(lookup.node, \"x\"); if (err) { throw new FS.ErrnoError(err) } FS.currentPath = lookup.path }, createDefaultDirectories: function () { FS.mkdir(\"/tmp\"); FS.mkdir(\"/home\"); FS.mkdir(\"/home/web_user\") }, createDefaultDevices: function () { FS.mkdir(\"/dev\"); FS.registerDevice(FS.makedev(1, 3), { read: function () { return 0 }, write: function (stream, buffer, offset, length, pos) { return length } }); FS.mkdev(\"/dev/null\", FS.makedev(1, 3)); TTY.register(FS.makedev(5, 0), TTY.default_tty_ops); TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops); FS.mkdev(\"/dev/tty\", FS.makedev(5, 0)); FS.mkdev(\"/dev/tty1\", FS.makedev(6, 0)); var random_device; if (typeof crypto === \"object\" && typeof crypto[\"getRandomValues\"] === \"function\") { var randomBuffer = new Uint8Array(1); random_device = function () { crypto.getRandomValues(randomBuffer); return randomBuffer[0] } } else if (ENVIRONMENT_IS_NODE) { try { var crypto_module = require(\"crypto\"); random_device = function () { return crypto_module[\"randomBytes\"](1)[0] } } catch (e) { } } else { } if (!random_device) { random_device = function () { abort(\"random_device\") } } FS.createDevice(\"/dev\", \"random\", random_device); FS.createDevice(\"/dev\", \"urandom\", random_device); FS.mkdir(\"/dev/shm\"); FS.mkdir(\"/dev/shm/tmp\") }, createSpecialDirectories: function () { FS.mkdir(\"/proc\"); FS.mkdir(\"/proc/self\"); FS.mkdir(\"/proc/self/fd\"); FS.mount({ mount: function () { var node = FS.createNode(\"/proc/self\", \"fd\", 16384 | 511, 73); node.node_ops = { lookup: function (parent, name) { var fd = +name; var stream = FS.getStream(fd); if (!stream) throw new FS.ErrnoError(9); var ret = { parent: null, mount: { mountpoint: \"fake\" }, node_ops: { readlink: function () { return stream.path } } }; ret.parent = ret; return ret } }; return node } }, {}, \"/proc/self/fd\") }, createStandardStreams: function () { if (Module[\"stdin\"]) { FS.createDevice(\"/dev\", \"stdin\", Module[\"stdin\"]) } else { FS.symlink(\"/dev/tty\", \"/dev/stdin\") } if (Module[\"stdout\"]) { FS.createDevice(\"/dev\", \"stdout\", null, Module[\"stdout\"]) } else { FS.symlink(\"/dev/tty\", \"/dev/stdout\") } if (Module[\"stderr\"]) { FS.createDevice(\"/dev\", \"stderr\", null, Module[\"stderr\"]) } else { FS.symlink(\"/dev/tty1\", \"/dev/stderr\") } var stdin = FS.open(\"/dev/stdin\", \"r\"); var stdout = FS.open(\"/dev/stdout\", \"w\"); var stderr = FS.open(\"/dev/stderr\", \"w\") }, ensureErrnoError: function () { if (FS.ErrnoError) return; FS.ErrnoError = function ErrnoError(errno, node) { this.node = node; this.setErrno = function (errno) { this.errno = errno }; this.setErrno(errno); this.message = \"FS error\"; if (this.stack) Object.defineProperty(this, \"stack\", { value: (new Error).stack, writable: true }) }; FS.ErrnoError.prototype = new Error; FS.ErrnoError.prototype.constructor = FS.ErrnoError;[2].forEach(function (code) { FS.genericErrors[code] = new FS.ErrnoError(code); FS.genericErrors[code].stack = \"<generic error, no stack>\" }) }, staticInit: function () { FS.ensureErrnoError(); FS.nameTable = new Array(4096); FS.mount(MEMFS, {}, \"/\"); FS.createDefaultDirectories(); FS.createDefaultDevices(); FS.createSpecialDirectories(); FS.filesystems = { \"MEMFS\": MEMFS, \"IDBFS\": IDBFS, \"NODEFS\": NODEFS, \"WORKERFS\": WORKERFS } }, init: function (input, output, error) { FS.init.initialized = true; FS.ensureErrnoError(); Module[\"stdin\"] = input || Module[\"stdin\"]; Module[\"stdout\"] = output || Module[\"stdout\"]; Module[\"stderr\"] = error || Module[\"stderr\"]; FS.createStandardStreams() }, quit: function () { FS.init.initialized = false; var fflush = Module[\"_fflush\"]; if (fflush) fflush(0); for (var i = 0; i < FS.streams.length; i++) { var stream = FS.streams[i]; if (!stream) { continue } FS.close(stream) } }, getMode: function (canRead, canWrite) { var mode = 0; if (canRead) mode |= 292 | 73; if (canWrite) mode |= 146; return mode }, joinPath: function (parts, forceRelative) { var path = PATH.join.apply(null, parts); if (forceRelative && path[0] == \"/\") path = path.substr(1); return path }, absolutePath: function (relative, base) { return PATH_FS.resolve(base, relative) }, standardizePath: function (path) { return PATH.normalize(path) }, findObject: function (path, dontResolveLastLink) { var ret = FS.analyzePath(path, dontResolveLastLink); if (ret.exists) { return ret.object } else { ___setErrNo(ret.error); return null } }, analyzePath: function (path, dontResolveLastLink) { try { var lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); path = lookup.path } catch (e) { } var ret = { isRoot: false, exists: false, error: 0, name: null, path: null, object: null, parentExists: false, parentPath: null, parentObject: null }; try { var lookup = FS.lookupPath(path, { parent: true }); ret.parentExists = true; ret.parentPath = lookup.path; ret.parentObject = lookup.node; ret.name = PATH.basename(path); lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); ret.exists = true; ret.path = lookup.path; ret.object = lookup.node; ret.name = lookup.node.name; ret.isRoot = lookup.path === \"/\" } catch (e) { ret.error = e.errno } return ret }, createFolder: function (parent, name, canRead, canWrite) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); var mode = FS.getMode(canRead, canWrite); return FS.mkdir(path, mode) }, createPath: function (parent, path, canRead, canWrite) { parent = typeof parent === \"string\" ? parent : FS.getPath(parent); var parts = path.split(\"/\").reverse(); while (parts.length) { var part = parts.pop(); if (!part) continue; var current = PATH.join2(parent, part); try { FS.mkdir(current) } catch (e) { } parent = current } return current }, createFile: function (parent, name, properties, canRead, canWrite) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); var mode = FS.getMode(canRead, canWrite); return FS.create(path, mode) }, createDataFile: function (parent, name, data, canRead, canWrite, canOwn) { var path = name ? PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name) : parent; var mode = FS.getMode(canRead, canWrite); var node = FS.create(path, mode); if (data) { if (typeof data === \"string\") { var arr = new Array(data.length); for (var i = 0, len = data.length; i < len; ++i)arr[i] = data.charCodeAt(i); data = arr } FS.chmod(node, mode | 146); var stream = FS.open(node, \"w\"); FS.write(stream, data, 0, data.length, 0, canOwn); FS.close(stream); FS.chmod(node, mode) } return node }, createDevice: function (parent, name, input, output) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); var mode = FS.getMode(!!input, !!output); if (!FS.createDevice.major) FS.createDevice.major = 64; var dev = FS.makedev(FS.createDevice.major++, 0); FS.registerDevice(dev, { open: function (stream) { stream.seekable = false }, close: function (stream) { if (output && output.buffer && output.buffer.length) { output(10) } }, read: function (stream, buffer, offset, length, pos) { var bytesRead = 0; for (var i = 0; i < length; i++) { var result; try { result = input() } catch (e) { throw new FS.ErrnoError(5) } if (result === undefined && bytesRead === 0) { throw new FS.ErrnoError(11) } if (result === null || result === undefined) break; bytesRead++; buffer[offset + i] = result } if (bytesRead) { stream.node.timestamp = Date.now() } return bytesRead }, write: function (stream, buffer, offset, length, pos) { for (var i = 0; i < length; i++) { try { output(buffer[offset + i]) } catch (e) { throw new FS.ErrnoError(5) } } if (length) { stream.node.timestamp = Date.now() } return i } }); return FS.mkdev(path, mode, dev) }, createLink: function (parent, name, target, canRead, canWrite) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); return FS.symlink(target, path) }, forceLoadFile: function (obj) { if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; var success = true; if (typeof XMLHttpRequest !== \"undefined\") { throw new Error(\"Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.\") } else if (Module[\"read\"]) { try { obj.contents = intArrayFromString(Module[\"read\"](obj.url), true); obj.usedBytes = obj.contents.length } catch (e) { success = false } } else { throw new Error(\"Cannot load without read() or XMLHttpRequest.\") } if (!success) ___setErrNo(5); return success }, createLazyFile: function (parent, name, url, canRead, canWrite) { function LazyUint8Array() { this.lengthKnown = false; this.chunks = [] } LazyUint8Array.prototype.get = function LazyUint8Array_get(idx) { if (idx > this.length - 1 || idx < 0) { return undefined } var chunkOffset = idx % this.chunkSize; var chunkNum = idx / this.chunkSize | 0; return this.getter(chunkNum)[chunkOffset] }; LazyUint8Array.prototype.setDataGetter = function LazyUint8Array_setDataGetter(getter) { this.getter = getter }; LazyUint8Array.prototype.cacheLength = function LazyUint8Array_cacheLength() { var xhr = new XMLHttpRequest; xhr.open(\"HEAD\", url, false); xhr.send(null); if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error(\"Couldn't load \" + url + \". Status: \" + xhr.status); var datalength = Number(xhr.getResponseHeader(\"Content-length\")); var header; var hasByteServing = (header = xhr.getResponseHeader(\"Accept-Ranges\")) && header === \"bytes\"; var usesGzip = (header = xhr.getResponseHeader(\"Content-Encoding\")) && header === \"gzip\"; var chunkSize = 1024 * 1024; if (!hasByteServing) chunkSize = datalength; var doXHR = function (from, to) { if (from > to) throw new Error(\"invalid range (\" + from + \", \" + to + \") or no bytes requested!\"); if (to > datalength - 1) throw new Error(\"only \" + datalength + \" bytes available! programmer error!\"); var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, false); if (datalength !== chunkSize) xhr.setRequestHeader(\"Range\", \"bytes=\" + from + \"-\" + to); if (typeof Uint8Array != \"undefined\") xhr.responseType = \"arraybuffer\"; if (xhr.overrideMimeType) { xhr.overrideMimeType(\"text/plain; charset=x-user-defined\") } xhr.send(null); if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error(\"Couldn't load \" + url + \". Status: \" + xhr.status); if (xhr.response !== undefined) { return new Uint8Array(xhr.response || []) } else { return intArrayFromString(xhr.responseText || \"\", true) } }; var lazyArray = this; lazyArray.setDataGetter(function (chunkNum) { var start = chunkNum * chunkSize; var end = (chunkNum + 1) * chunkSize - 1; end = Math.min(end, datalength - 1); if (typeof lazyArray.chunks[chunkNum] === \"undefined\") { lazyArray.chunks[chunkNum] = doXHR(start, end) } if (typeof lazyArray.chunks[chunkNum] === \"undefined\") throw new Error(\"doXHR failed!\"); return lazyArray.chunks[chunkNum] }); if (usesGzip || !datalength) { chunkSize = datalength = 1; datalength = this.getter(0).length; chunkSize = datalength; console.log(\"LazyFiles on gzip forces download of the whole file when length is accessed\") } this._length = datalength; this._chunkSize = chunkSize; this.lengthKnown = true }; if (typeof XMLHttpRequest !== \"undefined\") { if (!ENVIRONMENT_IS_WORKER) throw \"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc\"; var lazyArray = new LazyUint8Array; Object.defineProperties(lazyArray, { length: { get: function () { if (!this.lengthKnown) { this.cacheLength() } return this._length } }, chunkSize: { get: function () { if (!this.lengthKnown) { this.cacheLength() } return this._chunkSize } } }); var properties = { isDevice: false, contents: lazyArray } } else { var properties = { isDevice: false, url: url } } var node = FS.createFile(parent, name, properties, canRead, canWrite); if (properties.contents) { node.contents = properties.contents } else if (properties.url) { node.contents = null; node.url = properties.url } Object.defineProperties(node, { usedBytes: { get: function () { return this.contents.length } } }); var stream_ops = {}; var keys = Object.keys(node.stream_ops); keys.forEach(function (key) { var fn = node.stream_ops[key]; stream_ops[key] = function forceLoadLazyFile() { if (!FS.forceLoadFile(node)) { throw new FS.ErrnoError(5) } return fn.apply(null, arguments) } }); stream_ops.read = function stream_ops_read(stream, buffer, offset, length, position) { if (!FS.forceLoadFile(node)) { throw new FS.ErrnoError(5) } var contents = stream.node.contents; if (position >= contents.length) return 0; var size = Math.min(contents.length - position, length); if (contents.slice) { for (var i = 0; i < size; i++) { buffer[offset + i] = contents[position + i] } } else { for (var i = 0; i < size; i++) { buffer[offset + i] = contents.get(position + i) } } return size }; node.stream_ops = stream_ops; return node }, createPreloadedFile: function (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) { Browser.init(); var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent; var dep = getUniqueRunDependency(\"cp \" + fullname); function processData(byteArray) { function finish(byteArray) { if (preFinish) preFinish(); if (!dontCreateFile) { FS.createDataFile(parent, name, byteArray, canRead, canWrite, canOwn) } if (onload) onload(); removeRunDependency(dep) } var handled = false; Module[\"preloadPlugins\"].forEach(function (plugin) { if (handled) return; if (plugin[\"canHandle\"](fullname)) { plugin[\"handle\"](byteArray, fullname, finish, function () { if (onerror) onerror(); removeRunDependency(dep) }); handled = true } }); if (!handled) finish(byteArray) } addRunDependency(dep); if (typeof url == \"string\") { Browser.asyncLoad(url, function (byteArray) { processData(byteArray) }, onerror) } else { processData(url) } }, indexedDB: function () { return window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB }, DB_NAME: function () { return \"EM_FS_\" + window.location.pathname }, DB_VERSION: 20, DB_STORE_NAME: \"FILE_DATA\", saveFilesToDB: function (paths, onload, onerror) { onload = onload || function () { }; onerror = onerror || function () { }; var indexedDB = FS.indexedDB(); try { var openRequest = indexedDB.open(FS.DB_NAME(), FS.DB_VERSION) } catch (e) { return onerror(e) } openRequest.onupgradeneeded = function openRequest_onupgradeneeded() { console.log(\"creating db\"); var db = openRequest.result; db.createObjectStore(FS.DB_STORE_NAME) }; openRequest.onsuccess = function openRequest_onsuccess() { var db = openRequest.result; var transaction = db.transaction([FS.DB_STORE_NAME], \"readwrite\"); var files = transaction.objectStore(FS.DB_STORE_NAME); var ok = 0, fail = 0, total = paths.length; function finish() { if (fail == 0) onload(); else onerror() } paths.forEach(function (path) { var putRequest = files.put(FS.analyzePath(path).object.contents, path); putRequest.onsuccess = function putRequest_onsuccess() { ok++; if (ok + fail == total) finish() }; putRequest.onerror = function putRequest_onerror() { fail++; if (ok + fail == total) finish() } }); transaction.onerror = onerror }; openRequest.onerror = onerror }, loadFilesFromDB: function (paths, onload, onerror) { onload = onload || function () { }; onerror = onerror || function () { }; var indexedDB = FS.indexedDB(); try { var openRequest = indexedDB.open(FS.DB_NAME(), FS.DB_VERSION) } catch (e) { return onerror(e) } openRequest.onupgradeneeded = onerror; openRequest.onsuccess = function openRequest_onsuccess() { var db = openRequest.result; try { var transaction = db.transaction([FS.DB_STORE_NAME], \"readonly\") } catch (e) { onerror(e); return } var files = transaction.objectStore(FS.DB_STORE_NAME); var ok = 0, fail = 0, total = paths.length; function finish() { if (fail == 0) onload(); else onerror() } paths.forEach(function (path) { var getRequest = files.get(path); getRequest.onsuccess = function getRequest_onsuccess() { if (FS.analyzePath(path).exists) { FS.unlink(path) } FS.createDataFile(PATH.dirname(path), PATH.basename(path), getRequest.result, true, true, true); ok++; if (ok + fail == total) finish() }; getRequest.onerror = function getRequest_onerror() { fail++; if (ok + fail == total) finish() } }); transaction.onerror = onerror }; openRequest.onerror = onerror } }; var SYSCALLS = { DEFAULT_POLLMASK: 5, mappings: {}, umask: 511, calculateAt: function (dirfd, path) { if (path[0] !== \"/\") { var dir; if (dirfd === -100) { dir = FS.cwd() } else { var dirstream = FS.getStream(dirfd); if (!dirstream) throw new FS.ErrnoError(9); dir = dirstream.path } path = PATH.join2(dir, path) } return path }, doStat: function (func, path, buf) { try { var stat = func(path) } catch (e) { if (e && e.node && PATH.normalize(path) !== PATH.normalize(FS.getPath(e.node))) { return -20 } throw e } HEAP32[buf >> 2] = stat.dev; HEAP32[buf + 4 >> 2] = 0; HEAP32[buf + 8 >> 2] = stat.ino; HEAP32[buf + 12 >> 2] = stat.mode; HEAP32[buf + 16 >> 2] = stat.nlink; HEAP32[buf + 20 >> 2] = stat.uid; HEAP32[buf + 24 >> 2] = stat.gid; HEAP32[buf + 28 >> 2] = stat.rdev; HEAP32[buf + 32 >> 2] = 0; tempI64 = [stat.size >>> 0, (tempDouble = stat.size, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[buf + 40 >> 2] = tempI64[0], HEAP32[buf + 44 >> 2] = tempI64[1]; HEAP32[buf + 48 >> 2] = 4096; HEAP32[buf + 52 >> 2] = stat.blocks; HEAP32[buf + 56 >> 2] = stat.atime.getTime() / 1e3 | 0; HEAP32[buf + 60 >> 2] = 0; HEAP32[buf + 64 >> 2] = stat.mtime.getTime() / 1e3 | 0; HEAP32[buf + 68 >> 2] = 0; HEAP32[buf + 72 >> 2] = stat.ctime.getTime() / 1e3 | 0; HEAP32[buf + 76 >> 2] = 0; tempI64 = [stat.ino >>> 0, (tempDouble = stat.ino, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[buf + 80 >> 2] = tempI64[0], HEAP32[buf + 84 >> 2] = tempI64[1]; return 0 }, doMsync: function (addr, stream, len, flags) { var buffer = new Uint8Array(HEAPU8.subarray(addr, addr + len)); FS.msync(stream, buffer, 0, len, flags) }, doMkdir: function (path, mode) { path = PATH.normalize(path); if (path[path.length - 1] === \"/\") path = path.substr(0, path.length - 1); FS.mkdir(path, mode, 0); return 0 }, doMknod: function (path, mode, dev) { switch (mode & 61440) { case 32768: case 8192: case 24576: case 4096: case 49152: break; default: return -22 }FS.mknod(path, mode, dev); return 0 }, doReadlink: function (path, buf, bufsize) { if (bufsize <= 0) return -22; var ret = FS.readlink(path); var len = Math.min(bufsize, lengthBytesUTF8(ret)); var endChar = HEAP8[buf + len]; stringToUTF8(ret, buf, bufsize + 1); HEAP8[buf + len] = endChar; return len }, doAccess: function (path, amode) { if (amode & ~7) { return -22 } var node; var lookup = FS.lookupPath(path, { follow: true }); node = lookup.node; var perms = \"\"; if (amode & 4) perms += \"r\"; if (amode & 2) perms += \"w\"; if (amode & 1) perms += \"x\"; if (perms && FS.nodePermissions(node, perms)) { return -13 } return 0 }, doDup: function (path, flags, suggestFD) { var suggest = FS.getStream(suggestFD); if (suggest) FS.close(suggest); return FS.open(path, flags, 0, suggestFD, suggestFD).fd }, doReadv: function (stream, iov, iovcnt, offset) { var ret = 0; for (var i = 0; i < iovcnt; i++) { var ptr = HEAP32[iov + i * 8 >> 2]; var len = HEAP32[iov + (i * 8 + 4) >> 2]; var curr = FS.read(stream, HEAP8, ptr, len, offset); if (curr < 0) return -1; ret += curr; if (curr < len) break } return ret }, doWritev: function (stream, iov, iovcnt, offset) { var ret = 0; for (var i = 0; i < iovcnt; i++) { var ptr = HEAP32[iov + i * 8 >> 2]; var len = HEAP32[iov + (i * 8 + 4) >> 2]; var curr = FS.write(stream, HEAP8, ptr, len, offset); if (curr < 0) return -1; ret += curr } return ret }, varargs: 0, get: function (varargs) { SYSCALLS.varargs += 4; var ret = HEAP32[SYSCALLS.varargs - 4 >> 2]; return ret }, getStr: function () { var ret = UTF8ToString(SYSCALLS.get()); return ret }, getStreamFromFD: function () { var stream = FS.getStream(SYSCALLS.get()); if (!stream) throw new FS.ErrnoError(9); return stream }, get64: function () { var low = SYSCALLS.get(), high = SYSCALLS.get(); return low }, getZero: function () { SYSCALLS.get() } }; function ___syscall140(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), offset_high = SYSCALLS.get(), offset_low = SYSCALLS.get(), result = SYSCALLS.get(), whence = SYSCALLS.get(); if (!(offset_high == -1 && offset_low < 0) && !(offset_high == 0 && offset_low >= 0)) { return -75 } var offset = offset_low; FS.llseek(stream, offset, whence); tempI64 = [stream.position >>> 0, (tempDouble = stream.position, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[result >> 2] = tempI64[0], HEAP32[result + 4 >> 2] = tempI64[1]; if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; return 0 } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall146(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), iov = SYSCALLS.get(), iovcnt = SYSCALLS.get(); return SYSCALLS.doWritev(stream, iov, iovcnt) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall168(which, varargs) { SYSCALLS.varargs = varargs; try { var fds = SYSCALLS.get(), nfds = SYSCALLS.get(), timeout = SYSCALLS.get(); var nonzero = 0; for (var i = 0; i < nfds; i++) { var pollfd = fds + 8 * i; var fd = HEAP32[pollfd >> 2]; var events = HEAP16[pollfd + 4 >> 1]; var mask = 32; var stream = FS.getStream(fd); if (stream) { mask = SYSCALLS.DEFAULT_POLLMASK; if (stream.stream_ops.poll) { mask = stream.stream_ops.poll(stream) } } mask &= events | 8 | 16; if (mask) nonzero++; HEAP16[pollfd + 6 >> 1] = mask } return nonzero } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall195(which, varargs) { SYSCALLS.varargs = varargs; try { var path = SYSCALLS.getStr(), buf = SYSCALLS.get(); return SYSCALLS.doStat(FS.stat, path, buf) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall196(which, varargs) { SYSCALLS.varargs = varargs; try { var path = SYSCALLS.getStr(), buf = SYSCALLS.get(); return SYSCALLS.doStat(FS.lstat, path, buf) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall197(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), buf = SYSCALLS.get(); return SYSCALLS.doStat(FS.stat, stream.path, buf) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall221(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), cmd = SYSCALLS.get(); switch (cmd) { case 0: { var arg = SYSCALLS.get(); if (arg < 0) { return -22 } var newStream; newStream = FS.open(stream.path, stream.flags, 0, arg); return newStream.fd } case 1: case 2: return 0; case 3: return stream.flags; case 4: { var arg = SYSCALLS.get(); stream.flags |= arg; return 0 } case 12: { var arg = SYSCALLS.get(); var offset = 0; HEAP16[arg + offset >> 1] = 2; return 0 } case 13: case 14: return 0; case 16: case 8: return -22; case 9: ___setErrNo(22); return -1; default: { return -22 } } } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall3(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), buf = SYSCALLS.get(), count = SYSCALLS.get(); return FS.read(stream, HEAP8, buf, count) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall4(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), buf = SYSCALLS.get(), count = SYSCALLS.get(); return FS.write(stream, HEAP8, buf, count) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall41(which, varargs) { SYSCALLS.varargs = varargs; try { var old = SYSCALLS.getStreamFromFD(); return FS.open(old.path, old.flags, 0).fd } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } var ERRNO_CODES = { EPERM: 1, ENOENT: 2, ESRCH: 3, EINTR: 4, EIO: 5, ENXIO: 6, E2BIG: 7, ENOEXEC: 8, EBADF: 9, ECHILD: 10, EAGAIN: 11, EWOULDBLOCK: 11, ENOMEM: 12, EACCES: 13, EFAULT: 14, ENOTBLK: 15, EBUSY: 16, EEXIST: 17, EXDEV: 18, ENODEV: 19, ENOTDIR: 20, EISDIR: 21, EINVAL: 22, ENFILE: 23, EMFILE: 24, ENOTTY: 25, ETXTBSY: 26, EFBIG: 27, ENOSPC: 28, ESPIPE: 29, EROFS: 30, EMLINK: 31, EPIPE: 32, EDOM: 33, ERANGE: 34, ENOMSG: 42, EIDRM: 43, ECHRNG: 44, EL2NSYNC: 45, EL3HLT: 46, EL3RST: 47, ELNRNG: 48, EUNATCH: 49, ENOCSI: 50, EL2HLT: 51, EDEADLK: 35, ENOLCK: 37, EBADE: 52, EBADR: 53, EXFULL: 54, ENOANO: 55, EBADRQC: 56, EBADSLT: 57, EDEADLOCK: 35, EBFONT: 59, ENOSTR: 60, ENODATA: 61, ETIME: 62, ENOSR: 63, ENONET: 64, ENOPKG: 65, EREMOTE: 66, ENOLINK: 67, EADV: 68, ESRMNT: 69, ECOMM: 70, EPROTO: 71, EMULTIHOP: 72, EDOTDOT: 73, EBADMSG: 74, ENOTUNIQ: 76, EBADFD: 77, EREMCHG: 78, ELIBACC: 79, ELIBBAD: 80, ELIBSCN: 81, ELIBMAX: 82, ELIBEXEC: 83, ENOSYS: 38, ENOTEMPTY: 39, ENAMETOOLONG: 36, ELOOP: 40, EOPNOTSUPP: 95, EPFNOSUPPORT: 96, ECONNRESET: 104, ENOBUFS: 105, EAFNOSUPPORT: 97, EPROTOTYPE: 91, ENOTSOCK: 88, ENOPROTOOPT: 92, ESHUTDOWN: 108, ECONNREFUSED: 111, EADDRINUSE: 98, ECONNABORTED: 103, ENETUNREACH: 101, ENETDOWN: 100, ETIMEDOUT: 110, EHOSTDOWN: 112, EHOSTUNREACH: 113, EINPROGRESS: 115, EALREADY: 114, EDESTADDRREQ: 89, EMSGSIZE: 90, EPROTONOSUPPORT: 93, ESOCKTNOSUPPORT: 94, EADDRNOTAVAIL: 99, ENETRESET: 102, EISCONN: 106, ENOTCONN: 107, ETOOMANYREFS: 109, EUSERS: 87, EDQUOT: 122, ESTALE: 116, ENOTSUP: 95, ENOMEDIUM: 123, EILSEQ: 84, EOVERFLOW: 75, ECANCELED: 125, ENOTRECOVERABLE: 131, EOWNERDEAD: 130, ESTRPIPE: 86 }; var PIPEFS = { BUCKET_BUFFER_SIZE: 8192, mount: function (mount) { return FS.createNode(null, \"/\", 16384 | 511, 0) }, createPipe: function () { var pipe = { buckets: [] }; pipe.buckets.push({ buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: 0, roffset: 0 }); var rName = PIPEFS.nextname(); var wName = PIPEFS.nextname(); var rNode = FS.createNode(PIPEFS.root, rName, 4096, 0); var wNode = FS.createNode(PIPEFS.root, wName, 4096, 0); rNode.pipe = pipe; wNode.pipe = pipe; var readableStream = FS.createStream({ path: rName, node: rNode, flags: FS.modeStringToFlags(\"r\"), seekable: false, stream_ops: PIPEFS.stream_ops }); rNode.stream = readableStream; var writableStream = FS.createStream({ path: wName, node: wNode, flags: FS.modeStringToFlags(\"w\"), seekable: false, stream_ops: PIPEFS.stream_ops }); wNode.stream = writableStream; return { readable_fd: readableStream.fd, writable_fd: writableStream.fd } }, stream_ops: { poll: function (stream) { var pipe = stream.node.pipe; if ((stream.flags & 2097155) === 1) { return 256 | 4 } else { if (pipe.buckets.length > 0) { for (var i = 0; i < pipe.buckets.length; i++) { var bucket = pipe.buckets[i]; if (bucket.offset - bucket.roffset > 0) { return 64 | 1 } } } } return 0 }, ioctl: function (stream, request, varargs) { return ERRNO_CODES.EINVAL }, read: function (stream, buffer, offset, length, position) { var pipe = stream.node.pipe; var currentLength = 0; for (var i = 0; i < pipe.buckets.length; i++) { var bucket = pipe.buckets[i]; currentLength += bucket.offset - bucket.roffset } assert(buffer instanceof ArrayBuffer || ArrayBuffer.isView(buffer)); var data = buffer.subarray(offset, offset + length); if (length <= 0) { return 0 } if (currentLength == 0) { throw new FS.ErrnoError(ERRNO_CODES.EAGAIN) } var toRead = Math.min(currentLength, length); var totalRead = toRead; var toRemove = 0; for (var i = 0; i < pipe.buckets.length; i++) { var currBucket = pipe.buckets[i]; var bucketSize = currBucket.offset - currBucket.roffset; if (toRead <= bucketSize) { var tmpSlice = currBucket.buffer.subarray(currBucket.roffset, currBucket.offset); if (toRead < bucketSize) { tmpSlice = tmpSlice.subarray(0, toRead); currBucket.roffset += toRead } else { toRemove++ } data.set(tmpSlice); break } else { var tmpSlice = currBucket.buffer.subarray(currBucket.roffset, currBucket.offset); data.set(tmpSlice); data = data.subarray(tmpSlice.byteLength); toRead -= tmpSlice.byteLength; toRemove++ } } if (toRemove && toRemove == pipe.buckets.length) { toRemove--; pipe.buckets[toRemove].offset = 0; pipe.buckets[toRemove].roffset = 0 } pipe.buckets.splice(0, toRemove); return totalRead }, write: function (stream, buffer, offset, length, position) { var pipe = stream.node.pipe; assert(buffer instanceof ArrayBuffer || ArrayBuffer.isView(buffer)); var data = buffer.subarray(offset, offset + length); var dataLen = data.byteLength; if (dataLen <= 0) { return 0 } var currBucket = null; if (pipe.buckets.length == 0) { currBucket = { buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: 0, roffset: 0 }; pipe.buckets.push(currBucket) } else { currBucket = pipe.buckets[pipe.buckets.length - 1] } assert(currBucket.offset <= PIPEFS.BUCKET_BUFFER_SIZE); var freeBytesInCurrBuffer = PIPEFS.BUCKET_BUFFER_SIZE - currBucket.offset; if (freeBytesInCurrBuffer >= dataLen) { currBucket.buffer.set(data, currBucket.offset); currBucket.offset += dataLen; return dataLen } else if (freeBytesInCurrBuffer > 0) { currBucket.buffer.set(data.subarray(0, freeBytesInCurrBuffer), currBucket.offset); currBucket.offset += freeBytesInCurrBuffer; data = data.subarray(freeBytesInCurrBuffer, data.byteLength) } var numBuckets = data.byteLength / PIPEFS.BUCKET_BUFFER_SIZE | 0; var remElements = data.byteLength % PIPEFS.BUCKET_BUFFER_SIZE; for (var i = 0; i < numBuckets; i++) { var newBucket = { buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: PIPEFS.BUCKET_BUFFER_SIZE, roffset: 0 }; pipe.buckets.push(newBucket); newBucket.buffer.set(data.subarray(0, PIPEFS.BUCKET_BUFFER_SIZE)); data = data.subarray(PIPEFS.BUCKET_BUFFER_SIZE, data.byteLength) } if (remElements > 0) { var newBucket = { buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: data.byteLength, roffset: 0 }; pipe.buckets.push(newBucket); newBucket.buffer.set(data) } return dataLen }, close: function (stream) { var pipe = stream.node.pipe; pipe.buckets = null } }, nextname: function () { if (!PIPEFS.nextname.current) { PIPEFS.nextname.current = 0 } return \"pipe[\" + PIPEFS.nextname.current++ + \"]\" } }; function ___syscall42(which, varargs) { SYSCALLS.varargs = varargs; try { var fdPtr = SYSCALLS.get(); if (fdPtr == 0) { throw new FS.ErrnoError(14) } var res = PIPEFS.createPipe(); HEAP32[fdPtr >> 2] = res.readable_fd; HEAP32[fdPtr + 4 >> 2] = res.writable_fd; return 0 } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall5(which, varargs) { SYSCALLS.varargs = varargs; try { var pathname = SYSCALLS.getStr(), flags = SYSCALLS.get(), mode = SYSCALLS.get(); var stream = FS.open(pathname, flags, mode); return stream.fd } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall6(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(); FS.close(stream); return 0 } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function _abort() { Module[\"abort\"]() } function _emscripten_get_heap_size() { return HEAP8.length } function abortOnCannotGrowMemory(requestedSize) { abort(\"OOM\") } function emscripten_realloc_buffer(size) { var PAGE_MULTIPLE = 65536; size = alignUp(size, PAGE_MULTIPLE); var oldSize = buffer.byteLength; try { var result = wasmMemory.grow((size - oldSize) / 65536); if (result !== (-1 | 0)) { buffer = wasmMemory.buffer; return true } else { return false } } catch (e) { return false } } function _emscripten_resize_heap(requestedSize) { var oldSize = _emscripten_get_heap_size(); var PAGE_MULTIPLE = 65536; var LIMIT = 2147483648 - PAGE_MULTIPLE; if (requestedSize > LIMIT) { return false } var MIN_TOTAL_MEMORY = 16777216; var newSize = Math.max(oldSize, MIN_TOTAL_MEMORY); while (newSize < requestedSize) { if (newSize <= 536870912) { newSize = alignUp(2 * newSize, PAGE_MULTIPLE) } else { newSize = Math.min(alignUp((3 * newSize + 2147483648) / 4, PAGE_MULTIPLE), LIMIT) } } if (!emscripten_realloc_buffer(newSize)) { return false } updateGlobalBufferViews(); return true } function _exit(status) { exit(status) } var ___tm_current = 277408; var ___tm_timezone = (stringToUTF8(\"GMT\", 277456, 4), 277456); function _tzset() { if (_tzset.called) return; _tzset.called = true; HEAP32[__get_timezone() >> 2] = (new Date).getTimezoneOffset() * 60; var winter = new Date(2e3, 0, 1); var summer = new Date(2e3, 6, 1); HEAP32[__get_daylight() >> 2] = Number(winter.getTimezoneOffset() != summer.getTimezoneOffset()); function extractZone(date) { var match = date.toTimeString().match(/\\(([A-Za-z ]+)\\)$/); return match ? match[1] : \"GMT\" } var winterName = extractZone(winter); var summerName = extractZone(summer); var winterNamePtr = allocate(intArrayFromString(winterName), \"i8\", ALLOC_NORMAL); var summerNamePtr = allocate(intArrayFromString(summerName), \"i8\", ALLOC_NORMAL); if (summer.getTimezoneOffset() < winter.getTimezoneOffset()) { HEAP32[__get_tzname() >> 2] = winterNamePtr; HEAP32[__get_tzname() + 4 >> 2] = summerNamePtr } else { HEAP32[__get_tzname() >> 2] = summerNamePtr; HEAP32[__get_tzname() + 4 >> 2] = winterNamePtr } } function _localtime_r(time, tmPtr) { _tzset(); var date = new Date(HEAP32[time >> 2] * 1e3); HEAP32[tmPtr >> 2] = date.getSeconds(); HEAP32[tmPtr + 4 >> 2] = date.getMinutes(); HEAP32[tmPtr + 8 >> 2] = date.getHours(); HEAP32[tmPtr + 12 >> 2] = date.getDate(); HEAP32[tmPtr + 16 >> 2] = date.getMonth(); HEAP32[tmPtr + 20 >> 2] = date.getFullYear() - 1900; HEAP32[tmPtr + 24 >> 2] = date.getDay(); var start = new Date(date.getFullYear(), 0, 1); var yday = (date.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24) | 0; HEAP32[tmPtr + 28 >> 2] = yday; HEAP32[tmPtr + 36 >> 2] = -(date.getTimezoneOffset() * 60); var summerOffset = new Date(2e3, 6, 1).getTimezoneOffset(); var winterOffset = start.getTimezoneOffset(); var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset)) | 0; HEAP32[tmPtr + 32 >> 2] = dst; var zonePtr = HEAP32[__get_tzname() + (dst ? 4 : 0) >> 2]; HEAP32[tmPtr + 40 >> 2] = zonePtr; return tmPtr } function _localtime(time) { return _localtime_r(time, ___tm_current) } function _emscripten_memcpy_big(dest, src, num) { HEAPU8.set(HEAPU8.subarray(src, src + num), dest) } function _mktime(tmPtr) { _tzset(); var date = new Date(HEAP32[tmPtr + 20 >> 2] + 1900, HEAP32[tmPtr + 16 >> 2], HEAP32[tmPtr + 12 >> 2], HEAP32[tmPtr + 8 >> 2], HEAP32[tmPtr + 4 >> 2], HEAP32[tmPtr >> 2], 0); var dst = HEAP32[tmPtr + 32 >> 2]; var guessedOffset = date.getTimezoneOffset(); var start = new Date(date.getFullYear(), 0, 1); var summerOffset = new Date(2e3, 6, 1).getTimezoneOffset(); var winterOffset = start.getTimezoneOffset(); var dstOffset = Math.min(winterOffset, summerOffset); if (dst < 0) { HEAP32[tmPtr + 32 >> 2] = Number(summerOffset != winterOffset && dstOffset == guessedOffset) } else if (dst > 0 != (dstOffset == guessedOffset)) { var nonDstOffset = Math.max(winterOffset, summerOffset); var trueOffset = dst > 0 ? dstOffset : nonDstOffset; date.setTime(date.getTime() + (trueOffset - guessedOffset) * 6e4) } HEAP32[tmPtr + 24 >> 2] = date.getDay(); var yday = (date.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24) | 0; HEAP32[tmPtr + 28 >> 2] = yday; return date.getTime() / 1e3 | 0 } function _posix_spawn_file_actions_addclose() { err(\"missing function: posix_spawn_file_actions_addclose\"); abort(-1) } function _posix_spawn_file_actions_adddup2() { err(\"missing function: posix_spawn_file_actions_adddup2\"); abort(-1) } function _posix_spawn_file_actions_destroy() { err(\"missing function: posix_spawn_file_actions_destroy\"); abort(-1) } function _posix_spawn_file_actions_init() { err(\"missing function: posix_spawn_file_actions_init\"); abort(-1) } function _fork() { ___setErrNo(11); return -1 } function _posix_spawnp() { return _fork.apply(null, arguments) } function _timegm(tmPtr) { _tzset(); var time = Date.UTC(HEAP32[tmPtr + 20 >> 2] + 1900, HEAP32[tmPtr + 16 >> 2], HEAP32[tmPtr + 12 >> 2], HEAP32[tmPtr + 8 >> 2], HEAP32[tmPtr + 4 >> 2], HEAP32[tmPtr >> 2], 0); var date = new Date(time); HEAP32[tmPtr + 24 >> 2] = date.getUTCDay(); var start = Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0); var yday = (date.getTime() - start) / (1e3 * 60 * 60 * 24) | 0; HEAP32[tmPtr + 28 >> 2] = yday; return date.getTime() / 1e3 | 0 } function _wait(stat_loc) { ___setErrNo(10); return -1 } function _waitpid() { return _wait.apply(null, arguments) } FS.staticInit(); if (ENVIRONMENT_IS_NODE) { var fs = require(\"fs\"); var NODEJS_PATH = require(\"path\"); NODEFS.staticInit() } function intArrayFromString(stringy, dontAddNull, length) { var len = length > 0 ? length : lengthBytesUTF8(stringy) + 1; var u8array = new Array(len); var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); if (dontAddNull) u8array.length = numBytesWritten; return u8array } var asmGlobalArg = {}; var asmLibraryArg = { \"b\": abort, \"q\": setTempRet0, \"G\": ___buildEnvironment, \"l\": ___setErrNo, \"s\": ___syscall140, \"i\": ___syscall146, \"p\": ___syscall168, \"o\": ___syscall195, \"n\": ___syscall196, \"m\": ___syscall197, \"c\": ___syscall221, \"F\": ___syscall3, \"E\": ___syscall4, \"D\": ___syscall41, \"C\": ___syscall42, \"B\": ___syscall5, \"h\": ___syscall6, \"g\": _abort, \"A\": _emscripten_get_heap_size, \"z\": _emscripten_memcpy_big, \"y\": _emscripten_resize_heap, \"f\": _exit, \"x\": _localtime, \"d\": _mktime, \"e\": _posix_spawn_file_actions_addclose, \"k\": _posix_spawn_file_actions_adddup2, \"j\": _posix_spawn_file_actions_destroy, \"w\": _posix_spawn_file_actions_init, \"v\": _posix_spawnp, \"u\": _timegm, \"t\": _waitpid, \"r\": abortOnCannotGrowMemory, \"a\": DYNAMICTOP_PTR }; var asm = Module[\"asm\"](asmGlobalArg, asmLibraryArg, buffer); Module[\"asm\"] = asm; var ___emscripten_environ_constructor = Module[\"___emscripten_environ_constructor\"] = function () { return Module[\"asm\"][\"H\"].apply(null, arguments) }; var ___errno_location = Module[\"___errno_location\"] = function () { return Module[\"asm\"][\"I\"].apply(null, arguments) }; var __get_daylight = Module[\"__get_daylight\"] = function () { return Module[\"asm\"][\"J\"].apply(null, arguments) }; var __get_timezone = Module[\"__get_timezone\"] = function () { return Module[\"asm\"][\"K\"].apply(null, arguments) }; var __get_tzname = Module[\"__get_tzname\"] = function () { return Module[\"asm\"][\"L\"].apply(null, arguments) }; var _archive_close = Module[\"_archive_close\"] = function () { return Module[\"asm\"][\"M\"].apply(null, arguments) }; var _archive_entry_filetype = Module[\"_archive_entry_filetype\"] = function () { return Module[\"asm\"][\"N\"].apply(null, arguments) }; var _archive_entry_is_encrypted = Module[\"_archive_entry_is_encrypted\"] = function () { return Module[\"asm\"][\"O\"].apply(null, arguments) }; var _archive_entry_pathname = Module[\"_archive_entry_pathname\"] = function () { return Module[\"asm\"][\"P\"].apply(null, arguments) }; var _archive_entry_pathname_utf8 = Module[\"_archive_entry_pathname_utf8\"] = function () { return Module[\"asm\"][\"Q\"].apply(null, arguments) }; var _archive_entry_size = Module[\"_archive_entry_size\"] = function () { return Module[\"asm\"][\"R\"].apply(null, arguments) }; var _archive_error_string = Module[\"_archive_error_string\"] = function () { return Module[\"asm\"][\"S\"].apply(null, arguments) }; var _archive_open = Module[\"_archive_open\"] = function () { return Module[\"asm\"][\"T\"].apply(null, arguments) }; var _archive_read_add_passphrase = Module[\"_archive_read_add_passphrase\"] = function () { return Module[\"asm\"][\"U\"].apply(null, arguments) }; var _archive_read_data_skip = Module[\"_archive_read_data_skip\"] = function () { return Module[\"asm\"][\"V\"].apply(null, arguments) }; var _archive_read_has_encrypted_entries = Module[\"_archive_read_has_encrypted_entries\"] = function () { return Module[\"asm\"][\"W\"].apply(null, arguments) }; var _free = Module[\"_free\"] = function () { return Module[\"asm\"][\"X\"].apply(null, arguments) }; var _get_filedata = Module[\"_get_filedata\"] = function () { return Module[\"asm\"][\"Y\"].apply(null, arguments) }; var _get_next_entry = Module[\"_get_next_entry\"] = function () { return Module[\"asm\"][\"Z\"].apply(null, arguments) }; var _get_version = Module[\"_get_version\"] = function () { return Module[\"asm\"][\"_\"].apply(null, arguments) }; var _malloc = Module[\"_malloc\"] = function () { return Module[\"asm\"][\"$\"].apply(null, arguments) }; var stackAlloc = Module[\"stackAlloc\"] = function () { return Module[\"asm\"][\"ca\"].apply(null, arguments) }; var stackRestore = Module[\"stackRestore\"] = function () { return Module[\"asm\"][\"da\"].apply(null, arguments) }; var stackSave = Module[\"stackSave\"] = function () { return Module[\"asm\"][\"ea\"].apply(null, arguments) }; var dynCall_v = Module[\"dynCall_v\"] = function () { return Module[\"asm\"][\"aa\"].apply(null, arguments) }; var dynCall_vi = Module[\"dynCall_vi\"] = function () { return Module[\"asm\"][\"ba\"].apply(null, arguments) }; Module[\"asm\"] = asm; Module[\"intArrayFromString\"] = intArrayFromString; Module[\"cwrap\"] = cwrap; Module[\"allocate\"] = allocate; Module[\"then\"] = function (func) { if (Module[\"calledRun\"]) { func(Module) } else { var old = Module[\"onRuntimeInitialized\"]; Module[\"onRuntimeInitialized\"] = function () { if (old) old(); func(Module) } } return Module }; function ExitStatus(status) { this.name = \"ExitStatus\"; this.message = \"Program terminated with exit(\" + status + \")\"; this.status = status } ExitStatus.prototype = new Error; ExitStatus.prototype.constructor = ExitStatus; dependenciesFulfilled = function runCaller() { if (!Module[\"calledRun\"]) run(); if (!Module[\"calledRun\"]) dependenciesFulfilled = runCaller }; function run(args) { args = args || Module[\"arguments\"]; if (runDependencies > 0) { return } preRun(); if (runDependencies > 0) return; if (Module[\"calledRun\"]) return; function doRun() { if (Module[\"calledRun\"]) return; Module[\"calledRun\"] = true; if (ABORT) return; ensureInitRuntime(); preMain(); if (Module[\"onRuntimeInitialized\"]) Module[\"onRuntimeInitialized\"](); postRun() } if (Module[\"setStatus\"]) { Module[\"setStatus\"](\"Running...\"); setTimeout(function () { setTimeout(function () { Module[\"setStatus\"](\"\") }, 1); doRun() }, 1) } else { doRun() } } Module[\"run\"] = run; function exit(status, implicit) { if (implicit && Module[\"noExitRuntime\"] && status === 0) { return } if (Module[\"noExitRuntime\"]) { } else { ABORT = true; EXITSTATUS = status; exitRuntime(); if (Module[\"onExit\"]) Module[\"onExit\"](status) } Module[\"quit\"](status, new ExitStatus(status)) } function abort(what) { if (Module[\"onAbort\"]) { Module[\"onAbort\"](what) } if (what !== undefined) { out(what); err(what); what = JSON.stringify(what) } else { what = \"\" } ABORT = true; EXITSTATUS = 1; throw \"abort(\" + what + \"). Build with -s ASSERTIONS=1 for more info.\" } Module[\"abort\"] = abort; if (Module[\"preInit\"]) { if (typeof Module[\"preInit\"] == \"function\") Module[\"preInit\"] = [Module[\"preInit\"]]; while (Module[\"preInit\"].length > 0) { Module[\"preInit\"].pop()() } } Module[\"noExitRuntime\"] = true; run();\n\n\n      return libarchive\n    }\n  );\n})();\nexport default libarchive;"
  },
  {
    "path": "client/static/libarchive/worker-bundle.js",
    "content": "!function () { \"use strict\"; const e = { 32768: \"FILE\", 16384: \"DIR\", 40960: \"SYMBOLIC_LINK\", 49152: \"SOCKET\", 8192: \"CHARACTER_DEVICE\", 24576: \"BLOCK_DEVICE\", 4096: \"NAMED_PIPE\" }; class r { constructor(e) { this._wasmModule = e, this._runCode = e.runCode, this._file = null, this._passphrase = null } open(e) { null !== this._file && (console.warn(\"Closing previous file\"), this.close()); const { promise: r, resolve: t, reject: n } = this._promiseHandles(); this._file = e; const o = new FileReader; return o.onload = (() => this._loadFile(o.result, t, n)), o.readAsArrayBuffer(e), r } close() { this._runCode.closeArchive(this._archive), this._wasmModule._free(this._filePtr), this._file = null, this._filePtr = null, this._archive = null } hasEncryptedData() { this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase), this._runCode.getNextEntry(this._archive); const e = this._runCode.hasEncryptedEntries(this._archive); return 0 !== e && (e > 0 || null) } setPassphrase(e) { this._passphrase = e } *entries(r = !1, t = null) { let n; for (this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase); 0 !== (n = this._runCode.getNextEntry(this._archive));) { const o = { size: this._runCode.getEntrySize(n), path: this._runCode.getEntryName(n), type: e[this._runCode.getEntryType(n)], ref: n }; if (\"FILE\" === o.type) { let e = o.path.split(\"/\"); o.fileName = e[e.length - 1] } if (r && t !== o.path) this._runCode.skipEntry(this._archive); else { const e = this._runCode.getFileData(this._archive, o.size); if (e < 0) throw new Error(this._runCode.getError(this._archive)); o.fileData = this._wasmModule.HEAP8.slice(e, e + o.size), this._wasmModule._free(e) } yield o } } _loadFile(e, r, t) { try { const n = new Uint8Array(e); this._fileLength = n.length, this._filePtr = this._runCode.malloc(this._fileLength), this._wasmModule.HEAP8.set(n, this._filePtr), r() } catch (e) { t(e) } } _promiseHandles() { let e = null, r = null; return { promise: new Promise((t, n) => { e = t, r = n }), resolve: e, reject: r } } } var t, n = (t = \"undefined\" != typeof document && document.currentScript ? document.currentScript.src : void 0, function (e) { var r, n = void 0 !== (e = e || {}) ? e : {}, o = {}; for (r in n) n.hasOwnProperty(r) && (o[r] = n[r]); n.arguments = [], n.thisProgram = \"./this.program\", n.quit = function (e, r) { throw r }, n.preRun = [], n.postRun = []; var i, a, s = !1, u = !1; s = \"object\" == typeof window, u = \"function\" == typeof importScripts, i = \"object\" == typeof process && \"function\" == typeof require && !s && !u, a = !s && !i && !u; var c, f, l = \"\"; i ? (l = __dirname + \"/\", n.read = function (e, r) { var t; return c || (c = require(\"fs\")), f || (f = require(\"path\")), e = f.normalize(e), t = c.readFileSync(e), r ? t : t.toString() }, n.readBinary = function (e) { var r = n.read(e, !0); return r.buffer || (r = new Uint8Array(r)), y(r.buffer), r }, process.argv.length > 1 && (n.thisProgram = process.argv[1].replace(/\\\\/g, \"/\")), n.arguments = process.argv.slice(2), process.on(\"uncaughtException\", function (e) { if (!(e instanceof We)) throw e }), process.on(\"unhandledRejection\", Ke), n.quit = function (e) { process.exit(e) }, n.inspect = function () { return \"[Emscripten Module object]\" }) : a ? (\"undefined\" != typeof read && (n.read = function (e) { return read(e) }), n.readBinary = function (e) { var r; return \"function\" == typeof readbuffer ? new Uint8Array(readbuffer(e)) : (y(\"object\" == typeof (r = read(e, \"binary\"))), r) }, \"undefined\" != typeof scriptArgs ? n.arguments = scriptArgs : void 0 !== arguments && (n.arguments = arguments), \"function\" == typeof quit && (n.quit = function (e) { quit(e) })) : (s || u) && (u ? l = self.location.href : document.currentScript && (l = document.currentScript.src), t && (l = t), l = 0 !== l.indexOf(\"blob:\") ? l.substr(0, l.lastIndexOf(\"/\") + 1) : \"\", n.read = function (e) { var r = new XMLHttpRequest; return r.open(\"GET\", e, !1), r.send(null), r.responseText }, u && (n.readBinary = function (e) { var r = new XMLHttpRequest; return r.open(\"GET\", e, !1), r.responseType = \"arraybuffer\", r.send(null), new Uint8Array(r.response) }), n.readAsync = function (e, r, t) { var n = new XMLHttpRequest; n.open(\"GET\", e, !0), n.responseType = \"arraybuffer\", n.onload = function () { 200 == n.status || 0 == n.status && n.response ? r(n.response) : t() }, n.onerror = t, n.send(null) }, n.setWindowTitle = function (e) { document.title = e }); var d = n.print || (\"undefined\" != typeof console ? console.log.bind(console) : \"undefined\" != typeof print ? print : null), p = n.printErr || (\"undefined\" != typeof printErr ? printErr : \"undefined\" != typeof console && console.warn.bind(console) || d); for (r in o) o.hasOwnProperty(r) && (n[r] = o[r]); function m(e) { var r = M[q >> 2], t = r + e + 15 & -16; if (t <= Fe()) M[q >> 2] = t; else if (!Ae(t)) return 0; return r } function h(e) { switch (e) { case \"i1\": case \"i8\": return 1; case \"i16\": return 2; case \"i32\": return 4; case \"i64\": return 8; case \"float\": return 4; case \"double\": return 8; default: if (\"*\" === e[e.length - 1]) return 4; if (\"i\" === e[0]) { var r = parseInt(e.substr(1)); return y(r % 8 == 0, \"getNativeTypeSize invalid bits \" + r + \", type \" + e), r / 8 } return 0 } } o = void 0; var w, v = { \"f64-rem\": function (e, r) { return e % r }, debugger: function () { } }; new Array(0), \"object\" != typeof WebAssembly && p(\"no native wasm support detected\"); var g = !1; function y(e, r) { e || Ke(\"Assertion failed: \" + r) } function E(e) { var r = n[\"_\" + e]; return y(r, \"Cannot call unknown function \" + e + \", make sure it is exported\"), r } function _(e, r, t, n, o) { var i = { string: function (e) { var r = 0; if (null != e && 0 !== e) { var t = 1 + (e.length << 2); C(e, r = Ue(t), t) } return r }, array: function (e) { var r, t, n = Ue(e.length); return r = e, t = n, P.set(r, t), n } }, a = E(e), s = [], u = 0; if (n) for (var c = 0; c < n.length; c++) { var f = i[t[c]]; f ? (0 === u && (u = He()), s[c] = f(n[c])) : s[c] = n[c] } var l = a.apply(null, s); return l = function (e) { return \"string\" === r ? I(e) : \"boolean\" === r ? Boolean(e) : e }(l), 0 !== u && je(u), l } function b(e, r, t, n) { switch (\"*\" === (t = t || \"i8\").charAt(t.length - 1) && (t = \"i32\"), t) { case \"i1\": case \"i8\": P[e >> 0] = r; break; case \"i16\": T[e >> 1] = r; break; case \"i32\": M[e >> 2] = r; break; case \"i64\": tempI64 = [r >>> 0, (tempDouble = r, +J(tempDouble) >= 1 ? tempDouble > 0 ? (0 | re(+ee(tempDouble / 4294967296), 4294967295)) >>> 0 : ~~+Q((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], M[e >> 2] = tempI64[0], M[e + 4 >> 2] = tempI64[1]; break; case \"float\": B[e >> 2] = r; break; case \"double\": x[e >> 3] = r; break; default: Ke(\"invalid type for setValue: \" + t) } } var k = 0, D = 3; function S(e, r, t, n) { var o, i; \"number\" == typeof e ? (o = !0, i = e) : (o = !1, i = e.length); var a, s = \"string\" == typeof r ? r : null; if (a = t == D ? n : [Le, Ue, m][t](Math.max(i, s ? 1 : r.length)), o) { var u; for (n = a, y(0 == (3 & a)), u = a + (-4 & i); n < u; n += 4)M[n >> 2] = 0; for (u = a + i; n < u;)P[n++ >> 0] = 0; return a } if (\"i8\" === s) return e.subarray || e.slice ? R.set(e, a) : R.set(new Uint8Array(e), a), a; for (var c, f, l, d = 0; d < i;) { var p = e[d]; 0 !== (c = s || r[d]) ? (\"i64\" == c && (c = \"i32\"), b(a + d, p, c), l !== c && (f = h(c), l = c), d += f) : d++ } return a } function F(e) { return $ ? Le(e) : m(e) } var A, P, R, T, M, B, x, O = \"undefined\" != typeof TextDecoder ? new TextDecoder(\"utf8\") : void 0; function N(e, r, t) { for (var n = r + t, o = r; e[o] && !(o >= n);)++o; if (o - r > 16 && e.subarray && O) return O.decode(e.subarray(r, o)); for (var i = \"\"; r < o;) { var a = e[r++]; if (128 & a) { var s = 63 & e[r++]; if (192 != (224 & a)) { var u = 63 & e[r++]; if ((a = 224 == (240 & a) ? (15 & a) << 12 | s << 6 | u : (7 & a) << 18 | s << 12 | u << 6 | 63 & e[r++]) < 65536) i += String.fromCharCode(a); else { var c = a - 65536; i += String.fromCharCode(55296 | c >> 10, 56320 | 1023 & c) } } else i += String.fromCharCode((31 & a) << 6 | s) } else i += String.fromCharCode(a) } return i } function I(e, r) { return e ? N(R, e, r) : \"\" } function z(e, r, t, n) { if (!(n > 0)) return 0; for (var o = t, i = t + n - 1, a = 0; a < e.length; ++a) { var s = e.charCodeAt(a); if (s >= 55296 && s <= 57343 && (s = 65536 + ((1023 & s) << 10) | 1023 & e.charCodeAt(++a)), s <= 127) { if (t >= i) break; r[t++] = s } else if (s <= 2047) { if (t + 1 >= i) break; r[t++] = 192 | s >> 6, r[t++] = 128 | 63 & s } else if (s <= 65535) { if (t + 2 >= i) break; r[t++] = 224 | s >> 12, r[t++] = 128 | s >> 6 & 63, r[t++] = 128 | 63 & s } else { if (t + 3 >= i) break; r[t++] = 240 | s >> 18, r[t++] = 128 | s >> 12 & 63, r[t++] = 128 | s >> 6 & 63, r[t++] = 128 | 63 & s } } return r[t] = 0, t - o } function C(e, r, t) { return z(e, R, r, t) } function L(e) { for (var r = 0, t = 0; t < e.length; ++t) { var n = e.charCodeAt(t); n >= 55296 && n <= 57343 && (n = 65536 + ((1023 & n) << 10) | 1023 & e.charCodeAt(++t)), n <= 127 ? ++r : r += n <= 2047 ? 2 : n <= 65535 ? 3 : 4 } return r } function U(e, r, t) { for (var n = 0; n < e.length; ++n)P[r++ >> 0] = e.charCodeAt(n); t || (P[r >> 0] = 0) } function j() { var e = function () { var e = new Error; if (!e.stack) { try { throw new Error(0) } catch (r) { e = r } if (!e.stack) return \"(no stack trace available)\" } return e.stack.toString() }(); return n.extraStackTrace && (e += \"\\n\" + n.extraStackTrace()), e.replace(/__Z[\\w\\d_]+/g, function (e) { var r = e; return e === r ? e : r + \" [\" + e + \"]\" }) } function H(e, r) { return e % r > 0 && (e += r - e % r), e } function W() { n.HEAP8 = P = new Int8Array(A), n.HEAP16 = T = new Int16Array(A), n.HEAP32 = M = new Int32Array(A), n.HEAPU8 = R = new Uint8Array(A), n.HEAPU16 = new Uint16Array(A), n.HEAPU32 = new Uint32Array(A), n.HEAPF32 = B = new Float32Array(A), n.HEAPF64 = x = new Float64Array(A) } \"undefined\" != typeof TextDecoder && new TextDecoder(\"utf-16le\"); var q = 277552, K = n.TOTAL_MEMORY || 16777216; function Z(e) { for (; e.length > 0;) { var r = e.shift(); if (\"function\" != typeof r) { var t = r.func; \"number\" == typeof t ? void 0 === r.arg ? n.dynCall_v(t) : n.dynCall_vi(t, r.arg) : t(void 0 === r.arg ? null : r.arg) } else r() } } K < 5242880 && p(\"TOTAL_MEMORY should be larger than TOTAL_STACK, was \" + K + \"! (TOTAL_STACK=5242880)\"), n.buffer ? A = n.buffer : \"object\" == typeof WebAssembly && \"function\" == typeof WebAssembly.Memory ? (w = new WebAssembly.Memory({ initial: K / 65536 }), A = w.buffer) : A = new ArrayBuffer(K), W(), M[q >> 2] = 5520464; var V = [], X = [], Y = [], G = [], $ = !1, J = Math.abs, Q = Math.ceil, ee = Math.floor, re = Math.min, te = 0, ne = null; function oe(e) { te++, n.monitorRunDependencies && n.monitorRunDependencies(te) } function ie(e) { if (te--, n.monitorRunDependencies && n.monitorRunDependencies(te), 0 == te && ne) { var r = ne; ne = null, r() } } n.preloadedImages = {}, n.preloadedAudios = {}; var ae = \"data:application/octet-stream;base64,\"; function se(e) { return String.prototype.startsWith ? e.startsWith(ae) : 0 === e.indexOf(ae) } var ue, ce = \"libarchive.wasm\"; function fe() { try { if (n.wasmBinary) return new Uint8Array(n.wasmBinary); if (n.readBinary) return n.readBinary(ce); throw \"both async and sync fetching of the wasm failed\" } catch (e) { Ke(e) } } function le(e) { var r = { env: e, global: { NaN: NaN, Infinity: 1 / 0 }, \"global.Math\": Math, asm2wasm: v }; function t(e, r) { var t = e.exports; n.asm = t, ie() } if (oe(), n.instantiateWasm) try { return n.instantiateWasm(r, t) } catch (e) { return p(\"Module.instantiateWasm callback failed with error: \" + e), !1 } function o(e) { t(e.instance) } function i(e) { (n.wasmBinary || !s && !u || \"function\" != typeof fetch ? new Promise(function (e, r) { e(fe()) }) : fetch(ce, { credentials: \"same-origin\" }).then(function (e) { if (!e.ok) throw \"failed to load wasm binary file at '\" + ce + \"'\"; return e.arrayBuffer() }).catch(function () { return fe() })).then(function (e) { return WebAssembly.instantiate(e, r) }).then(e, function (e) { p(\"failed to asynchronously prepare wasm: \" + e), Ke(e) }) } return n.wasmBinary || \"function\" != typeof WebAssembly.instantiateStreaming || se(ce) || \"function\" != typeof fetch ? i(o) : WebAssembly.instantiateStreaming(fetch(ce, { credentials: \"same-origin\" }), r).then(o, function (e) { p(\"wasm streaming compile failed: \" + e), p(\"falling back to ArrayBuffer instantiation\"), i(o) }), {} } se(ce) || (ue = ce, ce = n.locateFile ? n.locateFile(ue, l) : l + ue), n.asm = function (e, r, t) { return r.memory = w, r.table = new WebAssembly.Table({ initial: 507, maximum: 507, element: \"anyfunc\" }), r.__memory_base = 1024, r.__table_base = 0, le(r) }, X.push({ func: function () { Ne() } }); var de = {}, pe = { splitPath: function (e) { return /^(\\/?|)([\\s\\S]*?)((?:\\.{1,2}|[^\\/]+?|)(\\.[^.\\/]*|))(?:[\\/]*)$/.exec(e).slice(1) }, normalizeArray: function (e, r) { for (var t = 0, n = e.length - 1; n >= 0; n--) { var o = e[n]; \".\" === o ? e.splice(n, 1) : \"..\" === o ? (e.splice(n, 1), t++) : t && (e.splice(n, 1), t--) } if (r) for (; t; t--)e.unshift(\"..\"); return e }, normalize: function (e) { var r = \"/\" === e.charAt(0), t = \"/\" === e.substr(-1); return (e = pe.normalizeArray(e.split(\"/\").filter(function (e) { return !!e }), !r).join(\"/\")) || r || (e = \".\"), e && t && (e += \"/\"), (r ? \"/\" : \"\") + e }, dirname: function (e) { var r = pe.splitPath(e), t = r[0], n = r[1]; return t || n ? (n && (n = n.substr(0, n.length - 1)), t + n) : \".\" }, basename: function (e) { if (\"/\" === e) return \"/\"; var r = e.lastIndexOf(\"/\"); return -1 === r ? e : e.substr(r + 1) }, extname: function (e) { return pe.splitPath(e)[3] }, join: function () { var e = Array.prototype.slice.call(arguments, 0); return pe.normalize(e.join(\"/\")) }, join2: function (e, r) { return pe.normalize(e + \"/\" + r) } }; function me(e) { return n.___errno_location && (M[n.___errno_location() >> 2] = e), e } var he = { resolve: function () { for (var e = \"\", r = !1, t = arguments.length - 1; t >= -1 && !r; t--) { var n = t >= 0 ? arguments[t] : _e.cwd(); if (\"string\" != typeof n) throw new TypeError(\"Arguments to path.resolve must be strings\"); if (!n) return \"\"; e = n + \"/\" + e, r = \"/\" === n.charAt(0) } return (r ? \"/\" : \"\") + (e = pe.normalizeArray(e.split(\"/\").filter(function (e) { return !!e }), !r).join(\"/\")) || \".\" }, relative: function (e, r) { function t(e) { for (var r = 0; r < e.length && \"\" === e[r]; r++); for (var t = e.length - 1; t >= 0 && \"\" === e[t]; t--); return r > t ? [] : e.slice(r, t - r + 1) } e = he.resolve(e).substr(1), r = he.resolve(r).substr(1); for (var n = t(e.split(\"/\")), o = t(r.split(\"/\")), i = Math.min(n.length, o.length), a = i, s = 0; s < i; s++)if (n[s] !== o[s]) { a = s; break } var u = []; for (s = a; s < n.length; s++)u.push(\"..\"); return (u = u.concat(o.slice(a))).join(\"/\") } }, we = { ttys: [], init: function () { }, shutdown: function () { }, register: function (e, r) { we.ttys[e] = { input: [], output: [], ops: r }, _e.registerDevice(e, we.stream_ops) }, stream_ops: { open: function (e) { var r = we.ttys[e.node.rdev]; if (!r) throw new _e.ErrnoError(19); e.tty = r, e.seekable = !1 }, close: function (e) { e.tty.ops.flush(e.tty) }, flush: function (e) { e.tty.ops.flush(e.tty) }, read: function (e, r, t, n, o) { if (!e.tty || !e.tty.ops.get_char) throw new _e.ErrnoError(6); for (var i = 0, a = 0; a < n; a++) { var s; try { s = e.tty.ops.get_char(e.tty) } catch (e) { throw new _e.ErrnoError(5) } if (void 0 === s && 0 === i) throw new _e.ErrnoError(11); if (null == s) break; i++, r[t + a] = s } return i && (e.node.timestamp = Date.now()), i }, write: function (e, r, t, n, o) { if (!e.tty || !e.tty.ops.put_char) throw new _e.ErrnoError(6); try { for (var i = 0; i < n; i++)e.tty.ops.put_char(e.tty, r[t + i]) } catch (e) { throw new _e.ErrnoError(5) } return n && (e.node.timestamp = Date.now()), i } }, default_tty_ops: { get_char: function (e) { if (!e.input.length) { var r = null; if (i) { var t = new Buffer(256), n = 0, o = \"win32\" != process.platform, a = process.stdin.fd; if (o) { var s = !1; try { a = Te.openSync(\"/dev/stdin\", \"r\"), s = !0 } catch (e) { } } try { n = Te.readSync(a, t, 0, 256, null) } catch (e) { if (-1 == e.toString().indexOf(\"EOF\")) throw e; n = 0 } s && Te.closeSync(a), r = n > 0 ? t.slice(0, n).toString(\"utf-8\") : null } else \"undefined\" != typeof window && \"function\" == typeof window.prompt ? null !== (r = window.prompt(\"Input: \")) && (r += \"\\n\") : \"function\" == typeof readline && null !== (r = readline()) && (r += \"\\n\"); if (!r) return null; e.input = Be(r, !0) } return e.input.shift() }, put_char: function (e, r) { null === r || 10 === r ? (d(N(e.output, 0)), e.output = []) : 0 != r && e.output.push(r) }, flush: function (e) { e.output && e.output.length > 0 && (d(N(e.output, 0)), e.output = []) } }, default_tty1_ops: { put_char: function (e, r) { null === r || 10 === r ? (p(N(e.output, 0)), e.output = []) : 0 != r && e.output.push(r) }, flush: function (e) { e.output && e.output.length > 0 && (p(N(e.output, 0)), e.output = []) } } }, ve = { ops_table: null, mount: function (e) { return ve.createNode(null, \"/\", 16895, 0) }, createNode: function (e, r, t, n) { if (_e.isBlkdev(t) || _e.isFIFO(t)) throw new _e.ErrnoError(1); ve.ops_table || (ve.ops_table = { dir: { node: { getattr: ve.node_ops.getattr, setattr: ve.node_ops.setattr, lookup: ve.node_ops.lookup, mknod: ve.node_ops.mknod, rename: ve.node_ops.rename, unlink: ve.node_ops.unlink, rmdir: ve.node_ops.rmdir, readdir: ve.node_ops.readdir, symlink: ve.node_ops.symlink }, stream: { llseek: ve.stream_ops.llseek } }, file: { node: { getattr: ve.node_ops.getattr, setattr: ve.node_ops.setattr }, stream: { llseek: ve.stream_ops.llseek, read: ve.stream_ops.read, write: ve.stream_ops.write, allocate: ve.stream_ops.allocate, mmap: ve.stream_ops.mmap, msync: ve.stream_ops.msync } }, link: { node: { getattr: ve.node_ops.getattr, setattr: ve.node_ops.setattr, readlink: ve.node_ops.readlink }, stream: {} }, chrdev: { node: { getattr: ve.node_ops.getattr, setattr: ve.node_ops.setattr }, stream: _e.chrdev_stream_ops } }); var o = _e.createNode(e, r, t, n); return _e.isDir(o.mode) ? (o.node_ops = ve.ops_table.dir.node, o.stream_ops = ve.ops_table.dir.stream, o.contents = {}) : _e.isFile(o.mode) ? (o.node_ops = ve.ops_table.file.node, o.stream_ops = ve.ops_table.file.stream, o.usedBytes = 0, o.contents = null) : _e.isLink(o.mode) ? (o.node_ops = ve.ops_table.link.node, o.stream_ops = ve.ops_table.link.stream) : _e.isChrdev(o.mode) && (o.node_ops = ve.ops_table.chrdev.node, o.stream_ops = ve.ops_table.chrdev.stream), o.timestamp = Date.now(), e && (e.contents[r] = o), o }, getFileDataAsRegularArray: function (e) { if (e.contents && e.contents.subarray) { for (var r = [], t = 0; t < e.usedBytes; ++t)r.push(e.contents[t]); return r } return e.contents }, getFileDataAsTypedArray: function (e) { return e.contents ? e.contents.subarray ? e.contents.subarray(0, e.usedBytes) : new Uint8Array(e.contents) : new Uint8Array }, expandFileStorage: function (e, r) { var t = e.contents ? e.contents.length : 0; if (!(t >= r)) { r = Math.max(r, t * (t < 1048576 ? 2 : 1.125) | 0), 0 != t && (r = Math.max(r, 256)); var n = e.contents; e.contents = new Uint8Array(r), e.usedBytes > 0 && e.contents.set(n.subarray(0, e.usedBytes), 0) } }, resizeFileStorage: function (e, r) { if (e.usedBytes != r) { if (0 == r) return e.contents = null, void (e.usedBytes = 0); if (!e.contents || e.contents.subarray) { var t = e.contents; return e.contents = new Uint8Array(new ArrayBuffer(r)), t && e.contents.set(t.subarray(0, Math.min(r, e.usedBytes))), void (e.usedBytes = r) } if (e.contents || (e.contents = []), e.contents.length > r) e.contents.length = r; else for (; e.contents.length < r;)e.contents.push(0); e.usedBytes = r } }, node_ops: { getattr: function (e) { var r = {}; return r.dev = _e.isChrdev(e.mode) ? e.id : 1, r.ino = e.id, r.mode = e.mode, r.nlink = 1, r.uid = 0, r.gid = 0, r.rdev = e.rdev, _e.isDir(e.mode) ? r.size = 4096 : _e.isFile(e.mode) ? r.size = e.usedBytes : _e.isLink(e.mode) ? r.size = e.link.length : r.size = 0, r.atime = new Date(e.timestamp), r.mtime = new Date(e.timestamp), r.ctime = new Date(e.timestamp), r.blksize = 4096, r.blocks = Math.ceil(r.size / r.blksize), r }, setattr: function (e, r) { void 0 !== r.mode && (e.mode = r.mode), void 0 !== r.timestamp && (e.timestamp = r.timestamp), void 0 !== r.size && ve.resizeFileStorage(e, r.size) }, lookup: function (e, r) { throw _e.genericErrors[2] }, mknod: function (e, r, t, n) { return ve.createNode(e, r, t, n) }, rename: function (e, r, t) { if (_e.isDir(e.mode)) { var n; try { n = _e.lookupNode(r, t) } catch (e) { } if (n) for (var o in n.contents) throw new _e.ErrnoError(39) } delete e.parent.contents[e.name], e.name = t, r.contents[t] = e, e.parent = r }, unlink: function (e, r) { delete e.contents[r] }, rmdir: function (e, r) { var t = _e.lookupNode(e, r); for (var n in t.contents) throw new _e.ErrnoError(39); delete e.contents[r] }, readdir: function (e) { var r = [\".\", \"..\"]; for (var t in e.contents) e.contents.hasOwnProperty(t) && r.push(t); return r }, symlink: function (e, r, t) { var n = ve.createNode(e, r, 41471, 0); return n.link = t, n }, readlink: function (e) { if (!_e.isLink(e.mode)) throw new _e.ErrnoError(22); return e.link } }, stream_ops: { read: function (e, r, t, n, o) { var i = e.node.contents; if (o >= e.node.usedBytes) return 0; var a = Math.min(e.node.usedBytes - o, n); if (a > 8 && i.subarray) r.set(i.subarray(o, o + a), t); else for (var s = 0; s < a; s++)r[t + s] = i[o + s]; return a }, write: function (e, r, t, n, o, i) { if (!n) return 0; var a = e.node; if (a.timestamp = Date.now(), r.subarray && (!a.contents || a.contents.subarray)) { if (0 === a.usedBytes && 0 === o) return a.contents = new Uint8Array(r.subarray(t, t + n)), a.usedBytes = n, n; if (o + n <= a.usedBytes) return a.contents.set(r.subarray(t, t + n), o), n } if (ve.expandFileStorage(a, o + n), a.contents.subarray && r.subarray) a.contents.set(r.subarray(t, t + n), o); else for (var s = 0; s < n; s++)a.contents[o + s] = r[t + s]; return a.usedBytes = Math.max(a.usedBytes, o + n), n }, llseek: function (e, r, t) { var n = r; if (1 === t ? n += e.position : 2 === t && _e.isFile(e.node.mode) && (n += e.node.usedBytes), n < 0) throw new _e.ErrnoError(22); return n }, allocate: function (e, r, t) { ve.expandFileStorage(e.node, r + t), e.node.usedBytes = Math.max(e.node.usedBytes, r + t) }, mmap: function (e, r, t, n, o, i, a) { if (!_e.isFile(e.node.mode)) throw new _e.ErrnoError(19); var s, u, c = e.node.contents; if (2 & a || c.buffer !== r && c.buffer !== r.buffer) { if ((o > 0 || o + n < e.node.usedBytes) && (c = c.subarray ? c.subarray(o, o + n) : Array.prototype.slice.call(c, o, o + n)), u = !0, !(s = Le(n))) throw new _e.ErrnoError(12); r.set(c, s) } else u = !1, s = c.byteOffset; return { ptr: s, allocated: u } }, msync: function (e, r, t, n, o) { if (!_e.isFile(e.node.mode)) throw new _e.ErrnoError(19); return 2 & o ? 0 : (ve.stream_ops.write(e, r, 0, n, t, !1), 0) } } }, ge = { dbs: {}, indexedDB: function () { if (\"undefined\" != typeof indexedDB) return indexedDB; var e = null; return \"object\" == typeof window && (e = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB), y(e, \"IDBFS used, but indexedDB not supported\"), e }, DB_VERSION: 21, DB_STORE_NAME: \"FILE_DATA\", mount: function (e) { return ve.mount.apply(null, arguments) }, syncfs: function (e, r, t) { ge.getLocalSet(e, function (n, o) { if (n) return t(n); ge.getRemoteSet(e, function (e, n) { if (e) return t(e); var i = r ? n : o, a = r ? o : n; ge.reconcile(i, a, t) }) }) }, getDB: function (e, r) { var t, n = ge.dbs[e]; if (n) return r(null, n); try { t = ge.indexedDB().open(e, ge.DB_VERSION) } catch (e) { return r(e) } if (!t) return r(\"Unable to connect to IndexedDB\"); t.onupgradeneeded = function (e) { var r, t = e.target.result, n = e.target.transaction; (r = t.objectStoreNames.contains(ge.DB_STORE_NAME) ? n.objectStore(ge.DB_STORE_NAME) : t.createObjectStore(ge.DB_STORE_NAME)).indexNames.contains(\"timestamp\") || r.createIndex(\"timestamp\", \"timestamp\", { unique: !1 }) }, t.onsuccess = function () { n = t.result, ge.dbs[e] = n, r(null, n) }, t.onerror = function (e) { r(this.error), e.preventDefault() } }, getLocalSet: function (e, r) { var t = {}; function n(e) { return \".\" !== e && \"..\" !== e } function o(e) { return function (r) { return pe.join2(e, r) } } for (var i = _e.readdir(e.mountpoint).filter(n).map(o(e.mountpoint)); i.length;) { var a, s = i.pop(); try { a = _e.stat(s) } catch (e) { return r(e) } _e.isDir(a.mode) && i.push.apply(i, _e.readdir(s).filter(n).map(o(s))), t[s] = { timestamp: a.mtime } } return r(null, { type: \"local\", entries: t }) }, getRemoteSet: function (e, r) { var t = {}; ge.getDB(e.mountpoint, function (e, n) { if (e) return r(e); try { var o = n.transaction([ge.DB_STORE_NAME], \"readonly\"); o.onerror = function (e) { r(this.error), e.preventDefault() }, o.objectStore(ge.DB_STORE_NAME).index(\"timestamp\").openKeyCursor().onsuccess = function (e) { var o = e.target.result; if (!o) return r(null, { type: \"remote\", db: n, entries: t }); t[o.primaryKey] = { timestamp: o.key }, o.continue() } } catch (e) { return r(e) } }) }, loadLocalEntry: function (e, r) { var t, n; try { n = _e.lookupPath(e).node, t = _e.stat(e) } catch (e) { return r(e) } return _e.isDir(t.mode) ? r(null, { timestamp: t.mtime, mode: t.mode }) : _e.isFile(t.mode) ? (n.contents = ve.getFileDataAsTypedArray(n), r(null, { timestamp: t.mtime, mode: t.mode, contents: n.contents })) : r(new Error(\"node type not supported\")) }, storeLocalEntry: function (e, r, t) { try { if (_e.isDir(r.mode)) _e.mkdir(e, r.mode); else { if (!_e.isFile(r.mode)) return t(new Error(\"node type not supported\")); _e.writeFile(e, r.contents, { canOwn: !0 }) } _e.chmod(e, r.mode), _e.utime(e, r.timestamp, r.timestamp) } catch (e) { return t(e) } t(null) }, removeLocalEntry: function (e, r) { try { _e.lookupPath(e); var t = _e.stat(e); _e.isDir(t.mode) ? _e.rmdir(e) : _e.isFile(t.mode) && _e.unlink(e) } catch (e) { return r(e) } r(null) }, loadRemoteEntry: function (e, r, t) { var n = e.get(r); n.onsuccess = function (e) { t(null, e.target.result) }, n.onerror = function (e) { t(this.error), e.preventDefault() } }, storeRemoteEntry: function (e, r, t, n) { var o = e.put(t, r); o.onsuccess = function () { n(null) }, o.onerror = function (e) { n(this.error), e.preventDefault() } }, removeRemoteEntry: function (e, r, t) { var n = e.delete(r); n.onsuccess = function () { t(null) }, n.onerror = function (e) { t(this.error), e.preventDefault() } }, reconcile: function (e, r, t) { var n = 0, o = []; Object.keys(e.entries).forEach(function (t) { var i = e.entries[t], a = r.entries[t]; (!a || i.timestamp > a.timestamp) && (o.push(t), n++) }); var i = []; if (Object.keys(r.entries).forEach(function (t) { r.entries[t], e.entries[t] || (i.push(t), n++) }), !n) return t(null); var a = 0, s = (\"remote\" === e.type ? e.db : r.db).transaction([ge.DB_STORE_NAME], \"readwrite\"), u = s.objectStore(ge.DB_STORE_NAME); function c(e) { return e ? c.errored ? void 0 : (c.errored = !0, t(e)) : ++a >= n ? t(null) : void 0 } s.onerror = function (e) { c(this.error), e.preventDefault() }, o.sort().forEach(function (e) { \"local\" === r.type ? ge.loadRemoteEntry(u, e, function (r, t) { if (r) return c(r); ge.storeLocalEntry(e, t, c) }) : ge.loadLocalEntry(e, function (r, t) { if (r) return c(r); ge.storeRemoteEntry(u, e, t, c) }) }), i.sort().reverse().forEach(function (e) { \"local\" === r.type ? ge.removeLocalEntry(e, c) : ge.removeRemoteEntry(u, e, c) }) } }, ye = { isWindows: !1, staticInit: function () { ye.isWindows = !!process.platform.match(/^win/); var e = process.binding(\"constants\"); e.fs && (e = e.fs), ye.flagsForNodeMap = { 1024: e.O_APPEND, 64: e.O_CREAT, 128: e.O_EXCL, 0: e.O_RDONLY, 2: e.O_RDWR, 4096: e.O_SYNC, 512: e.O_TRUNC, 1: e.O_WRONLY } }, bufferFrom: function (e) { return Buffer.alloc ? Buffer.from(e) : new Buffer(e) }, mount: function (e) { return y(i), ye.createNode(null, \"/\", ye.getMode(e.opts.root), 0) }, createNode: function (e, r, t, n) { if (!_e.isDir(t) && !_e.isFile(t) && !_e.isLink(t)) throw new _e.ErrnoError(22); var o = _e.createNode(e, r, t); return o.node_ops = ye.node_ops, o.stream_ops = ye.stream_ops, o }, getMode: function (e) { var r; try { r = Te.lstatSync(e), ye.isWindows && (r.mode = r.mode | (292 & r.mode) >> 2) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } return r.mode }, realPath: function (e) { for (var r = []; e.parent !== e;)r.push(e.name), e = e.parent; return r.push(e.mount.opts.root), r.reverse(), pe.join.apply(null, r) }, flagsForNode: function (e) { e &= -2097153, e &= -2049, e &= -32769, e &= -524289; var r = 0; for (var t in ye.flagsForNodeMap) e & t && (r |= ye.flagsForNodeMap[t], e ^= t); if (e) throw new _e.ErrnoError(22); return r }, node_ops: { getattr: function (e) { var r, t = ye.realPath(e); try { r = Te.lstatSync(t) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } return ye.isWindows && !r.blksize && (r.blksize = 4096), ye.isWindows && !r.blocks && (r.blocks = (r.size + r.blksize - 1) / r.blksize | 0), { dev: r.dev, ino: r.ino, mode: r.mode, nlink: r.nlink, uid: r.uid, gid: r.gid, rdev: r.rdev, size: r.size, atime: r.atime, mtime: r.mtime, ctime: r.ctime, blksize: r.blksize, blocks: r.blocks } }, setattr: function (e, r) { var t = ye.realPath(e); try { if (void 0 !== r.mode && (Te.chmodSync(t, r.mode), e.mode = r.mode), void 0 !== r.timestamp) { var n = new Date(r.timestamp); Te.utimesSync(t, n, n) } void 0 !== r.size && Te.truncateSync(t, r.size) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, lookup: function (e, r) { var t = pe.join2(ye.realPath(e), r), n = ye.getMode(t); return ye.createNode(e, r, n) }, mknod: function (e, r, t, n) { var o = ye.createNode(e, r, t, n), i = ye.realPath(o); try { _e.isDir(o.mode) ? Te.mkdirSync(i, o.mode) : Te.writeFileSync(i, \"\", { mode: o.mode }) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } return o }, rename: function (e, r, t) { var n = ye.realPath(e), o = pe.join2(ye.realPath(r), t); try { Te.renameSync(n, o) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, unlink: function (e, r) { var t = pe.join2(ye.realPath(e), r); try { Te.unlinkSync(t) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, rmdir: function (e, r) { var t = pe.join2(ye.realPath(e), r); try { Te.rmdirSync(t) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, readdir: function (e) { var r = ye.realPath(e); try { return Te.readdirSync(r) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, symlink: function (e, r, t) { var n = pe.join2(ye.realPath(e), r); try { Te.symlinkSync(t, n) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, readlink: function (e) { var r = ye.realPath(e); try { return r = Te.readlinkSync(r), r = Me.relative(Me.resolve(e.mount.opts.root), r) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } } }, stream_ops: { open: function (e) { var r = ye.realPath(e.node); try { _e.isFile(e.node.mode) && (e.nfd = Te.openSync(r, ye.flagsForNode(e.flags))) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, close: function (e) { try { _e.isFile(e.node.mode) && e.nfd && Te.closeSync(e.nfd) } catch (e) { if (!e.code) throw e; throw new _e.ErrnoError(-e.errno) } }, read: function (e, r, t, n, o) { if (0 === n) return 0; try { return Te.readSync(e.nfd, ye.bufferFrom(r.buffer), t, n, o) } catch (e) { throw new _e.ErrnoError(-e.errno) } }, write: function (e, r, t, n, o) { try { return Te.writeSync(e.nfd, ye.bufferFrom(r.buffer), t, n, o) } catch (e) { throw new _e.ErrnoError(-e.errno) } }, llseek: function (e, r, t) { var n = r; if (1 === t) n += e.position; else if (2 === t && _e.isFile(e.node.mode)) try { n += Te.fstatSync(e.nfd).size } catch (e) { throw new _e.ErrnoError(-e.errno) } if (n < 0) throw new _e.ErrnoError(22); return n } } }, Ee = { DIR_MODE: 16895, FILE_MODE: 33279, reader: null, mount: function (e) { y(u), Ee.reader || (Ee.reader = new FileReaderSync); var r = Ee.createNode(null, \"/\", Ee.DIR_MODE, 0), t = {}; function n(e) { for (var n = e.split(\"/\"), o = r, i = 0; i < n.length - 1; i++) { var a = n.slice(0, i + 1).join(\"/\"); t[a] || (t[a] = Ee.createNode(o, n[i], Ee.DIR_MODE, 0)), o = t[a] } return o } function o(e) { var r = e.split(\"/\"); return r[r.length - 1] } return Array.prototype.forEach.call(e.opts.files || [], function (e) { Ee.createNode(n(e.name), o(e.name), Ee.FILE_MODE, 0, e, e.lastModifiedDate) }), (e.opts.blobs || []).forEach(function (e) { Ee.createNode(n(e.name), o(e.name), Ee.FILE_MODE, 0, e.data) }), (e.opts.packages || []).forEach(function (e) { e.metadata.files.forEach(function (r) { var t = r.filename.substr(1); Ee.createNode(n(t), o(t), Ee.FILE_MODE, 0, e.blob.slice(r.start, r.end)) }) }), r }, createNode: function (e, r, t, n, o, i) { var a = _e.createNode(e, r, t); return a.mode = t, a.node_ops = Ee.node_ops, a.stream_ops = Ee.stream_ops, a.timestamp = (i || new Date).getTime(), y(Ee.FILE_MODE !== Ee.DIR_MODE), t === Ee.FILE_MODE ? (a.size = o.size, a.contents = o) : (a.size = 4096, a.contents = {}), e && (e.contents[r] = a), a }, node_ops: { getattr: function (e) { return { dev: 1, ino: void 0, mode: e.mode, nlink: 1, uid: 0, gid: 0, rdev: void 0, size: e.size, atime: new Date(e.timestamp), mtime: new Date(e.timestamp), ctime: new Date(e.timestamp), blksize: 4096, blocks: Math.ceil(e.size / 4096) } }, setattr: function (e, r) { void 0 !== r.mode && (e.mode = r.mode), void 0 !== r.timestamp && (e.timestamp = r.timestamp) }, lookup: function (e, r) { throw new _e.ErrnoError(2) }, mknod: function (e, r, t, n) { throw new _e.ErrnoError(1) }, rename: function (e, r, t) { throw new _e.ErrnoError(1) }, unlink: function (e, r) { throw new _e.ErrnoError(1) }, rmdir: function (e, r) { throw new _e.ErrnoError(1) }, readdir: function (e) { var r = [\".\", \"..\"]; for (var t in e.contents) e.contents.hasOwnProperty(t) && r.push(t); return r }, symlink: function (e, r, t) { throw new _e.ErrnoError(1) }, readlink: function (e) { throw new _e.ErrnoError(1) } }, stream_ops: { read: function (e, r, t, n, o) { if (o >= e.node.size) return 0; var i = e.node.contents.slice(o, o + n), a = Ee.reader.readAsArrayBuffer(i); return r.set(new Uint8Array(a), t), i.size }, write: function (e, r, t, n, o) { throw new _e.ErrnoError(5) }, llseek: function (e, r, t) { var n = r; if (1 === t ? n += e.position : 2 === t && _e.isFile(e.node.mode) && (n += e.node.size), n < 0) throw new _e.ErrnoError(22); return n } } }, _e = { root: null, mounts: [], devices: {}, streams: [], nextInode: 1, nameTable: null, currentPath: \"/\", initialized: !1, ignorePermissions: !0, trackingDelegate: {}, tracking: { openFlags: { READ: 1, WRITE: 2 } }, ErrnoError: null, genericErrors: {}, filesystems: null, syncFSRequests: 0, handleFSError: function (e) { if (!(e instanceof _e.ErrnoError)) throw e + \" : \" + j(); return me(e.errno) }, lookupPath: function (e, r) { if (r = r || {}, !(e = he.resolve(_e.cwd(), e))) return { path: \"\", node: null }; var t = { follow_mount: !0, recurse_count: 0 }; for (var n in t) void 0 === r[n] && (r[n] = t[n]); if (r.recurse_count > 8) throw new _e.ErrnoError(40); for (var o = pe.normalizeArray(e.split(\"/\").filter(function (e) { return !!e }), !1), i = _e.root, a = \"/\", s = 0; s < o.length; s++) { var u = s === o.length - 1; if (u && r.parent) break; if (i = _e.lookupNode(i, o[s]), a = pe.join2(a, o[s]), _e.isMountpoint(i) && (!u || u && r.follow_mount) && (i = i.mounted.root), !u || r.follow) for (var c = 0; _e.isLink(i.mode);) { var f = _e.readlink(a); if (a = he.resolve(pe.dirname(a), f), i = _e.lookupPath(a, { recurse_count: r.recurse_count }).node, c++ > 40) throw new _e.ErrnoError(40) } } return { path: a, node: i } }, getPath: function (e) { for (var r; ;) { if (_e.isRoot(e)) { var t = e.mount.mountpoint; return r ? \"/\" !== t[t.length - 1] ? t + \"/\" + r : t + r : t } r = r ? e.name + \"/\" + r : e.name, e = e.parent } }, hashName: function (e, r) { for (var t = 0, n = 0; n < r.length; n++)t = (t << 5) - t + r.charCodeAt(n) | 0; return (e + t >>> 0) % _e.nameTable.length }, hashAddNode: function (e) { var r = _e.hashName(e.parent.id, e.name); e.name_next = _e.nameTable[r], _e.nameTable[r] = e }, hashRemoveNode: function (e) { var r = _e.hashName(e.parent.id, e.name); if (_e.nameTable[r] === e) _e.nameTable[r] = e.name_next; else for (var t = _e.nameTable[r]; t;) { if (t.name_next === e) { t.name_next = e.name_next; break } t = t.name_next } }, lookupNode: function (e, r) { var t = _e.mayLookup(e); if (t) throw new _e.ErrnoError(t, e); for (var n = _e.hashName(e.id, r), o = _e.nameTable[n]; o; o = o.name_next) { var i = o.name; if (o.parent.id === e.id && i === r) return o } return _e.lookup(e, r) }, createNode: function (e, r, t, n) { _e.FSNode || (_e.FSNode = function (e, r, t, n) { e || (e = this), this.parent = e, this.mount = e.mount, this.mounted = null, this.id = _e.nextInode++, this.name = r, this.mode = t, this.node_ops = {}, this.stream_ops = {}, this.rdev = n }, _e.FSNode.prototype = {}, Object.defineProperties(_e.FSNode.prototype, { read: { get: function () { return 365 == (365 & this.mode) }, set: function (e) { e ? this.mode |= 365 : this.mode &= -366 } }, write: { get: function () { return 146 == (146 & this.mode) }, set: function (e) { e ? this.mode |= 146 : this.mode &= -147 } }, isFolder: { get: function () { return _e.isDir(this.mode) } }, isDevice: { get: function () { return _e.isChrdev(this.mode) } } })); var o = new _e.FSNode(e, r, t, n); return _e.hashAddNode(o), o }, destroyNode: function (e) { _e.hashRemoveNode(e) }, isRoot: function (e) { return e === e.parent }, isMountpoint: function (e) { return !!e.mounted }, isFile: function (e) { return 32768 == (61440 & e) }, isDir: function (e) { return 16384 == (61440 & e) }, isLink: function (e) { return 40960 == (61440 & e) }, isChrdev: function (e) { return 8192 == (61440 & e) }, isBlkdev: function (e) { return 24576 == (61440 & e) }, isFIFO: function (e) { return 4096 == (61440 & e) }, isSocket: function (e) { return 49152 == (49152 & e) }, flagModes: { r: 0, rs: 1052672, \"r+\": 2, w: 577, wx: 705, xw: 705, \"w+\": 578, \"wx+\": 706, \"xw+\": 706, a: 1089, ax: 1217, xa: 1217, \"a+\": 1090, \"ax+\": 1218, \"xa+\": 1218 }, modeStringToFlags: function (e) { var r = _e.flagModes[e]; if (void 0 === r) throw new Error(\"Unknown file open mode: \" + e); return r }, flagsToPermissionString: function (e) { var r = [\"r\", \"w\", \"rw\"][3 & e]; return 512 & e && (r += \"w\"), r }, nodePermissions: function (e, r) { return _e.ignorePermissions ? 0 : (-1 === r.indexOf(\"r\") || 292 & e.mode) && (-1 === r.indexOf(\"w\") || 146 & e.mode) && (-1 === r.indexOf(\"x\") || 73 & e.mode) ? 0 : 13 }, mayLookup: function (e) { var r = _e.nodePermissions(e, \"x\"); return r || (e.node_ops.lookup ? 0 : 13) }, mayCreate: function (e, r) { try { return _e.lookupNode(e, r), 17 } catch (e) { } return _e.nodePermissions(e, \"wx\") }, mayDelete: function (e, r, t) { var n; try { n = _e.lookupNode(e, r) } catch (e) { return e.errno } var o = _e.nodePermissions(e, \"wx\"); if (o) return o; if (t) { if (!_e.isDir(n.mode)) return 20; if (_e.isRoot(n) || _e.getPath(n) === _e.cwd()) return 16 } else if (_e.isDir(n.mode)) return 21; return 0 }, mayOpen: function (e, r) { return e ? _e.isLink(e.mode) ? 40 : _e.isDir(e.mode) && (\"r\" !== _e.flagsToPermissionString(r) || 512 & r) ? 21 : _e.nodePermissions(e, _e.flagsToPermissionString(r)) : 2 }, MAX_OPEN_FDS: 4096, nextfd: function (e, r) { e = e || 0, r = r || _e.MAX_OPEN_FDS; for (var t = e; t <= r; t++)if (!_e.streams[t]) return t; throw new _e.ErrnoError(24) }, getStream: function (e) { return _e.streams[e] }, createStream: function (e, r, t) { _e.FSStream || (_e.FSStream = function () { }, _e.FSStream.prototype = {}, Object.defineProperties(_e.FSStream.prototype, { object: { get: function () { return this.node }, set: function (e) { this.node = e } }, isRead: { get: function () { return 1 != (2097155 & this.flags) } }, isWrite: { get: function () { return 0 != (2097155 & this.flags) } }, isAppend: { get: function () { return 1024 & this.flags } } })); var n = new _e.FSStream; for (var o in e) n[o] = e[o]; e = n; var i = _e.nextfd(r, t); return e.fd = i, _e.streams[i] = e, e }, closeStream: function (e) { _e.streams[e] = null }, chrdev_stream_ops: { open: function (e) { var r = _e.getDevice(e.node.rdev); e.stream_ops = r.stream_ops, e.stream_ops.open && e.stream_ops.open(e) }, llseek: function () { throw new _e.ErrnoError(29) } }, major: function (e) { return e >> 8 }, minor: function (e) { return 255 & e }, makedev: function (e, r) { return e << 8 | r }, registerDevice: function (e, r) { _e.devices[e] = { stream_ops: r } }, getDevice: function (e) { return _e.devices[e] }, getMounts: function (e) { for (var r = [], t = [e]; t.length;) { var n = t.pop(); r.push(n), t.push.apply(t, n.mounts) } return r }, syncfs: function (e, r) { \"function\" == typeof e && (r = e, e = !1), _e.syncFSRequests++, _e.syncFSRequests > 1 && console.log(\"warning: \" + _e.syncFSRequests + \" FS.syncfs operations in flight at once, probably just doing extra work\"); var t = _e.getMounts(_e.root.mount), n = 0; function o(e) { return _e.syncFSRequests--, r(e) } function i(e) { if (e) return i.errored ? void 0 : (i.errored = !0, o(e)); ++n >= t.length && o(null) } t.forEach(function (r) { if (!r.type.syncfs) return i(null); r.type.syncfs(r, e, i) }) }, mount: function (e, r, t) { var n, o = \"/\" === t, i = !t; if (o && _e.root) throw new _e.ErrnoError(16); if (!o && !i) { var a = _e.lookupPath(t, { follow_mount: !1 }); if (t = a.path, n = a.node, _e.isMountpoint(n)) throw new _e.ErrnoError(16); if (!_e.isDir(n.mode)) throw new _e.ErrnoError(20) } var s = { type: e, opts: r, mountpoint: t, mounts: [] }, u = e.mount(s); return u.mount = s, s.root = u, o ? _e.root = u : n && (n.mounted = s, n.mount && n.mount.mounts.push(s)), u }, unmount: function (e) { var r = _e.lookupPath(e, { follow_mount: !1 }); if (!_e.isMountpoint(r.node)) throw new _e.ErrnoError(22); var t = r.node, n = t.mounted, o = _e.getMounts(n); Object.keys(_e.nameTable).forEach(function (e) { for (var r = _e.nameTable[e]; r;) { var t = r.name_next; -1 !== o.indexOf(r.mount) && _e.destroyNode(r), r = t } }), t.mounted = null; var i = t.mount.mounts.indexOf(n); t.mount.mounts.splice(i, 1) }, lookup: function (e, r) { return e.node_ops.lookup(e, r) }, mknod: function (e, r, t) { var n = _e.lookupPath(e, { parent: !0 }).node, o = pe.basename(e); if (!o || \".\" === o || \"..\" === o) throw new _e.ErrnoError(22); var i = _e.mayCreate(n, o); if (i) throw new _e.ErrnoError(i); if (!n.node_ops.mknod) throw new _e.ErrnoError(1); return n.node_ops.mknod(n, o, r, t) }, create: function (e, r) { return r = void 0 !== r ? r : 438, r &= 4095, r |= 32768, _e.mknod(e, r, 0) }, mkdir: function (e, r) { return r = void 0 !== r ? r : 511, r &= 1023, r |= 16384, _e.mknod(e, r, 0) }, mkdirTree: function (e, r) { for (var t = e.split(\"/\"), n = \"\", o = 0; o < t.length; ++o)if (t[o]) { n += \"/\" + t[o]; try { _e.mkdir(n, r) } catch (e) { if (17 != e.errno) throw e } } }, mkdev: function (e, r, t) { return void 0 === t && (t = r, r = 438), r |= 8192, _e.mknod(e, r, t) }, symlink: function (e, r) { if (!he.resolve(e)) throw new _e.ErrnoError(2); var t = _e.lookupPath(r, { parent: !0 }).node; if (!t) throw new _e.ErrnoError(2); var n = pe.basename(r), o = _e.mayCreate(t, n); if (o) throw new _e.ErrnoError(o); if (!t.node_ops.symlink) throw new _e.ErrnoError(1); return t.node_ops.symlink(t, n, e) }, rename: function (e, r) { var t, n, o = pe.dirname(e), i = pe.dirname(r), a = pe.basename(e), s = pe.basename(r); try { t = _e.lookupPath(e, { parent: !0 }).node, n = _e.lookupPath(r, { parent: !0 }).node } catch (e) { throw new _e.ErrnoError(16) } if (!t || !n) throw new _e.ErrnoError(2); if (t.mount !== n.mount) throw new _e.ErrnoError(18); var u, c = _e.lookupNode(t, a), f = he.relative(e, i); if (\".\" !== f.charAt(0)) throw new _e.ErrnoError(22); if (\".\" !== (f = he.relative(r, o)).charAt(0)) throw new _e.ErrnoError(39); try { u = _e.lookupNode(n, s) } catch (e) { } if (c !== u) { var l = _e.isDir(c.mode), d = _e.mayDelete(t, a, l); if (d) throw new _e.ErrnoError(d); if (d = u ? _e.mayDelete(n, s, l) : _e.mayCreate(n, s)) throw new _e.ErrnoError(d); if (!t.node_ops.rename) throw new _e.ErrnoError(1); if (_e.isMountpoint(c) || u && _e.isMountpoint(u)) throw new _e.ErrnoError(16); if (n !== t && (d = _e.nodePermissions(t, \"w\"))) throw new _e.ErrnoError(d); try { _e.trackingDelegate.willMovePath && _e.trackingDelegate.willMovePath(e, r) } catch (t) { console.log(\"FS.trackingDelegate['willMovePath']('\" + e + \"', '\" + r + \"') threw an exception: \" + t.message) } _e.hashRemoveNode(c); try { t.node_ops.rename(c, n, s) } catch (e) { throw e } finally { _e.hashAddNode(c) } try { _e.trackingDelegate.onMovePath && _e.trackingDelegate.onMovePath(e, r) } catch (t) { console.log(\"FS.trackingDelegate['onMovePath']('\" + e + \"', '\" + r + \"') threw an exception: \" + t.message) } } }, rmdir: function (e) { var r = _e.lookupPath(e, { parent: !0 }).node, t = pe.basename(e), n = _e.lookupNode(r, t), o = _e.mayDelete(r, t, !0); if (o) throw new _e.ErrnoError(o); if (!r.node_ops.rmdir) throw new _e.ErrnoError(1); if (_e.isMountpoint(n)) throw new _e.ErrnoError(16); try { _e.trackingDelegate.willDeletePath && _e.trackingDelegate.willDeletePath(e) } catch (r) { console.log(\"FS.trackingDelegate['willDeletePath']('\" + e + \"') threw an exception: \" + r.message) } r.node_ops.rmdir(r, t), _e.destroyNode(n); try { _e.trackingDelegate.onDeletePath && _e.trackingDelegate.onDeletePath(e) } catch (r) { console.log(\"FS.trackingDelegate['onDeletePath']('\" + e + \"') threw an exception: \" + r.message) } }, readdir: function (e) { var r = _e.lookupPath(e, { follow: !0 }).node; if (!r.node_ops.readdir) throw new _e.ErrnoError(20); return r.node_ops.readdir(r) }, unlink: function (e) { var r = _e.lookupPath(e, { parent: !0 }).node, t = pe.basename(e), n = _e.lookupNode(r, t), o = _e.mayDelete(r, t, !1); if (o) throw new _e.ErrnoError(o); if (!r.node_ops.unlink) throw new _e.ErrnoError(1); if (_e.isMountpoint(n)) throw new _e.ErrnoError(16); try { _e.trackingDelegate.willDeletePath && _e.trackingDelegate.willDeletePath(e) } catch (r) { console.log(\"FS.trackingDelegate['willDeletePath']('\" + e + \"') threw an exception: \" + r.message) } r.node_ops.unlink(r, t), _e.destroyNode(n); try { _e.trackingDelegate.onDeletePath && _e.trackingDelegate.onDeletePath(e) } catch (r) { console.log(\"FS.trackingDelegate['onDeletePath']('\" + e + \"') threw an exception: \" + r.message) } }, readlink: function (e) { var r = _e.lookupPath(e).node; if (!r) throw new _e.ErrnoError(2); if (!r.node_ops.readlink) throw new _e.ErrnoError(22); return he.resolve(_e.getPath(r.parent), r.node_ops.readlink(r)) }, stat: function (e, r) { var t = _e.lookupPath(e, { follow: !r }).node; if (!t) throw new _e.ErrnoError(2); if (!t.node_ops.getattr) throw new _e.ErrnoError(1); return t.node_ops.getattr(t) }, lstat: function (e) { return _e.stat(e, !0) }, chmod: function (e, r, t) { var n; if (!(n = \"string\" == typeof e ? _e.lookupPath(e, { follow: !t }).node : e).node_ops.setattr) throw new _e.ErrnoError(1); n.node_ops.setattr(n, { mode: 4095 & r | -4096 & n.mode, timestamp: Date.now() }) }, lchmod: function (e, r) { _e.chmod(e, r, !0) }, fchmod: function (e, r) { var t = _e.getStream(e); if (!t) throw new _e.ErrnoError(9); _e.chmod(t.node, r) }, chown: function (e, r, t, n) { var o; if (!(o = \"string\" == typeof e ? _e.lookupPath(e, { follow: !n }).node : e).node_ops.setattr) throw new _e.ErrnoError(1); o.node_ops.setattr(o, { timestamp: Date.now() }) }, lchown: function (e, r, t) { _e.chown(e, r, t, !0) }, fchown: function (e, r, t) { var n = _e.getStream(e); if (!n) throw new _e.ErrnoError(9); _e.chown(n.node, r, t) }, truncate: function (e, r) { if (r < 0) throw new _e.ErrnoError(22); var t; if (!(t = \"string\" == typeof e ? _e.lookupPath(e, { follow: !0 }).node : e).node_ops.setattr) throw new _e.ErrnoError(1); if (_e.isDir(t.mode)) throw new _e.ErrnoError(21); if (!_e.isFile(t.mode)) throw new _e.ErrnoError(22); var n = _e.nodePermissions(t, \"w\"); if (n) throw new _e.ErrnoError(n); t.node_ops.setattr(t, { size: r, timestamp: Date.now() }) }, ftruncate: function (e, r) { var t = _e.getStream(e); if (!t) throw new _e.ErrnoError(9); if (0 == (2097155 & t.flags)) throw new _e.ErrnoError(22); _e.truncate(t.node, r) }, utime: function (e, r, t) { var n = _e.lookupPath(e, { follow: !0 }).node; n.node_ops.setattr(n, { timestamp: Math.max(r, t) }) }, open: function (e, r, t, o, i) { if (\"\" === e) throw new _e.ErrnoError(2); var a; if (t = void 0 === t ? 438 : t, t = 64 & (r = \"string\" == typeof r ? _e.modeStringToFlags(r) : r) ? 4095 & t | 32768 : 0, \"object\" == typeof e) a = e; else { e = pe.normalize(e); try { a = _e.lookupPath(e, { follow: !(131072 & r) }).node } catch (e) { } } var s = !1; if (64 & r) if (a) { if (128 & r) throw new _e.ErrnoError(17) } else a = _e.mknod(e, t, 0), s = !0; if (!a) throw new _e.ErrnoError(2); if (_e.isChrdev(a.mode) && (r &= -513), 65536 & r && !_e.isDir(a.mode)) throw new _e.ErrnoError(20); if (!s) { var u = _e.mayOpen(a, r); if (u) throw new _e.ErrnoError(u) } 512 & r && _e.truncate(a, 0), r &= -641; var c = _e.createStream({ node: a, path: _e.getPath(a), flags: r, seekable: !0, position: 0, stream_ops: a.stream_ops, ungotten: [], error: !1 }, o, i); c.stream_ops.open && c.stream_ops.open(c), !n.logReadFiles || 1 & r || (_e.readFiles || (_e.readFiles = {}), e in _e.readFiles || (_e.readFiles[e] = 1, console.log(\"FS.trackingDelegate error on read file: \" + e))); try { if (_e.trackingDelegate.onOpenFile) { var f = 0; 1 != (2097155 & r) && (f |= _e.tracking.openFlags.READ), 0 != (2097155 & r) && (f |= _e.tracking.openFlags.WRITE), _e.trackingDelegate.onOpenFile(e, f) } } catch (r) { console.log(\"FS.trackingDelegate['onOpenFile']('\" + e + \"', flags) threw an exception: \" + r.message) } return c }, close: function (e) { if (_e.isClosed(e)) throw new _e.ErrnoError(9); e.getdents && (e.getdents = null); try { e.stream_ops.close && e.stream_ops.close(e) } catch (e) { throw e } finally { _e.closeStream(e.fd) } e.fd = null }, isClosed: function (e) { return null === e.fd }, llseek: function (e, r, t) { if (_e.isClosed(e)) throw new _e.ErrnoError(9); if (!e.seekable || !e.stream_ops.llseek) throw new _e.ErrnoError(29); if (0 != t && 1 != t && 2 != t) throw new _e.ErrnoError(22); return e.position = e.stream_ops.llseek(e, r, t), e.ungotten = [], e.position }, read: function (e, r, t, n, o) { if (n < 0 || o < 0) throw new _e.ErrnoError(22); if (_e.isClosed(e)) throw new _e.ErrnoError(9); if (1 == (2097155 & e.flags)) throw new _e.ErrnoError(9); if (_e.isDir(e.node.mode)) throw new _e.ErrnoError(21); if (!e.stream_ops.read) throw new _e.ErrnoError(22); var i = void 0 !== o; if (i) { if (!e.seekable) throw new _e.ErrnoError(29) } else o = e.position; var a = e.stream_ops.read(e, r, t, n, o); return i || (e.position += a), a }, write: function (e, r, t, n, o, i) { if (n < 0 || o < 0) throw new _e.ErrnoError(22); if (_e.isClosed(e)) throw new _e.ErrnoError(9); if (0 == (2097155 & e.flags)) throw new _e.ErrnoError(9); if (_e.isDir(e.node.mode)) throw new _e.ErrnoError(21); if (!e.stream_ops.write) throw new _e.ErrnoError(22); 1024 & e.flags && _e.llseek(e, 0, 2); var a = void 0 !== o; if (a) { if (!e.seekable) throw new _e.ErrnoError(29) } else o = e.position; var s = e.stream_ops.write(e, r, t, n, o, i); a || (e.position += s); try { e.path && _e.trackingDelegate.onWriteToFile && _e.trackingDelegate.onWriteToFile(e.path) } catch (r) { console.log(\"FS.trackingDelegate['onWriteToFile']('\" + e.path + \"') threw an exception: \" + r.message) } return s }, allocate: function (e, r, t) { if (_e.isClosed(e)) throw new _e.ErrnoError(9); if (r < 0 || t <= 0) throw new _e.ErrnoError(22); if (0 == (2097155 & e.flags)) throw new _e.ErrnoError(9); if (!_e.isFile(e.node.mode) && !_e.isDir(e.node.mode)) throw new _e.ErrnoError(19); if (!e.stream_ops.allocate) throw new _e.ErrnoError(95); e.stream_ops.allocate(e, r, t) }, mmap: function (e, r, t, n, o, i, a) { if (1 == (2097155 & e.flags)) throw new _e.ErrnoError(13); if (!e.stream_ops.mmap) throw new _e.ErrnoError(19); return e.stream_ops.mmap(e, r, t, n, o, i, a) }, msync: function (e, r, t, n, o) { return e && e.stream_ops.msync ? e.stream_ops.msync(e, r, t, n, o) : 0 }, munmap: function (e) { return 0 }, ioctl: function (e, r, t) { if (!e.stream_ops.ioctl) throw new _e.ErrnoError(25); return e.stream_ops.ioctl(e, r, t) }, readFile: function (e, r) { if ((r = r || {}).flags = r.flags || \"r\", r.encoding = r.encoding || \"binary\", \"utf8\" !== r.encoding && \"binary\" !== r.encoding) throw new Error('Invalid encoding type \"' + r.encoding + '\"'); var t, n = _e.open(e, r.flags), o = _e.stat(e).size, i = new Uint8Array(o); return _e.read(n, i, 0, o, 0), \"utf8\" === r.encoding ? t = N(i, 0) : \"binary\" === r.encoding && (t = i), _e.close(n), t }, writeFile: function (e, r, t) { (t = t || {}).flags = t.flags || \"w\"; var n = _e.open(e, t.flags, t.mode); if (\"string\" == typeof r) { var o = new Uint8Array(L(r) + 1), i = z(r, o, 0, o.length); _e.write(n, o, 0, i, void 0, t.canOwn) } else { if (!ArrayBuffer.isView(r)) throw new Error(\"Unsupported data type\"); _e.write(n, r, 0, r.byteLength, void 0, t.canOwn) } _e.close(n) }, cwd: function () { return _e.currentPath }, chdir: function (e) { var r = _e.lookupPath(e, { follow: !0 }); if (null === r.node) throw new _e.ErrnoError(2); if (!_e.isDir(r.node.mode)) throw new _e.ErrnoError(20); var t = _e.nodePermissions(r.node, \"x\"); if (t) throw new _e.ErrnoError(t); _e.currentPath = r.path }, createDefaultDirectories: function () { _e.mkdir(\"/tmp\"), _e.mkdir(\"/home\"), _e.mkdir(\"/home/web_user\") }, createDefaultDevices: function () { var e; if (_e.mkdir(\"/dev\"), _e.registerDevice(_e.makedev(1, 3), { read: function () { return 0 }, write: function (e, r, t, n, o) { return n } }), _e.mkdev(\"/dev/null\", _e.makedev(1, 3)), we.register(_e.makedev(5, 0), we.default_tty_ops), we.register(_e.makedev(6, 0), we.default_tty1_ops), _e.mkdev(\"/dev/tty\", _e.makedev(5, 0)), _e.mkdev(\"/dev/tty1\", _e.makedev(6, 0)), \"object\" == typeof crypto && \"function\" == typeof crypto.getRandomValues) { var r = new Uint8Array(1); e = function () { return crypto.getRandomValues(r), r[0] } } else if (i) try { var t = require(\"crypto\"); e = function () { return t.randomBytes(1)[0] } } catch (e) { } e || (e = function () { Ke(\"random_device\") }), _e.createDevice(\"/dev\", \"random\", e), _e.createDevice(\"/dev\", \"urandom\", e), _e.mkdir(\"/dev/shm\"), _e.mkdir(\"/dev/shm/tmp\") }, createSpecialDirectories: function () { _e.mkdir(\"/proc\"), _e.mkdir(\"/proc/self\"), _e.mkdir(\"/proc/self/fd\"), _e.mount({ mount: function () { var e = _e.createNode(\"/proc/self\", \"fd\", 16895, 73); return e.node_ops = { lookup: function (e, r) { var t = +r, n = _e.getStream(t); if (!n) throw new _e.ErrnoError(9); var o = { parent: null, mount: { mountpoint: \"fake\" }, node_ops: { readlink: function () { return n.path } } }; return o.parent = o, o } }, e } }, {}, \"/proc/self/fd\") }, createStandardStreams: function () { n.stdin ? _e.createDevice(\"/dev\", \"stdin\", n.stdin) : _e.symlink(\"/dev/tty\", \"/dev/stdin\"), n.stdout ? _e.createDevice(\"/dev\", \"stdout\", null, n.stdout) : _e.symlink(\"/dev/tty\", \"/dev/stdout\"), n.stderr ? _e.createDevice(\"/dev\", \"stderr\", null, n.stderr) : _e.symlink(\"/dev/tty1\", \"/dev/stderr\"), _e.open(\"/dev/stdin\", \"r\"), _e.open(\"/dev/stdout\", \"w\"), _e.open(\"/dev/stderr\", \"w\") }, ensureErrnoError: function () { _e.ErrnoError || (_e.ErrnoError = function (e, r) { this.node = r, this.setErrno = function (e) { this.errno = e }, this.setErrno(e), this.message = \"FS error\", this.stack && Object.defineProperty(this, \"stack\", { value: (new Error).stack, writable: !0 }) }, _e.ErrnoError.prototype = new Error, _e.ErrnoError.prototype.constructor = _e.ErrnoError, [2].forEach(function (e) { _e.genericErrors[e] = new _e.ErrnoError(e), _e.genericErrors[e].stack = \"<generic error, no stack>\" })) }, staticInit: function () { _e.ensureErrnoError(), _e.nameTable = new Array(4096), _e.mount(ve, {}, \"/\"), _e.createDefaultDirectories(), _e.createDefaultDevices(), _e.createSpecialDirectories(), _e.filesystems = { MEMFS: ve, IDBFS: ge, NODEFS: ye, WORKERFS: Ee } }, init: function (e, r, t) { _e.init.initialized = !0, _e.ensureErrnoError(), n.stdin = e || n.stdin, n.stdout = r || n.stdout, n.stderr = t || n.stderr, _e.createStandardStreams() }, quit: function () { _e.init.initialized = !1; var e = n._fflush; e && e(0); for (var r = 0; r < _e.streams.length; r++) { var t = _e.streams[r]; t && _e.close(t) } }, getMode: function (e, r) { var t = 0; return e && (t |= 365), r && (t |= 146), t }, joinPath: function (e, r) { var t = pe.join.apply(null, e); return r && \"/\" == t[0] && (t = t.substr(1)), t }, absolutePath: function (e, r) { return he.resolve(r, e) }, standardizePath: function (e) { return pe.normalize(e) }, findObject: function (e, r) { var t = _e.analyzePath(e, r); return t.exists ? t.object : (me(t.error), null) }, analyzePath: function (e, r) { try { e = (n = _e.lookupPath(e, { follow: !r })).path } catch (e) { } var t = { isRoot: !1, exists: !1, error: 0, name: null, path: null, object: null, parentExists: !1, parentPath: null, parentObject: null }; try { var n = _e.lookupPath(e, { parent: !0 }); t.parentExists = !0, t.parentPath = n.path, t.parentObject = n.node, t.name = pe.basename(e), n = _e.lookupPath(e, { follow: !r }), t.exists = !0, t.path = n.path, t.object = n.node, t.name = n.node.name, t.isRoot = \"/\" === n.path } catch (e) { t.error = e.errno } return t }, createFolder: function (e, r, t, n) { var o = pe.join2(\"string\" == typeof e ? e : _e.getPath(e), r), i = _e.getMode(t, n); return _e.mkdir(o, i) }, createPath: function (e, r, t, n) { e = \"string\" == typeof e ? e : _e.getPath(e); for (var o = r.split(\"/\").reverse(); o.length;) { var i = o.pop(); if (i) { var a = pe.join2(e, i); try { _e.mkdir(a) } catch (e) { } e = a } } return a }, createFile: function (e, r, t, n, o) { var i = pe.join2(\"string\" == typeof e ? e : _e.getPath(e), r), a = _e.getMode(n, o); return _e.create(i, a) }, createDataFile: function (e, r, t, n, o, i) { var a = r ? pe.join2(\"string\" == typeof e ? e : _e.getPath(e), r) : e, s = _e.getMode(n, o), u = _e.create(a, s); if (t) { if (\"string\" == typeof t) { for (var c = new Array(t.length), f = 0, l = t.length; f < l; ++f)c[f] = t.charCodeAt(f); t = c } _e.chmod(u, 146 | s); var d = _e.open(u, \"w\"); _e.write(d, t, 0, t.length, 0, i), _e.close(d), _e.chmod(u, s) } return u }, createDevice: function (e, r, t, n) { var o = pe.join2(\"string\" == typeof e ? e : _e.getPath(e), r), i = _e.getMode(!!t, !!n); _e.createDevice.major || (_e.createDevice.major = 64); var a = _e.makedev(_e.createDevice.major++, 0); return _e.registerDevice(a, { open: function (e) { e.seekable = !1 }, close: function (e) { n && n.buffer && n.buffer.length && n(10) }, read: function (e, r, n, o, i) { for (var a = 0, s = 0; s < o; s++) { var u; try { u = t() } catch (e) { throw new _e.ErrnoError(5) } if (void 0 === u && 0 === a) throw new _e.ErrnoError(11); if (null == u) break; a++, r[n + s] = u } return a && (e.node.timestamp = Date.now()), a }, write: function (e, r, t, o, i) { for (var a = 0; a < o; a++)try { n(r[t + a]) } catch (e) { throw new _e.ErrnoError(5) } return o && (e.node.timestamp = Date.now()), a } }), _e.mkdev(o, i, a) }, createLink: function (e, r, t, n, o) { var i = pe.join2(\"string\" == typeof e ? e : _e.getPath(e), r); return _e.symlink(t, i) }, forceLoadFile: function (e) { if (e.isDevice || e.isFolder || e.link || e.contents) return !0; var r = !0; if (\"undefined\" != typeof XMLHttpRequest) throw new Error(\"Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.\"); if (!n.read) throw new Error(\"Cannot load without read() or XMLHttpRequest.\"); try { e.contents = Be(n.read(e.url), !0), e.usedBytes = e.contents.length } catch (e) { r = !1 } return r || me(5), r }, createLazyFile: function (e, r, t, n, o) { function i() { this.lengthKnown = !1, this.chunks = [] } if (i.prototype.get = function (e) { if (!(e > this.length - 1 || e < 0)) { var r = e % this.chunkSize, t = e / this.chunkSize | 0; return this.getter(t)[r] } }, i.prototype.setDataGetter = function (e) { this.getter = e }, i.prototype.cacheLength = function () { var e = new XMLHttpRequest; if (e.open(\"HEAD\", t, !1), e.send(null), !(e.status >= 200 && e.status < 300 || 304 === e.status)) throw new Error(\"Couldn't load \" + t + \". Status: \" + e.status); var r, n = Number(e.getResponseHeader(\"Content-length\")), o = (r = e.getResponseHeader(\"Accept-Ranges\")) && \"bytes\" === r, i = (r = e.getResponseHeader(\"Content-Encoding\")) && \"gzip\" === r, a = 1048576; o || (a = n); var s = this; s.setDataGetter(function (e) { var r = e * a, o = (e + 1) * a - 1; if (o = Math.min(o, n - 1), void 0 === s.chunks[e] && (s.chunks[e] = function (e, r) { if (e > r) throw new Error(\"invalid range (\" + e + \", \" + r + \") or no bytes requested!\"); if (r > n - 1) throw new Error(\"only \" + n + \" bytes available! programmer error!\"); var o = new XMLHttpRequest; if (o.open(\"GET\", t, !1), n !== a && o.setRequestHeader(\"Range\", \"bytes=\" + e + \"-\" + r), \"undefined\" != typeof Uint8Array && (o.responseType = \"arraybuffer\"), o.overrideMimeType && o.overrideMimeType(\"text/plain; charset=x-user-defined\"), o.send(null), !(o.status >= 200 && o.status < 300 || 304 === o.status)) throw new Error(\"Couldn't load \" + t + \". Status: \" + o.status); return void 0 !== o.response ? new Uint8Array(o.response || []) : Be(o.responseText || \"\", !0) }(r, o)), void 0 === s.chunks[e]) throw new Error(\"doXHR failed!\"); return s.chunks[e] }), !i && n || (a = n = 1, n = this.getter(0).length, a = n, console.log(\"LazyFiles on gzip forces download of the whole file when length is accessed\")), this._length = n, this._chunkSize = a, this.lengthKnown = !0 }, \"undefined\" != typeof XMLHttpRequest) { if (!u) throw \"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc\"; var a = new i; Object.defineProperties(a, { length: { get: function () { return this.lengthKnown || this.cacheLength(), this._length } }, chunkSize: { get: function () { return this.lengthKnown || this.cacheLength(), this._chunkSize } } }); var s = { isDevice: !1, contents: a } } else s = { isDevice: !1, url: t }; var c = _e.createFile(e, r, s, n, o); s.contents ? c.contents = s.contents : s.url && (c.contents = null, c.url = s.url), Object.defineProperties(c, { usedBytes: { get: function () { return this.contents.length } } }); var f = {}; return Object.keys(c.stream_ops).forEach(function (e) { var r = c.stream_ops[e]; f[e] = function () { if (!_e.forceLoadFile(c)) throw new _e.ErrnoError(5); return r.apply(null, arguments) } }), f.read = function (e, r, t, n, o) { if (!_e.forceLoadFile(c)) throw new _e.ErrnoError(5); var i = e.node.contents; if (o >= i.length) return 0; var a = Math.min(i.length - o, n); if (i.slice) for (var s = 0; s < a; s++)r[t + s] = i[o + s]; else for (s = 0; s < a; s++)r[t + s] = i.get(o + s); return a }, c.stream_ops = f, c }, createPreloadedFile: function (e, r, t, o, i, a, s, u, c, f) { Browser.init(); var l = r ? he.resolve(pe.join2(e, r)) : e; function d(t) { function d(t) { f && f(), u || _e.createDataFile(e, r, t, o, i, c), a && a(), ie() } var p = !1; n.preloadPlugins.forEach(function (e) { p || e.canHandle(l) && (e.handle(t, l, d, function () { s && s(), ie() }), p = !0) }), p || d(t) } oe(), \"string\" == typeof t ? Browser.asyncLoad(t, function (e) { d(e) }, s) : d(t) }, indexedDB: function () { return window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB }, DB_NAME: function () { return \"EM_FS_\" + window.location.pathname }, DB_VERSION: 20, DB_STORE_NAME: \"FILE_DATA\", saveFilesToDB: function (e, r, t) { r = r || function () { }, t = t || function () { }; var n = _e.indexedDB(); try { var o = n.open(_e.DB_NAME(), _e.DB_VERSION) } catch (e) { return t(e) } o.onupgradeneeded = function () { console.log(\"creating db\"), o.result.createObjectStore(_e.DB_STORE_NAME) }, o.onsuccess = function () { var n = o.result.transaction([_e.DB_STORE_NAME], \"readwrite\"), i = n.objectStore(_e.DB_STORE_NAME), a = 0, s = 0, u = e.length; function c() { 0 == s ? r() : t() } e.forEach(function (e) { var r = i.put(_e.analyzePath(e).object.contents, e); r.onsuccess = function () { ++a + s == u && c() }, r.onerror = function () { a + ++s == u && c() } }), n.onerror = t }, o.onerror = t }, loadFilesFromDB: function (e, r, t) { r = r || function () { }, t = t || function () { }; var n = _e.indexedDB(); try { var o = n.open(_e.DB_NAME(), _e.DB_VERSION) } catch (e) { return t(e) } o.onupgradeneeded = t, o.onsuccess = function () { var n = o.result; try { var i = n.transaction([_e.DB_STORE_NAME], \"readonly\") } catch (e) { return void t(e) } var a = i.objectStore(_e.DB_STORE_NAME), s = 0, u = 0, c = e.length; function f() { 0 == u ? r() : t() } e.forEach(function (e) { var r = a.get(e); r.onsuccess = function () { _e.analyzePath(e).exists && _e.unlink(e), _e.createDataFile(pe.dirname(e), pe.basename(e), r.result, !0, !0, !0), ++s + u == c && f() }, r.onerror = function () { s + ++u == c && f() } }), i.onerror = t }, o.onerror = t } }, be = { DEFAULT_POLLMASK: 5, mappings: {}, umask: 511, calculateAt: function (e, r) { if (\"/\" !== r[0]) { var t; if (-100 === e) t = _e.cwd(); else { var n = _e.getStream(e); if (!n) throw new _e.ErrnoError(9); t = n.path } r = pe.join2(t, r) } return r }, doStat: function (e, r, t) { try { var n = e(r) } catch (e) { if (e && e.node && pe.normalize(r) !== pe.normalize(_e.getPath(e.node))) return -20; throw e } return M[t >> 2] = n.dev, M[t + 4 >> 2] = 0, M[t + 8 >> 2] = n.ino, M[t + 12 >> 2] = n.mode, M[t + 16 >> 2] = n.nlink, M[t + 20 >> 2] = n.uid, M[t + 24 >> 2] = n.gid, M[t + 28 >> 2] = n.rdev, M[t + 32 >> 2] = 0, tempI64 = [n.size >>> 0, (tempDouble = n.size, +J(tempDouble) >= 1 ? tempDouble > 0 ? (0 | re(+ee(tempDouble / 4294967296), 4294967295)) >>> 0 : ~~+Q((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], M[t + 40 >> 2] = tempI64[0], M[t + 44 >> 2] = tempI64[1], M[t + 48 >> 2] = 4096, M[t + 52 >> 2] = n.blocks, M[t + 56 >> 2] = n.atime.getTime() / 1e3 | 0, M[t + 60 >> 2] = 0, M[t + 64 >> 2] = n.mtime.getTime() / 1e3 | 0, M[t + 68 >> 2] = 0, M[t + 72 >> 2] = n.ctime.getTime() / 1e3 | 0, M[t + 76 >> 2] = 0, tempI64 = [n.ino >>> 0, (tempDouble = n.ino, +J(tempDouble) >= 1 ? tempDouble > 0 ? (0 | re(+ee(tempDouble / 4294967296), 4294967295)) >>> 0 : ~~+Q((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], M[t + 80 >> 2] = tempI64[0], M[t + 84 >> 2] = tempI64[1], 0 }, doMsync: function (e, r, t, n) { var o = new Uint8Array(R.subarray(e, e + t)); _e.msync(r, o, 0, t, n) }, doMkdir: function (e, r) { return \"/\" === (e = pe.normalize(e))[e.length - 1] && (e = e.substr(0, e.length - 1)), _e.mkdir(e, r, 0), 0 }, doMknod: function (e, r, t) { switch (61440 & r) { case 32768: case 8192: case 24576: case 4096: case 49152: break; default: return -22 }return _e.mknod(e, r, t), 0 }, doReadlink: function (e, r, t) { if (t <= 0) return -22; var n = _e.readlink(e), o = Math.min(t, L(n)), i = P[r + o]; return C(n, r, t + 1), P[r + o] = i, o }, doAccess: function (e, r) { if (-8 & r) return -22; var t; t = _e.lookupPath(e, { follow: !0 }).node; var n = \"\"; return 4 & r && (n += \"r\"), 2 & r && (n += \"w\"), 1 & r && (n += \"x\"), n && _e.nodePermissions(t, n) ? -13 : 0 }, doDup: function (e, r, t) { var n = _e.getStream(t); return n && _e.close(n), _e.open(e, r, 0, t, t).fd }, doReadv: function (e, r, t, n) { for (var o = 0, i = 0; i < t; i++) { var a = M[r + 8 * i >> 2], s = M[r + (8 * i + 4) >> 2], u = _e.read(e, P, a, s, n); if (u < 0) return -1; if (o += u, u < s) break } return o }, doWritev: function (e, r, t, n) { for (var o = 0, i = 0; i < t; i++) { var a = M[r + 8 * i >> 2], s = M[r + (8 * i + 4) >> 2], u = _e.write(e, P, a, s, n); if (u < 0) return -1; o += u } return o }, varargs: 0, get: function (e) { return be.varargs += 4, M[be.varargs - 4 >> 2] }, getStr: function () { return I(be.get()) }, getStreamFromFD: function () { var e = _e.getStream(be.get()); if (!e) throw new _e.ErrnoError(9); return e }, get64: function () { var e = be.get(); return be.get(), e }, getZero: function () { be.get() } }, ke = 11, De = 22, Se = { BUCKET_BUFFER_SIZE: 8192, mount: function (e) { return _e.createNode(null, \"/\", 16895, 0) }, createPipe: function () { var e = { buckets: [] }; e.buckets.push({ buffer: new Uint8Array(Se.BUCKET_BUFFER_SIZE), offset: 0, roffset: 0 }); var r = Se.nextname(), t = Se.nextname(), n = _e.createNode(Se.root, r, 4096, 0), o = _e.createNode(Se.root, t, 4096, 0); n.pipe = e, o.pipe = e; var i = _e.createStream({ path: r, node: n, flags: _e.modeStringToFlags(\"r\"), seekable: !1, stream_ops: Se.stream_ops }); n.stream = i; var a = _e.createStream({ path: t, node: o, flags: _e.modeStringToFlags(\"w\"), seekable: !1, stream_ops: Se.stream_ops }); return o.stream = a, { readable_fd: i.fd, writable_fd: a.fd } }, stream_ops: { poll: function (e) { var r = e.node.pipe; if (1 == (2097155 & e.flags)) return 260; if (r.buckets.length > 0) for (var t = 0; t < r.buckets.length; t++) { var n = r.buckets[t]; if (n.offset - n.roffset > 0) return 65 } return 0 }, ioctl: function (e, r, t) { return De }, read: function (e, r, t, n, o) { for (var i = e.node.pipe, a = 0, s = 0; s < i.buckets.length; s++) { var u = i.buckets[s]; a += u.offset - u.roffset } y(r instanceof ArrayBuffer || ArrayBuffer.isView(r)); var c = r.subarray(t, t + n); if (n <= 0) return 0; if (0 == a) throw new _e.ErrnoError(ke); var f = Math.min(a, n), l = f, d = 0; for (s = 0; s < i.buckets.length; s++) { var p = i.buckets[s], m = p.offset - p.roffset; if (f <= m) { var h = p.buffer.subarray(p.roffset, p.offset); f < m ? (h = h.subarray(0, f), p.roffset += f) : d++, c.set(h); break } h = p.buffer.subarray(p.roffset, p.offset), c.set(h), c = c.subarray(h.byteLength), f -= h.byteLength, d++ } return d && d == i.buckets.length && (d--, i.buckets[d].offset = 0, i.buckets[d].roffset = 0), i.buckets.splice(0, d), l }, write: function (e, r, t, n, o) { var i = e.node.pipe; y(r instanceof ArrayBuffer || ArrayBuffer.isView(r)); var a = r.subarray(t, t + n), s = a.byteLength; if (s <= 0) return 0; var u = null; 0 == i.buckets.length ? (u = { buffer: new Uint8Array(Se.BUCKET_BUFFER_SIZE), offset: 0, roffset: 0 }, i.buckets.push(u)) : u = i.buckets[i.buckets.length - 1], y(u.offset <= Se.BUCKET_BUFFER_SIZE); var c = Se.BUCKET_BUFFER_SIZE - u.offset; if (c >= s) return u.buffer.set(a, u.offset), u.offset += s, s; c > 0 && (u.buffer.set(a.subarray(0, c), u.offset), u.offset += c, a = a.subarray(c, a.byteLength)); for (var f = a.byteLength / Se.BUCKET_BUFFER_SIZE | 0, l = a.byteLength % Se.BUCKET_BUFFER_SIZE, d = 0; d < f; d++) { var p = { buffer: new Uint8Array(Se.BUCKET_BUFFER_SIZE), offset: Se.BUCKET_BUFFER_SIZE, roffset: 0 }; i.buckets.push(p), p.buffer.set(a.subarray(0, Se.BUCKET_BUFFER_SIZE)), a = a.subarray(Se.BUCKET_BUFFER_SIZE, a.byteLength) } return l > 0 && (p = { buffer: new Uint8Array(Se.BUCKET_BUFFER_SIZE), offset: a.byteLength, roffset: 0 }, i.buckets.push(p), p.buffer.set(a)), s }, close: function (e) { e.node.pipe.buckets = null } }, nextname: function () { return Se.nextname.current || (Se.nextname.current = 0), \"pipe[\" + Se.nextname.current++ + \"]\" } }; function Fe() { return P.length } function Ae(e) { var r = Fe(); if (e > 2147418112) return !1; for (var t = Math.max(r, 16777216); t < e;)t = t <= 536870912 ? H(2 * t, 65536) : Math.min(H((3 * t + 2147483648) / 4, 65536), 2147418112); return !!function (e) { e = H(e, 65536); var r = A.byteLength; try { return -1 !== w.grow((e - r) / 65536) && (A = w.buffer, !0) } catch (e) { return !1 } }(t) && (W(), !0) } var Pe = 277408; function Re() { if (!Re.called) { Re.called = !0, M[ze() >> 2] = 60 * (new Date).getTimezoneOffset(); var e = new Date(2e3, 0, 1), r = new Date(2e3, 6, 1); M[Ie() >> 2] = Number(e.getTimezoneOffset() != r.getTimezoneOffset()); var t = a(e), n = a(r), o = S(Be(t), \"i8\", k), i = S(Be(n), \"i8\", k); r.getTimezoneOffset() < e.getTimezoneOffset() ? (M[Ce() >> 2] = o, M[Ce() + 4 >> 2] = i) : (M[Ce() >> 2] = i, M[Ce() + 4 >> 2] = o) } function a(e) { var r = e.toTimeString().match(/\\(([A-Za-z ]+)\\)$/); return r ? r[1] : \"GMT\" } } if (C(\"GMT\", 277456, 4), _e.staticInit(), i) { var Te = require(\"fs\"), Me = require(\"path\"); ye.staticInit() } function Be(e, r, t) { var n = t > 0 ? t : L(e) + 1, o = new Array(n), i = z(e, o, 0, o.length); return r && (o.length = i), o } var xe = { b: Ke, q: function (e) { }, G: function e(r) { var t, o; e.called ? (o = M[r >> 2], t = M[o >> 2]) : (e.called = !0, de.USER = de.LOGNAME = \"web_user\", de.PATH = \"/\", de.PWD = \"/\", de.HOME = \"/home/web_user\", de.LANG = \"C.UTF-8\", de._ = n.thisProgram, t = F(1024), o = F(256), M[o >> 2] = t, M[r >> 2] = o); var i = [], a = 0; for (var s in de) if (\"string\" == typeof de[s]) { var u = s + \"=\" + de[s]; i.push(u), a += u.length } if (a > 1024) throw new Error(\"Environment size exceeded TOTAL_ENV_SIZE!\"); for (var c = 0; c < i.length; c++)U(u = i[c], t), M[o + 4 * c >> 2] = t, t += u.length + 1; M[o + 4 * i.length >> 2] = 0 }, l: me, s: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(), n = be.get(), o = be.get(), i = be.get(), a = be.get(); if (!(-1 == n && o < 0 || 0 == n && o >= 0)) return -75; var s = o; return _e.llseek(t, s, a), tempI64 = [t.position >>> 0, (tempDouble = t.position, +J(tempDouble) >= 1 ? tempDouble > 0 ? (0 | re(+ee(tempDouble / 4294967296), 4294967295)) >>> 0 : ~~+Q((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], M[i >> 2] = tempI64[0], M[i + 4 >> 2] = tempI64[1], t.getdents && 0 === s && 0 === a && (t.getdents = null), 0 } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, i: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(), n = be.get(), o = be.get(); return be.doWritev(t, n, o) } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, p: function (e, r) { be.varargs = r; try { for (var t = be.get(), n = be.get(), o = (be.get(), 0), i = 0; i < n; i++) { var a = t + 8 * i, s = M[a >> 2], u = T[a + 4 >> 1], c = 32, f = _e.getStream(s); f && (c = be.DEFAULT_POLLMASK, f.stream_ops.poll && (c = f.stream_ops.poll(f))), (c &= 24 | u) && o++, T[a + 6 >> 1] = c } return o } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, o: function (e, r) { be.varargs = r; try { var t = be.getStr(), n = be.get(); return be.doStat(_e.stat, t, n) } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, n: function (e, r) { be.varargs = r; try { var t = be.getStr(), n = be.get(); return be.doStat(_e.lstat, t, n) } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, m: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(), n = be.get(); return be.doStat(_e.stat, t.path, n) } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, c: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(); switch (be.get()) { case 0: return (n = be.get()) < 0 ? -22 : _e.open(t.path, t.flags, 0, n).fd; case 1: case 2: return 0; case 3: return t.flags; case 4: var n = be.get(); return t.flags |= n, 0; case 12: return n = be.get(), T[n + 0 >> 1] = 2, 0; case 13: case 14: return 0; case 16: case 8: return -22; case 9: return me(22), -1; default: return -22 } } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, F: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(), n = be.get(), o = be.get(); return _e.read(t, P, n, o) } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, E: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(), n = be.get(), o = be.get(); return _e.write(t, P, n, o) } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, D: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(); return _e.open(t.path, t.flags, 0).fd } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, C: function (e, r) { be.varargs = r; try { var t = be.get(); if (0 == t) throw new _e.ErrnoError(14); var n = Se.createPipe(); return M[t >> 2] = n.readable_fd, M[t + 4 >> 2] = n.writable_fd, 0 } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, B: function (e, r) { be.varargs = r; try { var t = be.getStr(), n = be.get(), o = be.get(); return _e.open(t, n, o).fd } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, h: function (e, r) { be.varargs = r; try { var t = be.getStreamFromFD(); return _e.close(t), 0 } catch (e) { return void 0 !== _e && e instanceof _e.ErrnoError || Ke(e), -e.errno } }, g: function () { n.abort() }, A: Fe, z: function (e, r, t) { R.set(R.subarray(r, r + t), e) }, y: Ae, f: function (e) { !function (e, r) { r && n.noExitRuntime && 0 === e || (n.noExitRuntime || (g = !0, n.onExit && n.onExit(e)), n.quit(e, new We(e))) }(e) }, x: function (e) { return function (e, r) { Re(); var t = new Date(1e3 * M[e >> 2]); M[r >> 2] = t.getSeconds(), M[r + 4 >> 2] = t.getMinutes(), M[r + 8 >> 2] = t.getHours(), M[r + 12 >> 2] = t.getDate(), M[r + 16 >> 2] = t.getMonth(), M[r + 20 >> 2] = t.getFullYear() - 1900, M[r + 24 >> 2] = t.getDay(); var n = new Date(t.getFullYear(), 0, 1), o = (t.getTime() - n.getTime()) / 864e5 | 0; M[r + 28 >> 2] = o, M[r + 36 >> 2] = -60 * t.getTimezoneOffset(); var i = new Date(2e3, 6, 1).getTimezoneOffset(), a = n.getTimezoneOffset(), s = 0 | (i != a && t.getTimezoneOffset() == Math.min(a, i)); M[r + 32 >> 2] = s; var u = M[Ce() + (s ? 4 : 0) >> 2]; return M[r + 40 >> 2] = u, r }(e, Pe) }, d: function (e) { Re(); var r = new Date(M[e + 20 >> 2] + 1900, M[e + 16 >> 2], M[e + 12 >> 2], M[e + 8 >> 2], M[e + 4 >> 2], M[e >> 2], 0), t = M[e + 32 >> 2], n = r.getTimezoneOffset(), o = new Date(r.getFullYear(), 0, 1), i = new Date(2e3, 6, 1).getTimezoneOffset(), a = o.getTimezoneOffset(), s = Math.min(a, i); if (t < 0) M[e + 32 >> 2] = Number(i != a && s == n); else if (t > 0 != (s == n)) { var u = Math.max(a, i), c = t > 0 ? s : u; r.setTime(r.getTime() + 6e4 * (c - n)) } M[e + 24 >> 2] = r.getDay(); var f = (r.getTime() - o.getTime()) / 864e5 | 0; return M[e + 28 >> 2] = f, r.getTime() / 1e3 | 0 }, e: function () { p(\"missing function: posix_spawn_file_actions_addclose\"), Ke(-1) }, k: function () { p(\"missing function: posix_spawn_file_actions_adddup2\"), Ke(-1) }, j: function () { p(\"missing function: posix_spawn_file_actions_destroy\"), Ke(-1) }, w: function () { p(\"missing function: posix_spawn_file_actions_init\"), Ke(-1) }, v: function () { return function () { return me(11), -1 }.apply(null, arguments) }, u: function (e) { Re(); var r = Date.UTC(M[e + 20 >> 2] + 1900, M[e + 16 >> 2], M[e + 12 >> 2], M[e + 8 >> 2], M[e + 4 >> 2], M[e >> 2], 0), t = new Date(r); M[e + 24 >> 2] = t.getUTCDay(); var n = Date.UTC(t.getUTCFullYear(), 0, 1, 0, 0, 0, 0), o = (t.getTime() - n) / 864e5 | 0; return M[e + 28 >> 2] = o, t.getTime() / 1e3 | 0 }, t: function () { return function (e) { return me(10), -1 }.apply(null, arguments) }, r: function (e) { Ke(\"OOM\") }, a: q }, Oe = n.asm({}, xe, A); n.asm = Oe; var Ne = n.___emscripten_environ_constructor = function () { return n.asm.H.apply(null, arguments) }, Ie = (n.___errno_location = function () { return n.asm.I.apply(null, arguments) }, n.__get_daylight = function () { return n.asm.J.apply(null, arguments) }), ze = n.__get_timezone = function () { return n.asm.K.apply(null, arguments) }, Ce = n.__get_tzname = function () { return n.asm.L.apply(null, arguments) }, Le = (n._archive_close = function () { return n.asm.M.apply(null, arguments) }, n._archive_entry_filetype = function () { return n.asm.N.apply(null, arguments) }, n._archive_entry_is_encrypted = function () { return n.asm.O.apply(null, arguments) }, n._archive_entry_pathname = function () { return n.asm.P.apply(null, arguments) }, n._archive_entry_pathname_utf8 = function () { return n.asm.Q.apply(null, arguments) }, n._archive_entry_size = function () { return n.asm.R.apply(null, arguments) }, n._archive_error_string = function () { return n.asm.S.apply(null, arguments) }, n._archive_open = function () { return n.asm.T.apply(null, arguments) }, n._archive_read_add_passphrase = function () { return n.asm.U.apply(null, arguments) }, n._archive_read_data_skip = function () { return n.asm.V.apply(null, arguments) }, n._archive_read_has_encrypted_entries = function () { return n.asm.W.apply(null, arguments) }, n._free = function () { return n.asm.X.apply(null, arguments) }, n._get_filedata = function () { return n.asm.Y.apply(null, arguments) }, n._get_next_entry = function () { return n.asm.Z.apply(null, arguments) }, n._get_version = function () { return n.asm._.apply(null, arguments) }, n._malloc = function () { return n.asm.$.apply(null, arguments) }), Ue = n.stackAlloc = function () { return n.asm.ca.apply(null, arguments) }, je = n.stackRestore = function () { return n.asm.da.apply(null, arguments) }, He = n.stackSave = function () { return n.asm.ea.apply(null, arguments) }; function We(e) { this.name = \"ExitStatus\", this.message = \"Program terminated with exit(\" + e + \")\", this.status = e } function qe(e) { function r() { n.calledRun || (n.calledRun = !0, g || ($ || ($ = !0, n.noFSInit || _e.init.initialized || _e.init(), we.init(), Se.root = _e.mount(Se, {}, null), Z(X)), _e.ignorePermissions = !1, Z(Y), n.onRuntimeInitialized && n.onRuntimeInitialized(), function () { if (n.postRun) for (\"function\" == typeof n.postRun && (n.postRun = [n.postRun]); n.postRun.length;)e = n.postRun.shift(), G.unshift(e); var e; Z(G) }())) } e = e || n.arguments, te > 0 || (function () { if (n.preRun) for (\"function\" == typeof n.preRun && (n.preRun = [n.preRun]); n.preRun.length;)e = n.preRun.shift(), V.unshift(e); var e; Z(V) }(), te > 0 || n.calledRun || (n.setStatus ? (n.setStatus(\"Running...\"), setTimeout(function () { setTimeout(function () { n.setStatus(\"\") }, 1), r() }, 1)) : r())) } function Ke(e) { throw n.onAbort && n.onAbort(e), void 0 !== e ? (d(e), p(e), e = JSON.stringify(e)) : e = \"\", g = !0, \"abort(\" + e + \"). Build with -s ASSERTIONS=1 for more info.\" } if (n.dynCall_v = function () { return n.asm.aa.apply(null, arguments) }, n.dynCall_vi = function () { return n.asm.ba.apply(null, arguments) }, n.asm = Oe, n.intArrayFromString = Be, n.cwrap = function (e, r, t, n) { var o = (t = t || []).every(function (e) { return \"number\" === e }); return \"string\" !== r && o && !n ? E(e) : function () { return _(e, r, t, arguments) } }, n.allocate = S, n.then = function (e) { if (n.calledRun) e(n); else { var r = n.onRuntimeInitialized; n.onRuntimeInitialized = function () { r && r(), e(n) } } return n }, We.prototype = new Error, We.prototype.constructor = We, ne = function e() { n.calledRun || qe(), n.calledRun || (ne = e) }, n.run = qe, n.abort = Ke, n.preInit) for (\"function\" == typeof n.preInit && (n.preInit = [n.preInit]); n.preInit.length > 0;)n.preInit.pop()(); return n.noExitRuntime = !0, qe(), e }); class o { constructor() { this.preRun = [], this.postRun = [], this.totalDependencies = 0 } print(...e) { console.log(e) } printErr(...e) { console.error(e) } initFunctions() { this.runCode = { getVersion: this.cwrap(\"get_version\", \"string\", []), openArchive: this.cwrap(\"archive_open\", \"number\", [\"number\", \"number\", \"string\"]), getNextEntry: this.cwrap(\"get_next_entry\", \"number\", [\"number\"]), getFileData: this.cwrap(\"get_filedata\", \"number\", [\"number\", \"number\"]), skipEntry: this.cwrap(\"archive_read_data_skip\", \"number\", [\"number\"]), closeArchive: this.cwrap(\"archive_close\", null, [\"number\"]), getEntrySize: this.cwrap(\"archive_entry_size\", \"number\", [\"number\"]), getEntryName: this.cwrap(\"archive_entry_pathname\", \"string\", [\"number\"]), getEntryType: this.cwrap(\"archive_entry_filetype\", \"number\", [\"number\"]), getError: this.cwrap(\"archive_error_string\", \"string\", [\"number\"]), entryIsEncrypted: this.cwrap(\"archive_entry_is_encrypted\", \"number\", [\"number\"]), hasEncryptedEntries: this.cwrap(\"archive_read_has_encrypted_entries\", \"number\", [\"number\"]), addPassphrase: this.cwrap(\"archive_read_add_passphrase\", \"number\", [\"number\", \"string\"]), string: e => this.allocate(this.intArrayFromString(e), \"i8\", 0), malloc: this.cwrap(\"malloc\", \"number\", [\"number\"]), free: this.cwrap(\"free\", null, [\"number\"]) } } monitorRunDependencies() { } locateFile(e) { return `wasm-gen/${e}` } } let i = null, a = !1; var s; s = (e => { i = new r(e), a = !1, self.postMessage({ type: \"READY\" }) }), n(new o).then(e => { e.initFunctions(), s(e) }), self.onmessage = (async ({ data: e }) => { if (a) return void self.postMessage({ type: \"BUSY\" }); let r = !1; a = !0; try { switch (e.type) { case \"HELLO\": break; case \"OPEN\": await i.open(e.file), self.postMessage({ type: \"OPENED\" }); break; case \"LIST_FILES\": r = !0; case \"EXTRACT_FILES\": for (const e of i.entries(r)) self.postMessage({ type: \"ENTRY\", entry: e }); self.postMessage({ type: \"END\" }); break; case \"EXTRACT_SINGLE_FILE\": for (const r of i.entries(!0, e.target)) r.fileData && self.postMessage({ type: \"FILE\", entry: r }); break; case \"CHECK_ENCRYPTION\": self.postMessage({ type: \"ENCRYPTION_STATUS\", status: i.hasEncryptedData() }); break; case \"SET_PASSPHRASE\": i.setPassphrase(e.passphrase), self.postMessage({ type: \"PASSPHRASE_STATUS\", status: !0 }); break; default: throw new Error(\"Invalid Command\") } } catch (e) { self.postMessage({ type: \"ERROR\", error: { message: e.message, name: e.name, stack: e.stack } }) } finally { a = !1 } }) }();"
  },
  {
    "path": "client/static/libs/marked/LICENSE",
    "content": "# License information\n\n## Contribution License Agreement\n\nIf you contribute code to this project, you are implicitly allowing your code\nto be distributed under the MIT license. You are also implicitly verifying that\nall code is your original work. `</legalese>`\n\n## Marked\n\nCopyright (c) 2018+, MarkedJS (https://github.com/markedjs/)\nCopyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)\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\nall copies 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\nTHE SOFTWARE.\n\n## Markdown\n\nCopyright © 2004, John Gruber\nhttp://daringfireball.net/\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\n* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\n* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.\n\nThis software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage."
  },
  {
    "path": "client/static/libs/marked/index.js",
    "content": "/**\n * marked - a markdown parser\n * Copyright (c) 2011-2022, Christopher Jeffrey. (MIT Licensed)\n * https://github.com/markedjs/marked\n */\n !function(e,t){\"object\"==typeof exports&&\"undefined\"!=typeof module?t(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],t):t((e=\"undefined\"!=typeof globalThis?globalThis:e||self).marked={})}(this,function(r){\"use strict\";function i(e,t){for(var u=0;u<t.length;u++){var n=t[u];n.enumerable=n.enumerable||!1,n.configurable=!0,\"value\"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function s(e,t){(null==t||t>e.length)&&(t=e.length);for(var u=0,n=new Array(t);u<t;u++)n[u]=e[u];return n}function b(e,t){var u,n=\"undefined\"!=typeof Symbol&&e[Symbol.iterator]||e[\"@@iterator\"];if(n)return(n=n.call(e)).next.bind(n);if(Array.isArray(e)||(n=function(e,t){if(e){if(\"string\"==typeof e)return s(e,t);var u=Object.prototype.toString.call(e).slice(8,-1);return\"Map\"===(u=\"Object\"===u&&e.constructor?e.constructor.name:u)||\"Set\"===u?Array.from(e):\"Arguments\"===u||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(u)?s(e,t):void 0}}(e))||t&&e&&\"number\"==typeof e.length)return n&&(e=n),u=0,function(){return u>=e.length?{done:!0}:{done:!1,value:e[u++]}};throw new TypeError(\"Invalid attempt to iterate non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}function e(){return{baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:\"\",highlight:null,langPrefix:\"language-\",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}r.defaults=e();function u(e){return t[e]}var n=/[&<>\"']/,l=/[&<>\"']/g,a=/[<>\"']|&(?!#?\\w+;)/,o=/[<>\"']|&(?!#?\\w+;)/g,t={\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\",\"'\":\"&#39;\"};function D(e,t){if(t){if(n.test(e))return e.replace(l,u)}else if(a.test(e))return e.replace(o,u);return e}var c=/&(#(?:\\d+)|(?:#x[0-9A-Fa-f]+)|(?:\\w+));?/gi;function x(e){return e.replace(c,function(e,t){return\"colon\"===(t=t.toLowerCase())?\":\":\"#\"===t.charAt(0)?\"x\"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):\"\"})}var h=/(^|[^\\[])\\^/g;function p(u,e){u=\"string\"==typeof u?u:u.source,e=e||\"\";var n={replace:function(e,t){return t=(t=t.source||t).replace(h,\"$1\"),u=u.replace(e,t),n},getRegex:function(){return new RegExp(u,e)}};return n}var f=/[^\\w:]/g,Z=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;function g(e,t,u){if(e){try{n=decodeURIComponent(x(u)).replace(f,\"\").toLowerCase()}catch(e){return null}if(0===n.indexOf(\"javascript:\")||0===n.indexOf(\"vbscript:\")||0===n.indexOf(\"data:\"))return null}var n;t&&!Z.test(u)&&(e=u,F[\" \"+(n=t)]||(O.test(n)?F[\" \"+n]=n+\"/\":F[\" \"+n]=k(n,\"/\",!0)),t=-1===(n=F[\" \"+n]).indexOf(\":\"),u=\"//\"===e.substring(0,2)?t?e:n.replace(q,\"$1\")+e:\"/\"===e.charAt(0)?t?e:n.replace(L,\"$1\")+e:n+e);try{u=encodeURI(u).replace(/%25/g,\"%\")}catch(e){return null}return u}var F={},O=/^[^:]+:\\/*[^/]*$/,q=/^([^:]+:)[\\s\\S]*$/,L=/^([^:]+:\\/*[^/]*)[\\s\\S]*$/;var A={exec:function(){}};function d(e){for(var t,u,n=1;n<arguments.length;n++)for(u in t=arguments[n])Object.prototype.hasOwnProperty.call(t,u)&&(e[u]=t[u]);return e}function C(e,t){var u=e.replace(/\\|/g,function(e,t,u){for(var n=!1,r=t;0<=--r&&\"\\\\\"===u[r];)n=!n;return n?\"|\":\" |\"}).split(/ \\|/),n=0;if(u[0].trim()||u.shift(),0<u.length&&!u[u.length-1].trim()&&u.pop(),u.length>t)u.splice(t);else for(;u.length<t;)u.push(\"\");for(;n<u.length;n++)u[n]=u[n].trim().replace(/\\\\\\|/g,\"|\");return u}function k(e,t,u){var n=e.length;if(0===n)return\"\";for(var r=0;r<n;){var i=e.charAt(n-r-1);if(i!==t||u){if(i===t||!u)break;r++}else r++}return e.slice(0,n-r)}function E(e){e&&e.sanitize&&!e.silent&&console.warn(\"marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options\")}function m(e,t){if(t<1)return\"\";for(var u=\"\";1<t;)1&t&&(u+=e),t>>=1,e+=e;return u+e}function B(e,t,u,n){var r=t.href,t=t.title?D(t.title):null,i=e[1].replace(/\\\\([\\[\\]])/g,\"$1\");return\"!\"!==e[0].charAt(0)?(n.state.inLink=!0,e={type:\"link\",raw:u,href:r,title:t,text:i,tokens:n.inlineTokens(i,[])},n.state.inLink=!1,e):{type:\"image\",raw:u,href:r,title:t,text:D(i)}}var w=function(){function e(e){this.options=e||r.defaults}var t=e.prototype;return t.space=function(e){e=this.rules.block.newline.exec(e);if(e&&0<e[0].length)return{type:\"space\",raw:e[0]}},t.code=function(e){var t,e=this.rules.block.code.exec(e);if(e)return t=e[0].replace(/^ {1,4}/gm,\"\"),{type:\"code\",raw:e[0],codeBlockStyle:\"indented\",text:this.options.pedantic?t:k(t,\"\\n\")}},t.fences=function(e){var t,u,e=this.rules.block.fences.exec(e);if(e)return u=function(e,t){if(null===(e=e.match(/^(\\s+)(?:```)/)))return t;var u=e[1];return t.split(\"\\n\").map(function(e){var t=e.match(/^\\s+/);return null!==t&&t[0].length>=u.length?e.slice(u.length):e}).join(\"\\n\")}(t=e[0],e[3]||\"\"),{type:\"code\",raw:t,lang:e[2]&&e[2].trim(),text:u}},t.heading=function(e){var t,u,e=this.rules.block.heading.exec(e);if(e)return t=e[2].trim(),/#$/.test(t)&&(u=k(t,\"#\"),!this.options.pedantic&&u&&!/ $/.test(u)||(t=u.trim())),u={type:\"heading\",raw:e[0],depth:e[1].length,text:t,tokens:[]},this.lexer.inline(u.text,u.tokens),u},t.hr=function(e){e=this.rules.block.hr.exec(e);if(e)return{type:\"hr\",raw:e[0]}},t.blockquote=function(e){var t,e=this.rules.block.blockquote.exec(e);if(e)return t=e[0].replace(/^ *>[ \\t]?/gm,\"\"),{type:\"blockquote\",raw:e[0],tokens:this.lexer.blockTokens(t,[]),text:t}},t.list=function(e){var t=this.rules.block.list.exec(e);if(t){var u,n,r,i,s,l,a,o,D,c,h,p=1<(g=t[1].trim()).length,f={type:\"list\",raw:\"\",ordered:p,start:p?+g.slice(0,-1):\"\",loose:!1,items:[]},g=p?\"\\\\d{1,9}\\\\\"+g.slice(-1):\"\\\\\"+g;this.options.pedantic&&(g=p?g:\"[*+-]\");for(var F=new RegExp(\"^( {0,3}\"+g+\")((?:[\\t ][^\\\\n]*)?(?:\\\\n|$))\");e&&(h=!1,t=F.exec(e))&&!this.rules.block.hr.test(e);){if(u=t[0],e=e.substring(u.length),a=t[2].split(\"\\n\",1)[0],o=e.split(\"\\n\",1)[0],this.options.pedantic?(i=2,c=a.trimLeft()):(i=t[2].search(/[^ ]/),c=a.slice(i=4<i?1:i),i+=t[1].length),s=!1,!a&&/^ *$/.test(o)&&(u+=o+\"\\n\",e=e.substring(o.length+1),h=!0),!h)for(var A=new RegExp(\"^ {0,\"+Math.min(3,i-1)+\"}(?:[*+-]|\\\\d{1,9}[.)])((?: [^\\\\n]*)?(?:\\\\n|$))\"),d=new RegExp(\"^ {0,\"+Math.min(3,i-1)+\"}((?:- *){3,}|(?:_ *){3,}|(?:\\\\* *){3,})(?:\\\\n+|$)\"),C=new RegExp(\"^( {0,\"+Math.min(3,i-1)+\"})(```|~~~)\");e&&(a=D=e.split(\"\\n\",1)[0],this.options.pedantic&&(a=a.replace(/^ {1,4}(?=( {4})*[^ ])/g,\"  \")),!C.test(a))&&!this.rules.block.heading.test(a)&&!A.test(a)&&!d.test(e);){if(a.search(/[^ ]/)>=i||!a.trim())c+=\"\\n\"+a.slice(i);else{if(s)break;c+=\"\\n\"+a}s||a.trim()||(s=!0),u+=D+\"\\n\",e=e.substring(D.length+1)}f.loose||(l?f.loose=!0:/\\n *\\n *$/.test(u)&&(l=!0)),this.options.gfm&&(n=/^\\[[ xX]\\] /.exec(c))&&(r=\"[ ] \"!==n[0],c=c.replace(/^\\[[ xX]\\] +/,\"\")),f.items.push({type:\"list_item\",raw:u,task:!!n,checked:r,loose:!1,text:c}),f.raw+=u}f.items[f.items.length-1].raw=u.trimRight(),f.items[f.items.length-1].text=c.trimRight(),f.raw=f.raw.trimRight();for(var k=f.items.length,E=0;E<k;E++){this.lexer.state.top=!1,f.items[E].tokens=this.lexer.blockTokens(f.items[E].text,[]);var x=f.items[E].tokens.filter(function(e){return\"space\"===e.type}),m=x.every(function(e){for(var t,u=0,n=b(e.raw.split(\"\"));!(t=n()).done;)if(\"\\n\"===t.value&&(u+=1),1<u)return!0;return!1});!f.loose&&x.length&&m&&(f.loose=!0,f.items[E].loose=!0)}return f}},t.html=function(e){var t,e=this.rules.block.html.exec(e);if(e)return t={type:\"html\",raw:e[0],pre:!this.options.sanitizer&&(\"pre\"===e[1]||\"script\"===e[1]||\"style\"===e[1]),text:e[0]},this.options.sanitize&&(t.type=\"paragraph\",t.text=this.options.sanitizer?this.options.sanitizer(e[0]):D(e[0]),t.tokens=[],this.lexer.inline(t.text,t.tokens)),t},t.def=function(e){e=this.rules.block.def.exec(e);if(e)return e[3]&&(e[3]=e[3].substring(1,e[3].length-1)),{type:\"def\",tag:e[1].toLowerCase().replace(/\\s+/g,\" \"),raw:e[0],href:e[2],title:e[3]}},t.table=function(e){e=this.rules.block.table.exec(e);if(e){var t={type:\"table\",header:C(e[1]).map(function(e){return{text:e}}),align:e[2].replace(/^ *|\\| *$/g,\"\").split(/ *\\| */),rows:e[3]&&e[3].trim()?e[3].replace(/\\n[ \\t]*$/,\"\").split(\"\\n\"):[]};if(t.header.length===t.align.length){t.raw=e[0];for(var u,n,r,i=t.align.length,s=0;s<i;s++)/^ *-+: *$/.test(t.align[s])?t.align[s]=\"right\":/^ *:-+: *$/.test(t.align[s])?t.align[s]=\"center\":/^ *:-+ *$/.test(t.align[s])?t.align[s]=\"left\":t.align[s]=null;for(i=t.rows.length,s=0;s<i;s++)t.rows[s]=C(t.rows[s],t.header.length).map(function(e){return{text:e}});for(i=t.header.length,u=0;u<i;u++)t.header[u].tokens=[],this.lexer.inline(t.header[u].text,t.header[u].tokens);for(i=t.rows.length,u=0;u<i;u++)for(r=t.rows[u],n=0;n<r.length;n++)r[n].tokens=[],this.lexer.inline(r[n].text,r[n].tokens);return t}}},t.lheading=function(e){var e=this.rules.block.lheading.exec(e);if(e)return e={type:\"heading\",raw:e[0],depth:\"=\"===e[2].charAt(0)?1:2,text:e[1],tokens:[]},this.lexer.inline(e.text,e.tokens),e},t.paragraph=function(e){var e=this.rules.block.paragraph.exec(e);if(e)return e={type:\"paragraph\",raw:e[0],text:\"\\n\"===e[1].charAt(e[1].length-1)?e[1].slice(0,-1):e[1],tokens:[]},this.lexer.inline(e.text,e.tokens),e},t.text=function(e){var e=this.rules.block.text.exec(e);if(e)return e={type:\"text\",raw:e[0],text:e[0],tokens:[]},this.lexer.inline(e.text,e.tokens),e},t.escape=function(e){e=this.rules.inline.escape.exec(e);if(e)return{type:\"escape\",raw:e[0],text:D(e[1])}},t.tag=function(e){e=this.rules.inline.tag.exec(e);if(e)return!this.lexer.state.inLink&&/^<a /i.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&/^<\\/a>/i.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\\s|>)/i.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\\/(pre|code|kbd|script)(\\s|>)/i.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:this.options.sanitize?\"text\":\"html\",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(e[0]):D(e[0]):e[0]}},t.link=function(e){e=this.rules.inline.link.exec(e);if(e){var t=e[2].trim();if(!this.options.pedantic&&/^</.test(t)){if(!/>$/.test(t))return;var u=k(t.slice(0,-1),\"\\\\\");if((t.length-u.length)%2==0)return}else{u=function(e,t){if(-1===e.indexOf(t[1]))return-1;for(var u=e.length,n=0,r=0;r<u;r++)if(\"\\\\\"===e[r])r++;else if(e[r]===t[0])n++;else if(e[r]===t[1]&&--n<0)return r;return-1}(e[2],\"()\");-1<u&&(r=(0===e[0].indexOf(\"!\")?5:4)+e[1].length+u,e[2]=e[2].substring(0,u),e[0]=e[0].substring(0,r).trim(),e[3]=\"\")}var n,u=e[2],r=\"\";return this.options.pedantic?(n=/^([^'\"]*[^\\s])\\s+(['\"])(.*)\\2/.exec(u))&&(u=n[1],r=n[3]):r=e[3]?e[3].slice(1,-1):\"\",u=u.trim(),B(e,{href:(u=/^</.test(u)?this.options.pedantic&&!/>$/.test(t)?u.slice(1):u.slice(1,-1):u)&&u.replace(this.rules.inline._escapes,\"$1\"),title:r&&r.replace(this.rules.inline._escapes,\"$1\")},e[0],this.lexer)}},t.reflink=function(e,t){var u;if((u=this.rules.inline.reflink.exec(e))||(u=this.rules.inline.nolink.exec(e)))return(e=t[(e=(u[2]||u[1]).replace(/\\s+/g,\" \")).toLowerCase()])&&e.href?B(u,e,u[0],this.lexer):{type:\"text\",raw:t=u[0].charAt(0),text:t}},t.emStrong=function(e,t,u){void 0===u&&(u=\"\");var n=this.rules.inline.emStrong.lDelim.exec(e);if(n&&(!n[3]||!u.match(/(?:[0-9A-Za-z\\xAA\\xB2\\xB3\\xB5\\xB9\\xBA\\xBC-\\xBE\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u037F\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u052F\\u0531-\\u0556\\u0559\\u0560-\\u0588\\u05D0-\\u05EA\\u05EF-\\u05F2\\u0620-\\u064A\\u0660-\\u0669\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07C0-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u0860-\\u086A\\u0870-\\u0887\\u0889-\\u088E\\u08A0-\\u08C9\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0966-\\u096F\\u0971-\\u0980\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09E6-\\u09F1\\u09F4-\\u09F9\\u09FC\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A66-\\u0A6F\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0AE6-\\u0AEF\\u0AF9\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B66-\\u0B6F\\u0B71-\\u0B77\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0BE6-\\u0BF2\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C39\\u0C3D\\u0C58-\\u0C5A\\u0C5D\\u0C60\\u0C61\\u0C66-\\u0C6F\\u0C78-\\u0C7E\\u0C80\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDD\\u0CDE\\u0CE0\\u0CE1\\u0CE6-\\u0CEF\\u0CF1\\u0CF2\\u0D04-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D54-\\u0D56\\u0D58-\\u0D61\\u0D66-\\u0D78\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0DE6-\\u0DEF\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E50-\\u0E59\\u0E81\\u0E82\\u0E84\\u0E86-\\u0E8A\\u0E8C-\\u0EA3\\u0EA5\\u0EA7-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0ED0-\\u0ED9\\u0EDC-\\u0EDF\\u0F00\\u0F20-\\u0F33\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F-\\u1049\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u1090-\\u1099\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1369-\\u137C\\u1380-\\u138F\\u13A0-\\u13F5\\u13F8-\\u13FD\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F8\\u1700-\\u1711\\u171F-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u17E0-\\u17E9\\u17F0-\\u17F9\\u1810-\\u1819\\u1820-\\u1878\\u1880-\\u1884\\u1887-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191E\\u1946-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19B0-\\u19C9\\u19D0-\\u19DA\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1A80-\\u1A89\\u1A90-\\u1A99\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4C\\u1B50-\\u1B59\\u1B83-\\u1BA0\\u1BAE-\\u1BE5\\u1C00-\\u1C23\\u1C40-\\u1C49\\u1C4D-\\u1C7D\\u1C80-\\u1C88\\u1C90-\\u1CBA\\u1CBD-\\u1CBF\\u1CE9-\\u1CEC\\u1CEE-\\u1CF3\\u1CF5\\u1CF6\\u1CFA\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2070\\u2071\\u2074-\\u2079\\u207F-\\u2089\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2150-\\u2189\\u2460-\\u249B\\u24EA-\\u24FF\\u2776-\\u2793\\u2C00-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2CFD\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312F\\u3131-\\u318E\\u3192-\\u3195\\u31A0-\\u31BF\\u31F0-\\u31FF\\u3220-\\u3229\\u3248-\\u324F\\u3251-\\u325F\\u3280-\\u3289\\u32B1-\\u32BF\\u3400-\\u4DBF\\u4E00-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA62B\\uA640-\\uA66E\\uA67F-\\uA69D\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA7CA\\uA7D0\\uA7D1\\uA7D3\\uA7D5-\\uA7D9\\uA7F2-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA830-\\uA835\\uA840-\\uA873\\uA882-\\uA8B3\\uA8D0-\\uA8D9\\uA8F2-\\uA8F7\\uA8FB\\uA8FD\\uA8FE\\uA900-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF-\\uA9D9\\uA9E0-\\uA9E4\\uA9E6-\\uA9FE\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA50-\\uAA59\\uAA60-\\uAA76\\uAA7A\\uAA7E-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uAB30-\\uAB5A\\uAB5C-\\uAB69\\uAB70-\\uABE2\\uABF0-\\uABF9\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF10-\\uFF19\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC]|\\uD800[\\uDC00-\\uDC0B\\uDC0D-\\uDC26\\uDC28-\\uDC3A\\uDC3C\\uDC3D\\uDC3F-\\uDC4D\\uDC50-\\uDC5D\\uDC80-\\uDCFA\\uDD07-\\uDD33\\uDD40-\\uDD78\\uDD8A\\uDD8B\\uDE80-\\uDE9C\\uDEA0-\\uDED0\\uDEE1-\\uDEFB\\uDF00-\\uDF23\\uDF2D-\\uDF4A\\uDF50-\\uDF75\\uDF80-\\uDF9D\\uDFA0-\\uDFC3\\uDFC8-\\uDFCF\\uDFD1-\\uDFD5]|\\uD801[\\uDC00-\\uDC9D\\uDCA0-\\uDCA9\\uDCB0-\\uDCD3\\uDCD8-\\uDCFB\\uDD00-\\uDD27\\uDD30-\\uDD63\\uDD70-\\uDD7A\\uDD7C-\\uDD8A\\uDD8C-\\uDD92\\uDD94\\uDD95\\uDD97-\\uDDA1\\uDDA3-\\uDDB1\\uDDB3-\\uDDB9\\uDDBB\\uDDBC\\uDE00-\\uDF36\\uDF40-\\uDF55\\uDF60-\\uDF67\\uDF80-\\uDF85\\uDF87-\\uDFB0\\uDFB2-\\uDFBA]|\\uD802[\\uDC00-\\uDC05\\uDC08\\uDC0A-\\uDC35\\uDC37\\uDC38\\uDC3C\\uDC3F-\\uDC55\\uDC58-\\uDC76\\uDC79-\\uDC9E\\uDCA7-\\uDCAF\\uDCE0-\\uDCF2\\uDCF4\\uDCF5\\uDCFB-\\uDD1B\\uDD20-\\uDD39\\uDD80-\\uDDB7\\uDDBC-\\uDDCF\\uDDD2-\\uDE00\\uDE10-\\uDE13\\uDE15-\\uDE17\\uDE19-\\uDE35\\uDE40-\\uDE48\\uDE60-\\uDE7E\\uDE80-\\uDE9F\\uDEC0-\\uDEC7\\uDEC9-\\uDEE4\\uDEEB-\\uDEEF\\uDF00-\\uDF35\\uDF40-\\uDF55\\uDF58-\\uDF72\\uDF78-\\uDF91\\uDFA9-\\uDFAF]|\\uD803[\\uDC00-\\uDC48\\uDC80-\\uDCB2\\uDCC0-\\uDCF2\\uDCFA-\\uDD23\\uDD30-\\uDD39\\uDE60-\\uDE7E\\uDE80-\\uDEA9\\uDEB0\\uDEB1\\uDF00-\\uDF27\\uDF30-\\uDF45\\uDF51-\\uDF54\\uDF70-\\uDF81\\uDFB0-\\uDFCB\\uDFE0-\\uDFF6]|\\uD804[\\uDC03-\\uDC37\\uDC52-\\uDC6F\\uDC71\\uDC72\\uDC75\\uDC83-\\uDCAF\\uDCD0-\\uDCE8\\uDCF0-\\uDCF9\\uDD03-\\uDD26\\uDD36-\\uDD3F\\uDD44\\uDD47\\uDD50-\\uDD72\\uDD76\\uDD83-\\uDDB2\\uDDC1-\\uDDC4\\uDDD0-\\uDDDA\\uDDDC\\uDDE1-\\uDDF4\\uDE00-\\uDE11\\uDE13-\\uDE2B\\uDE80-\\uDE86\\uDE88\\uDE8A-\\uDE8D\\uDE8F-\\uDE9D\\uDE9F-\\uDEA8\\uDEB0-\\uDEDE\\uDEF0-\\uDEF9\\uDF05-\\uDF0C\\uDF0F\\uDF10\\uDF13-\\uDF28\\uDF2A-\\uDF30\\uDF32\\uDF33\\uDF35-\\uDF39\\uDF3D\\uDF50\\uDF5D-\\uDF61]|\\uD805[\\uDC00-\\uDC34\\uDC47-\\uDC4A\\uDC50-\\uDC59\\uDC5F-\\uDC61\\uDC80-\\uDCAF\\uDCC4\\uDCC5\\uDCC7\\uDCD0-\\uDCD9\\uDD80-\\uDDAE\\uDDD8-\\uDDDB\\uDE00-\\uDE2F\\uDE44\\uDE50-\\uDE59\\uDE80-\\uDEAA\\uDEB8\\uDEC0-\\uDEC9\\uDF00-\\uDF1A\\uDF30-\\uDF3B\\uDF40-\\uDF46]|\\uD806[\\uDC00-\\uDC2B\\uDCA0-\\uDCF2\\uDCFF-\\uDD06\\uDD09\\uDD0C-\\uDD13\\uDD15\\uDD16\\uDD18-\\uDD2F\\uDD3F\\uDD41\\uDD50-\\uDD59\\uDDA0-\\uDDA7\\uDDAA-\\uDDD0\\uDDE1\\uDDE3\\uDE00\\uDE0B-\\uDE32\\uDE3A\\uDE50\\uDE5C-\\uDE89\\uDE9D\\uDEB0-\\uDEF8]|\\uD807[\\uDC00-\\uDC08\\uDC0A-\\uDC2E\\uDC40\\uDC50-\\uDC6C\\uDC72-\\uDC8F\\uDD00-\\uDD06\\uDD08\\uDD09\\uDD0B-\\uDD30\\uDD46\\uDD50-\\uDD59\\uDD60-\\uDD65\\uDD67\\uDD68\\uDD6A-\\uDD89\\uDD98\\uDDA0-\\uDDA9\\uDEE0-\\uDEF2\\uDFB0\\uDFC0-\\uDFD4]|\\uD808[\\uDC00-\\uDF99]|\\uD809[\\uDC00-\\uDC6E\\uDC80-\\uDD43]|\\uD80B[\\uDF90-\\uDFF0]|[\\uD80C\\uD81C-\\uD820\\uD822\\uD840-\\uD868\\uD86A-\\uD86C\\uD86F-\\uD872\\uD874-\\uD879\\uD880-\\uD883][\\uDC00-\\uDFFF]|\\uD80D[\\uDC00-\\uDC2E]|\\uD811[\\uDC00-\\uDE46]|\\uD81A[\\uDC00-\\uDE38\\uDE40-\\uDE5E\\uDE60-\\uDE69\\uDE70-\\uDEBE\\uDEC0-\\uDEC9\\uDED0-\\uDEED\\uDF00-\\uDF2F\\uDF40-\\uDF43\\uDF50-\\uDF59\\uDF5B-\\uDF61\\uDF63-\\uDF77\\uDF7D-\\uDF8F]|\\uD81B[\\uDE40-\\uDE96\\uDF00-\\uDF4A\\uDF50\\uDF93-\\uDF9F\\uDFE0\\uDFE1\\uDFE3]|\\uD821[\\uDC00-\\uDFF7]|\\uD823[\\uDC00-\\uDCD5\\uDD00-\\uDD08]|\\uD82B[\\uDFF0-\\uDFF3\\uDFF5-\\uDFFB\\uDFFD\\uDFFE]|\\uD82C[\\uDC00-\\uDD22\\uDD50-\\uDD52\\uDD64-\\uDD67\\uDD70-\\uDEFB]|\\uD82F[\\uDC00-\\uDC6A\\uDC70-\\uDC7C\\uDC80-\\uDC88\\uDC90-\\uDC99]|\\uD834[\\uDEE0-\\uDEF3\\uDF60-\\uDF78]|\\uD835[\\uDC00-\\uDC54\\uDC56-\\uDC9C\\uDC9E\\uDC9F\\uDCA2\\uDCA5\\uDCA6\\uDCA9-\\uDCAC\\uDCAE-\\uDCB9\\uDCBB\\uDCBD-\\uDCC3\\uDCC5-\\uDD05\\uDD07-\\uDD0A\\uDD0D-\\uDD14\\uDD16-\\uDD1C\\uDD1E-\\uDD39\\uDD3B-\\uDD3E\\uDD40-\\uDD44\\uDD46\\uDD4A-\\uDD50\\uDD52-\\uDEA5\\uDEA8-\\uDEC0\\uDEC2-\\uDEDA\\uDEDC-\\uDEFA\\uDEFC-\\uDF14\\uDF16-\\uDF34\\uDF36-\\uDF4E\\uDF50-\\uDF6E\\uDF70-\\uDF88\\uDF8A-\\uDFA8\\uDFAA-\\uDFC2\\uDFC4-\\uDFCB\\uDFCE-\\uDFFF]|\\uD837[\\uDF00-\\uDF1E]|\\uD838[\\uDD00-\\uDD2C\\uDD37-\\uDD3D\\uDD40-\\uDD49\\uDD4E\\uDE90-\\uDEAD\\uDEC0-\\uDEEB\\uDEF0-\\uDEF9]|\\uD839[\\uDFE0-\\uDFE6\\uDFE8-\\uDFEB\\uDFED\\uDFEE\\uDFF0-\\uDFFE]|\\uD83A[\\uDC00-\\uDCC4\\uDCC7-\\uDCCF\\uDD00-\\uDD43\\uDD4B\\uDD50-\\uDD59]|\\uD83B[\\uDC71-\\uDCAB\\uDCAD-\\uDCAF\\uDCB1-\\uDCB4\\uDD01-\\uDD2D\\uDD2F-\\uDD3D\\uDE00-\\uDE03\\uDE05-\\uDE1F\\uDE21\\uDE22\\uDE24\\uDE27\\uDE29-\\uDE32\\uDE34-\\uDE37\\uDE39\\uDE3B\\uDE42\\uDE47\\uDE49\\uDE4B\\uDE4D-\\uDE4F\\uDE51\\uDE52\\uDE54\\uDE57\\uDE59\\uDE5B\\uDE5D\\uDE5F\\uDE61\\uDE62\\uDE64\\uDE67-\\uDE6A\\uDE6C-\\uDE72\\uDE74-\\uDE77\\uDE79-\\uDE7C\\uDE7E\\uDE80-\\uDE89\\uDE8B-\\uDE9B\\uDEA1-\\uDEA3\\uDEA5-\\uDEA9\\uDEAB-\\uDEBB]|\\uD83C[\\uDD00-\\uDD0C]|\\uD83E[\\uDFF0-\\uDFF9]|\\uD869[\\uDC00-\\uDEDF\\uDF00-\\uDFFF]|\\uD86D[\\uDC00-\\uDF38\\uDF40-\\uDFFF]|\\uD86E[\\uDC00-\\uDC1D\\uDC20-\\uDFFF]|\\uD873[\\uDC00-\\uDEA1\\uDEB0-\\uDFFF]|\\uD87A[\\uDC00-\\uDFE0]|\\uD87E[\\uDC00-\\uDE1D]|\\uD884[\\uDC00-\\uDF4A])/))){var r=n[1]||n[2]||\"\";if(!r||\"\"===u||this.rules.inline.punctuation.exec(u)){var i=n[0].length-1,s=i,l=0,a=\"*\"===n[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+i);null!=(n=a.exec(t));)if(o=n[1]||n[2]||n[3]||n[4]||n[5]||n[6])if(o=o.length,n[3]||n[4])s+=o;else if((n[5]||n[6])&&i%3&&!((i+o)%3))l+=o;else if(!(0<(s-=o))){var o=Math.min(o,o+s+l);if(Math.min(i,o)%2)return D=e.slice(1,i+n.index+o),{type:\"em\",raw:e.slice(0,i+n.index+o+1),text:D,tokens:this.lexer.inlineTokens(D,[])};var D=e.slice(2,i+n.index+o-1);return{type:\"strong\",raw:e.slice(0,i+n.index+o+1),text:D,tokens:this.lexer.inlineTokens(D,[])}}}}},t.codespan=function(e){var t,u,n,e=this.rules.inline.code.exec(e);if(e)return n=e[2].replace(/\\n/g,\" \"),t=/[^ ]/.test(n),u=/^ /.test(n)&&/ $/.test(n),n=D(n=t&&u?n.substring(1,n.length-1):n,!0),{type:\"codespan\",raw:e[0],text:n}},t.br=function(e){e=this.rules.inline.br.exec(e);if(e)return{type:\"br\",raw:e[0]}},t.del=function(e){e=this.rules.inline.del.exec(e);if(e)return{type:\"del\",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2],[])}},t.autolink=function(e,t){var u,e=this.rules.inline.autolink.exec(e);if(e)return t=\"@\"===e[2]?\"mailto:\"+(u=D(this.options.mangle?t(e[1]):e[1])):u=D(e[1]),{type:\"link\",raw:e[0],text:u,href:t,tokens:[{type:\"text\",raw:u,text:u}]}},t.url=function(e,t){var u,n,r,i;if(u=this.rules.inline.url.exec(e)){if(\"@\"===u[2])r=\"mailto:\"+(n=D(this.options.mangle?t(u[0]):u[0]));else{for(;i=u[0],u[0]=this.rules.inline._backpedal.exec(u[0])[0],i!==u[0];);n=D(u[0]),r=\"www.\"===u[1]?\"http://\"+n:n}return{type:\"link\",raw:u[0],text:n,href:r,tokens:[{type:\"text\",raw:n,text:n}]}}},t.inlineText=function(e,t){e=this.rules.inline.text.exec(e);if(e)return t=this.lexer.state.inRawBlock?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(e[0]):D(e[0]):e[0]:D(this.options.smartypants?t(e[0]):e[0]),{type:\"text\",raw:e[0],text:t}},e}(),y={newline:/^(?: *(?:\\n|$))+/,code:/^( {4}[^\\n]+(?:\\n(?: *(?:\\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\\n]*\\n)|~{3,})([^\\n]*)\\n(?:|([\\s\\S]*?)\\n)(?: {0,3}\\1[~`]* *(?=\\n|$)|$)/,hr:/^ {0,3}((?:-[\\t ]*){3,}|(?:_[ \\t]*){3,}|(?:\\*[ \\t]*){3,})(?:\\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\\s|$)(.*)(?:\\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\\n]*)(?:\\n|$))+/,list:/^( {0,3}bull)([ \\t][^\\n]+?)?(?:\\n|$)/,html:\"^ {0,3}(?:<(script|pre|style|textarea)[\\\\s>][\\\\s\\\\S]*?(?:</\\\\1>[^\\\\n]*\\\\n+|$)|comment[^\\\\n]*(\\\\n+|$)|<\\\\?[\\\\s\\\\S]*?(?:\\\\?>\\\\n*|$)|<![A-Z][\\\\s\\\\S]*?(?:>\\\\n*|$)|<!\\\\[CDATA\\\\[[\\\\s\\\\S]*?(?:\\\\]\\\\]>\\\\n*|$)|</?(tag)(?: +|\\\\n|/?>)[\\\\s\\\\S]*?(?:(?:\\\\n *)+\\\\n|$)|<(?!script|pre|style|textarea)([a-z][\\\\w-]*)(?:attribute)*? */?>(?=[ \\\\t]*(?:\\\\n|$))[\\\\s\\\\S]*?(?:(?:\\\\n *)+\\\\n|$)|</(?!script|pre|style|textarea)[a-z][\\\\w-]*\\\\s*>(?=[ \\\\t]*(?:\\\\n|$))[\\\\s\\\\S]*?(?:(?:\\\\n *)+\\\\n|$))\",def:/^ {0,3}\\[(label)\\]: *(?:\\n *)?<?([^\\s>]+)>?(?:(?: +(?:\\n *)?| *\\n *)(title))? *(?:\\n+|$)/,table:A,lheading:/^([^\\n]+)\\n {0,3}(=+|-+) *(?:\\n+|$)/,_paragraph:/^([^\\n]+(?:\\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\\n)[^\\n]+)*)/,text:/^[^\\n]+/,_label:/(?!\\s*\\])(?:\\\\.|[^\\[\\]\\\\])+/,_title:/(?:\"(?:\\\\\"?|[^\"\\\\])*\"|'[^'\\n]*(?:\\n[^'\\n]+)*\\n?'|\\([^()]*\\))/},v=(y.def=p(y.def).replace(\"label\",y._label).replace(\"title\",y._title).getRegex(),y.bullet=/(?:[*+-]|\\d{1,9}[.)])/,y.listItemStart=p(/^( *)(bull) */).replace(\"bull\",y.bullet).getRegex(),y.list=p(y.list).replace(/bull/g,y.bullet).replace(\"hr\",\"\\\\n+(?=\\\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\\\* *){3,})(?:\\\\n+|$))\").replace(\"def\",\"\\\\n+(?=\"+y.def.source+\")\").getRegex(),y._tag=\"address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul\",y._comment=/<!--(?!-?>)[\\s\\S]*?(?:-->|$)/,y.html=p(y.html,\"i\").replace(\"comment\",y._comment).replace(\"tag\",y._tag).replace(\"attribute\",/ +[a-zA-Z:_][\\w.:-]*(?: *= *\"[^\"\\n]*\"| *= *'[^'\\n]*'| *= *[^\\s\"'=<>`]+)?/).getRegex(),y.paragraph=p(y._paragraph).replace(\"hr\",y.hr).replace(\"heading\",\" {0,3}#{1,6} \").replace(\"|lheading\",\"\").replace(\"|table\",\"\").replace(\"blockquote\",\" {0,3}>\").replace(\"fences\",\" {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n\").replace(\"list\",\" {0,3}(?:[*+-]|1[.)]) \").replace(\"html\",\"</?(?:tag)(?: +|\\\\n|/?>)|<(?:script|pre|style|textarea|!--)\").replace(\"tag\",y._tag).getRegex(),y.blockquote=p(y.blockquote).replace(\"paragraph\",y.paragraph).getRegex(),y.normal=d({},y),y.gfm=d({},y.normal,{table:\"^ *([^\\\\n ].*\\\\|.*)\\\\n {0,3}(?:\\\\| *)?(:?-+:? *(?:\\\\| *:?-+:? *)*)(?:\\\\| *)?(?:\\\\n((?:(?! *\\\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\\\n|$))*)\\\\n*|$)\"}),y.gfm.table=p(y.gfm.table).replace(\"hr\",y.hr).replace(\"heading\",\" {0,3}#{1,6} \").replace(\"blockquote\",\" {0,3}>\").replace(\"code\",\" {4}[^\\\\n]\").replace(\"fences\",\" {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n\").replace(\"list\",\" {0,3}(?:[*+-]|1[.)]) \").replace(\"html\",\"</?(?:tag)(?: +|\\\\n|/?>)|<(?:script|pre|style|textarea|!--)\").replace(\"tag\",y._tag).getRegex(),y.gfm.paragraph=p(y._paragraph).replace(\"hr\",y.hr).replace(\"heading\",\" {0,3}#{1,6} \").replace(\"|lheading\",\"\").replace(\"table\",y.gfm.table).replace(\"blockquote\",\" {0,3}>\").replace(\"fences\",\" {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n\").replace(\"list\",\" {0,3}(?:[*+-]|1[.)]) \").replace(\"html\",\"</?(?:tag)(?: +|\\\\n|/?>)|<(?:script|pre|style|textarea|!--)\").replace(\"tag\",y._tag).getRegex(),y.pedantic=d({},y.normal,{html:p(\"^ *(?:comment *(?:\\\\n|\\\\s*$)|<(tag)[\\\\s\\\\S]+?</\\\\1> *(?:\\\\n{2,}|\\\\s*$)|<tag(?:\\\"[^\\\"]*\\\"|'[^']*'|\\\\s[^'\\\"/>\\\\s]*)*?/?> *(?:\\\\n{2,}|\\\\s*$))\").replace(\"comment\",y._comment).replace(/tag/g,\"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\\\b)\\\\w+(?!:|[^\\\\w\\\\s@]*@)\\\\b\").getRegex(),def:/^ *\\[([^\\]]+)\\]: *<?([^\\s>]+)>?(?: +([\"(][^\\n]+[\")]))? *(?:\\n+|$)/,heading:/^(#{1,6})(.*)(?:\\n+|$)/,fences:A,paragraph:p(y.normal._paragraph).replace(\"hr\",y.hr).replace(\"heading\",\" *#{1,6} *[^\\n]\").replace(\"lheading\",y.lheading).replace(\"blockquote\",\" {0,3}>\").replace(\"|fences\",\"\").replace(\"|list\",\"\").replace(\"|html\",\"\").getRegex()}),{escape:/^\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])/,autolink:/^<(scheme:[^\\s\\x00-\\x1f<>]*|email)>/,url:A,tag:\"^comment|^</[a-zA-Z][\\\\w:-]*\\\\s*>|^<[a-zA-Z][\\\\w-]*(?:attribute)*?\\\\s*/?>|^<\\\\?[\\\\s\\\\S]*?\\\\?>|^<![a-zA-Z]+\\\\s[\\\\s\\\\S]*?>|^<!\\\\[CDATA\\\\[[\\\\s\\\\S]*?\\\\]\\\\]>\",link:/^!?\\[(label)\\]\\(\\s*(href)(?:\\s+(title))?\\s*\\)/,reflink:/^!?\\[(label)\\]\\[(ref)\\]/,nolink:/^!?\\[(ref)\\](?:\\[\\])?/,reflinkSearch:\"reflink|nolink(?!\\\\()\",emStrong:{lDelim:/^(?:\\*+(?:([punct_])|[^\\s*]))|^_+(?:([punct*])|([^\\s_]))/,rDelimAst:/^[^_*]*?\\_\\_[^_*]*?\\*[^_*]*?(?=\\_\\_)|[^*]+(?=[^*])|[punct_](\\*+)(?=[\\s]|$)|[^punct*_\\s](\\*+)(?=[punct_\\s]|$)|[punct_\\s](\\*+)(?=[^punct*_\\s])|[\\s](\\*+)(?=[punct_])|[punct_](\\*+)(?=[punct_])|[^punct*_\\s](\\*+)(?=[^punct*_\\s])/,rDelimUnd:/^[^_*]*?\\*\\*[^_*]*?\\_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|[punct*](\\_+)(?=[\\s]|$)|[^punct*_\\s](\\_+)(?=[punct*\\s]|$)|[punct*\\s](\\_+)(?=[^punct*_\\s])|[\\s](\\_+)(?=[punct*])|[punct*](\\_+)(?=[punct*])/},code:/^(`+)([^`]|[^`][\\s\\S]*?[^`])\\1(?!`)/,br:/^( {2,}|\\\\)\\n(?!\\s*$)/,del:A,text:/^(`+|[^`])(?:(?= {2,}\\n)|[\\s\\S]*?(?:(?=[\\\\<!\\[`*_]|\\b_|$)|[^ ](?= {2,}\\n)))/,punctuation:/^([\\spunctuation])/});function j(e){return e.replace(/---/g,\"—\").replace(/--/g,\"–\").replace(/(^|[-\\u2014/(\\[{\"\\s])'/g,\"$1‘\").replace(/'/g,\"’\").replace(/(^|[-\\u2014/(\\[{\\u2018\\s])\"/g,\"$1“\").replace(/\"/g,\"”\").replace(/\\.{3}/g,\"…\")}function _(e){for(var t,u=\"\",n=e.length,r=0;r<n;r++)t=e.charCodeAt(r),u+=\"&#\"+(t=.5<Math.random()?\"x\"+t.toString(16):t)+\";\";return u}v._punctuation=\"!\\\"#$%&'()+\\\\-.,/:;<=>?@\\\\[\\\\]`^{|}~\",v.punctuation=p(v.punctuation).replace(/punctuation/g,v._punctuation).getRegex(),v.blockSkip=/\\[[^\\]]*?\\]\\([^\\)]*?\\)|`[^`]*?`|<[^>]*?>/g,v.escapedEmSt=/\\\\\\*|\\\\_/g,v._comment=p(y._comment).replace(\"(?:--\\x3e|$)\",\"--\\x3e\").getRegex(),v.emStrong.lDelim=p(v.emStrong.lDelim).replace(/punct/g,v._punctuation).getRegex(),v.emStrong.rDelimAst=p(v.emStrong.rDelimAst,\"g\").replace(/punct/g,v._punctuation).getRegex(),v.emStrong.rDelimUnd=p(v.emStrong.rDelimUnd,\"g\").replace(/punct/g,v._punctuation).getRegex(),v._escapes=/\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])/g,v._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,v._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,v.autolink=p(v.autolink).replace(\"scheme\",v._scheme).replace(\"email\",v._email).getRegex(),v._attribute=/\\s+[a-zA-Z:_][\\w.:-]*(?:\\s*=\\s*\"[^\"]*\"|\\s*=\\s*'[^']*'|\\s*=\\s*[^\\s\"'=<>`]+)?/,v.tag=p(v.tag).replace(\"comment\",v._comment).replace(\"attribute\",v._attribute).getRegex(),v._label=/(?:\\[(?:\\\\.|[^\\[\\]\\\\])*\\]|\\\\.|`[^`]*`|[^\\[\\]\\\\`])*?/,v._href=/<(?:\\\\.|[^\\n<>\\\\])+>|[^\\s\\x00-\\x1f]*/,v._title=/\"(?:\\\\\"?|[^\"\\\\])*\"|'(?:\\\\'?|[^'\\\\])*'|\\((?:\\\\\\)?|[^)\\\\])*\\)/,v.link=p(v.link).replace(\"label\",v._label).replace(\"href\",v._href).replace(\"title\",v._title).getRegex(),v.reflink=p(v.reflink).replace(\"label\",v._label).replace(\"ref\",y._label).getRegex(),v.nolink=p(v.nolink).replace(\"ref\",y._label).getRegex(),v.reflinkSearch=p(v.reflinkSearch,\"g\").replace(\"reflink\",v.reflink).replace(\"nolink\",v.nolink).getRegex(),v.normal=d({},v),v.pedantic=d({},v.normal,{strong:{start:/^__|\\*\\*/,middle:/^__(?=\\S)([\\s\\S]*?\\S)__(?!_)|^\\*\\*(?=\\S)([\\s\\S]*?\\S)\\*\\*(?!\\*)/,endAst:/\\*\\*(?!\\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\\*/,middle:/^()\\*(?=\\S)([\\s\\S]*?\\S)\\*(?!\\*)|^_(?=\\S)([\\s\\S]*?\\S)_(?!_)/,endAst:/\\*(?!\\*)/g,endUnd:/_(?!_)/g},link:p(/^!?\\[(label)\\]\\((.*?)\\)/).replace(\"label\",v._label).getRegex(),reflink:p(/^!?\\[(label)\\]\\s*\\[([^\\]]*)\\]/).replace(\"label\",v._label).getRegex()}),v.gfm=d({},v.normal,{escape:p(v.escape).replace(\"])\",\"~|])\").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\\/\\/|www\\.)(?:[a-zA-Z0-9\\-]+\\.?)+[^\\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_~()&]+|\\([^)]*\\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,del:/^(~~?)(?=[^\\s~])([\\s\\S]*?[^\\s~])\\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\\n)|(?=[a-zA-Z0-9.!#$%&'*+\\/=?_`{\\|}~-]+@)|[\\s\\S]*?(?:(?=[\\\\<!\\[`*~_]|\\b_|https?:\\/\\/|ftp:\\/\\/|www\\.|$)|[^ ](?= {2,}\\n)|[^a-zA-Z0-9.!#$%&'*+\\/=?_`{\\|}~-](?=[a-zA-Z0-9.!#$%&'*+\\/=?_`{\\|}~-]+@)))/}),v.gfm.url=p(v.gfm.url,\"i\").replace(\"email\",v.gfm._extended_email).getRegex(),v.breaks=d({},v.gfm,{br:p(v.br).replace(\"{2,}\",\"*\").getRegex(),text:p(v.gfm.text).replace(\"\\\\b_\",\"\\\\b_| {2,}\\\\n\").replace(/\\{2,\\}/g,\"*\").getRegex()});var z=function(){function u(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||r.defaults,this.options.tokenizer=this.options.tokenizer||new w,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,(this.tokenizer.lexer=this).inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};e={block:y.normal,inline:v.normal};this.options.pedantic?(e.block=y.pedantic,e.inline=v.pedantic):this.options.gfm&&(e.block=y.gfm,this.options.breaks?e.inline=v.breaks:e.inline=v.gfm),this.tokenizer.rules=e}u.lex=function(e,t){return new u(t).lex(e)},u.lexInline=function(e,t){return new u(t).inlineTokens(e)};var e,t,n=u.prototype;return n.lex=function(e){var t;for(e=e.replace(/\\r\\n|\\r/g,\"\\n\"),this.blockTokens(e,this.tokens);t=this.inlineQueue.shift();)this.inlineTokens(t.src,t.tokens);return this.tokens},n.blockTokens=function(r,t){var u,e,i,n,s=this;for(void 0===t&&(t=[]),r=this.options.pedantic?r.replace(/\\t/g,\"    \").replace(/^ +$/gm,\"\"):r.replace(/^( *)(\\t+)/gm,function(e,t,u){return t+\"    \".repeat(u.length)});r;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some(function(e){return!!(u=e.call({lexer:s},r,t))&&(r=r.substring(u.raw.length),t.push(u),!0)})))if(u=this.tokenizer.space(r))r=r.substring(u.raw.length),1===u.raw.length&&0<t.length?t[t.length-1].raw+=\"\\n\":t.push(u);else if(u=this.tokenizer.code(r))r=r.substring(u.raw.length),!(e=t[t.length-1])||\"paragraph\"!==e.type&&\"text\"!==e.type?t.push(u):(e.raw+=\"\\n\"+u.raw,e.text+=\"\\n\"+u.text,this.inlineQueue[this.inlineQueue.length-1].src=e.text);else if(u=this.tokenizer.fences(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.heading(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.hr(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.blockquote(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.list(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.html(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.def(r))r=r.substring(u.raw.length),!(e=t[t.length-1])||\"paragraph\"!==e.type&&\"text\"!==e.type?this.tokens.links[u.tag]||(this.tokens.links[u.tag]={href:u.href,title:u.title}):(e.raw+=\"\\n\"+u.raw,e.text+=\"\\n\"+u.raw,this.inlineQueue[this.inlineQueue.length-1].src=e.text);else if(u=this.tokenizer.table(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.lheading(r))r=r.substring(u.raw.length),t.push(u);else if(i=r,this.options.extensions&&this.options.extensions.startBlock&&!function(){var t=1/0,u=r.slice(1),n=void 0;s.options.extensions.startBlock.forEach(function(e){\"number\"==typeof(n=e.call({lexer:this},u))&&0<=n&&(t=Math.min(t,n))}),t<1/0&&0<=t&&(i=r.substring(0,t+1))}(),this.state.top&&(u=this.tokenizer.paragraph(i)))e=t[t.length-1],n&&\"paragraph\"===e.type?(e.raw+=\"\\n\"+u.raw,e.text+=\"\\n\"+u.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=e.text):t.push(u),n=i.length!==r.length,r=r.substring(u.raw.length);else if(u=this.tokenizer.text(r))r=r.substring(u.raw.length),(e=t[t.length-1])&&\"text\"===e.type?(e.raw+=\"\\n\"+u.raw,e.text+=\"\\n\"+u.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=e.text):t.push(u);else if(r){var l=\"Infinite loop on byte: \"+r.charCodeAt(0);if(this.options.silent){console.error(l);break}throw new Error(l)}return this.state.top=!0,t},n.inline=function(e,t){this.inlineQueue.push({src:e,tokens:t})},n.inlineTokens=function(r,t){var u,e,i,n,s,l,a=this,o=(void 0===t&&(t=[]),r);if(this.tokens.links){var D=Object.keys(this.tokens.links);if(0<D.length)for(;null!=(n=this.tokenizer.rules.inline.reflinkSearch.exec(o));)D.includes(n[0].slice(n[0].lastIndexOf(\"[\")+1,-1))&&(o=o.slice(0,n.index)+\"[\"+m(\"a\",n[0].length-2)+\"]\"+o.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(n=this.tokenizer.rules.inline.blockSkip.exec(o));)o=o.slice(0,n.index)+\"[\"+m(\"a\",n[0].length-2)+\"]\"+o.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(n=this.tokenizer.rules.inline.escapedEmSt.exec(o));)o=o.slice(0,n.index)+\"++\"+o.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex);for(;r;)if(s||(l=\"\"),s=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some(function(e){return!!(u=e.call({lexer:a},r,t))&&(r=r.substring(u.raw.length),t.push(u),!0)})))if(u=this.tokenizer.escape(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.tag(r))r=r.substring(u.raw.length),(e=t[t.length-1])&&\"text\"===u.type&&\"text\"===e.type?(e.raw+=u.raw,e.text+=u.text):t.push(u);else if(u=this.tokenizer.link(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.reflink(r,this.tokens.links))r=r.substring(u.raw.length),(e=t[t.length-1])&&\"text\"===u.type&&\"text\"===e.type?(e.raw+=u.raw,e.text+=u.text):t.push(u);else if(u=this.tokenizer.emStrong(r,o,l))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.codespan(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.br(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.del(r))r=r.substring(u.raw.length),t.push(u);else if(u=this.tokenizer.autolink(r,_))r=r.substring(u.raw.length),t.push(u);else if(!this.state.inLink&&(u=this.tokenizer.url(r,_)))r=r.substring(u.raw.length),t.push(u);else if(i=r,this.options.extensions&&this.options.extensions.startInline&&!function(){var t=1/0,u=r.slice(1),n=void 0;a.options.extensions.startInline.forEach(function(e){\"number\"==typeof(n=e.call({lexer:this},u))&&0<=n&&(t=Math.min(t,n))}),t<1/0&&0<=t&&(i=r.substring(0,t+1))}(),u=this.tokenizer.inlineText(i,j))r=r.substring(u.raw.length),\"_\"!==u.raw.slice(-1)&&(l=u.raw.slice(-1)),s=!0,(e=t[t.length-1])&&\"text\"===e.type?(e.raw+=u.raw,e.text+=u.text):t.push(u);else if(r){var c=\"Infinite loop on byte: \"+r.charCodeAt(0);if(this.options.silent){console.error(c);break}throw new Error(c)}return t},n=u,t=[{key:\"rules\",get:function(){return{block:y,inline:v}}}],(e=null)&&i(n.prototype,e),t&&i(n,t),Object.defineProperty(n,\"prototype\",{writable:!1}),u}(),$=function(){function e(e){this.options=e||r.defaults}var t=e.prototype;return t.code=function(e,t,u){var n,t=(t||\"\").match(/\\S*/)[0];return this.options.highlight&&null!=(n=this.options.highlight(e,t))&&n!==e&&(u=!0,e=n),e=e.replace(/\\n$/,\"\")+\"\\n\",t?'<pre><code class=\"'+this.options.langPrefix+D(t,!0)+'\">'+(u?e:D(e,!0))+\"</code></pre>\\n\":\"<pre><code>\"+(u?e:D(e,!0))+\"</code></pre>\\n\"},t.blockquote=function(e){return\"<blockquote>\\n\"+e+\"</blockquote>\\n\"},t.html=function(e){return e},t.heading=function(e,t,u,n){return this.options.headerIds?\"<h\"+t+' id=\"'+(this.options.headerPrefix+n.slug(u))+'\">'+e+\"</h\"+t+\">\\n\":\"<h\"+t+\">\"+e+\"</h\"+t+\">\\n\"},t.hr=function(){return this.options.xhtml?\"<hr/>\\n\":\"<hr>\\n\"},t.list=function(e,t,u){var n=t?\"ol\":\"ul\";return\"<\"+n+(t&&1!==u?' start=\"'+u+'\"':\"\")+\">\\n\"+e+\"</\"+n+\">\\n\"},t.listitem=function(e){return\"<li>\"+e+\"</li>\\n\"},t.checkbox=function(e){return\"<input \"+(e?'checked=\"\" ':\"\")+'disabled=\"\" type=\"checkbox\"'+(this.options.xhtml?\" /\":\"\")+\"> \"},t.paragraph=function(e){return\"<p>\"+e+\"</p>\\n\"},t.table=function(e,t){return\"<table>\\n<thead>\\n\"+e+\"</thead>\\n\"+(t=t&&\"<tbody>\"+t+\"</tbody>\")+\"</table>\\n\"},t.tablerow=function(e){return\"<tr>\\n\"+e+\"</tr>\\n\"},t.tablecell=function(e,t){var u=t.header?\"th\":\"td\";return(t.align?\"<\"+u+' align=\"'+t.align+'\">':\"<\"+u+\">\")+e+\"</\"+u+\">\\n\"},t.strong=function(e){return\"<strong>\"+e+\"</strong>\"},t.em=function(e){return\"<em>\"+e+\"</em>\"},t.codespan=function(e){return\"<code>\"+e+\"</code>\"},t.br=function(){return this.options.xhtml?\"<br/>\":\"<br>\"},t.del=function(e){return\"<del>\"+e+\"</del>\"},t.link=function(e,t,u){if(null===(e=g(this.options.sanitize,this.options.baseUrl,e)))return u;e='<a href=\"'+D(e)+'\"';return t&&(e+=' title=\"'+t+'\"'),e+=\">\"+u+\"</a>\"},t.image=function(e,t,u){if(null===(e=g(this.options.sanitize,this.options.baseUrl,e)))return u;e='<img src=\"'+e+'\" alt=\"'+u+'\"';return t&&(e+=' title=\"'+t+'\"'),e+=this.options.xhtml?\"/>\":\">\"},t.text=function(e){return e},e}(),S=function(){function e(){}var t=e.prototype;return t.strong=function(e){return e},t.em=function(e){return e},t.codespan=function(e){return e},t.del=function(e){return e},t.html=function(e){return e},t.text=function(e){return e},t.link=function(e,t,u){return\"\"+u},t.image=function(e,t,u){return\"\"+u},t.br=function(){return\"\"},e}(),T=function(){function e(){this.seen={}}var t=e.prototype;return t.serialize=function(e){return e.toLowerCase().trim().replace(/<[!\\/a-z].*?>/gi,\"\").replace(/[\\u2000-\\u206F\\u2E00-\\u2E7F\\\\'!\"#$%&()*+,./:;<=>?@[\\]^`{|}~]/g,\"\").replace(/\\s/g,\"-\")},t.getNextSafeSlug=function(e,t){var u=e,n=0;if(this.seen.hasOwnProperty(u))for(n=this.seen[e];u=e+\"-\"+ ++n,this.seen.hasOwnProperty(u););return t||(this.seen[e]=n,this.seen[u]=0),u},t.slug=function(e,t){void 0===t&&(t={});e=this.serialize(e);return this.getNextSafeSlug(e,t.dryrun)},e}(),R=function(){function u(e){this.options=e||r.defaults,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new S,this.slugger=new T}u.parse=function(e,t){return new u(t).parse(e)},u.parseInline=function(e,t){return new u(t).parseInline(e)};var e=u.prototype;return e.parse=function(e,t){void 0===t&&(t=!0);for(var u,n,r,i,s,l,a,o,D,c,h,p,f,g,F,A,d=\"\",C=e.length,k=0;k<C;k++)if(o=e[k],this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[o.type]&&(!1!==(A=this.options.extensions.renderers[o.type].call({parser:this},o))||![\"space\",\"hr\",\"heading\",\"code\",\"table\",\"blockquote\",\"list\",\"html\",\"paragraph\",\"text\"].includes(o.type)))d+=A||\"\";else switch(o.type){case\"space\":continue;case\"hr\":d+=this.renderer.hr();continue;case\"heading\":d+=this.renderer.heading(this.parseInline(o.tokens),o.depth,x(this.parseInline(o.tokens,this.textRenderer)),this.slugger);continue;case\"code\":d+=this.renderer.code(o.text,o.lang,o.escaped);continue;case\"table\":for(l=D=\"\",r=o.header.length,u=0;u<r;u++)l+=this.renderer.tablecell(this.parseInline(o.header[u].tokens),{header:!0,align:o.align[u]});for(D+=this.renderer.tablerow(l),a=\"\",r=o.rows.length,u=0;u<r;u++){for(l=\"\",i=(s=o.rows[u]).length,n=0;n<i;n++)l+=this.renderer.tablecell(this.parseInline(s[n].tokens),{header:!1,align:o.align[n]});a+=this.renderer.tablerow(l)}d+=this.renderer.table(D,a);continue;case\"blockquote\":a=this.parse(o.tokens),d+=this.renderer.blockquote(a);continue;case\"list\":for(D=o.ordered,E=o.start,c=o.loose,r=o.items.length,a=\"\",u=0;u<r;u++)f=(p=o.items[u]).checked,g=p.task,h=\"\",p.task&&(F=this.renderer.checkbox(f),c?0<p.tokens.length&&\"paragraph\"===p.tokens[0].type?(p.tokens[0].text=F+\" \"+p.tokens[0].text,p.tokens[0].tokens&&0<p.tokens[0].tokens.length&&\"text\"===p.tokens[0].tokens[0].type&&(p.tokens[0].tokens[0].text=F+\" \"+p.tokens[0].tokens[0].text)):p.tokens.unshift({type:\"text\",text:F}):h+=F),h+=this.parse(p.tokens,c),a+=this.renderer.listitem(h,g,f);d+=this.renderer.list(a,D,E);continue;case\"html\":d+=this.renderer.html(o.text);continue;case\"paragraph\":d+=this.renderer.paragraph(this.parseInline(o.tokens));continue;case\"text\":for(a=o.tokens?this.parseInline(o.tokens):o.text;k+1<C&&\"text\"===e[k+1].type;)a+=\"\\n\"+((o=e[++k]).tokens?this.parseInline(o.tokens):o.text);d+=t?this.renderer.paragraph(a):a;continue;default:var E='Token with \"'+o.type+'\" type was not found.';if(this.options.silent)return void console.error(E);throw new Error(E)}return d},e.parseInline=function(e,t){t=t||this.renderer;for(var u,n,r=\"\",i=e.length,s=0;s<i;s++)if(u=e[s],this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[u.type]&&(!1!==(n=this.options.extensions.renderers[u.type].call({parser:this},u))||![\"escape\",\"html\",\"link\",\"image\",\"strong\",\"em\",\"codespan\",\"br\",\"del\",\"text\"].includes(u.type)))r+=n||\"\";else switch(u.type){case\"escape\":r+=t.text(u.text);break;case\"html\":r+=t.html(u.text);break;case\"link\":r+=t.link(u.href,u.title,this.parseInline(u.tokens,t));break;case\"image\":r+=t.image(u.href,u.title,u.text);break;case\"strong\":r+=t.strong(this.parseInline(u.tokens,t));break;case\"em\":r+=t.em(this.parseInline(u.tokens,t));break;case\"codespan\":r+=t.codespan(u.text);break;case\"br\":r+=t.br();break;case\"del\":r+=t.del(this.parseInline(u.tokens,t));break;case\"text\":r+=t.text(u.text);break;default:var l='Token with \"'+u.type+'\" type was not found.';if(this.options.silent)return void console.error(l);throw new Error(l)}return r},u}();function I(e,u,n){if(null==e)throw new Error(\"marked(): input parameter is undefined or null\");if(\"string\"!=typeof e)throw new Error(\"marked(): input parameter is of type \"+Object.prototype.toString.call(e)+\", string expected\");if(\"function\"==typeof u&&(n=u,u=null),E(u=d({},I.defaults,u||{})),n){var r,i=u.highlight;try{r=z.lex(e,u)}catch(e){return n(e)}var s=function(t){var e;if(!t)try{u.walkTokens&&I.walkTokens(r,u.walkTokens),e=R.parse(r,u)}catch(e){t=e}return u.highlight=i,t?n(t):n(null,e)};if(!i||i.length<3)return s();if(delete u.highlight,!r.length)return s();var l=0;return I.walkTokens(r,function(u){\"code\"===u.type&&(l++,setTimeout(function(){i(u.text,u.lang,function(e,t){if(e)return s(e);null!=t&&t!==u.text&&(u.text=t,u.escaped=!0),0===--l&&s()})},0))}),void(0===l&&s())}try{var t=z.lex(e,u);return u.walkTokens&&I.walkTokens(t,u.walkTokens),R.parse(t,u)}catch(e){if(e.message+=\"\\nPlease report this to https://github.com/markedjs/marked.\",u.silent)return\"<p>An error occurred:</p><pre>\"+D(e.message+\"\",!0)+\"</pre>\";throw e}}I.options=I.setOptions=function(e){return d(I.defaults,e),e=I.defaults,r.defaults=e,I},I.getDefaults=e,I.defaults=r.defaults,I.use=function(){for(var e=arguments.length,t=new Array(e),u=0;u<e;u++)t[u]=arguments[u];var n,r=d.apply(void 0,[{}].concat(t)),o=I.defaults.extensions||{renderers:{},childTokens:{}};t.forEach(function(s){if(s.extensions&&(n=!0,s.extensions.forEach(function(r){if(!r.name)throw new Error(\"extension name required\");var i;if(r.renderer&&(i=o.renderers?o.renderers[r.name]:null,o.renderers[r.name]=i?function(){for(var e=arguments.length,t=new Array(e),u=0;u<e;u++)t[u]=arguments[u];var n=r.renderer.apply(this,t);return n=!1===n?i.apply(this,t):n}:r.renderer),r.tokenizer){if(!r.level||\"block\"!==r.level&&\"inline\"!==r.level)throw new Error(\"extension level must be 'block' or 'inline'\");o[r.level]?o[r.level].unshift(r.tokenizer):o[r.level]=[r.tokenizer],r.start&&(\"block\"===r.level?o.startBlock?o.startBlock.push(r.start):o.startBlock=[r.start]:\"inline\"===r.level&&(o.startInline?o.startInline.push(r.start):o.startInline=[r.start]))}r.childTokens&&(o.childTokens[r.name]=r.childTokens)})),s.renderer){var e,l=I.defaults.renderer||new $;for(e in s.renderer)!function(r){var i=l[r];l[r]=function(){for(var e=arguments.length,t=new Array(e),u=0;u<e;u++)t[u]=arguments[u];var n=s.renderer[r].apply(l,t);return n=!1===n?i.apply(l,t):n}}(e);r.renderer=l}if(s.tokenizer){var t,a=I.defaults.tokenizer||new w;for(t in s.tokenizer)!function(r){var i=a[r];a[r]=function(){for(var e=arguments.length,t=new Array(e),u=0;u<e;u++)t[u]=arguments[u];var n=s.tokenizer[r].apply(a,t);return n=!1===n?i.apply(a,t):n}}(t);r.tokenizer=a}var u;s.walkTokens&&(u=I.defaults.walkTokens,r.walkTokens=function(e){s.walkTokens.call(this,e),u&&u.call(this,e)}),n&&(r.extensions=o),I.setOptions(r)})},I.walkTokens=function(e,l){for(var a,t=b(e);!(a=t()).done;)!function(){var t=a.value;switch(l.call(I,t),t.type){case\"table\":for(var e=b(t.header);!(u=e()).done;){var u=u.value;I.walkTokens(u.tokens,l)}for(var n,r=b(t.rows);!(n=r()).done;)for(var i=b(n.value);!(s=i()).done;){var s=s.value;I.walkTokens(s.tokens,l)}break;case\"list\":I.walkTokens(t.items,l);break;default:I.defaults.extensions&&I.defaults.extensions.childTokens&&I.defaults.extensions.childTokens[t.type]?I.defaults.extensions.childTokens[t.type].forEach(function(e){I.walkTokens(t[e],l)}):t.tokens&&I.walkTokens(t.tokens,l)}}()},I.parseInline=function(e,t){if(null==e)throw new Error(\"marked.parseInline(): input parameter is undefined or null\");if(\"string\"!=typeof e)throw new Error(\"marked.parseInline(): input parameter is of type \"+Object.prototype.toString.call(e)+\", string expected\");E(t=d({},I.defaults,t||{}));try{var u=z.lexInline(e,t);return t.walkTokens&&I.walkTokens(u,t.walkTokens),R.parseInline(u,t)}catch(e){if(e.message+=\"\\nPlease report this to https://github.com/markedjs/marked.\",t.silent)return\"<p>An error occurred:</p><pre>\"+D(e.message+\"\",!0)+\"</pre>\";throw e}},I.Parser=R,I.parser=R.parse,I.Renderer=$,I.TextRenderer=S,I.Lexer=z,I.lexer=z.lex,I.Tokenizer=w,I.Slugger=T;var A=(I.parse=I).options,P=I.setOptions,Q=I.use,U=I.walkTokens,M=I.parseInline,N=I,X=R.parse,G=z.lex;r.Lexer=z,r.Parser=R,r.Renderer=$,r.Slugger=T,r.TextRenderer=S,r.Tokenizer=w,r.getDefaults=e,r.lexer=G,r.marked=I,r.options=A,r.parse=N,r.parseInline=M,r.parser=X,r.setOptions=P,r.use=Q,r.walkTokens=U,Object.defineProperty(r,\"__esModule\",{value:!0})});"
  },
  {
    "path": "client/static/robots.txt",
    "content": "User-Agent: * \nDisallow: / "
  },
  {
    "path": "client/store/globals.js",
    "content": "export const state = () => ({\n  isMobile: false,\n  isMobileLandscape: false,\n  isMobilePortrait: false,\n  showBatchCollectionModal: false,\n  showCollectionsModal: false,\n  showEditCollectionModal: false,\n  showPlaylistsModal: false,\n  showEditPlaylistModal: false,\n  showEditPodcastEpisode: false,\n  showViewPodcastEpisodeModal: false,\n  showRSSFeedOpenCloseModal: false,\n  showShareModal: false,\n  showConfirmPrompt: false,\n  showRawCoverPreviewModal: false,\n  confirmPromptOptions: null,\n  showEditAuthorModal: false,\n  rssFeedEntity: null,\n  selectedEpisode: null,\n  selectedPlaylistItems: null,\n  selectedPlaylist: null,\n  selectedCollection: null,\n  selectedAuthor: null,\n  selectedMediaItems: [],\n  selectedRawCoverUrl: null,\n  selectedMediaItemShare: null,\n  isCasting: false, // Actively casting\n  isChromecastInitialized: false, // Script loadeds\n  showBatchQuickMatchModal: false,\n  dateFormats: [\n    {\n      text: 'MM/DD/YYYY',\n      value: 'MM/dd/yyyy'\n    },\n    {\n      text: 'DD/MM/YYYY',\n      value: 'dd/MM/yyyy'\n    },\n    {\n      text: 'DD.MM.YYYY',\n      value: 'dd.MM.yyyy'\n    },\n    {\n      text: 'YYYY-MM-DD',\n      value: 'yyyy-MM-dd'\n    },\n    {\n      text: 'MMM do, yyyy',\n      value: 'MMM do, yyyy'\n    },\n    {\n      text: 'MMMM do, yyyy',\n      value: 'MMMM do, yyyy'\n    },\n    {\n      text: 'dd MMM yyyy',\n      value: 'dd MMM yyyy'\n    },\n    {\n      text: 'dd MMMM yyyy',\n      value: 'dd MMMM yyyy'\n    }\n  ],\n  timeFormats: [\n    {\n      text: 'h:mma (am/pm)',\n      value: 'h:mma'\n    },\n    {\n      text: 'HH:mm (24-hour)',\n      value: 'HH:mm'\n    }\n  ],\n  podcastTypes: [\n    { text: 'Episodic', value: 'episodic', descriptionKey: 'LabelEpisodic' },\n    { text: 'Serial', value: 'serial', descriptionKey: 'LabelSerial' }\n  ],\n  episodeTypes: [\n    { text: 'Full', value: 'full', descriptionKey: 'LabelFull' },\n    { text: 'Trailer', value: 'trailer', descriptionKey: 'LabelTrailer' },\n    { text: 'Bonus', value: 'bonus', descriptionKey: 'LabelBonus' }\n  ],\n  libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart']\n})\n\nexport const getters = {\n  getLibraryItemCoverSrc:\n    (state, getters, rootState, rootGetters) =>\n    (libraryItem, placeholder = null, raw = false) => {\n      if (!placeholder) placeholder = getters.getPlaceholderCoverSrc\n      if (!libraryItem) return placeholder\n      const media = libraryItem.media\n      if (!media?.coverPath || media.coverPath === placeholder) return placeholder\n\n      // Absolute URL covers (should no longer be used)\n      if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath\n\n      const lastUpdate = libraryItem.updatedAt || Date.now()\n      const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers\n      return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}`\n    },\n  getLibraryItemCoverSrcById:\n    (state, getters, rootState, rootGetters) =>\n    (libraryItemId, timestamp = null, raw = false) => {\n      if (!libraryItemId) return getters.getPlaceholderCoverSrc\n\n      return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`\n    },\n  getPlaceholderCoverSrc: (state, getters, rootState, rootGetters) => {\n    return `${rootState.routerBasePath}/book_placeholder.jpg`\n  },\n  getIsBatchSelectingMediaItems: (state) => {\n    return state.selectedMediaItems.length\n  }\n}\n\nexport const mutations = {\n  updateWindowSize(state, { width, height }) {\n    state.isMobile = width < 640 || height < 640\n    state.isMobileLandscape = state.isMobile && height < width\n    state.isMobilePortrait = state.isMobile && height >= width\n  },\n  setShowCollectionsModal(state, val) {\n    state.showBatchCollectionModal = false\n    state.showCollectionsModal = val\n  },\n  setShowBatchCollectionsModal(state, val) {\n    state.showBatchCollectionModal = true\n    state.showCollectionsModal = val\n  },\n  setShowEditCollectionModal(state, val) {\n    state.showEditCollectionModal = val\n  },\n  setShowPlaylistsModal(state, val) {\n    state.showPlaylistsModal = val\n  },\n  setShowEditPlaylistModal(state, val) {\n    state.showEditPlaylistModal = val\n  },\n  setShowEditPodcastEpisodeModal(state, val) {\n    state.showEditPodcastEpisode = val\n  },\n  setShowViewPodcastEpisodeModal(state, val) {\n    state.showViewPodcastEpisodeModal = val\n  },\n  setShowRSSFeedOpenCloseModal(state, val) {\n    state.showRSSFeedOpenCloseModal = val\n  },\n  setRSSFeedOpenCloseModal(state, entity) {\n    state.rssFeedEntity = entity\n    state.showRSSFeedOpenCloseModal = true\n  },\n  setShowShareModal(state, val) {\n    state.showShareModal = val\n  },\n  setShareModal(state, mediaItemShare) {\n    state.selectedMediaItemShare = mediaItemShare\n    state.showShareModal = true\n  },\n  setShowConfirmPrompt(state, val) {\n    state.showConfirmPrompt = val\n  },\n  setConfirmPrompt(state, options) {\n    state.confirmPromptOptions = options\n    state.showConfirmPrompt = true\n  },\n  setShowRawCoverPreviewModal(state, val) {\n    state.showRawCoverPreviewModal = val\n  },\n  setRawCoverPreviewModal(state, rawCoverUrl) {\n    state.selectedRawCoverUrl = rawCoverUrl\n    state.showRawCoverPreviewModal = true\n  },\n  setEditCollection(state, collection) {\n    state.selectedCollection = collection\n    state.showEditCollectionModal = true\n  },\n  setEditPlaylist(state, playlist) {\n    state.selectedPlaylist = playlist\n    state.showEditPlaylistModal = true\n  },\n  setSelectedEpisode(state, episode) {\n    state.selectedEpisode = episode\n  },\n  setSelectedPlaylistItems(state, items) {\n    state.selectedPlaylistItems = items\n  },\n  showEditAuthorModal(state, author) {\n    state.selectedAuthor = author\n    state.showEditAuthorModal = true\n  },\n  setShowEditAuthorModal(state, val) {\n    state.showEditAuthorModal = val\n  },\n  setSelectedAuthor(state, author) {\n    state.selectedAuthor = author\n  },\n  setChromecastInitialized(state, val) {\n    state.isChromecastInitialized = val\n  },\n  setCasting(state, val) {\n    state.isCasting = val\n  },\n  setShowBatchQuickMatchModal(state, val) {\n    state.showBatchQuickMatchModal = val\n  },\n  resetSelectedMediaItems(state) {\n    state.selectedMediaItems = []\n  },\n  toggleMediaItemSelected(state, item) {\n    if (state.selectedMediaItems.some((i) => i.id === item.id)) {\n      state.selectedMediaItems = state.selectedMediaItems.filter((i) => i.id !== item.id)\n    } else {\n      state.selectedMediaItems.push(item)\n    }\n  },\n  setMediaItemSelected(state, { item, selected }) {\n    const isAlreadySelected = state.selectedMediaItems.some((i) => i.id === item.id)\n    if (isAlreadySelected && !selected) {\n      state.selectedMediaItems = state.selectedMediaItems.filter((i) => i.id !== item.id)\n    } else if (selected && !isAlreadySelected) {\n      state.selectedMediaItems.push(item)\n    }\n  }\n}\n"
  },
  {
    "path": "client/store/index.js",
    "content": "import { checkForUpdate, currentVersion } from '@/plugins/version'\nimport Vue from 'vue'\nconst { Constants } = require('../plugins/constants')\n\nexport const state = () => ({\n  Source: null,\n  versionData: null,\n  serverSettings: null,\n  playbackSessionId: null,\n  streamLibraryItem: null,\n  streamEpisodeId: null,\n  streamIsPlaying: false,\n  playerQueueItems: [],\n  playerQueueAutoPlay: true,\n  playerIsFullscreen: false,\n  editModalTab: 'details',\n  editPodcastModalTab: 'details',\n  showEditModal: false,\n  showEReader: false,\n  ereaderKeepProgress: false,\n  ereaderFileId: null,\n  selectedLibraryItem: null,\n  developerMode: false,\n  processingBatch: false,\n  previousPath: '/',\n  bookshelfBookIds: [],\n  episodeTableEpisodeIds: [],\n  openModal: null,\n  innerModalOpen: false,\n  lastBookshelfScrollData: {},\n  routerBasePath: '/'\n})\n\nexport const getters = {\n  getServerSetting: (state) => (key) => {\n    if (!state.serverSettings) return null\n    return state.serverSettings[key]\n  },\n  getLibraryItemIdStreaming: (state) => {\n    return state.streamLibraryItem?.id || null\n  },\n  getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {\n    if (!state.streamLibraryItem) return false\n    return state.streamLibraryItem.libraryId !== rootState.libraries.currentLibraryId\n  },\n  getIsMediaStreaming: (state) => (libraryItemId, episodeId) => {\n    if (!state.streamLibraryItem) return null\n    if (!episodeId) return state.streamLibraryItem.id == libraryItemId\n    return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId\n  },\n  getIsMediaQueued: (state) => (libraryItemId, episodeId) => {\n    return state.playerQueueItems.some((i) => {\n      if (!episodeId) return i.libraryItemId === libraryItemId\n      return i.libraryItemId === libraryItemId && i.episodeId === episodeId\n    })\n  },\n  getBookshelfView: (state) => {\n    if (!state.serverSettings || isNaN(state.serverSettings.bookshelfView)) return Constants.BookshelfView.STANDARD\n    return state.serverSettings.bookshelfView\n  },\n  getHomeBookshelfView: (state) => {\n    if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD\n    return state.serverSettings.homeBookshelfView\n  }\n}\n\nexport const actions = {\n  updateServerSettings({ commit }, payload) {\n    const updatePayload = {\n      ...payload\n    }\n    return this.$axios\n      .$patch('/api/settings', updatePayload)\n      .then((result) => {\n        if (result.serverSettings) {\n          commit('setServerSettings', result.serverSettings)\n        }\n        return result\n      })\n      .catch((error) => {\n        console.error('Failed to update server settings', error)\n        const errorMsg = error.response?.data || 'Unknown error'\n        return {\n          error: errorMsg\n        }\n      })\n  },\n  checkForUpdate({ commit }) {\n    const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes\n    var lastVerCheck = localStorage.getItem('lastVerCheck') || 0\n    var savedVersionData = localStorage.getItem('versionData')\n    if (savedVersionData) {\n      try {\n        savedVersionData = JSON.parse(localStorage.getItem('versionData'))\n      } catch (error) {\n        console.error('Failed to parse version data', error)\n        savedVersionData = null\n        localStorage.removeItem('versionData')\n      }\n    }\n\n    var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF\n    if (!shouldCheckForUpdate && savedVersionData && (savedVersionData.version !== currentVersion || !savedVersionData.releasesToShow)) {\n      // Version mismatch between saved data so check for update anyway\n      shouldCheckForUpdate = true\n    }\n\n    if (shouldCheckForUpdate) {\n      return checkForUpdate()\n        .then((res) => {\n          if (res) {\n            localStorage.setItem('lastVerCheck', Date.now())\n            localStorage.setItem('versionData', JSON.stringify(res))\n\n            commit('setVersionData', res)\n          }\n          return res && res.hasUpdate\n        })\n        .catch((error) => {\n          console.error('Update check failed', error)\n          return false\n        })\n    } else if (savedVersionData) {\n      commit('setVersionData', savedVersionData)\n    }\n    return null\n  }\n}\n\nexport const mutations = {\n  setRouterBasePath(state, rbp) {\n    state.routerBasePath = rbp\n  },\n  setSource(state, source) {\n    state.Source = source\n  },\n  setPlayerIsFullscreen(state, val) {\n    state.playerIsFullscreen = val\n  },\n  setLastBookshelfScrollData(state, { scrollTop, path, name }) {\n    state.lastBookshelfScrollData[name] = { scrollTop, path }\n  },\n  setBookshelfBookIds(state, val) {\n    state.bookshelfBookIds = val || []\n  },\n  setEpisodeTableEpisodeIds(state, val) {\n    state.episodeTableEpisodeIds = val || []\n  },\n  setPreviousPath(state, val) {\n    state.previousPath = val\n  },\n  setVersionData(state, versionData) {\n    state.versionData = versionData\n  },\n  setServerSettings(state, settings) {\n    if (!settings) return\n    state.serverSettings = settings\n  },\n  setPlaybackSessionId(state, playbackSessionId) {\n    state.playbackSessionId = playbackSessionId\n  },\n  setMediaPlaying(state, payload) {\n    if (!payload) {\n      state.streamLibraryItem = null\n      state.streamEpisodeId = null\n      state.streamIsPlaying = false\n      state.playerQueueItems = []\n    } else {\n      state.streamLibraryItem = payload.libraryItem\n      state.streamEpisodeId = payload.episodeId || null\n      state.playerQueueItems = payload.queueItems || []\n    }\n  },\n  updateStreamLibraryItem(state, libraryItem) {\n    if (!libraryItem) return\n    state.streamLibraryItem = libraryItem\n  },\n  setIsPlaying(state, isPlaying) {\n    state.streamIsPlaying = isPlaying\n  },\n  setPlayerQueueItems(state, items) {\n    state.playerQueueItems = items || []\n  },\n  removeItemFromQueue(state, item) {\n    state.playerQueueItems = state.playerQueueItems.filter((i) => {\n      if (!i.episodeId) return i.libraryItemId !== item.libraryItemId\n      return i.libraryItemId !== item.libraryItemId || i.episodeId !== item.episodeId\n    })\n  },\n  addItemToQueue(state, item) {\n    const exists = state.playerQueueItems.some((i) => {\n      if (!i.episodeId) return i.libraryItemId === item.libraryItemId\n      return i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId\n    })\n    if (!exists) {\n      state.playerQueueItems.push(item)\n    }\n  },\n  setPlayerQueueAutoPlay(state, autoPlay) {\n    state.playerQueueAutoPlay = !!autoPlay\n    localStorage.setItem('playerQueueAutoPlay', !!autoPlay ? '1' : '0')\n  },\n  showEditModal(state, libraryItem) {\n    state.editModalTab = 'details'\n    state.selectedLibraryItem = libraryItem\n    state.showEditModal = true\n  },\n  showEditModalOnTab(state, { libraryItem, tab }) {\n    state.editModalTab = tab\n    state.selectedLibraryItem = libraryItem\n    state.showEditModal = true\n  },\n  setEditModalTab(state, tab) {\n    state.editModalTab = tab\n  },\n  setShowEditModal(state, val) {\n    state.showEditModal = val\n  },\n  setEditPodcastModalTab(state, tab) {\n    state.editPodcastModalTab = tab\n  },\n  showEReader(state, { libraryItem, keepProgress, fileId }) {\n    state.selectedLibraryItem = libraryItem\n    state.ereaderKeepProgress = keepProgress\n    state.ereaderFileId = fileId\n\n    state.showEReader = true\n  },\n  setShowEReader(state, val) {\n    state.showEReader = val\n  },\n  setDeveloperMode(state, val) {\n    state.developerMode = val\n  },\n  setSelectedLibraryItem(state, val) {\n    Vue.set(state, 'selectedLibraryItem', val)\n  },\n  setProcessingBatch(state, val) {\n    state.processingBatch = val\n  },\n  setOpenModal(state, val) {\n    state.openModal = val\n  },\n  setInnerModalOpen(state, val) {\n    state.innerModalOpen = val\n  }\n}\n"
  },
  {
    "path": "client/store/libraries.js",
    "content": "const { Constants } = require('../plugins/constants')\n\nexport const state = () => ({\n  libraries: [],\n  lastLoad: 0,\n  listeners: [],\n  currentLibraryId: null,\n  folders: [],\n  issues: 0,\n  folderLastUpdate: 0,\n  filterData: null,\n  numUserPlaylists: 0,\n  collections: [],\n  userPlaylists: [],\n  ereaderDevices: []\n})\n\nexport const getters = {\n  getCurrentLibrary: (state) => {\n    return state.libraries.find((lib) => lib.id === state.currentLibraryId)\n  },\n  getCurrentLibraryName: (state, getters) => {\n    var currentLibrary = getters.getCurrentLibrary\n    if (!currentLibrary) return ''\n    return currentLibrary.name\n  },\n  getCurrentLibraryMediaType: (state, getters) => {\n    if (!getters.getCurrentLibrary) return null\n    return getters.getCurrentLibrary.mediaType\n  },\n  getSortedLibraries: (state) => () => {\n    return state.libraries.map((lib) => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)\n  },\n  getLibraryProvider: (state) => (libraryId) => {\n    var library = state.libraries.find((l) => l.id === libraryId)\n    if (!library) return null\n    return library.provider\n  },\n  getNextAccessibleLibrary: (state, getters, rootState, rootGetters) => {\n    var librariesSorted = getters['getSortedLibraries']()\n    if (!librariesSorted.length) return null\n\n    var canAccessAllLibraries = rootGetters['user/getUserCanAccessAllLibraries']\n    var userAccessibleLibraries = rootGetters['user/getLibrariesAccessible']\n    if (canAccessAllLibraries) return librariesSorted[0]\n    librariesSorted = librariesSorted.filter((lib) => {\n      return userAccessibleLibraries.includes(lib.id)\n    })\n    if (!librariesSorted.length) return null\n    return librariesSorted[0]\n  },\n  getCurrentLibrarySettings: (state, getters) => {\n    if (!getters.getCurrentLibrary) return null\n    return getters.getCurrentLibrary.settings\n  },\n  getBookCoverAspectRatio: (state, getters) => {\n    if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1\n    return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1\n  },\n  getLibraryIsAudiobooksOnly: (state, getters) => {\n    return !!getters.getCurrentLibrarySettings?.audiobooksOnly\n  },\n  getLibraryEpubsAllowScriptedContent: (state, getters) => {\n    return !!getters.getCurrentLibrarySettings?.epubsAllowScriptedContent\n  },\n  getCollection: (state) => (id) => {\n    return state.collections.find((c) => c.id === id)\n  },\n  getPlaylist: (state) => (id) => {\n    return state.userPlaylists.find((p) => p.id === id)\n  }\n}\n\nexport const actions = {\n  requestLibraryScan({ state, commit }, { libraryId, force }) {\n    return this.$axios.$post(`/api/libraries/${libraryId}/scan?force=${force ? 1 : 0}`)\n  },\n  loadFolders({ state, commit }) {\n    if (state.folders.length) {\n      const lastCheck = Date.now() - state.folderLastUpdate\n      if (lastCheck < 1000 * 5) {\n        // 5 seconds\n        // Folders up to date\n        return state.folders\n      }\n    }\n    commit('setFoldersLastUpdate')\n\n    return this.$axios\n      .$get('/api/filesystem')\n      .then((res) => {\n        commit('setFolders', res.directories)\n        return res.directories\n      })\n      .catch((error) => {\n        console.error('Failed to load dirs', error)\n        commit('setFolders', [])\n        return []\n      })\n  },\n  fetch({ state, dispatch, commit, rootState, rootGetters }, libraryId) {\n    if (!rootState.user || !rootState.user.user) {\n      console.error('libraries/fetch - User not set')\n      return false\n    }\n\n    var canUserAccessLibrary = rootGetters['user/getCanAccessLibrary'](libraryId)\n    if (!canUserAccessLibrary) {\n      console.warn('Access not allowed to library')\n      return false\n    }\n\n    const libraryChanging = state.currentLibraryId !== libraryId\n    return this.$axios\n      .$get(`/api/libraries/${libraryId}?include=filterdata`)\n      .then((data) => {\n        const library = data.library\n        const filterData = data.filterdata\n        const issues = data.issues || 0\n        const numUserPlaylists = data.numUserPlaylists\n\n        dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })\n\n        if (libraryChanging) {\n          commit('setCollections', [])\n          commit('setUserPlaylists', [])\n        }\n\n        commit('addUpdate', library)\n        commit('setLibraryIssues', issues)\n        commit('setLibraryFilterData', filterData)\n        commit('setNumUserPlaylists', numUserPlaylists)\n        commit('setCurrentLibrary', { id: libraryId })\n        return data\n      })\n      .catch((error) => {\n        console.error('Failed', error)\n        return false\n      })\n  },\n  // Return true if calling load\n  load({ state, commit, rootState }) {\n    if (!rootState.user || !rootState.user.user) {\n      console.error('libraries/load - User not set')\n      return false\n    }\n\n    // Don't load again if already loaded in the last 5 minutes\n    var lastLoadDiff = Date.now() - state.lastLoad\n    if (lastLoadDiff < 5 * 60 * 1000) {\n      // Already up to date\n      return false\n    }\n\n    this.$axios\n      .$get(`/api/libraries`)\n      .then((data) => {\n        commit('set', data.libraries)\n        commit('setLastLoad', new Date())\n      })\n      .catch((error) => {\n        console.error('Failed', error)\n        commit('set', [])\n      })\n    return true\n  }\n}\n\nexport const mutations = {\n  setFolders(state, folders) {\n    state.folders = folders\n  },\n  setFoldersLastUpdate(state) {\n    state.folderLastUpdate = Date.now()\n  },\n  setLastLoad(state, date) {\n    state.lastLoad = date\n  },\n  setLibraryIssues(state, val) {\n    state.issues = val\n  },\n  setCurrentLibrary(state, { id }) {\n    state.currentLibraryId = id\n  },\n  set(state, libraries) {\n    state.libraries = libraries\n    state.listeners.forEach((listener) => {\n      listener.meth()\n    })\n  },\n  addUpdate(state, library) {\n    var index = state.libraries.findIndex((a) => a.id === library.id)\n    if (index >= 0) {\n      state.libraries.splice(index, 1, library)\n    } else {\n      state.libraries.push(library)\n    }\n\n    state.listeners.forEach((listener) => {\n      listener.meth()\n    })\n  },\n  remove(state, library) {\n    state.libraries = state.libraries.filter((a) => a.id !== library.id)\n\n    state.listeners.forEach((listener) => {\n      listener.meth()\n    })\n  },\n  addListener(state, listener) {\n    var index = state.listeners.findIndex((l) => l.id === listener.id)\n    if (index >= 0) state.listeners.splice(index, 1, listener)\n    else state.listeners.push(listener)\n  },\n  removeListener(state, listenerId) {\n    state.listeners = state.listeners.filter((l) => l.id !== listenerId)\n  },\n  setLibraryFilterData(state, filterData) {\n    state.filterData = filterData\n  },\n  setNumUserPlaylists(state, numUserPlaylists) {\n    state.numUserPlaylists = numUserPlaylists\n  },\n  removeSeriesFromFilterData(state, seriesId) {\n    if (!seriesId || !state.filterData) return\n    state.filterData.series = state.filterData.series.filter((se) => se.id !== seriesId)\n  },\n  updateFilterDataWithItem(state, libraryItem) {\n    if (!libraryItem || !state.filterData) return\n    if (state.currentLibraryId !== libraryItem.libraryId) return\n    /*\n    structure of filterData:\n    {\n      authors: [],\n      genres: [],\n      tags: [],\n      series: [],\n      narrators: [],\n      languages: [],\n      publishers: [],\n      publishedDecades: []\n    }\n    */\n    const mediaMetadata = libraryItem.media.metadata\n\n    // Add/update book authors\n    if (mediaMetadata.authors?.length) {\n      mediaMetadata.authors.forEach((author) => {\n        const indexOf = state.filterData.authors.findIndex((au) => au.id === author.id)\n        if (indexOf >= 0) {\n          state.filterData.authors.splice(indexOf, 1, author)\n        } else {\n          state.filterData.authors.push(author)\n          state.filterData.authors.sort((a, b) => (a.name || '').localeCompare(b.name || ''))\n        }\n      })\n    }\n\n    // Add/update series\n    if (mediaMetadata.series?.length) {\n      mediaMetadata.series.forEach((series) => {\n        const indexOf = state.filterData.series.findIndex((se) => se.id === series.id)\n        if (indexOf >= 0) {\n          state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })\n        } else {\n          state.filterData.series.push({ id: series.id, name: series.name })\n          state.filterData.series.sort((a, b) => (a.name || '').localeCompare(b.name || ''))\n        }\n      })\n    }\n\n    // Add genres\n    if (mediaMetadata.genres?.length) {\n      mediaMetadata.genres.forEach((genre) => {\n        if (!state.filterData.genres.includes(genre)) {\n          state.filterData.genres.push(genre)\n          state.filterData.genres.sort((a, b) => a.localeCompare(b))\n        }\n      })\n    }\n\n    // Add tags\n    if (libraryItem.media.tags?.length) {\n      libraryItem.media.tags.forEach((tag) => {\n        if (!state.filterData.tags.includes(tag)) {\n          state.filterData.tags.push(tag)\n          state.filterData.tags.sort((a, b) => a.localeCompare(b))\n        }\n      })\n    }\n\n    // Add narrators\n    if (mediaMetadata.narrators?.length) {\n      mediaMetadata.narrators.forEach((narrator) => {\n        if (!state.filterData.narrators.includes(narrator)) {\n          state.filterData.narrators.push(narrator)\n          state.filterData.narrators.sort((a, b) => a.localeCompare(b))\n        }\n      })\n    }\n\n    // Add publishers\n    if (mediaMetadata.publisher && !state.filterData.publishers.includes(mediaMetadata.publisher)) {\n      state.filterData.publishers.push(mediaMetadata.publisher)\n      state.filterData.publishers.sort((a, b) => a.localeCompare(b))\n    }\n\n    // Add publishedDecades\n    if (mediaMetadata.publishedYear && !isNaN(mediaMetadata.publishedYear)) {\n      const publishedYear = parseInt(mediaMetadata.publishedYear, 10)\n      const decade = (Math.floor(publishedYear / 10) * 10).toString()\n      if (!state.filterData.publishedDecades.includes(decade)) {\n        state.filterData.publishedDecades.push(decade)\n        state.filterData.publishedDecades.sort((a, b) => a - b)\n      }\n    }\n\n    // Add language\n    if (mediaMetadata.language && !state.filterData.languages.includes(mediaMetadata.language)) {\n      state.filterData.languages.push(mediaMetadata.language)\n      state.filterData.languages.sort((a, b) => a.localeCompare(b))\n    }\n  },\n  setCollections(state, collections) {\n    state.collections = collections\n  },\n  addUpdateCollection(state, collection) {\n    var index = state.collections.findIndex((c) => c.id === collection.id)\n    if (index >= 0) {\n      state.collections.splice(index, 1, collection)\n    } else {\n      state.collections.push(collection)\n    }\n  },\n  removeCollection(state, collection) {\n    state.collections = state.collections.filter((c) => c.id !== collection.id)\n  },\n  setUserPlaylists(state, playlists) {\n    state.userPlaylists = playlists\n    state.numUserPlaylists = playlists.length\n  },\n  addUpdateUserPlaylist(state, playlist) {\n    const index = state.userPlaylists.findIndex((p) => p.id === playlist.id)\n    if (index >= 0) {\n      state.userPlaylists.splice(index, 1, playlist)\n    } else {\n      state.userPlaylists.push(playlist)\n      state.numUserPlaylists++\n    }\n  },\n  removeUserPlaylist(state, playlist) {\n    state.userPlaylists = state.userPlaylists.filter((p) => p.id !== playlist.id)\n    state.numUserPlaylists = state.userPlaylists.length\n  },\n  setEReaderDevices(state, ereaderDevices) {\n    state.ereaderDevices = ereaderDevices\n  }\n}\n"
  },
  {
    "path": "client/store/scanners.js",
    "content": "export const state = () => ({\n  bookProviders: [],\n  podcastProviders: [],\n  bookCoverProviders: [],\n  podcastCoverProviders: [],\n  providersLoaded: false\n})\n\nexport const getters = {\n  checkBookProviderExists: (state) => (providerValue) => {\n    return state.bookProviders.some((p) => p.value === providerValue)\n  },\n  checkPodcastProviderExists: (state) => (providerValue) => {\n    return state.podcastProviders.some((p) => p.value === providerValue)\n  },\n  areProvidersLoaded: (state) => state.providersLoaded\n}\n\nexport const actions = {\n  async fetchProviders({ commit, state }) {\n    // Only fetch if not already loaded\n    if (state.providersLoaded) {\n      return\n    }\n\n    try {\n      const response = await this.$axios.$get('/api/search/providers')\n      if (response?.providers) {\n        commit('setAllProviders', response.providers)\n      }\n    } catch (error) {\n      console.error('Failed to fetch providers', error)\n    }\n  },\n  async refreshProviders({ commit, state }) {\n    // if providers are not loaded, do nothing - they will be fetched when required (\n    if (!state.providersLoaded) {\n      return\n    }\n\n    try {\n      const response = await this.$axios.$get('/api/search/providers')\n      if (response?.providers) {\n        commit('setAllProviders', response.providers)\n      }\n    } catch (error) {\n      console.error('Failed to refresh providers', error)\n    }\n  }\n}\n\nexport const mutations = {\n  setAllProviders(state, providers) {\n    state.bookProviders = providers.books || []\n    state.podcastProviders = providers.podcasts || []\n    state.bookCoverProviders = providers.booksCovers || []\n    state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only\n    state.providersLoaded = true\n  }\n}\n"
  },
  {
    "path": "client/store/tasks.js",
    "content": "import Vue from 'vue'\n\nexport const state = () => ({\n  tasks: [],\n  queuedEmbedLIds: [],\n  audioFilesEncoding: {},\n  audioFilesFinished: {},\n  taskProgress: {}\n})\n\nexport const getters = {\n  getTasksByLibraryItemId: (state) => (libraryItemId) => {\n    return state.tasks.filter((t) => t.data?.libraryItemId === libraryItemId)\n  },\n  getRunningLibraryScanTask: (state) => (libraryId) => {\n    const libraryScanActions = ['library-scan', 'library-match-all']\n    return state.tasks.find((t) => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished)\n  },\n  getAudioFilesEncoding: (state) => (libraryItemId) => {\n    return state.audioFilesEncoding[libraryItemId]\n  },\n  getAudioFilesFinished: (state) => (libraryItemId) => {\n    return state.audioFilesFinished[libraryItemId]\n  },\n  getTaskProgress: (state) => (libraryItemId) => {\n    return state.taskProgress[libraryItemId]\n  }\n}\n\nexport const actions = {}\n\nexport const mutations = {\n  updateAudioFilesEncoding(state, payload) {\n    if (!state.audioFilesEncoding[payload.libraryItemId]) {\n      Vue.set(state.audioFilesEncoding, payload.libraryItemId, {})\n    }\n    Vue.set(state.audioFilesEncoding[payload.libraryItemId], payload.ino, payload.progress)\n  },\n  updateAudioFilesFinished(state, payload) {\n    if (!state.audioFilesFinished[payload.libraryItemId]) {\n      Vue.set(state.audioFilesFinished, payload.libraryItemId, {})\n    }\n    Vue.set(state.audioFilesFinished[payload.libraryItemId], payload.ino, payload.finished)\n  },\n  updateTaskProgress(state, payload) {\n    Vue.set(state.taskProgress, payload.libraryItemId, payload.progress)\n  },\n  setTasks(state, tasks) {\n    state.tasks = tasks\n  },\n  addUpdateTask(state, task) {\n    const index = state.tasks.findIndex((d) => d.id === task.id)\n    if (index >= 0) {\n      state.tasks.splice(index, 1, task)\n    } else {\n      // Remove duplicate (only have one library item per action)\n      state.tasks = state.tasks.filter((_task) => {\n        if (!_task.data?.libraryItemId || _task.action !== task.action) return true\n        return _task.data.libraryItemId !== task.data.libraryItemId\n      })\n\n      state.tasks.push(task)\n    }\n  },\n  removeTask(state, task) {\n    state.tasks = state.tasks.filter((d) => d.id !== task.id)\n  },\n  setQueuedEmbedLIds(state, libraryItemIds) {\n    state.queuedEmbedLIds = libraryItemIds\n  },\n  addQueuedEmbedLId(state, libraryItemId) {\n    if (!state.queuedEmbedLIds.some((lid) => lid === libraryItemId)) {\n      state.queuedEmbedLIds.push(libraryItemId)\n    }\n  },\n  removeQueuedEmbedLId(state, libraryItemId) {\n    state.queuedEmbedLIds = state.queuedEmbedLIds.filter((lid) => lid !== libraryItemId)\n  }\n}\n"
  },
  {
    "path": "client/store/user.js",
    "content": "export const state = () => ({\n  user: null,\n  accessToken: null,\n  settings: {\n    orderBy: 'media.metadata.title',\n    orderDesc: false,\n    filterBy: 'all',\n    playbackRate: 1,\n    playbackRateIncrementDecrement: 0.1,\n    bookshelfCoverSize: 120,\n    collapseSeries: false,\n    collapseBookSeries: false,\n    showSubtitles: false,\n    useChapterTrack: false,\n    seriesSortBy: 'name',\n    seriesSortDesc: false,\n    seriesFilterBy: 'all',\n    authorSortBy: 'name',\n    authorSortDesc: false,\n    jumpForwardAmount: 10,\n    jumpBackwardAmount: 10\n  }\n})\n\nexport const getters = {\n  getIsRoot: (state) => state.user && state.user.type === 'root',\n  getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),\n  getToken: (state) => {\n    return state.accessToken || null\n  },\n  getUserMediaProgress:\n    (state) =>\n    (libraryItemId, episodeId = null) => {\n      if (!state.user?.mediaProgress) return null\n      return state.user.mediaProgress.find((li) => {\n        if (episodeId && li.episodeId !== episodeId) return false\n        return li.libraryItemId == libraryItemId\n      })\n    },\n  getUserBookmarksForItem: (state) => (libraryItemId) => {\n    if (!state.user?.bookmarks) return []\n    return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)\n  },\n  getUserSetting: (state) => (key) => {\n    return state.settings?.[key] || null\n  },\n  getUserCanUpdate: (state) => {\n    return !!state.user?.permissions?.update\n  },\n  getUserCanDelete: (state) => {\n    return !!state.user?.permissions?.delete\n  },\n  getUserCanDownload: (state) => {\n    return !!state.user?.permissions?.download\n  },\n  getUserCanUpload: (state) => {\n    return !!state.user?.permissions?.upload\n  },\n  getUserCanAccessAllLibraries: (state) => {\n    return !!state.user?.permissions?.accessAllLibraries\n  },\n  getUserCanAccessExplicitContent: (state) => {\n    return !!state.user?.permissions?.accessExplicitContent\n  },\n  getLibrariesAccessible: (state, getters) => {\n    if (!state.user) return []\n    if (getters.getUserCanAccessAllLibraries) return []\n    return state.user.librariesAccessible || []\n  },\n  getCanAccessLibrary: (state, getters) => (libraryId) => {\n    if (!state.user) return false\n    if (getters.getUserCanAccessAllLibraries) return true\n    return getters.getLibrariesAccessible.includes(libraryId)\n  },\n  getIsSeriesRemovedFromContinueListening: (state) => (seriesId) => {\n    if (!state.user || !state.user.seriesHideFromContinueListening || !state.user.seriesHideFromContinueListening.length) return false\n    return state.user.seriesHideFromContinueListening.includes(seriesId)\n  },\n  getSizeMultiplier: (state) => {\n    return state.settings.bookshelfCoverSize / 120\n  }\n}\n\nexport const actions = {\n  // When changing libraries make sure sort and filter is still valid\n  checkUpdateLibrarySortFilter({ state, dispatch, commit }, mediaType) {\n    const settingsUpdate = {}\n    if (mediaType == 'podcast') {\n      if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {\n        settingsUpdate.orderBy = 'media.metadata.author'\n      }\n      if (state.settings.orderBy == 'media.duration') {\n        settingsUpdate.orderBy = 'media.numTracks'\n      }\n      if (state.settings.orderBy == 'media.metadata.publishedYear' || state.settings.orderBy == 'progress') {\n        settingsUpdate.orderBy = 'media.metadata.title'\n      }\n      const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged']\n      const filterByFirstPart = (state.settings.filterBy || '').split('.').shift()\n      if (invalidFilters.includes(filterByFirstPart)) {\n        settingsUpdate.filterBy = 'all'\n      }\n    } else {\n      if (state.settings.orderBy == 'media.metadata.author') {\n        settingsUpdate.orderBy = 'media.metadata.authorName'\n      }\n      if (state.settings.orderBy == 'media.numTracks') {\n        settingsUpdate.orderBy = 'media.duration'\n      }\n    }\n    if (Object.keys(settingsUpdate).length) {\n      dispatch('updateUserSettings', settingsUpdate)\n    }\n  },\n  updateUserSettings({ state, commit }, payload) {\n    if (!payload) return false\n\n    let hasChanges = false\n    const existingSettings = { ...state.settings }\n    for (const key in existingSettings) {\n      if (payload[key] !== undefined && existingSettings[key] !== payload[key]) {\n        hasChanges = true\n        existingSettings[key] = payload[key]\n      }\n    }\n    if (hasChanges) {\n      commit('setSettings', existingSettings)\n      this.$eventBus.$emit('user-settings', state.settings)\n    }\n  },\n  loadUserSettings({ state, commit }) {\n    // Load settings from local storage\n    try {\n      let userSettingsFromLocal = localStorage.getItem('userSettings')\n      if (userSettingsFromLocal) {\n        userSettingsFromLocal = JSON.parse(userSettingsFromLocal)\n        const userSettings = { ...state.settings }\n        for (const key in userSettings) {\n          if (userSettingsFromLocal[key] !== undefined) {\n            userSettings[key] = userSettingsFromLocal[key]\n          }\n        }\n        commit('setSettings', userSettings)\n        this.$eventBus.$emit('user-settings', state.settings)\n      }\n    } catch (error) {\n      console.error('Failed to load userSettings from local storage', error)\n    }\n  },\n  refreshToken({ state, commit }) {\n    return this.$axios\n      .$post('/auth/refresh')\n      .then(async (response) => {\n        const newAccessToken = response.user.accessToken\n        commit('setAccessToken', newAccessToken)\n        // Emit event used to re-authenticate socket in default.vue since $root is not available here\n        if (this.$eventBus) {\n          this.$eventBus.$emit('token_refreshed', newAccessToken)\n        }\n        return newAccessToken\n      })\n      .catch((error) => {\n        console.error('Failed to refresh token', error)\n        commit('setUser', null)\n        commit('setAccessToken', null)\n        // Calling function handles redirect to login\n        throw error\n      })\n  }\n}\n\nexport const mutations = {\n  setUser(state, user) {\n    state.user = user\n  },\n  setAccessToken(state, token) {\n    if (!token) {\n      localStorage.removeItem('token')\n      state.accessToken = null\n    } else {\n      state.accessToken = token\n      localStorage.setItem('token', token)\n    }\n  },\n  updateMediaProgress(state, { id, data }) {\n    if (!state.user) return\n    if (!data) {\n      state.user.mediaProgress = state.user.mediaProgress.filter((lip) => lip.id != id)\n    } else {\n      var indexOf = state.user.mediaProgress.findIndex((lip) => lip.id == id)\n      if (indexOf >= 0) {\n        state.user.mediaProgress.splice(indexOf, 1, data)\n      } else {\n        state.user.mediaProgress.push(data)\n      }\n    }\n  },\n  setSettings(state, settings) {\n    if (!settings) return\n    localStorage.setItem('userSettings', JSON.stringify(settings))\n    state.settings = settings\n  }\n}\n"
  },
  {
    "path": "client/store/users.js",
    "content": "\nexport const state = () => ({\n  usersOnline: []\n})\n\nexport const getters = {\n  getIsUserOnline: state => id => {\n    return state.usersOnline.find(u => u.id === id)\n  }\n}\n\nexport const actions = {\n\n}\n\nexport const mutations = {\n  setUsersOnline(state, usersOnline) {\n    state.usersOnline = usersOnline\n  },\n  updateUserOnline(state, user) {\n    var index = state.usersOnline.findIndex(u => u.id === user.id)\n    if (index >= 0) {\n      state.usersOnline.splice(index, 1, user)\n    } else {\n      state.usersOnline.push(user)\n    }\n  },\n  removeUserOnline(state, user) {\n    state.usersOnline = state.usersOnline.filter(u => u.id !== user.id)\n  }\n}"
  },
  {
    "path": "client/strings/ar.json",
    "content": "{\n  \"ButtonAdd\": \"إضافة\",\n  \"ButtonAddApiKey\": \"إضافة مفتاح واجهة برمجة التطبيقات\",\n  \"ButtonAddChapters\": \"إضافة الفصول\",\n  \"ButtonAddDevice\": \"إضافة جهاز\",\n  \"ButtonAddLibrary\": \"إضافة مكتبة\",\n  \"ButtonAddPodcasts\": \"إضافة بودكاست\",\n  \"ButtonAddUser\": \"إضافة مستخدم\",\n  \"ButtonAddYourFirstLibrary\": \"أضف مكتبتك الأولى\",\n  \"ButtonApply\": \"حفظ\",\n  \"ButtonApplyChapters\": \"حفظ الفصول\",\n  \"ButtonAuthors\": \"المؤلفون\",\n  \"ButtonBack\": \"الرجوع\",\n  \"ButtonBatchEditPopulateFromExisting\": \"تعبئة من الموجود\",\n  \"ButtonBatchEditPopulateMapDetails\": \"تعبئة تفاصيل الخريطة\",\n  \"ButtonBrowseForFolder\": \"البحث عن المجلد\",\n  \"ButtonCancel\": \"إلغاء\",\n  \"ButtonCancelEncode\": \"إلغاء الترميز\",\n  \"ButtonChangeRootPassword\": \"تغيير كلمة المرور الرئيسية\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"التحقق من الحلقات الجديدة وتنزيلها\",\n  \"ButtonChooseAFolder\": \"اختر المجلد\",\n  \"ButtonChooseFiles\": \"اختر الملفات\",\n  \"ButtonClearFilter\": \"تصفية الفرز\",\n  \"ButtonClose\": \"إغلاق\",\n  \"ButtonCloseFeed\": \"إغلاق الموجز\",\n  \"ButtonCloseSession\": \"إغلاق الجلسة المفتوحة\",\n  \"ButtonCollections\": \"المجموعات\",\n  \"ButtonConfigureScanner\": \"إعدادات الماسح الضوئي\",\n  \"ButtonCreate\": \"إنشاء\",\n  \"ButtonCreateBackup\": \"إنشاء نسخة احتياطية\",\n  \"ButtonDelete\": \"حذف\",\n  \"ButtonDownloadQueue\": \"قائمة\",\n  \"ButtonEdit\": \"تعديل\",\n  \"ButtonEditChapters\": \"تعديل الفصول\",\n  \"ButtonEditPodcast\": \"تعديل البودكاست\",\n  \"ButtonEnable\": \"تفعيل\",\n  \"ButtonFireAndFail\": \"محاولة فاشلة\",\n  \"ButtonFireOnTest\": \"تشغيل حدث الاختبار\",\n  \"ButtonForceReScan\": \"فرض إعادة المسح\",\n  \"ButtonFullPath\": \"المسار الكامل\",\n  \"ButtonHide\": \"إخفاء\",\n  \"ButtonHome\": \"الرئيسية\",\n  \"ButtonIssues\": \"مشاكل\",\n  \"ButtonJumpBackward\": \"اقفز للخلف\",\n  \"ButtonJumpForward\": \"اقفز للأمام\",\n  \"ButtonLatest\": \"الأحدث\",\n  \"ButtonLibrary\": \"المكتبة\",\n  \"ButtonLogout\": \"تسجيل الخروج\",\n  \"ButtonLookup\": \"البحث\",\n  \"ButtonManageTracks\": \"إدارة المقاطع\",\n  \"ButtonMapChapterTitles\": \"مطابقة عناوين الفصول\",\n  \"ButtonMatchAllAuthors\": \"مطابقة كل المؤلفون\",\n  \"ButtonMatchBooks\": \"مطابقة الكتب\",\n  \"ButtonNevermind\": \"لا تهتم\",\n  \"ButtonNext\": \"التالي\",\n  \"ButtonNextChapter\": \"الفصل التالي\",\n  \"ButtonNextItemInQueue\": \"العنصر التالي في قائمة الانتظار\",\n  \"ButtonOk\": \"موافق\",\n  \"ButtonOpenFeed\": \"فتح التغذية\",\n  \"ButtonOpenManager\": \"فتح الإدارة\",\n  \"ButtonPause\": \"إيقاف مؤقت\",\n  \"ButtonPlay\": \"تشغيل\",\n  \"ButtonPlayAll\": \"تشغيل الكل\",\n  \"ButtonPlaying\": \"جاري التشغيل\",\n  \"ButtonPlaylists\": \"قوائم التشغيل\",\n  \"ButtonPrevious\": \"سابِق\",\n  \"ButtonPreviousChapter\": \"الفصل السابق\",\n  \"ButtonProbeAudioFile\": \"فحص ملف الصوت\",\n  \"ButtonPurgeAllCache\": \"مسح كافة ذاكرة التخزين المؤقتة\",\n  \"ButtonPurgeItemsCache\": \"مسح ذاكرة التخزين المؤقتة للعناصر\",\n  \"ButtonQueueAddItem\": \"أضف إلى قائمة الانتظار\",\n  \"ButtonQueueRemoveItem\": \"إزالة من قائمة الانتظار\",\n  \"ButtonQuickEmbed\": \"التضمين السريع\",\n  \"ButtonQuickEmbedMetadata\": \"إدراج سريع للبيانات الوصفية\",\n  \"ButtonQuickMatch\": \"مطابقة سريعة\",\n  \"ButtonReScan\": \"إعادة البحث\",\n  \"ButtonRead\": \"اقرأ\",\n  \"ButtonReadLess\": \"اقرأ أقل\",\n  \"ButtonReadMore\": \"اقرأ أكثر\",\n  \"ButtonRefresh\": \"تحديث\",\n  \"ButtonRemove\": \"إزالة\",\n  \"ButtonRemoveAll\": \"إزالة الكل\",\n  \"ButtonRemoveAllLibraryItems\": \"إزالة كافة عناصر المكتبة\",\n  \"ButtonRemoveFromContinueListening\": \"إزالة من متابعة الاستماع\",\n  \"ButtonRemoveFromContinueReading\": \"إزالة من متابعة القراءة\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"إزالة السلسلة من استمرار السلسلة\",\n  \"ButtonReset\": \"إعادة ضبط\",\n  \"ButtonResetToDefault\": \"إعادة ضبط إلى الوضع الافتراضي\",\n  \"ButtonRestore\": \"إستِعادة\",\n  \"ButtonSave\": \"حفظ\",\n  \"ButtonSaveAndClose\": \"حفظ و إغلاق\",\n  \"ButtonSaveTracklist\": \"حفظ قائمة التشغيل\",\n  \"ButtonScan\": \"تَحَقُق\",\n  \"ButtonScanLibrary\": \"تَحَقُق من المكتبة\",\n  \"ButtonScrollLeft\": \"تمرير لليسار\",\n  \"ButtonScrollRight\": \"تمرير لليمين\",\n  \"ButtonSearch\": \"بحث\",\n  \"ButtonSelectFolderPath\": \"حدد مسار المجلد\",\n  \"ButtonSeries\": \"سلسلة\",\n  \"ButtonSetChaptersFromTracks\": \"تعيين الفصول من الملفات\",\n  \"ButtonShare\": \"نشر\",\n  \"ButtonShiftTimes\": \"أوقات العمل\",\n  \"ButtonShow\": \"أعرض\",\n  \"ButtonStartM4BEncode\": \"ابدأ ترميز M4B\",\n  \"ButtonStartMetadataEmbed\": \"ابدأ تضمين البيانات الوصفية\",\n  \"ButtonStats\": \"الإحصائيات\",\n  \"ButtonSubmit\": \"إرسال\",\n  \"ButtonTest\": \"اختبار\",\n  \"ButtonUnlinkOpenId\": \"إلغاء ربط المعرف\",\n  \"ButtonUpload\": \"رفع\",\n  \"ButtonUploadBackup\": \"تحميل النسخة الاحتياطية\",\n  \"ButtonUploadCover\": \"ارفق الغلاف\",\n  \"ButtonUploadOPMLFile\": \"رفع ملف OPML\",\n  \"ButtonUserDelete\": \"حذف المستخدم {0}\",\n  \"ButtonUserEdit\": \"تعديل المستخدم {0}\",\n  \"ButtonViewAll\": \"عرض الكل\",\n  \"ButtonYes\": \"نعم\",\n  \"ErrorUploadFetchMetadataAPI\": \"خطأ في جلب البيانات الوصفية\",\n  \"ErrorUploadFetchMetadataNoResults\": \"لم يتم العثور على البيانات الوصفية - حاول تحديث العنوان و/أو المؤلف\",\n  \"ErrorUploadLacksTitle\": \"يجب أن يكون له عنوان\",\n  \"HeaderAccount\": \"الحساب\",\n  \"HeaderAddCustomMetadataProvider\": \"إضافة موفر بيانات تعريفية مخصص\",\n  \"HeaderAdvanced\": \"متقدم\",\n  \"HeaderApiKeys\": \"مفاتيح API\",\n  \"HeaderAppriseNotificationSettings\": \"إعدادات الإشعارات\",\n  \"HeaderAudioTracks\": \"المقاطع الصوتية\",\n  \"HeaderAudiobookTools\": \"أدوات إدارة ملفات الكتب الصوتية\",\n  \"HeaderAuthentication\": \"المصادقة\",\n  \"HeaderBackups\": \"النسخ الاحتياطية\",\n  \"HeaderBulkChapterModal\": \"أضف فصولاً متعددة\",\n  \"HeaderChangePassword\": \"تغيير كلمة المرور\",\n  \"HeaderChapters\": \"الفصول\",\n  \"HeaderChooseAFolder\": \"اختيار المجلد\",\n  \"HeaderCollection\": \"مجموعة\",\n  \"HeaderCollectionItems\": \"عناصر المجموعة\",\n  \"HeaderCover\": \"الغلاف\",\n  \"HeaderCurrentDownloads\": \"التنزيلات الجارية\",\n  \"HeaderCustomMessageOnLogin\": \"رسالة مخصصة عند تسجيل الدخول\",\n  \"HeaderCustomMetadataProviders\": \"مقدمو البيانات الوصفية المخصصة\",\n  \"HeaderDetails\": \"التفاصيل\",\n  \"HeaderDownloadQueue\": \"تنزيل قائمة الانتظار\",\n  \"HeaderEbookFiles\": \"ملفات الكتب الإلكترونية\",\n  \"HeaderEmail\": \"البريد الإلكتروني\",\n  \"HeaderEmailSettings\": \"إعدادات البريد الإلكتروني\",\n  \"HeaderEpisodes\": \"الحلقات\",\n  \"HeaderEreaderDevices\": \"أجهزة قراءة الكتب الإلكترونية\",\n  \"HeaderEreaderSettings\": \"إعدادات القارئ الإلكتروني\",\n  \"HeaderFiles\": \"ملفات\",\n  \"HeaderFindChapters\": \"البحث عن الفصول\",\n  \"HeaderIgnoredFiles\": \"الملفات المتجاهلة\",\n  \"HeaderItemFiles\": \"ملفات العنصر\",\n  \"HeaderItemMetadataUtils\": \"بيانات تعريف العنصر\",\n  \"HeaderLastListeningSession\": \"آخر جلسة استماع\",\n  \"HeaderLatestEpisodes\": \"أحدث الحلقات\",\n  \"HeaderLibraries\": \"المكتبات\",\n  \"HeaderLibraryFiles\": \"ملفات المكتبة\",\n  \"HeaderLibraryStats\": \"إحصائيات المكتبة\",\n  \"HeaderListeningSessions\": \"جلسات الاستماع\",\n  \"HeaderListeningStats\": \"جلسات الاستماع\",\n  \"HeaderLogin\": \"تسجيل الدخول\",\n  \"HeaderLogs\": \"السجلات\",\n  \"HeaderManageGenres\": \"إدارة الانواع\",\n  \"HeaderManageTags\": \"إدارة العلامات\",\n  \"HeaderMapDetails\": \"تفاصيل الخريطة\",\n  \"HeaderMatch\": \"مطابقة\",\n  \"HeaderMetadataOrderOfPrecedence\": \"ترتيب أولوية البيانات الوصفية\",\n  \"HeaderMetadataToEmbed\": \"البيانات الوصفية المراد تضمينها\",\n  \"HeaderNewAccount\": \"حساب جديد\",\n  \"HeaderNewApiKey\": \"مفتاح API جديد\",\n  \"HeaderNewLibrary\": \"مكتبة جديدة\",\n  \"HeaderNotificationCreate\": \"إنشاء إشعار\",\n  \"HeaderNotificationUpdate\": \"تحديث إشعار\",\n  \"HeaderNotifications\": \"إشعارات\",\n  \"HeaderOpenIDConnectAuthentication\": \"مصادقة OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"جلسات الاستماع المفتوحة\",\n  \"HeaderOpenRSSFeed\": \"عرض تغذية RSS\",\n  \"HeaderOtherFiles\": \"ملفات أخرى\",\n  \"HeaderPasswordAuthentication\": \"مصادقة كلمة المرور\",\n  \"HeaderPermissions\": \"الصلاحيات\",\n  \"HeaderPlayerQueue\": \"قائمة انتظار المشغل\",\n  \"HeaderPlayerSettings\": \"إعدادات المشغل\",\n  \"HeaderPlaylist\": \"قائمة تشغيل\",\n  \"HeaderPlaylistItems\": \"عناصر قائمة التشغيل\",\n  \"HeaderPodcastsToAdd\": \"بودكاست لإضافتها\",\n  \"HeaderPresets\": \"إعدادات مسبقة\",\n  \"HeaderPreviewCover\": \"معاينة الغلاف\",\n  \"HeaderRSSFeedGeneral\": \"تفاصيل RSS\",\n  \"HeaderRSSFeedIsOpen\": \"مغذي RSS مفتوح\",\n  \"HeaderRSSFeeds\": \"خلاصات RSS\",\n  \"HeaderRemoveEpisode\": \"إزالة حلقة\",\n  \"HeaderRemoveEpisodes\": \"إزالة {0} حلقات\",\n  \"HeaderSavedMediaProgress\": \"تقدم الوسائط المحفوظة\",\n  \"HeaderSchedule\": \"جَدْوَل\",\n  \"HeaderScheduleEpisodeDownloads\": \"جدولة التنزيلات التلقائية للحلقات\",\n  \"HeaderScheduleLibraryScans\": \"جدولة عمليات المسح التلقائي للمكتبة\",\n  \"HeaderSession\": \"الجلسة\",\n  \"HeaderSetBackupSchedule\": \"تعيين جدول النسخ الاحتياطي\",\n  \"HeaderSettings\": \"إعدادات\",\n  \"HeaderSettingsDisplay\": \"عرض\",\n  \"HeaderSettingsExperimental\": \"ميزات تجريبية\",\n  \"HeaderSettingsGeneral\": \"عام\",\n  \"HeaderSettingsScanner\": \"إعدادات المسح\",\n  \"HeaderSettingsSecurity\": \"الأمان\",\n  \"HeaderSettingsWebClient\": \"عميل الويب\",\n  \"HeaderSleepTimer\": \"مؤقت النوم\",\n  \"HeaderStatsLargestItems\": \"أكبر العناصر حجماً\",\n  \"HeaderStatsLongestItems\": \"أطول العناصر (بالساعات)\",\n  \"HeaderStatsMinutesListeningChart\": \"الدقائق المسموعة (آخر 7 أيام)\",\n  \"HeaderStatsRecentSessions\": \"الجلسات الأخيرة\",\n  \"HeaderStatsTop10Authors\": \"أفضل 10 مؤلفين\",\n  \"HeaderStatsTop5Genres\": \"أفضل 5 أنواع\",\n  \"HeaderTableOfContents\": \"جدول المحتويات\",\n  \"HeaderTools\": \"أدوات\",\n  \"HeaderUpdateAccount\": \"تحديث الحساب\",\n  \"HeaderUpdateApiKey\": \"تحديث مفتاح API\",\n  \"HeaderUpdateAuthor\": \"تحديث المؤلف\",\n  \"HeaderUpdateDetails\": \"تحديث التفاصيل\",\n  \"HeaderUpdateLibrary\": \"تحديث المكتبة\",\n  \"HeaderUsers\": \"المستخدمون\",\n  \"HeaderYearReview\": \"ملخص عام {0}\",\n  \"HeaderYourStats\": \"إحصائياتك\",\n  \"LabelAbridged\": \"مختصر\",\n  \"LabelAbridgedChecked\": \"مختصر (محدد)\",\n  \"LabelAbridgedUnchecked\": \"غير مختصر (غير محدد)\",\n  \"LabelAccessibleBy\": \"يمكن الوصول إليه بواسطة\",\n  \"LabelAccountType\": \"نوع الحساب\",\n  \"LabelAccountTypeAdmin\": \"مدير\",\n  \"LabelAccountTypeGuest\": \"ضيف\",\n  \"LabelAccountTypeUser\": \"مستخدم\",\n  \"LabelActivities\": \"النشاطات\",\n  \"LabelActivity\": \"نشاط\",\n  \"LabelAddToCollection\": \"إضافة إلى المجموعة\",\n  \"LabelAddToCollectionBatch\": \"إضافة {0} كتابًا إلى المجموعة\",\n  \"LabelAddToPlaylist\": \"أضف إلى قائمة التشغيل\",\n  \"LabelAddToPlaylistBatch\": \"إضافة {0} عناصر إلى قائمة التشغيل\",\n  \"LabelAddedAt\": \"أضيفت على\",\n  \"LabelAddedDate\": \"تمت الإضافة\",\n  \"LabelAdminUsersOnly\": \"للمستخدمين المديرين فقط\",\n  \"LabelAll\": \"الكل\",\n  \"LabelAllEpisodesDownloaded\": \"تم تنزيل جميع الحلقات\",\n  \"LabelAllUsers\": \"جميع المستخدمين\",\n  \"LabelAllUsersExcludingGuests\": \"جميع المستخدمين باستثناء الضيوف\",\n  \"LabelAllUsersIncludingGuests\": \"جميع المستخدمين بما في ذلك الضيوف\",\n  \"LabelAlreadyInYourLibrary\": \"موجود بالفعل في مكتبتك\",\n  \"LabelApiKeyCreated\": \"تم إنشاء مفتاح API \\\"{0}\\\" بنجاح.\",\n  \"LabelApiKeyCreatedDescription\": \"تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.\",\n  \"LabelApiToken\": \"رمز API\",\n  \"LabelAppend\": \"إلحاق\",\n  \"LabelAudioBitrate\": \"معدل بت الصوت (على سبيل المثال 128 كيلو بايت)\",\n  \"LabelAudioChannels\": \"قنوات الصوت (1 أو 2)\",\n  \"LabelAudioCodec\": \"برنامج ترميز الصوت\",\n  \"LabelAuthor\": \"المؤلف\",\n  \"LabelAuthorFirstLast\": \"المؤلف (الاسم الأول الأخير)\",\n  \"LabelAuthorLastFirst\": \"المؤلف (الاسم الأخير، الأول)\",\n  \"LabelAuthors\": \"المؤلفون\",\n  \"LabelAutoDownloadEpisodes\": \"تنزيل الحلقات تلقائيًا\",\n  \"LabelAutoFetchMetadata\": \"جلب البيانات الوصفية تلقائيًا\",\n  \"LabelAutoFetchMetadataHelp\": \"يجلب البيانات الوصفية للعنوان والمؤلف والسلسلة لتسهيل عملية الرفع. قد يلزم مطابقة بيانات وصفية إضافية بعد الرفع.\",\n  \"LabelAutoLaunch\": \"تشغيل تلقائي\",\n  \"LabelAutoLaunchDescription\": \"إعادة التوجيه إلى مزود المصادقة تلقائيًا عند الانتقال إلى صفحة تسجيل الدخول (مسار التجاوز اليدوي <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"تسجيل تلقائي\",\n  \"LabelAutoRegisterDescription\": \"إنشاء مستخدمين جدد تلقائيًا بعد تسجيل الدخول\",\n  \"LabelBackToUser\": \"العودة إلى المستخدم\",\n  \"LabelBackupAudioFiles\": \"نسخ ملفات الصوت احتياطيًا\",\n  \"LabelBackupLocation\": \"موقع النسخ الاحتياطي\",\n  \"LabelBackupsEnableAutomaticBackups\": \"نسخ احتياطية تلقائية\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"النسخ الاحتياطية المحفوظة في / البيانات الوصفية / النسخ الاحتياطية\",\n  \"LabelBackupsMaxBackupSize\": \"الحد الأقصى لحجم النسخ الاحتياطي (بالجيجابايت) (0 لغير محدود)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"كإجراء وقائي ضد سوء التكوين، ستفشل عمليات النسخ الاحتياطي إذا تجاوزت الحجم المحدد.\",\n  \"LabelBackupsNumberToKeep\": \"عدد النسخ الاحتياطية التي يجب الاحتفاظ بها\",\n  \"LabelBackupsNumberToKeepHelp\": \"ستتم إزالة نسخة احتياطية واحدة فقط في كل مرة، لذا إذا كان لديك بالفعل عدد نسخ احتياطية أكبر من هذا، فيجب عليك إزالتها يدويًا.\",\n  \"LabelBitrate\": \"معدل البت\",\n  \"LabelBonus\": \"مكافأة\",\n  \"LabelBooks\": \"الكتب\",\n  \"LabelButtonText\": \"نص الزر\",\n  \"LabelByAuthor\": \"بواسطة {0}\",\n  \"LabelChangePassword\": \"تغيير كلمة المرور\",\n  \"LabelChannels\": \"قنوات\",\n  \"LabelChapterCount\": \"{0} فصول\",\n  \"LabelChapterTitle\": \"عنوان الفصل\",\n  \"LabelChapters\": \"الفصول\",\n  \"LabelChaptersFound\": \"تم العثور على فصول\",\n  \"LabelClickForMoreInfo\": \"انقر لمزيد من المعلومات\",\n  \"LabelClickToUseCurrentValue\": \"انقر لاستخدام القيمة الحالية\",\n  \"LabelClosePlayer\": \"إغلاق المشغل\",\n  \"LabelCodec\": \"برنامج الترميز\",\n  \"LabelCollapseSeries\": \"إخفاء المسلسلات\",\n  \"LabelCollapseSubSeries\": \"إخفاء المسلسلات الفرعية\",\n  \"LabelCollection\": \"مجموعة\",\n  \"LabelCollections\": \"مجموعات\",\n  \"LabelComplete\": \"مكتمل\",\n  \"LabelConfirmPassword\": \"تأكيد كلمة المرور\",\n  \"LabelContinueListening\": \"استمرار الاستماع\",\n  \"LabelContinueReading\": \"استمرار القراءة\",\n  \"LabelContinueSeries\": \"استمرار المسلسلات\",\n  \"LabelCover\": \"الغلاف\",\n  \"LabelCoverImageURL\": \"رابط صورة الغلاف\",\n  \"LabelCoverProvider\": \"مزود الغلاف\",\n  \"LabelCreatedAt\": \"تاريخ الإنشاء\",\n  \"LabelCronExpression\": \"تعبير Cron\",\n  \"LabelCurrent\": \"الحالي\",\n  \"LabelCurrently\": \"حاليًا:\",\n  \"LabelCustomCronExpression\": \"تعبير Cron مخصص:\",\n  \"LabelDatetime\": \"التاريخ والوقت\",\n  \"LabelDays\": \"أيام\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"حذف من نظام الملفات (إلغاء التحديد للإزالة من قاعدة البيانات فقط)\",\n  \"LabelDescription\": \"الوصف\",\n  \"LabelDeselectAll\": \"إلغاء تحديد الكل\",\n  \"LabelDevice\": \"الجهاز\",\n  \"LabelDeviceInfo\": \"معلومات الجهاز\",\n  \"LabelDeviceIsAvailableTo\": \"الجهاز متاح لـ...\",\n  \"LabelDirectory\": \"مجلد / دليل\",\n  \"LabelDiscFromFilename\": \"القرص من اسم الملف\",\n  \"LabelDiscFromMetadata\": \"القرص من البيانات الوصفية\",\n  \"LabelDiscover\": \"استكشف\",\n  \"LabelDownload\": \"تنزيل\",\n  \"LabelDownloadNEpisodes\": \"تنزيل {0} حلقات\",\n  \"LabelDownloadable\": \"قابل للتنزيل\",\n  \"LabelDuration\": \"المدة\",\n  \"LabelDurationComparisonExactMatch\": \"(تطابق تام)\",\n  \"LabelDurationComparisonLonger\": \"(أطول بـ {0})\",\n  \"LabelDurationComparisonShorter\": \"({0} أقصر)\",\n  \"LabelDurationFound\": \"المدة الموجودة:\",\n  \"LabelEbook\": \"الكتاب الإلكتروني\",\n  \"LabelEbooks\": \"الكتب الإلكترونية\",\n  \"LabelEdit\": \"تعديل\",\n  \"LabelEmail\": \"البريد الإلكتروني\",\n  \"LabelEmailSettingsFromAddress\": \"عنوان المرسل\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"رفض الشهادات غير المصرح بها\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"قد يؤدي تعطيل التحقق من شهادة SSL إلى تعريض اتصالك لمخاطر أمنية، مثل هجمات الوسيط. لا تقم بتعطيل هذا الخيار إلا إذا كنت تفهم الآثار المترتبة عليه وتثق في خادم البريد الذي تتصل به.\",\n  \"LabelEmailSettingsSecure\": \"آمن\",\n  \"LabelEmailSettingsSecureHelp\": \"إذا كانت القيمة true، فسيستخدم الاتصال TLS عند الاتصال بالخادم. وإذا كانت false، فسيتم استخدام TLS إذا كان الخادم يدعم امتداد STARTTLS. في معظم الحالات، اضبط هذه القيمة على true إذا كنت تتصل بالمنفذ 465. أما بالنسبة للمنفذ 587 أو 25، فاحتفظ بها على false. (من nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"عنوان الاختبار\",\n  \"LabelEmbeddedCover\": \"غلاف مضمن\",\n  \"LabelEnable\": \"تمكين\",\n  \"LabelEncodingBackupLocation\": \"سيتم تخزين نسخة احتياطية من ملفاتك الصوتية الأصلية في:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"الفصول غير مضمنة في الكتب الصوتية متعددة المسارات.\",\n  \"LabelEncodingClearItemCache\": \"تأكد من مسح ذاكرة التخزين المؤقت للعناصر بشكل دوري.\",\n  \"LabelEncodingFinishedM4B\": \"سيتم وضع ملف M4B النهائي في مجلد الكتب الصوتية الخاص بك في:\",\n  \"LabelEncodingInfoEmbedded\": \"سيتم تضمين البيانات الوصفية في المسارات الصوتية داخل مجلد الكتب الصوتية الخاص بك.\",\n  \"LabelEncodingStartedNavigation\": \"بمجرد بدء المهمة، يمكنك الانتقال من هذه الصفحة.\",\n  \"LabelEncodingTimeWarning\": \"قد تستغرق عملية الترميز ما يصل إلى 30 دقيقة.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"تحذير: لا تقم بتحديث هذه الإعدادات إلا إذا كنت على دراية بخيارات ترميز ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"إذا قمت بتعطيل المراقب، فستحتاج إلى إعادة فحص هذا الكتاب الصوتي بعد ذلك.\",\n  \"LabelEnd\": \"انهاء\",\n  \"LabelEndOfChapter\": \"نهاية الفصل\",\n  \"LabelEpisode\": \"الحلقة\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"الحلقة غير مرتبطة بخلاصة RSS\",\n  \"LabelEpisodeNumber\": \"الحلقة #{0}\",\n  \"LabelEpisodeTitle\": \"عنوان الحلقة\",\n  \"LabelEpisodeType\": \"نوع الحلقة\",\n  \"LabelEpisodeUrlFromRssFeed\": \"رابط الحلقة من خلاصة RSS\",\n  \"LabelEpisodes\": \"حلقات\",\n  \"LabelEpisodic\": \"عرضي / حلقي\",\n  \"LabelExample\": \"مثال\",\n  \"LabelExpandSeries\": \"توسيع السلاسل\",\n  \"LabelExpandSubSeries\": \"توسيع السلاسل الفرعية\",\n  \"LabelExplicit\": \"محتوى صريح\",\n  \"LabelExplicitChecked\": \"صريح (محدد)\",\n  \"LabelExplicitUnchecked\": \"غير صريح (غير محدد)\",\n  \"LabelExportOPML\": \"تصدير OPML\",\n  \"LabelFeedURL\": \"عنوان التغذية\",\n  \"LabelFetchingMetadata\": \"جلب البيانات الوصفية\",\n  \"LabelFile\": \"الملف\",\n  \"LabelFileBirthtime\": \"وقت انشاء الملف\",\n  \"LabelFileBornDate\": \"تاريخ الإنشاء {0}\",\n  \"LabelFileModified\": \"تم تعديل الملف\",\n  \"LabelFileModifiedDate\": \"تم التعديل في {0}\",\n  \"LabelFilename\": \"اسم الملف\",\n  \"LabelFilterByUser\": \"تصفية حسب المستخدم\",\n  \"LabelFindEpisodes\": \"البحث عن حلقات\",\n  \"LabelFinished\": \"المنجزة\",\n  \"LabelFolder\": \"المجلد\",\n  \"LabelFolders\": \"مجلدات\",\n  \"LabelFontBold\": \"عريض\",\n  \"LabelFontBoldness\": \"تعريض الخط\",\n  \"LabelFontFamily\": \"عائلة الخطوط\",\n  \"LabelFontItalic\": \"مائل\",\n  \"LabelFontScale\": \"نطاق الخط\",\n  \"LabelFontStrikethrough\": \"يتوسطه خط\",\n  \"LabelFormat\": \"تنسيق\",\n  \"LabelFull\": \"كامل\",\n  \"LabelGenre\": \"التصنيف\",\n  \"LabelGenres\": \"التصانيف\",\n  \"LabelHardDeleteFile\": \"حذف الملف نهائيًا\",\n  \"LabelHasEbook\": \"يحتوي كتاب إلكتروني\",\n  \"LabelHasSupplementaryEbook\": \"يحتوي كتاب إلكتروني تكميلي\",\n  \"LabelHideSubtitles\": \"إخفاء الترجمة\",\n  \"LabelHighestPriority\": \"الأولوية القصوى\",\n  \"LabelHost\": \"المضيف\",\n  \"LabelHour\": \"ساعة\",\n  \"LabelHours\": \"ساعات\",\n  \"LabelIcon\": \"أيقونة\",\n  \"LabelImageURLFromTheWeb\": \"رابط الصورة من الويب\",\n  \"LabelInProgress\": \"تحت التنفيذ\",\n  \"LabelIncludeInTracklist\": \"تضمين في قائمة المسارات\",\n  \"LabelIncomplete\": \"غير مكتمل\",\n  \"LabelInterval\": \"فاصل زمني\",\n  \"LabelIntervalCustomDailyWeekly\": \"يومي/أسبوعي مخصص\",\n  \"LabelIntervalEvery12Hours\": \"كل 12 ساعة\",\n  \"LabelIntervalEvery15Minutes\": \"كل 15 دقيقة\",\n  \"LabelIntervalEvery2Hours\": \"كل ساعتين\",\n  \"LabelIntervalEvery30Minutes\": \"كل 30 دقيقة\",\n  \"LabelIntervalEvery6Hours\": \"كل 6 ساعات\",\n  \"LabelIntervalEveryDay\": \"كل يوم\",\n  \"LabelIntervalEveryHour\": \"كل ساعة\",\n  \"LabelIntervalEveryMinute\": \"كل دقيقة\",\n  \"LabelInvert\": \"عكس\",\n  \"LabelItem\": \"عنصر\",\n  \"LabelJumpBackwardAmount\": \"مقدار الرجوع للخلف\",\n  \"LabelJumpForwardAmount\": \"مقدار التقدم للأمام\",\n  \"LabelLanguage\": \"اللغة\",\n  \"LabelLanguageDefaultServer\": \"لغة الخادم الافتراضية\",\n  \"LabelLanguages\": \"اللغات\",\n  \"LabelLastBookAdded\": \"آخر كتاب تمت إضافته\",\n  \"LabelLastBookUpdated\": \"آخر كتاب تم تحديثه\",\n  \"LabelLastSeen\": \"آخر ظهور\",\n  \"LabelLastTime\": \"آخر مرة\",\n  \"LabelLastUpdate\": \"آخر تحديث\",\n  \"LabelLayout\": \"التنسيق\",\n  \"LabelLayoutSinglePage\": \"صفحة واحدة\",\n  \"LabelLayoutSplitPage\": \"صفحتان متقابلتان\",\n  \"LabelLess\": \"أقل\",\n  \"LabelLibrariesAccessibleToUser\": \"المكتبات المتاحة للمستخدم\",\n  \"LabelLibrary\": \"مكتبة\",\n  \"LabelLibraryFilterSublistEmpty\": \"لا يوجد {0}\",\n  \"LabelLibraryItem\": \"عنصر المكتبة\",\n  \"LabelLibraryName\": \"اسم المكتبة\",\n  \"LabelLimit\": \"حد\",\n  \"LabelLineSpacing\": \"تباعد الأسطر\",\n  \"LabelListenAgain\": \"الاستماع مجدداً\",\n  \"LabelLogLevelDebug\": \"تصحيح الأخطاء\",\n  \"LabelLogLevelInfo\": \"معلومات\",\n  \"LabelLogLevelWarn\": \"تحذير\",\n  \"LabelLookForNewEpisodesAfterDate\": \"البحث عن حلقات جديدة بعد هذا التاريخ\",\n  \"LabelLowestPriority\": \"الأولوية الأدنى\",\n  \"LabelMatchExistingUsersBy\": \"مطابقة المستخدمين الحاليين بواسطة\",\n  \"LabelMatchExistingUsersByDescription\": \"يستخدم لربط المستخدمين الحاليين. بمجرد الاتصال، سيتم مطابقة المستخدمين بواسطة معرف فريد من مزود SSO الخاص بك\",\n  \"LabelMaxEpisodesToDownload\": \"الحد الأقصى لعدد الحلقات التي سيتم تنزيلها. استخدم 0 لغير محدود.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"الحد الأقصى لعدد الحلقات الجديدة التي سيتم تنزيلها في كل فحص\",\n  \"LabelMaxEpisodesToKeep\": \"الحد الأقصى لعدد الحلقات التي سيتم الاحتفاظ بها\",\n  \"LabelMaxEpisodesToKeepHelp\": \"القيمة 0 لا تضع حدًا أقصى. بعد تنزيل حلقة جديدة تلقائيًا، سيؤدي هذا إلى حذف أقدم حلقة إذا كان لديك أكثر من X حلقة. سيؤدي هذا إلى حذف حلقة واحدة فقط لكل تنزيل جديد.\",\n  \"LabelMediaPlayer\": \"مشغل الوسائط\",\n  \"LabelMediaType\": \"نوع الوسائط\",\n  \"LabelMetaTag\": \"علامة بيانات وصفية\",\n  \"LabelMetaTags\": \"علامات البيانات الوصفية\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"ستتجاوز مصادر البيانات الوصفية ذات الأولوية الأعلى مصادر البيانات الوصفية ذات الأولوية الأقل\",\n  \"LabelMetadataProvider\": \"مزود البيانات الوصفية\",\n  \"LabelMinute\": \"دقيقة\",\n  \"LabelMinutes\": \"دقائق\",\n  \"LabelMissing\": \"مفقود\",\n  \"LabelMissingEbook\": \"لا يوجد كتاب إلكتروني\",\n  \"LabelMissingSupplementaryEbook\": \"لا يوجد كتاب إلكتروني تكميلي\",\n  \"LabelMobileRedirectURIs\": \"معرفات URI لإعادة التوجيه المسموح بها لتطبيقات الجوال\",\n  \"LabelMobileRedirectURIsDescription\": \"هذه قائمة بيضاء لمعرفات URI لإعادة التوجيه الصالحة لتطبيقات الجوال. المعرف الافتراضي هو <code>audiobookshelf://oauth</code>، والذي يمكنك إزالته أو استكماله بمعرفات URI إضافية لتكامل تطبيقات الطرف الثالث. استخدام علامة النجمة (<code>*</code>) كإدخال وحيد يسمح بأي معرف URI.\",\n  \"LabelMore\": \"أكثر\",\n  \"LabelMoreInfo\": \"معلومات أكثر\",\n  \"LabelName\": \"الاسم\",\n  \"LabelNarrator\": \"الراوي\",\n  \"LabelNarrators\": \"الرواة\",\n  \"LabelNew\": \"جديد\",\n  \"LabelNewPassword\": \"كلمة سر جديدة\",\n  \"LabelNewestAuthors\": \"أجدد المؤلفين\",\n  \"LabelNewestEpisodes\": \"أجدد الحلقات\",\n  \"LabelNextBackupDate\": \"تاريخ النسخ الاحتياطي التالي\",\n  \"LabelNextScheduledRun\": \"التشغيل المجدول التالي\",\n  \"LabelNoCustomMetadataProviders\": \"لا يوجد مزودو بيانات وصفية مخصصون\",\n  \"LabelNoEpisodesSelected\": \"لم يتم تحديد أي حلقات\",\n  \"LabelNotFinished\": \"لم يتم الانتهاء\",\n  \"LabelNotStarted\": \"لم يتم البدء\",\n  \"LabelNotes\": \"ملاحظات\",\n  \"LabelNotificationAppriseURL\": \"رابط (روابط) Apprise\",\n  \"LabelNotificationAvailableVariables\": \"المتغيرات المتاحة\",\n  \"LabelNotificationBodyTemplate\": \"قالب النص\",\n  \"LabelNotificationEvent\": \"حدث الإشعار\",\n  \"LabelNotificationTitleTemplate\": \"قالب العنوان\",\n  \"LabelNotificationsMaxFailedAttempts\": \"الحد الأقصى لعدد المحاولات الفاشلة\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"يتم تعطيل الإشعارات بمجرد فشل إرسالها لهذا العدد من المرات\",\n  \"LabelNotificationsMaxQueueSize\": \"الحد الأقصى لحجم قائمة انتظار أحداث الإشعارات\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"تقتصر الأحداث على التشغيل مرة واحدة في الثانية. سيتم تجاهل الأحداث إذا كانت قائمة الانتظار في الحد الأقصى لحجمها. هذا يمنع إرسال الإشعارات بشكل متكرر.\",\n  \"LabelNumberOfBooks\": \"عدد الكتب\",\n  \"LabelNumberOfEpisodes\": \"# من الحلقات\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"اسم مطالبة OpenID التي تحتوي على أذونات متقدمة لإجراءات المستخدم داخل التطبيق والتي ستطبق على الأدوار غير الإدارية (<b>إذا تم تكوينها</b>). إذا كانت المطالبة مفقودة من الاستجابة، فسيتم رفض الوصول إلى ABS. إذا كان هناك خيار واحد مفقودًا، فسيتم التعامل معه على أنه <code>false</code>. تأكد من أن مطالبة موفر الهوية تطابق البنية المتوقعة:\",\n  \"LabelOpenIDClaims\": \"اترك الخيارات التالية فارغة لتعطيل تعيين المجموعة والأذونات المتقدمة، وسيتم تعيين مجموعة \\\"مستخدم\\\" تلقائيًا بعد ذلك.\",\n  \"LabelOpenIDGroupClaimDescription\": \"اسم مطالبة OpenID التي تحتوي على قائمة بمجموعات المستخدم. يشار إليها عادةً باسم <code>groups</code>.<b>إذا تم تكوينها</b>، فسيقوم التطبيق تلقائيًا بتعيين الأدوار بناءً على عضويات مجموعة المستخدم، بشرط أن تسمى هذه المجموعات بشكل غير حساس لحالة الأحرف \\\"admin\\\" أو \\\"user\\\" أو \\\"guest\\\" في المطالبة. يجب أن تحتوي المطالبة على قائمة، وإذا كان المستخدم ينتمي إلى مجموعات متعددة، فسيقوم التطبيق بتعيين الدور المقابل لأعلى مستوى من الوصول. إذا لم تتطابق أي مجموعة، فسيتم رفض الوصول.\",\n  \"LabelOpenRSSFeed\": \"تغذية RSS مفتوحة\",\n  \"LabelOverwrite\": \"استبدال\",\n  \"LabelPaginationPageXOfY\": \"صفحة {0} من {1}\",\n  \"LabelPassword\": \"كلمة المرور\",\n  \"LabelPath\": \"مسار\",\n  \"LabelPermanent\": \"دائم\",\n  \"LabelPermissionsAccessAllLibraries\": \"يمكنه الوصول إلى جميع المكتبات\",\n  \"LabelPermissionsAccessAllTags\": \"يمكنه الوصول إلى جميع العلامات\",\n  \"LabelPermissionsAccessExplicitContent\": \"يمكنه الوصول إلى المحتوى الصريح\",\n  \"LabelPermissionsCreateEreader\": \"يمكنه إنشاء قارئ إلكتروني\",\n  \"LabelPermissionsDelete\": \"يمكنه الحذف\",\n  \"LabelPermissionsDownload\": \"يمكنه التنزيل\",\n  \"LabelPermissionsUpdate\": \"يمكنه التحديث\",\n  \"LabelPermissionsUpload\": \"يمكنه الرفع\",\n  \"LabelPersonalYearReview\": \"ملخص عامك ({0})\",\n  \"LabelPhotoPathURL\": \"مسار/رابط الصورة\",\n  \"LabelPlayMethod\": \"طريقة التشغيل\",\n  \"LabelPlaybackRateIncrementDecrement\": \"مقدار زيادة/نقصان سرعة التشغيل\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} من {1}\",\n  \"LabelPlaylists\": \"قوائم التشغيل\",\n  \"LabelPodcast\": \"مدونة صوتية\",\n  \"LabelPodcastSearchRegion\": \"منطقة البحث عن البودكاست\",\n  \"LabelPodcastType\": \"نوع البودكاست\",\n  \"LabelPodcasts\": \"بودكاست\",\n  \"LabelPort\": \"منفذ\",\n  \"LabelPrefixesToIgnore\": \"البادئات التي يجب تجاهلها (غير حساسة لحالة الأحرف)\",\n  \"LabelPreventIndexing\": \"منع فهرسة تغذيتك بواسطة دليل آيتونز وقوقل بودكاست\",\n  \"LabelPrimaryEbook\": \"الكتاب الإلكتروني الأساسي\",\n  \"LabelProgress\": \"تقدم\",\n  \"LabelProvider\": \"مزود\",\n  \"LabelProviderAuthorizationValue\": \"قيمة رأس التفويض\",\n  \"LabelPubDate\": \"تاريخ النشر\",\n  \"LabelPublishYear\": \"سنة النشر\",\n  \"LabelPublishedDate\": \"منشور {0}\",\n  \"LabelPublishedDecade\": \"عقد النشر\",\n  \"LabelPublishedDecades\": \"عقود النشر\",\n  \"LabelPublisher\": \"الناشر\",\n  \"LabelPublishers\": \"الناشرون\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"البريد الالكتروني المخصص للمالك\",\n  \"LabelRSSFeedCustomOwnerName\": \"الاسم المخصص للمالك\",\n  \"LabelRSSFeedOpen\": \"موجز RSS مفتوح\",\n  \"LabelRSSFeedPreventIndexing\": \"منع الفهرسة\",\n  \"LabelRSSFeedSlug\": \"اسم تعريف تغذية RSS\",\n  \"LabelRSSFeedURL\": \"رابط تغذية RSS\",\n  \"LabelRandomly\": \"عشوائياً\",\n  \"LabelReAddSeriesToContinueListening\": \"إعادة إضافة السلسلة إلى \\\"متابعة الاستماع\\\"\",\n  \"LabelRead\": \"اقرأ\",\n  \"LabelReadAgain\": \"اقرأ مرة أخرى\",\n  \"LabelReadEbookWithoutProgress\": \"قراءة الكتاب الإلكتروني دون حفظ التقدم\",\n  \"LabelRecentSeries\": \"المسلسلات الحديثة\",\n  \"LabelRecentlyAdded\": \"المضافة حديثاً\",\n  \"LabelRecommended\": \"موصى به\",\n  \"LabelRedo\": \"إعادة\",\n  \"LabelRegion\": \"المنطقة\",\n  \"LabelReleaseDate\": \"تاريخ الإصدار\",\n  \"LabelRemoveAllMetadataAbs\": \"إزالة جميع ملفات metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"إزالة جميع ملفات metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"إزالة مقدمة وخاتمة Audible من الفصول\",\n  \"LabelRemoveCover\": \"إزالة الغلاف\",\n  \"LabelRemoveMetadataFile\": \"إزالة ملفات البيانات الوصفية في مجلدات عناصر المكتبة\",\n  \"LabelRemoveMetadataFileHelp\": \"إزالة جميع ملفات metadata.json و metadata.abs في مجلدات {0} الخاصة بك.\",\n  \"LabelRowsPerPage\": \"عدد الصفوف في الصفحة\",\n  \"LabelSearchTerm\": \"مصطلح البحث\",\n  \"LabelSearchTitle\": \"بحث بالعنوان\",\n  \"LabelSearchTitleOrASIN\": \"بحث بالعنوان أو ASIN\",\n  \"LabelSeason\": \"الموسم\",\n  \"LabelSeasonNumber\": \"الموسم #{0}\",\n  \"LabelSelectAll\": \"تحديد الكل\",\n  \"LabelSelectAllEpisodes\": \"تحديد جميع الحلقات\",\n  \"LabelSelectEpisodesShowing\": \"تحديد {0} حلقة معروضة\",\n  \"LabelSelectUsers\": \"تحديد المستخدمين\",\n  \"LabelSendEbookToDevice\": \"إرسال الكتاب الإلكتروني إلى...\",\n  \"LabelSequence\": \"تسلسل\",\n  \"LabelSerial\": \"مسلسل\",\n  \"LabelSeries\": \"المسلسلات\",\n  \"LabelSeriesName\": \"اسم السلسلة\",\n  \"LabelSeriesProgress\": \"تقدم السلسلة\",\n  \"LabelServerLogLevel\": \"مستوى سجل الخادم\",\n  \"LabelServerYearReview\": \"ملخص عام الخادم ({0})\",\n  \"LabelSetEbookAsPrimary\": \"تعيين كرئيسي\",\n  \"LabelSetEbookAsSupplementary\": \"تعيين كتكميلي\",\n  \"LabelSettingsAllowIframe\": \"السماح بالتضمين في إطار iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"كتب صوتية فقط\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"سيؤدي تمكين هذا الإعداد إلى تجاهل ملفات الكتب الإلكترونية ما لم تكن داخل مجلد كتاب صوتي، وفي هذه الحالة سيتم تعيينها ككتب إلكترونية تكميلية\",\n  \"LabelSettingsBookshelfViewHelp\": \"تصميم يحاكي الواقع مع رفوف خشبية\",\n  \"LabelSettingsChromecastSupport\": \"دعم Chromecast\",\n  \"LabelSettingsDateFormat\": \"تنسيق التاريخ\",\n  \"LabelSettingsEnableWatcher\": \"مراقبة المكتبات تلقائياً بحثاً عن تغييرات\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"فحص المكتبة تلقائيًا بحثًا عن تغييرات\",\n  \"LabelSettingsEnableWatcherHelp\": \"يمكّن الإضافة/التحديث التلقائي للعناصر عند اكتشاف تغييرات في الملفات. *يتطلب إعادة تشغيل الخادم\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"السماح بالمحتوى النصي في ملفات epub\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"السماح لملفات epub بتنفيذ النصوص البرمجية. يوصى بإبقاء هذا الإعداد معطلاً ما لم تثق في مصدر ملفات epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"ميزات تجريبية\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"ميزات قيد التطوير يمكنها استخدام ملاحظاتك والمساعدة في اختبارها. انقر لفتح مناقشة على GitHub.\",\n  \"LabelSettingsFindCovers\": \"البحث عن الأغلفة\",\n  \"LabelSettingsFindCoversHelp\": \"إذا لم يكن لدى كتابك الصوتي غلاف مضمن أو صورة غلاف داخل المجلد، فسيحاول الماسح الضوئي العثور على غلاف.&lt;br> ملاحظة: سيؤدي هذا إلى إطالة وقت الفحص\",\n  \"LabelSettingsHideSingleBookSeries\": \"إخفاء السلاسل ذات الكتاب الواحد\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"سيتم إخفاء السلاسل التي تحتوي على كتاب واحد من صفحة السلاسل وأرفف الصفحة الرئيسية.\",\n  \"LabelSettingsHomePageBookshelfView\": \"استخدام عرض الرفوف في الصفحة الرئيسية\",\n  \"LabelSettingsLibraryBookshelfView\": \"استخدام عرض الرفوف في المكتبة\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"النسبة المئوية المكتملة أكبر من\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"الوقت المتبقي أقل من (ثواني)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"تعليم عنصر الوسائط على أنه منتهٍ عند\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"تخطي الكتب السابقة في \\\"متابعة السلسلة\\\"\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"يعرض رف \\\"متابعة السلسلة\\\" في الصفحة الرئيسية أول كتاب لم يبدأ في السلاسل التي تحتوي على كتاب واحد على الأقل منتهي ولا يوجد كتب قيد التقدم. سيؤدي تمكين هذا الإعداد إلى متابعة السلاسل من أبعد كتاب مكتمل بدلاً من أول كتاب لم يبدأ.\",\n  \"LabelSettingsParseSubtitles\": \"تحليل الترجمة\",\n  \"LabelSettingsParseSubtitlesHelp\": \"استخراج الترجمة من أسماء مجلدات الكتب الصوتية.&lt;br>يجب فصل الترجمة بـ \\\" - \\\"&lt;br>مثال: \\\"عنوان الكتاب - ترجمة هنا\\\" تحتوي على الترجمة \\\"ترجمة هنا\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"تفضيل البيانات الوصفية المطابقة\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"ستتجاوز البيانات المطابقة تفاصيل العنصر عند استخدام المطابقة السريعة. بشكل افتراضي، ستملأ المطابقة السريعة التفاصيل المفقودة فقط.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"تخطي مطابقة الكتب التي لديها ASIN بالفعل\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"تخطي مطابقة الكتب التي لديها ISBN بالفعل\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"تجاهل البادئات عند الفرز\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"مثال: بالنسبة للبادئة \\\"the\\\"، سيتم فرز عنوان الكتاب \\\"The Book Title\\\" كـ \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"استخدام أغلفة كتب مربعة\",\n  \"LabelSettingsSquareBookCoversHelp\": \"تفضيل استخدام الأغلفة المربعة على أغلفة الكتب القياسية بنسبة 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"تخزين الأغلفة مع العنصر\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"بشكل افتراضي، يتم تخزين الأغلفة في /metadata/items، وسيؤدي تمكين هذا الإعداد إلى تخزين الأغلفة في مجلد عنصر المكتبة الخاص بك. سيتم الاحتفاظ بملف واحد فقط باسم \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"تخزين البيانات الوصفية مع العنصر\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"بشكل افتراضي، يتم تخزين ملفات البيانات الوصفية في /metadata/items، وسيؤدي تمكين هذا الإعداد إلى تخزين ملفات البيانات الوصفية في مجلدات عناصر المكتبة الخاصة بك\",\n  \"LabelSettingsTimeFormat\": \"تنسيق الوقت\",\n  \"LabelShare\": \"مشاركة\",\n  \"LabelShareDownloadableHelp\": \"يسمح للمستخدمين الذين لديهم رابط المشاركة بتنزيل ملف مضغوط لعنصر المكتبة.\",\n  \"LabelShareOpen\": \"فتح المشاركة\",\n  \"LabelShareURL\": \"رابط المشاركة\",\n  \"LabelShowAll\": \"إظهار الكل\",\n  \"LabelShowSeconds\": \"إظهار الثواني\",\n  \"LabelShowSubtitles\": \"إظهار الترجمة\",\n  \"LabelSize\": \"الحجم\",\n  \"LabelSleepTimer\": \"مؤقت النوم\",\n  \"LabelSlug\": \"اسم تعريفي سهل القراءة\",\n  \"LabelSortAscending\": \"تصاعدي\",\n  \"LabelSortDescending\": \"تنازلي\",\n  \"LabelSortPubDate\": \"فرز حسب تاريخ النشر\",\n  \"LabelStart\": \"ابدأ\",\n  \"LabelStartTime\": \"وقت البدء\",\n  \"LabelStarted\": \"بدأ\",\n  \"LabelStartedAt\": \"بدأ في\",\n  \"LabelStatsAudioTracks\": \"مسارات الصوت\",\n  \"LabelStatsAuthors\": \"المؤلفون\",\n  \"LabelStatsBestDay\": \"أفضل يوم\",\n  \"LabelStatsDailyAverage\": \"المتوسط اليومي\",\n  \"LabelStatsDays\": \"أيام\",\n  \"LabelStatsDaysListened\": \"أيام الاستماع\",\n  \"LabelStatsHours\": \"ساعات\",\n  \"LabelStatsInARow\": \"على التوالي\",\n  \"LabelStatsItemsFinished\": \"العناصر المنتهية\",\n  \"LabelStatsItemsInLibrary\": \"العناصر في المكتبة\",\n  \"LabelStatsMinutes\": \"دقائق\",\n  \"LabelStatsMinutesListening\": \"دقائق الاستماع\",\n  \"LabelStatsOverallDays\": \"إجمالي الأيام\",\n  \"LabelStatsOverallHours\": \"إجمالي الساعات\",\n  \"LabelStatsWeekListening\": \"استماع هذا الأسبوع\",\n  \"LabelSubtitle\": \"عنوان فرعي / ترجمة\",\n  \"LabelSupportedFileTypes\": \"أنواع الملفات المدعومة\",\n  \"LabelTag\": \"علامة\",\n  \"LabelTags\": \"علامات\",\n  \"LabelTagsAccessibleToUser\": \"العلامات المتاحة للمستخدم\",\n  \"LabelTagsNotAccessibleToUser\": \"العلامات غير المتاحة للمستخدم\",\n  \"LabelTasks\": \"المهام قيد التشغيل\",\n  \"LabelTextEditorBulletedList\": \"قائمة نقطية\",\n  \"LabelTextEditorLink\": \"رابط\",\n  \"LabelTextEditorNumberedList\": \"قائمة مرقمة\",\n  \"LabelTextEditorUnlink\": \"إزالة الرابط\",\n  \"LabelTheme\": \"النمط\",\n  \"LabelThemeDark\": \"غامق\",\n  \"LabelThemeLight\": \"فاتح\",\n  \"LabelTimeBase\": \"قاعدة الوقت\",\n  \"LabelTimeDurationXHours\": \"{0} ساعات\",\n  \"LabelTimeDurationXMinutes\": \"{0} دقائق\",\n  \"LabelTimeDurationXSeconds\": \"{0} ثواني\",\n  \"LabelTimeInMinutes\": \"الوقت بالدقائق\",\n  \"LabelTimeLeft\": \"باقي {0}\",\n  \"LabelTimeListened\": \"الوقت المستمع إليه\",\n  \"LabelTimeListenedToday\": \"الوقت المستمع إليه اليوم\",\n  \"LabelTimeRemaining\": \"{0} متبقية\",\n  \"LabelTimeToShift\": \"الوقت المراد إزاحته بالثواني\",\n  \"LabelTitle\": \"عنوان\",\n  \"LabelToolsEmbedMetadata\": \"تضمين البيانات الوصفية\",\n  \"LabelToolsEmbedMetadataDescription\": \"تضمين البيانات الوصفية في ملفات الصوت بما في ذلك صورة الغلاف والفصول.\",\n  \"LabelToolsM4bEncoder\": \"ترميز M4B\",\n  \"LabelToolsMakeM4b\": \"إنشاء ملف كتاب صوتي M4B\",\n  \"LabelToolsMakeM4bDescription\": \"إنشاء ملف كتاب صوتي ‎.M4B مع بيانات وصفية مضمنة وصورة غلاف وفصول.\",\n  \"LabelToolsSplitM4b\": \"تقسيم M4B إلى ملفات MP3\",\n  \"LabelToolsSplitM4bDescription\": \"إنشاء ملفات MP3 من ملف M4B مقسم حسب الفصول مع بيانات وصفية مضمنة وصورة غلاف وفصول.\",\n  \"LabelTotalDuration\": \"المدة الكلية\",\n  \"LabelTotalTimeListened\": \"إجمالي وقت الاستماع\",\n  \"LabelTrackFromFilename\": \"المسار من اسم الملف\",\n  \"LabelTrackFromMetadata\": \"المسار من البيانات الوصفية\",\n  \"LabelTracks\": \"المسارات\",\n  \"LabelTracksMultiTrack\": \"متعدد المسارات\",\n  \"LabelTracksNone\": \"لا توجد مسارات\",\n  \"LabelTracksSingleTrack\": \"مسار واحد\",\n  \"LabelTrailer\": \"مقطع دعائي\",\n  \"LabelType\": \"نوع\",\n  \"LabelUnabridged\": \"غير مختصر\",\n  \"LabelUndo\": \"تراجع\",\n  \"LabelUnknown\": \"مجهول\",\n  \"LabelUnknownPublishDate\": \"تاريخ النشر مجهول\",\n  \"LabelUpdateCover\": \"تحديث الغلاف\",\n  \"LabelUpdateCoverHelp\": \"السماح باستبدال الأغلفة الموجودة للكتب المحددة عند العثور على تطابق\",\n  \"LabelUpdateDetails\": \"تحديث التفاصيل\",\n  \"LabelUpdateDetailsHelp\": \"السماح باستبدال التفاصيل الموجودة للكتب المحددة عند العثور على تطابق\",\n  \"LabelUpdatedAt\": \"تاريخ التحديث\",\n  \"LabelUploaderDragAndDrop\": \"اسحب وأفلت الملفات أو المجلدات\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"اسحب وأفلت الملفات\",\n  \"LabelUploaderDropFiles\": \"إفلات الملفات\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"جلب العنوان والمؤلف والسلسلة تلقائيًا\",\n  \"LabelUseAdvancedOptions\": \"استخدام الخيارات المتقدمة\",\n  \"LabelUseChapterTrack\": \"استخدام مسار الفصل\",\n  \"LabelUseFullTrack\": \"استخدام المسار الكامل\",\n  \"LabelUseZeroForUnlimited\": \"استخدم 0 لغير محدود\",\n  \"LabelUser\": \"مستخدم\",\n  \"LabelUsername\": \"اسم المستخدم\",\n  \"LabelValue\": \"القيمة\",\n  \"LabelVersion\": \"الإصدار\",\n  \"LabelViewBookmarks\": \"عرض الإشارات المرجعية\",\n  \"LabelViewChapters\": \"عرض الفصول\",\n  \"LabelViewPlayerSettings\": \"عرض إعدادات المشغل\",\n  \"LabelViewQueue\": \"عرض قائمة انتظار المشغل\",\n  \"LabelVolume\": \"مستوى الصوت\",\n  \"LabelWebRedirectURLsDescription\": \"قم بتخويل عناوين URL هذه في موفر OAuth الخاص بك للسماح بإعادة التوجيه إلى تطبيق الويب بعد تسجيل الدخول:\",\n  \"LabelWebRedirectURLsSubfolder\": \"مجلد فرعي لعناوين URL لإعادة التوجيه\",\n  \"LabelWeekdaysToRun\": \"أيام الأسبوع المراد التشغيل فيها\",\n  \"LabelXBooks\": \"{0} كتب\",\n  \"LabelXItems\": \"{0} عناصر\",\n  \"LabelYearReviewHide\": \"إخفاء ملخص العام\",\n  \"LabelYearReviewShow\": \"عرض ملخص العام\",\n  \"LabelYourAudiobookDuration\": \"مدة كتابك الصوتي\",\n  \"LabelYourBookmarks\": \"علاماتك المرجعية\",\n  \"LabelYourPlaylists\": \"قوائم التشغيل الخاصة بك\",\n  \"LabelYourProgress\": \"تقدمك\",\n  \"MessageAddToPlayerQueue\": \"إضافة إلى قائمة انتظار المشغل\",\n  \"MessageAppriseDescription\": \"لاستخدام هذه الميزة، ستحتاج إلى تشغيل مثيل <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> أو واجهة برمجة تطبيقات تتعامل مع نفس الطلبات. <br />يجب أن يكون عنوان URL الخاص بـ Apprise API هو مسار URL الكامل لإرسال الإشعار، على سبيل المثال، إذا كان مثيل واجهة برمجة التطبيقات الخاصة بك يعمل على <code>http://192.168.1.1:8337</code>، فستضع <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"تأكد من أنك تستخدم ASIN من منطقة Audible الصحيحة، وليس Amazon.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"أعد تشغيل الخادم بعد الحفظ لتطبيق تغييرات OIDC.\",\n  \"MessageBackupsDescription\": \"تتضمن النسخ الاحتياطية المستخدمين وتقدم المستخدم وتفاصيل عنصر المكتبة وإعدادات الخادم والصور المخزنة في <code>/metadata/items</code> و <code>/metadata/authors</code>. <strong>لا</strong> تتضمن النسخ الاحتياطية أي ملفات مخزنة في مجلدات مكتبتك.\",\n  \"MessageBackupsLocationEditNote\": \"ملاحظة: لن يؤدي تحديث موقع النسخ الاحتياطي إلى نقل أو تعديل النسخ الاحتياطية الموجودة\",\n  \"MessageBackupsLocationNoEditNote\": \"ملاحظة: يتم تعيين موقع النسخ الاحتياطي من خلال متغير بيئة ولا يمكن تغييره هنا.\",\n  \"MessageBackupsLocationPathEmpty\": \"لا يمكن أن يكون مسار موقع النسخ الاحتياطي فارغًا\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"املأ الحقول الممكّنة ببيانات من جميع العناصر. سيتم دمج الحقول ذات القيم المتعددة\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"املأ حقول تفاصيل الخريطة الممكّنة ببيانات من هذا العنصر\",\n  \"MessageBatchQuickMatchDescription\": \"ستحاول المطابقة السريعة إضافة الأغلفة والبيانات الوصفية المفقودة للعناصر المحددة. قم بتمكين الخيارات أدناه للسماح للمطابقة السريعة بالكتابة فوق الأغلفة و/أو البيانات الوصفية الموجودة.\",\n  \"MessageBookshelfNoCollections\": \"لم تنشئ أي مجموعات حتى الآن\",\n  \"MessageBookshelfNoCollectionsHelp\": \"المجموعات عامة. يمكن لجميع المستخدمين الذين لديهم حق الوصول إلى المكتبة رؤيتها.\",\n  \"MessageBookshelfNoRSSFeeds\": \"لا توجد خلاصات RSS مفتوحة\",\n  \"MessageBookshelfNoResultsForFilter\": \"لا توجد نتائج للفلتر \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"لا توجد نتائج للاستعلام\",\n  \"MessageBookshelfNoSeries\": \"ليس لديك أي مسلسلات\",\n  \"MessageChapterEndIsAfter\": \"نهاية الفصل بعد نهاية كتابك الصوتي\",\n  \"MessageChapterErrorFirstNotZero\": \"يجب أن يبدأ الفصل الأول عند 0\",\n  \"MessageChapterErrorStartGteDuration\": \"يجب أن يكون وقت البدء غير الصالح أقل من مدة الكتاب الصوتي\",\n  \"MessageChapterErrorStartLtPrev\": \"يجب أن يكون وقت البدء غير الصالح أكبر من أو يساوي وقت بدء الفصل السابق\",\n  \"MessageChapterStartIsAfter\": \"بداية الفصل بعد نهاية كتابك الصوتي\",\n  \"MessageChaptersNotFound\": \"لم يتم العثور على فصول\",\n  \"MessageCheckingCron\": \"جارٍ فحص cron...\",\n  \"MessageConfirmCloseFeed\": \"هل أنت متأكد أنك تريد إغلاق هذه التغذية؟\",\n  \"MessageConfirmDeleteBackup\": \"هل أنت متأكد أنك تريد حذف النسخ الاحتياطي لـ {0}؟\",\n  \"MessageConfirmDeleteDevice\": \"هل أنت متأكد أنك تريد حذف جهاز القارئ الإلكتروني \\\"{0}\\\"؟\",\n  \"MessageConfirmDeleteFile\": \"سيؤدي هذا إلى حذف الملف من نظام الملفات الخاص بك. هل أنت متأكد؟\",\n  \"MessageConfirmDeleteLibrary\": \"هل أنت متأكد أنك تريد حذف المكتبة \\\"{0}\\\" نهائيًا؟\",\n  \"MessageConfirmDeleteLibraryItem\": \"سيؤدي هذا إلى حذف عنصر المكتبة من قاعدة البيانات ونظام الملفات الخاص بك. هل أنت متأكد؟\",\n  \"MessageConfirmDeleteLibraryItems\": \"سيؤدي هذا إلى حذف {0} عنصرًا من عناصر المكتبة من قاعدة البيانات ونظام الملفات الخاص بك. هل أنت متأكد؟\",\n  \"MessageConfirmDeleteMetadataProvider\": \"هل أنت متأكد أنك تريد حذف مزود البيانات الوصفية المخصص \\\"{0}\\\"؟\",\n  \"MessageConfirmDeleteNotification\": \"هل أنت متأكد أنك تريد حذف هذا الإشعار؟\",\n  \"MessageConfirmDeleteSession\": \"هل أنت متأكد أنك تريد حذف هذه الجلسة؟\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"هل أنت متأكد أنك تريد تضمين البيانات الوصفية في {0} ملفًا صوتيًا؟\",\n  \"MessageConfirmForceReScan\": \"هل أنت متأكد أنك تريد فرض إعادة الفحص؟\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"هل أنت متأكد أنك تريد تعليم جميع الحلقات على أنها منتهية؟\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"هل أنت متأكد أنك تريد تعليم جميع الحلقات على أنها غير منتهية؟\",\n  \"MessageConfirmMarkItemFinished\": \"هل أنت متأكد أنك تريد تعليم \\\"{0}\\\" على أنه منتهٍ؟\",\n  \"MessageConfirmMarkItemNotFinished\": \"هل أنت متأكد أنك تريد تعليم \\\"{0}\\\" على أنه غير منتهٍ؟\",\n  \"MessageConfirmMarkSeriesFinished\": \"هل أنت متأكد أنك تريد تعليم جميع الكتب في هذه السلسلة على أنها منتهية؟\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"هل أنت متأكد أنك تريد تعليم جميع الكتب في هذه السلسلة على أنها غير منتهية؟\",\n  \"MessageConfirmNotificationTestTrigger\": \"هل تريد تشغيل هذا الإشعار ببيانات اختبار؟\",\n  \"MessageConfirmPurgeCache\": \"سيؤدي مسح ذاكرة التخزين المؤقت إلى حذف الدليل بأكمله في <code>/metadata/cache</code>. <br /><br />هل أنت متأكد أنك تريد إزالة دليل ذاكرة التخزين المؤقت؟\",\n  \"MessageConfirmPurgeItemsCache\": \"سيؤدي مسح ذاكرة التخزين المؤقت للعناصر إلى حذف الدليل بأكمله في <code>/metadata/cache/items</code> <br />هل أنت متأكد؟\",\n  \"MessageConfirmQuickEmbed\": \"تحذير! لن يقوم التضمين السريع بنسخ ملفاتك الصوتية احتياطيًا. تأكد من أن لديك نسخة احتياطية من ملفاتك الصوتية. <br><br>هل ترغب في المتابعة؟\",\n  \"MessageConfirmQuickMatchEpisodes\": \"ستؤدي المطابقة السريعة للحلقات إلى الكتابة فوق التفاصيل إذا تم العثور على تطابق. سيتم تحديث الحلقات غير المتطابقة فقط. هل أنت متأكد؟\",\n  \"MessageConfirmReScanLibraryItems\": \"هل أنت متأكد أنك تريد إعادة فحص {0} عنصرًا؟\",\n  \"MessageConfirmRemoveAllChapters\": \"هل أنت متأكد أنك تريد إزالة جميع الفصول؟\",\n  \"MessageConfirmRemoveAuthor\": \"هل أنت متأكد أنك تريد إزالة المؤلف \\\"{0}\\\"؟\",\n  \"MessageConfirmRemoveCollection\": \"هل أنت متأكد أنك تريد إزالة المجموعة \\\"{0}\\\"؟\",\n  \"MessageConfirmRemoveEpisode\": \"هل أنت متأكد أنك تريد إزالة الحلقة \\\"{0}\\\"؟\",\n  \"MessageConfirmRemoveEpisodes\": \"هل أنت متأكد أنك تريد إزالة {0} حلقة؟\",\n  \"MessageConfirmRemoveListeningSessions\": \"هل أنت متأكد أنك تريد إزالة {0} جلسة استماع؟\",\n  \"MessageConfirmRemoveMetadataFiles\": \"هل أنت متأكد أنك تريد إزالة جميع ملفات البيانات الوصفية {0} الموجودة في مجلدات عناصر مكتبتك؟\",\n  \"MessageConfirmRemoveNarrator\": \"هل أنت متأكد أنك تريد إزالة الراوي \\\"{0}\\\"؟\",\n  \"MessageConfirmRemovePlaylist\": \"هل أنت متأكد أنك تريد إزالة قائمة التشغيل الخاصة بك \\\"{0}\\\"؟\",\n  \"MessageConfirmRenameGenre\": \"هل أنت متأكد أنك تريد إعادة تسمية النوع \\\"{0}\\\" إلى \\\"{1}\\\" لجميع العناصر؟\",\n  \"MessageConfirmRenameGenreMergeNote\": \"ملاحظة: هذا النوع موجود بالفعل لذا سيتم دمجهما.\",\n  \"MessageConfirmRenameGenreWarning\": \"تحذير! يوجد نوع مشابه بحالة أحرف مختلفة بالفعل \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"هل أنت متأكد أنك تريد إعادة تسمية العلامة \\\"{0}\\\" إلى \\\"{1}\\\" لجميع العناصر؟\",\n  \"MessageConfirmRenameTagMergeNote\": \"ملاحظة: هذه العلامة موجودة بالفعل لذا سيتم دمجهما.\",\n  \"MessageConfirmRenameTagWarning\": \"تحذير! توجد علامة مشابهة بحالة أحرف مختلفة بالفعل \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"هل أنت متأكد أنك تريد إعادة تعيين تقدمك؟\",\n  \"MessageConfirmSendEbookToDevice\": \"هل أنت متأكد أنك تريد إرسال الكتاب الإلكتروني \\\"{1}\\\" ({0}) إلى الجهاز \\\"{2}\\\"؟\",\n  \"MessageConfirmUnlinkOpenId\": \"هل أنت متأكد أنك تريد فصل هذا المستخدم عن OpenID؟\",\n  \"MessageDaysListenedInTheLastYear\": \"تم الاستماع لمدة {0} يومًا في العام الماضي\",\n  \"MessageDownloadingEpisode\": \"جاري تنزيل الحلقة\",\n  \"MessageDragFilesIntoTrackOrder\": \"اسحب الملفات إلى ترتيب المسارات الصحيح\",\n  \"MessageEmbedFailed\": \"فشل التضمين!\",\n  \"MessageEmbedFinished\": \"تم الانتهاء من التضمين!\",\n  \"MessageEmbedQueue\": \"تمت إضافته إلى قائمة انتظار تضمين البيانات الوصفية ({0} في قائمة الانتظار)\",\n  \"MessageEpisodesQueuedForDownload\": \"تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل\",\n  \"MessageEreaderDevices\": \"لضمان تسليم الكتب الإلكترونية، قد تحتاج إلى إضافة عنوان البريد الإلكتروني أعلاه كمرسل صالح لكل جهاز مدرج أدناه.\",\n  \"MessageFeedURLWillBe\": \"سيكون رابط التغذية هو {0}\",\n  \"MessageFetching\": \"جاري الجلب...\",\n  \"MessageForceReScanDescription\": \"سيقوم بفحص جميع الملفات مرة أخرى كفحص جديد. سيتم فحص علامات ID3 لملفات الصوت وملفات OPF والملفات النصية كأنها جديدة.\",\n  \"MessageImportantNotice\": \"إشعار هام!\",\n  \"MessageInsertChapterBelow\": \"إدراج فصل أدناه\",\n  \"MessageInvalidAsin\": \"ASIN غير صالح\",\n  \"MessageItemsSelected\": \"تم تحديد {0} عنصرًا\",\n  \"MessageItemsUpdated\": \"تم تحديث {0} عنصرًا\",\n  \"MessageJoinUsOn\": \"انضم إلينا على\",\n  \"MessageLoading\": \"جاري التحميل...\",\n  \"MessageLoadingFolders\": \"جاري تحميل المجلدات...\",\n  \"MessageLogsDescription\": \"يتم تخزين السجلات في <code>/metadata/logs</code> كملفات JSON. يتم تخزين سجلات الأعطال في <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"فشل M4B!\",\n  \"MessageM4BFinished\": \"تم الانتهاء من M4B!\",\n  \"MessageMapChapterTitles\": \"ربط عناوين الفصول بفصول كتابك الصوتي الحالي دون تعديل الطوابع الزمنية\",\n  \"MessageMarkAllEpisodesFinished\": \"تعليم جميع الحلقات على أنها منتهية\",\n  \"MessageMarkAllEpisodesNotFinished\": \"تعليم جميع الحلقات على أنها غير منتهية\",\n  \"MessageMarkAsFinished\": \"وضع علامة \\\"تم الإنتهاء\\\"\",\n  \"MessageMarkAsNotFinished\": \"وضع علامة \\\"غير منته\\\"\",\n  \"MessageMatchBooksDescription\": \"سيحاول مطابقة الكتب في المكتبة مع كتاب من مزود البحث المحدد وملء التفاصيل الفارغة وصورة الغلاف. لا يستبدل التفاصيل الموجودة.\",\n  \"MessageNoAudioTracks\": \"لا توجد مسارات صوتية\",\n  \"MessageNoAuthors\": \"لا يوجد مؤلفون\",\n  \"MessageNoBackups\": \"لا توجد نسخ احتياطية\",\n  \"MessageNoBookmarks\": \"لا توجد علامات مرجعية\",\n  \"MessageNoChapters\": \"لا توجد فصول\",\n  \"MessageNoCollections\": \"لا توجد مجموعات\",\n  \"MessageNoCoversFound\": \"لم يتم العثور على أغلفة\",\n  \"MessageNoDescription\": \"لا يوجد وصف\",\n  \"MessageNoDevices\": \"لا توجد أجهزة\",\n  \"MessageNoDownloadsInProgress\": \"لا توجد تنزيلات قيد التقدم حاليًا\",\n  \"MessageNoDownloadsQueued\": \"لا توجد تنزيلات في قائمة الانتظار\",\n  \"MessageNoEpisodeMatchesFound\": \"لم يتم العثور على أي تطابقات للحلقات\",\n  \"MessageNoEpisodes\": \"لا توجد حلقات\",\n  \"MessageNoFoldersAvailable\": \"لا توجد مجلدات متاحة\",\n  \"MessageNoGenres\": \"لا توجد تصانيف\",\n  \"MessageNoIssues\": \"لا توجد مشاكل\",\n  \"MessageNoItems\": \"لا توجد عناصر\",\n  \"MessageNoItemsFound\": \"لم يتم العثور على عناصر\",\n  \"MessageNoListeningSessions\": \"لا توجد جلسات استماع\",\n  \"MessageNoLogs\": \"لا توجد سجلات\",\n  \"MessageNoMediaProgress\": \"لا يوجد تقدم للوسائط\",\n  \"MessageNoNotifications\": \"لا توجد إشعارات\",\n  \"MessageNoPodcastFeed\": \"بودكاست غير صالح: لا يوجد تغذية\",\n  \"MessageNoPodcastsFound\": \"لم يتم العثور على أي بودكاست\",\n  \"MessageNoResults\": \"لا توجد نتائج\",\n  \"MessageNoSearchResultsFor\": \"لا توجد نتائج بحث عن \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"لا توجد مسلسلات\",\n  \"MessageNoTags\": \"لا توجد علامات\",\n  \"MessageNoTasksRunning\": \"لا توجد مهام قيد التشغيل\",\n  \"MessageNoUpdatesWereNecessary\": \"لا حاجة لأي تحديثات\",\n  \"MessageNoUserPlaylists\": \"ليس لديك أي قوائم تشغيل\",\n  \"MessageNoUserPlaylistsHelp\": \"قوائم التشغيل خاصة. لا يمكن إلا للمستخدم الذي ينشئها رؤيتها.\",\n  \"MessageNotYetImplemented\": \"لم يتم تنفيذه بعد\",\n  \"MessageOpmlPreviewNote\": \"ملاحظة: هذه معاينة لملف OPML الذي تم تحليله. سيتم أخذ عنوان البودكاست الفعلي من خلاصة RSS.\",\n  \"MessageOr\": \"أو\",\n  \"MessagePauseChapter\": \"إيقاف تشغيل الفصل مؤقتًا\",\n  \"MessagePlayChapter\": \"الاستماع إلى بداية الفصل\",\n  \"MessagePlaylistCreateFromCollection\": \"إنشاء قائمة تشغيل من المجموعة\",\n  \"MessagePleaseWait\": \"الرجاء الانتظار...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"لا يحتوي البودكاست على عنوان URL لخلاصة RSS لاستخدامه في المطابقة\",\n  \"MessagePodcastSearchField\": \"أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS\",\n  \"MessageQuickEmbedInProgress\": \"التضمين السريع قيد التقدم\",\n  \"MessageQuickEmbedQueue\": \"تمت إضافته إلى قائمة انتظار التضمين السريع ({0} في قائمة الانتظار)\",\n  \"MessageQuickMatchAllEpisodes\": \"مطابقة سريعة لجميع الحلقات\",\n  \"MessageQuickMatchDescription\": \"املأ تفاصيل العنصر الفارغة والغلاف بأول نتيجة مطابقة من '{0}'. لا يستبدل التفاصيل إلا إذا تم تمكين إعداد الخادم 'تفضيل البيانات الوصفية المطابقة'.\",\n  \"MessageRemoveChapter\": \"إزالة الفصل\",\n  \"MessageRemoveEpisodes\": \"إزالة {0} حلقة (حلقات)\",\n  \"MessageRemoveFromPlayerQueue\": \"إزالة من قائمة انتظار المشغل\",\n  \"MessageRemoveUserWarning\": \"هل أنت متأكد أنك تريد حذف المستخدم \\\"{0}\\\" نهائيًا؟\",\n  \"MessageReportBugsAndContribute\": \"أبلغ عن الأخطاء، واطلب الميزات، وساهم في\",\n  \"MessageResetChaptersConfirm\": \"هل أنت متأكد أنك تريد إعادة تعيين الفصول والتراجع عن التغييرات التي أجريتها؟\",\n  \"MessageRestoreBackupConfirm\": \"هل أنت متأكد أنك تريد استعادة النسخ الاحتياطي الذي تم إنشاؤه في\",\n  \"MessageRestoreBackupWarning\": \"ستؤدي استعادة النسخ الاحتياطي إلى الكتابة فوق قاعدة البيانات بأكملها الموجودة في /config وصور الأغلفة في /metadata/items و /metadata/authors.<br /><br /> لا تعدل النسخ الاحتياطية أي ملفات في مجلدات مكتبتك. إذا قمت بتمكين إعدادات الخادم لتخزين صور الأغلفة والبيانات الوصفية في مجلدات مكتبتك، فلن يتم نسخها احتياطيًا أو الكتابة فوقها.<br /><br /> سيتم تحديث جميع العملاء الذين يستخدمون الخادم الخاص بك تلقائيًا.\",\n  \"MessageScheduleLibraryScanNote\": \"لمعظم المستخدمين، موصى بترك هذه الميزة معطلة وإبقاء ممكّنة الأعداد، ”قم بمراقبة المكتبة تلقائاً للتغييرات“. سوف يقم بالكشف التلقائي عن تغييرات في مجلدات مكتبتك. لو لم يعمل الإعداد، \\\"قم بمراقبة المكتبة تلقائاً للتغييرات،“مع نظمة ملفاتك المستخدمة (مثل NFS على سبيل المثال)، فأمكِن هذه الميزة.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"تشغيل كل {0} في الساعة {1}\",\n  \"MessageSearchResultsFor\": \"نتائج البحث عن\",\n  \"MessageSelected\": \"تم تحديد {0}\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"السلسلة المتعاقبة لا يمكن أن تحتوي على مسافات\",\n  \"MessageServerCouldNotBeReached\": \"تعذر الوصول إلى الخادم\",\n  \"MessageSetChaptersFromTracksDescription\": \"تعيين الفصول باستخدام كل ملف صوتي كفصل وعنوان الفصل كاسم الملف الصوتي\",\n  \"MessageShareExpirationWillBe\": \"سيكون تاريخ الانتهاء <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"ينتهي خلال {0}\",\n  \"MessageShareURLWillBe\": \"سيكون رابط المشاركة هو <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"هل تريد بدء التشغيل لـ \\\"{0}\\\" في الساعة {1}؟\",\n  \"MessageTaskAudioFileNotWritable\": \"الملف الصوتي \\\"{0}\\\" غير قابل للكتابة\",\n  \"MessageTaskCanceledByUser\": \"تم إلغاء المهمة بواسطة المستخدم\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"جاري تنزيل الحلقة \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"جاري تضمين البيانات الوصفية\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"جاري تضمين البيانات الوصفية في الكتاب الصوتي \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"جاري ترميز M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"جاري ترميز الكتاب الصوتي \\\"{0}\\\" في ملف m4b واحد\",\n  \"MessageTaskFailed\": \"فشل\",\n  \"MessageTaskFailedToBackupAudioFile\": \"فشل في نسخ الملف الصوتي \\\"{0}\\\" احتياطيًا\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"فشل في إنشاء دليل ذاكرة التخزين المؤقت\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"فشل في تضمين البيانات الوصفية في الملف \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"فشل في دمج الملفات الصوتية\",\n  \"MessageTaskFailedToMoveM4bFile\": \"فشل في نقل ملف m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"فشل في كتابة ملف البيانات الوصفية\",\n  \"MessageTaskMatchingBooksInLibrary\": \"جارٍ مطابقة الكتب في المكتبة \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"لا توجد ملفات للفحص\",\n  \"MessageTaskOpmlImport\": \"استيراد OPML\",\n  \"MessageTaskOpmlImportDescription\": \"جارٍ إنشاء بودكاست من {0} خلاصة RSS\",\n  \"MessageTaskOpmlImportFeed\": \"استيراد تغذية OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"جارٍ استيراد خلاصة RSS \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"فشل في الحصول على تغذية البودكاست\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"جارٍ إنشاء بودكاست \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"البودكاست موجود بالفعل في المسار\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"فشل في إنشاء البودكاست\",\n  \"MessageTaskOpmlImportFinished\": \"تمت إضافة {0} بودكاست\",\n  \"MessageTaskOpmlParseFailed\": \"فشل في تحليل ملف OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"ملف OPML غير صالح، لم يتم العثور على علامة <opml> أو لم يتم العثور على علامة <outline>\",\n  \"MessageTaskOpmlParseNoneFound\": \"لم يتم العثور على أي خلاصات في ملف OPML\",\n  \"MessageTaskScanItemsAdded\": \"تمت إضافة {0}\",\n  \"MessageTaskScanItemsMissing\": \"{0} مفقود\",\n  \"MessageTaskScanItemsUpdated\": \"{0} تم تحديثه\",\n  \"MessageTaskScanNoChangesNeeded\": \"لا توجد تغييرات مطلوبة\",\n  \"MessageTaskScanningFileChanges\": \"جاري فحص تغييرات الملفات في \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"جاري فحص مكتبة \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"الدليل الهدف غير قابل للكتابة\",\n  \"MessageThinking\": \"جاري التفكير...\",\n  \"MessageUploaderItemFailed\": \"فشل الرفع\",\n  \"MessageUploaderItemSuccess\": \"تم الرفع بنجاح!\",\n  \"MessageUploading\": \"جاري الرفع...\",\n  \"MessageValidCronExpression\": \"تعبير Cron صالح\",\n  \"MessageWatcherIsDisabledGlobally\": \"المراقب معطل عالميًا في إعدادات الخادم\",\n  \"MessageXLibraryIsEmpty\": \"مكتبة {0} فارغة!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"مدة كتابك الصوتي أطول من المدة التي تم العثور عليها\",\n  \"MessageYourAudiobookDurationIsShorter\": \"مدة كتابك الصوتي أقصر من المدة التي تم العثور عليها\",\n  \"NoteChangeRootPassword\": \"مستخدم الجذر هو المستخدم الوحيد الذي يمكن أن يكون لديه كلمة مرور فارغة\",\n  \"NoteChapterEditorTimes\": \"ملاحظة: يجب أن يظل وقت بدء الفصل الأول عند 0:00 ولا يمكن أن يتجاوز وقت بدء الفصل الأخير مدة هذا الكتاب الصوتي.\",\n  \"NoteFolderPicker\": \"ملاحظة: لن يتم عرض المجلدات التي تم تعيينها بالفعل\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"تحذير: تتطلب معظم تطبيقات البث الصوتي أن يكون عنوان URL الخاص بتغذية RSS يستخدم HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"تحذير: حلقة واحدة أو أكثر من حلقاتك ليس لها تاريخ نشر. بعض تطبيقات البودكاست تتطلب هذا.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"سيتم التعامل مع المجلدات التي تحتوي على ملفات وسائط كعناصر مكتبة منفصلة.\",\n  \"NoteUploaderOnlyAudioFiles\": \"في حالة رفع ملفات صوتية فقط، سيتم التعامل مع كل ملف صوتي ككتاب صوتي منفصل.\",\n  \"NoteUploaderUnsupportedFiles\": \"يتم تجاهل الملفات غير المدعومة. عند اختيار مجلد أو إسقاطه، يتم تجاهل الملفات الأخرى التي ليست في مجلد عنصر.\",\n  \"NotificationOnBackupCompletedDescription\": \"يتم تشغيله عند اكتمال النسخ الاحتياطي\",\n  \"NotificationOnBackupFailedDescription\": \"يتم تشغيله عند فشل النسخ الاحتياطي\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"يتم تشغيله عند تنزيل حلقة بودكاست تلقائيًا\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"يتم تشغيله عندما يتم تعطيل تنزيلات الحلقة التلقائية بسبب الكثير من المحاولات الفاشلة\",\n  \"NotificationOnRSSFeedFailedDescription\": \"يتم تشغيله عند فشل طلب تغذية RSS في تنزيل حلقة تلقائية\",\n  \"NotificationOnTestDescription\": \"حدث لاختبار نظام الإشعارات\",\n  \"PlaceholderNewCollection\": \"اسم المجموعة الجديدة\",\n  \"PlaceholderNewFolderPath\": \"مسار المجلد الجديد\",\n  \"PlaceholderNewPlaylist\": \"اسم قائمة التشغيل الجديدة\",\n  \"PlaceholderSearch\": \"بحث..\",\n  \"PlaceholderSearchEpisode\": \"بحث عن حلقة..\",\n  \"StatsAuthorsAdded\": \"تمت إضافة مؤلفين\",\n  \"StatsBooksAdded\": \"تمت إضافة كتب\",\n  \"StatsBooksAdditional\": \"تتضمن بعض الإضافات…\",\n  \"StatsBooksFinished\": \"كتب تم الانتهاء منها\",\n  \"StatsBooksFinishedThisYear\": \"بعض الكتب التي تم الانتهاء منها هذا العام…\",\n  \"StatsBooksListenedTo\": \"كتب تم الاستماع إليها\",\n  \"StatsCollectionGrewTo\": \"نما مجموعتك من الكتب لتصبح…\",\n  \"StatsSessions\": \"جلسات\",\n  \"StatsSpentListening\": \"تم قضاء وقت في الاستماع\",\n  \"StatsTopAuthor\": \"أفضل مؤلف\",\n  \"StatsTopAuthors\": \"أفضل المؤلفين\",\n  \"StatsTopGenre\": \"أفضل تصنيف\",\n  \"StatsTopGenres\": \"أفضل التصنيفات\",\n  \"StatsTopMonth\": \"أفضل شهر\",\n  \"StatsTopNarrator\": \"أفضل راوي\",\n  \"StatsTopNarrators\": \"أفضل الرواة\",\n  \"StatsTotalDuration\": \"بإجمالي مدة…\",\n  \"StatsYearInReview\": \"ملخص العام\",\n  \"ToastAccountUpdateSuccess\": \"تم تحديث الحساب\",\n  \"ToastAppriseUrlRequired\": \"يجب إدخال عنوان URL لـ Apprise\",\n  \"ToastAsinRequired\": \"ASIN مطلوب\",\n  \"ToastAuthorImageRemoveSuccess\": \"تمت إزالة صورة المؤلف\",\n  \"ToastAuthorNotFound\": \"لم يتم العثور على المؤلف \\\"{0}\\\"\",\n  \"ToastAuthorRemoveSuccess\": \"تمت إزالة المؤلف\",\n  \"ToastAuthorSearchNotFound\": \"لم يتم العثور على المؤلف\",\n  \"ToastAuthorUpdateMerged\": \"تم دمج المؤلف\",\n  \"ToastAuthorUpdateSuccess\": \"تم تحديث المؤلف\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"تم تحديث المؤلف (لم يتم العثور على صورة)\",\n  \"ToastBackupAppliedSuccess\": \"تم تطبيق النسخ الاحتياطي\",\n  \"ToastBackupCreateFailed\": \"فشل إنشاء النسخ الاحتياطي\",\n  \"ToastBackupCreateSuccess\": \"تم إنشاء النسخ الاحتياطي\",\n  \"ToastBackupDeleteFailed\": \"فشل حذف النسخ الاحتياطي\",\n  \"ToastBackupDeleteSuccess\": \"تم حذف النسخ الاحتياطي\",\n  \"ToastBackupInvalidMaxKeep\": \"عدد غير صالح للنسخ الاحتياطية التي يجب الاحتفاظ بها\",\n  \"ToastBackupInvalidMaxSize\": \"حجم أقصى غير صالح للنسخ الاحتياطي\",\n  \"ToastBackupRestoreFailed\": \"فشل استعادة النسخ الاحتياطي\",\n  \"ToastBackupUploadFailed\": \"فشل رفع النسخ الاحتياطي\",\n  \"ToastBackupUploadSuccess\": \"تم رفع النسخ الاحتياطي\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"تم تطبيق التفاصيل على العناصر\",\n  \"ToastBatchDeleteFailed\": \"فشل الحذف المجمّع\",\n  \"ToastBatchDeleteSuccess\": \"نجاح الحذف المجمّع\",\n  \"ToastBatchQuickMatchFailed\": \"فشلت المطابقة السريعة المجمّعة!\",\n  \"ToastBatchQuickMatchStarted\": \"بدأت المطابقة السريعة المجمّعة لـ {0} كتابًا!\",\n  \"ToastBatchUpdateFailed\": \"فشل التحديث المجمّع\",\n  \"ToastBatchUpdateSuccess\": \"نجاح التحديث المجمّع\",\n  \"ToastBookmarkCreateFailed\": \"فشل في إنشاء الإشارة المرجعية\",\n  \"ToastBookmarkCreateSuccess\": \"تمت إضافة الإشارة المرجعية\",\n  \"ToastBookmarkRemoveSuccess\": \"تمت إزالة الإشارة المرجعية\",\n  \"ToastCachePurgeFailed\": \"فشل مسح ذاكرة التخزين المؤقت\",\n  \"ToastCachePurgeSuccess\": \"تم مسح ذاكرة التخزين المؤقت بنجاح\",\n  \"ToastChaptersHaveErrors\": \"الفصول تحتوي على أخطاء\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"مقدار إزاحة غير صالح. سيمتد وقت بدء الفصل الأخير إلى ما بعد مدة هذا الكتاب الصوتي.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"مقدار إزاحة غير صالح. سيكون للفصل الأول طول صفري أو سالب وسيتم الكتابة فوقه بواسطة الفصل الثاني. قم بزيادة مدة بدء الفصل الثاني.\",\n  \"ToastChaptersMustHaveTitles\": \"يجب أن تحتوي الفصول على عناوين\",\n  \"ToastChaptersRemoved\": \"تمت إزالة الفصول\",\n  \"ToastChaptersUpdated\": \"تم تحديث الفصول\",\n  \"ToastCollectionItemsAddFailed\": \"فشل إضافة عنصر (عناصر) إلى المجموعة\",\n  \"ToastCollectionRemoveSuccess\": \"تمت إزالة المجموعة\",\n  \"ToastCollectionUpdateSuccess\": \"تم تحديث المجموعة\",\n  \"ToastCoverUpdateFailed\": \"فشل تحديث الغلاف\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"التاريخ والوقت غير صالحين أو غير مكتملين\",\n  \"ToastDeleteFileFailed\": \"فشل حذف الملف\",\n  \"ToastDeleteFileSuccess\": \"تم حذف الملف\",\n  \"ToastDeviceAddFailed\": \"فشل إضافة الجهاز\",\n  \"ToastDeviceNameAlreadyExists\": \"جهاز قارئ إلكتروني بهذا الاسم موجود بالفعل\",\n  \"ToastDeviceTestEmailFailed\": \"فشل إرسال البريد الإلكتروني التجريبي\",\n  \"ToastDeviceTestEmailSuccess\": \"تم إرسال البريد الإلكتروني التجريبي\",\n  \"ToastEmailSettingsUpdateSuccess\": \"تم تحديث إعدادات البريد الإلكتروني\",\n  \"ToastEncodeCancelFailed\": \"فشل إلغاء الترميز\",\n  \"ToastEncodeCancelSucces\": \"تم إلغاء الترميز\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"فشل مسح قائمة انتظار تنزيل الحلقات\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"تم مسح قائمة انتظار تنزيل الحلقات\",\n  \"ToastEpisodeUpdateSuccess\": \"تم تحديث {0} حلقة\",\n  \"ToastErrorCannotShare\": \"لا يمكن المشاركة محليًا على هذا الجهاز\",\n  \"ToastFailedToLoadData\": \"فشل تحميل البيانات\",\n  \"ToastFailedToMatch\": \"فشل التطابق\",\n  \"ToastFailedToShare\": \"فشل المشاركة\",\n  \"ToastFailedToUpdate\": \"فشل التحديث\",\n  \"ToastInvalidImageUrl\": \"رابط صورة غير صالح\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"الحد الأقصى غير صالح لعدد الحلقات المراد تنزيلها\",\n  \"ToastInvalidUrl\": \"رابط غير صالح\",\n  \"ToastItemCoverUpdateSuccess\": \"تم تحديث غلاف العنصر\",\n  \"ToastItemDeletedFailed\": \"فشل حذف العنصر\",\n  \"ToastItemDeletedSuccess\": \"تم حذف العنصر\",\n  \"ToastItemDetailsUpdateSuccess\": \"تم تحديث تفاصيل العنصر\",\n  \"ToastItemMarkedAsFinishedFailed\": \"فشل وضع علامة \\\"مكتمل\\\"\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"تم وضع علامة \\\"تم الانتهاء\\\" على العنصر\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"فشل وضع علامة \\\"غير مكتمل\\\"\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"تم وضع علامة \\\"غير مكتمل\\\" على العنصر\",\n  \"ToastItemUpdateSuccess\": \"تم تحديث العنصر\",\n  \"ToastLibraryCreateFailed\": \"فشل إنشاء المكتبة\",\n  \"ToastLibraryCreateSuccess\": \"تم إنشاء المكتبة \\\"{0}\\\"\",\n  \"ToastLibraryDeleteFailed\": \"فشل حذف المكتبة\",\n  \"ToastLibraryDeleteSuccess\": \"تم حذف المكتبة\",\n  \"ToastLibraryScanFailedToStart\": \"فشل بدء الفحص\",\n  \"ToastLibraryScanStarted\": \"بدأ فحص المكتبة\",\n  \"ToastLibraryUpdateSuccess\": \"تم تحديث المكتبة \\\"{0}\\\"\",\n  \"ToastMatchAllAuthorsFailed\": \"فشل مطابقة جميع المؤلفين\",\n  \"ToastMetadataFilesRemovedError\": \"حدث خطأ أثناء إزالة ملفات البيانات الوصفية. {0}\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"لا توجد بيانات وصفية. تم العثور على {0} ملف في المكتبة\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"لا توجد بيانات وصفية. تمت إزالة {0} ملفًا\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} بيانات وصفية. تم إزالة {1} ملف\",\n  \"ToastMustHaveAtLeastOnePath\": \"يجب أن يكون هناك مسار واحد على الأقل\",\n  \"ToastNameEmailRequired\": \"الاسم والبريد الإلكتروني مطلوبان\",\n  \"ToastNameRequired\": \"الاسم مطلوب\",\n  \"ToastNewEpisodesFound\": \"تم العثور على {0} حلقة جديدة\",\n  \"ToastNewUserCreatedFailed\": \"فشل إنشاء الحساب: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"تم إنشاء حساب جديد\",\n  \"ToastNewUserLibraryError\": \"يجب تحديد مكتبة واحدة على الأقل\",\n  \"ToastNewUserPasswordError\": \"يجب أن يكون لديك كلمة مرور، يمكن لمستخدم الجذر فقط أن يكون لديه كلمة مرور فارغة\",\n  \"ToastNewUserTagError\": \"يجب تحديد علامة واحدة على الأقل\",\n  \"ToastNewUserUsernameError\": \"أدخل اسم مستخدم\",\n  \"ToastNoNewEpisodesFound\": \"لم يتم العثور على حلقات جديدة\",\n  \"ToastNoRSSFeed\": \"لا يحتوي البودكاست على خلاصة RSS\",\n  \"ToastNoUpdatesNecessary\": \"لا توجد تحديثات ضرورية\",\n  \"ToastNotificationCreateFailed\": \"فشل إنشاء الإشعار\",\n  \"ToastNotificationDeleteFailed\": \"فشل حذف الإشعار\",\n  \"ToastNotificationFailedMaximum\": \"يجب أن يكون الحد الأقصى للمحاولات الفاشلة >= 0\",\n  \"ToastNotificationQueueMaximum\": \"يجب أن يكون الحد الأقصى لقائمة انتظار الإشعارات >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"تم تحديث إعدادات الإشعارات\",\n  \"ToastNotificationTestTriggerFailed\": \"فشل تشغيل إشعار الاختبار\",\n  \"ToastNotificationTestTriggerSuccess\": \"تم تشغيل إشعار الاختبار\",\n  \"ToastNotificationUpdateSuccess\": \"تم تحديث الإشعار\",\n  \"ToastPlaylistCreateFailed\": \"فشل إنشاء قائمة التشغيل\",\n  \"ToastPlaylistCreateSuccess\": \"تم إنشاء قائمة التشغيل\",\n  \"ToastPlaylistRemoveSuccess\": \"تمت إزالة قائمة التشغيل\",\n  \"ToastPlaylistUpdateSuccess\": \"تم تحديث قائمة التشغيل\",\n  \"ToastPodcastCreateFailed\": \"فشل إنشاء البودكاست\",\n  \"ToastPodcastCreateSuccess\": \"تم إنشاء البودكاست بنجاح\",\n  \"ToastPodcastGetFeedFailed\": \"فشل في الحصول على تغذية البودكاست\",\n  \"ToastPodcastNoEpisodesInFeed\": \"لم يتم العثور على حلقات في خلاصة RSS\",\n  \"ToastPodcastNoRssFeed\": \"لا يحتوي البودكاست على خلاصة RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"لا تتم مزامنة التقدم، أعد تشغيل التشغيل\",\n  \"ToastProviderCreatedFailed\": \"فشل إضافة المزود\",\n  \"ToastProviderCreatedSuccess\": \"تمت إضافة مزود جديد\",\n  \"ToastProviderNameAndUrlRequired\": \"الاسم والرابط مطلوبان\",\n  \"ToastProviderRemoveSuccess\": \"تمت إزالة المزود\",\n  \"ToastRSSFeedCloseFailed\": \"فشل إغلاق مغذّي RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"تم إغلاق مغذّي RSS\",\n  \"ToastRemoveFailed\": \"فشل الإزالة\",\n  \"ToastRemoveItemFromCollectionFailed\": \"فشل إزالة العنصر من المجموعة\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"تمت إزالة العنصر من المجموعة\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"فشل إزالة عناصر المكتبة التي بها مشاكل\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"تمت إزالة عناصر المكتبة التي بها مشاكل\",\n  \"ToastRenameFailed\": \"فشل إعادة التسمية\",\n  \"ToastRescanFailed\": \"فشل إعادة الفحص لـ {0}\",\n  \"ToastRescanRemoved\": \"اكتملت إعادة الفحص، وتمت إزالة العنصر\",\n  \"ToastRescanUpToDate\": \"اكتملت إعادة الفحص، العنصر كان محدثًا\",\n  \"ToastRescanUpdated\": \"اكتملت إعادة الفحص، وتم تحديث العنصر\",\n  \"ToastScanFailed\": \"فشل فحص عنصر المكتبة\",\n  \"ToastSelectAtLeastOneUser\": \"حدد مستخدمًا واحدًا على الأقل\",\n  \"ToastSendEbookToDeviceFailed\": \"فشل إرسال الكتاب الإلكتروني إلى الجهاز\",\n  \"ToastSendEbookToDeviceSuccess\": \"تم إرسال الكتاب الإلكتروني إلى الجهاز \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"لا يمكن إضافة سلسلتين بنفس الاسم\",\n  \"ToastSeriesUpdateFailed\": \"فشل تحديث السلسلة\",\n  \"ToastSeriesUpdateSuccess\": \"نجاح تحديث السلسلة\",\n  \"ToastServerSettingsUpdateSuccess\": \"تم تحديث إعدادات الخادم\",\n  \"ToastSessionCloseFailed\": \"فشل إغلاق الجلسة\",\n  \"ToastSessionDeleteFailed\": \"فشل حذف الجلسة\",\n  \"ToastSessionDeleteSuccess\": \"تم حذف الجلسة\",\n  \"ToastSleepTimerDone\": \"انتهى مؤقت النوم... ششششش\",\n  \"ToastSlugMustChange\": \"يحتوي الاسم التعريفي على أحرف غير صالحة\",\n  \"ToastSlugRequired\": \"الاسم التعريفي مطلوب\",\n  \"ToastSocketConnected\": \"تم الاتصال بالمقبس\",\n  \"ToastSocketDisconnected\": \"تم قطع الاتصال بالمقبس\",\n  \"ToastSocketFailedToConnect\": \"فشل الاتصال بالمقبس\",\n  \"ToastSortingPrefixesEmptyError\": \"يجب أن يكون هناك بادئة فرز واحدة على الأقل\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"تم تحديث بادئات الفرز ({0} عنصرًا)\",\n  \"ToastTitleRequired\": \"العنوان مطلوب\",\n  \"ToastUnknownError\": \"خطأ غير معروف\",\n  \"ToastUnlinkOpenIdFailed\": \"فشل فصل المستخدم عن OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"تم فصل المستخدم عن OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"مسار الملف \\\"{0}\\\" موجود بالفعل على الخادم\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"يستخدم العنصر \\\"{0}\\\" دليلًا فرعيًا لمسار الرفع.\",\n  \"ToastUserDeleteFailed\": \"فشل حذف المستخدم\",\n  \"ToastUserDeleteSuccess\": \"تم حذف المستخدم\",\n  \"ToastUserPasswordChangeSuccess\": \"تم تغيير كلمة المرور بنجاح\",\n  \"ToastUserPasswordMismatch\": \"كلمات المرور غير متطابقة\",\n  \"ToastUserPasswordMustChange\": \"يجب ألا تطابق كلمة المرور الجديدة كلمة المرور القديمة\",\n  \"ToastUserRootRequireName\": \"يجب إدخال اسم مستخدم الجذر\"\n}\n"
  },
  {
    "path": "client/strings/be.json",
    "content": "{\n  \"ButtonAdd\": \"Дадаць\",\n  \"ButtonAddApiKey\": \"Дадаць API-ключ\",\n  \"ButtonAddChapters\": \"Дадаць раздзелы\",\n  \"ButtonAddDevice\": \"Дадаць прыладу\",\n  \"ButtonAddLibrary\": \"Дадаць бібліятэку\",\n  \"ButtonAddPodcasts\": \"Дадаць падкасты\",\n  \"ButtonAddUser\": \"Дадаць карыстальніка\",\n  \"ButtonAddYourFirstLibrary\": \"Дадайце сваю першую бібліятэку\",\n  \"ButtonApply\": \"Прымяніць\",\n  \"ButtonApplyChapters\": \"Прымяніць раздзелы\",\n  \"ButtonAuthors\": \"Аўтары\",\n  \"ButtonBack\": \"Назад\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Запоўніць з існуючага\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Запоўніць падрабязнасці карты\",\n  \"ButtonBrowseForFolder\": \"Агляд папак\",\n  \"ButtonCancel\": \"Скасаваць\",\n  \"ButtonCancelEncode\": \"Скасаваць кадзіраванне\",\n  \"ButtonChangeRootPassword\": \"Зменіце Root пароль\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Праверыць і спампаваць новыя выпускі\",\n  \"ButtonChooseAFolder\": \"Выбраць папку\",\n  \"ButtonChooseFiles\": \"Выбраць файлы\",\n  \"ButtonClearFilter\": \"Ачысціць фільтр\",\n  \"ButtonClose\": \"Закрыць\",\n  \"ButtonCloseFeed\": \"Закрыць стужку\",\n  \"ButtonCloseSession\": \"Закрыць адкрыты сеанс\",\n  \"ButtonCollections\": \"Калекцыі\",\n  \"ButtonConfigureScanner\": \"Наладзіць сканер\",\n  \"ButtonCreate\": \"Стварыць\",\n  \"ButtonCreateBackup\": \"Стварыць рэзервовую копію\",\n  \"ButtonDelete\": \"Выдаліць\",\n  \"ButtonDownloadQueue\": \"Чарга\",\n  \"ButtonEdit\": \"Рэдагаваць\",\n  \"ButtonEditChapters\": \"Рэдагаваць раздзелы\",\n  \"ButtonEditPodcast\": \"Рэдагаваць падкаст\",\n  \"ButtonEnable\": \"Уключыць\",\n  \"ButtonFireAndFail\": \"Агонь і няўдача\",\n  \"ButtonFireOnTest\": \"Тэст на вогнеўстойлівасць\",\n  \"ButtonForceReScan\": \"Прымусова паўторна сканіраваць\",\n  \"ButtonFullPath\": \"Поўны шлях\",\n  \"ButtonHide\": \"Схаваць\",\n  \"ButtonHome\": \"Галоўная\",\n  \"ButtonIssues\": \"Праблемы\",\n  \"ButtonJumpBackward\": \"Перайсці назад\",\n  \"ButtonJumpForward\": \"Перайсці наперад\",\n  \"ButtonLatest\": \"Апошняе\",\n  \"ButtonLibrary\": \"Бібліятэка\",\n  \"ButtonLogout\": \"Выйсці\",\n  \"ButtonLookup\": \"Пошук\",\n  \"ButtonManageTracks\": \"Кіраванне трэкамі\",\n  \"ButtonMapChapterTitles\": \"Супаставіць загалоўкі раздзелаў\",\n  \"ButtonMatchAllAuthors\": \"Супадзенне ўсіх аўтараў\",\n  \"ButtonMatchBooks\": \"Параўнаць кнігі\",\n  \"ButtonNevermind\": \"Няважна\",\n  \"ButtonNext\": \"Далей\",\n  \"ButtonNextChapter\": \"Наступны раздзел\",\n  \"ButtonNextItemInQueue\": \"Наступны элемент у чарзе\",\n  \"ButtonOk\": \"ОК\",\n  \"ButtonOpenFeed\": \"Адкрыць стужку\",\n  \"ButtonOpenManager\": \"Адкрыць менеджар\",\n  \"ButtonPause\": \"Прыпыніць\",\n  \"ButtonPlay\": \"Прайграць\",\n  \"ButtonPlayAll\": \"Прайграць усё\",\n  \"ButtonPlaying\": \"Прайграваецца\",\n  \"ButtonPlaylists\": \"Плэй-лісты\",\n  \"ButtonPrevious\": \"Папярэдні\",\n  \"ButtonPreviousChapter\": \"Папярэдні раздзел\",\n  \"ButtonProbeAudioFile\": \"Праверыць аўдыяфайл\",\n  \"ButtonPurgeAllCache\": \"Ачысціць увесь кэш\",\n  \"ButtonPurgeItemsCache\": \"Ачысціць кэш элементаў\",\n  \"ButtonQueueAddItem\": \"Дадаць у чаргу\",\n  \"ButtonQueueRemoveItem\": \"Выдаліць з чаргі\",\n  \"ButtonQuickEmbed\": \"Хуткае ўбудаванне\",\n  \"ButtonQuickEmbedMetadata\": \"Хуткае ўбудаванне метаданых\",\n  \"ButtonQuickMatch\": \"Хуткі пошук\",\n  \"ButtonReScan\": \"Паўторна сканіраваць\",\n  \"ButtonRead\": \"Чытаць\",\n  \"ButtonReadLess\": \"Чытаць менш\",\n  \"ButtonReadMore\": \"Чытаць больш\",\n  \"ButtonRefresh\": \"Абнавіць\",\n  \"ButtonRemove\": \"Выдаліць\",\n  \"ButtonRemoveAll\": \"Выдаліць усе\",\n  \"ButtonRemoveAllLibraryItems\": \"Выдаліць усе элементы бібліятэкі\",\n  \"ButtonRemoveFromContinueListening\": \"Выдаліць з Працягнуць праслухоўванне\",\n  \"ButtonRemoveFromContinueReading\": \"Выдаліць з Працягваць чытанне\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Выдаліць серыю з Працягваць серыю\",\n  \"ButtonReset\": \"Скінуць\",\n  \"ButtonResetToDefault\": \"Скінуць да прадвызначаных\",\n  \"ButtonRestore\": \"Аднавіць\",\n  \"ButtonSave\": \"Захаваць\",\n  \"ButtonSaveAndClose\": \"Захаваць і зачыніць\",\n  \"ButtonSaveTracklist\": \"Захаваць спіс трэкаў\",\n  \"ButtonScan\": \"Сканаваць\",\n  \"ButtonScanLibrary\": \"Сканіраваць бібліятэку\",\n  \"ButtonScrollLeft\": \"Пракруціць улева\",\n  \"ButtonScrollRight\": \"Пракруціць направа\",\n  \"ButtonSearch\": \"Пошук\",\n  \"ButtonSelectFolderPath\": \"Выбраць шлях да папкі\",\n  \"ButtonSeries\": \"Серыі\",\n  \"ButtonSetChaptersFromTracks\": \"Задаць раздзелы з трэкаў\",\n  \"ButtonShare\": \"Падзяліцца\",\n  \"ButtonShiftTimes\": \"Карэкцыя часу\",\n  \"ButtonShow\": \"Паказаць\",\n  \"ButtonStartM4BEncode\": \"Пачаць кадзіраванне ў M4B\",\n  \"ButtonStartMetadataEmbed\": \"Пачаць убудаванне метаданых\",\n  \"ButtonStats\": \"Статыстыка\",\n  \"ButtonSubmit\": \"Адправіць\",\n  \"ButtonTest\": \"Тэст\",\n  \"ButtonUnlinkOpenId\": \"Адвязаць OpenID\",\n  \"ButtonUpload\": \"Запампаваць\",\n  \"ButtonUploadBackup\": \"Запампаваць рэзервовую копію\",\n  \"ButtonUploadCover\": \"Запампаваць вокладку\",\n  \"ButtonUploadOPMLFile\": \"Запампаваць файл OPML\",\n  \"ButtonUserDelete\": \"Выдаліць карыстальніка {0}\",\n  \"ButtonUserEdit\": \"Рэдагаваць карыстальніка {0}\",\n  \"ButtonViewAll\": \"Прагледзець усе\",\n  \"ButtonYes\": \"Так\",\n  \"ErrorUploadFetchMetadataAPI\": \"Памылка пры атрыманні метаданых\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Не ўдалося атрымаць метаданыя – паспрабуйце абнавіць загаловак і/або аўтара\",\n  \"ErrorUploadLacksTitle\": \"Павінен быць загаловак\",\n  \"HeaderAccount\": \"Уліковы запіс\",\n  \"HeaderAddCustomMetadataProvider\": \"Дадаванне карыстальніцкага пастаўшчыка метаданых\",\n  \"HeaderAdvanced\": \"Дадаткова\",\n  \"HeaderApiKeys\": \"API-ключы\",\n  \"HeaderAppriseNotificationSettings\": \"Налады апавяшчэнняў Apprise\",\n  \"HeaderAudioTracks\": \"Аўдыятрэкі\",\n  \"HeaderAudiobookTools\": \"Сродкі кіравання файламі аўдыякніг\",\n  \"HeaderAuthentication\": \"Аўтэнтыфікацыя\",\n  \"HeaderBackups\": \"Рэзервовыя копіі\",\n  \"HeaderBulkChapterModal\": \"Дадаць некалькі раздзелаў\",\n  \"HeaderChangePassword\": \"Змяніць пароль\",\n  \"HeaderChapters\": \"Раздзелы\",\n  \"HeaderChooseAFolder\": \"Выберыце папку\",\n  \"HeaderCollection\": \"Калекцыя\",\n  \"HeaderCollectionItems\": \"Элементы калекцыі\",\n  \"HeaderCover\": \"Вокладка\",\n  \"HeaderCurrentDownloads\": \"Бягучыя спампоўванні\",\n  \"HeaderCustomMessageOnLogin\": \"Карыстальніцкае паведамленне пры ўваходзе\",\n  \"HeaderCustomMetadataProviders\": \"Карыстальніцкія пастаўшчыкі метаданых\",\n  \"HeaderDetails\": \"Падрабязнасці\",\n  \"HeaderDownloadQueue\": \"Чарга спамповак\",\n  \"HeaderEbookFiles\": \"Файлы электронных кніг\",\n  \"HeaderEmail\": \"Электронная пошта\",\n  \"HeaderEmailSettings\": \"Налады электроннай пошты\",\n  \"HeaderEpisodes\": \"Выпускі\",\n  \"HeaderEreaderDevices\": \"Прылады для чытання\",\n  \"HeaderEreaderSettings\": \"Налады прылады для чытання\",\n  \"HeaderFiles\": \"Файлы\",\n  \"HeaderFindChapters\": \"Знайсці раздзелы\",\n  \"HeaderIgnoredFiles\": \"Ігнараваныя файлы\",\n  \"HeaderItemFiles\": \"Файлы элементаў\",\n  \"HeaderItemMetadataUtils\": \"Утыліты для метаданых\",\n  \"HeaderLastListeningSession\": \"Апошні сеанс праслухоўвання\",\n  \"HeaderLatestEpisodes\": \"Апошнія выпускі\",\n  \"HeaderLibraries\": \"Бібліятэкі\",\n  \"HeaderLibraryFiles\": \"Файлы бібліятэкі\",\n  \"HeaderLibraryStats\": \"Статыстыка бібліятэкі\",\n  \"HeaderListeningSessions\": \"Сеансы праслухоўвання\",\n  \"HeaderListeningStats\": \"Статыстыка праслухоўвання\",\n  \"HeaderLogin\": \"Уваход\",\n  \"HeaderLogs\": \"Журналы\",\n  \"HeaderManageGenres\": \"Кіраванне жанрамі\",\n  \"HeaderManageTags\": \"Кіраванне тэгамі\",\n  \"HeaderMapDetails\": \"Падрабязнасці адлюстравання\",\n  \"HeaderMatch\": \"Супадзенне\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Парадак прыярытэту метаданых\",\n  \"HeaderMetadataToEmbed\": \"Метаданыя для ўбудавання\",\n  \"HeaderNewAccount\": \"Новы ўліковы запіс\",\n  \"HeaderNewApiKey\": \"Новы API-ключ\",\n  \"HeaderNewLibrary\": \"Новая бібліятэка\",\n  \"HeaderNotificationCreate\": \"Стварыць апавяшчэнне\",\n  \"HeaderNotificationUpdate\": \"Абнавіць апавяшчэнне\",\n  \"HeaderNotifications\": \"Апавяшчэнні\",\n  \"HeaderOpenIDConnectAuthentication\": \"Аўтэнтыфікацыя праз OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Адкрыць сеансы праслухоўвання\",\n  \"HeaderOpenRSSFeed\": \"Адкрыць RSS-стужку\",\n  \"HeaderOtherFiles\": \"Іншыя файлы\",\n  \"HeaderPasswordAuthentication\": \"Аўтэнтыфікацыя паролем\",\n  \"HeaderPermissions\": \"Дазволы\",\n  \"HeaderPlayerQueue\": \"Чарга прайгравання\",\n  \"HeaderPlayerSettings\": \"Налады прайгравальніка\",\n  \"HeaderPlaylist\": \"Плэй-ліст\",\n  \"HeaderPlaylistItems\": \"Элементы плэй-ліста\",\n  \"HeaderPodcastsToAdd\": \"Падкасты для дадання\",\n  \"HeaderPresets\": \"Прадустаноўкі\",\n  \"HeaderPreviewCover\": \"Прадпрагляд вокладкі\",\n  \"HeaderRSSFeedGeneral\": \"Падрабязнасці RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-стужка адкрытая\",\n  \"HeaderRSSFeeds\": \"RSS-стужкі\",\n  \"HeaderRemoveEpisode\": \"Выдаліць выпуск\",\n  \"HeaderRemoveEpisodes\": \"Выдаліць {0} выпускаў\",\n  \"HeaderSavedMediaProgress\": \"Захаваны прагрэс медыя\",\n  \"HeaderSchedule\": \"Расклад\",\n  \"HeaderScheduleEpisodeDownloads\": \"Расклад аўтаматычных спампоўванняў выпускаў\",\n  \"HeaderScheduleLibraryScans\": \"Расклад аўтаматычнага сканавання бібліятэкі\",\n  \"HeaderSession\": \"Сеанс\",\n  \"HeaderSetBackupSchedule\": \"Наладзіць расклад рэзервовага капіравання\",\n  \"HeaderSettings\": \"Налады\",\n  \"HeaderSettingsDisplay\": \"Выгляд\",\n  \"HeaderSettingsExperimental\": \"Эксперыментальныя функцыі\",\n  \"HeaderSettingsGeneral\": \"Агульныя\",\n  \"HeaderSettingsScanner\": \"Сканер\",\n  \"HeaderSettingsSecurity\": \"Бяспека\",\n  \"HeaderSettingsWebClient\": \"Вэб-кліент\",\n  \"HeaderSleepTimer\": \"Таймер сну\",\n  \"HeaderStatsLargestItems\": \"Найбуйнейшыя элементы\",\n  \"HeaderStatsLongestItems\": \"Найдаўжэйшыя элементы (гадзіны)\",\n  \"HeaderStatsMinutesListeningChart\": \"Хвілін праслухоўвання (апошнія 7 дзён)\",\n  \"HeaderStatsRecentSessions\": \"Апошнія сеансы\",\n  \"HeaderStatsTop10Authors\": \"Топ 10 аўтараў\",\n  \"HeaderStatsTop5Genres\": \"Топ 5 жанраў\",\n  \"HeaderTableOfContents\": \"Змест\",\n  \"HeaderTools\": \"Інструменты\",\n  \"HeaderUpdateAccount\": \"Абнавіць уліковы запіс\",\n  \"HeaderUpdateApiKey\": \"Абнавіць API-ключ\",\n  \"HeaderUpdateAuthor\": \"Абнавіць аўтара\",\n  \"HeaderUpdateDetails\": \"Абнавіць падрабязнасці\",\n  \"HeaderUpdateLibrary\": \"Абнавіць бібліятэку\",\n  \"HeaderUsers\": \"Карыстальнікі\",\n  \"HeaderYearReview\": \"Вынікі {0} года\",\n  \"HeaderYourStats\": \"Ваша статыстыка\",\n  \"LabelAbridged\": \"Скарочаная версія\",\n  \"LabelAbridgedChecked\": \"Скарочаная версія (праверана)\",\n  \"LabelAbridgedUnchecked\": \"Поўная версія (неправерана)\",\n  \"LabelAccessibleBy\": \"Даступна для\",\n  \"LabelAccountType\": \"Тып уліковага запіса\",\n  \"LabelAccountTypeAdmin\": \"Адміністратар\",\n  \"LabelAccountTypeGuest\": \"Госць\",\n  \"LabelAccountTypeUser\": \"Карыстальнік\",\n  \"LabelActivities\": \"Дзеянні\",\n  \"LabelActivity\": \"Дзеянне\",\n  \"LabelAddToCollection\": \"Дадаць у калекцыю\",\n  \"LabelAddToCollectionBatch\": \"Дадаць {0} кніг у калекцыю\",\n  \"LabelAddToPlaylist\": \"Дадаць у плэй-ліст\",\n  \"LabelAddToPlaylistBatch\": \"Дадаць {0} элементаў у плэй-ліст\",\n  \"LabelAddedAt\": \"Дата дабаўлення\",\n  \"LabelAddedDate\": \"Дададзена {0}\",\n  \"LabelAdminUsersOnly\": \"Толькі для адміністратараў\",\n  \"LabelAll\": \"Усе\",\n  \"LabelAllEpisodesDownloaded\": \"Усе выпускі спампаваныя\",\n  \"LabelAllUsers\": \"Усе карыстальнікі\",\n  \"LabelAllUsersExcludingGuests\": \"Усіх карыстальнікаў, акрамя гасцей\",\n  \"LabelAllUsersIncludingGuests\": \"Усіх карыстальнікаў, уключаючы гасцей\",\n  \"LabelAlreadyInYourLibrary\": \"Ужо ў вашай бібліятэцы\",\n  \"LabelApiKeyCreated\": \"API-ключ \\\"{0}\\\" паспяхова створаны.\",\n  \"LabelApiKeyCreatedDescription\": \"Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.\",\n  \"LabelApiKeyUser\": \"Дзейнічаць ад імя карыстальніка\",\n  \"LabelApiKeyUserDescription\": \"Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.\",\n  \"LabelApiToken\": \"Токен API\",\n  \"LabelAppend\": \"Дадаць\",\n  \"LabelAudioBitrate\": \"Бітрэйт аўдыя (напрыклад, 128к)\",\n  \"LabelAudioChannels\": \"Аўдыяканалы (1 або 2)\",\n  \"LabelAudioCodec\": \"Аўдыякодэк\",\n  \"LabelAuthor\": \"Аўтар\",\n  \"LabelAuthorFirstLast\": \"Аўтар (Імя Прозвішча)\",\n  \"LabelAuthorLastFirst\": \"Аўтар (Прозвішча, Імя)\",\n  \"LabelAuthors\": \"Аўтары\",\n  \"LabelAutoDownloadEpisodes\": \"Аўтаматычна спампоўваць выпускі\",\n  \"LabelAutoFetchMetadata\": \"Аўтаматычнае атрыманне метаданых\",\n  \"LabelAutoFetchMetadataHelp\": \"Атрыманне звестак пра загаловак, аўтара і серыю для спрашчэння запампоўвання. Пасля запампоўвання, магчыма, спатрэбіцца супаставіць дадатковыя метаданыя.\",\n  \"LabelAutoLaunch\": \"Аўтазапуск\",\n  \"LabelAutoLaunchDescription\": \"Аўтаматычна перанакіроўваць да пастаўшчыка аўтэнтыфікацыі пры пераходзе на старонку ўваходу (ручное пераключэнне праз шлях <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Аўтарэгістрацыя\",\n  \"LabelAutoRegisterDescription\": \"Аўтаматычна ствараць новых карыстальнікаў пасля ўваходу ў сістэму\",\n  \"LabelBackToUser\": \"Вярнуцца да карыстальніка\",\n  \"LabelBackupAudioFiles\": \"Рэзервовае капіраванне аўдыяфайлаў\",\n  \"LabelBackupLocation\": \"Размяшчэнне рэзервовых копій\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Аўтаматычнае рэзервовае капіраванне\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Рэзервовыя копіі захаваныя ў /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Максімальны памер рэзервовай копіі (у ГБ) (0 — неабмежавана)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Для таго, каб пазбегнуць няправільных налад, рэзервовыя копіі не будуць створаны, калі іх памер будзе больш за дапушчальны.\",\n  \"LabelBackupsNumberToKeep\": \"Колькасць захаваных рэзервовых копій\",\n  \"LabelBackupsNumberToKeepHelp\": \"Адначасова будзе выдаляцца толькі 1 рэзервовая копія, таму, калі ў вас іх больш, вам варта выдаліць іх уручную.\",\n  \"LabelBitrate\": \"Бітрэйт\",\n  \"LabelBonus\": \"Бонус\",\n  \"LabelBooks\": \"Кнігі\",\n  \"LabelButtonText\": \"Тэкст кнопкі\",\n  \"LabelByAuthor\": \"– {0}\",\n  \"LabelChangePassword\": \"Змяніць пароль\",\n  \"LabelChannels\": \"Каналы\",\n  \"LabelChapterCount\": \"{0} раздзелаў\",\n  \"LabelChapterTitle\": \"Загаловак раздзела\",\n  \"LabelChapters\": \"Раздзелы\",\n  \"LabelChaptersFound\": \"раздзелаў знойдзена\",\n  \"LabelClickForMoreInfo\": \"Націсніце для больш падрабязнай інфармацыі\",\n  \"LabelClickToUseCurrentValue\": \"Націсніце, каб выкарыстоўваць бягучае значэнне\",\n  \"LabelClosePlayer\": \"Зачыніць прайгравальнік\",\n  \"LabelCodec\": \"Кодэк\",\n  \"LabelCollapseSeries\": \"Згарнуць серыі\",\n  \"LabelCollapseSubSeries\": \"Згарнуць падсерыі\",\n  \"LabelCollection\": \"Калекцыя\",\n  \"LabelCollections\": \"Калекцыі\",\n  \"LabelComplete\": \"Завяршыць\",\n  \"LabelConfirmPassword\": \"Пацвердзіце пароль\",\n  \"LabelContinueListening\": \"Працягнуць праслухоўванне\",\n  \"LabelContinueReading\": \"Працягнуць чытанне\",\n  \"LabelContinueSeries\": \"Працягнуць серыі\",\n  \"LabelCorsAllowed\": \"Дазволеныя крыніцы CORS\",\n  \"LabelCover\": \"Вокладка\",\n  \"LabelCoverImageURL\": \"URL-адрас відарыса вокладкі\",\n  \"LabelCoverProvider\": \"Пастаўшчык вокладак\",\n  \"LabelCreatedAt\": \"Дата стварэння\",\n  \"LabelCronExpression\": \"Запіс Cron\",\n  \"LabelCurrent\": \"Бягучы\",\n  \"LabelCurrently\": \"Бягучы:\",\n  \"LabelCustomCronExpression\": \"Уласны запіс Cron:\",\n  \"LabelDatetime\": \"Дата і час\",\n  \"LabelDays\": \"Дзён\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Выдаліць з файлавай сістэмы (зніміце галачку, каб выдаліць толькі з базы даных)\",\n  \"LabelDescription\": \"Апісанне\",\n  \"LabelDeselectAll\": \"Скасаваць выбар усяго\",\n  \"LabelDetectedPattern\": \"Выяўлены ўзор:\",\n  \"LabelDevice\": \"Прылада\",\n  \"LabelDeviceInfo\": \"Інфармацыя пра прыладу\",\n  \"LabelDeviceIsAvailableTo\": \"Прылада даступная для...\",\n  \"LabelDirectory\": \"Каталог\",\n  \"LabelDiscFromFilename\": \"Дыск з файла\",\n  \"LabelDiscFromMetadata\": \"Дыск з метаданых\",\n  \"LabelDiscover\": \"Знаходкі\",\n  \"LabelDownload\": \"Спампаваць\",\n  \"LabelDownloadNEpisodes\": \"Спампавана {0} выпускаў\",\n  \"LabelDownloadable\": \"Спампоўваецца\",\n  \"LabelDuration\": \"Працягласць\",\n  \"LabelDurationComparisonExactMatch\": \"(дакладнае супадзенне)\",\n  \"LabelDurationComparisonLonger\": \"(на {0} даўжэй)\",\n  \"LabelDurationComparisonShorter\": \"(на {0} карацей)\",\n  \"LabelDurationFound\": \"Знойдзеная працягласць:\",\n  \"LabelEbook\": \"Электронная кніга\",\n  \"LabelEbooks\": \"Электронныя кнігі\",\n  \"LabelEdit\": \"Рэдагаваць\",\n  \"LabelEmail\": \"Электронная пошта\",\n  \"LabelEmailSettingsFromAddress\": \"Адрас адпраўніка\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Адхіляць неаўтарызаваныя сертыфікаты\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Адключэнне праверкі SSL-сертыфіката можа зрабіць ваша злучэнне ўразлівым перад пагрозамі бяспекі, такімі як атакі \\\"чалавек пасярэдзіне\\\". Адключайце гэтую опцыю толькі калі цалкам разумееце наступствы і ўпэўнены ў надзейнасці паштовага сервера.\",\n  \"LabelEmailSettingsSecure\": \"Бяспечна\",\n  \"LabelEmailSettingsSecureHelp\": \"Калі ўключана, злучэнне будзе выкарыстоўваць TLS пры падключэнні да сервера. Калі выключана, TLS будзе выкарыстоўвацца толькі ў выпадку падтрымкі пашырэння STARTTLS на серверы. У большасці выпадкаў усталюйце значэнне true пры падключэнні да порта 465. Для партоў 587 або 25 не ўключайце яго. (інфармацыя з nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Тэставы адрас\",\n  \"LabelEmbeddedCover\": \"Убудаваная вокладка\",\n  \"LabelEnable\": \"Уключыць\",\n  \"LabelEncodingBackupLocation\": \"Рэзервовая копія арыгінальных аўдыяфайлаў будзе захавана ў:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Раздзелы не ўбудоўваюцца ў аўдыякнігі з некалькімі трэкамі.\",\n  \"LabelEncodingClearItemCache\": \"Пераканайцеся, што перыядычна ачышчаеце кэш элементаў.\",\n  \"LabelEncodingFinishedM4B\": \"Гатовы файл M4B будзе змешчаны ў вашу папку з аўдыякнігамі ў:\",\n  \"LabelEncodingInfoEmbedded\": \"Метаданыя будуць убудаваны ў аўдыятрэкі ўнутры папкі з аўдыякнігамі.\",\n  \"LabelEncodingStartedNavigation\": \"Пасля запуску задачы вы можаце перайсці на іншую старонку.\",\n  \"LabelEncodingTimeWarning\": \"Кадаванне можа заняць да 30 хвілін.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Увага: Не абнаўляйце гэтыя налады, калі вы не знаёмыя з параметрамі кадавання ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Калі ў вас адключана адсочванне змен у папцы, пасля трэба будзе паўторна сканіраваць гэту аўдыякнігу.\",\n  \"LabelEnd\": \"Канец\",\n  \"LabelEndOfChapter\": \"Канец раздзела\",\n  \"LabelEpisode\": \"Выпуск\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Выпуск не звязаны з RSS-стужкай\",\n  \"LabelEpisodeNumber\": \"Выпуск №{0}\",\n  \"LabelEpisodeTitle\": \"Загаловак выпуску\",\n  \"LabelEpisodeType\": \"Тып выпуску\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL-адрас выпуску з RSS-стужкі\",\n  \"LabelEpisodes\": \"Выпускі\",\n  \"LabelEpisodic\": \"Эпізадычны\",\n  \"LabelExample\": \"Прыклад\",\n  \"LabelExpandSeries\": \"Разгарнуць серыю\",\n  \"LabelExpandSubSeries\": \"Разгарнуць падсерыі\",\n  \"LabelExpired\": \"Пратэрмінаваны\",\n  \"LabelExpiresAt\": \"Тэрмін дзеяння заканчваецца ў\",\n  \"LabelExpiresInSeconds\": \"Тэрмін дзеяння заканчваецца праз (секунд)\",\n  \"LabelExpiresNever\": \"Ніколі\",\n  \"LabelExplicit\": \"Непрыстойнае\",\n  \"LabelExplicitChecked\": \"Непрыстойнае (пазначана)\",\n  \"LabelExplicitUnchecked\": \"Прыстойнае (не пазначана)\",\n  \"LabelExportOPML\": \"Экспарт OPML\",\n  \"LabelFeedURL\": \"URL стужкі\",\n  \"LabelFetchingMetadata\": \"Атрыманне метаданых\",\n  \"LabelFile\": \"Файл\",\n  \"LabelFileBirthtime\": \"Час стварэння файла\",\n  \"LabelFileBornDate\": \"Створаны {0}\",\n  \"LabelFileModified\": \"Час змянення файла\",\n  \"LabelFileModifiedDate\": \"Зменены {0}\",\n  \"LabelFilename\": \"Назва файла\",\n  \"LabelFilterByUser\": \"Фільтраваць па карыстальніку\",\n  \"LabelFindEpisodes\": \"Знайсці выпускі\",\n  \"LabelFinished\": \"Завершана\",\n  \"LabelFinishedDate\": \"Завершана {0}\",\n  \"LabelFolder\": \"Папка\",\n  \"LabelFolders\": \"Папкі\",\n  \"LabelFontBold\": \"Тоўсты\",\n  \"LabelFontBoldness\": \"Таўшчыня шрыфта\",\n  \"LabelFontFamily\": \"Сямейства шрыфтоў\",\n  \"LabelFontItalic\": \"Курсіў\",\n  \"LabelFontScale\": \"Памер шрыфту\",\n  \"LabelFontStrikethrough\": \"Перакрэслены\",\n  \"LabelFormat\": \"Фармат\",\n  \"LabelFull\": \"Поўны\",\n  \"LabelGenre\": \"Жанр\",\n  \"LabelGenres\": \"Жанры\",\n  \"LabelHardDeleteFile\": \"Жорстка выдаляць файл\",\n  \"LabelHasEbook\": \"Мае электронную кнігу\",\n  \"LabelHasSupplementaryEbook\": \"Мае дадатковую электронную кнігу\",\n  \"LabelHideSubtitles\": \"Схаваць падзагалоўкі\",\n  \"LabelHighestPriority\": \"Найвышэйшы прыярытэт\",\n  \"LabelHost\": \"Хост\",\n  \"LabelHour\": \"Гадзіна\",\n  \"LabelHours\": \"Гадзіны\",\n  \"LabelIcon\": \"Значок\",\n  \"LabelImageURLFromTheWeb\": \"URL-адрас відарыса з інтэрнэту\",\n  \"LabelInProgress\": \"У працэсе\",\n  \"LabelIncludeInTracklist\": \"Уключыць у спіс трэкаў\",\n  \"LabelIncomplete\": \"Незавершана\",\n  \"LabelInterval\": \"Інтэрвал\",\n  \"LabelIntervalCustomDailyWeekly\": \"Карыстальніцкі штодзённы/штотыднёвы\",\n  \"LabelIntervalEvery12Hours\": \"Кожныя 12 гадзін\",\n  \"LabelIntervalEvery15Minutes\": \"Кожныя 15 хвілін\",\n  \"LabelIntervalEvery2Hours\": \"Кожныя 2 гадзіны\",\n  \"LabelIntervalEvery30Minutes\": \"Кожныя 30 хвілін\",\n  \"LabelIntervalEvery6Hours\": \"Кожныя 6 гадзін\",\n  \"LabelIntervalEveryDay\": \"Кожны дзень\",\n  \"LabelIntervalEveryHour\": \"Кожную гадзіну\",\n  \"LabelIntervalEveryMinute\": \"Кожную хвіліну\",\n  \"LabelInvert\": \"Інвертаваць\",\n  \"LabelItem\": \"Элемент\",\n  \"LabelJumpBackwardAmount\": \"Час пераходу назад\",\n  \"LabelJumpForwardAmount\": \"Час пераходу наперад\",\n  \"LabelLanguage\": \"Мова\",\n  \"LabelLanguageDefaultServer\": \"Прадвызначаная мова сервера\",\n  \"LabelLanguages\": \"Мовы\",\n  \"LabelLastBookAdded\": \"Апошняя дададзеная кніга\",\n  \"LabelLastBookUpdated\": \"Апошняя абноўленая кніга\",\n  \"LabelLastProgressDate\": \"Апошні прагрэс: {0}\",\n  \"LabelLastSeen\": \"Апошні прагляд\",\n  \"LabelLastTime\": \"Апошні раз\",\n  \"LabelLastUpdate\": \"Апошняе абнаўленне\",\n  \"LabelLayout\": \"Знешні выгляд\",\n  \"LabelLayoutSinglePage\": \"Аднабаковы\",\n  \"LabelLayoutSplitPage\": \"Падзяліць старонку\",\n  \"LabelLess\": \"Менш\",\n  \"LabelLibrariesAccessibleToUser\": \"Бібліятэкі, даступныя карыстальніку\",\n  \"LabelLibrary\": \"Бібліятэка\",\n  \"LabelLibraryFilterSublistEmpty\": \"Не {0}\",\n  \"LabelLibraryItem\": \"Элемент бібліятэкі\",\n  \"LabelLibraryName\": \"Назва бібліятэкі\",\n  \"LabelLibrarySortByProgress\": \"Прагрэс: апошняе абнаўленне\",\n  \"LabelLibrarySortByProgressFinished\": \"Прагрэс: завершана\",\n  \"LabelLibrarySortByProgressStarted\": \"Прагрэс: пачата\",\n  \"LabelLimit\": \"Абмежаванне\",\n  \"LabelLineSpacing\": \"Міжрадковы інтэрвал\",\n  \"LabelListenAgain\": \"Паслухаць зноў\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Warn\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Шукаць новыя выпускі пасля гэтай даты\",\n  \"LabelLowestPriority\": \"Найніжэйшы прыярытэт\",\n  \"LabelMatchConfidence\": \"Упэўненасць\",\n  \"LabelMatchExistingUsersBy\": \"Параўноўваць існуючых карыстальнікаў па\",\n  \"LabelMatchExistingUsersByDescription\": \"Выкарыстоўваецца для падключэння існуючых карыстальнікаў. Пасля падключэння карыстальнікі будуць супастаўляцца з дапамогай унікальнага ідэнтыфікатара ад пастаўшчыка SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Максімальная колькасць выпускаў для спампоўвання. 0 – неабмежаваная колькасць.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Максімальная колькасць новых выпускаў для спампоўвання за праверку\",\n  \"LabelMaxEpisodesToKeep\": \"Максімальная колькасць выпускаў, якія будуць захоўвацца\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Значэнне 0 не задае максімальнага абмежавання. Пасля аўтаматычнага спампоўвання новага выпуску будзе выдалены самы стары выпуск, калі ў вас больш за X выпускаў. Пры кожным новым спампоўванні будзе выдаляцца толькі 1 выпуск.\",\n  \"LabelMediaPlayer\": \"Медыяпрайгравальнік\",\n  \"LabelMediaType\": \"Тып медыя\",\n  \"LabelMetaTag\": \"Метатэг\",\n  \"LabelMetaTags\": \"Метатэгі\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Крыніцы метаданых з вышэйшым прыярытэтам будуць замяняць крыніцы з ніжэйшым прыярытэтам\",\n  \"LabelMetadataProvider\": \"Пастаўшчык метаданых\",\n  \"LabelMinute\": \"Хвіліна\",\n  \"LabelMinutes\": \"Хвіліны\",\n  \"LabelMissing\": \"Адсутнічае\",\n  \"LabelMissingEbook\": \"Няма электроннай кнігі\",\n  \"LabelMissingSupplementaryEbook\": \"Няма дадатковай электроннай кнігі\",\n  \"LabelMobileRedirectURIs\": \"Дазволеныя URI перанакіравання для мабільных прылад\",\n  \"LabelMobileRedirectURIsDescription\": \"Гэта белы спіс дапушчальных URI перанакіравання для мабільных праграм. Стандартным з'яўляецца <code>audiobookshelf://oauth</code>, які вы можаце выдаліць або дапоўніць дадатковымі URI для інтэграцыі са староннімі праграмамі. Выкарыстанне зорачкі (<code>*</code>) у якасці адзінага запісу дазваляе любы URI.\",\n  \"LabelMore\": \"Больш\",\n  \"LabelMoreInfo\": \"Больш інфармацыі\",\n  \"LabelName\": \"Назва\",\n  \"LabelNarrator\": \"Дыктар\",\n  \"LabelNarrators\": \"Дыктары\",\n  \"LabelNew\": \"Новы\",\n  \"LabelNewPassword\": \"Новы пароль\",\n  \"LabelNewestAuthors\": \"Новыя аўтары\",\n  \"LabelNewestEpisodes\": \"Найноўшыя выпускі\",\n  \"LabelNextBackupDate\": \"Дата наступнага рэзервовага капіравання\",\n  \"LabelNextChapters\": \"Наступныя раздзелы:\",\n  \"LabelNextScheduledRun\": \"Наступны запланаваны запуск\",\n  \"LabelNoApiKeys\": \"Няма ключоў API\",\n  \"LabelNoCustomMetadataProviders\": \"Няма карыстальніцкіх пастаўшчыкоў метаданых\",\n  \"LabelNoEpisodesSelected\": \"Не выбрана ніводнага выпуску\",\n  \"LabelNotFinished\": \"Незавершана\",\n  \"LabelNotStarted\": \"Не пачата\",\n  \"LabelNotes\": \"Заўвагі\",\n  \"LabelNotificationAppriseURL\": \"URL-адрасы Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Даступныя пераменныя\",\n  \"LabelNotificationBodyTemplate\": \"Шаблон зместу\",\n  \"LabelNotificationEvent\": \"Падзея апавяшчэння\",\n  \"LabelNotificationTitleTemplate\": \"Шаблон загалоўка\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Максімальная колькасць няўдалых спроб\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Апавяшчэнні адключаюцца пасля таго, як не ўдаецца іх адправіць гэтулькі разоў\",\n  \"LabelNotificationsMaxQueueSize\": \"Максімальны памер чаргі для падзей апавяшчэнняў\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Падзеі могуць спрацоўваць толькі адзін раз у секунду. Падзеі будуць ігнаравацца пры дасягненні максімальнага памеру чаргі. Гэта прадухіляе рассылку спаму.\",\n  \"LabelNumberOfBooks\": \"Колькасць кніг\",\n  \"LabelNumberOfChapters\": \"Колькасць раздзелаў:\",\n  \"LabelNumberOfEpisodes\": \"Колькасць выпускаў\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Назва прэтэнзіі OpenID, якая змяшчае пашыраныя дазволы для дзеянняў карыстальніка ў праграме, якія будуць прымяняцца да роляў, якія не з'яўляюцца адміністратарамі (<b>калі наладжана</b>). Калі прэтэнзія адсутнічае ў адказе, доступ да ABS будзе забаронены. Калі адсутнічае адзін параметр, ён будзе разглядацца як <code>false</code>. Пераканайцеся, што прэтэнзія пастаўшчыка ідэнтыфікацыі адпавядае чаканай структуры:\",\n  \"LabelOpenIDClaims\": \"Пакіньце наступныя параметры пустымі, каб адключыць пашыранае прызначэнне груп і дазволаў, аўтаматычна прызначаючы групу \\\"Карыстальнік\\\".\",\n  \"LabelOpenIDGroupClaimDescription\": \"Назва прэтэнзіі OpenID, якая змяшчае спіс груп карыстальніка. Звычайна іх называюць <code>групамі</code>. <b>Калі наладжана</b>, праграма будзе аўтаматычна прызначаць ролі на аснове членства карыстальніка ў групах, пры ўмове, што ў прэтэнзіі гэтыя групы названы без уліку рэгістра: \\\"адміністратар\\\", \\\"карыстальнік або \\\"госць\\\". Прэтэнзія павінна ўтрымліваць спіс, і калі карыстальнік належыць да некалькіх груп, праграма прызначыць ролю, якая адпавядае найвышэйшаму ўзроўню доступу. Калі ніводная група не супадае, доступ будзе забаронены.\",\n  \"LabelOpenRSSFeed\": \"Адкрыць RSS-стужку\",\n  \"LabelOverwrite\": \"Перазапісаць\",\n  \"LabelPaginationPageXOfY\": \"Старонка {0} з {1}\",\n  \"LabelPassword\": \"Пароль\",\n  \"LabelPath\": \"Шлях\",\n  \"LabelPermanent\": \"Пастаянны\",\n  \"LabelPermissionsAccessAllLibraries\": \"Мае доступ да ўсіх бібліятэк\",\n  \"LabelPermissionsAccessAllTags\": \"Мае доступ да ўсіх тэгаў\",\n  \"LabelPermissionsAccessExplicitContent\": \"Мае доступ да непрыстойнага змесціва\",\n  \"LabelPermissionsCreateEreader\": \"Можа ствараць прыладу для чытання\",\n  \"LabelPermissionsDelete\": \"Можа выдаляць\",\n  \"LabelPermissionsDownload\": \"Можа спампоўваць\",\n  \"LabelPermissionsUpdate\": \"Можа абнаўляць\",\n  \"LabelPermissionsUpload\": \"Можа запампоўваць\",\n  \"LabelPersonalYearReview\": \"Вынікі года ({0})\",\n  \"LabelPhotoPathURL\": \"Шлях/URL-адрас фота\",\n  \"LabelPlayMethod\": \"Метад прайгравання\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Павелічэнне/памяншэнне хуткасці прайгравання\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} з {1}\",\n  \"LabelPlaylists\": \"Плэй-лісты\",\n  \"LabelPodcast\": \"Падкаст\",\n  \"LabelPodcastSearchRegion\": \"Рэгіён пошуку падкастаў\",\n  \"LabelPodcastType\": \"Тып падкаста\",\n  \"LabelPodcasts\": \"Падкасты\",\n  \"LabelPort\": \"Порт\",\n  \"LabelPrefixesToIgnore\": \"Прэфіксы, якія трэба ігнараваць (без уліку рэгістра)\",\n  \"LabelPreventIndexing\": \"Прадухіліць індэксацыю вашай стужкі каталогамі падкастаў iTunes і Google\",\n  \"LabelPrimaryEbook\": \"Асноўная электронная кніга\",\n  \"LabelProgress\": \"Прагрэс\",\n  \"LabelProvider\": \"Пастаўшчык\",\n  \"LabelProviderAuthorizationValue\": \"Значэнне загалоўка аўтарызацыі\",\n  \"LabelPubDate\": \"Дата публікацыі\",\n  \"LabelPublishYear\": \"Год публікацыі\",\n  \"LabelPublishedDate\": \"Апублікавана {0}\",\n  \"LabelPublishedDecade\": \"Дзесяцігоддзе публікацыі\",\n  \"LabelPublishedDecades\": \"Дзесяцігоддзі публікацыі\",\n  \"LabelPublisher\": \"Выдавец\",\n  \"LabelPublishers\": \"Выдаўцы\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Карыстальніцкая электронная пошта ўладальніка\",\n  \"LabelRSSFeedCustomOwnerName\": \"Карыстальніцкае імя ўладальніка\",\n  \"LabelRSSFeedOpen\": \"RSS-стужка адкрыта\",\n  \"LabelRSSFeedPreventIndexing\": \"Прадухіліць індэксацыю\",\n  \"LabelRSSFeedSlug\": \"Ідэнтыфікатар RSS-стужкі\",\n  \"LabelRSSFeedURL\": \"URL RSS-стужкі\",\n  \"LabelRandomly\": \"Выпадкова\",\n  \"LabelReAddSeriesToContinueListening\": \"Дадаць серыю зноў у Працягнуць праслухоўванне\",\n  \"LabelRead\": \"Чытаць\",\n  \"LabelReadAgain\": \"Чытаць зноў\",\n  \"LabelReadEbookWithoutProgress\": \"Чытаць электронную кнігу без захавання прагрэсу\",\n  \"LabelRecentSeries\": \"Апошнія серыі\",\n  \"LabelRecentlyAdded\": \"Нядаўна дададзеныя\",\n  \"LabelRecommended\": \"Рэкамендаваныя\",\n  \"LabelRedo\": \"Узнавіць\",\n  \"LabelRegion\": \"Рэгіён\",\n  \"LabelReleaseDate\": \"Дата выпуску\",\n  \"LabelRemoveAllMetadataAbs\": \"Выдаліць усе файлы metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Выдаліць усе файлы metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Выдаляць уступленне і завяршэнне Audible з раздзелаў\",\n  \"LabelRemoveCover\": \"Выдаліць вокладку\",\n  \"LabelRemoveMetadataFile\": \"Выдаліць файлы метаданых у папках элементаў бібліятэкі\",\n  \"LabelRemoveMetadataFileHelp\": \"Выдаліць усе файлы metadata.json і metadata.abs у {0} папках.\",\n  \"LabelRowsPerPage\": \"Радкоў на старонку\",\n  \"LabelSearchTerm\": \"Пошукавы запыт\",\n  \"LabelSearchTitle\": \"Пошук па загалоўку\",\n  \"LabelSearchTitleOrASIN\": \"Пошук па загалоўку або ASIN\",\n  \"LabelSeason\": \"Сезон\",\n  \"LabelSeasonNumber\": \"Сезон #{0}\",\n  \"LabelSelectAll\": \"Выбраць усё\",\n  \"LabelSelectAllEpisodes\": \"Выбраць усе выпускі\",\n  \"LabelSelectEpisodesShowing\": \"Выбраць {0} выпускаў для паказу\",\n  \"LabelSelectUser\": \"Выберыце карыстальніка\",\n  \"LabelSelectUsers\": \"Выбраць карыстальнікаў\",\n  \"LabelSendEbookToDevice\": \"Адправіць электронную кнігу на...\",\n  \"LabelSequence\": \"Паслядоўнасць\",\n  \"LabelSerial\": \"Серыйны\",\n  \"LabelSeries\": \"Серыі\",\n  \"LabelSeriesName\": \"Назва серыі\",\n  \"LabelSeriesProgress\": \"Прагрэс серыі\",\n  \"LabelServerLogLevel\": \"Узровень журнала сервера\",\n  \"LabelServerYearReview\": \"Вынікі года сервера ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Зрабіць асноўным\",\n  \"LabelSetEbookAsSupplementary\": \"Зрабіць дадатковым\",\n  \"LabelSettingsAllowIframe\": \"Дазволіць убудоўванне ў iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Толькі аўдыякнігі\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Пры ўключэнні гэтай налады файлы электронных кніг будуць ігнаравацца, калі толькі яны не знаходзяцца ў папцы з аўдыякнігамі. У такім выпадку яны будуць пазначаны як дадатковыя электронныя кнігі.\",\n  \"LabelSettingsBookshelfViewHelp\": \"Рэалістычны дызайн з драўлянымі паліцамі\",\n  \"LabelSettingsChromecastSupport\": \"Падтрымка Chromecast\",\n  \"LabelSettingsDateFormat\": \"Фарматы даты\",\n  \"LabelSettingsEnableWatcher\": \"Аўтаматычна сачыць за зменамі ў бібліятэках\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Аўтаматычна сачыць за зменамі ў бібліятэцы\",\n  \"LabelSettingsEnableWatcherHelp\": \"Адключае аўтаматычнае дадаванне/абнаўленне элементаў пры выяўленні змен у файлах. *Патрабуецца перазапуск сервера\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Дазваляць скрыпты ў файлах EPUB\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Дазволіць файлам EPUB выконваць скрыпты. Рэкамендуецца пакінуць гэтую наладу выключанай, калі вы не давяраеце крыніцы файлаў EPUB.\",\n  \"LabelSettingsExperimentalFeatures\": \"Эксперыментальныя функцыі\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Функцыі ў распрацоўцы, для якіх вашы водгукі і дапамога ў тэставанні будуць карыснымі. Націсніце, каб адкрыць абмеркаванне на GitHub.\",\n  \"LabelSettingsFindCovers\": \"Шукаць вокладкі\",\n  \"LabelSettingsFindCoversHelp\": \"Калі ў вашай аўдыякнізе няма ўбудаванай вокладкі або відарыса вокладкі ў папцы, сканер паспрабуе знайсці вокладку.<br>Заўвага: гэта павялічыць час сканіравання\",\n  \"LabelSettingsHideSingleBookSeries\": \"Схаваць серыі з адной кнігай\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Серыі, якія змяшчаюць толькі адну кнігу, будуць схаваны са старонкі серый і паліц на галоўнай старонцы.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Кніжныя паліцы на галоўнай старонцы\",\n  \"LabelSettingsLibraryBookshelfView\": \"Кніжныя паліцы ў бібліятэцы\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Працэнт завяршэння большы за\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Час, што застаўся, меншы за (секунд)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Пазначыць элемент медыя як завершаны, калі\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Прапусціць папярэднія кнігі ў \\\"Працягнуць серыю\\\"\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Паліца \\\"Працягнуць серыю\\\" на галоўнай старонцы паказвае першую не пачатую кнігу ў серыях, дзе завершана хаця б адна кніга і няма кніг у працэсе чытання. Уключэнне гэтай налады дазволіць працягваць серыю з самай апошняй завершанай кнігі замест першай не пачатай.\",\n  \"LabelSettingsParseSubtitles\": \"Аналізаваць падзагалоўкі\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Выдзяляць падзагаловак з назваў папак аўдыякніг.<br>Падзагаловак павінен быць аддзелены сімвалам \\\" - \\\".<br>Напрыклад, \\\"Назва кнігі - Падзагаловак тут\\\" мае падзагаловак \\\"Падзагаловак тут\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Аддаваць перавагу супадаючым метаданым\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Супадаючыя даныя будуць замяняць звесткі элемента пры выкарыстанні функцыі Хуткі пошук. Прадвызначана Хуткі пошук запаўняе толькі адсутныя звесткі.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Прапусціць параўнанне кніг, якія ўжо маюць ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Прапусціць параўнанне кніг, якія ўжо маюць ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ігнараваць прэфіксы пры сартаванні\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"напрыклад, для прэфікса \\\"the\\\" загаловак кнігі \\\"The Book Title\\\" будзе сартавацца як \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Выкарыстоўваць квадратныя вокладкі кніг\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Аддаваць перавагу квадратным вокладкам замест стандартных вокладак з суадносінамі бакоў 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Захоўваць вокладкі з элементам\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Прадвызначана вокладкі захоўваюцца ў /metadata/items, уключэнне гэтай опцыі забяспечыць захоўванне вокладак у папцы элемента бібліятэкі. Захоўвацца будзе толькі адзін файл з назвай \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Захоўваць метаданыя з элементам\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Прадвызначана метаданыя захоўваюцца ў /metadata/items. Пры ўключэнні гэтай опцыі файлаў метаданых будуць захоўвацца ў папках элементаў бібліятэкі\",\n  \"LabelSettingsTimeFormat\": \"Фармат часу\",\n  \"LabelShare\": \"Абагуліць\",\n  \"LabelShareDownloadableHelp\": \"Дазваляе карыстальнікам, якія маюць спасылку, спампоўваць ZIP-архіў элемента бібліятэкі.\",\n  \"LabelShareOpen\": \"Абагульванне адкрыта\",\n  \"LabelShareURL\": \"URL-адрас для абагульвання\",\n  \"LabelShowAll\": \"Паказаць усё\",\n  \"LabelShowSeconds\": \"Паказваць секунды\",\n  \"LabelShowSubtitles\": \"Паказаць падзагалоўкі\",\n  \"LabelSize\": \"Памер\",\n  \"LabelSleepTimer\": \"Таймер сну\",\n  \"LabelSlug\": \"Ідэнтыфікатар\",\n  \"LabelSortAscending\": \"Па ўзрастанні\",\n  \"LabelSortDescending\": \"Па ўбыванні\",\n  \"LabelSortPubDate\": \"Сартаваць па даце публікацыі\",\n  \"LabelStart\": \"Пачаць\",\n  \"LabelStartTime\": \"Час пачатку\",\n  \"LabelStarted\": \"Пачата\",\n  \"LabelStartedAt\": \"Пачата ў\",\n  \"LabelStartedDate\": \"Пачата {0}\",\n  \"LabelStatsAudioTracks\": \"Аўдыятрэкаў\",\n  \"LabelStatsAuthors\": \"Аўтараў\",\n  \"LabelStatsBestDay\": \"Найлепшы дзень\",\n  \"LabelStatsDailyAverage\": \"У сярэднім за дзень\",\n  \"LabelStatsDays\": \"Дзён\",\n  \"LabelStatsDaysListened\": \"Дзён праслухана\",\n  \"LabelStatsHours\": \"Гадзін\",\n  \"LabelStatsInARow\": \"без перапынку\",\n  \"LabelStatsItemsFinished\": \"Элементаў завершана\",\n  \"LabelStatsItemsInLibrary\": \"Элементаў у бібліятэцы\",\n  \"LabelStatsMinutes\": \"хвілін\",\n  \"LabelStatsMinutesListening\": \"Хвілін праслухоўвання\",\n  \"LabelStatsOverallDays\": \"Агульная колькасць дзён\",\n  \"LabelStatsOverallHours\": \"Агульная колькасць гадзін\",\n  \"LabelStatsWeekListening\": \"Праслухана за тыдзень\",\n  \"LabelSubtitle\": \"Падзагаловак\",\n  \"LabelSupportedFileTypes\": \"Падтрымліваюцца тыпы файлаў\",\n  \"LabelTag\": \"Метка\",\n  \"LabelTags\": \"Меткі\",\n  \"LabelTagsAccessibleToUser\": \"Меткі, даступныя карыстальніку\",\n  \"LabelTagsNotAccessibleToUser\": \"Меткі, недаступныя карыстальніку\",\n  \"LabelTasks\": \"Запушчаныя задачы\",\n  \"LabelTextEditorBulletedList\": \"Маркіраваны спіс\",\n  \"LabelTextEditorLink\": \"Спасылка\",\n  \"LabelTextEditorNumberedList\": \"Нумараваны спіс\",\n  \"LabelTextEditorUnlink\": \"Адключыць спасылку\",\n  \"LabelTheme\": \"Тэма\",\n  \"LabelThemeDark\": \"Цёмная\",\n  \"LabelThemeLight\": \"Светлая\",\n  \"LabelThemeSepia\": \"Сепія\",\n  \"LabelTimeBase\": \"Часавая база\",\n  \"LabelTimeDurationXHours\": \"{0} гадзін\",\n  \"LabelTimeDurationXMinutes\": \"{0} хвілін\",\n  \"LabelTimeDurationXSeconds\": \"{0} секунд\",\n  \"LabelTimeInMinutes\": \"Час у хвілінах\",\n  \"LabelTimeLeft\": \"{0} засталося\",\n  \"LabelTimeListened\": \"Час праслухоўвання\",\n  \"LabelTimeListenedToday\": \"Час праслухоўвання сёння\",\n  \"LabelTimeRemaining\": \"Засталося {0}\",\n  \"LabelTimeToShift\": \"Час зрушэння ў секундах\",\n  \"LabelTitle\": \"Загаловак\",\n  \"LabelToolsEmbedMetadata\": \"Убудаваць метаданыя\",\n  \"LabelToolsEmbedMetadataDescription\": \"Убудаваць метаданыя ў аўдыяфайлы, уключаючы відарыс вокладкі і раздзелы.\",\n  \"LabelToolsM4bEncoder\": \"Кадавальнік M4B\",\n  \"LabelToolsMakeM4b\": \"Стварыць файл аўдыякнігі M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Стварыць аўдыякнігу ў фармаце .M4B з убудаванымі метаданымі, відарысам вокладкі і раздзеламі.\",\n  \"LabelToolsSplitM4b\": \"Падзяліць M4B на MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Стварэнне MP3 з M4B, падзеленага па раздзелах, з убудаванымі метаданымі, відарысам вокладкі і раздзеламі.\",\n  \"LabelTotalDuration\": \"Агульная працягласць\",\n  \"LabelTotalTimeListened\": \"Агульны час праслухоўвання\",\n  \"LabelTrackFromFilename\": \"Трэк з файла\",\n  \"LabelTrackFromMetadata\": \"Трэк з метаданых\",\n  \"LabelTracks\": \"Трэкі\",\n  \"LabelTracksMultiTrack\": \"Некалькі трэкаў\",\n  \"LabelTracksNone\": \"Няма трэкаў\",\n  \"LabelTracksSingleTrack\": \"Адзін трэк\",\n  \"LabelTrailer\": \"Трэйлер\",\n  \"LabelType\": \"Тып\",\n  \"LabelUnabridged\": \"Поўная версія\",\n  \"LabelUndo\": \"Адрабіць\",\n  \"LabelUnknown\": \"Невядома\",\n  \"LabelUnknownPublishDate\": \"Невядомая дата публікацыі\",\n  \"LabelUpdateCover\": \"Абнавіць вокладку\",\n  \"LabelUpdateCoverHelp\": \"Дазволіць замену існуючых вокладак для выбраных кніг пры выяўленні адпаведнасці\",\n  \"LabelUpdateDetails\": \"Абнавіць падрабязнасці\",\n  \"LabelUpdateDetailsHelp\": \"Дазволіць замену існуючых падрабязнасцей для выбраных кніг пры выяўленні адпаведнасці\",\n  \"LabelUpdatedAt\": \"Абноўлена ў\",\n  \"LabelUploaderDragAndDrop\": \"Перацягніце файлы або папкі\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Перацягвайце і скідайце файлы\",\n  \"LabelUploaderDropFiles\": \"Скідайце файлы\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Аўтаматычна атрымліваць загаловак, аўтара і серыю\",\n  \"LabelUseAdvancedOptions\": \"Выкарыстоўваць пашыраныя параметры\",\n  \"LabelUseChapterTrack\": \"Выкарыстоўваць трэк раздзела\",\n  \"LabelUseFullTrack\": \"Выкарыстоўваць увесь трэк\",\n  \"LabelUseZeroForUnlimited\": \"0 – неабмежавана\",\n  \"LabelUser\": \"Карыстальнік\",\n  \"LabelUsername\": \"Імя карыстальніка\",\n  \"LabelValue\": \"Значэнне\",\n  \"LabelVersion\": \"Версія\",\n  \"LabelViewBookmarks\": \"Праглядзець закладкі\",\n  \"LabelViewChapters\": \"Праглядзець раздзелы\",\n  \"LabelViewPlayerSettings\": \"Праглядзець налады прайгравальніка\",\n  \"LabelViewQueue\": \"Праглядзець чаргу прайгравальніка\",\n  \"LabelVolume\": \"Гучнасць\",\n  \"LabelWebRedirectURLsDescription\": \"Аўтарызуйце гэтыя URL-адрасы ў вашым пастаўшчыку OAuth для перанакіравання ў вэб-праграму пасля ўваходу:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Падпапка для URL-адрасоў перанакіравання\",\n  \"LabelWeekdaysToRun\": \"Дні тыдня для запуску\",\n  \"LabelXBooks\": \"{0} кніг\",\n  \"LabelXItems\": \"{0} элементаў\",\n  \"LabelYearReviewHide\": \"Схаваць вынікі года\",\n  \"LabelYearReviewShow\": \"Азнаёміцца з вынікамі года\",\n  \"LabelYourAudiobookDuration\": \"Працягласць аўдыякнігі\",\n  \"LabelYourBookmarks\": \"Вашы закладкі\",\n  \"LabelYourPlaylists\": \"Вашы плэй-лісты\",\n  \"LabelYourProgress\": \"Ваш прагрэс\",\n  \"MessageAddToPlayerQueue\": \"Дадаць у чаргу прайгравальніка\",\n  \"MessageAppriseDescription\": \"Каб выкарыстоўваць гэтую функцыю, вам спатрэбіцца запусціць асобнік <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> або API, які будзе апрацоўваць тыя ж запыты.<br />URL Apprise API павінен быць поўным шляхам для адпраўкі апавяшчэння, напрыклад, калі ваш API працуе па адрасе <code>http://192.168.1.1:8337</code>, то вы павінны ўвесці <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Пераканайцеся, што выкарыстоўваеце ASIN з правільнага рэгіёна Audible, а не Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Састарэлыя токены API будуць выдалены ў будучыні. Замест іх выкарыстоўвайце <a href=\\\"/config/api-keys\\\">ключы API</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Перазапусціце сервер пасля захавання, каб прымяніць змены OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"Дзеля бяспекі была палепшана аўтэнтыфікацыя. Усім карыстальнікам трэба паўторна ўвайсці ў сістэму.\",\n  \"MessageBackupsDescription\": \"Рэзервовыя копіі ўключаюць карыстальнікаў, іх прагрэс, падрабязнасці элементаў бібліятэкі, налады сервера і відарысы, якія захоўваюцца ў <code>/metadata/items</code> і <code>/metadata/authors</code>. Рэзервовыя копіі <strong>не</strong> ўключаюць файлы, якія захоўваюцца ў папках бібліятэкі.\",\n  \"MessageBackupsLocationEditNote\": \"Заўвага: Абнаўленне месцазнаходжання рэзервовых копій не перамяшчае і не змяняе існуючыя рэзервовыя копіі\",\n  \"MessageBackupsLocationNoEditNote\": \"Заўвага: Размяшчэнне рэзервовых копій задаецца праз зменную асяроддзя і не можа быць зменена тут.\",\n  \"MessageBackupsLocationPathEmpty\": \"Шлях да месцазнаходжання рэзервовых копій не можа быць пустым\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Запоўніце ўключаныя палі данымі з усіх элементаў. Палі з некалькімі значэннямі будуць аб'яднаны\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Запоўніце ўключаныя палі падрабязнасцей карты данымі з гэтага элемента\",\n  \"MessageBatchQuickMatchDescription\": \"Хуткі пошук паспрабуе дадаць адсутныя вокладкі і метаданыя для выбраных элементаў. Уключыце параметры ніжэй, каб дазволіць Хуткаму пошуку замяняць існуючыя вокладкі і/або метаданыя.\",\n  \"MessageBookshelfNoCollections\": \"Вы пакуль не стварылі ніводнай калекцый\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Калекцыі публічныя. Усе карыстальнікі, якія маюць доступ да бібліятэкі, могуць іх бачыць.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Няма адкрытых RSS-стужак\",\n  \"MessageBookshelfNoResultsForFilter\": \"Няма вынікаў для фільтра \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Няма вынікаў па запыце\",\n  \"MessageBookshelfNoSeries\": \"У вас няма серый\",\n  \"MessageBulkChapterPattern\": \"Колькі раздзелаў вы хочаце дадаць з дапамогай гэтага ўзору нумарацыі?\",\n  \"MessageChapterEndIsAfter\": \"Канец раздзела ідзе пасля канца аўдыякнігі\",\n  \"MessageChapterErrorFirstNotZero\": \"Першы раздзел павінен пачынацца з 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Няправільны час пачатку: ён павінен быць меншым за працягласць аўдыякнігі\",\n  \"MessageChapterErrorStartLtPrev\": \"Няправільны час пачатку: ён павінен быць большым або роўным часу пачатку папярэдняга раздзела\",\n  \"MessageChapterStartIsAfter\": \"Пачатак раздзела ідзе пасля канца аўдыякнігі\",\n  \"MessageChaptersNotFound\": \"Раздзелы не знойдзены\",\n  \"MessageCheckingCron\": \"Праверка cron...\",\n  \"MessageConfirmCloseFeed\": \"Вы ўпэўнены, што хочаце закрыць гэту стужку?\",\n  \"MessageConfirmDeleteApiKey\": \"Вы ўпэўнены, што хочаце выдаліць ключ API \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Вы ўпэўнены, што хочаце выдаліць рэзервовую копію для {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Вы ўпэўнены, што хочаце выдаліць прыладу для чытання \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Будзе выдалены файл з файлавай сістэмы. Вы ўпэўнены?\",\n  \"MessageConfirmDeleteLibrary\": \"Вы ўпэўнены, што хочаце назаўжды выдаліць бібліятэку \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Будзе выдалены элемент бібліятэкі з базы даных і файлавай сістэмы. Вы ўпэўнены?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Будзе выдалена {0} элементаў бібліятэкі з базы даных і файлавай сістэмы. Вы ўпэўнены?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Вы ўпэўнены, што хочаце выдаліць карыстальніцкага пастаўшчыка метаданых \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Вы ўпэўнены, што хочаце выдаліць гэта апавяшчэнне?\",\n  \"MessageConfirmDeleteSession\": \"Вы ўпэўнены, што хочаце выдаліць гэты сеанс?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Вы ўпэўнены, што хочаце ўбудаваць метаданыя ў {0} аўдыяфайлаў?\",\n  \"MessageConfirmForceReScan\": \"Вы ўпэўнены, што хочаце прымусова паўторна сканіраваць?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Вы ўпэўнены, што хочаце пазначыць усе выпускі як завершаныя?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Вы ўпэўнены, што хочаце пазначыць усе выпускі як незавершаныя?\",\n  \"MessageConfirmMarkItemFinished\": \"Вы ўпэўнены, што хочаце пазначыць \\\"{0}\\\" як завершаны?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Вы ўпэўнены, што хочаце пазначыць \\\"{0}\\\" як незавершаны?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Вы ўпэўнены, што хочаце пазначыць усе кнігі ў гэтай серыі як завершаныя?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Вы ўпэўнены, што хочаце пазначыць усе кнігі ў гэтай серыі як незавершаныя?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Уключыць гэта апавяшчэнне з тэставымі данымі?\",\n  \"MessageConfirmPurgeCache\": \"Ачышчэнне кэшу выдаліць увесь каталог па адрасе <code>/metadata/cache</code>. <br /><br />Вы ўпэўнены, што хочаце выдаліць каталог кэшу?\",\n  \"MessageConfirmPurgeItemsCache\": \"Ачышчэнне кэшу элементаў выдаліць увесь каталог па адрасе <code>/metadata/cache/items</code>.<br />Вы ўпэўнены?\",\n  \"MessageConfirmQuickEmbed\": \"Увага! Хуткае ўбудаванне не стварае рэзервовых копій аўдыяфайлаў. Пераканайцеся, што ў вас ёсць рэзервовая копія аўдыяфайлаў. <br><br>Хочаце працягнуць?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Хуткае супадзенне выпускаў перазапіша дэталі, калі супадзенне будзе знойдзена. Будуць абноўлены толькі выпускі, якія не супадаюць. Вы ўпэўнены?\",\n  \"MessageConfirmReScanLibraryItems\": \"Вы ўпэўнены, што хочаце паўторна сканіраваць {0} элементаў?\",\n  \"MessageConfirmRemoveAllChapters\": \"Вы ўпэўнены, што хочаце выдаліць усе раздзелы?\",\n  \"MessageConfirmRemoveAuthor\": \"Вы ўпэўнены, што хочаце выдаліць аўтара \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Вы ўпэўнены, што хочаце выдаліць калекцыю \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Вы ўпэўнены, што хочаце выдаліць выпуск \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Заўвага: Аўдыяфайл не будзе выдалены, калі не ўключыць параметр \\\"Жорстка выдаляць файл\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Вы ўпэўнены, што хочаце выдаліць {0} выпускаў?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Вы ўпэўнены, што хочаце выдаліць {0} сеансаў праслухоўвання?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Вы ўпэўнены, што хочаце выдаліць усе файлы metadata.{0} у папках элементаў бібліятэкі?\",\n  \"MessageConfirmRemoveNarrator\": \"Вы ўпэўнены, што хочаце выдаліць дыктара \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Вы ўпэўнены, што хочаце выдаліць плэй-ліст \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Вы ўпэўнены, што хочаце перайменаваць жанр \\\"{0}\\\" на \\\"{1}\\\" для ўсіх элементаў?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Заўвага: Гэты жанр ужо існуе, таму яны будуць аб'яднаны.\",\n  \"MessageConfirmRenameGenreWarning\": \"Увага! Падобны жанр з іншым рэгістрам літар ужо існуе — \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Вы ўпэўнены, што хочаце перайменаваць тэг \\\"{0}\\\" на \\\"{1}\\\" для ўсіх элементаў?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Заўвага: Гэты тэг ужо існуе, таму яны будуць аб'яднаны.\",\n  \"MessageConfirmRenameTagWarning\": \"Увага! Падобны тэг з іншым рэгістрам ужо існуе: \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Вы ўпэўнены, што хочаце скінуць свой прагрэс?\",\n  \"MessageConfirmSendEbookToDevice\": \"Вы ўпэўнены, што хочаце адправіць {0} электронную кнігу \\\"{1}\\\" на прыладу \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Вы ўпэўнены, што хочаце адвязаць гэтага карыстальніка ад OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} дзён праслухоўвання за апошні год\",\n  \"MessageDownloadingEpisode\": \"Спампоўванне выпуску\",\n  \"MessageDragFilesIntoTrackOrder\": \"Перацягніце файлы ў правільным парадку трэкаў\",\n  \"MessageEmbedFailed\": \"Не ўдалося ўбудаваць!\",\n  \"MessageEmbedFinished\": \"Убудаванне завершана!\",\n  \"MessageEmbedQueue\": \"У чарзе на ўбудаванне метаданых (у чарзе {0})\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} выпуск(-аў) у чарзе спампоўвання\",\n  \"MessageEreaderDevices\": \"Каб забяспечыць дастаўку электронных кніг, вам можа спатрэбіцца дадаць вышэйзгаданы адрас электроннай пошты як дазволенага адпраўніка для кожнай прылады, пералічанай ніжэй.\",\n  \"MessageFeedURLWillBe\": \"URL стужкі будзе {0}\",\n  \"MessageFetching\": \"Атрыманне...\",\n  \"MessageForceReScanDescription\": \"прасканіруе ўсе файлы зноў, як пры новым сканаванні. Тэгі ID3 аўдыёфайлаў, файлы OPF і тэкставыя файлы будуць сканіравацца як новыя.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} праслухана</strong> on {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Няма сеансаў праслухоўвання на {0}\",\n  \"MessageImportantNotice\": \"Важная заўвага!\",\n  \"MessageInsertChapterBelow\": \"Уставіць раздзел ніжэй\",\n  \"MessageInvalidAsin\": \"Няправільны ASIN\",\n  \"MessageItemsSelected\": \"Выбрана элементаў: {0}\",\n  \"MessageItemsUpdated\": \"{0} элементаў абноўлена\",\n  \"MessageJoinUsOn\": \"Далучайцеся да нас у\",\n  \"MessageLoading\": \"Загрузка...\",\n  \"MessageLoadingFolders\": \"Загрузка папак...\",\n  \"MessageLogsDescription\": \"Журналы захоўваюцца ў каталогу <code>/metadata/logs</code> у фармаце JSON. Журналы памылак захоўваюцца ў файле <code>/metadata/logs/crashlogs.txt</code>.\",\n  \"MessageM4BFailed\": \"Памылка M4B!\",\n  \"MessageM4BFinished\": \"M4B завершана!\",\n  \"MessageMapChapterTitles\": \"Супаставіць загалоўкі раздзелаў з існуючымі раздзеламі аўдыякнігі без змянення пазнак часу\",\n  \"MessageMarkAllEpisodesFinished\": \"Пазначыць усе выпускі як завершаныя\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Пазначыць усе выпускі як незавершаныя\",\n  \"MessageMarkAsFinished\": \"Пазначыць як завершаную\",\n  \"MessageMarkAsNotFinished\": \"Пазначыць як незавершаную\",\n  \"MessageMatchBooksDescription\": \"паспрабуе параўнаць кнігі ў бібліятэцы з кнігай ад выбранай пошукавай сістэмы і запоўніць пустыя палі і вокладку. Не перазапісвае звесткі.\",\n  \"MessageNoAudioTracks\": \"Няма аўдыятрэкаў\",\n  \"MessageNoAuthors\": \"Няма аўтараў\",\n  \"MessageNoBackups\": \"Няма рэзервовых копій\",\n  \"MessageNoBookmarks\": \"Няма закладак\",\n  \"MessageNoChapters\": \"Няма раздзелаў\",\n  \"MessageNoCollections\": \"Няма калекцый\",\n  \"MessageNoCoversFound\": \"Не знойдзена вокладак\",\n  \"MessageNoDescription\": \"Няма апісання\",\n  \"MessageNoDevices\": \"Няма прылад\",\n  \"MessageNoDownloadsInProgress\": \"Зараз няма актыўных спампованняў\",\n  \"MessageNoDownloadsQueued\": \"Няма спамповак у чарзе\",\n  \"MessageNoEpisodeMatchesFound\": \"Адпаведных выпускаў не знойдзена\",\n  \"MessageNoEpisodes\": \"Няма выпускаў\",\n  \"MessageNoFoldersAvailable\": \"Няма даступных папак\",\n  \"MessageNoGenres\": \"Няма жанраў\",\n  \"MessageNoIssues\": \"Няма праблем\",\n  \"MessageNoItems\": \"Няма элементаў\",\n  \"MessageNoItemsFound\": \"Элементы не знойдзены\",\n  \"MessageNoListeningSessions\": \"Няма сеансаў праслухоўвання\",\n  \"MessageNoLogs\": \"Няма журналаў\",\n  \"MessageNoMediaProgress\": \"Няма прагрэсу медыя\",\n  \"MessageNoNotifications\": \"Няма апавяшчэнняў\",\n  \"MessageNoPodcastFeed\": \"Няправільны падкаст: Няма стужкі\",\n  \"MessageNoPodcastsFound\": \"Падкасты не знойдзены\",\n  \"MessageNoResults\": \"Няма вынікаў\",\n  \"MessageNoSearchResultsFor\": \"Няма вынікаў пошуку па запыце \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Няма серый\",\n  \"MessageNoTags\": \"Няма тэгаў\",\n  \"MessageNoTasksRunning\": \"Няма запушчаных задач\",\n  \"MessageNoUpdatesWereNecessary\": \"Абнаўленні не патрабаваліся\",\n  \"MessageNoUserPlaylists\": \"У вас няма плэй-лістоў\",\n  \"MessageNoUserPlaylistsHelp\": \"Плэй-лісты прыватныя. Толькі карыстальнік, які іх стварыў, можа іх бачыць.\",\n  \"MessageNotYetImplemented\": \"Пакуль не рэалізавана\",\n  \"MessageOpmlPreviewNote\": \"Заўвага: гэта перадпрагляд прааналізаванага файла OPML. Фактычны загаловак падкаста будзе ўзяты з RSS-стужкі.\",\n  \"MessageOr\": \"або\",\n  \"MessagePauseChapter\": \"Прыпыніць прайграванне раздзела\",\n  \"MessagePlayChapter\": \"Паслухаць пачатак раздзела\",\n  \"MessagePlaylistCreateFromCollection\": \"Стварыць плэй-ліст з калекцыі\",\n  \"MessagePleaseWait\": \"Пачакайце...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"У падкаста няма URL RSS-стужкі для супадзення\",\n  \"MessagePodcastSearchField\": \"Увядзіце пошукавы запыт або URL RSS-стужкі\",\n  \"MessageQuickEmbedInProgress\": \"Выконваецца хуткае ўбудаванне\",\n  \"MessageQuickEmbedQueue\": \"Пастаўлена ў чаргу для хуткага ўбудавання ({0} у чарзе)\",\n  \"MessageQuickMatchAllEpisodes\": \"Хуткае параўнанне ўсіх выпускаў\",\n  \"MessageQuickMatchDescription\": \"Запоўніць пустыя звесткі элемента і вокладку першым вынікам супадзення з '{0}'. Не замяняе звесткіі, калі параметр \\\"Аддаваць перавагу супадаючым метаданым\\\" на серверы не ўключана.\",\n  \"MessageRemoveChapter\": \"Выдаліць раздзел\",\n  \"MessageRemoveEpisodes\": \"Выдаліць выпускі ({0})\",\n  \"MessageRemoveFromPlayerQueue\": \"Выдаліць з чаргі прагравання\",\n  \"MessageRemoveUserWarning\": \"Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на\",\n  \"MessageResetChaptersConfirm\": \"Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?\",\n  \"MessageRestoreBackupConfirm\": \"Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную\",\n  \"MessageRestoreBackupWarning\": \"Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.\",\n  \"MessageScheduleLibraryScanNote\": \"Большасці карыстальнікаў рэкамендуецца не выключаць гэтую функцыю і пакідаць уключанай наладу \\\"Аўтаматычна сачыць за зменамі ў бібліятэцы\\\" — яна будзе аўтаматычна выяўляць змены ў папках бібліятэкі. Уключыце гэту функцыю, калі \\\"Аўтаматычна сачыць за зменамі ў бібліятэцы\\\" не працуе для вашай файлавай сістэмы (напрыклад, NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Выконваць кожныя {0} у {1}\",\n  \"MessageSearchResultsFor\": \"Вынікі пошуку для\",\n  \"MessageSelected\": \"Выбрана: {0}\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Паслядоўнасць серый не можа ўтрымліваць прабелы\",\n  \"MessageServerCouldNotBeReached\": \"Сервер недаступны\",\n  \"MessageSetChaptersFromTracksDescription\": \"Задаць раздзелы, выкарыстоўваючы кожны аўдыяфайл у якасці раздзела і назву аўдыяфайла ў якасці загалоўка раздзела\",\n  \"MessageShareExpirationWillBe\": \"Тэрмін дзеяння будзе <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Тэрмін дзеяння заканчваецца праз {0}\",\n  \"MessageShareURLWillBe\": \"URL-адрас для абагульвання будзе <strong>{0</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Пачаць прайграванне для \\\"{0}\\\" з {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Аўдыяфайл \\\"{0}\\\" недаступны для запісу\",\n  \"MessageTaskCanceledByUser\": \"Задача скасавана карыстальнікам\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Спампоўванне выпуску \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Убудаванне метаданых\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Убудаванне метаданых у аўдыякнігу \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Кадаванне M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Кадаванне аўдыякнігі \\\"{0}\\\" у адзін файл m4b\",\n  \"MessageTaskFailed\": \"Не ўдалося\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Не ўдалося зрабіць рэзервовую копію аўдыяфайла \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Не ўдалося стварыць каталог кэшу\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Не ўдалося ўбудаваць метаданыя ў файл \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Не ўдалося аб’яднаць аўдыяфайлы\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Не ўдалося перамясціць файл m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Не ўдалося захаваць файл метаданых\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Пошук супадзенняў кніг у бібліятэцы \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Няма файлаў для сканавання\",\n  \"MessageTaskOpmlImport\": \"Імпарт OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Стварэнне падкастаў з {0} RSS-стужак\",\n  \"MessageTaskOpmlImportFeed\": \"Імпарт стужкі OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Імпартаванне RSS-стужкі \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Не ўдалося атрымаць стужку падкаста\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Стварэнне падкаста \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Падкаст ужо існуе па гэтым шляху\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Не ўдалося стварыць падкаст\",\n  \"MessageTaskOpmlImportFinished\": \"Дададзена {0} падкастаў\",\n  \"MessageTaskOpmlParseFailed\": \"Не ўдалося прааналізаваць файл OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"Памылковы файл OPML: тэг <opml> не знойдзены АБО тэг <outline> не знойдзены\",\n  \"MessageTaskOpmlParseNoneFound\": \"У файле OPML не знойдзена стужак\",\n  \"MessageTaskScanItemsAdded\": \"{0} дададзена\",\n  \"MessageTaskScanItemsMissing\": \"{0} адсутнічае\",\n  \"MessageTaskScanItemsUpdated\": \"{0} абноўлена\",\n  \"MessageTaskScanNoChangesNeeded\": \"Змены не патрабуюцца\",\n  \"MessageTaskScanningFileChanges\": \"Сканіраванне змяненняў у файле \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Сканіраванне бібліятэкі \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Мэтавы каталог недаступны для запісу\",\n  \"MessageThinking\": \"Думаю...\",\n  \"MessageUploaderItemFailed\": \"Не ўдалося запампаваць\",\n  \"MessageUploaderItemSuccess\": \"Паспяхова запампавана!\",\n  \"MessageUploading\": \"Запампоўванне...\",\n  \"MessageValidCronExpression\": \"Карэктны выраз cron\",\n  \"MessageWatcherIsDisabledGlobally\": \"Адсочванне змен у папках адключана глабальна ў наладах сервера\",\n  \"MessageXLibraryIsEmpty\": \"{0} Бібліятэка пустая!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Працягласць аўдыякнігі большая за знойдзеную працягласць\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Працягласць аўдыякнігі карацейшая за знойдзеную працягласць\",\n  \"NoteChangeRootPassword\": \"Толькі карыстальнік root можа мець пусты пароль\",\n  \"NoteChapterEditorTimes\": \"Заўвага: Час пачатку першага раздзела павінен заставацца 0:00, а час пачатку апошняга раздзела не можа перавышаць працягласць гэтай аўдыякнігі.\",\n  \"NoteFolderPicker\": \"Заўвага: ужо супастаўленыя папкі адлюстроўвацца не будуць\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Папярэджанне: адзін ці больш выпускаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Папкі з медыяфайламі будуць апрацоўвацца як асобныя элементы бібліятэкі.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Пры запампоўванні толькі аўдыяфайлаў кожны аўдыяфайл будзе апрацоўвацца як асобная аўдыякніга.\",\n  \"NoteUploaderUnsupportedFiles\": \"Файлы, якія не падтрымліваюцца, ігнаруюцца. Пры выбары або выдаленні папкі іншыя файлы, якія не знаходзяцца ў папцы элемента, ігнаруюцца.\",\n  \"NotificationOnBackupCompletedDescription\": \"Спрацоўвае пасля завяршэння рэзервовага капіравання\",\n  \"NotificationOnBackupFailedDescription\": \"Спрацоўвае пры збоі рэзервовага капіравання\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Спрацоўвае, калі выпуск падкаста аўтаматычна спампоўваецца\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Спрацоўвае, калі аўтаматычнае спампоўванне выпускаў адключана з-за занадта вялікай колькасці няўдалых спроб\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Спрацоўвае, пры памылцы запыту RSS-стужкі для аўтаматычнага спампоўвання выпуску\",\n  \"NotificationOnTestDescription\": \"Падзея для тэсціравання сістэмы апавяшчэнняў\",\n  \"PlaceholderBulkChapterInput\": \"Увядзіце загаловак раздзела або выкарыстоўвайце нумарацыю (напрыклад, «Выпуск 1», «Раздзел 10», «1.»)\",\n  \"PlaceholderNewCollection\": \"Назва новай калекцыі\",\n  \"PlaceholderNewFolderPath\": \"Шлях да новай папкі\",\n  \"PlaceholderNewPlaylist\": \"Назва новага плэй-ліста\",\n  \"PlaceholderSearch\": \"Пошук..\",\n  \"PlaceholderSearchEpisode\": \"Пошук выпуску...\",\n  \"StatsAuthorsAdded\": \"дададзена аўтараў\",\n  \"StatsBooksAdded\": \"дададзена кніг\",\n  \"StatsBooksAdditional\": \"Некаторыя дапаўненні ўключаюць…\",\n  \"StatsBooksFinished\": \"завершана кніг\",\n  \"StatsBooksFinishedThisYear\": \"Некаторыя кнігі завершаны ў гэтым годзе…\",\n  \"StatsBooksListenedTo\": \"кніг праслухана\",\n  \"StatsCollectionGrewTo\": \"Ваша калекцыя кніг павялічылася да…\",\n  \"StatsSessions\": \"сеансаў\",\n  \"StatsSpentListening\": \"праслухана\",\n  \"StatsTopAuthor\": \"ТОП АЎТАР\",\n  \"StatsTopAuthors\": \"ТОП АЎТАРЫ\",\n  \"StatsTopGenre\": \"ТОП ЖАНР\",\n  \"StatsTopGenres\": \"ТОП ЖАНРЫ\",\n  \"StatsTopMonth\": \"ТОП МЕСЯЦ\",\n  \"StatsTopNarrator\": \"ТОП ДЫКТАР\",\n  \"StatsTopNarrators\": \"ТОП ДЫКТАРЫ\",\n  \"StatsTotalDuration\": \"З агульнай працягласцю…\",\n  \"StatsYearInReview\": \"ВЫНІКІ ГОДА\",\n  \"ToastAccountUpdateSuccess\": \"Уліковы запіс абноўлены\",\n  \"ToastAppriseUrlRequired\": \"Неабходна ўвесці URL-адрас Apprise\",\n  \"ToastAsinRequired\": \"ASIN абавязковы\",\n  \"ToastAuthorImageRemoveSuccess\": \"Відарыс аўтара выдалены\",\n  \"ToastAuthorNotFound\": \"Аўтар \\\"{0}\\\" не знойдзены\",\n  \"ToastAuthorRemoveSuccess\": \"Аўтар выдалены\",\n  \"ToastAuthorSearchNotFound\": \"Аўтар не знойдзены\",\n  \"ToastAuthorUpdateMerged\": \"Аўтар аб'яднаны\",\n  \"ToastAuthorUpdateSuccess\": \"Аўтар абноўлены\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Аўтар абноўлены (відарыс не знойдзены)\",\n  \"ToastBackupAppliedSuccess\": \"Рэзервовая копія прыменена\",\n  \"ToastBackupCreateFailed\": \"Не ўдалося стварыць рэзервовую копію\",\n  \"ToastBackupCreateSuccess\": \"Рэзервовая копія створана\",\n  \"ToastBackupDeleteFailed\": \"Не ўдалося выдаліць рэзервовую копію\",\n  \"ToastBackupDeleteSuccess\": \"Рэзервовая копія выдалена\",\n  \"ToastBackupInvalidMaxKeep\": \"Няправільная колькасць рэзервовых копій для захоўвання\",\n  \"ToastBackupInvalidMaxSize\": \"Няправільны максімальны памер рэзервовай копіі\",\n  \"ToastBackupRestoreFailed\": \"Не ўдалося аднавіць рэзервовую копію\",\n  \"ToastBackupUploadFailed\": \"Не ўдалося запампаваць рэзервовую копію\",\n  \"ToastBackupUploadSuccess\": \"Рэзервовая копія запампавана\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Звесткі прыменены да элементаў\",\n  \"ToastBatchDeleteFailed\": \"Памылка групавога выдалення\",\n  \"ToastBatchDeleteSuccess\": \"Групавое выдаленне выканана\",\n  \"ToastBatchQuickMatchFailed\": \"Памылка групавога хуткага параўнання!\",\n  \"ToastBatchQuickMatchStarted\": \"Групавое хуткае параўнанне {0} кніг запушчана!\",\n  \"ToastBatchUpdateFailed\": \"Памылка групавога абнаўлення\",\n  \"ToastBatchUpdateSuccess\": \"Групавое абнаўленне выканана\",\n  \"ToastBookmarkCreateFailed\": \"Не ўдалося стварыць закладку\",\n  \"ToastBookmarkCreateSuccess\": \"Закладка дададзена\",\n  \"ToastBookmarkRemoveSuccess\": \"Закладка выдалена\",\n  \"ToastBulkChapterInvalidCount\": \"Увядзіце лік ад 1 да 150\",\n  \"ToastCachePurgeFailed\": \"Не ўдалося ачысціць кэш\",\n  \"ToastCachePurgeSuccess\": \"Кэш паспяхова ачышчаны\",\n  \"ToastChapterLocked\": \"Раздзел заблакіраваны.\",\n  \"ToastChapterStartTimeAdjusted\": \"Час пачатку раздзела адкарэктаваны на {0} секунд\",\n  \"ToastChaptersAllLocked\": \"Усе раздзелы заблакіраваны. Разблакіруйце некаторыя раздзелы, каб зрушыць іх час.\",\n  \"ToastChaptersHaveErrors\": \"Раздзелы маюць памылкі\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Памылковая велічыня зруху. Час пачатку апошняга раздзела перавышае працягласць гэтай аўдыякнігі.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Памылковая велічыня зруху. Першы раздзел будзе мець нулявую або адмоўную працягласць і будзе перазапісаны другім раздзелам. Павялічце пачатковую працягласць другога раздзела.\",\n  \"ToastChaptersMustHaveTitles\": \"Раздзелы павінны мець загалоўкі\",\n  \"ToastChaptersRemoved\": \"Раздзелы выдалены\",\n  \"ToastChaptersUpdated\": \"Раздзелы абноўлены\",\n  \"ToastCollectionItemsAddFailed\": \"Не ўдалося дадаць элемент(ы) у калекцыю\",\n  \"ToastCollectionRemoveSuccess\": \"Калекцыя выдалена\",\n  \"ToastCollectionUpdateSuccess\": \"Калекцыя абноўлена\",\n  \"ToastConnectionNotAvailable\": \"Падключэнне недаступна. Паспрабуйце яшчэ раз пазней\",\n  \"ToastCoverSearchFailed\": \"Не ўдалося знайсці вокладку\",\n  \"ToastCoverUpdateFailed\": \"Не ўдалося абнавіць вокладку\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Дата і час указаны некарэктна або не цалкам\",\n  \"ToastDeleteFileFailed\": \"Не ўдалося выдаліць файл\",\n  \"ToastDeleteFileSuccess\": \"Файл выдалены\",\n  \"ToastDeviceAddFailed\": \"Не ўдалося дадаць прыладу\",\n  \"ToastDeviceNameAlreadyExists\": \"Прылада для чытання электронных кніг з такой назвай ужо існуе\",\n  \"ToastDeviceTestEmailFailed\": \"Не ўдалося адправіць тэставае электроннае пісьмо\",\n  \"ToastDeviceTestEmailSuccess\": \"Тэставы электронны ліст адпраўлены\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Налады электроннай пошты абноўлены\",\n  \"ToastEncodeCancelFailed\": \"Не ўдалося скасаваць кадаванне\",\n  \"ToastEncodeCancelSucces\": \"Кадаванне скасавана\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Не ўдалося ачысціць чаргу\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Чарга спампоўвання выпускаў ачышчана\",\n  \"ToastEpisodeUpdateSuccess\": \"Абноўлена выпускаў: {0}\",\n  \"ToastErrorCannotShare\": \"Немагчыма абагуліць на гэтай прыладзе\",\n  \"ToastFailedToCreate\": \"Не ўдалося стварыць\",\n  \"ToastFailedToDelete\": \"Не ўдалося выдаліць\",\n  \"ToastFailedToLoadData\": \"Не ўдалося загрузіць даныя\",\n  \"ToastFailedToMatch\": \"Не атрымалася знайсці супадзенне\",\n  \"ToastFailedToShare\": \"Не ўдалося абагуліць\",\n  \"ToastFailedToUpdate\": \"Не здалося абнавіць\",\n  \"ToastInvalidImageUrl\": \"Памылковы URL-адрас відарыса\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Няправільная максімальная колькасць выпускаў для спампоўвання\",\n  \"ToastInvalidUrl\": \"Памылковы URL-адрас\",\n  \"ToastInvalidUrls\": \"Адзін або некалькі URL-адрасоў памылковыя\",\n  \"ToastItemCoverUpdateSuccess\": \"Вокладка элемента абноўлена\",\n  \"ToastItemDeletedFailed\": \"Не ўдалося выдаліць элемент\",\n  \"ToastItemDeletedSuccess\": \"Выдалены элемент\",\n  \"ToastItemDetailsUpdateSuccess\": \"Звесткі элемента абноўлены\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Не ўдалося пазначыць як завершаны\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Элемент пазначаны як завершаны\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Не ўдалося пазначыць як незавершаны\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Элемент пазначаны як незавершаны\",\n  \"ToastItemUpdateSuccess\": \"Элемент абноўлены\",\n  \"ToastLibraryCreateFailed\": \"Не ўдалося стварыць бібліятэку\",\n  \"ToastLibraryCreateSuccess\": \"Бібліятэка \\\"{0}\\\" створана\",\n  \"ToastLibraryDeleteFailed\": \"Не ўдалося выдаліць бібліятэку\",\n  \"ToastLibraryDeleteSuccess\": \"Бібліятэка выдалена\",\n  \"ToastLibraryScanFailedToStart\": \"Не ўдалося запусціць сканаванне\",\n  \"ToastLibraryScanStarted\": \"Сканаванне бібліятэкі запушчана\",\n  \"ToastLibraryUpdateSuccess\": \"Бібліятэка \\\"{0}\\\" абноўлена\",\n  \"ToastMatchAllAuthorsFailed\": \"Не ўдалося знайсці адпаведнасць для ўсіх аўтараў\",\n  \"ToastMetadataFilesRemovedError\": \"Памылка пры выдаленні metadata.{0} файлаў\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"У бібліятэцы не знойдзены metadata.{0} файлаў\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Не выдалена metadata.{0} файлаў\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} файлаў выдалена\",\n  \"ToastMustHaveAtLeastOnePath\": \"Павінен быць хаця б адзін шлях\",\n  \"ToastNameEmailRequired\": \"Імя і электронная пошта абавязковыя\",\n  \"ToastNameRequired\": \"Імя абавязковае\",\n  \"ToastNewApiKeyUserError\": \"Трэба выбраць карыстальніка\",\n  \"ToastNewEpisodesFound\": \"Знойдзена новых выпускаў: {0}\",\n  \"ToastNewUserCreatedFailed\": \"Не ўдалося стварыць уліковы запіс: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Новы ўліковы запіс створаны\",\n  \"ToastNewUserLibraryError\": \"Трэба выбраць хаця б адну бібліятэку\",\n  \"ToastNewUserPasswordError\": \"Мусіць мець пароль, толькі карыстальнік root можа мець пусты пароль\",\n  \"ToastNewUserTagError\": \"Трэбаа выбраць хаця б адзін тэг\",\n  \"ToastNewUserUsernameError\": \"Увядзіце імя карыстальніка\",\n  \"ToastNoNewEpisodesFound\": \"Новых выпускаў не знойдзена\",\n  \"ToastNoRSSFeed\": \"У падкаста няма RSS-стужкі\",\n  \"ToastNoUpdatesNecessary\": \"Абнаўленні не патрэбныя\",\n  \"ToastNotificationCreateFailed\": \"Не ўдалося стварыць апавяшчэнне\",\n  \"ToastNotificationDeleteFailed\": \"Не ўдалося выдаліць апавяшчэнне\",\n  \"ToastNotificationFailedMaximum\": \"Максімальная колькасць няўдалых спроб павінна быць >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Максімальная чарга апавяшчэнняў павінна быць >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Налады апавяшчэнняў абноўлены\",\n  \"ToastNotificationTestTriggerFailed\": \"Не ўдалося ўключыць тэставае апавяшчэнне\",\n  \"ToastNotificationTestTriggerSuccess\": \"Уключана тэставае апавяшчэнне\",\n  \"ToastNotificationUpdateSuccess\": \"Апавяшчэнне абноўлена\",\n  \"ToastPlaylistCreateFailed\": \"Не ўдалося стварыць плэй-ліст\",\n  \"ToastPlaylistCreateSuccess\": \"Плэй-ліст створаны\",\n  \"ToastPlaylistRemoveSuccess\": \"Плэй-ліст выдалены\",\n  \"ToastPlaylistUpdateSuccess\": \"Плэй-ліст абноўлены\",\n  \"ToastPodcastCreateFailed\": \"Не ўдалося стварыць падкаст\",\n  \"ToastPodcastCreateSuccess\": \"Падкаст паспяхова створаны\",\n  \"ToastPodcastEpisodeUpdated\": \"Выпуск абноўлены\",\n  \"ToastPodcastGetFeedFailed\": \"Не ўдалося атрымаць стужку падкаста\",\n  \"ToastPodcastNoEpisodesInFeed\": \"У RSS-ленце не знойдзена выпускаў\",\n  \"ToastPodcastNoRssFeed\": \"У падкаста няма RSS-стужкі\",\n  \"ToastProgressIsNotBeingSynced\": \"Прагрэс не сінхранізуецца, перазапусціце прайграванне\",\n  \"ToastProviderCreatedFailed\": \"Не ўдалося дадаць пастаўшчыка\",\n  \"ToastProviderCreatedSuccess\": \"Новы пастаўшчык дададзены\",\n  \"ToastProviderNameAndUrlRequired\": \"Назва і URL-адрас абавязковыя\",\n  \"ToastProviderRemoveSuccess\": \"Пастаўшчык выдалены\",\n  \"ToastRSSFeedCloseFailed\": \"Не ўдалося закрыць RSS-стужку\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-стужка закрыта\",\n  \"ToastRemoveFailed\": \"Не ўдалося выдаліць\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Не ўдалося выдаліць элемент з калекцыі\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Элемент выдалены з калекцыі\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Не ўдалося выдаліць элементы бібліятэкі з праблемамі\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Выдалены элементы бібліятэкі з праблемамі\",\n  \"ToastRenameFailed\": \"Не ўдалося перайменаваць\",\n  \"ToastRescanFailed\": \"Не ўдалося паўторна прасканіраваць {0}\",\n  \"ToastRescanRemoved\": \"Паўторнае сканаванне завершана, элемент быў выдалены\",\n  \"ToastRescanUpToDate\": \"Паўторнае сканаванне завершана, элемент быў у актуальным стане\",\n  \"ToastRescanUpdated\": \"Паўторнае сканаванне завершана, элемент быў абноўлены\",\n  \"ToastScanFailed\": \"Не ўдалося адсканіраваць элемент бібліятэкі\",\n  \"ToastSelectAtLeastOneUser\": \"Выберыце прынамсі аднаго карыстальніка\",\n  \"ToastSendEbookToDeviceFailed\": \"Не ўдалося адправіць электронную кнігу на прыладу\",\n  \"ToastSendEbookToDeviceSuccess\": \"Электронная кніга адпраўлена на прыладу \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Немагчыма дадаць дзве серыі з аднолькавай назвай\",\n  \"ToastSeriesUpdateFailed\": \"Не ўдалося абнавіць серыі\",\n  \"ToastSeriesUpdateSuccess\": \"Серыі абноўлены\",\n  \"ToastServerSettingsUpdateSuccess\": \"Налады сервера абноўлены\",\n  \"ToastSessionCloseFailed\": \"Не ўдалося закрыць сеанс\",\n  \"ToastSessionDeleteFailed\": \"Не ўдалося выдаліць сеанс\",\n  \"ToastSessionDeleteSuccess\": \"Сеанс выдалены\",\n  \"ToastSleepTimerDone\": \"Таймер сну скончыўся... Хр-р-р\",\n  \"ToastSlugMustChange\": \"Ідэнтыфікатар змяшчае недапушчальныя сімвалы\",\n  \"ToastSlugRequired\": \"Ідэнтыфікатар абавязковы\",\n  \"ToastSocketConnected\": \"Сокет падключаны\",\n  \"ToastSocketDisconnected\": \"Сокет адключаны\",\n  \"ToastSocketFailedToConnect\": \"Не ўдалося падключыць сокет\",\n  \"ToastSortingPrefixesEmptyError\": \"Мусіць мець хаця б адзін прэфікс сартавання\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Прэфіксы сартавання абноўлены ({0} элементаў)\",\n  \"ToastTitleRequired\": \"Загаловак абавязковы\",\n  \"ToastUnknownError\": \"Невядомая памылка\",\n  \"ToastUnlinkOpenIdFailed\": \"Не ўдалося адвязаць карыстальніка ад OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Карыстальнік адвязаны ад OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Файл \\\"{0}\\\" ужо існуе на серверы\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Элемент \\\"{0}\\\" выкарыстоўвае падкаталог шляху запампоўкі.\",\n  \"ToastUserDeleteFailed\": \"Не ўдалося выдаліць карыстальніка\",\n  \"ToastUserDeleteSuccess\": \"Карыстальнік выдалены\",\n  \"ToastUserPasswordChangeSuccess\": \"Пароль паспяхова зменены\",\n  \"ToastUserPasswordMismatch\": \"Паролі не супадаюць\",\n  \"ToastUserPasswordMustChange\": \"Новы пароль не можа супадаць са старым\",\n  \"ToastUserRootRequireName\": \"Неабходна ўвесці імя карыстальніка адміністратара\",\n  \"TooltipAddChapters\": \"Дадаць раздзел(ы)\",\n  \"TooltipAddOneSecond\": \"Дадаць 1 секунду\",\n  \"TooltipAdjustChapterStart\": \"Націсніце, каб адкарэкціраваць час пачатку\",\n  \"TooltipLockAllChapters\": \"Заблакіраваць усе раздзелы\",\n  \"TooltipLockChapter\": \"Заблакіраваць раздзел (Shift+націсканне для дыяпазону)\",\n  \"TooltipSubtractOneSecond\": \"Адняць 1 секунду\",\n  \"TooltipUnlockAllChapters\": \"Разблакіраваць усе раздзелы\",\n  \"TooltipUnlockChapter\": \"Разблакіраваць раздзел (Shift+націсканне для выбару дыяпазону)\"\n}\n"
  },
  {
    "path": "client/strings/bg.json",
    "content": "{\n  \"ButtonAdd\": \"Създай\",\n  \"ButtonAddApiKey\": \"Добави API ключ\",\n  \"ButtonAddChapters\": \"Добави Глави\",\n  \"ButtonAddDevice\": \"Добави Устройство\",\n  \"ButtonAddLibrary\": \"Добави Библиотека\",\n  \"ButtonAddPodcasts\": \"Добави Подкаст\",\n  \"ButtonAddUser\": \"Добави Потребител\",\n  \"ButtonAddYourFirstLibrary\": \"Добави първата ти библиотека\",\n  \"ButtonApply\": \"Приложи\",\n  \"ButtonApplyChapters\": \"Приложи Глави\",\n  \"ButtonAuthors\": \"Автори\",\n  \"ButtonBack\": \"Назад\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Попълни от съществуващи\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Попълни подробности за картата\",\n  \"ButtonBrowseForFolder\": \"Прегледай за папка\",\n  \"ButtonCancel\": \"Отказ\",\n  \"ButtonCancelEncode\": \"Откажи закодирането\",\n  \"ButtonChangeRootPassword\": \"Промени паролата за Root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Провери и Свали Нови Епизоди\",\n  \"ButtonChooseAFolder\": \"Избери Папка\",\n  \"ButtonChooseFiles\": \"Избери Файлове\",\n  \"ButtonClearFilter\": \"Изчисти филтър\",\n  \"ButtonClose\": \"Затвори\",\n  \"ButtonCloseFeed\": \"Затвори стената\",\n  \"ButtonCloseSession\": \"Затвори отворената сесия\",\n  \"ButtonCollections\": \"Колекции\",\n  \"ButtonConfigureScanner\": \"Конфигурирай Скенера\",\n  \"ButtonCreate\": \"Създай\",\n  \"ButtonCreateBackup\": \"Създай Backup\",\n  \"ButtonDelete\": \"Изтрий\",\n  \"ButtonDownloadQueue\": \"Опашка за Сваляне\",\n  \"ButtonEdit\": \"Редактирай\",\n  \"ButtonEditChapters\": \"Редактирай Глави\",\n  \"ButtonEditPodcast\": \"Редактирай Подкаст\",\n  \"ButtonEnable\": \"Активирай\",\n  \"ButtonFireAndFail\": \"Задействай и неуспей\",\n  \"ButtonFireOnTest\": \"Задействай събитие onTest\",\n  \"ButtonForceReScan\": \"Принудително Пресканиране\",\n  \"ButtonFullPath\": \"Пълен Път\",\n  \"ButtonHide\": \"Скрий\",\n  \"ButtonHome\": \"Начало\",\n  \"ButtonIssues\": \"Проблеми\",\n  \"ButtonJumpBackward\": \"Прескочи назад\",\n  \"ButtonJumpForward\": \"Прескоци напред\",\n  \"ButtonLatest\": \"Последни\",\n  \"ButtonLibrary\": \"Библиотека\",\n  \"ButtonLogout\": \"Изход\",\n  \"ButtonLookup\": \"Търси\",\n  \"ButtonManageTracks\": \"Управление на Канали\",\n  \"ButtonMapChapterTitles\": \"Асоцийрай Заглавия на Глави\",\n  \"ButtonMatchAllAuthors\": \"Съвпадение на Всички Автори\",\n  \"ButtonMatchBooks\": \"Съвпадение на Книги\",\n  \"ButtonNevermind\": \"Няма значение\",\n  \"ButtonNext\": \"Следващо\",\n  \"ButtonNextChapter\": \"Следваща Глава\",\n  \"ButtonNextItemInQueue\": \"Следващият елемент в опашката\",\n  \"ButtonOk\": \"Приемам\",\n  \"ButtonOpenFeed\": \"Отвори стената\",\n  \"ButtonOpenManager\": \"Отвори Мениджър\",\n  \"ButtonPause\": \"Паузирай\",\n  \"ButtonPlay\": \"Пусни\",\n  \"ButtonPlayAll\": \"Пусни всички\",\n  \"ButtonPlaying\": \"Пуска се\",\n  \"ButtonPlaylists\": \"Плейлисти\",\n  \"ButtonPrevious\": \"Предишен\",\n  \"ButtonPreviousChapter\": \"Предишна Глава\",\n  \"ButtonProbeAudioFile\": \"Провери аудио файла\",\n  \"ButtonPurgeAllCache\": \"Изчисти Всички Кешове\",\n  \"ButtonPurgeItemsCache\": \"Изчисти Кеша на Елементи\",\n  \"ButtonQueueAddItem\": \"Добави към опашката\",\n  \"ButtonQueueRemoveItem\": \"Премахни от опашката\",\n  \"ButtonQuickEmbed\": \"Бързо вграждане\",\n  \"ButtonQuickEmbedMetadata\": \"Бързо вграждане метадата\",\n  \"ButtonQuickMatch\": \"Бързо Съпоставяне\",\n  \"ButtonReScan\": \"Пресканирай\",\n  \"ButtonRead\": \"Прочети\",\n  \"ButtonReadLess\": \"Изчети по-малко\",\n  \"ButtonReadMore\": \"Прочети дълго\",\n  \"ButtonRefresh\": \"Обнови\",\n  \"ButtonRemove\": \"Премахни\",\n  \"ButtonRemoveAll\": \"Премахни Всички\",\n  \"ButtonRemoveAllLibraryItems\": \"Премахни Всички Елементи от Библиотеката\",\n  \"ButtonRemoveFromContinueListening\": \"Премахни от Продължаване на Слушане\",\n  \"ButtonRemoveFromContinueReading\": \"Премахни от Продължаване на Четене\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Премахни Серия от Продължаване на Серии\",\n  \"ButtonReset\": \"Нулиране\",\n  \"ButtonResetToDefault\": \"Нулиране до стойност по подразбиране\",\n  \"ButtonRestore\": \"Възстанови\",\n  \"ButtonSave\": \"Запази\",\n  \"ButtonSaveAndClose\": \"Запази и Затвори\",\n  \"ButtonSaveTracklist\": \"Запази Списък с Канали\",\n  \"ButtonScan\": \"Сканирай\",\n  \"ButtonScanLibrary\": \"Сканирай Библиотека\",\n  \"ButtonScrollLeft\": \"Скролни наляво\",\n  \"ButtonScrollRight\": \"Скролни надясно\",\n  \"ButtonSearch\": \"Търси в\",\n  \"ButtonSelectFolderPath\": \"Избери Път на Папка\",\n  \"ButtonSeries\": \"Серии\",\n  \"ButtonSetChaptersFromTracks\": \"Задай Глави от Песни\",\n  \"ButtonShare\": \"Сподели\",\n  \"ButtonShiftTimes\": \"Измести Времената\",\n  \"ButtonShow\": \"Покажи\",\n  \"ButtonStartM4BEncode\": \"Започни M4B Кодиране\",\n  \"ButtonStartMetadataEmbed\": \"Започни Вграждане на Метаданни\",\n  \"ButtonStats\": \"Статистики\",\n  \"ButtonSubmit\": \"Изпрати\",\n  \"ButtonTest\": \"Тест\",\n  \"ButtonUnlinkOpenId\": \"Премахни връзката с OpenID\",\n  \"ButtonUpload\": \"Качи\",\n  \"ButtonUploadBackup\": \"Качи Backup\",\n  \"ButtonUploadCover\": \"Качи Корица\",\n  \"ButtonUploadOPMLFile\": \"Качи OPML Файл\",\n  \"ButtonUserDelete\": \"Изтрий Потребител {0}\",\n  \"ButtonUserEdit\": \"Редактирай Потребител {0}\",\n  \"ButtonViewAll\": \"Виж Всички\",\n  \"ButtonYes\": \"Да\",\n  \"ErrorUploadFetchMetadataAPI\": \"Грешка при Взимане на Метаданни\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора\",\n  \"ErrorUploadLacksTitle\": \"Трябва да има Заглавие\",\n  \"HeaderAccount\": \"Профил\",\n  \"HeaderAddCustomMetadataProvider\": \"Добави персонализиран доставчик на метаданни\",\n  \"HeaderAdvanced\": \"Разширени настройки\",\n  \"HeaderApiKeys\": \"API ключове\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise Notification Опции\",\n  \"HeaderAudioTracks\": \"Песни\",\n  \"HeaderAudiobookTools\": \"Инструмент за Менижиране на Аудиокниги\",\n  \"HeaderAuthentication\": \"Аутентикация\",\n  \"HeaderBackups\": \"Архив\",\n  \"HeaderBulkChapterModal\": \"Добави няколко глави\",\n  \"HeaderChangePassword\": \"Промяна на Парола\",\n  \"HeaderChapters\": \"Глави\",\n  \"HeaderChooseAFolder\": \"Избети Папка\",\n  \"HeaderCollection\": \"Колекция\",\n  \"HeaderCollectionItems\": \"Елемент в колекция\",\n  \"HeaderCover\": \"Корица\",\n  \"HeaderCurrentDownloads\": \"Текущи Сваляния\",\n  \"HeaderCustomMessageOnLogin\": \"Потребителско съобщение при влизане\",\n  \"HeaderCustomMetadataProviders\": \"Потребителски Доставчици на Метаданни\",\n  \"HeaderDetails\": \"Детайли\",\n  \"HeaderDownloadQueue\": \"Опашка за Сваляне\",\n  \"HeaderEbookFiles\": \"Е-книги файлове\",\n  \"HeaderEmail\": \"Емейл\",\n  \"HeaderEmailSettings\": \"Настройки Емайл\",\n  \"HeaderEpisodes\": \"Епизоди\",\n  \"HeaderEreaderDevices\": \"Елктронни Четци\",\n  \"HeaderEreaderSettings\": \"Настройки на Е-четецът\",\n  \"HeaderFiles\": \"Файлове\",\n  \"HeaderFindChapters\": \"Намери Глави\",\n  \"HeaderIgnoredFiles\": \"Игнорирани Файлове\",\n  \"HeaderItemFiles\": \"Файлове на Елемент\",\n  \"HeaderItemMetadataUtils\": \"Инструменти за Метаданни на Елемент\",\n  \"HeaderLastListeningSession\": \"Последна Сесия на Слушане\",\n  \"HeaderLatestEpisodes\": \"Последни епизоди\",\n  \"HeaderLibraries\": \"Библиотеки\",\n  \"HeaderLibraryFiles\": \"Файлове на Библиотека\",\n  \"HeaderLibraryStats\": \"Статистика на Библиотека\",\n  \"HeaderListeningSessions\": \"Сесии на Слушане\",\n  \"HeaderListeningStats\": \"Статистика на Слушане\",\n  \"HeaderLogin\": \"Вход\",\n  \"HeaderLogs\": \"Логове\",\n  \"HeaderManageGenres\": \"Управление на Жанрове\",\n  \"HeaderManageTags\": \"Управление на Тагове\",\n  \"HeaderMapDetails\": \"Асоцирай Детайли\",\n  \"HeaderMatch\": \"Съпостави\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Предимство на Метаданни\",\n  \"HeaderMetadataToEmbed\": \"Метаданни за Вграждане\",\n  \"HeaderNewAccount\": \"Нов Профил\",\n  \"HeaderNewApiKey\": \"Нов API ключ\",\n  \"HeaderNewLibrary\": \"Нова Библиотека\",\n  \"HeaderNotificationCreate\": \"Създай нотификация\",\n  \"HeaderNotificationUpdate\": \"Обнови нотификация\",\n  \"HeaderNotifications\": \"Известия\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect Аутентикация\",\n  \"HeaderOpenListeningSessions\": \"Отвори сесия\",\n  \"HeaderOpenRSSFeed\": \"Отвори RSS емисията\",\n  \"HeaderOtherFiles\": \"Други Файлове\",\n  \"HeaderPasswordAuthentication\": \"Паролна Аутентикация\",\n  \"HeaderPermissions\": \"Права\",\n  \"HeaderPlayerQueue\": \"Опашка на Плейъра\",\n  \"HeaderPlayerSettings\": \"Настройки на плейъра\",\n  \"HeaderPlaylist\": \"Плейлист\",\n  \"HeaderPlaylistItems\": \"Елементи от плейлист\",\n  \"HeaderPodcastsToAdd\": \"Подкасти за Добавяне\",\n  \"HeaderPresets\": \"Настройки по подразбиране\",\n  \"HeaderPreviewCover\": \"Преглед на Корица\",\n  \"HeaderRSSFeedGeneral\": \"RSS подробности\",\n  \"HeaderRSSFeedIsOpen\": \"RSS емисията е отворена\",\n  \"HeaderRSSFeeds\": \"RSS Feed-ове\",\n  \"HeaderRemoveEpisode\": \"Премахни Епизод\",\n  \"HeaderRemoveEpisodes\": \"Премахни {0} Епизоди\",\n  \"HeaderSavedMediaProgress\": \"Запазен Прогрес на Медията\",\n  \"HeaderSchedule\": \"График\",\n  \"HeaderScheduleEpisodeDownloads\": \"Планирай автоматично изтегляне на епизоди\",\n  \"HeaderScheduleLibraryScans\": \"График за Автоматично Сканиране на Библиотека\",\n  \"HeaderSession\": \"Сесия\",\n  \"HeaderSetBackupSchedule\": \"Задай График за Backup\",\n  \"HeaderSettings\": \"Настройки\",\n  \"HeaderSettingsDisplay\": \"Визуализация\",\n  \"HeaderSettingsExperimental\": \"Експериментални Функции\",\n  \"HeaderSettingsGeneral\": \"Общи\",\n  \"HeaderSettingsScanner\": \"Скенер\",\n  \"HeaderSettingsSecurity\": \"Сигурност\",\n  \"HeaderSettingsWebClient\": \"Уеб клиент\",\n  \"HeaderSleepTimer\": \"Таймер за заспиване\",\n  \"HeaderStatsLargestItems\": \"Най-Големите Елементи\",\n  \"HeaderStatsLongestItems\": \"Най-Дългите Елементи (часове)\",\n  \"HeaderStatsMinutesListeningChart\": \"Изслушани минути (последните 7 дни)\",\n  \"HeaderStatsRecentSessions\": \"Последни сесии\",\n  \"HeaderStatsTop10Authors\": \"Топ 10 Автори\",\n  \"HeaderStatsTop5Genres\": \"Топ 5 Жанрове\",\n  \"HeaderTableOfContents\": \"Съдържание\",\n  \"HeaderTools\": \"Инструменти\",\n  \"HeaderUpdateAccount\": \"Обнови Профил\",\n  \"HeaderUpdateApiKey\": \"Обнови API ключ\",\n  \"HeaderUpdateAuthor\": \"Обнови Автор\",\n  \"HeaderUpdateDetails\": \"Обнови Детайли\",\n  \"HeaderUpdateLibrary\": \"Обнови Библиотека\",\n  \"HeaderUsers\": \"Потребители\",\n  \"HeaderYearReview\": \"Преглед на {0} Година\",\n  \"HeaderYourStats\": \"Вашата статистика\",\n  \"LabelAbridged\": \"Съкратен\",\n  \"LabelAbridgedChecked\": \"Съкратена (отбелязано)\",\n  \"LabelAbridgedUnchecked\": \"Несъкратена (не отбелязано)\",\n  \"LabelAccessibleBy\": \"Достъпно от\",\n  \"LabelAccountType\": \"Тип на Профила\",\n  \"LabelAccountTypeAdmin\": \"Администратор\",\n  \"LabelAccountTypeGuest\": \"Гост\",\n  \"LabelAccountTypeUser\": \"Потребител\",\n  \"LabelActivities\": \"Дейности\",\n  \"LabelActivity\": \"Дейност\",\n  \"LabelAddToCollection\": \"Добави в Колекция\",\n  \"LabelAddToCollectionBatch\": \"Добави {0} Книги в Колекция\",\n  \"LabelAddToPlaylist\": \"Добави в плейлист\",\n  \"LabelAddToPlaylistBatch\": \"Добави {0} Елемент в Плейлист\",\n  \"LabelAddedAt\": \"Добавено в\",\n  \"LabelAddedDate\": \"Добавено\",\n  \"LabelAdminUsersOnly\": \"Само за Администратори\",\n  \"LabelAll\": \"Всичко\",\n  \"LabelAllEpisodesDownloaded\": \"Всички епизоди са изтеглени\",\n  \"LabelAllUsers\": \"Всички Потребители\",\n  \"LabelAllUsersExcludingGuests\": \"Всички потребители без гости\",\n  \"LabelAllUsersIncludingGuests\": \"Всички потребители включително гости\",\n  \"LabelAlreadyInYourLibrary\": \"Вече е в твоята библиотека\",\n  \"LabelApiKeyCreated\": \"API ключ \\\"{0}\\\" успешно създатен.\",\n  \"LabelApiKeyCreatedDescription\": \"Погрижете се да копирате API ключът сега, защото повече няма да можете да го виждате онново.\",\n  \"LabelApiKeyUser\": \"Действай от името на потребителя\",\n  \"LabelApiKeyUserDescription\": \"Този API ключ ще има същите права като на потребителя за чието име действа. В логовете ще изглежда все едно потребителя прави заявката.\",\n  \"LabelApiToken\": \"АПИ Токен\",\n  \"LabelAppend\": \"Добави\",\n  \"LabelAudioBitrate\": \"Аудио битрейт (напр. 128k)\",\n  \"LabelAudioChannels\": \"Аудио канали (1 или 2)\",\n  \"LabelAudioCodec\": \"Аудио кодек\",\n  \"LabelAuthor\": \"Автор\",\n  \"LabelAuthorFirstLast\": \"Автор (Първи, Последен)\",\n  \"LabelAuthorLastFirst\": \"Автор (Последен, Първи)\",\n  \"LabelAuthors\": \"Автори\",\n  \"LabelAutoDownloadEpisodes\": \"Автоматично изтегляне на епизоди\",\n  \"LabelAutoFetchMetadata\": \"Автоматично Взимане на Метаданни\",\n  \"LabelAutoFetchMetadataHelp\": \"Взима метаданни за заглвие, автор и серии за да опрости качването. Допълнителни метаданни може да трябва да бъде взера след качване.\",\n  \"LabelAutoLaunch\": \"Автоматично Стартиране\",\n  \"LabelAutoLaunchDescription\": \"Пренасочване към доставчик за аутентикация автоматично когато се навигира до страницата за вход (ръчно заменяне на пътя <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Автоматична Регистрация\",\n  \"LabelAutoRegisterDescription\": \"Автоматично създаване на нови потребители след вход\",\n  \"LabelBackToUser\": \"Обратно към Потребител\",\n  \"LabelBackupAudioFiles\": \"Създай резервно копие на аудио файлове\",\n  \"LabelBackupLocation\": \"Местоположение на Архив\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Автоматично архивиране\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Архиви запазени в /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Максимален размер на архива (в GB) (0 за неограничен)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"За защита срещу грешки в конфигурацията, архивите ще се провалят ако надхвърлят конфигурирания размер.\",\n  \"LabelBackupsNumberToKeep\": \"Брой архиви за запазване\",\n  \"LabelBackupsNumberToKeepHelp\": \"Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.\",\n  \"LabelBitrate\": \"Битрейт\",\n  \"LabelBonus\": \"Бонус\",\n  \"LabelBooks\": \"Книги\",\n  \"LabelButtonText\": \"Текст на Бутон\",\n  \"LabelByAuthor\": \"от {0}\",\n  \"LabelChangePassword\": \"Промени Парола\",\n  \"LabelChannels\": \"Канали\",\n  \"LabelChapterCount\": \"{0} Глави\",\n  \"LabelChapterTitle\": \"Заглавие на Глава\",\n  \"LabelChapters\": \"Глави\",\n  \"LabelChaptersFound\": \"намерени глави\",\n  \"LabelClickForMoreInfo\": \"Кликни за повече информация\",\n  \"LabelClickToUseCurrentValue\": \"Натисни да ползваш сегашната стойност\",\n  \"LabelClosePlayer\": \"Затвори плейъра\",\n  \"LabelCodec\": \"Кодек\",\n  \"LabelCollapseSeries\": \"Скрий сериите\",\n  \"LabelCollapseSubSeries\": \"Свий подсерии\",\n  \"LabelCollection\": \"Колекция\",\n  \"LabelCollections\": \"Колекции\",\n  \"LabelComplete\": \"Приключено\",\n  \"LabelConfirmPassword\": \"Потвърди Парола\",\n  \"LabelContinueListening\": \"Продължи слушане\",\n  \"LabelContinueReading\": \"Продължи четене\",\n  \"LabelContinueSeries\": \"Продължи серии\",\n  \"LabelCorsAllowed\": \"Разрешени CORS Origins\",\n  \"LabelCover\": \"Корица\",\n  \"LabelCoverImageURL\": \"URL на Корица\",\n  \"LabelCoverProvider\": \"Източник за обложки\",\n  \"LabelCreatedAt\": \"Създадено на\",\n  \"LabelCronExpression\": \"Cron израз\",\n  \"LabelCurrent\": \"Текущо\",\n  \"LabelCurrently\": \"Текущо:\",\n  \"LabelCustomCronExpression\": \"Потребителски Cron Expression:\",\n  \"LabelDatetime\": \"Дата и Време\",\n  \"LabelDays\": \"Дни\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)\",\n  \"LabelDescription\": \"Описание\",\n  \"LabelDeselectAll\": \"Премахни всички\",\n  \"LabelDetectedPattern\": \"Намерен образец:\",\n  \"LabelDevice\": \"Устройство\",\n  \"LabelDeviceInfo\": \"Информация за Устройство\",\n  \"LabelDeviceIsAvailableTo\": \"Устройството е достъпно за ...\",\n  \"LabelDirectory\": \"Директория\",\n  \"LabelDiscFromFilename\": \"Диск от Име на Файл\",\n  \"LabelDiscFromMetadata\": \"Диск от Метаданни\",\n  \"LabelDiscover\": \"Открий\",\n  \"LabelDownload\": \"Свали\",\n  \"LabelDownloadNEpisodes\": \"Свали {0} епизоди\",\n  \"LabelDownloadable\": \"Може да се изтегли\",\n  \"LabelDuration\": \"Продължителност\",\n  \"LabelDurationComparisonExactMatch\": \"(точно съвпадение)\",\n  \"LabelDurationComparisonLonger\": \"({0} по-дълго)\",\n  \"LabelDurationComparisonShorter\": \"({0} по-късо)\",\n  \"LabelDurationFound\": \"Намерена продължителност:\",\n  \"LabelEbook\": \"Е-Книга\",\n  \"LabelEbooks\": \"Е-книги\",\n  \"LabelEdit\": \"Редакция\",\n  \"LabelEmail\": \"Имейл\",\n  \"LabelEmailSettingsFromAddress\": \"От Адрес\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Отхвърли неавторизирани сертификати\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Спирането на валидацията на SSL сертификате може да изложи връзката ви на рискове, като man-in-the-middle атака. Спираите тази опция само ако знете имоликацийте от това и се доверявате на mail сървъра към който се свързвате.\",\n  \"LabelEmailSettingsSecure\": \"Сигурна\",\n  \"LabelEmailSettingsSecureHelp\": \"Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Тестов Адрес\",\n  \"LabelEmbeddedCover\": \"Вградена Корица\",\n  \"LabelEnable\": \"Активирай\",\n  \"LabelEncodingBackupLocation\": \"Резервно копие на вашите оригинални аудио файлове ще бъде съхранено в:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Главите не са вградени в аудиокнигите с множество тракове.\",\n  \"LabelEncodingClearItemCache\": \"Уверете се, че периодично изчиствате кеша на елементите.\",\n  \"LabelEncodingFinishedM4B\": \"Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:\",\n  \"LabelEncodingInfoEmbedded\": \"Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.\",\n  \"LabelEncodingStartedNavigation\": \"Когато задачата е стартирана, можете да смените тази страница.\",\n  \"LabelEncodingTimeWarning\": \"Кодирането може да отнеме до 30 минути.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Внимание: Не променяйте тези настройки, ако не сте запознати с ffmpeg настройките за кодиране.\",\n  \"LabelEncodingWatcherDisabled\": \"Ако сте изключили наблюдението на папки, ще е нужно да сканирате повторно аудио книгата.\",\n  \"LabelEnd\": \"Край\",\n  \"LabelEndOfChapter\": \"Край на глава\",\n  \"LabelEpisode\": \"Епизод\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Епизодът не е свързан с RSS канал\",\n  \"LabelEpisodeNumber\": \"Епизод #{0}\",\n  \"LabelEpisodeTitle\": \"Заглавие на Епизод\",\n  \"LabelEpisodeType\": \"Тип на Епизод\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL адрес на епизод от RSS канал\",\n  \"LabelEpisodes\": \"Епизоди\",\n  \"LabelEpisodic\": \"Епизодичен\",\n  \"LabelExample\": \"Пример\",\n  \"LabelExpandSeries\": \"Покажи сериите\",\n  \"LabelExpandSubSeries\": \"Покажи съб сериите\",\n  \"LabelExpired\": \"Изтекъл\",\n  \"LabelExpiresAt\": \"Изтича на\",\n  \"LabelExpiresInSeconds\": \"Изтича след (секунди)\",\n  \"LabelExpiresNever\": \"Никога\",\n  \"LabelExplicit\": \"Експлицитно\",\n  \"LabelExplicitChecked\": \"С нецензурно съдържание (проверено)\",\n  \"LabelExplicitUnchecked\": \"Без нецензурно съдържание (непроверено)\",\n  \"LabelExportOPML\": \"Експортирай OPML\",\n  \"LabelFeedURL\": \"URL на емисия\",\n  \"LabelFetchingMetadata\": \"Взимане на Метаданни\",\n  \"LabelFile\": \"Файл\",\n  \"LabelFileBirthtime\": \"Дата на създаване на файла\",\n  \"LabelFileBornDate\": \"Роден {0}\",\n  \"LabelFileModified\": \"Дата на модификация на файла\",\n  \"LabelFileModifiedDate\": \"Променен {0}\",\n  \"LabelFilename\": \"Име на файла\",\n  \"LabelFilterByUser\": \"Филтриране по Потребител\",\n  \"LabelFindEpisodes\": \"Намери Епизоди\",\n  \"LabelFinished\": \"Дата на приключване\",\n  \"LabelFinishedDate\": \"Приключено на {0}\",\n  \"LabelFolder\": \"Папка\",\n  \"LabelFolders\": \"Папки\",\n  \"LabelFontBold\": \"Получерно\",\n  \"LabelFontBoldness\": \"Дебелина на шрифта\",\n  \"LabelFontFamily\": \"Шрифт\",\n  \"LabelFontItalic\": \"Курсив\",\n  \"LabelFontScale\": \"Мащаб на шрифта\",\n  \"LabelFontStrikethrough\": \"Зачертан\",\n  \"LabelFormat\": \"Формат\",\n  \"LabelFull\": \"Пълен\",\n  \"LabelGenre\": \"Жанр\",\n  \"LabelGenres\": \"Жанрове\",\n  \"LabelHardDeleteFile\": \"Пълно Изтриване на Файл\",\n  \"LabelHasEbook\": \"Има е-книга\",\n  \"LabelHasSupplementaryEbook\": \"Има допълнителна е-книга\",\n  \"LabelHideSubtitles\": \"Скрий субтитри\",\n  \"LabelHighestPriority\": \"Най-висок Приоритет\",\n  \"LabelHost\": \"Хост\",\n  \"LabelHour\": \"Час\",\n  \"LabelHours\": \"Часа\",\n  \"LabelIcon\": \"Икона\",\n  \"LabelImageURLFromTheWeb\": \"URL на Изображение от Интернет\",\n  \"LabelInProgress\": \"В процес на изпълнение\",\n  \"LabelIncludeInTracklist\": \"Включи в Списъка с Канали\",\n  \"LabelIncomplete\": \"Незавършено\",\n  \"LabelInterval\": \"Интервал\",\n  \"LabelIntervalCustomDailyWeekly\": \"Потребително дневно/седмично\",\n  \"LabelIntervalEvery12Hours\": \"Всеки 12 часа\",\n  \"LabelIntervalEvery15Minutes\": \"Всеки 15 минути\",\n  \"LabelIntervalEvery2Hours\": \"Всеки 2 часа\",\n  \"LabelIntervalEvery30Minutes\": \"Всеки 30 минути\",\n  \"LabelIntervalEvery6Hours\": \"Всеки 6 часа\",\n  \"LabelIntervalEveryDay\": \"Всеки ден\",\n  \"LabelIntervalEveryHour\": \"Всеки час\",\n  \"LabelIntervalEveryMinute\": \"Всяка минута\",\n  \"LabelInvert\": \"Обърни\",\n  \"LabelItem\": \"Елемент\",\n  \"LabelJumpBackwardAmount\": \"Количество за прескачане назад\",\n  \"LabelJumpForwardAmount\": \"Количество за прескачане напред\",\n  \"LabelLanguage\": \"Език\",\n  \"LabelLanguageDefaultServer\": \"Език по подразбиране на сървъра\",\n  \"LabelLanguages\": \"Езици\",\n  \"LabelLastBookAdded\": \"Последно Добавена Книга\",\n  \"LabelLastBookUpdated\": \"Последно Обновена Книга\",\n  \"LabelLastProgressDate\": \"Последен прогрес: {0}\",\n  \"LabelLastSeen\": \"Последно Видян\",\n  \"LabelLastTime\": \"Последно Време\",\n  \"LabelLastUpdate\": \"Последно Обновяване\",\n  \"LabelLayout\": \"Оформление\",\n  \"LabelLayoutSinglePage\": \"Единична страница\",\n  \"LabelLayoutSplitPage\": \"Разделена Страница\",\n  \"LabelLess\": \"По-малко\",\n  \"LabelLibrariesAccessibleToUser\": \"Библиотеки Достъпни за Потребителя\",\n  \"LabelLibrary\": \"Библиотека\",\n  \"LabelLibraryFilterSublistEmpty\": \"Не {0}\",\n  \"LabelLibraryItem\": \"Елемент на Библиотека\",\n  \"LabelLibraryName\": \"Име на Библиотека\",\n  \"LabelLibrarySortByProgress\": \"Прогрес: Последно Обновен\",\n  \"LabelLibrarySortByProgressFinished\": \"Прогрес: Приключено\",\n  \"LabelLibrarySortByProgressStarted\": \"Прогрес: Започнато\",\n  \"LabelLimit\": \"Лимит\",\n  \"LabelLineSpacing\": \"Междуредие\",\n  \"LabelListenAgain\": \"Слушай отново\",\n  \"LabelLogLevelDebug\": \"Дебъг\",\n  \"LabelLogLevelInfo\": \"Информация\",\n  \"LabelLogLevelWarn\": \"Предупреждение\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Търси нови епизоди след дата\",\n  \"LabelLowestPriority\": \"Най-нисък Приоритет\",\n  \"LabelMatchConfidence\": \"Увереност\",\n  \"LabelMatchExistingUsersBy\": \"Съпостави съществуващи потребители по\",\n  \"LabelMatchExistingUsersByDescription\": \"Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Максимален брой епизоди за сваляне. Използвай 0 за неограничен.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Максимален брой нови епизоди за сваляне за проверка\",\n  \"LabelMaxEpisodesToKeep\": \"Максимален брой епизоди за запазване\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Стойност 0 указва без максимален лимит. След като нов епизод е автоматично свален, най-старият епизод ще бъде изтрит, ако имате повече от X епизода. Само по един епизод ще бъде изтриван за всеки нов свален такъв.\",\n  \"LabelMediaPlayer\": \"Медия Плейър\",\n  \"LabelMediaType\": \"Тип медия\",\n  \"LabelMetaTag\": \"Мета Таг\",\n  \"LabelMetaTags\": \"Мета Тагове\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"По-високите източници на метаданни ще заменят по-ниските\",\n  \"LabelMetadataProvider\": \"Доставчик на Метаданни\",\n  \"LabelMinute\": \"Минута\",\n  \"LabelMinutes\": \"Минути\",\n  \"LabelMissing\": \"Липсващо\",\n  \"LabelMissingEbook\": \"Няма електронна книга\",\n  \"LabelMissingSupplementaryEbook\": \"Няма допълнителна електронна книга\",\n  \"LabelMobileRedirectURIs\": \"Позволени URI за Мобилно Пренасочване\",\n  \"LabelMobileRedirectURIsDescription\": \"Това е whitelist на валидни URI за пренасочване за мобилни приложения. По подразбиране е <code>audiobookshelf://oauth</code>, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (<code>*</code>) като единствен запис позволява всеки URI.\",\n  \"LabelMore\": \"Повече\",\n  \"LabelMoreInfo\": \"Повече информация\",\n  \"LabelName\": \"Име\",\n  \"LabelNarrator\": \"Разказвач\",\n  \"LabelNarrators\": \"Разказвачи\",\n  \"LabelNew\": \"Нови\",\n  \"LabelNewPassword\": \"Нова Парола\",\n  \"LabelNewestAuthors\": \"Най-новите автори\",\n  \"LabelNewestEpisodes\": \"Най-новите епизоди\",\n  \"LabelNextBackupDate\": \"Следваща Дата на Архивиране\",\n  \"LabelNextChapters\": \"Следващите глави ще бъдат:\",\n  \"LabelNextScheduledRun\": \"Следващо Планирано Изпълнение\",\n  \"LabelNoApiKeys\": \"Няма API ключове\",\n  \"LabelNoCustomMetadataProviders\": \"Няма потребителски доставчици на метаданни\",\n  \"LabelNoEpisodesSelected\": \"Няма избрани епизоди\",\n  \"LabelNotFinished\": \"Не е приключено\",\n  \"LabelNotStarted\": \"Не е започнато\",\n  \"LabelNotes\": \"Бележки\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL-и\",\n  \"LabelNotificationAvailableVariables\": \"Налични променливи\",\n  \"LabelNotificationBodyTemplate\": \"Тяло на Шаблона\",\n  \"LabelNotificationEvent\": \"Събитие за Известие\",\n  \"LabelNotificationTitleTemplate\": \"Заглавие на Шаблона\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Максимален брой неуспешни опити за известия\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Известията се деактивират след като не успеят да бъдат изпратени толкова пъти\",\n  \"LabelNotificationsMaxQueueSize\": \"Максимален размер на опашката за известия\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.\",\n  \"LabelNumberOfBooks\": \"Брой на Книги\",\n  \"LabelNumberOfChapters\": \"Брой глави:\",\n  \"LabelNumberOfEpisodes\": \"Брой епизоди\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Име на OpenID твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (<b>ако е конфигурирано</b>). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като <code>false</code>. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:\",\n  \"LabelOpenIDClaims\": \"Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича <code>groups</code>. <b>Ако е конфигурирано</b>, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.\",\n  \"LabelOpenRSSFeed\": \"Отвори RSS Feed\",\n  \"LabelOverwrite\": \"Презапиши\",\n  \"LabelPaginationPageXOfY\": \"Страница {0} от {1}\",\n  \"LabelPassword\": \"Парола\",\n  \"LabelPath\": \"Път\",\n  \"LabelPermanent\": \"Постоянен\",\n  \"LabelPermissionsAccessAllLibraries\": \"Може да достъпи до всички библиотеки\",\n  \"LabelPermissionsAccessAllTags\": \"Може да достъпи всички тагове\",\n  \"LabelPermissionsAccessExplicitContent\": \"Може да достъпи експлицитно съдържание\",\n  \"LabelPermissionsCreateEreader\": \"Може да създава електронен четец\",\n  \"LabelPermissionsDelete\": \"Може да трие\",\n  \"LabelPermissionsDownload\": \"Може да сваля\",\n  \"LabelPermissionsUpdate\": \"Може да обновява\",\n  \"LabelPermissionsUpload\": \"Може да качва\",\n  \"LabelPersonalYearReview\": \"Преглед на годината Ви ({0})\",\n  \"LabelPhotoPathURL\": \"Път/URL на Снимка\",\n  \"LabelPlayMethod\": \"Метод на Пускане\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Размер на увеличаване/намаляне при скоростта на възпроизвеждане\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} от {1}\",\n  \"LabelPlaylists\": \"Плейлисти\",\n  \"LabelPodcast\": \"Подкаст\",\n  \"LabelPodcastSearchRegion\": \"Регион за Търсене на Подкасти\",\n  \"LabelPodcastType\": \"Тип на Подкаст\",\n  \"LabelPodcasts\": \"Подкасти\",\n  \"LabelPort\": \"Порт\",\n  \"LabelPrefixesToIgnore\": \"Префикси за Игнориране (без значение за главни/малки букви)\",\n  \"LabelPreventIndexing\": \"Предотвратете индексирането на вашата емисия от директориите на iTunes и Google за подкасти\",\n  \"LabelPrimaryEbook\": \"Основна Електронна Книга\",\n  \"LabelProgress\": \"Прогрес\",\n  \"LabelProvider\": \"Доставчик\",\n  \"LabelProviderAuthorizationValue\": \"Стойност на Authorization Header\",\n  \"LabelPubDate\": \"Дата на публикуване\",\n  \"LabelPublishYear\": \"Година на публикуване\",\n  \"LabelPublishedDate\": \"Публикувани {0}\",\n  \"LabelPublishedDecade\": \"Десетилетие на публикуване\",\n  \"LabelPublishedDecades\": \"Десетилетия на публикуване\",\n  \"LabelPublisher\": \"Издател\",\n  \"LabelPublishers\": \"Издателство\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Персонализиран имейл на собственика\",\n  \"LabelRSSFeedCustomOwnerName\": \"Персонализирано име на собственика\",\n  \"LabelRSSFeedOpen\": \"RSS Feed е отворен\",\n  \"LabelRSSFeedPreventIndexing\": \"Предотвратете индексиране\",\n  \"LabelRSSFeedSlug\": \"идентификатор на RSS емисия\",\n  \"LabelRSSFeedURL\": \"URL на RSS емисия\",\n  \"LabelRandomly\": \"Случайно\",\n  \"LabelReAddSeriesToContinueListening\": \"Добави отново в \\\"Продължете да слушате\\\"\",\n  \"LabelRead\": \"Прочети\",\n  \"LabelReadAgain\": \"Прочети отново\",\n  \"LabelReadEbookWithoutProgress\": \"Прочети електронната книга без записване прогрес\",\n  \"LabelRecentSeries\": \"Скорошни серии\",\n  \"LabelRecentlyAdded\": \"Скорошно добавени\",\n  \"LabelRecommended\": \"Препоръчано\",\n  \"LabelRedo\": \"Повтори\",\n  \"LabelRegion\": \"Регион\",\n  \"LabelReleaseDate\": \"Дата на Издаване\",\n  \"LabelRemoveAllMetadataAbs\": \"Премахни всички metadata.abs файлове\",\n  \"LabelRemoveAllMetadataJson\": \"Премахни всички metadata.json файлове\",\n  \"LabelRemoveAudibleBranding\": \"Премахни въведението и заключението на Audible от главите\",\n  \"LabelRemoveCover\": \"Премахни Корица\",\n  \"LabelRemoveMetadataFile\": \"Премахни файловете с метаданни от папката на библиотеката\",\n  \"LabelRemoveMetadataFileHelp\": \"Премахни всички metadata.json и metadata.abs файлове от вашата {0} папка.\",\n  \"LabelRowsPerPage\": \"Редове на Страница\",\n  \"LabelSearchTerm\": \"Търси Термин\",\n  \"LabelSearchTitle\": \"Търси Заглавие\",\n  \"LabelSearchTitleOrASIN\": \"Търси Заглавие или ASIN\",\n  \"LabelSeason\": \"Сезон\",\n  \"LabelSeasonNumber\": \"Сезон #{0}\",\n  \"LabelSelectAll\": \"Избери всичко\",\n  \"LabelSelectAllEpisodes\": \"Избери всички епизоди\",\n  \"LabelSelectEpisodesShowing\": \"Избери {0} епизоди показани\",\n  \"LabelSelectUser\": \"Избери потребител\",\n  \"LabelSelectUsers\": \"Избери Потребители\",\n  \"LabelSendEbookToDevice\": \"Изпрати електронна книга до ...\",\n  \"LabelSequence\": \"Последователност\",\n  \"LabelSerial\": \"Сериал\",\n  \"LabelSeries\": \"От сериите\",\n  \"LabelSeriesName\": \"Име на Серия\",\n  \"LabelSeriesProgress\": \"Прогрес на Серия\",\n  \"LabelServerLogLevel\": \"Ниво на сървърен журнал\",\n  \"LabelServerYearReview\": \"Преглед на годината на сървъра ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Направи главен\",\n  \"LabelSetEbookAsSupplementary\": \"Направи второстепенен\",\n  \"LabelSettingsAllowIframe\": \"Разреши вграждане в iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Само аудиокниги\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги\",\n  \"LabelSettingsBookshelfViewHelp\": \"Скеуморфен дизайн с дървени рафтове\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast поддръжка\",\n  \"LabelSettingsDateFormat\": \"Формат на Дата\",\n  \"LabelSettingsEnableWatcher\": \"Автоматично преглеждане на библиотеките за промени\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Автоматично преглеждане на библиотеката за промени\",\n  \"LabelSettingsEnableWatcherHelp\": \"Включва автоматичното добавяне/обновяване на елементи, когато се открият промени във файловете. *Изисква рестарт на сървъра\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Позволи скриптово съдържание в epub-и\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Позволи epub файловете да изпълняват скриптове. Препоръчително е да бъде изключено освен ако не се доверявате на източника на epub файловете.\",\n  \"LabelSettingsExperimentalFeatures\": \"Експериментални Функции\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Функции в разработка, които могат да изискват вашето мнение и помощ за тестване. Кликнете за да отворите дискусия в github.\",\n  \"LabelSettingsFindCovers\": \"Намери Корици\",\n  \"LabelSettingsFindCoversHelp\": \"Ако аудиокнигата ви няма вградена корица или изображение на корицата в папката, скенерът ще опита да намери корица.<br>Забележка: Това ще удължи времето за сканиране\",\n  \"LabelSettingsHideSingleBookSeries\": \"Скрий серии с една книга\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Сериите с една книга ще бъдат скрити от страницата на серията и рафтовете на началната страница.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Начална страница изглед на рафт\",\n  \"LabelSettingsLibraryBookshelfView\": \"Библиотека изглед на рафт\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Процент завършеност е по-голям от\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Оставащо време е по-малко от (секунди)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Отбелязване на мултимедиен елемент като завършен когато\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Пропусни предишни книги в Продължи Поредица\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.\",\n  \"LabelSettingsParseSubtitles\": \"Извлечи подзаглавия\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Извлича подзаглавия от имената на папките на аудио книгите.<br>Подзаглавията трябва да бъдат разделени с \\\" - \\\"<br>например \\\"Заглавие на Книга - Тук е подзаглавието\\\" има подзаглавие \\\"Тук е подзаглавието\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Предпочети съвпадащи метаданни\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Съвпадащите данни ще заменят детайлите на елемента при използване на Бързо Съпоставяне. По подразбиране Бързото Съпоставяне ще попълни само липсващите детайли.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Пропусни съвпадащи книги, които вече имат ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Пропусни съвпадащи книги, които вече имат ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Игнорирай Префикси при Сортиране\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"например за префикс \\\"the\\\" заглавието на книгата \\\"The Book Title\\\" ще се сортира като \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Квадратни Корици на Книги\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Предпочитайте квадратни корици пред стандартни 1.6:1 корици на книги\",\n  \"LabelSettingsStoreCoversWithItem\": \"Запази кориците с елемента\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"По подразбиране кориците се съхраняват в /metadata/items, като активирате тази настройка кориците ще се съхраняват в папката на елемента на вашата библиотека. Само един файл с име \\\"cover\\\" ще бъде запазен\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Запази метаданните с елемента\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека\",\n  \"LabelSettingsTimeFormat\": \"Формат на Време\",\n  \"LabelShare\": \"Сподели\",\n  \"LabelShareDownloadableHelp\": \"Разреши на потребителите през връзка за споделяне да свалят zip файл с мултимедийния елемент.\",\n  \"LabelShareOpen\": \"Общодостъпно\",\n  \"LabelShareURL\": \"URL за споделяне\",\n  \"LabelShowAll\": \"Покажи всички\",\n  \"LabelShowSeconds\": \"Покажи секунди\",\n  \"LabelShowSubtitles\": \"Показвай подзаглавия\",\n  \"LabelSize\": \"Размер\",\n  \"LabelSleepTimer\": \"Таймер за изключване\",\n  \"LabelSlug\": \"Слъг\",\n  \"LabelSortAscending\": \"Възходящ\",\n  \"LabelSortDescending\": \"Низходящ\",\n  \"LabelSortPubDate\": \"Подреди по дата на публикуване\",\n  \"LabelStart\": \"Старт\",\n  \"LabelStartTime\": \"Начално Време\",\n  \"LabelStarted\": \"Стартирано\",\n  \"LabelStartedAt\": \"Стартирано на\",\n  \"LabelStartedDate\": \"Започнато {0}\",\n  \"LabelStatsAudioTracks\": \"Аудио Канали\",\n  \"LabelStatsAuthors\": \"Автори\",\n  \"LabelStatsBestDay\": \"Най-добър ден\",\n  \"LabelStatsDailyAverage\": \"Средно дневно\",\n  \"LabelStatsDays\": \"Общо дни\",\n  \"LabelStatsDaysListened\": \"Общо слушани дни\",\n  \"LabelStatsHours\": \"Часове\",\n  \"LabelStatsInARow\": \"последователно\",\n  \"LabelStatsItemsFinished\": \"Приключени елементи\",\n  \"LabelStatsItemsInLibrary\": \"Елементи в Библиотеката\",\n  \"LabelStatsMinutes\": \"минути\",\n  \"LabelStatsMinutesListening\": \"Общо слушани минути\",\n  \"LabelStatsOverallDays\": \"Общо Дни\",\n  \"LabelStatsOverallHours\": \"Общо Часове\",\n  \"LabelStatsWeekListening\": \"Общо слушани седмици\",\n  \"LabelSubtitle\": \"Подзаглавие\",\n  \"LabelSupportedFileTypes\": \"Поддържани Типове Файлове\",\n  \"LabelTag\": \"Таг\",\n  \"LabelTags\": \"Тагове\",\n  \"LabelTagsAccessibleToUser\": \"Тагове Достъпни за Потребителя\",\n  \"LabelTagsNotAccessibleToUser\": \"Тагове Недостъпни за Потребителя\",\n  \"LabelTasks\": \"Вървящи Задачи\",\n  \"LabelTextEditorBulletedList\": \"Лист с точки\",\n  \"LabelTextEditorLink\": \"Свържи\",\n  \"LabelTextEditorNumberedList\": \"Лист с номера\",\n  \"LabelTextEditorUnlink\": \"Развържи\",\n  \"LabelTheme\": \"Тема\",\n  \"LabelThemeDark\": \"Тъмна\",\n  \"LabelThemeLight\": \"Светла\",\n  \"LabelThemeSepia\": \"Сепия\",\n  \"LabelTimeBase\": \"Времева Основа\",\n  \"LabelTimeDurationXHours\": \"{0} часа\",\n  \"LabelTimeDurationXMinutes\": \"{0} минути\",\n  \"LabelTimeDurationXSeconds\": \"{0} секунди\",\n  \"LabelTimeInMinutes\": \"Време в минути\",\n  \"LabelTimeLeft\": \"остава {0}\",\n  \"LabelTimeListened\": \"Време Слушано\",\n  \"LabelTimeListenedToday\": \"Време Слушано Днес\",\n  \"LabelTimeRemaining\": \"{0} оставащи\",\n  \"LabelTimeToShift\": \"Време за изместване в секунди\",\n  \"LabelTitle\": \"Заглавие\",\n  \"LabelToolsEmbedMetadata\": \"Вграждане на Метаданни\",\n  \"LabelToolsEmbedMetadataDescription\": \"Вграждане на метаданни в аудио файлове, включително корица и глави.\",\n  \"LabelToolsM4bEncoder\": \"M4B кодировчик\",\n  \"LabelToolsMakeM4b\": \"Направи M4B Аудиокнига Файл\",\n  \"LabelToolsMakeM4bDescription\": \"Генериране на .M4B аудиокнига файл с вградени метаданни, корица и глави.\",\n  \"LabelToolsSplitM4b\": \"Раздели M4B на MP3-ки\",\n  \"LabelToolsSplitM4bDescription\": \"Създай MP3-ки от M4B разделени по глави с вградени метаданни, корица и глави.\",\n  \"LabelTotalDuration\": \"Обща Продължителност\",\n  \"LabelTotalTimeListened\": \"Общо Време Слушано\",\n  \"LabelTrackFromFilename\": \"Канал от Име на Файл\",\n  \"LabelTrackFromMetadata\": \"Канал от Метаданни\",\n  \"LabelTracks\": \"Тракове\",\n  \"LabelTracksMultiTrack\": \"Многоканален\",\n  \"LabelTracksNone\": \"Няма канали\",\n  \"LabelTracksSingleTrack\": \"Единичен канал\",\n  \"LabelTrailer\": \"Трейлър\",\n  \"LabelType\": \"Тип\",\n  \"LabelUnabridged\": \"Несъкратен\",\n  \"LabelUndo\": \"Отмени\",\n  \"LabelUnknown\": \"Неизвестен\",\n  \"LabelUnknownPublishDate\": \"Неизвестна дата на публикуване\",\n  \"LabelUpdateCover\": \"Обнови Корица\",\n  \"LabelUpdateCoverHelp\": \"Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение\",\n  \"LabelUpdateDetails\": \"Обнови Детайли\",\n  \"LabelUpdateDetailsHelp\": \"Позволи презаписване на съществуващите детайли за избраните книги, когато се намери съвпадение\",\n  \"LabelUpdatedAt\": \"Обновено на\",\n  \"LabelUploaderDragAndDrop\": \"Плъзни и Пусни Файлове или Папки\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Извлачване на файлове\",\n  \"LabelUploaderDropFiles\": \"Пусни Файлове\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Автоматично вземи заглавие, автор и серия\",\n  \"LabelUseAdvancedOptions\": \"Използвай разширени опции\",\n  \"LabelUseChapterTrack\": \"Използвай канал за глава\",\n  \"LabelUseFullTrack\": \"Използвай пълен канал\",\n  \"LabelUseZeroForUnlimited\": \"Използвай 0 за неограничен\",\n  \"LabelUser\": \"Потребител\",\n  \"LabelUsername\": \"Потребителско име\",\n  \"LabelValue\": \"Стойност\",\n  \"LabelVersion\": \"Версия\",\n  \"LabelViewBookmarks\": \"Виж Отметки\",\n  \"LabelViewChapters\": \"Виж Глави\",\n  \"LabelViewPlayerSettings\": \"Виж настройки на плеъра\",\n  \"LabelViewQueue\": \"Виж Опашка\",\n  \"LabelVolume\": \"Сила на Звука\",\n  \"LabelWebRedirectURLsDescription\": \"Разрешете тези URL-и във вашият OAuth доставчик, за да позволите пренасочването обратно към уеб приложението след вход:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Подпапка за URL адреси за пренасочване\",\n  \"LabelWeekdaysToRun\": \"Делници за изпълнение\",\n  \"LabelXBooks\": \"{0} книги\",\n  \"LabelXItems\": \"{0} елемента\",\n  \"LabelYearReviewHide\": \"Скрий ревю на годината ти\",\n  \"LabelYearReviewShow\": \"Виж ревю на годината ти\",\n  \"LabelYourAudiobookDuration\": \"Продължителност на вашата аудиокнига\",\n  \"LabelYourBookmarks\": \"Твойте отметки\",\n  \"LabelYourPlaylists\": \"Вашите Плейлисти\",\n  \"LabelYourProgress\": \"Твоят прогрес\",\n  \"MessageAddToPlayerQueue\": \"Добави към опашката на плейъра\",\n  \"MessageAppriseDescription\": \"За да ползвате тази функция трябва да имате активна инстанция на <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> или на друго АПИ което да обработва тези заявки. <br />The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от <code>http://192.168.1.1:8337</code> трябва да сложитев <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Уверете се, че използвате ASIN от правилния Audible регион, а не от Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Остарелите API токени ще бъдат премахнати в бъдеще. Вместо това използвайте <a href=\\\"/config/api-keys\\\">API ключове</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Рестартирайте сървърът след записването на настройките, за да активирате OIDC промените.\",\n  \"MessageAuthenticationSecurityMessage\": \"За осигуряването на по-добра сигурност, автентикацията беше подобрена. Всеки потребител ще трябва да се автентикира наново.\",\n  \"MessageBackupsDescription\": \"Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в <code>/metadata/items</code> и <code>/metadata/authors</code>. Резервните копия <strong>не</strong> включват никакви файлове, съхранени в папките на вашата библиотека.\",\n  \"MessageBackupsLocationEditNote\": \"Забележка: Актуализирането на местоположението за архивиране няма да премести или промени съществуващите архиви\",\n  \"MessageBackupsLocationNoEditNote\": \"Забележка: Местоположението за архивиране се задава с помощта на променлива на средата и не може бъде променена от тук.\",\n  \"MessageBackupsLocationPathEmpty\": \"Пътят към местоположението за архивиране не може да бъде празен\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Популирайте активираните полета с данни от всички елементи. Полетата със няколко стоайности ще бъдат обединени\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Попълнете активираните полета с информация за картата с данни от този елемент\",\n  \"MessageBatchQuickMatchDescription\": \"Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.\",\n  \"MessageBookshelfNoCollections\": \"Все още нямате създадени колекции\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Колекциите са публични. Всички потребители с достъп до библиотеката ще могат да ги виждат.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Няма отворени RSS feed-ове\",\n  \"MessageBookshelfNoResultsForFilter\": \"Няма резултат за филтер \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Няма резултати от заявката\",\n  \"MessageBookshelfNoSeries\": \"Нямаш сеЗЙ\",\n  \"MessageBulkChapterPattern\": \"Колко глави искате да добавите, използвайки тази схема за номериране?\",\n  \"MessageChapterEndIsAfter\": \"Краят на главата е след края на вашата аудиокнига\",\n  \"MessageChapterErrorFirstNotZero\": \"Първата глава трябва да започва от 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Началото на главата трябва да бъде по-малко от продължителността на аудиокнигата\",\n  \"MessageChapterErrorStartLtPrev\": \"Началото на главата трябва да бъде по-голямо или равно на края на предишната глава\",\n  \"MessageChapterStartIsAfter\": \"Началото на главата е след края на вашата аудиокнига\",\n  \"MessageChaptersNotFound\": \"Главите не са намерени\",\n  \"MessageCheckingCron\": \"Проверяване на cron...\",\n  \"MessageConfirmCloseFeed\": \"Сигурни ли сте, че искате да затворите този feed?\",\n  \"MessageConfirmDeleteApiKey\": \"Сигурни ли сте, че искате да изтриете API ключ \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Сигурни ли сте, че искате да изтриете този архив {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Сигурни ли сте, че искате да изтриете е-четец \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Това ще изтрие файла от файловата Ви система. Сигурни ли сте?\",\n  \"MessageConfirmDeleteLibrary\": \"Сигурни ли сте, че искате да изтриете за винаги библиотека \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Това ще изтрие елемента от базата данни и файловата Ви система. Сигурни ли сте?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Това ще изтрие {0} елемента от базата данни и файловата Ви система. Сигурни ли сте?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Сигурни ли сте, че искате да изтриете доставчика нa метаданни \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Сигурни ли сте, че искате да изтриете това уведомление?\",\n  \"MessageConfirmDeleteSession\": \"Сигурни ли сте, че искате да изтриете тази сесия?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Сигурнли ли сте, че искате да вградите метаданните в {0} аудио файла?\",\n  \"MessageConfirmForceReScan\": \"Сигурни ли сте, че искате да принудите повторно сканиране?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Сигурни ли сте, че искате да маркирате всички епизоди като завършени?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?\",\n  \"MessageConfirmMarkItemFinished\": \"Сигурни ли сте, че искате да маркирате \\\"{0}\\\" като приключено?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Сигурни ли сте, че искате да маркирате \\\"{0}\\\" като неприключено?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Пуснете това уведомление с тестови данни?\",\n  \"MessageConfirmPurgeCache\": \"Изчистването на кеша ще изтрие цялата директория в <code>/metadata/cache</code>. <br /><br />Сигурни ли сте, че искате да премахнете директорията на кеша?\",\n  \"MessageConfirmPurgeItemsCache\": \"Изчистването на кеша на елементите ще изтрие цялата директория в <code>/metadata/cache/items</code>. <br />Сигурни ли сте?\",\n  \"MessageConfirmQuickEmbed\": \"Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове. <br><br>Искате ли да продължите?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Бързото сравняване на епизоди ще презапише детайлите, ако се намери съвпадение. Само не съвпаднали епизоди ще бъдат обновени. Сигурни ли сте?\",\n  \"MessageConfirmReScanLibraryItems\": \"Сигурни ли сте, че искате да сканирате отново {0} елемента?\",\n  \"MessageConfirmRemoveAllChapters\": \"Сигурни ли сте, че искате да премахнете всички глави?\",\n  \"MessageConfirmRemoveAuthor\": \"Сигурни ли сте, че искате да премахнете автор \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Сигурни ли сте, че искате да премахнете колекция \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Сигурни ли сте, че искате да премахнете епизод \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Забележка: Това няма да доведе до изтриване на аудио файла, освен ако не активирате опцията \\\"Твърдо изтриване на файла\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Сигурни ли сте, че искате да премахнете {0} епизода?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Сигурни ли сте, че искате да премахнете {0} слушателски сесии?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Сигурни ли сте, че искате да премахнете всичките метаданни. {0} файлове във папките на Вашата библиотека?\",\n  \"MessageConfirmRemoveNarrator\": \"Сигурни ли сте, че искате да премахнете разказвач \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Сигурни ли сте, че искате да премахнете плейлиста \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Сигурни ли сте, че искате да преименувате жанра \\\"{0}\\\" на \\\"{1}\\\" за всички елементи?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Забележка: Този жанр вече съществува и ще бъде слято.\",\n  \"MessageConfirmRenameGenreWarning\": \"Внимание! Вече съществува подобен жанр с различно писане \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Сигурни ли сте, че искате да преименувате таг \\\"{0}\\\" на \\\"{1}\\\" за всички елементи?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Забележка: Този таг вече съществува и ще бъде слято.\",\n  \"MessageConfirmRenameTagWarning\": \"Внимание! Вече съществува подобен таг с различно писане \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Сигурни ли сте, че искате да нулирате прогреса си?\",\n  \"MessageConfirmSendEbookToDevice\": \"Сигурни ли сте, че искате да изпратите {0} електронна книга \\\"{1}\\\" до устройство \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Сигурни ли сте, че искате да отвържете този потребител от OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} дни слушане през последната година\",\n  \"MessageDownloadingEpisode\": \"Сваля епизод\",\n  \"MessageDragFilesIntoTrackOrder\": \"Плъзнете файлове в правилния ред на каналите\",\n  \"MessageEmbedFailed\": \"Вграждането беше неуспешно!\",\n  \"MessageEmbedFinished\": \"Вграждането завърши!\",\n  \"MessageEmbedQueue\": \"Поставено в опашката за вграждане на метаданни ({0} в опашката)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Епизод(и) са сложени за сваляне\",\n  \"MessageEreaderDevices\": \"За да осигурите доставката на е-книги, може да се наложи да добавите горепосочения имейл адрес като валиден подател за всяко устройство, изброено по-долу.\",\n  \"MessageFeedURLWillBe\": \"Адресът на емисията ще бъде {0}\",\n  \"MessageFetching\": \"Извличане...\",\n  \"MessageForceReScanDescription\": \"ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} слушане</strong> на {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Няма сесии за слушане на {0}\",\n  \"MessageImportantNotice\": \"Важно Съобщение!\",\n  \"MessageInsertChapterBelow\": \"Вмъкни глава под\",\n  \"MessageInvalidAsin\": \"Невалиден ASIN\",\n  \"MessageItemsSelected\": \"{0} избрани елемента\",\n  \"MessageItemsUpdated\": \"{0} обновени елемента\",\n  \"MessageJoinUsOn\": \"Присъединете се към нас\",\n  \"MessageLoading\": \"Зарежда...\",\n  \"MessageLoadingFolders\": \"Зареждане на Папки...\",\n  \"MessageLogsDescription\": \"Логовете се съхраняват в <code>/metadata/logs</code> като JSON файлове. Дневниците за сривове се съхраняват в <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B Провалено!\",\n  \"MessageM4BFinished\": \"M4B Завършено!\",\n  \"MessageMapChapterTitles\": \"Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената\",\n  \"MessageMarkAllEpisodesFinished\": \"Маркирай всички епизоди като завършени\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Маркирай всички епизоди като незавършени\",\n  \"MessageMarkAsFinished\": \"Маркирай като завършено\",\n  \"MessageMarkAsNotFinished\": \"Маркирай като Незавършено\",\n  \"MessageMatchBooksDescription\": \"ще се опита да съпостави книги в библиотеката с книга от избрания доставчик за търсене и ще попълни празни детайли и корици. Не презаписва детайлите.\",\n  \"MessageNoAudioTracks\": \"Няма аудио канали\",\n  \"MessageNoAuthors\": \"Няма Автори\",\n  \"MessageNoBackups\": \"Няма архиви\",\n  \"MessageNoBookmarks\": \"Няма отметки\",\n  \"MessageNoChapters\": \"Няма глави\",\n  \"MessageNoCollections\": \"Няма колекции\",\n  \"MessageNoCoversFound\": \"Не са намерени корици\",\n  \"MessageNoDescription\": \"Няма описание\",\n  \"MessageNoDevices\": \"Няма устройства\",\n  \"MessageNoDownloadsInProgress\": \"Няма изтегляния в прогрес\",\n  \"MessageNoDownloadsQueued\": \"Няма изтегляния в опашка\",\n  \"MessageNoEpisodeMatchesFound\": \"Няма намерени съвпадения за епизоди\",\n  \"MessageNoEpisodes\": \"Няма Епизоди\",\n  \"MessageNoFoldersAvailable\": \"Няма налични папки\",\n  \"MessageNoGenres\": \"Няма Жанрове\",\n  \"MessageNoIssues\": \"Няма проблеми\",\n  \"MessageNoItems\": \"Няма елементи\",\n  \"MessageNoItemsFound\": \"Няма намерени елементи\",\n  \"MessageNoListeningSessions\": \"Няма сесии за слушане\",\n  \"MessageNoLogs\": \"Няма логове\",\n  \"MessageNoMediaProgress\": \"Няма прогрес на медията\",\n  \"MessageNoNotifications\": \"Няма известия\",\n  \"MessageNoPodcastFeed\": \"Невалиден подкаст: Няма канал\",\n  \"MessageNoPodcastsFound\": \"Няма намерени подкасти\",\n  \"MessageNoResults\": \"Няма резултати\",\n  \"MessageNoSearchResultsFor\": \"Няма резултати за \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Няма Серии\",\n  \"MessageNoTags\": \"Няма Тагове\",\n  \"MessageNoTasksRunning\": \"Няма вършещи се задачи\",\n  \"MessageNoUpdatesWereNecessary\": \"Няма нужда от обновяване\",\n  \"MessageNoUserPlaylists\": \"Нямате създадени плейлисти\",\n  \"MessageNoUserPlaylistsHelp\": \"Плейлистите за частни. Само създалият ги потребител ще може да ги вижда.\",\n  \"MessageNotYetImplemented\": \"Още не е изпълнено\",\n  \"MessageOpmlPreviewNote\": \"Забележка: Това е преглед на анализирания OPML файл. Действителното заглавие на подкаста ще бъде взето от RSS фийда.\",\n  \"MessageOr\": \"или\",\n  \"MessagePauseChapter\": \"Пауза на глава\",\n  \"MessagePlayChapter\": \"Пусни налчалото на глава\",\n  \"MessagePlaylistCreateFromCollection\": \"Създай плейлист от колекция\",\n  \"MessagePleaseWait\": \"Моля изчакайте...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Подкастът няма URL адрес на RSS feed за използване за съпоставяне\",\n  \"MessagePodcastSearchField\": \"Въведи какво да търся или RSS емисия адрес\",\n  \"MessageQuickEmbedInProgress\": \"Бързото вграждане е в процес на изпълнение\",\n  \"MessageQuickEmbedQueue\": \"Поставено в опашката за бързо вграждане ({0} в опашката)\",\n  \"MessageQuickMatchAllEpisodes\": \"Бързо Сравняване на Всички Епизоди\",\n  \"MessageQuickMatchDescription\": \"Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.\",\n  \"MessageRemoveChapter\": \"Премахни глава\",\n  \"MessageRemoveEpisodes\": \"Премахни {0} епизод(и)\",\n  \"MessageRemoveFromPlayerQueue\": \"Премахни от опашката на плейъра\",\n  \"MessageRemoveUserWarning\": \"Сигурни ли сте, че искате да изтриете потребител \\\"{0}\\\" завинаги?\",\n  \"MessageReportBugsAndContribute\": \"Докладвайте грешки, поискайте нови функции и допринасяйте на\",\n  \"MessageResetChaptersConfirm\": \"Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?\",\n  \"MessageRestoreBackupConfirm\": \"Сигурни ли сте, че искате да възстановите архива създаден на\",\n  \"MessageRestoreBackupWarning\": \"Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.<br /><br />Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.<br /><br />Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.\",\n  \"MessageScheduleLibraryScanNote\": \"За повече потребители се препоръчва да оставят този фийчър изключен и да оставят настройката \\\"Автоматично преглеждане за промени в библиотеката\\\" включена - тя автоматично ще засече промени в папките на вашата библиотека. Включете тази настройка ако \\\"Автоматично преглеждане за промени в библиотеката\\\" не рабови на вашата файлова система (например NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Изпълни всеки {0} в {1}\",\n  \"MessageSearchResultsFor\": \"Резултати от търсенето за\",\n  \"MessageSelected\": \"{0} избрани\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Подредбата в серия не може да съдържа шпации.\",\n  \"MessageServerCouldNotBeReached\": \"Сървърът не може да бъде достигнат\",\n  \"MessageSetChaptersFromTracksDescription\": \"Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла\",\n  \"MessageShareExpirationWillBe\": \"Изтичането ще бъде на <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Изтича след {0}\",\n  \"MessageShareURLWillBe\": \"URL за споделяне ще бъде <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Започни възпроизвеждане на \\\"{0}\\\" в {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"На Аудио файл \\\"{0}\\\" не може да се записва\",\n  \"MessageTaskCanceledByUser\": \"Задачата е отказана от потребител\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Изтегляне на епизод \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Вграждане на метаданни\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Вграждане на метаданни в аудиокнига \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Кодиране M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Кодиране на аудиокнига \\\"{0}\\\" в единичен m4b файл\",\n  \"MessageTaskFailed\": \"Неуспешно\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Неуспешно създаване на разервно копие на аудио файл \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Неуспешно създаване на директория за кеширане\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Неуспешно вграждане на метаданни във файл \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Неуспешно сливане на аудио файловете\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Неуспешно преместване на m4b файл\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Неуспешно записване на файла за метаданни\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Съответстващи книги в библиотека \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Няма файлове за сканиране\",\n  \"MessageTaskOpmlImport\": \"OPML импортиране\",\n  \"MessageTaskOpmlImportDescription\": \"Създаване на подкасти от {0} RSS хранилки\",\n  \"MessageTaskOpmlImportFeed\": \"OPML импортиран фийд\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Импортиране на RSS хранилка \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Неуспешно взимане на подкаст фийд\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Създаване на подкаст \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"На този път вече съществува подкаст\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Неуспешно създаване на подкаст\",\n  \"MessageTaskOpmlImportFinished\": \"Добавени {0} подкаста\",\n  \"MessageTaskOpmlParseFailed\": \"Неуспешно анализиране на OPML файла\",\n  \"MessageTaskOpmlParseFastFail\": \"Невалиден OPML файл, не беше намерен нито <opml> таг нито <outline> таг\",\n  \"MessageTaskOpmlParseNoneFound\": \"Няма намерени канали във OPML файла\",\n  \"MessageTaskScanItemsAdded\": \"{0} добавени\",\n  \"MessageTaskScanItemsMissing\": \"{0} липсващи\",\n  \"MessageTaskScanItemsUpdated\": \"{0} обновени\",\n  \"MessageTaskScanNoChangesNeeded\": \"Не са нужни промени\",\n  \"MessageTaskScanningFileChanges\": \"Проверка за промени във файловете в \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Сканиране на \\\"{0}\\\" библиотека\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Целевата директория не е достъпна за запис\",\n  \"MessageThinking\": \"Мисля...\",\n  \"MessageUploaderItemFailed\": \"Неуспешно качване\",\n  \"MessageUploaderItemSuccess\": \"Успешно качване!\",\n  \"MessageUploading\": \"Качва се...\",\n  \"MessageValidCronExpression\": \"Валиден cron expression\",\n  \"MessageWatcherIsDisabledGlobally\": \"Наблюдателят е деактивиран глобално в настройките на сървъра\",\n  \"MessageXLibraryIsEmpty\": \"{0} библиотеката е празна!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Продължителността на вашата аудиокнига е по-дълга от намерената\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Продължителността на вашата аудиокнига е по-кратка от намерената\",\n  \"NoteChangeRootPassword\": \"Root потребителят е единственият потребител, който може да има празна парола\",\n  \"NoteChapterEditorTimes\": \"Забележка: Първото време на начало на главата трябва да остане на 0:00, а последното време на начало на главата не може да надвишава продължителността на тази аудиокнига.\",\n  \"NoteFolderPicker\": \"Забележка: папките, които вече са картографирани, няма да бъдат показани\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Предупреждение: Повечето приложения за подкасти изискват URL адресът на RSS емисията да използва HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Предупреждение: Един или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.\",\n  \"NoteUploaderUnsupportedFiles\": \"Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.\",\n  \"NotificationOnBackupCompletedDescription\": \"Изпълнява се при завършване на създаване на резервно копие\",\n  \"NotificationOnBackupFailedDescription\": \"Изпълнява се при неуспешено създаване на резервно копие\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Изпълнява се при автоматично изтегляне на подкаст епизод\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод\",\n  \"PlaceholderNewCollection\": \"Ново име на колекцията\",\n  \"PlaceholderNewFolderPath\": \"Нов път на папката\",\n  \"PlaceholderNewPlaylist\": \"Ново име на плейлиста\",\n  \"PlaceholderSearch\": \"Търсене...\",\n  \"PlaceholderSearchEpisode\": \"Търсене на Епизоди...\",\n  \"StatsAuthorsAdded\": \"добаврени автори\",\n  \"StatsBooksAdded\": \"добавени книги\",\n  \"StatsBooksFinished\": \"завършени книги\",\n  \"ToastAccountUpdateSuccess\": \"Успешно обновяване на акаунта\",\n  \"ToastAuthorImageRemoveSuccess\": \"Авторската снимка е премахната\",\n  \"ToastAuthorUpdateMerged\": \"Обновяване на автора сливано\",\n  \"ToastAuthorUpdateSuccess\": \"Автора обновен\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Автор обновен (не е намерена снимка)\",\n  \"ToastBackupCreateFailed\": \"Неуспешно създаване на архив\",\n  \"ToastBackupCreateSuccess\": \"Архивът е създаден\",\n  \"ToastBackupDeleteFailed\": \"Неуспешно изтриване на архив\",\n  \"ToastBackupDeleteSuccess\": \"Архивът е изтрит\",\n  \"ToastBackupRestoreFailed\": \"Неуспешно възстановяване на архив\",\n  \"ToastBackupUploadFailed\": \"Неуспешно качване на архив\",\n  \"ToastBackupUploadSuccess\": \"Архивът е качен\",\n  \"ToastBatchUpdateFailed\": \"Неуспешно групово актуализиране\",\n  \"ToastBatchUpdateSuccess\": \"Успешно групово актуализиране\",\n  \"ToastBookmarkCreateFailed\": \"Неуспешно създаване на отметка\",\n  \"ToastBookmarkCreateSuccess\": \"Отметката е създадена\",\n  \"ToastBookmarkRemoveSuccess\": \"Отметката е премахната\",\n  \"ToastCachePurgeFailed\": \"Неуспешно изчистване на кеша\",\n  \"ToastCachePurgeSuccess\": \"Успешно изчистване на кеша\",\n  \"ToastChaptersHaveErrors\": \"Главите имат грешки\",\n  \"ToastChaptersMustHaveTitles\": \"Главите трябва да имат заглавия\",\n  \"ToastCollectionRemoveSuccess\": \"Колекцията е премахната\",\n  \"ToastCollectionUpdateSuccess\": \"Колекцията е обновена\",\n  \"ToastDeleteFileFailed\": \"Неуспешно изтриване на файла\",\n  \"ToastDeleteFileSuccess\": \"Успешно изтриване на файла\",\n  \"ToastFailedToLoadData\": \"Неуспешно зареждане на данни\",\n  \"ToastItemCoverUpdateSuccess\": \"Корицата на елемента е обновена\",\n  \"ToastItemDetailsUpdateSuccess\": \"Детайлите на елемента са обновени\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Неуспешно маркиране като Завършено\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Елементът е маркиран като завършен\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Неуспешно маркиране като Незавършено\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Елементът е маркиран като незавършен\",\n  \"ToastLibraryCreateFailed\": \"Неуспешно създаване на библиотека\",\n  \"ToastLibraryCreateSuccess\": \"Библиотеката \\\"{0}\\\" е създадена\",\n  \"ToastLibraryDeleteFailed\": \"Неуспешно изтриване на библиотека\",\n  \"ToastLibraryDeleteSuccess\": \"Библиотеката е изтрита\",\n  \"ToastLibraryScanFailedToStart\": \"Неуспешно стартиране на сканиране\",\n  \"ToastLibraryScanStarted\": \"Сканирането на библиотеката е стартирано\",\n  \"ToastLibraryUpdateSuccess\": \"Библиотеката \\\"{0}\\\" е обновена\",\n  \"ToastPlaylistCreateFailed\": \"Неуспешно създаване на плейлист\",\n  \"ToastPlaylistCreateSuccess\": \"Плейлистът е създаден\",\n  \"ToastPlaylistRemoveSuccess\": \"Плейлистът е премахнат\",\n  \"ToastPlaylistUpdateSuccess\": \"Плейлистът е обновен\",\n  \"ToastPodcastCreateFailed\": \"Неуспешно създаване на подкаст\",\n  \"ToastPodcastCreateSuccess\": \"Подкаст успешно създаден\",\n  \"ToastRSSFeedCloseFailed\": \"Неуспешно затваряне на RSS емисията\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS емисията е затворена\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Неуспешно премахване на елемент от колекция\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Елементът е премахнат от колекция\",\n  \"ToastSendEbookToDeviceFailed\": \"Неуспешно изпращане на електронна книга до устройство\",\n  \"ToastSendEbookToDeviceSuccess\": \"Електронната книга е изпратена до устройство \\\"{0}\\\"\",\n  \"ToastSeriesUpdateFailed\": \"Неуспешно обновяване на серия\",\n  \"ToastSeriesUpdateSuccess\": \"Серията е обновена\",\n  \"ToastServerSettingsUpdateSuccess\": \"Настройките на сървъра са актуализирани\",\n  \"ToastSessionDeleteFailed\": \"Неуспешно изтриване на сесия\",\n  \"ToastSessionDeleteSuccess\": \"Сесията е изтрита\",\n  \"ToastSocketConnected\": \"Свързан сокет\",\n  \"ToastSocketDisconnected\": \"Сокетът е прекъснат\",\n  \"ToastSocketFailedToConnect\": \"Неуспешно свързване на сокет\",\n  \"ToastSortingPrefixesEmptyError\": \"Трябва да има поне 1 префикс за сортиране\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Префиксите за сортиране са актуализирани ({0} елемента)\",\n  \"ToastUserDeleteFailed\": \"Неуспешно изтриване на потребител\",\n  \"ToastUserDeleteSuccess\": \"Потребителят е изтрит\"\n}\n"
  },
  {
    "path": "client/strings/bn.json",
    "content": "{\n  \"ButtonAdd\": \"যোগ করুন\",\n  \"ButtonAddApiKey\": \"এপিআই কী যোগ করুন\",\n  \"ButtonAddChapters\": \"অধ্যায় যোগ করুন\",\n  \"ButtonAddDevice\": \"ডিভাইস যোগ করুন\",\n  \"ButtonAddLibrary\": \"লাইব্রেরি যোগ করুন\",\n  \"ButtonAddPodcasts\": \"পডকাস্ট যোগ করুন\",\n  \"ButtonAddUser\": \"ব্যবহারকারী যোগ করুন\",\n  \"ButtonAddYourFirstLibrary\": \"আপনার প্রথম লাইব্রেরি যোগ করুন\",\n  \"ButtonApply\": \"প্রয়োগ করুন\",\n  \"ButtonApplyChapters\": \"অধ্যায় প্রয়োগ করুন\",\n  \"ButtonAuthors\": \"লেখকগণ\",\n  \"ButtonBack\": \"পেছনে যান\",\n  \"ButtonBatchEditPopulateFromExisting\": \"বিদ্যমান থেকে পূরণ করুন\",\n  \"ButtonBatchEditPopulateMapDetails\": \"ম্যাপ থেকে পূরণ করুন\",\n  \"ButtonBrowseForFolder\": \"ফোল্ডারের জন্য ব্রাউজ করুন\",\n  \"ButtonCancel\": \"বাতিল করুন\",\n  \"ButtonCancelEncode\": \"এনকোড বাতিল করুন\",\n  \"ButtonChangeRootPassword\": \"রুট পাসওয়ার্ড পরিবর্তন করুন\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"নতুন পর্বগুলি পরীক্ষা এবং ডাউনলোড করুন\",\n  \"ButtonChooseAFolder\": \"একটি ফোল্ডার চয়ন করুন\",\n  \"ButtonChooseFiles\": \"ফাইল চয়ন করুন\",\n  \"ButtonClearFilter\": \"ফিল্টার পরিষ্কার করুন\",\n  \"ButtonClose\": \"বন্ধ করুন\",\n  \"ButtonCloseFeed\": \"ফিড বন্ধ করুন\",\n  \"ButtonCloseSession\": \"খোলা সেশন বন্ধ করুন\",\n  \"ButtonCollections\": \"সংগ্রহ\",\n  \"ButtonConfigureScanner\": \"স্ক্যানার কনফিগার করুন\",\n  \"ButtonCreate\": \"তৈরি করুন\",\n  \"ButtonCreateBackup\": \"ব্যাকআপ তৈরি করুন\",\n  \"ButtonDelete\": \"মুছুন\",\n  \"ButtonDownloadQueue\": \"সারি\",\n  \"ButtonEdit\": \"সম্পাদনা করুন\",\n  \"ButtonEditChapters\": \"অধ্যায় সম্পাদনা করুন\",\n  \"ButtonEditPodcast\": \"পডকাস্ট সম্পাদনা করুন\",\n  \"ButtonEnable\": \"সক্রিয় করুন\",\n  \"ButtonFireAndFail\": \"সক্রিয় এবং ব্যর্থ\",\n  \"ButtonFireOnTest\": \"পরীক্ষামূলক ইভেন্টে সক্রিয় করুন\",\n  \"ButtonForceReScan\": \"জোরপূর্বক পুনরায় স্ক্যান করুন\",\n  \"ButtonFullPath\": \"সম্পূর্ণ পথ\",\n  \"ButtonHide\": \"লুকান\",\n  \"ButtonHome\": \"নীড়\",\n  \"ButtonIssues\": \"ইস্যু\",\n  \"ButtonJumpBackward\": \"পিছনে লাফ দিন\",\n  \"ButtonJumpForward\": \"সামনে লাফ দিন\",\n  \"ButtonLatest\": \"সর্বশেষ\",\n  \"ButtonLibrary\": \"লাইব্রেরি\",\n  \"ButtonLogout\": \"লগআউট\",\n  \"ButtonLookup\": \"সন্ধান\",\n  \"ButtonManageTracks\": \"ট্র্যাকগুলি পরিচালনা করুন\",\n  \"ButtonMapChapterTitles\": \"অধ্যায়ের শিরোনাম ম্যাপ করুন\",\n  \"ButtonMatchAllAuthors\": \"সমস্ত লেখকের সাথে মিল করুন\",\n  \"ButtonMatchBooks\": \"বইগুলো মিল করুন\",\n  \"ButtonNevermind\": \"কিছু মনে করবেন না\",\n  \"ButtonNext\": \"পরবর্তী\",\n  \"ButtonNextChapter\": \"পরবর্তী অধ্যায়\",\n  \"ButtonNextItemInQueue\": \"সারিতে পরের আইটেম\",\n  \"ButtonOk\": \"ঠিক আছে\",\n  \"ButtonOpenFeed\": \"ফিড খুলুন\",\n  \"ButtonOpenManager\": \"ম্যানেজার খুলুন\",\n  \"ButtonPause\": \"বিরতি\",\n  \"ButtonPlay\": \"বাজান\",\n  \"ButtonPlayAll\": \"সব চালান\",\n  \"ButtonPlaying\": \"বাজছে\",\n  \"ButtonPlaylists\": \"প্লেলিস্ট\",\n  \"ButtonPrevious\": \"পূর্ববর্তী\",\n  \"ButtonPreviousChapter\": \"আগের অধ্যায়\",\n  \"ButtonProbeAudioFile\": \"প্রোব অডিও ফাইল\",\n  \"ButtonPurgeAllCache\": \"সমস্ত ক্যাশে পরিষ্কার করুন\",\n  \"ButtonPurgeItemsCache\": \"আইটেম ক্যাশে পরিষ্কার করুন\",\n  \"ButtonQueueAddItem\": \"সারিতে যোগ করুন\",\n  \"ButtonQueueRemoveItem\": \"সারি থেকে মুছে ফেলুন\",\n  \"ButtonQuickEmbed\": \"দ্রুত এম্বেড করুন\",\n  \"ButtonQuickEmbedMetadata\": \"মেটাডেটা দ্রুত এম্বেড করুন\",\n  \"ButtonQuickMatch\": \"দ্রুত ম্যাচ\",\n  \"ButtonReScan\": \"পুনরায় স্ক্যান\",\n  \"ButtonRead\": \"পড়ুন\",\n  \"ButtonReadLess\": \"সংক্ষিপ্ত\",\n  \"ButtonReadMore\": \"বিস্তারিত পড়ুন\",\n  \"ButtonRefresh\": \"রিফ্রেশ\",\n  \"ButtonRemove\": \"মুছে ফেলুন\",\n  \"ButtonRemoveAll\": \"সব মুছে ফেলুন\",\n  \"ButtonRemoveAllLibraryItems\": \"সমস্ত লাইব্রেরি আইটেম মুছে ফেলুন\",\n  \"ButtonRemoveFromContinueListening\": \"শোনা চালিয়ে যাওয়া থেকে মুছে ফেলুন\",\n  \"ButtonRemoveFromContinueReading\": \"পঠন চালিয়ে যান থেকে মুছে ফেলুন\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"কন্টিনিউ সিরিজ থেকে সিরিজ মুছে ফেলুন\",\n  \"ButtonReset\": \"রিসেট\",\n  \"ButtonResetToDefault\": \"ডিফল্টে পুনরায় সেট করুন\",\n  \"ButtonRestore\": \"পুনরুদ্ধার করুন\",\n  \"ButtonSave\": \"সংরক্ষণ করুন\",\n  \"ButtonSaveAndClose\": \"সংরক্ষণ এবং বন্ধ করুন\",\n  \"ButtonSaveTracklist\": \"ট্র্যাকলিস্ট সংরক্ষণ করুন\",\n  \"ButtonScan\": \"স্ক্যান\",\n  \"ButtonScanLibrary\": \"স্ক্যান লাইব্রেরি\",\n  \"ButtonScrollLeft\": \"বাম দিকে স্ক্রল করুন\",\n  \"ButtonScrollRight\": \"ডানদিকে স্ক্রল করুন\",\n  \"ButtonSearch\": \"অনুসন্ধান\",\n  \"ButtonSelectFolderPath\": \"ফোল্ডারের পথ নির্বাচন করুন\",\n  \"ButtonSeries\": \"সিরিজ\",\n  \"ButtonSetChaptersFromTracks\": \"ট্র্যাক থেকে অধ্যায় সেট করুন\",\n  \"ButtonShare\": \"শেয়ার করুন\",\n  \"ButtonShiftTimes\": \"সময় শিফট করুন\",\n  \"ButtonShow\": \"দেখান\",\n  \"ButtonStartM4BEncode\": \"M4B এনকোড শুরু করুন\",\n  \"ButtonStartMetadataEmbed\": \"মেটাডেটা এম্বেড শুরু করুন\",\n  \"ButtonStats\": \"পরিসংখ্যান\",\n  \"ButtonSubmit\": \"জমা দিন\",\n  \"ButtonTest\": \"পরীক্ষা\",\n  \"ButtonUnlinkOpenId\": \"ওপেন আইডি লিঙ্কমুক্ত করুন\",\n  \"ButtonUpload\": \"আপলোড\",\n  \"ButtonUploadBackup\": \"আপলোড ব্যাকআপ\",\n  \"ButtonUploadCover\": \"কভার আপলোড করুন\",\n  \"ButtonUploadOPMLFile\": \"OPML ফাইল আপলোড করুন\",\n  \"ButtonUserDelete\": \"ব্যবহারকারী {0} মুছুন\",\n  \"ButtonUserEdit\": \"ব্যবহারকারী {0} সম্পাদনা করুন\",\n  \"ButtonViewAll\": \"সমস্ত দেখুন\",\n  \"ButtonYes\": \"হ্যাঁ\",\n  \"ErrorUploadFetchMetadataAPI\": \"মেটাডেটা আনতে ত্রুটি হচ্ছে\",\n  \"ErrorUploadFetchMetadataNoResults\": \"মেটাডেটা আনা যায়নি - শিরোনাম এবং/অথবা লেখক আপডেট করার চেষ্টা করুন\",\n  \"ErrorUploadLacksTitle\": \"একটি শিরোনাম থাকতে হবে\",\n  \"HeaderAccount\": \"অ্যাকাউন্ট\",\n  \"HeaderAddCustomMetadataProvider\": \"কাস্টম মেটাডেটা সরবরাহকারী যোগ করুন\",\n  \"HeaderAdvanced\": \"অ্যাডভান্সড\",\n  \"HeaderApiKeys\": \"এপিআই কী সমূহ\",\n  \"HeaderAppriseNotificationSettings\": \"বিজ্ঞপ্তি সেটিংস অবহিত করুন\",\n  \"HeaderAudioTracks\": \"অডিও ট্র্যাকসগুলো\",\n  \"HeaderAudiobookTools\": \"অডিওবই ফাইল ম্যানেজমেন্ট টুলস\",\n  \"HeaderAuthentication\": \"প্রমাণীকরণ\",\n  \"HeaderBackups\": \"ব্যাকআপ\",\n  \"HeaderBulkChapterModal\": \"একাধিক অধ্যায় যোগ করুন\",\n  \"HeaderChangePassword\": \"পাসওয়ার্ড পরিবর্তন করুন\",\n  \"HeaderChapters\": \"অধ্যায়\",\n  \"HeaderChooseAFolder\": \"একটি ফোল্ডার চয়ন করুন\",\n  \"HeaderCollection\": \"সংগ্রহ\",\n  \"HeaderCollectionItems\": \"সংগ্রহ আইটেম\",\n  \"HeaderCover\": \"কভার\",\n  \"HeaderCurrentDownloads\": \"বর্তমান ডাউনলোডগুলি\",\n  \"HeaderCustomMessageOnLogin\": \"লগইন এ কাস্টম বার্তা\",\n  \"HeaderCustomMetadataProviders\": \"কাস্টম মেটাডেটা প্রদানকারী\",\n  \"HeaderDetails\": \"বিস্তারিত\",\n  \"HeaderDownloadQueue\": \"ডাউনলোড সারি\",\n  \"HeaderEbookFiles\": \"ই-বই ফাইল\",\n  \"HeaderEmail\": \"ইমেইল\",\n  \"HeaderEmailSettings\": \"ইমেল সেটিংস\",\n  \"HeaderEpisodes\": \"পর্ব\",\n  \"HeaderEreaderDevices\": \"ই-রিডার ডিভাইস\",\n  \"HeaderEreaderSettings\": \"ই-রিডার সেটিংস\",\n  \"HeaderFiles\": \"ফাইল\",\n  \"HeaderFindChapters\": \"অধ্যায় খুঁজুন\",\n  \"HeaderIgnoredFiles\": \"উপেক্ষিত ফাইল\",\n  \"HeaderItemFiles\": \"আইটেম ফাইল\",\n  \"HeaderItemMetadataUtils\": \"আইটেম মেটাডেটা ইউটিলস\",\n  \"HeaderLastListeningSession\": \"শেষ শোনার অধিবেশন\",\n  \"HeaderLatestEpisodes\": \"সর্বশেষ পর্ব\",\n  \"HeaderLibraries\": \"লাইব্রেরি\",\n  \"HeaderLibraryFiles\": \"লাইব্রেরি ফাইল\",\n  \"HeaderLibraryStats\": \"লাইব্রেরি পরিসংখ্যান\",\n  \"HeaderListeningSessions\": \"শোনার সেশন\",\n  \"HeaderListeningStats\": \"শোনার পরিসংখ্যান\",\n  \"HeaderLogin\": \"লগইন\",\n  \"HeaderLogs\": \"লগস\",\n  \"HeaderManageGenres\": \"ঘরানাগুলো পরিচালনা করুন\",\n  \"HeaderManageTags\": \"ট্যাগগুলো পরিচালনা করুন\",\n  \"HeaderMapDetails\": \"মানচিত্রের বিবরণ\",\n  \"HeaderMatch\": \"ম্যাচ\",\n  \"HeaderMetadataOrderOfPrecedence\": \"মেটাডেটা অগ্রাধিকারের ক্রম\",\n  \"HeaderMetadataToEmbed\": \"এম্বেড করার জন্য মেটাডেটা\",\n  \"HeaderNewAccount\": \"নতুন অ্যাকাউন্ট\",\n  \"HeaderNewApiKey\": \"নতুন API কী\",\n  \"HeaderNewLibrary\": \"নতুন লাইব্রেরি\",\n  \"HeaderNotificationCreate\": \"বিজ্ঞপ্তি তৈরি করুন\",\n  \"HeaderNotificationUpdate\": \"বিজ্ঞপ্তি আপডেট করুন\",\n  \"HeaderNotifications\": \"বিজ্ঞপ্তি\",\n  \"HeaderOpenIDConnectAuthentication\": \"ওপেনআইডি সংযোগ প্রমাণীকরণ\",\n  \"HeaderOpenListeningSessions\": \"শোনার সেশন খুলুন\",\n  \"HeaderOpenRSSFeed\": \"আরএসএস ফিড খুলুন\",\n  \"HeaderOtherFiles\": \"অন্যান্য ফাইল\",\n  \"HeaderPasswordAuthentication\": \"পাসওয়ার্ড প্রমাণীকরণ\",\n  \"HeaderPermissions\": \"অনুমতি\",\n  \"HeaderPlayerQueue\": \"প্লেয়ার সারি\",\n  \"HeaderPlayerSettings\": \"প্লেয়ার সেটিংস\",\n  \"HeaderPlaylist\": \"প্লেলিস্ট\",\n  \"HeaderPlaylistItems\": \"প্লেলিস্ট আইটেম\",\n  \"HeaderPodcastsToAdd\": \"যোগ করার জন্য পডকাস্ট\",\n  \"HeaderPresets\": \"প্রিসেট\",\n  \"HeaderPreviewCover\": \"কভার ্দেখুন\",\n  \"HeaderRSSFeedGeneral\": \"আরএসএস বিবরণ\",\n  \"HeaderRSSFeedIsOpen\": \"আরএসএস ফিড খোলা আছে\",\n  \"HeaderRSSFeeds\": \"আরএসএস ফিড\",\n  \"HeaderRemoveEpisode\": \"পর্বটি সরান\",\n  \"HeaderRemoveEpisodes\": \"{0}টি পর্ব সরান\",\n  \"HeaderSavedMediaProgress\": \"মিডিয়া সংরক্ষণের অগ্রগতি\",\n  \"HeaderSchedule\": \"সময়সূচী\",\n  \"HeaderScheduleEpisodeDownloads\": \"স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন\",\n  \"HeaderScheduleLibraryScans\": \"স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী\",\n  \"HeaderSession\": \"সেশন\",\n  \"HeaderSetBackupSchedule\": \"ব্যাকআপ সময়সূচী সেট করুন\",\n  \"HeaderSettings\": \"সেটিংস\",\n  \"HeaderSettingsDisplay\": \"প্রদর্শন\",\n  \"HeaderSettingsExperimental\": \"পরীক্ষামূলক ফিচার\",\n  \"HeaderSettingsGeneral\": \"সাধারণ\",\n  \"HeaderSettingsScanner\": \"স্ক্যানার\",\n  \"HeaderSettingsSecurity\": \"নিরাপত্তা\",\n  \"HeaderSettingsWebClient\": \"ওয়েব ক্লায়েন্ট\",\n  \"HeaderSleepTimer\": \"স্লিপ টাইমার\",\n  \"HeaderStatsLargestItems\": \"সবচেয়ে বড় আইটেম\",\n  \"HeaderStatsLongestItems\": \"দীর্ঘতম আইটেম (ঘন্টা)\",\n  \"HeaderStatsMinutesListeningChart\": \"মিনিট শ্রবণ (গত ৭ দিন)\",\n  \"HeaderStatsRecentSessions\": \"সাম্প্রতিক সেশন\",\n  \"HeaderStatsTop10Authors\": \"শীর্ষ ১০ জন লেখক\",\n  \"HeaderStatsTop5Genres\": \"শীর্ষ ৫ টি ঘরানা\",\n  \"HeaderTableOfContents\": \"বিষয়বস্তুর সারণী\",\n  \"HeaderTools\": \"টুলস\",\n  \"HeaderUpdateAccount\": \"অ্যাকাউন্ট আপডেট করুন\",\n  \"HeaderUpdateAuthor\": \"লেখক আপডেট করুন\",\n  \"HeaderUpdateDetails\": \"বিশদ আপডেট করুন\",\n  \"HeaderUpdateLibrary\": \"লাইব্রেরি আপডেট করুন\",\n  \"HeaderUsers\": \"ব্যবহারকারীরা\",\n  \"HeaderYearReview\": \"বাৎসরিক পর্যালোচনা {0}\",\n  \"HeaderYourStats\": \"আপনার পরিসংখ্যান\",\n  \"LabelAbridged\": \"সংক্ষিপ্ত\",\n  \"LabelAbridgedChecked\": \"সংক্ষিপ্ত (চেক)\",\n  \"LabelAbridgedUnchecked\": \"অসংক্ষেপিত (চেক করা হয়নি)\",\n  \"LabelAccessibleBy\": \"দ্বারা প্রবেশযোগ্য\",\n  \"LabelAccountType\": \"অ্যাকাউন্টের প্রকার\",\n  \"LabelAccountTypeAdmin\": \"প্রশাসন\",\n  \"LabelAccountTypeGuest\": \"অতিথি\",\n  \"LabelAccountTypeUser\": \"ব্যবহারকারী\",\n  \"LabelActivity\": \"ক্রিয়াকলাপ\",\n  \"LabelAddToCollection\": \"সংগ্রহে যোগ করুন\",\n  \"LabelAddToCollectionBatch\": \"সংগ্রহে {0}টি বই যোগ করুন\",\n  \"LabelAddToPlaylist\": \"প্লেলিস্টে যোগ করুন\",\n  \"LabelAddToPlaylistBatch\": \"প্লেলিস্টে {0}টি আইটেম যোগ করুন\",\n  \"LabelAddedAt\": \"এতে যোগ করা হয়েছে\",\n  \"LabelAddedDate\": \"যোগ করা হয়েছে {0}\",\n  \"LabelAdminUsersOnly\": \"শুধু অ্যাডমিন ব্যবহারকারী\",\n  \"LabelAll\": \"সব\",\n  \"LabelAllUsers\": \"সমস্ত ব্যবহারকারী\",\n  \"LabelAllUsersExcludingGuests\": \"অতিথি ব্যতীত সকল ব্যবহারকারী\",\n  \"LabelAllUsersIncludingGuests\": \"অতিথি সহ সকল ব্যবহারকারী\",\n  \"LabelAlreadyInYourLibrary\": \"ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে\",\n  \"LabelApiToken\": \"API টোকেন\",\n  \"LabelAppend\": \"সংযোজন\",\n  \"LabelAudioBitrate\": \"অডিও বিটরেট (যেমন- 128k)\",\n  \"LabelAudioChannels\": \"অডিও চ্যানেল (১ বা ২)\",\n  \"LabelAudioCodec\": \"অডিও কোডেক\",\n  \"LabelAuthor\": \"লেখক\",\n  \"LabelAuthorFirstLast\": \"লেখক (প্রথম শেষ)\",\n  \"LabelAuthorLastFirst\": \"লেখক (শেষ, প্রথম)\",\n  \"LabelAuthors\": \"লেখকগণ\",\n  \"LabelAutoDownloadEpisodes\": \"স্বয়ংক্রিয় ডাউনলোড পর্ব\",\n  \"LabelAutoFetchMetadata\": \"স্বয়ংক্রিয় ফেচ মেটাডেটা\",\n  \"LabelAutoFetchMetadataHelp\": \"আপলোডিং স্ট্রিমলাইন করার জন্য শিরোনাম, লেখক এবং সিরিজের জন্য মেটাডেটা খুঁজুন। আপলোড করার পরে অতিরিক্ত মেটাডেটা মিলতে হতে পারে।\",\n  \"LabelAutoLaunch\": \"স্বয়ংক্রিয় আরম্ভ\",\n  \"LabelAutoLaunchDescription\": \"লগইন পৃষ্ঠায় নেভিগেট করার সময় স্বয়ংক্রিয়ভাবে অনুমোদন প্রদানকারীর কাছে পুনঃনির্দেশ করুন (হস্তকৃত ওভাররাইড পথ <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"স্বয়ংক্রিয় নিবন্ধন\",\n  \"LabelAutoRegisterDescription\": \"লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন\",\n  \"LabelBackToUser\": \"ব্যবহারকারীর কাছে ফিরে যান\",\n  \"LabelBackupAudioFiles\": \"অডিও ফাইলগুলো ব্যাকআপ\",\n  \"LabelBackupLocation\": \"ব্যাকআপ অবস্থান\",\n  \"LabelBackupsEnableAutomaticBackups\": \"স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত\",\n  \"LabelBackupsMaxBackupSize\": \"সর্বোচ্চ ব্যাকআপ আকার (GB-তে) (অসীমের জন্য 0)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"ভুল কনফিগারেশনের বিরুদ্ধে সুরক্ষা হিসেবে ব্যাকআপগুলি ব্যর্থ হবে যদি তারা কনফিগার করা আকার অতিক্রম করে।\",\n  \"LabelBackupsNumberToKeep\": \"ব্যাকআপের সংখ্যা রাখুন\",\n  \"LabelBackupsNumberToKeepHelp\": \"এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।\",\n  \"LabelBitrate\": \"বিটরেট\",\n  \"LabelBonus\": \"উপরিলাভ\",\n  \"LabelBooks\": \"বইগুলো\",\n  \"LabelButtonText\": \"ঘর পাঠ্য\",\n  \"LabelByAuthor\": \"দ্বারা {0}\",\n  \"LabelChangePassword\": \"পাসওয়ার্ড পরিবর্তন করুন\",\n  \"LabelChannels\": \"চ্যানেল\",\n  \"LabelChapterCount\": \"{0} অধ্যায়\",\n  \"LabelChapterTitle\": \"অধ্যায়ের শিরোনাম\",\n  \"LabelChapters\": \"অধ্যায়\",\n  \"LabelChaptersFound\": \"অধ্যায় পাওয়া গেছে\",\n  \"LabelClickForMoreInfo\": \"আরো তথ্যের জন্য ক্লিক করুন\",\n  \"LabelClickToUseCurrentValue\": \"বর্তমান মান ব্যবহার করতে ক্লিক করুন\",\n  \"LabelClosePlayer\": \"প্লেয়ার বন্ধ করুন\",\n  \"LabelCodec\": \"কোডেক\",\n  \"LabelCollapseSeries\": \"সিরিজ সঙ্কুচিত করুন\",\n  \"LabelCollapseSubSeries\": \"উপ-সিরিজ সঙ্কুচিত করুন\",\n  \"LabelCollection\": \"সংগ্রহ\",\n  \"LabelCollections\": \"সংগ্রহ\",\n  \"LabelComplete\": \"সম্পূর্ণ\",\n  \"LabelConfirmPassword\": \"পাসওয়ার্ড নিশ্চিত করুন\",\n  \"LabelContinueListening\": \"শোনা চালিয়ে যান\",\n  \"LabelContinueReading\": \"পড়া চালিয়ে যান\",\n  \"LabelContinueSeries\": \"সিরিজ চালিয়ে যান\",\n  \"LabelCover\": \"কভার\",\n  \"LabelCoverImageURL\": \"ছবির কভারের URL\",\n  \"LabelCreatedAt\": \"তৈরি করা হয়েছে\",\n  \"LabelCronExpression\": \"Cron এক্সপ্রেশন\",\n  \"LabelCurrent\": \"বর্তমান\",\n  \"LabelCurrently\": \"বর্তমানে:\",\n  \"LabelCustomCronExpression\": \"কাস্টম Cron এক্সপ্রেশন:\",\n  \"LabelDatetime\": \"তারিখ সময়\",\n  \"LabelDays\": \"দিনগুলো\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"ফাইল সিস্টেম থেকে মুছে ফেলুন (শুধু ডাটাবেস থেকে সরাতে টিক চিহ্ন মুক্ত করুন)\",\n  \"LabelDescription\": \"বিবরণ\",\n  \"LabelDeselectAll\": \"সমস্ত অনির্বাচিত করুন\",\n  \"LabelDevice\": \"ডিভাইস\",\n  \"LabelDeviceInfo\": \"ডিভাইস তথ্য\",\n  \"LabelDeviceIsAvailableTo\": \"ডিভাইস এর জন্য উপলব্ধ...\",\n  \"LabelDirectory\": \"ডিরেক্টরি\",\n  \"LabelDiscFromFilename\": \"ফাইলের নাম থেকে ডিস্ক\",\n  \"LabelDiscFromMetadata\": \"মেটাডেটা থেকে ডিস্ক\",\n  \"LabelDiscover\": \"আবিষ্কার\",\n  \"LabelDownload\": \"ডাউনলোড করুন\",\n  \"LabelDownloadNEpisodes\": \"{0}টি পর্ব ডাউনলোড করুন\",\n  \"LabelDownloadable\": \"ডাউনলোডযোগ্য\",\n  \"LabelDuration\": \"সময়কাল\",\n  \"LabelDurationComparisonExactMatch\": \"(সঠিক মিল)\",\n  \"LabelDurationComparisonLonger\": \"({0} দীর্ঘ)\",\n  \"LabelDurationComparisonShorter\": \"({0} ছোট)\",\n  \"LabelDurationFound\": \"সময়কাল পাওয়া গেছে:\",\n  \"LabelEbook\": \"ই-বই\",\n  \"LabelEbooks\": \"ই-বইগুলো\",\n  \"LabelEdit\": \"সম্পাদনা করুন\",\n  \"LabelEmail\": \"ইমেইল\",\n  \"LabelEmailSettingsFromAddress\": \"ঠিকানা থেকে\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"অননুমোদিত সার্টিফিকেট প্রত্যাখ্যান করুন\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"SSL প্রমাণপত্রের বৈধতা নিষ্ক্রিয় করা আপনার সংযোগকে নিরাপত্তা ঝুঁকিতে ফেলতে পারে, যেমন ম্যান-ইন-দ্য-মিডল আক্রমণ। শুধুমাত্র এই বিকল্পটি নিষ্ক্রিয় করুন যদি আপনি এর প্রভাবগুলি বুঝতে পারেন এবং আপনি যে মেইল সার্ভারের সাথে সংযোগ করছেন তাকে বিশ্বাস করেন।\",\n  \"LabelEmailSettingsSecure\": \"নিরাপদ\",\n  \"LabelEmailSettingsSecureHelp\": \"যদি সত্য হয় সার্ভারের সাথে সংযোগ করার সময় সংযোগটি TLS ব্যবহার করবে। মিথ্যা হলে TLS ব্যবহার করা হবে যদি সার্ভার STARTTLS এক্সটেনশন সমর্থন করে। বেশিরভাগ ক্ষেত্রে এই মানটিকে সত্য হিসাবে সেট করুন যদি আপনি পোর্ট 465-এর সাথে সংযোগ করছেন। পোর্ট 587 বা পোর্টের জন্য 25 এটি মিথ্যা রাখুন। (nodemailer.com/smtp/#authentication থেকে)\",\n  \"LabelEmailSettingsTestAddress\": \"পরীক্ষার ঠিকানা\",\n  \"LabelEmbeddedCover\": \"এম্বেডেড কভার\",\n  \"LabelEnable\": \"সক্ষম করুন\",\n  \"LabelEncodingBackupLocation\": \"আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।\",\n  \"LabelEncodingClearItemCache\": \"পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।\",\n  \"LabelEncodingFinishedM4B\": \"সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:\",\n  \"LabelEncodingInfoEmbedded\": \"আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।\",\n  \"LabelEncodingStartedNavigation\": \"একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।\",\n  \"LabelEncodingTimeWarning\": \"এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।\",\n  \"LabelEncodingWarningAdvancedSettings\": \"সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।\",\n  \"LabelEncodingWatcherDisabled\": \"আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।\",\n  \"LabelEnd\": \"সমাপ্ত\",\n  \"LabelEndOfChapter\": \"অধ্যায়ের সমাপ্তি\",\n  \"LabelEpisode\": \"পর্ব\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি\",\n  \"LabelEpisodeNumber\": \"পর্ব #{0}\",\n  \"LabelEpisodeTitle\": \"পর্বের শিরোনাম\",\n  \"LabelEpisodeType\": \"পর্বের ধরন\",\n  \"LabelEpisodeUrlFromRssFeed\": \"আরএসএস ফিড থেকে পর্ব URL\",\n  \"LabelEpisodes\": \"পর্বগুলো\",\n  \"LabelEpisodic\": \"প্রাসঙ্গিক\",\n  \"LabelExample\": \"উদাহরণ\",\n  \"LabelExpandSeries\": \"সিরিজ প্রসারিত করুন\",\n  \"LabelExpandSubSeries\": \"সাব সিরিজ প্রসারিত করুন\",\n  \"LabelExplicit\": \"বিশদ\",\n  \"LabelExplicitChecked\": \"সুস্পষ্ট (পরীক্ষিত)\",\n  \"LabelExplicitUnchecked\": \"অস্পষ্ট (অপরিক্ষীত)\",\n  \"LabelExportOPML\": \"OPML এক্সপোর্ট করুন\",\n  \"LabelFeedURL\": \"ফিড ইউআরএল\",\n  \"LabelFetchingMetadata\": \"মেটাডেটা আনা হচ্ছে\",\n  \"LabelFile\": \"ফাইল\",\n  \"LabelFileBirthtime\": \"ফাইল জন্মের সময়\",\n  \"LabelFileBornDate\": \"জন্ম {0}\",\n  \"LabelFileModified\": \"ফাইল পরিবর্তিত\",\n  \"LabelFileModifiedDate\": \"পরিবর্তিত {0}\",\n  \"LabelFilename\": \"ফাইলের নাম\",\n  \"LabelFilterByUser\": \"ব্যবহারকারী দ্বারা ফিল্টারকৃত\",\n  \"LabelFindEpisodes\": \"পর্বগুলো খুঁজুন\",\n  \"LabelFinished\": \"সমাপ্ত\",\n  \"LabelFolder\": \"ফোল্ডার\",\n  \"LabelFolders\": \"ফোল্ডারগুলো\",\n  \"LabelFontBold\": \"বোল্ড\",\n  \"LabelFontBoldness\": \"হরফ বোল্ডনেস\",\n  \"LabelFontFamily\": \"হরফ পরিবার\",\n  \"LabelFontItalic\": \"ইটালিক\",\n  \"LabelFontScale\": \"ফন্ট স্কেল\",\n  \"LabelFontStrikethrough\": \"অবচ্ছেদন রেখা\",\n  \"LabelFormat\": \"ফরম্যাট\",\n  \"LabelFull\": \"পূর্ণ\",\n  \"LabelGenre\": \"ঘরানা\",\n  \"LabelGenres\": \"ঘরানাগুলো\",\n  \"LabelHardDeleteFile\": \"জোরপূর্বক ফাইল মুছে ফেলুন\",\n  \"LabelHasEbook\": \"ই-বই আছে\",\n  \"LabelHasSupplementaryEbook\": \"পরিপূরক ই-বই আছে\",\n  \"LabelHideSubtitles\": \"সাবটাইটেল লুকান\",\n  \"LabelHighestPriority\": \"সর্বোচ্চ অগ্রাধিকার\",\n  \"LabelHost\": \"নিমন্ত্রণকর্তা\",\n  \"LabelHour\": \"ঘন্টা\",\n  \"LabelHours\": \"ঘন্টা\",\n  \"LabelIcon\": \"আইকন\",\n  \"LabelImageURLFromTheWeb\": \"ওয়েব থেকে ছবির ইউআরএল\",\n  \"LabelInProgress\": \"প্রগতিতে আছে\",\n  \"LabelIncludeInTracklist\": \"ট্র্যাকলিস্টে অন্তর্ভুক্ত করুন\",\n  \"LabelIncomplete\": \"অসম্পূর্ণ\",\n  \"LabelInterval\": \"বিরতি\",\n  \"LabelIntervalCustomDailyWeekly\": \"কাস্টম দৈনিক/সাপ্তাহিক\",\n  \"LabelIntervalEvery12Hours\": \"প্রতি ১২ ঘন্টায়\",\n  \"LabelIntervalEvery15Minutes\": \"প্রতি ১৫ মিনিটে\",\n  \"LabelIntervalEvery2Hours\": \"প্রতি ২ ঘন্টায়\",\n  \"LabelIntervalEvery30Minutes\": \"প্রতি ৩০ মিনিটে\",\n  \"LabelIntervalEvery6Hours\": \"প্রতি ৬ ঘন্টায়\",\n  \"LabelIntervalEveryDay\": \"প্রতিদিন\",\n  \"LabelIntervalEveryHour\": \"প্রতি ঘন্টা\",\n  \"LabelInvert\": \"উল্টানো\",\n  \"LabelItem\": \"আইটেম\",\n  \"LabelJumpBackwardAmount\": \"পিছন দিকে ঝাঁপের পরিমাণ\",\n  \"LabelJumpForwardAmount\": \"সামনের দিকে ঝাঁপের পরিমাণ\",\n  \"LabelLanguage\": \"ভাষা\",\n  \"LabelLanguageDefaultServer\": \"সার্ভারের ডিফল্ট ভাষা\",\n  \"LabelLanguages\": \"ভাষাসমূহ\",\n  \"LabelLastBookAdded\": \"শেষ বই যোগ করা হয়েছে\",\n  \"LabelLastBookUpdated\": \"শেষ বই আপডেট করা হয়েছে\",\n  \"LabelLastSeen\": \"শেষ দেখা\",\n  \"LabelLastTime\": \"শেষ বার\",\n  \"LabelLastUpdate\": \"শেষ আপডেট\",\n  \"LabelLayout\": \"লেআউট\",\n  \"LabelLayoutSinglePage\": \"একক পৃষ্ঠা\",\n  \"LabelLayoutSplitPage\": \"বিভক্ত পৃষ্ঠা\",\n  \"LabelLess\": \"কম\",\n  \"LabelLibrariesAccessibleToUser\": \"ব্যবহারকারীর কাছে অ্যাক্সেসযোগ্য লাইব্রেরি\",\n  \"LabelLibrary\": \"লাইব্রেরি\",\n  \"LabelLibraryFilterSublistEmpty\": \"না {0}\",\n  \"LabelLibraryItem\": \"লাইব্রেরি আইটেম\",\n  \"LabelLibraryName\": \"লাইব্রেরির নাম\",\n  \"LabelLimit\": \"সীমা\",\n  \"LabelLineSpacing\": \"লাইন স্পেসিং\",\n  \"LabelListenAgain\": \"আবার শুনুন\",\n  \"LabelLogLevelDebug\": \"ডিবাগ\",\n  \"LabelLogLevelInfo\": \"তথ্য\",\n  \"LabelLogLevelWarn\": \"সতর্ক\",\n  \"LabelLookForNewEpisodesAfterDate\": \"এই তারিখের পরে নতুন পর্বগুলি সন্ধান করুন\",\n  \"LabelLowestPriority\": \"সর্বনিম্ন অগ্রাধিকার\",\n  \"LabelMatchExistingUsersBy\": \"বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন\",\n  \"LabelMatchExistingUsersByDescription\": \"বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে\",\n  \"LabelMaxEpisodesToDownload\": \"সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে\",\n  \"LabelMaxEpisodesToKeep\": \"সর্বোচ্চ # টি পর্ব রাখা হবে\",\n  \"LabelMaxEpisodesToKeepHelp\": \"০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।\",\n  \"LabelMediaPlayer\": \"মিডিয়া প্লেয়ার\",\n  \"LabelMediaType\": \"মিডিয়ার ধরন\",\n  \"LabelMetaTag\": \"মেটা ট্যাগ\",\n  \"LabelMetaTags\": \"মেটা ট্যাগগুলো\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"উচ্চ অগ্রাধিকারের মেটাডেটার উৎসগুলো নিম্ন অগ্রাধিকারের মেটাডেটা উৎসগুলোকে ওভাররাইড করবে\",\n  \"LabelMetadataProvider\": \"মেটাডেটা প্রদানকারী\",\n  \"LabelMinute\": \"মিনিট\",\n  \"LabelMinutes\": \"মিনিটস\",\n  \"LabelMissing\": \"নিখোঁজ\",\n  \"LabelMissingEbook\": \"কোনও ই-বই নেই\",\n  \"LabelMissingSupplementaryEbook\": \"কোনও সম্পূরক ই-বই নেই\",\n  \"LabelMobileRedirectURIs\": \"অনুমোদিত মোবাইল রিডাইরেক্ট URIs\",\n  \"LabelMobileRedirectURIsDescription\": \"এটি মোবাইল অ্যাপের জন্য বৈধ পুনঃনির্দেশিত URI-এর একটি সাদা তালিকা। ডিফল্টটি হল <code>audiobookshelf://oauth</code>, যা আপনি তৃতীয় পক্ষের অ্যাপ ইন্টিগ্রেশনের জন্য অতিরিক্ত URI-এর সাথে সরাতে বা সম্পূরক করতে পারেন। একটি তারকাচিহ্ন (<code>*</code>) ব্যবহার করে একমাত্র এন্ট্রি যেকোন ইউআরআইকে অনুমতি দেয়।\",\n  \"LabelMore\": \"আরো\",\n  \"LabelMoreInfo\": \"আরো তথ্য\",\n  \"LabelName\": \"নাম\",\n  \"LabelNarrator\": \"কথক\",\n  \"LabelNarrators\": \"কথক\",\n  \"LabelNew\": \"নতুন\",\n  \"LabelNewPassword\": \"নতুন পাসওয়ার্ড\",\n  \"LabelNewestAuthors\": \"নতুন লেখক\",\n  \"LabelNewestEpisodes\": \"নতুনতম পর্ব\",\n  \"LabelNextBackupDate\": \"পরবর্তী ব্যাকআপ তারিখ\",\n  \"LabelNextScheduledRun\": \"পরবর্তী নির্ধারিত দৌড়\",\n  \"LabelNoCustomMetadataProviders\": \"কোনো কাস্টম মেটাডেটা প্রদানকারী নেই\",\n  \"LabelNoEpisodesSelected\": \"কোন পর্ব নির্বাচন করা হয়নি\",\n  \"LabelNotFinished\": \"সমাপ্ত হয়নি\",\n  \"LabelNotStarted\": \"শুরু হয়নি\",\n  \"LabelNotes\": \"নোটস\",\n  \"LabelNotificationAppriseURL\": \"অবহিত URL(গুলি)\",\n  \"LabelNotificationAvailableVariables\": \"ব্যবহারযোগ্য ভেরিয়েবল\",\n  \"LabelNotificationBodyTemplate\": \"বডি টেমপ্লেট\",\n  \"LabelNotificationEvent\": \"ইভেন্ট বিজ্ঞপ্তি\",\n  \"LabelNotificationTitleTemplate\": \"শিরোনাম টেমপ্লেট\",\n  \"LabelNotificationsMaxFailedAttempts\": \"সর্বোচ্চ ব্যর্থ প্রচেষ্টা\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"এটি বারবার পাঠাতে ব্যর্থ হলে বিজ্ঞপ্তি অক্ষম করা হবে\",\n  \"LabelNotificationsMaxQueueSize\": \"বিজ্ঞপ্তি ইভেন্টের জন্য সর্বোচ্চ সারির আকার\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"ইভেন্টগুলি প্রতি সেকেন্ডে ১ বার ইন্ধন করার মধ্যে সীমাবদ্ধ। সারি সর্বাধিক আকারে থাকলে ইভেন্টগুলি উপেক্ষা করা হবে। এটি বিজ্ঞপ্তি স্প্যামিং প্রতিরোধ করে।\",\n  \"LabelNumberOfBooks\": \"বইয়ের সংখ্যা\",\n  \"LabelNumberOfEpisodes\": \"# টি পর্ব\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"ওপেনআইডি দাবির নাম যাতে অ্যাপ্লিকেশনের মধ্যে ব্যবহারকারীর ক্রিয়াকলাপের জন্য উন্নত অনুমতি রয়েছে যা অ-প্রশাসক ভূমিকাগুলিতে প্রযোজ্য হবে (<b>যদি কনফিগার করা হয়</b>)। প্রতিক্রিয়া থেকে দাবিটি অনুপস্থিত থাকলে, অ্যাক্সেস করুন ABS-তে অস্বীকার করা হবে। যদি একটি একক বিকল্প অনুপস্থিত থাকে, তাহলে এটিকে <code>false</code> হিসাবে গণ্য করা হবে। নিশ্চিত করুন যে পরিচয় প্রদানকারীর দাবি প্রত্যাশিত কাঠামোর সাথে মেলে:\",\n  \"LabelOpenIDClaims\": \"অ্যাডভান্সড গ্রুপ এবং পারমিশন অ্যাসাইনমেন্ট নিষ্ক্রিয় করতে নিম্নলিখিত বিকল্পগুলিকে খালি ছেড়ে দিন, তারপর স্বয়ংক্রিয়ভাবে 'ব্যবহারকারী' গ্রুপকে বরাদ্দ করা হবে।\",\n  \"LabelOpenIDGroupClaimDescription\": \"ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।\",\n  \"LabelOpenRSSFeed\": \"আরএসএস ফিড খুলুন\",\n  \"LabelOverwrite\": \"পুনঃলিখিত\",\n  \"LabelPaginationPageXOfY\": \"{1} টির মধ্যে {0} পৃষ্ঠা\",\n  \"LabelPassword\": \"পাসওয়ার্ড\",\n  \"LabelPath\": \"পথ\",\n  \"LabelPermanent\": \"স্থায়ী\",\n  \"LabelPermissionsAccessAllLibraries\": \"সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে\",\n  \"LabelPermissionsAccessAllTags\": \"সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে\",\n  \"LabelPermissionsAccessExplicitContent\": \"স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে\",\n  \"LabelPermissionsCreateEreader\": \"ইরিডার তৈরি করতে পারেন\",\n  \"LabelPermissionsDelete\": \"মুছে দিতে পারবে\",\n  \"LabelPermissionsDownload\": \"ডাউনলোড করতে পারবে\",\n  \"LabelPermissionsUpdate\": \"আপডেট করতে পারবে\",\n  \"LabelPermissionsUpload\": \"আপলোড করতে পারবে\",\n  \"LabelPersonalYearReview\": \"আপনার বছরের পর্যালোচনা ({0})\",\n  \"LabelPhotoPathURL\": \"ছবি পথ/ইউআরএল\",\n  \"LabelPlayMethod\": \"প্লে পদ্ধতি\",\n  \"LabelPlayerChapterNumberMarker\": \"{1} এর মধ্যে {0}\",\n  \"LabelPlaylists\": \"প্লেলিস্ট\",\n  \"LabelPodcast\": \"পডকাস্ট\",\n  \"LabelPodcastSearchRegion\": \"পডকাস্ট অনুসন্ধান অঞ্চল\",\n  \"LabelPodcastType\": \"পডকাস্টের ধরন\",\n  \"LabelPodcasts\": \"পডকাস্টগুলো\",\n  \"LabelPort\": \"পোর্ট\",\n  \"LabelPrefixesToIgnore\": \"উপেক্ষা করার উপসর্গ (কেস সংবেদনশীল)\",\n  \"LabelPreventIndexing\": \"আইটিউনস এবং গুগল পডকাস্ট ডিরেক্টরি দ্বারা আপনার ফিডকে ইন্ডেক্স করা থেকে বিরত রাখুন\",\n  \"LabelPrimaryEbook\": \"প্রাথমিক ই-বই\",\n  \"LabelProgress\": \"প্রগতি\",\n  \"LabelProvider\": \"প্রদানকারী\",\n  \"LabelProviderAuthorizationValue\": \"অনুমোদন শিরোনামের মান\",\n  \"LabelPubDate\": \"প্রকাশের তারিখ\",\n  \"LabelPublishYear\": \"প্রকাশের বছর\",\n  \"LabelPublishedDate\": \"প্রকাশিত {0}\",\n  \"LabelPublishedDecade\": \"প্রকাশনার দশক\",\n  \"LabelPublishedDecades\": \"প্রকাশনার দশকগুলো\",\n  \"LabelPublisher\": \"প্রকাশক\",\n  \"LabelPublishers\": \"প্রকাশকরা\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"কাস্টম মালিকের ইমেইল\",\n  \"LabelRSSFeedCustomOwnerName\": \"কাস্টম মালিকের নাম\",\n  \"LabelRSSFeedOpen\": \"আরএসএস ফিড খুলুন\",\n  \"LabelRSSFeedPreventIndexing\": \"সূচীকরণ প্রতিরোধ করুন\",\n  \"LabelRSSFeedSlug\": \"আরএসএস ফিড স্লাগ\",\n  \"LabelRSSFeedURL\": \"আরএসএস ফিড ইউআরএল\",\n  \"LabelRandomly\": \"এলোমেলোভাবে\",\n  \"LabelReAddSeriesToContinueListening\": \"শোনা চালিয়ে যেতে সিরিজ পুনরায় যোগ করুন\",\n  \"LabelRead\": \"পড়ুন\",\n  \"LabelReadAgain\": \"আবার পড়ুন\",\n  \"LabelReadEbookWithoutProgress\": \"প্রগতি না রেখে ই-বই পড়ুন\",\n  \"LabelRecentSeries\": \"সাম্প্রতিক সিরিজ\",\n  \"LabelRecentlyAdded\": \"সম্প্রতি যোগ করা হয়েছে\",\n  \"LabelRecommended\": \"সুপারিশকৃত\",\n  \"LabelRedo\": \"পুনরায় করুন\",\n  \"LabelRegion\": \"অঞ্চল\",\n  \"LabelReleaseDate\": \"উন্মোচনের তারিখ\",\n  \"LabelRemoveAllMetadataAbs\": \"সমস্ত metadata.abs ফাইল সরান\",\n  \"LabelRemoveAllMetadataJson\": \"সমস্ত metadata.json ফাইল সরান\",\n  \"LabelRemoveCover\": \"কভার সরান\",\n  \"LabelRemoveMetadataFile\": \"লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান\",\n  \"LabelRemoveMetadataFileHelp\": \"আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।\",\n  \"LabelRowsPerPage\": \"প্রতি পৃষ্ঠায় সারি\",\n  \"LabelSearchTerm\": \"অনুসন্ধান শব্দ\",\n  \"LabelSearchTitle\": \"অনুসন্ধান শিরোনাম\",\n  \"LabelSearchTitleOrASIN\": \"অনুসন্ধান শিরোনাম বা ASIN\",\n  \"LabelSeason\": \"সেশন\",\n  \"LabelSeasonNumber\": \"মরসুম #{0}\",\n  \"LabelSelectAll\": \"সব নির্বাচন করুন\",\n  \"LabelSelectAllEpisodes\": \"সমস্ত পর্ব নির্বাচন করুন\",\n  \"LabelSelectEpisodesShowing\": \"দেখানো {0}টি পর্ব নির্বাচন করুন\",\n  \"LabelSelectUsers\": \"ব্যবহারকারী নির্বাচন করুন\",\n  \"LabelSendEbookToDevice\": \"ই-বই পাঠান...\",\n  \"LabelSequence\": \"ক্রম\",\n  \"LabelSerial\": \"ধারাবাহিক\",\n  \"LabelSeries\": \"সিরিজ\",\n  \"LabelSeriesName\": \"সিরিজের নাম\",\n  \"LabelSeriesProgress\": \"সিরিজের অগ্রগতি\",\n  \"LabelServerLogLevel\": \"সার্ভার লগ লেভেল\",\n  \"LabelServerYearReview\": \"সার্ভারের বাৎসরিক পর্যালোচনা ({0})\",\n  \"LabelSetEbookAsPrimary\": \"প্রাথমিক হিসাবে সেট করুন\",\n  \"LabelSetEbookAsSupplementary\": \"পরিপূরক হিসেবে সেট করুন\",\n  \"LabelSettingsAllowIframe\": \"আইফ্রেমে এম্বেড করার অনুমতি দিন\",\n  \"LabelSettingsAudiobooksOnly\": \"শুধুমাত্র অডিও বই\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে\",\n  \"LabelSettingsBookshelfViewHelp\": \"কাঠের তাক সহ স্কুমরফিক ডিজাইন\",\n  \"LabelSettingsChromecastSupport\": \"ক্রোমকাস্ট সমর্থন\",\n  \"LabelSettingsDateFormat\": \"তারিখ বিন্যাস\",\n  \"LabelSettingsEnableWatcherHelp\": \"ফাইলের পরিবর্তন শনাক্ত হলে আইটেমগুলির স্বয়ংক্রিয় যোগ/আপডেট সক্ষম করবে। *সার্ভার পুনরায় চালু করতে হবে\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"ইপাবে স্ক্রিপ্ট করা বিষয়বস্তুর অনুমতি দিন\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"ইপাব ফাইলগুলিকে স্ক্রিপ্ট চালানোর অনুমতি দিন। আপনি ইপাব ফাইলগুলির উৎসকে বিশ্বাস না করলে এই সেটিংটি নিষ্ক্রিয় রাখার সুপারিশ করা হলো।\",\n  \"LabelSettingsExperimentalFeatures\": \"পরীক্ষামূলক বৈশিষ্ট্য\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"ফিচারের বৈশিষ্ট্য যা আপনার প্রতিক্রিয়া ব্যবহার করতে পারে এবং পরীক্ষায় সহায়তা করতে পারে। গিটহাব আলোচনা খুলতে ক্লিক করুন।\",\n  \"LabelSettingsFindCovers\": \"কভার খুঁজুন\",\n  \"LabelSettingsFindCoversHelp\": \"যদি আপনার অডিওবইয়ের ফোল্ডারের ভিতরে একটি এমবেডেড কভার বা কভার ইমেজ না থাকে, তাহলে স্ক্যানার একটি কভার খোঁজার চেষ্টা করবে৷<br>দ্রষ্টব্য: এটি স্ক্যানের সময় বাড়িয়ে দেবে\",\n  \"LabelSettingsHideSingleBookSeries\": \"একক বই সিরিজ লুকান\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।\",\n  \"LabelSettingsHomePageBookshelfView\": \"নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন\",\n  \"LabelSettingsLibraryBookshelfView\": \"লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"শতকরা সম্পূর্ণ এর চেয়ে বেশি\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"বাকি সময় (সেকেন্ড) এর চেয়ে কম\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।\",\n  \"LabelSettingsParseSubtitles\": \"সাবটাইটেল পার্স করুন\",\n  \"LabelSettingsParseSubtitlesHelp\": \"অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল বের করুন৷<br>সাবটাইটেল অবশ্যই \\\" - \\\"<br>অর্থাৎ \\\"বুকের শিরোনাম - এখানে একটি সাবটাইটেল\\\" এর সাবটাইটেল আছে \\\"এখানে একটি সাবটাইটেল\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"মিলিত মেটাডেটা পছন্দ করুন\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"দ্রুত ম্যাচ ব্যবহার করার সময় মিলে যাওয়া ডেটা আইটেমের বিবরণকে ওভাররাইড করবে। ডিফল্টরূপে দ্রুত ম্যাচ শুধুমাত্র অনুপস্থিত বিশদগুলি পূরণ করবে।\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"এমন বইগুলি এড়িয়ে যান যেগুলির মধ্যে ইতিমধ্যে একটি ASIN আছে\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"ইতিমধ্যে একটি ISBN আছে এমন মেলা বইগুলি এড়িয়ে যান\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"বাছাই করার সময় উপসর্গ উপেক্ষা করুন\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"অর্থাৎ \\\"বইয়ের শিরোনাম\\\" বইয়ের শিরোনাম \\\"বইয়ের শিরোনাম, \\\" হিসাবে সাজানো হবে উপসর্গের জন্য\",\n  \"LabelSettingsSquareBookCovers\": \"বর্গাকার বইয়ের কভার ব্যবহার করুন\",\n  \"LabelSettingsSquareBookCoversHelp\": \"প্রমাণ ১.৬:১ বইয়ের কভারের চেয়ে বর্গাকার কভার ব্যবহার করতে পছন্দ করুন\",\n  \"LabelSettingsStoreCoversWithItem\": \"আইটেম সহ কভার সংরক্ষণ\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"ডিফল্টভাবে কভারগুলি /মেটাডাটা/আইটেমগুলিতে সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে আপনার লাইব্রেরি আইটেম ফোল্ডারে কভারগুলি সংরক্ষণ করা হবে৷ \\\"কভার\\\" নামে শুধুমাত্র একটি ফাইল রাখা হবে\",\n  \"LabelSettingsStoreMetadataWithItem\": \"আইটেমের সাথে মেটাডেটা সংরক্ষণ করুন\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে\",\n  \"LabelSettingsTimeFormat\": \"সময় বিন্যাস\",\n  \"LabelShare\": \"শেয়ার করুন\",\n  \"LabelShareDownloadableHelp\": \"শেয়ার লিঙ্ক সহ ব্যবহারকারীদের লাইব্রেরি আইটেমের একটি জিপ ফাইল ডাউনলোড করার অনুমতি দিন।\",\n  \"LabelShareOpen\": \"শেয়ার খোলা\",\n  \"LabelShareURL\": \"শেয়ার ইউআরএল\",\n  \"LabelShowAll\": \"সব দেখান\",\n  \"LabelShowSeconds\": \"সেকেন্ড দেখান\",\n  \"LabelShowSubtitles\": \"সহ-শিরোনাম দেখান\",\n  \"LabelSize\": \"আকার\",\n  \"LabelSleepTimer\": \"স্লিপ টাইমার\",\n  \"LabelSlug\": \"স্লাগ\",\n  \"LabelSortAscending\": \"আরোহী\",\n  \"LabelSortDescending\": \"অবরোহী\",\n  \"LabelStart\": \"শুরু\",\n  \"LabelStartTime\": \"শুরুর সময়\",\n  \"LabelStarted\": \"শুরু হয়েছে\",\n  \"LabelStartedAt\": \"এতে শুরু হয়েছে\",\n  \"LabelStatsAudioTracks\": \"অডিও ট্র্যাক\",\n  \"LabelStatsAuthors\": \"লেখক\",\n  \"LabelStatsBestDay\": \"সেরা দিন\",\n  \"LabelStatsDailyAverage\": \"দৈনিক গড়\",\n  \"LabelStatsDays\": \"দিন\",\n  \"LabelStatsDaysListened\": \"যেদিন শোনা হয়েছে\",\n  \"LabelStatsHours\": \"ঘন্টা\",\n  \"LabelStatsInARow\": \"এক সারিতে\",\n  \"LabelStatsItemsFinished\": \"আইটেম সমাপ্ত\",\n  \"LabelStatsItemsInLibrary\": \"লাইব্রেরির আইটেম\",\n  \"LabelStatsMinutes\": \"মিনিট\",\n  \"LabelStatsMinutesListening\": \"মিনিট শুনছেন\",\n  \"LabelStatsOverallDays\": \"সামগ্রিক দিন\",\n  \"LabelStatsOverallHours\": \"সামগ্রিক ঘন্টা\",\n  \"LabelStatsWeekListening\": \"সপ্তাহ শোনা\",\n  \"LabelSubtitle\": \"সাবটাইটেল\",\n  \"LabelSupportedFileTypes\": \"সমর্থিত ফাইল প্রকার\",\n  \"LabelTag\": \"ট্যাগ\",\n  \"LabelTags\": \"ট্যাগগুলো\",\n  \"LabelTagsAccessibleToUser\": \"ব্যবহারকারীর কাছে অ্যাক্সেসযোগ্য ট্যাগ\",\n  \"LabelTagsNotAccessibleToUser\": \"ট্যাগগুলি ব্যবহারকারীর কাছে অ্যাক্সেসযোগ্য নয়\",\n  \"LabelTasks\": \"কাজ চলছে\",\n  \"LabelTextEditorBulletedList\": \"বুলেটেড তালিকা\",\n  \"LabelTextEditorLink\": \"লিঙ্ক\",\n  \"LabelTextEditorNumberedList\": \"সংখ্যাযুক্ত তালিকা\",\n  \"LabelTextEditorUnlink\": \"বিচ্ছিন্ন\",\n  \"LabelTheme\": \"থিম\",\n  \"LabelThemeDark\": \"অন্ধকার\",\n  \"LabelThemeLight\": \"আলো\",\n  \"LabelTimeBase\": \"সময় বেস\",\n  \"LabelTimeDurationXHours\": \"{0} ঘণ্টা\",\n  \"LabelTimeDurationXMinutes\": \"{0} মিনিট\",\n  \"LabelTimeDurationXSeconds\": \"{0} সেকেন্ড\",\n  \"LabelTimeInMinutes\": \"মিনিটে সময়\",\n  \"LabelTimeLeft\": \"{0} বাকি\",\n  \"LabelTimeListened\": \"সময় শোনা হয়েছে\",\n  \"LabelTimeListenedToday\": \"আজ শোনার সময়\",\n  \"LabelTimeRemaining\": \"{0}টি অবশিষ্ট\",\n  \"LabelTimeToShift\": \"সেকেন্ডে স্থানান্তরের সময়\",\n  \"LabelTitle\": \"শিরোনাম\",\n  \"LabelToolsEmbedMetadata\": \"মেটাডেটা এম্বেড করুন\",\n  \"LabelToolsEmbedMetadataDescription\": \"কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।\",\n  \"LabelToolsM4bEncoder\": \"M4B এনকোডার\",\n  \"LabelToolsMakeM4b\": \"M4B অডিওবুক ফাইল তৈরি করুন\",\n  \"LabelToolsMakeM4bDescription\": \"এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।\",\n  \"LabelToolsSplitM4b\": \"M4B কে MP3 তে বিভক্ত করুন\",\n  \"LabelToolsSplitM4bDescription\": \"এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ অধ্যায় দ্বারা একটি M4B বিভক্ত থেকে MP3 তৈরি করুন।\",\n  \"LabelTotalDuration\": \"মোট সময়কাল\",\n  \"LabelTotalTimeListened\": \"মোট সময় শোনা\",\n  \"LabelTrackFromFilename\": \"ফাইলের নাম থেকে ট্র্যাক করুন\",\n  \"LabelTrackFromMetadata\": \"মেটাডেটা থেকে ট্র্যাক করুন\",\n  \"LabelTracks\": \"ট্র্যাকস\",\n  \"LabelTracksMultiTrack\": \"মাল্টি-ট্র্যাক\",\n  \"LabelTracksNone\": \"কোন ট্র্যাক নেই\",\n  \"LabelTracksSingleTrack\": \"একক-ট্র্যাক\",\n  \"LabelTrailer\": \"আনুগমিক\",\n  \"LabelType\": \"টাইপ\",\n  \"LabelUnabridged\": \"অসংলগ্ন\",\n  \"LabelUndo\": \"পূর্বাবস্থা\",\n  \"LabelUnknown\": \"অজানা\",\n  \"LabelUnknownPublishDate\": \"প্রকাশের তারিখ অজানা\",\n  \"LabelUpdateCover\": \"কভার আপডেট করুন\",\n  \"LabelUpdateCoverHelp\": \"একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান কভারগুলি ওভাররাইট করার অনুমতি দিন\",\n  \"LabelUpdateDetails\": \"বিশদ আপডেট করুন\",\n  \"LabelUpdateDetailsHelp\": \"একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন\",\n  \"LabelUpdatedAt\": \"আপডেট করা হয়েছে\",\n  \"LabelUploaderDragAndDrop\": \"ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"ফাইল টেনে আনুন\",\n  \"LabelUploaderDropFiles\": \"ফাইলগুলো ফেলে দিন\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন\",\n  \"LabelUseAdvancedOptions\": \"উন্নত বিকল্প ব্যবহার করুন\",\n  \"LabelUseChapterTrack\": \"অধ্যায় ট্র্যাক ব্যবহার করুন\",\n  \"LabelUseFullTrack\": \"সম্পূর্ণ ট্র্যাক ব্যবহার করুন\",\n  \"LabelUseZeroForUnlimited\": \"অসীমের জন্য 0 ব্যবহার করুন\",\n  \"LabelUser\": \"ব্যবহারকারী\",\n  \"LabelUsername\": \"ব্যবহারকারীর নাম\",\n  \"LabelValue\": \"মান\",\n  \"LabelVersion\": \"সংস্করণ\",\n  \"LabelViewBookmarks\": \"বুকমার্ক দেখুন\",\n  \"LabelViewChapters\": \"অধ্যায় দেখুন\",\n  \"LabelViewPlayerSettings\": \"প্লেয়ার সেটিংস দেখুন\",\n  \"LabelViewQueue\": \"প্লেয়ার সারি দেখুন\",\n  \"LabelVolume\": \"ভলিউম\",\n  \"LabelWebRedirectURLsDescription\": \"লগইন করার পরে ওয়েব অ্যাপে পুনঃনির্দেশের অনুমতি দেওয়ার জন্য আপনার OAuth প্রদানকারীতে এই URLগুলোকে অনুমোদন করুন:\",\n  \"LabelWebRedirectURLsSubfolder\": \"রিডাইরেক্ট URL এর জন্য সাবফোল্ডার\",\n  \"LabelWeekdaysToRun\": \"চলতে হবে সপ্তাহের দিন\",\n  \"LabelXBooks\": \"{0}টি বই\",\n  \"LabelXItems\": \"{0}টি আইটেম\",\n  \"LabelYearReviewHide\": \"পর্যালোচনার বছর লুকান\",\n  \"LabelYearReviewShow\": \"পর্যালোচনার বছর দেখুন\",\n  \"LabelYourAudiobookDuration\": \"আপনার অডিওবুকের সময়কাল\",\n  \"LabelYourBookmarks\": \"আপনার বুকমার্কস\",\n  \"LabelYourPlaylists\": \"আপনার প্লেলিস্ট\",\n  \"LabelYourProgress\": \"আপনার অগ্রগতি\",\n  \"MessageAddToPlayerQueue\": \"প্লেয়ার সারিতে যোগ করুন\",\n  \"MessageAppriseDescription\": \"এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> চালানোর একটি উদাহরণ বা একটি এপিআই পরিচালনা করতে হবে যে একই অনুরোধ পরিচালনা করবে। <br />অ্যাপ্রাইজ এপিআই ইউআরএলটি বিজ্ঞপ্তি পাঠানোর জন্য সম্পূর্ণ ইউআরএল পথ হওয়া উচিত, যেমন, যদি আপনার API ইনস্ট্যান্স <code>http://192.168.1.1:8337</code> এ পরিবেশিত হয় তাহলে আপনি <code> রাখবেন >http://192.168.1.1:8337/notify</code>।\",\n  \"MessageBackupsDescription\": \"ব্যাকআপের মধ্যে রয়েছে ব্যবহারকারী, ব্যবহারকারীর অগ্রগতি, লাইব্রেরি আইটেমের বিবরণ, সার্ভার সেটিংস এবং <code>/metadata/items</code> & <code>/metadata/authors</code>-এ সংরক্ষিত ছবি। ব্যাকআপগুলি <strong> আপনার লাইব্রেরি ফোল্ডারে সঞ্চিত কোনো ফাইল >অন্তর্ভুক্ত করবেন না</strong>।\",\n  \"MessageBackupsLocationEditNote\": \"দ্রষ্টব্য: ব্যাকআপ অবস্থান আপডেট করলে বিদ্যমান ব্যাকআপগুলি সরানো বা সংশোধন করা হবে না\",\n  \"MessageBackupsLocationNoEditNote\": \"দ্রষ্টব্য: ব্যাকআপ অবস্থান একটি পরিবেশ পরিবর্তনশীল মাধ্যমে স্থির করা হয়েছে এবং এখানে পরিবর্তন করা যাবে না।\",\n  \"MessageBackupsLocationPathEmpty\": \"ব্যাকআপ অবস্থানের পথ খালি থাকতে পারবে না\",\n  \"MessageBatchQuickMatchDescription\": \"কুইক ম্যাচ নির্বাচিত আইটেমগুলির জন্য অনুপস্থিত কভার এবং মেটাডেটা যোগ করার চেষ্টা করবে। বিদ্যমান কভার এবং/অথবা মেটাডেটা ওভাররাইট করার জন্য দ্রুত ম্যাচকে অনুমতি দিতে নীচের বিকল্পগুলি সক্ষম করুন।\",\n  \"MessageBookshelfNoCollections\": \"আপনি এখনও কোনো সংগ্রহ করেননি\",\n  \"MessageBookshelfNoRSSFeeds\": \"কোনও RSS ফিড খোলা নেই\",\n  \"MessageBookshelfNoResultsForFilter\": \"ফিল্টার \\\"{0}: {1}\\\" এর জন্য কোন ফলাফল নেই\",\n  \"MessageBookshelfNoResultsForQuery\": \"প্রশ্নের জন্য কোন ফলাফল নেই\",\n  \"MessageBookshelfNoSeries\": \"আপনার কোনো সিরিজ নেই\",\n  \"MessageChapterEndIsAfter\": \"অধ্যায়ের সমাপ্তি আপনার অডিওবুকের শেষে\",\n  \"MessageChapterErrorFirstNotZero\": \"প্রথম অধ্যায় 0 এ শুরু হতে হবে\",\n  \"MessageChapterErrorStartGteDuration\": \"অবৈধ শুরুর সময় অবশ্যই অডিওবুকের সময়কালের কম হতে হবে\",\n  \"MessageChapterErrorStartLtPrev\": \"অবৈধ শুরুর সময় অবশ্যই আগের অধ্যায় শুরুর সময়ের চেয়ে বেশি বা সমান হতে হবে\",\n  \"MessageChapterStartIsAfter\": \"আপনার অডিওবুক শেষ হওয়ার পরে অধ্যায় শুরু হয়\",\n  \"MessageCheckingCron\": \"ক্রন পরীক্ষা করা হচ্ছে...\",\n  \"MessageConfirmCloseFeed\": \"আপনি কি নিশ্চিত যে আপনি এই ফিডটি বন্ধ করতে চান?\",\n  \"MessageConfirmDeleteBackup\": \"আপনি কি নিশ্চিত যে আপনি {0} এর ব্যাকআপ মুছে ফেলতে চান?\",\n  \"MessageConfirmDeleteDevice\": \"আপনি কি নিশ্চিতভাবে ই-রিডার ডিভাইস \\\"{0}\\\" মুছতে চান?\",\n  \"MessageConfirmDeleteFile\": \"এটি আপনার ফাইল সিস্টেম থেকে ফাইলটি মুছে দেবে। আপনি কি নিশ্চিত?\",\n  \"MessageConfirmDeleteLibrary\": \"আপনি কি নিশ্চিত যে আপনি স্থায়ীভাবে লাইব্রেরি \\\"{0}\\\" মুছে ফেলতে চান?\",\n  \"MessageConfirmDeleteLibraryItem\": \"এটি ডাটাবেস এবং আপনার ফাইল সিস্টেম থেকে লাইব্রেরি আইটেমটি মুছে ফেলবে। আপনি কি নিশ্চিত?\",\n  \"MessageConfirmDeleteLibraryItems\": \"এটি ডাটাবেস এবং আপনার ফাইল সিস্টেম থেকে {0}টি লাইব্রেরি আইটেম মুছে ফেলবে। আপনি কি নিশ্চিত?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \\\"{0}\\\" মুছতে চান?\",\n  \"MessageConfirmDeleteNotification\": \"আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?\",\n  \"MessageConfirmDeleteSession\": \"আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmForceReScan\": \"আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?\",\n  \"MessageConfirmMarkItemFinished\": \"আপনি কি \\\"{0}\\\" কে সমাপ্ত হিসাবে চিহ্নিত করার বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmMarkItemNotFinished\": \"আপনি কি \\\"{0}\\\" শেষ হয়নি বলে চিহ্নিত করার বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmMarkSeriesFinished\": \"আপনি কি নিশ্চিত যে আপনি এই সিরিজের সমস্ত বইকে সমাপ্ত হিসাবে চিহ্নিত করতে চান?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"আপনি কি নিশ্চিত যে আপনি এই সিরিজের সমস্ত বইকে শেষ হয়নি বলে চিহ্নিত করতে চান?\",\n  \"MessageConfirmNotificationTestTrigger\": \"পরীক্ষার তথ্য দিয়ে এই বিজ্ঞপ্তিটি ট্রিগার করবেন?\",\n  \"MessageConfirmPurgeCache\": \"ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?\",\n  \"MessageConfirmPurgeItemsCache\": \"আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?\",\n  \"MessageConfirmQuickEmbed\": \"সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?\",\n  \"MessageConfirmReScanLibraryItems\": \"আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?\",\n  \"MessageConfirmRemoveAllChapters\": \"আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?\",\n  \"MessageConfirmRemoveAuthor\": \"আপনি কি নিশ্চিত যে আপনি লেখক \\\"{0}\\\" অপসারণ করতে চান?\",\n  \"MessageConfirmRemoveCollection\": \"আপনি কি নিশ্চিত যে আপনি সংগ্রহ \\\"{0}\\\" সরাতে চান?\",\n  \"MessageConfirmRemoveEpisode\": \"আপনি কি নিশ্চিত আপনি \\\"{0}\\\" পর্বটি সরাতে চান?\",\n  \"MessageConfirmRemoveEpisodes\": \"আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?\",\n  \"MessageConfirmRemoveListeningSessions\": \"আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmRemoveNarrator\": \"আপনি কি \\\"{0}\\\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmRemovePlaylist\": \"আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \\\"{0}\\\" সরাতে চান?\",\n  \"MessageConfirmRenameGenre\": \"আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \\\"{0}\\\" ধারার নাম পরিবর্তন করে \\\"{1}\\\" করতে চান?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"দ্রষ্টব্য: এই ধারাটি আগে থেকেই বিদ্যমান তাই সেগুলিকে একত্রিত করা হবে।\",\n  \"MessageConfirmRenameGenreWarning\": \"সতর্কতা! একটি ভিন্ন কেসিং সহ একটি অনুরূপ ধারা ইতিমধ্যেই বিদ্যমান \\\"{0}\\\"।\",\n  \"MessageConfirmRenameTag\": \"আপনি কি সব আইটেমের জন্য \\\"{0}\\\" ট্যাগের নাম পরিবর্তন করে \\\"{1}\\\" করার বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmRenameTagMergeNote\": \"দ্রষ্টব্য: এই ট্যাগটি আগে থেকেই বিদ্যমান তাই সেগুলিকে একত্র করা হবে।\",\n  \"MessageConfirmRenameTagWarning\": \"সতর্কতা! একটি ভিন্ন কেসিং সহ একটি অনুরূপ ট্যাগ ইতিমধ্যেই বিদ্যমান \\\"{0}\\\"।\",\n  \"MessageConfirmResetProgress\": \"আপনি কি আপনার অগ্রগতি রিসেট করার বিষয়ে নিশ্চিত?\",\n  \"MessageConfirmSendEbookToDevice\": \"আপনি কি নিশ্চিত যে আপনি \\\"{2}\\\" ডিভাইসে {0} ইবুক \\\"{1}\\\" পাঠাতে চান?\",\n  \"MessageConfirmUnlinkOpenId\": \"আপনি কি এই ব্যবহারকারীকে ওপেনআইডি থেকে লিঙ্কমুক্ত করার বিষয়ে নিশ্চিত?\",\n  \"MessageDownloadingEpisode\": \"ডাউনলোডিং পর্ব\",\n  \"MessageDragFilesIntoTrackOrder\": \"সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন\",\n  \"MessageEmbedFailed\": \"এম্বেড ব্যর্থ হয়েছে!\",\n  \"MessageEmbedFinished\": \"এম্বেড করা শেষ!\",\n  \"MessageEmbedQueue\": \"মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ\",\n  \"MessageEreaderDevices\": \"ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।\",\n  \"MessageFeedURLWillBe\": \"ফিড URL হবে {0}\",\n  \"MessageFetching\": \"আনয় হচ্ছে.।\",\n  \"MessageForceReScanDescription\": \"সকল ফাইল আবার নতুন স্ক্যানের মত স্ক্যান করবে। অডিও ফাইল ID3 ট্যাগ, OPF ফাইল, এবং টেক্সট ফাইলগুলি নতুন হিসাবে স্ক্যান করা হবে।\",\n  \"MessageImportantNotice\": \"গুরুত্বপূর্ণ বিজ্ঞপ্তি!\",\n  \"MessageInsertChapterBelow\": \"নীচে অধ্যায় ঢোকান\",\n  \"MessageItemsSelected\": \"{0}টি আইটেম নির্বাচিত\",\n  \"MessageItemsUpdated\": \"{0}টি আইটেম আপডেট করা হয়েছে\",\n  \"MessageJoinUsOn\": \"আমাদের সাথে যোগ দিন\",\n  \"MessageLoading\": \"লোড হচ্ছে.।\",\n  \"MessageLoadingFolders\": \"ফোল্ডার লোড হচ্ছে...\",\n  \"MessageLogsDescription\": \"লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।\",\n  \"MessageM4BFailed\": \"M4B ব্যর্থ!\",\n  \"MessageM4BFinished\": \"M4B সমাপ্ত!\",\n  \"MessageMapChapterTitles\": \"টাইমস্ট্যাম্প সামঞ্জস্য না করে আপনার বিদ্যমান অডিওবুক অধ্যায়গুলিতে অধ্যায়ের শিরোনাম ম্যাপ করুন\",\n  \"MessageMarkAllEpisodesFinished\": \"সমস্ত পর্ব সমাপ্ত চিহ্নিত করুন\",\n  \"MessageMarkAllEpisodesNotFinished\": \"সমস্ত পর্ব শেষ হয়নি চিহ্নিত করুন\",\n  \"MessageMarkAsFinished\": \"সমাপ্ত হিসাবে চিহ্নিত করুন\",\n  \"MessageMarkAsNotFinished\": \"সমাপ্ত হয়নি হিসাবে চিহ্নিত করুন\",\n  \"MessageMatchBooksDescription\": \"নির্বাচিত অনুসন্ধান প্রদানকারীর একটি বইয়ের সাথে লাইব্রেরিতে বই মেলানোর চেষ্টা করবে এবং খালি বিবরণ এবং কভার আর্ট পূরণ করবে। বিস্তারিত ওভাররাইট করে না।\",\n  \"MessageNoAudioTracks\": \"কোন অডিও ট্র্যাক নেই\",\n  \"MessageNoAuthors\": \"কোন লেখক নেই\",\n  \"MessageNoBackups\": \"কোন ব্যাকআপ নেই\",\n  \"MessageNoBookmarks\": \"কোন বুকমার্ক নেই\",\n  \"MessageNoChapters\": \"কোনও অধ্যায় নেই\",\n  \"MessageNoCollections\": \"কোন সংগ্রহ নেই\",\n  \"MessageNoCoversFound\": \"কোন কভার পাওয়া যায়নি\",\n  \"MessageNoDescription\": \"কোন বর্ণনা নেই\",\n  \"MessageNoDevices\": \"কোনো ডিভাইস নেই\",\n  \"MessageNoDownloadsInProgress\": \"বর্তমানে কোনো ডাউনলোড চলছে না\",\n  \"MessageNoDownloadsQueued\": \"কোনও ডাউনলোড সারি নেই\",\n  \"MessageNoEpisodeMatchesFound\": \"কোন পর্বের মিল পাওয়া যায়নি\",\n  \"MessageNoEpisodes\": \"কোন পর্ব নেই\",\n  \"MessageNoFoldersAvailable\": \"কোন ফোল্ডার উপলব্ধ নেই\",\n  \"MessageNoGenres\": \"কোন ধরন নেই\",\n  \"MessageNoIssues\": \"কোন সমস্যা নেই\",\n  \"MessageNoItems\": \"কোন আইটেম নেই\",\n  \"MessageNoItemsFound\": \"কোন আইটেম পাওয়া যায়নি\",\n  \"MessageNoListeningSessions\": \"কোনও শোনার সেশন নেই\",\n  \"MessageNoLogs\": \"কোনও লগ নেই\",\n  \"MessageNoMediaProgress\": \"মিডিয়া অগ্রগতি নেই\",\n  \"MessageNoNotifications\": \"কোনো বিজ্ঞপ্তি নেই\",\n  \"MessageNoPodcastFeed\": \"অবৈধ পডকাস্ট: কোনো ফিড নেই\",\n  \"MessageNoPodcastsFound\": \"কোন পডকাস্ট পাওয়া যায়নি\",\n  \"MessageNoResults\": \"কোন ফলাফল নেই\",\n  \"MessageNoSearchResultsFor\": \"\\\"{0}\\\" এর জন্য কোন অনুসন্ধান ফলাফল নেই\",\n  \"MessageNoSeries\": \"কোন সিরিজ নেই\",\n  \"MessageNoTags\": \"কোন ট্যাগ নেই\",\n  \"MessageNoTasksRunning\": \"কোন টাস্ক চলছে না\",\n  \"MessageNoUpdatesWereNecessary\": \"কোন আপডেটের প্রয়োজন ছিল না\",\n  \"MessageNoUserPlaylists\": \"আপনার কোনো প্লেলিস্ট নেই\",\n  \"MessageNotYetImplemented\": \"এখনও বাস্তবায়িত হয়নি\",\n  \"MessageOpmlPreviewNote\": \"দ্রষ্টব্য: এটি পার্স করা OPML ফাইলের একটি পূর্বরূপ। প্রকৃত পডকাস্ট শিরোনাম RSS ফিড থেকে নেওয়া হবে।\",\n  \"MessageOr\": \"বা\",\n  \"MessagePauseChapter\": \"পজ অধ্যায় প্লেব্যাক\",\n  \"MessagePlayChapter\": \"অধ্যায়ের শুরুতে শুনুন\",\n  \"MessagePlaylistCreateFromCollection\": \"সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন\",\n  \"MessagePleaseWait\": \"অনুগ্রহ করে অপেক্ষা করুন..।\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই\",\n  \"MessagePodcastSearchField\": \"অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন\",\n  \"MessageQuickEmbedInProgress\": \"দ্রুত এম্বেড করা হচ্ছে\",\n  \"MessageQuickEmbedQueue\": \"দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)\",\n  \"MessageQuickMatchAllEpisodes\": \"দ্রুত ম্যাচ সব পর্ব\",\n  \"MessageQuickMatchDescription\": \"খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।\",\n  \"MessageRemoveChapter\": \"অধ্যায় সরান\",\n  \"MessageRemoveEpisodes\": \"{0}টি পর্ব(গুলি) সরান\",\n  \"MessageRemoveFromPlayerQueue\": \"প্লেয়ার সারি থেকে সরান\",\n  \"MessageRemoveUserWarning\": \"আপনি কি নিশ্চিত আপনি স্থায়ীভাবে ব্যবহারকারী \\\"{0}\\\" মুছে ফেলতে চান?\",\n  \"MessageReportBugsAndContribute\": \"বাগ রিপোর্ট করুন, বৈশিষ্ট্যের অনুরোধ করুন এবং এতে অবদান রাখুন\",\n  \"MessageResetChaptersConfirm\": \"আপনি কি নিশ্চিত যে আপনি অধ্যায়গুলি পুনরায় সেট করতে চান এবং আপনার করা পরিবর্তনগুলি পূর্বাবস্থায় ফেরাতে চান?\",\n  \"MessageRestoreBackupConfirm\": \"আপনি কি নিশ্চিত যে আপনি তৈরি করা ব্যাকআপ পুনরুদ্ধার করতে চান\",\n  \"MessageRestoreBackupWarning\": \"একটি ব্যাকআপ পুনরুদ্ধার করা হলে তা /config-এ অবস্থিত সমগ্র ডাটাবেস ওভাররাইট করবে এবং /metadata/items & /metadata/authors-এ থাকা ছবিগুলিকে কভার করবে৷<br /><br />ব্যাকআপগুলি আপনার লাইব্রেরি ফোল্ডারে কোনো ফাইল পরিবর্তন করে না৷ আপনি যদি আপনার লাইব্রেরি ফোল্ডারে কভার আর্ট এবং মেটাডেটা সংরক্ষণ করতে সার্ভার সেটিংস সক্ষম করে থাকেন তবে সেগুলি ব্যাক আপ বা ওভাররাইট করা হয় না৷<br /><br />আপনার সার্ভার ব্যবহারকারী সমস্ত ক্লায়েন্ট স্বয়ংক্রিয়ভাবে রিফ্রেশ হবে৷\",\n  \"MessageSearchResultsFor\": \"এর জন্য অনুসন্ধান ফলাফল\",\n  \"MessageSelected\": \"{0}টি নির্বাচিত\",\n  \"MessageServerCouldNotBeReached\": \"সার্ভারে পৌঁছানো যায়নি\",\n  \"MessageSetChaptersFromTracksDescription\": \"প্রতিটি অডিও ফাইলকে অধ্যায় হিসেবে ব্যবহার করে অধ্যায় সেট করুন এবং অডিও ফাইলের নাম হিসেবে অধ্যায়ের শিরোনাম করুন\",\n  \"MessageShareExpirationWillBe\": \"মেয়াদ শেষ হবে <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"মেয়াদ শেষ হবে {0}\",\n  \"MessageShareURLWillBe\": \"শেয়ার করা ইউআরএল হবে <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"\\\"{0}\\\" এর জন্য {1} এ প্লেব্যাক শুরু করবেন?\",\n  \"MessageTaskAudioFileNotWritable\": \"অডিও ফাইল \\\"{0}\\\" লেখার যোগ্য নয়\",\n  \"MessageTaskCanceledByUser\": \"ব্যবহারকারী দ্বারা টাস্ক বাতিল করা হয়েছে\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"\\\"{0}\\\" পর্ব ডাউনলোড করা হচ্ছে\",\n  \"MessageTaskEmbeddingMetadata\": \"মেটাডেটা এম্বেড করা হচ্ছে\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"অডিওবুক \\\"{0}\\\" এ মেটাডেটা এম্বেড করা হচ্ছে\",\n  \"MessageTaskEncodingM4b\": \"এনকোডিং M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"একটি একক m4b ফাইলে অডিওবুক \\\"{0}\\\" এনকোড করা হচ্ছে\",\n  \"MessageTaskFailed\": \"ব্যর্থ হয়েছে\",\n  \"MessageTaskFailedToBackupAudioFile\": \"অডিও ফাইল \\\"{0}\\\" ব্যাকআপ করতে ব্যর্থ হয়েছে\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"ক্যাশে ডিরেক্টরি তৈরি করতে ব্যর্থ হয়েছে\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"\\\"{0}\\\" ফাইলে মেটাডেটা এম্বেড করতে ব্যর্থ হয়েছে\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"অডিও ফাইল মার্জ করতে ব্যর্থ হয়েছে\",\n  \"MessageTaskFailedToMoveM4bFile\": \"m4b ফাইল সরাতে ব্যর্থ হয়েছে\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"মেটাডেটা ফাইল লিখতে ব্যর্থ হয়েছে\",\n  \"MessageTaskMatchingBooksInLibrary\": \"লাইব্রেরি \\\"{0}\\\"-এ বই মিলানো হচ্ছে\",\n  \"MessageTaskNoFilesToScan\": \"স্ক্যান করার জন্য কোন ফাইল নেই\",\n  \"MessageTaskOpmlImport\": \"OPML আমদানি\",\n  \"MessageTaskOpmlImportDescription\": \"{0} RSS ফিড থেকে পডকাস্ট তৈরি করা হচ্ছে\",\n  \"MessageTaskOpmlImportFeed\": \"OPML ফিড আমদানি\",\n  \"MessageTaskOpmlImportFeedDescription\": \"RSS ফিড \\\"{0}\\\" আমদানি করা হচ্ছে\",\n  \"MessageTaskOpmlImportFeedFailed\": \"পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"পডকাস্ট তৈরি করা হচ্ছে \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"পডকাস্ট আগে থেকেই পাথে বিদ্যমান\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"পডকাস্ট তৈরি করতে ব্যর্থ\",\n  \"MessageTaskOpmlImportFinished\": \"{0}টি পডকাস্ট যোগ করা হয়েছে\",\n  \"MessageTaskOpmlParseFailed\": \"OPML ফাইল পার্স করতে ব্যর্থ হয়েছে\",\n  \"MessageTaskOpmlParseFastFail\": \"অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি\",\n  \"MessageTaskOpmlParseNoneFound\": \"OPML ফাইলে কোনো ফিড পাওয়া যায়নি\",\n  \"MessageTaskScanItemsAdded\": \"{0}টি করা হয়েছে\",\n  \"MessageTaskScanItemsMissing\": \"{0}টি অনুপস্থিত\",\n  \"MessageTaskScanItemsUpdated\": \"{0} টি আপডেট করা হয়েছে\",\n  \"MessageTaskScanNoChangesNeeded\": \"কোন পরিবর্তন প্রয়োজন নেই\",\n  \"MessageTaskScanningFileChanges\": \"\\\"{0}\\\" এ ফাইলের পরিবর্তন স্ক্যান করা হচ্ছে\",\n  \"MessageTaskScanningLibrary\": \"\\\"{0}\\\" লাইব্রেরি স্ক্যান করা হচ্ছে\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"টার্গেট ডিরেক্টরি লেখার যোগ্য নয়\",\n  \"MessageThinking\": \"চিন্তা করছি...\",\n  \"MessageUploaderItemFailed\": \"আপলোড করতে ব্যর্থ\",\n  \"MessageUploaderItemSuccess\": \"সফলভাবে আপলোড হয়েছে!\",\n  \"MessageUploading\": \"আপলোড হচ্ছে...\",\n  \"MessageValidCronExpression\": \"বৈধ ক্রোন এক্সপ্রেশন\",\n  \"MessageWatcherIsDisabledGlobally\": \"সার্ভার সেটিংসে বিশ্বব্যাপী প্রহরী অক্ষম করা হয়েছে\",\n  \"MessageXLibraryIsEmpty\": \"{0} লাইব্রেরি খালি!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"আপনার অডিওবুকের সময়কাল পাওয়া সময়ের চেয়ে বেশি\",\n  \"MessageYourAudiobookDurationIsShorter\": \"আপনার অডিওবুকের সময়কাল পাওয়া সময়ের চেয়ে কম\",\n  \"NoteChangeRootPassword\": \"রুট ব্যবহারকারীই একমাত্র ব্যবহারকারী যার একটি খালি পাসওয়ার্ড থাকতে পারে\",\n  \"NoteChapterEditorTimes\": \"দ্রষ্টব্য: প্রথম অধ্যায়ের শুরুর সময় অবশ্যই 0:00 এ থাকতে হবে এবং শেষ অধ্যায়ের শুরুর সময়টি এই অডিওবুকের সময়কাল অতিক্রম করতে পারবে না।\",\n  \"NoteFolderPicker\": \"দ্রষ্টব্য: ইতিমধ্যে ম্যাপ করা ফোল্ডারগুলি দেখানো হবে না\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"সতর্কতা: বেশিরভাগ পডকাস্ট অ্যাপের জন্য প্রয়োজন হবে RSS ফিড URL যেটি HTTPS ব্যবহার করছে\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"সতর্কতা: আপনার 1 বা তার বেশি পর্বের একটি পাব তারিখ নেই। কিছু পডকাস্ট অ্যাপের এটি প্রয়োজন।\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।\",\n  \"NoteUploaderOnlyAudioFiles\": \"যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।\",\n  \"NoteUploaderUnsupportedFiles\": \"অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।\",\n  \"NotificationOnBackupCompletedDescription\": \"ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে\",\n  \"NotificationOnBackupFailedDescription\": \"ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে\",\n  \"NotificationOnTestDescription\": \"বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট\",\n  \"PlaceholderNewCollection\": \"নতুন সংগ্রহের নাম\",\n  \"PlaceholderNewFolderPath\": \"নতুন ফোল্ডার পথ\",\n  \"PlaceholderNewPlaylist\": \"নতুন প্লেলিস্টের নাম\",\n  \"PlaceholderSearch\": \"অনুসন্ধান..\",\n  \"PlaceholderSearchEpisode\": \"অনুসন্ধান পর্ব..\",\n  \"StatsAuthorsAdded\": \"লেখক যোগ করা হয়েছে\",\n  \"StatsBooksAdded\": \"বই যোগ করা হয়েছে\",\n  \"StatsBooksAdditional\": \"কিছু সংযোজনের মধ্যে রয়েছে…\",\n  \"StatsBooksFinished\": \"বই সমাপ্ত\",\n  \"StatsBooksFinishedThisYear\": \"এ বছর শেষ হওয়া কিছু বই …\",\n  \"StatsBooksListenedTo\": \"বই শোনা হয়েছে\",\n  \"StatsCollectionGrewTo\": \"আপনার বইয়ের সংগ্রহ বেড়েছে…\",\n  \"StatsSessions\": \"অধিবেশনসমূহ\",\n  \"StatsSpentListening\": \"শুনে কাটিয়েছেন\",\n  \"StatsTopAuthor\": \"শীর্ষস্থানীয় লেখক\",\n  \"StatsTopAuthors\": \"শীর্ষস্থানীয় লেখকগণ\",\n  \"StatsTopGenre\": \"শীর্ষ ঘরানা\",\n  \"StatsTopGenres\": \"শীর্ষ ঘরানাগুলো\",\n  \"StatsTopMonth\": \"সেরা মাস\",\n  \"StatsTopNarrator\": \"শীর্ষ কথক\",\n  \"StatsTopNarrators\": \"শীর্ষ কথকগণ\",\n  \"StatsTotalDuration\": \"মোট সময়কাল…\",\n  \"StatsYearInReview\": \"বাৎসরিক পর্যালোচনা\",\n  \"ToastAccountUpdateSuccess\": \"অ্যাকাউন্ট আপডেট করা হয়েছে\",\n  \"ToastAppriseUrlRequired\": \"একটি Apprise ইউআরএল লিখতে হবে\",\n  \"ToastAsinRequired\": \"ASIN প্রয়োজন\",\n  \"ToastAuthorImageRemoveSuccess\": \"লেখকের ছবি সরানো হয়েছে\",\n  \"ToastAuthorNotFound\": \"লেখক \\\"{0}\\\" খুঁজে পাওয়া যায়নি\",\n  \"ToastAuthorRemoveSuccess\": \"লেখক সরানো হয়েছে\",\n  \"ToastAuthorSearchNotFound\": \"লেখক পাওয়া যায়নি\",\n  \"ToastAuthorUpdateMerged\": \"লেখক একত্রিত হয়েছে\",\n  \"ToastAuthorUpdateSuccess\": \"লেখক আপডেট করেছেন\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"লেখক আপডেট করেছেন (কোন ছবি পাওয়া যায়নি)\",\n  \"ToastBackupAppliedSuccess\": \"ব্যাকআপ প্রয়োগ করা হয়েছে\",\n  \"ToastBackupCreateFailed\": \"ব্যাকআপ তৈরি করতে ব্যর্থ\",\n  \"ToastBackupCreateSuccess\": \"ব্যাকআপ তৈরি করা হয়েছে\",\n  \"ToastBackupDeleteFailed\": \"ব্যাকআপ মুছে ফেলতে ব্যর্থ\",\n  \"ToastBackupDeleteSuccess\": \"ব্যাকআপ মুছে ফেলা হয়েছে\",\n  \"ToastBackupInvalidMaxKeep\": \"রাখার জন্য অকার্যকর ব্যাকআপের সংখ্যা\",\n  \"ToastBackupInvalidMaxSize\": \"অকার্যকর সর্বোচ্চ ব্যাকআপ আকার\",\n  \"ToastBackupRestoreFailed\": \"ব্যাকআপ পুনরুদ্ধার করতে ব্যর্থ\",\n  \"ToastBackupUploadFailed\": \"ব্যাকআপ আপলোড করতে ব্যর্থ\",\n  \"ToastBackupUploadSuccess\": \"ব্যাকআপ আপলোড হয়েছে\",\n  \"ToastBatchDeleteFailed\": \"ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে\",\n  \"ToastBatchDeleteSuccess\": \"ব্যাচ মুছে ফেলা সফল হয়েছে\",\n  \"ToastBatchQuickMatchFailed\": \"ব্যাচ কুইক ম্যাচ ব্যর্থ!\",\n  \"ToastBatchQuickMatchStarted\": \"{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!\",\n  \"ToastBatchUpdateFailed\": \"ব্যাচ আপডেট ব্যর্থ হয়েছে\",\n  \"ToastBatchUpdateSuccess\": \"ব্যাচ আপডেট সাফল্য\",\n  \"ToastBookmarkCreateFailed\": \"বুকমার্ক তৈরি করতে ব্যর্থ\",\n  \"ToastBookmarkCreateSuccess\": \"বুকমার্ক যোগ করা হয়েছে\",\n  \"ToastBookmarkRemoveSuccess\": \"বুকমার্ক সরানো হয়েছে\",\n  \"ToastCachePurgeFailed\": \"ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে\",\n  \"ToastCachePurgeSuccess\": \"ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে\",\n  \"ToastChaptersHaveErrors\": \"অধ্যায়ে ত্রুটি আছে\",\n  \"ToastChaptersMustHaveTitles\": \"অধ্যায়ের শিরোনাম থাকতে হবে\",\n  \"ToastChaptersRemoved\": \"অধ্যায়গুলো মুছে ফেলা হয়েছে\",\n  \"ToastChaptersUpdated\": \"অধ্যায় আপডেট করা হয়েছে\",\n  \"ToastCollectionItemsAddFailed\": \"আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে\",\n  \"ToastCollectionRemoveSuccess\": \"সংগ্রহ সরানো হয়েছে\",\n  \"ToastCollectionUpdateSuccess\": \"সংগ্রহ আপডেট করা হয়েছে\",\n  \"ToastCoverUpdateFailed\": \"কভার আপডেট ব্যর্থ হয়েছে\",\n  \"ToastDeleteFileFailed\": \"ফাইল মুছে ফেলতে ব্যর্থ হয়েছে\",\n  \"ToastDeleteFileSuccess\": \"ফাইল মুছে ফেলা হয়েছে\",\n  \"ToastDeviceAddFailed\": \"ডিভাইস যোগ করতে ব্যর্থ হয়েছে\",\n  \"ToastDeviceNameAlreadyExists\": \"এই নামের ইরিডার ডিভাইস ইতিমধ্যেই বিদ্যমান\",\n  \"ToastDeviceTestEmailFailed\": \"পরীক্ষামূলক ইমেল পাঠাতে ব্যর্থ হয়েছে\",\n  \"ToastDeviceTestEmailSuccess\": \"পরীক্ষামূলক ইমেল পাঠানো হয়েছে\",\n  \"ToastEmailSettingsUpdateSuccess\": \"ইমেল সেটিংস আপডেট করা হয়েছে\",\n  \"ToastEncodeCancelFailed\": \"এনকোড বাতিল করতে ব্যর্থ হয়েছে\",\n  \"ToastEncodeCancelSucces\": \"এনকোড বাতিল করা হয়েছে\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"সারি সাফ করতে ব্যর্থ হয়েছে\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে\",\n  \"ToastEpisodeUpdateSuccess\": \"{0}টি পর্ব আপডেট করা হয়েছে\",\n  \"ToastErrorCannotShare\": \"এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না\",\n  \"ToastFailedToLoadData\": \"ডেটা লোড করা যায়নি\",\n  \"ToastFailedToMatch\": \"মেলাতে ব্যর্থ হয়েছে\",\n  \"ToastFailedToShare\": \"শেয়ার করতে ব্যর্থ\",\n  \"ToastFailedToUpdate\": \"আপডেট করতে ব্যর্থ হয়েছে\",\n  \"ToastInvalidImageUrl\": \"অকার্যকর ছবির ইউআরএল\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব\",\n  \"ToastInvalidUrl\": \"অকার্যকর ইউআরএল\",\n  \"ToastItemCoverUpdateSuccess\": \"আইটেম কভার আপডেট করা হয়েছে\",\n  \"ToastItemDeletedFailed\": \"আইটেম মুছে ফেলতে ব্যর্থ\",\n  \"ToastItemDeletedSuccess\": \"মুছে ফেলা আইটেম\",\n  \"ToastItemDetailsUpdateSuccess\": \"আইটেমের বিবরণ আপডেট করা হয়েছে\",\n  \"ToastItemMarkedAsFinishedFailed\": \"সমাপ্ত হিসাবে চিহ্নিত করতে ব্যর্থ\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"আইটেম সমাপ্ত হিসাবে চিহ্নিত\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"সমাপ্ত হয়নি হিসাবে চিহ্নিত করতে ব্যর্থ\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"আইটেম সমাপ্ত হয়নি বলে চিহ্নিত\",\n  \"ToastItemUpdateSuccess\": \"আইটেম আপডেট করা হয়েছে\",\n  \"ToastLibraryCreateFailed\": \"লাইব্রেরি তৈরি করতে ব্যর্থ\",\n  \"ToastLibraryCreateSuccess\": \"লাইব্রেরি \\\"{0}\\\" তৈরি করা হয়েছে\",\n  \"ToastLibraryDeleteFailed\": \"লাইব্রেরি মুছে ফেলতে ব্যর্থ\",\n  \"ToastLibraryDeleteSuccess\": \"লাইব্রেরি মুছে ফেলা হয়েছে\",\n  \"ToastLibraryScanFailedToStart\": \"স্ক্যান শুরু করতে ব্যর্থ\",\n  \"ToastLibraryScanStarted\": \"লাইব্রেরি স্ক্যান শুরু হয়েছে\",\n  \"ToastLibraryUpdateSuccess\": \"লাইব্রেরি \\\"{0}\\\" আপডেট করা হয়েছে\",\n  \"ToastMatchAllAuthorsFailed\": \"সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে\",\n  \"ToastMetadataFilesRemovedError\": \"মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে\",\n  \"ToastMustHaveAtLeastOnePath\": \"অন্তত একটি পথ থাকতে হবে\",\n  \"ToastNameEmailRequired\": \"নাম এবং ইমেইল আবশ্যক\",\n  \"ToastNameRequired\": \"নাম আবশ্যক\",\n  \"ToastNewEpisodesFound\": \"{0}টি নতুন পর্ব পাওয়া গেছে\",\n  \"ToastNewUserCreatedFailed\": \"অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"নতুন একাউন্ট তৈরি হয়েছে\",\n  \"ToastNewUserLibraryError\": \"অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে\",\n  \"ToastNewUserPasswordError\": \"অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে\",\n  \"ToastNewUserTagError\": \"অন্তত একটি ট্যাগ নির্বাচন করতে হবে\",\n  \"ToastNewUserUsernameError\": \"একটি ব্যবহারকারীর নাম লিখুন\",\n  \"ToastNoNewEpisodesFound\": \"কোন নতুন পর্ব পাওয়া যায়নি\",\n  \"ToastNoUpdatesNecessary\": \"কোন আপডেটের প্রয়োজন নেই\",\n  \"ToastNotificationCreateFailed\": \"বিজ্ঞপ্তি তৈরি করতে ব্যর্থ\",\n  \"ToastNotificationDeleteFailed\": \"বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ\",\n  \"ToastNotificationFailedMaximum\": \"সর্বাধিক ব্যর্থ প্রচেষ্টা >= 0 হতে হবে\",\n  \"ToastNotificationQueueMaximum\": \"সর্বাধিক বিজ্ঞপ্তি সারি >= 0 হতে হবে\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"বিজ্ঞপ্তি সেটিংস আপডেট করা হয়েছে\",\n  \"ToastNotificationTestTriggerFailed\": \"পরীক্ষামূলক বিজ্ঞপ্তি ট্রিগার করতে ব্যর্থ হয়েছে\",\n  \"ToastNotificationTestTriggerSuccess\": \"পরীক্ষামুলক বিজ্ঞপ্তি ট্রিগার হয়েছে\",\n  \"ToastNotificationUpdateSuccess\": \"বিজ্ঞপ্তি আপডেট হয়েছে\",\n  \"ToastPlaylistCreateFailed\": \"প্লেলিস্ট তৈরি করতে ব্যর্থ\",\n  \"ToastPlaylistCreateSuccess\": \"প্লেলিস্ট তৈরি করা হয়েছে\",\n  \"ToastPlaylistRemoveSuccess\": \"প্লেলিস্ট সরানো হয়েছে\",\n  \"ToastPlaylistUpdateSuccess\": \"প্লেলিস্ট আপডেট করা হয়েছে\",\n  \"ToastPodcastCreateFailed\": \"পডকাস্ট তৈরি করতে ব্যর্থ\",\n  \"ToastPodcastCreateSuccess\": \"পডকাস্ট সফলভাবে তৈরি করা হয়েছে\",\n  \"ToastPodcastGetFeedFailed\": \"পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে\",\n  \"ToastPodcastNoEpisodesInFeed\": \"আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি\",\n  \"ToastPodcastNoRssFeed\": \"পডকাস্টের কোন আরএসএস ফিড নেই\",\n  \"ToastProgressIsNotBeingSynced\": \"অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন\",\n  \"ToastProviderCreatedFailed\": \"প্রদানকারী যোগ করতে ব্যর্থ হয়েছে\",\n  \"ToastProviderCreatedSuccess\": \"নতুন প্রদানকারী যোগ করা হয়েছে\",\n  \"ToastProviderNameAndUrlRequired\": \"নাম এবং ইউআরএল আবশ্যক\",\n  \"ToastProviderRemoveSuccess\": \"প্রদানকারী সরানো হয়েছে\",\n  \"ToastRSSFeedCloseFailed\": \"RSS ফিড বন্ধ করতে ব্যর্থ\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS ফিড বন্ধ\",\n  \"ToastRemoveFailed\": \"মুছে ফেলতে ব্যর্থ হয়েছে\",\n  \"ToastRemoveItemFromCollectionFailed\": \"সংগ্রহ থেকে আইটেম সরাতে ব্যর্থ\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"সংগ্রহ থেকে আইটেম সরানো হয়েছে\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"সমস্যাযুক্ত লাইব্রেরি আইটেমগুলি সরাতে ব্যর্থ হয়েছে\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"সমস্যাযুক্ত লাইব্রেরি আইটেম সরানো হয়েছে\",\n  \"ToastRenameFailed\": \"পুনঃনামকরণ ব্যর্থ হয়েছে\",\n  \"ToastRescanFailed\": \"{0} এর জন্য পুনরায় স্ক্যান করা ব্যর্থ হয়েছে\",\n  \"ToastRescanRemoved\": \"পুনরায় স্ক্যান সম্পূর্ণ,আইটেম সরানো হয়েছে\",\n  \"ToastRescanUpToDate\": \"পুনরায় স্ক্যান সম্পূর্ণ, আইটেম সাম্প্রতিক ছিল\",\n  \"ToastRescanUpdated\": \"পুনরায় স্ক্যান সম্পূর্ণ, আইটেম আপডেট করা হয়েছে\",\n  \"ToastScanFailed\": \"লাইব্রেরি আইটেম স্ক্যান করতে ব্যর্থ হয়েছে\",\n  \"ToastSelectAtLeastOneUser\": \"অন্তত একজন ব্যবহারকারী নির্বাচন করুন\",\n  \"ToastSendEbookToDeviceFailed\": \"ডিভাইসে ইবুক পাঠাতে ব্যর্থ\",\n  \"ToastSendEbookToDeviceSuccess\": \"ইবুক \\\"{0}\\\" ডিভাইসে পাঠানো হয়েছে\",\n  \"ToastSeriesUpdateFailed\": \"সিরিজ আপডেট ব্যর্থ হয়েছে\",\n  \"ToastSeriesUpdateSuccess\": \"সিরিজ আপডেট সাফল্য\",\n  \"ToastServerSettingsUpdateSuccess\": \"সার্ভার সেটিংস আপডেট করা হয়েছে\",\n  \"ToastSessionCloseFailed\": \"অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে\",\n  \"ToastSessionDeleteFailed\": \"সেশন মুছে ফেলতে ব্যর্থ\",\n  \"ToastSessionDeleteSuccess\": \"সেশন মুছে ফেলা হয়েছে\",\n  \"ToastSleepTimerDone\": \"স্লিপ টাইমার হয়ে গেছে... zZzzZz\",\n  \"ToastSlugMustChange\": \"স্লাগে অবৈধ অক্ষর রয়েছে\",\n  \"ToastSlugRequired\": \"স্লাগ আবশ্যক\",\n  \"ToastSocketConnected\": \"সকেট সংযুক্ত\",\n  \"ToastSocketDisconnected\": \"সকেট সংযোগ বিচ্ছিন্ন\",\n  \"ToastSocketFailedToConnect\": \"সকেট সংযোগ করতে ব্যর্থ হয়েছে\",\n  \"ToastSortingPrefixesEmptyError\": \"কমপক্ষে ১ টি সাজানোর উপসর্গ থাকতে হবে\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"বাছাই করা উপসর্গ আপডেট করা হয়েছে ({0}টি আইটেম)\",\n  \"ToastTitleRequired\": \"শিরোনাম আবশ্যক\",\n  \"ToastUnknownError\": \"অজানা ত্রুটি\",\n  \"ToastUnlinkOpenIdFailed\": \"OpenID থেকে ব্যবহারকারীকে আনলিঙ্ক করতে ব্যর্থ হয়েছে\",\n  \"ToastUnlinkOpenIdSuccess\": \"OpenID থেকে ব্যবহারকারীকে লিঙ্কমুক্ত করা হয়েছে\",\n  \"ToastUserDeleteFailed\": \"ব্যবহারকারী মুছতে ব্যর্থ\",\n  \"ToastUserDeleteSuccess\": \"ব্যবহারকারী মুছে ফেলা হয়েছে\",\n  \"ToastUserPasswordChangeSuccess\": \"পাসওয়ার্ড সফলভাবে পরিবর্তন করা হয়েছে\",\n  \"ToastUserPasswordMismatch\": \"পাসওয়ার্ড মিলছে না\",\n  \"ToastUserPasswordMustChange\": \"নতুন পাসওয়ার্ড পুরানো পাসওয়ার্ডের সাথে মিলতে পারবে না\",\n  \"ToastUserRootRequireName\": \"একটি রুট ব্যবহারকারীর নাম লিখতে হবে\"\n}\n"
  },
  {
    "path": "client/strings/ca.json",
    "content": "{\n  \"ButtonAdd\": \"Afegeix\",\n  \"ButtonAddApiKey\": \"Afegeix clau API\",\n  \"ButtonAddChapters\": \"Afegeix capítols\",\n  \"ButtonAddDevice\": \"Afegeix un aparell\",\n  \"ButtonAddLibrary\": \"Afegeix una biblioteca\",\n  \"ButtonAddPodcasts\": \"Afegeix pòdcasts\",\n  \"ButtonAddUser\": \"Afegeix un usuari\",\n  \"ButtonAddYourFirstLibrary\": \"Afegiu la vostra primera biblioteca\",\n  \"ButtonApply\": \"Aplica\",\n  \"ButtonApplyChapters\": \"Aplica capítols\",\n  \"ButtonAuthors\": \"Autors\",\n  \"ButtonBack\": \"Enrere\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Omplir des d'existent\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Omple els detalls del mapa\",\n  \"ButtonBrowseForFolder\": \"Cerca una carpeta\",\n  \"ButtonCancel\": \"Cancel·la\",\n  \"ButtonCancelEncode\": \"Cancel·la la codificació\",\n  \"ButtonChangeRootPassword\": \"Canvia Contrasenya Root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Verifica i Descarrega Nous Episodis\",\n  \"ButtonChooseAFolder\": \"Trieu una carpeta\",\n  \"ButtonChooseFiles\": \"Trieu fitxers\",\n  \"ButtonClearFilter\": \"Neteja el filtre\",\n  \"ButtonClose\": \"Tanca\",\n  \"ButtonCloseFeed\": \"Tanca el canal\",\n  \"ButtonCloseSession\": \"Tanca la sessió oberta\",\n  \"ButtonCollections\": \"Col·leccions\",\n  \"ButtonConfigureScanner\": \"Configura Escàner\",\n  \"ButtonCreate\": \"Crea\",\n  \"ButtonCreateBackup\": \"Crea Còpia de Seguretat\",\n  \"ButtonDelete\": \"Suprimeix\",\n  \"ButtonDownloadQueue\": \"Cua\",\n  \"ButtonEdit\": \"Edita\",\n  \"ButtonEditChapters\": \"Edita capítols\",\n  \"ButtonEditPodcast\": \"Edita el pòdcast\",\n  \"ButtonEnable\": \"Habilita\",\n  \"ButtonFireAndFail\": \"Executat i fallat\",\n  \"ButtonFireOnTest\": \"Activa esdeveniment de prova\",\n  \"ButtonForceReScan\": \"Força Re-escaneig\",\n  \"ButtonFullPath\": \"Ruta Completa\",\n  \"ButtonHide\": \"Amaga\",\n  \"ButtonHome\": \"Inici\",\n  \"ButtonIssues\": \"Problemes\",\n  \"ButtonJumpBackward\": \"Retrocedeix\",\n  \"ButtonJumpForward\": \"Avança\",\n  \"ButtonLatest\": \"Últims\",\n  \"ButtonLibrary\": \"Biblioteca\",\n  \"ButtonLogout\": \"Tanca Sessió\",\n  \"ButtonLookup\": \"Cerca\",\n  \"ButtonManageTracks\": \"Gestiona Pistes d'Àudio\",\n  \"ButtonMapChapterTitles\": \"Assigna Títols als Capítols\",\n  \"ButtonMatchAllAuthors\": \"Troba Tots els Autors\",\n  \"ButtonMatchBooks\": \"Troba Llibres\",\n  \"ButtonNevermind\": \"Oblida-ho\",\n  \"ButtonNext\": \"Següent\",\n  \"ButtonNextChapter\": \"Següent Capítol\",\n  \"ButtonNextItemInQueue\": \"Següent element a la cua\",\n  \"ButtonOk\": \"D’acord\",\n  \"ButtonOpenFeed\": \"Obre Font\",\n  \"ButtonOpenManager\": \"Obre Editor\",\n  \"ButtonPause\": \"Pausa\",\n  \"ButtonPlay\": \"Reprodueix\",\n  \"ButtonPlayAll\": \"Reprodueix tot\",\n  \"ButtonPlaying\": \"Reproduint\",\n  \"ButtonPlaylists\": \"Llistes de reproducció\",\n  \"ButtonPrevious\": \"Anterior\",\n  \"ButtonPreviousChapter\": \"Capítol Anterior\",\n  \"ButtonProbeAudioFile\": \"Examina fitxer d'àudio\",\n  \"ButtonPurgeAllCache\": \"Esborra Tot el Cache\",\n  \"ButtonPurgeItemsCache\": \"Esborra Cache d'Elements\",\n  \"ButtonQueueAddItem\": \"Afegeix a la Cua\",\n  \"ButtonQueueRemoveItem\": \"Elimina de la Cua\",\n  \"ButtonQuickEmbed\": \"Inserció Ràpida\",\n  \"ButtonQuickEmbedMetadata\": \"Afegeix Metadades Ràpidament\",\n  \"ButtonQuickMatch\": \"Troba Ràpidament\",\n  \"ButtonReScan\": \"Re-escaneja\",\n  \"ButtonRead\": \"Llegeix\",\n  \"ButtonReadLess\": \"Llegeix menys\",\n  \"ButtonReadMore\": \"Llegeix més\",\n  \"ButtonRefresh\": \"Refresca\",\n  \"ButtonRemove\": \"Elimina\",\n  \"ButtonRemoveAll\": \"Elimina Tot\",\n  \"ButtonRemoveAllLibraryItems\": \"Elimina Tots els Elements de la Biblioteca\",\n  \"ButtonRemoveFromContinueListening\": \"Elimina de Continuar Escoltant\",\n  \"ButtonRemoveFromContinueReading\": \"Elimina de Continuar Llegint\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Elimina Sèrie de Continuar Sèries\",\n  \"ButtonReset\": \"Restableix\",\n  \"ButtonResetToDefault\": \"Restaura Valors per Defecte\",\n  \"ButtonRestore\": \"Restaura\",\n  \"ButtonSave\": \"Desa\",\n  \"ButtonSaveAndClose\": \"Desa i Tanca\",\n  \"ButtonSaveTracklist\": \"Desa Pistes\",\n  \"ButtonScan\": \"Escaneja\",\n  \"ButtonScanLibrary\": \"Escaneja Biblioteca\",\n  \"ButtonScrollLeft\": \"Mou a l'esquerra\",\n  \"ButtonScrollRight\": \"Mou a la dreta\",\n  \"ButtonSearch\": \"Cerca\",\n  \"ButtonSelectFolderPath\": \"Selecciona Ruta de Carpeta\",\n  \"ButtonSeries\": \"Sèries\",\n  \"ButtonSetChaptersFromTracks\": \"Selecciona Capítols Segons les Pistes\",\n  \"ButtonShare\": \"Comparteix\",\n  \"ButtonShiftTimes\": \"Desplaça Temps\",\n  \"ButtonShow\": \"Mostra\",\n  \"ButtonStartM4BEncode\": \"Inicia Codificació M4B\",\n  \"ButtonStartMetadataEmbed\": \"Inicia Inserció de Metadades\",\n  \"ButtonStats\": \"Estadístiques\",\n  \"ButtonSubmit\": \"Envia\",\n  \"ButtonTest\": \"Prova\",\n  \"ButtonUnlinkOpenId\": \"Desvincula OpenID\",\n  \"ButtonUpload\": \"Carrega\",\n  \"ButtonUploadBackup\": \"Carrega Còpia de Seguretat\",\n  \"ButtonUploadCover\": \"Puja una coberta\",\n  \"ButtonUploadOPMLFile\": \"Puja un fitxer OPML\",\n  \"ButtonUserDelete\": \"Suprimeix l'usuari {0}\",\n  \"ButtonUserEdit\": \"Edita l'usuari {0}\",\n  \"ButtonViewAll\": \"Mostra-ho tot\",\n  \"ButtonYes\": \"Sí\",\n  \"ErrorUploadFetchMetadataAPI\": \"Error obtenint metadades\",\n  \"ErrorUploadFetchMetadataNoResults\": \"No s'han pogut obtenir metadades - Intenta actualitzar el títol i/o autor\",\n  \"ErrorUploadLacksTitle\": \"S'ha de tenir un títol\",\n  \"HeaderAccount\": \"Compte\",\n  \"HeaderAddCustomMetadataProvider\": \"Afegeix un proveïdor de metadades personalitzat\",\n  \"HeaderAdvanced\": \"Avançat\",\n  \"HeaderApiKeys\": \"Claus API\",\n  \"HeaderAppriseNotificationSettings\": \"Paràmetres de notificacions Apprise\",\n  \"HeaderAudioTracks\": \"Pistes d'àudio\",\n  \"HeaderAudiobookTools\": \"Eines de gestió de fitxers de l'audiollibre\",\n  \"HeaderAuthentication\": \"Autenticació\",\n  \"HeaderBackups\": \"Còpies de Seguretat\",\n  \"HeaderBulkChapterModal\": \"Afegeix capítols múltiples\",\n  \"HeaderChangePassword\": \"Canvia Contrasenya\",\n  \"HeaderChapters\": \"Capítols\",\n  \"HeaderChooseAFolder\": \"Tria una Carpeta\",\n  \"HeaderCollection\": \"Col·lecció\",\n  \"HeaderCollectionItems\": \"Elements a la Col·lecció\",\n  \"HeaderCover\": \"Coberta\",\n  \"HeaderCurrentDownloads\": \"Baixades actuals\",\n  \"HeaderCustomMessageOnLogin\": \"Missatge personalitzat en iniciar sessió\",\n  \"HeaderCustomMetadataProviders\": \"Proveïdors de metadades personalitzats\",\n  \"HeaderDetails\": \"Detalls\",\n  \"HeaderDownloadQueue\": \"Cua de baixades\",\n  \"HeaderEbookFiles\": \"Fitxers de llibres digitals\",\n  \"HeaderEmail\": \"Correu electrònic\",\n  \"HeaderEmailSettings\": \"Paràmetres de correu electrònic\",\n  \"HeaderEpisodes\": \"Episodis\",\n  \"HeaderEreaderDevices\": \"Dispositius Ereader\",\n  \"HeaderEreaderSettings\": \"Paràmetres del lector\",\n  \"HeaderFiles\": \"Element\",\n  \"HeaderFindChapters\": \"Cerca Capítol\",\n  \"HeaderIgnoredFiles\": \"Ignora Element\",\n  \"HeaderItemFiles\": \"Carpetes d'Elements\",\n  \"HeaderItemMetadataUtils\": \"Utilitats de Metadades d'Elements\",\n  \"HeaderLastListeningSession\": \"Últimes Sessions\",\n  \"HeaderLatestEpisodes\": \"Últims Episodis\",\n  \"HeaderLibraries\": \"Biblioteques\",\n  \"HeaderLibraryFiles\": \"Fitxers de Biblioteca\",\n  \"HeaderLibraryStats\": \"Estadístiques de Biblioteca\",\n  \"HeaderListeningSessions\": \"Sessió\",\n  \"HeaderListeningStats\": \"Estadístiques de Temps Escoltat\",\n  \"HeaderLogin\": \"Inicia Sessió\",\n  \"HeaderLogs\": \"Registres\",\n  \"HeaderManageGenres\": \"Gestiona Gèneres\",\n  \"HeaderManageTags\": \"Gestiona Etiquetes\",\n  \"HeaderMapDetails\": \"Assigna Detalls\",\n  \"HeaderMatch\": \"Troba\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Ordre de Precedència de Metadades\",\n  \"HeaderMetadataToEmbed\": \"Metadades a Inserir\",\n  \"HeaderNewAccount\": \"Nou Compte\",\n  \"HeaderNewApiKey\": \"Nova clau API\",\n  \"HeaderNewLibrary\": \"Nova Biblioteca\",\n  \"HeaderNotificationCreate\": \"Crea Notificació\",\n  \"HeaderNotificationUpdate\": \"Actualització de Notificació\",\n  \"HeaderNotifications\": \"Notificacions\",\n  \"HeaderOpenIDConnectAuthentication\": \"Autenticació OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Sessions públiques d'escolta\",\n  \"HeaderOpenRSSFeed\": \"Obre Font RSS\",\n  \"HeaderOtherFiles\": \"Altres Fitxers\",\n  \"HeaderPasswordAuthentication\": \"Autenticació per Contrasenya\",\n  \"HeaderPermissions\": \"Permisos\",\n  \"HeaderPlayerQueue\": \"Cua del Reproductor\",\n  \"HeaderPlayerSettings\": \"Paràmetres del reproductor\",\n  \"HeaderPlaylist\": \"Llista de Reproducció\",\n  \"HeaderPlaylistItems\": \"Elements de la Llista de Reproducció\",\n  \"HeaderPodcastsToAdd\": \"Pòdcasts a afegir\",\n  \"HeaderPresets\": \"Valors predefinits\",\n  \"HeaderPreviewCover\": \"Previsualització de la Portada\",\n  \"HeaderRSSFeedGeneral\": \"Detalls RSS\",\n  \"HeaderRSSFeedIsOpen\": \"La Font RSS està oberta\",\n  \"HeaderRSSFeeds\": \"Fonts RSS\",\n  \"HeaderRemoveEpisode\": \"Elimina Episodi\",\n  \"HeaderRemoveEpisodes\": \"Elimina {0} Episodis\",\n  \"HeaderSavedMediaProgress\": \"Desa el Progrés del Multimèdia\",\n  \"HeaderSchedule\": \"Horari\",\n  \"HeaderScheduleEpisodeDownloads\": \"Programa baixades automàtiques d'episodis\",\n  \"HeaderScheduleLibraryScans\": \"Programa Escaneig Automàtic de Biblioteca\",\n  \"HeaderSession\": \"Sessió\",\n  \"HeaderSetBackupSchedule\": \"Programa Còpies de Seguretat\",\n  \"HeaderSettings\": \"Paràmetres\",\n  \"HeaderSettingsDisplay\": \"Interfície\",\n  \"HeaderSettingsExperimental\": \"Funcionalitats experimentals\",\n  \"HeaderSettingsGeneral\": \"Generals\",\n  \"HeaderSettingsScanner\": \"Escàner\",\n  \"HeaderSettingsSecurity\": \"Seguretat\",\n  \"HeaderSettingsWebClient\": \"Client web\",\n  \"HeaderSleepTimer\": \"Temporitzador de son\",\n  \"HeaderStatsLargestItems\": \"Elements més grans\",\n  \"HeaderStatsLongestItems\": \"Elements més llargs (h)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minuts escoltant (últims 7 dies)\",\n  \"HeaderStatsRecentSessions\": \"Sessions recents\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autors\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Gèneres\",\n  \"HeaderTableOfContents\": \"Sumari\",\n  \"HeaderTools\": \"Eines\",\n  \"HeaderUpdateAccount\": \"Actualitza el compte\",\n  \"HeaderUpdateAuthor\": \"Actualitza l'autor\",\n  \"HeaderUpdateDetails\": \"Actualitza els detalls\",\n  \"HeaderUpdateLibrary\": \"Actualitza la biblioteca\",\n  \"HeaderUsers\": \"Usuaris\",\n  \"HeaderYearReview\": \"Revisió de l'any {0}\",\n  \"HeaderYourStats\": \"Les vostres estadístiques\",\n  \"LabelAbridged\": \"Resumit\",\n  \"LabelAbridgedChecked\": \"Resumit (comprovat)\",\n  \"LabelAbridgedUnchecked\": \"Sense resumir (no comprovat)\",\n  \"LabelAccessibleBy\": \"Accessible per\",\n  \"LabelAccountType\": \"Tipus de compte\",\n  \"LabelAccountTypeAdmin\": \"Administrador\",\n  \"LabelAccountTypeGuest\": \"Convidat\",\n  \"LabelAccountTypeUser\": \"Usuari\",\n  \"LabelActivities\": \"Activitats\",\n  \"LabelActivity\": \"Activitat\",\n  \"LabelAddToCollection\": \"Afegeix a la col·lecció\",\n  \"LabelAddToCollectionBatch\": \"Afegeix {0} llibres a la col·lecció\",\n  \"LabelAddToPlaylist\": \"Afegeix a la llista de reproducció\",\n  \"LabelAddToPlaylistBatch\": \"Afegeix {0} elements a la llista de reproducció\",\n  \"LabelAddedAt\": \"Afegit\",\n  \"LabelAddedDate\": \"{0} Afegit\",\n  \"LabelAdminUsersOnly\": \"Només usuaris administradors\",\n  \"LabelAll\": \"Tots\",\n  \"LabelAllEpisodesDownloaded\": \"Tots els episodis baixats\",\n  \"LabelAllUsers\": \"Tots els usuaris\",\n  \"LabelAllUsersExcludingGuests\": \"Tots els usuaris excepte convidats\",\n  \"LabelAllUsersIncludingGuests\": \"Tots els usuaris i convidats\",\n  \"LabelAlreadyInYourLibrary\": \"Ja existeix a la biblioteca\",\n  \"LabelApiToken\": \"Testimoni de l'API\",\n  \"LabelAppend\": \"Adjuntar\",\n  \"LabelAudioBitrate\": \"Taxa de bits d'àudio (per exemple, 128k)\",\n  \"LabelAudioChannels\": \"Canals d'àudio (1 o 2)\",\n  \"LabelAudioCodec\": \"Còdec d'àudio\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Nom Cognom)\",\n  \"LabelAuthorLastFirst\": \"Autor (Cognom, Nom)\",\n  \"LabelAuthors\": \"Autors\",\n  \"LabelAutoDownloadEpisodes\": \"Baixa episodis automàticament\",\n  \"LabelAutoFetchMetadata\": \"Recupera metadades automàticament\",\n  \"LabelAutoFetchMetadataHelp\": \"Obtén metadades de títol, autor i sèrie per agilitzar la càrrega. És possible que calgui revisar metadades addicionals després de la càrrega.\",\n  \"LabelAutoLaunch\": \"Inici automàtic\",\n  \"LabelAutoLaunchDescription\": \"Redirigir automàticament al proveïdor d'autenticació quan s'accedeix a la pàgina d'inici de sessió (ruta d'excepció manual <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Registre automàtic\",\n  \"LabelAutoRegisterDescription\": \"Crear usuaris automàticament en iniciar sessió\",\n  \"LabelBackToUser\": \"Torna a Usuari\",\n  \"LabelBackupAudioFiles\": \"Còpia de seguretat d'arxius d'àudio\",\n  \"LabelBackupLocation\": \"Ubicació de la Còpia de Seguretat\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Còpies de seguretat automàtiques\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Còpies de seguretat desades a /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Mida màxima de la còpia de seguretat (en GB) (0 per il·limitat)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Com a protecció contra una configuració incorrecta, les còpies de seguretat fallaran si superen la mida configurada.\",\n  \"LabelBackupsNumberToKeep\": \"Nombre de còpies de seguretat a conservar\",\n  \"LabelBackupsNumberToKeepHelp\": \"Només s'eliminarà una còpia de seguretat alhora. Si té més còpies desades, haurà d'eliminar-les manualment.\",\n  \"LabelBitrate\": \"Taxa de bits\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Llibres\",\n  \"LabelButtonText\": \"Text del botó\",\n  \"LabelByAuthor\": \"per {0}\",\n  \"LabelChangePassword\": \"Canviar Contrasenya\",\n  \"LabelChannels\": \"Canals\",\n  \"LabelChapterCount\": \"{0} capítols\",\n  \"LabelChapterTitle\": \"Títol del Capítol\",\n  \"LabelChapters\": \"Capítols\",\n  \"LabelChaptersFound\": \"Capítol Trobat\",\n  \"LabelClickForMoreInfo\": \"Fes clic per a més informació\",\n  \"LabelClickToUseCurrentValue\": \"Fes clic per utilitzar el valor actual\",\n  \"LabelClosePlayer\": \"Tanca el reproductor\",\n  \"LabelCodec\": \"Còdec\",\n  \"LabelCollapseSeries\": \"Contraure sèrie\",\n  \"LabelCollapseSubSeries\": \"Contraure la subsèrie\",\n  \"LabelCollection\": \"Col·lecció\",\n  \"LabelCollections\": \"Col·leccions\",\n  \"LabelComplete\": \"Complet\",\n  \"LabelConfirmPassword\": \"Confirmar Contrasenya\",\n  \"LabelContinueListening\": \"Continuar escoltant\",\n  \"LabelContinueReading\": \"Continuar llegint\",\n  \"LabelContinueSeries\": \"Continuar sèries\",\n  \"LabelCover\": \"Coberta\",\n  \"LabelCoverImageURL\": \"URL de la imatge de coberta\",\n  \"LabelCoverProvider\": \"Proveïdor de cobertes\",\n  \"LabelCreatedAt\": \"Creat\",\n  \"LabelCronExpression\": \"Expressió de Cron\",\n  \"LabelCurrent\": \"Actual\",\n  \"LabelCurrently\": \"En aquest moment:\",\n  \"LabelCustomCronExpression\": \"Expressió del Cron personalitzada:\",\n  \"LabelDatetime\": \"Data i hora\",\n  \"LabelDays\": \"Dies\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Suprimeix del sistema de fitxers (desmarqueu per a eliminar de la base de dades només)\",\n  \"LabelDescription\": \"Descripció\",\n  \"LabelDeselectAll\": \"Desseleccionar Tots\",\n  \"LabelDevice\": \"Dispositiu\",\n  \"LabelDeviceInfo\": \"Informació de l'aparell\",\n  \"LabelDeviceIsAvailableTo\": \"El dispositiu està disponible per a...\",\n  \"LabelDirectory\": \"Directori\",\n  \"LabelDiscFromFilename\": \"Disc a partir del nom de fitxer\",\n  \"LabelDiscFromMetadata\": \"Disc a partir de metadades\",\n  \"LabelDiscover\": \"Descobrir\",\n  \"LabelDownload\": \"Descarregar\",\n  \"LabelDownloadNEpisodes\": \"Descarregar {0} episodis\",\n  \"LabelDownloadable\": \"Baixables\",\n  \"LabelDuration\": \"Duració\",\n  \"LabelDurationComparisonExactMatch\": \"(coincidència exacta)\",\n  \"LabelDurationComparisonLonger\": \"({0} més llarg)\",\n  \"LabelDurationComparisonShorter\": \"({0} més curt)\",\n  \"LabelDurationFound\": \"Durada trobada:\",\n  \"LabelEbook\": \"Llibre electrònic\",\n  \"LabelEbooks\": \"Llibres electrònics\",\n  \"LabelEdit\": \"Edita\",\n  \"LabelEmail\": \"Correu electrònic\",\n  \"LabelEmailSettingsFromAddress\": \"Remitent\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Rebutja certificats no autoritzats\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Desactivar la validació de certificats SSL pot exposar la teva connexió a riscos de seguretat, com atacs de tipus man-in-the-middle. Desactiva aquesta opció només si coneixes les implicacions i confies en el servidor de correu al qual et connectes.\",\n  \"LabelEmailSettingsSecure\": \"Seguretat\",\n  \"LabelEmailSettingsSecureHelp\": \"Si està activat, es farà servir TLS per a connectar-se al servidor. Si està desactivat, es farà servir TLS si el servidor admet l'extensió STARTTLS. En la majoria dels casos, podeu deixar aquesta opció activada si us connecteu al port 465. Desactiveu-la en el cas d'usar els ports 587 o 25. (de nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Provar Adreça\",\n  \"LabelEmbeddedCover\": \"Portada Integrada\",\n  \"LabelEnable\": \"Habilitar\",\n  \"LabelEncodingBackupLocation\": \"Es desarà una còpia de seguretat dels vostres fitxers d'àudio originals a:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Els capítols no s'incrusten en els audiollibres multipista.\",\n  \"LabelEncodingClearItemCache\": \"Assegura't de purgar periòdicament la memòria cau.\",\n  \"LabelEncodingFinishedM4B\": \"El M4B acabat es col·locarà a la teva carpeta d'audiollibres a:\",\n  \"LabelEncodingInfoEmbedded\": \"Les metadades s'integraran a les pistes d'àudio dins de la carpeta d'audiollibres.\",\n  \"LabelEncodingStartedNavigation\": \"Un cop iniciada la tasca, pots sortir d'aquesta pàgina.\",\n  \"LabelEncodingTimeWarning\": \"La codificació pot trigar fins a 30 minuts.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Advertència: No actualitzis aquesta configuració tret que estiguis familiaritzat amb les opcions de codificació d'ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Si heu desactivat la supervisió dels fitxers, haureu de tornar a escanejar aquest audiollibre més endavant.\",\n  \"LabelEnd\": \"Fi\",\n  \"LabelEndOfChapter\": \"Fi del capítol\",\n  \"LabelEpisode\": \"Episodi\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episodi no enllaçat al canal RSS\",\n  \"LabelEpisodeNumber\": \"Episodi #{0}\",\n  \"LabelEpisodeTitle\": \"Títol de l'Episodi\",\n  \"LabelEpisodeType\": \"Tipus d'Episodi\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL de l'episodi del canal RSS\",\n  \"LabelEpisodes\": \"Episodis\",\n  \"LabelEpisodic\": \"Episodis\",\n  \"LabelExample\": \"Exemple\",\n  \"LabelExpandSeries\": \"Ampliar sèrie\",\n  \"LabelExpandSubSeries\": \"Expandir la subsèrie\",\n  \"LabelExplicit\": \"Explícit\",\n  \"LabelExplicitChecked\": \"Explícit (marcat)\",\n  \"LabelExplicitUnchecked\": \"No Explícit (sense marcar)\",\n  \"LabelExportOPML\": \"Exportar OPML\",\n  \"LabelFeedURL\": \"Font de URL\",\n  \"LabelFetchingMetadata\": \"Obtenció de metadades\",\n  \"LabelFile\": \"Fitxer\",\n  \"LabelFileBirthtime\": \"Fitxer creat a\",\n  \"LabelFileBornDate\": \"Creat {0}\",\n  \"LabelFileModified\": \"Fitxer modificat\",\n  \"LabelFileModifiedDate\": \"Modificat {0}\",\n  \"LabelFilename\": \"Nom del fitxer\",\n  \"LabelFilterByUser\": \"Filtrar per Usuari\",\n  \"LabelFindEpisodes\": \"Cercar Episodi\",\n  \"LabelFinished\": \"Acabat\",\n  \"LabelFolder\": \"Carpeta\",\n  \"LabelFolders\": \"Carpetes\",\n  \"LabelFontBold\": \"Negreta\",\n  \"LabelFontBoldness\": \"Gruix de la lletra\",\n  \"LabelFontFamily\": \"Família tipogràfica\",\n  \"LabelFontItalic\": \"Cursiva\",\n  \"LabelFontScale\": \"Escala de la lletra\",\n  \"LabelFontStrikethrough\": \"Ratllat\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Complet\",\n  \"LabelGenre\": \"Gènere\",\n  \"LabelGenres\": \"Gèneres\",\n  \"LabelHardDeleteFile\": \"Eliminar Definitivament\",\n  \"LabelHasEbook\": \"Té llibre electrònic\",\n  \"LabelHasSupplementaryEbook\": \"Té llibre electrònic suplementari\",\n  \"LabelHideSubtitles\": \"Amagar subtítols\",\n  \"LabelHighestPriority\": \"Prioritat més alta\",\n  \"LabelHost\": \"Amfitrió\",\n  \"LabelHour\": \"Hora\",\n  \"LabelHours\": \"Hores\",\n  \"LabelIcon\": \"Icona\",\n  \"LabelImageURLFromTheWeb\": \"URL de la imatge\",\n  \"LabelInProgress\": \"En procés\",\n  \"LabelIncludeInTracklist\": \"Incloure a la Llista de Pistes\",\n  \"LabelIncomplete\": \"Incomplet\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Personalitzar diari/setmanal\",\n  \"LabelIntervalEvery12Hours\": \"Cada 12 hores\",\n  \"LabelIntervalEvery15Minutes\": \"Cada 15 minuts\",\n  \"LabelIntervalEvery2Hours\": \"Cada 2 hores\",\n  \"LabelIntervalEvery30Minutes\": \"Cada 30 minuts\",\n  \"LabelIntervalEvery6Hours\": \"Cada 6 hores\",\n  \"LabelIntervalEveryDay\": \"Cada dia\",\n  \"LabelIntervalEveryHour\": \"Cada hora\",\n  \"LabelIntervalEveryMinute\": \"Cada minut\",\n  \"LabelInvert\": \"Invertir\",\n  \"LabelItem\": \"Element\",\n  \"LabelJumpBackwardAmount\": \"Quantitat de salts cap enrere\",\n  \"LabelJumpForwardAmount\": \"Quantitat de salts cap endavant\",\n  \"LabelLanguage\": \"Llengua\",\n  \"LabelLanguageDefaultServer\": \"Llengua per defecte del servidor\",\n  \"LabelLanguages\": \"Llengües\",\n  \"LabelLastBookAdded\": \"Últim llibre afegit\",\n  \"LabelLastBookUpdated\": \"Últim llibre actualitzat\",\n  \"LabelLastSeen\": \"Última Vegada Vist\",\n  \"LabelLastTime\": \"Última Vegada\",\n  \"LabelLastUpdate\": \"Última Actualització\",\n  \"LabelLayout\": \"Disposició\",\n  \"LabelLayoutSinglePage\": \"Pàgina única\",\n  \"LabelLayoutSplitPage\": \"Dues Pàgines\",\n  \"LabelLess\": \"Menys\",\n  \"LabelLibrariesAccessibleToUser\": \"Biblioteques Disponibles per a l'Usuari\",\n  \"LabelLibrary\": \"Biblioteca\",\n  \"LabelLibraryFilterSublistEmpty\": \"Sense {0}\",\n  \"LabelLibraryItem\": \"Element de Biblioteca\",\n  \"LabelLibraryName\": \"Nom de Biblioteca\",\n  \"LabelLibrarySortByProgress\": \"Progrés: Última actualització\",\n  \"LabelLibrarySortByProgressFinished\": \"Progrés: Finalitzat\",\n  \"LabelLibrarySortByProgressStarted\": \"Progrés: Començat\",\n  \"LabelLimit\": \"Límits\",\n  \"LabelLineSpacing\": \"Interlineat\",\n  \"LabelListenAgain\": \"Escoltar de nou\",\n  \"LabelLogLevelDebug\": \"Depurar\",\n  \"LabelLogLevelInfo\": \"Informació\",\n  \"LabelLogLevelWarn\": \"Advertència\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Cercar nous episodis a partir d'aquesta data\",\n  \"LabelLowestPriority\": \"Menor prioritat\",\n  \"LabelMatchExistingUsersBy\": \"Emparellar els usuaris existents per\",\n  \"LabelMatchExistingUsersByDescription\": \"S'utilitza per connectar usuaris existents. Un cop connectats, els usuaris seran emparellats per un identificador únic del seu proveïdor de SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Nombre màxim d'episodis per descarregar. Usa 0 per descarregar una quantitat il·limitada.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Nombre màxim de nous episodis que es descarregaran per comprovació\",\n  \"LabelMaxEpisodesToKeep\": \"Nombre màxim d'episodis que es mantindran\",\n  \"LabelMaxEpisodesToKeepHelp\": \"El valor 0 no estableix un límit màxim. Després de descarregar automàticament un nou episodi, això eliminarà l'episodi més antic si té més de X episodis. Això només eliminarà 1 episodi per nova descàrrega.\",\n  \"LabelMediaPlayer\": \"Reproductor multimèdia\",\n  \"LabelMediaType\": \"Tipus de mitjà\",\n  \"LabelMetaTag\": \"Metaetiqueta\",\n  \"LabelMetaTags\": \"Metaetiquetes\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Les fonts de metadades de major prioritat prevaldran sobre les de menor prioritat\",\n  \"LabelMetadataProvider\": \"Proveïdor de metadades\",\n  \"LabelMinute\": \"Minut\",\n  \"LabelMinutes\": \"Minuts\",\n  \"LabelMissing\": \"Falta\",\n  \"LabelMissingEbook\": \"No té llibre electrònic\",\n  \"LabelMissingSupplementaryEbook\": \"No té ebook complementari\",\n  \"LabelMobileRedirectURIs\": \"URI de redirecció mòbil permeses\",\n  \"LabelMobileRedirectURIsDescription\": \"Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és <code> audiobookshelf</code>, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc (<code> *</code>) com a única entrada que permet qualsevol URI.\",\n  \"LabelMore\": \"Més\",\n  \"LabelMoreInfo\": \"Més informació\",\n  \"LabelName\": \"Nom\",\n  \"LabelNarrator\": \"Narrador\",\n  \"LabelNarrators\": \"Narradors\",\n  \"LabelNew\": \"Nou\",\n  \"LabelNewPassword\": \"Nova Contrasenya\",\n  \"LabelNewestAuthors\": \"Autors més recents\",\n  \"LabelNewestEpisodes\": \"Episodis més recents\",\n  \"LabelNextBackupDate\": \"Data del Següent Respatller\",\n  \"LabelNextScheduledRun\": \"Proper Execució Programada\",\n  \"LabelNoCustomMetadataProviders\": \"Sense proveïdors de metadades personalitzats\",\n  \"LabelNoEpisodesSelected\": \"Cap Episodi Seleccionat\",\n  \"LabelNotFinished\": \"No acabat\",\n  \"LabelNotStarted\": \"Sense iniciar\",\n  \"LabelNotes\": \"Notes\",\n  \"LabelNotificationAppriseURL\": \"URL(s) d'Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Variables Disponibles\",\n  \"LabelNotificationBodyTemplate\": \"Plantilla de Cos\",\n  \"LabelNotificationEvent\": \"Esdeveniment de Notificació\",\n  \"LabelNotificationTitleTemplate\": \"Plantilla de Títol\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Màxim d'Intents Fallits\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Les notificacions es desactivaran després de fallar aquest nombre de vegades\",\n  \"LabelNotificationsMaxQueueSize\": \"Mida màxima de la cua de notificacions\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Les notificacions estan limitades a 1 per segon. Les notificacions seran ignorades si arriben al número màxim de cua per prevenir spam d'esdeveniments.\",\n  \"LabelNumberOfBooks\": \"Nombre de llibres\",\n  \"LabelNumberOfEpisodes\": \"Nre. d'episodis\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Nom de la notificació de OpenID que conté permisos avançats per accions d'usuari dins l'aplicació que s'aplicaran a rols que no siguin d'administrador (<b>si estan configurats</b>). Si el reclam no apareix en la resposta, es denegarà l'accés a ABS. Si manca una sola opció, es tractarà com a <code>falsa</code>. Assegura't que la notificació del proveïdor d'identitats coincideixi amb l'estructura esperada:\",\n  \"LabelOpenIDClaims\": \"Deixa les següents opcions buides per desactivar l'assignació avançada de grups i permisos, el que assignaria automàticament al grup 'Usuari'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Nom de la declaració OpenID que conté una llista de grups de l'usuari. Comunament coneguts com <code>grups</code>. <b>Si es configura</b>, l'aplicació assignarà automàticament rols basats en la pertinença a grups de l'usuari, sempre que aquests grups es denominen 'admin', 'user' o 'guest' en la notificació. La sol·licitud ha de contenir una llista, i si un usuari pertany a diversos grups, l'aplicació assignarà el rol corresponent al major nivell d'accés. Si cap grup coincideix, es denegarà l'accés.\",\n  \"LabelOpenRSSFeed\": \"Obre el canal RSS\",\n  \"LabelOverwrite\": \"Sobreescriure\",\n  \"LabelPaginationPageXOfY\": \"Pàgina {0} de {1}\",\n  \"LabelPassword\": \"Contrasenya\",\n  \"LabelPath\": \"Camí\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Pot accedir a totes les biblioteques\",\n  \"LabelPermissionsAccessAllTags\": \"Pot accedir a totes les etiquetes\",\n  \"LabelPermissionsAccessExplicitContent\": \"Pot accedir a contingut explícit\",\n  \"LabelPermissionsCreateEreader\": \"Pot crear un lector\",\n  \"LabelPermissionsDelete\": \"Pot suprimir\",\n  \"LabelPermissionsDownload\": \"Pot baixar\",\n  \"LabelPermissionsUpdate\": \"Pot actualitzar\",\n  \"LabelPermissionsUpload\": \"Pot pujar\",\n  \"LabelPersonalYearReview\": \"Revisió del vostre any ({0})\",\n  \"LabelPhotoPathURL\": \"Camí/URL de la foto\",\n  \"LabelPlayMethod\": \"Mètode de reproducció\",\n  \"LabelPlayerChapterNumberMarker\": \"{0}/{1}\",\n  \"LabelPlaylists\": \"Llistes de reproducció\",\n  \"LabelPodcast\": \"Pòdcast\",\n  \"LabelPodcastSearchRegion\": \"Regió de cerca de pòdcasts\",\n  \"LabelPodcastType\": \"Tipus de pòdcast\",\n  \"LabelPodcasts\": \"Pòdcasts\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Prefixos a ignorar (no distingeix entre majúscules i minúscules)\",\n  \"LabelPreventIndexing\": \"Evita que el vostre canal l'indexin els directoris de pòdcasts de l'iTunes i Google\",\n  \"LabelPrimaryEbook\": \"Llibre electrònic principal\",\n  \"LabelProgress\": \"Progrés\",\n  \"LabelProvider\": \"Proveïdor\",\n  \"LabelProviderAuthorizationValue\": \"Valor de l'encapçalament d'autorització\",\n  \"LabelPubDate\": \"Data de publicació\",\n  \"LabelPublishYear\": \"Any de publicació\",\n  \"LabelPublishedDate\": \"Publicat {0}\",\n  \"LabelPublishedDecade\": \"Dècada de publicació\",\n  \"LabelPublishedDecades\": \"Dècades Publicades\",\n  \"LabelPublisher\": \"Editor\",\n  \"LabelPublishers\": \"Editors\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Correu Electrònic Personalitzat del Propietari\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nom Personalitzat del Propietari\",\n  \"LabelRSSFeedOpen\": \"Font RSS Oberta\",\n  \"LabelRSSFeedPreventIndexing\": \"Evita la indexació\",\n  \"LabelRSSFeedSlug\": \"URL semàntic del canal RSS\",\n  \"LabelRSSFeedURL\": \"URL del canal RSS\",\n  \"LabelRandomly\": \"A l'atzar\",\n  \"LabelReAddSeriesToContinueListening\": \"Reafegir la sèrie per continuar escoltant-la\",\n  \"LabelRead\": \"Llegit\",\n  \"LabelReadAgain\": \"Tornar a llegir\",\n  \"LabelReadEbookWithoutProgress\": \"Llegir Ebook sense guardar progrés\",\n  \"LabelRecentSeries\": \"Sèries recents\",\n  \"LabelRecentlyAdded\": \"Addicions recents\",\n  \"LabelRecommended\": \"Recomanats\",\n  \"LabelRedo\": \"Refés\",\n  \"LabelRegion\": \"Regió\",\n  \"LabelReleaseDate\": \"Data d'estrena\",\n  \"LabelRemoveAllMetadataAbs\": \"Elimina tots els fitxers metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Elimina tots els fitxers metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Elimina la introducció i el tancament de l'Audible dels capítols\",\n  \"LabelRemoveCover\": \"Elimina la coberta\",\n  \"LabelRemoveMetadataFile\": \"Eliminar fitxers de metadades en carpetes d'elements de biblioteca\",\n  \"LabelRemoveMetadataFileHelp\": \"Elimina tots els fitxers metadata.json i metadata.abs de les vostres carpetes {0}.\",\n  \"LabelRowsPerPage\": \"Files per pàgina\",\n  \"LabelSearchTerm\": \"Cerca terme\",\n  \"LabelSearchTitle\": \"Cerca títol\",\n  \"LabelSearchTitleOrASIN\": \"Cerca títol o ASIN\",\n  \"LabelSeason\": \"Temporada\",\n  \"LabelSeasonNumber\": \"{0}a temporada\",\n  \"LabelSelectAll\": \"Selecciona-ho tot\",\n  \"LabelSelectAllEpisodes\": \"Selecciona tots els episodis\",\n  \"LabelSelectEpisodesShowing\": \"Seleccionar els {0} episodis visibles\",\n  \"LabelSelectUsers\": \"Seleccionar usuaris\",\n  \"LabelSendEbookToDevice\": \"Enviar Ebook a...\",\n  \"LabelSequence\": \"Seqüència\",\n  \"LabelSerial\": \"En sèrie\",\n  \"LabelSeries\": \"Sèrie\",\n  \"LabelSeriesName\": \"Nom de la sèrie\",\n  \"LabelSeriesProgress\": \"Progrés de la sèrie\",\n  \"LabelServerLogLevel\": \"Nivell de registre del servidor\",\n  \"LabelServerYearReview\": \"Resum de l'any del servidor ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Establir com a principal\",\n  \"LabelSetEbookAsSupplementary\": \"Establir com a suplementari\",\n  \"LabelSettingsAudiobooksOnly\": \"Només audiollibres\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"En activar aquesta opció s'ignoraran els fitxers de llibre electrònic, excepte si estan dins d'una carpeta d'audiollibre; en aquest cas es marcaran com a llibres suplementaris\",\n  \"LabelSettingsBookshelfViewHelp\": \"Disseny esqueomorf amb prestatgeries de fusta\",\n  \"LabelSettingsChromecastSupport\": \"Compatibilitat amb Chromecast\",\n  \"LabelSettingsDateFormat\": \"Format de data\",\n  \"LabelSettingsEnableWatcherHelp\": \"Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Permetre scripts en epubs\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Funcions Experimentals\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funcions en desenvolupament que es beneficiarien dels teus comentaris i experiències de prova. Feu clic aquí per obrir una conversa a Github.\",\n  \"LabelSettingsFindCovers\": \"Troba cobertes\",\n  \"LabelSettingsHideSingleBookSeries\": \"Amaga les sèries amb un sol llibre\",\n  \"LabelSettingsParseSubtitles\": \"Analitza els subtítols\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignora els prefixos en ordenar\",\n  \"LabelSettingsTimeFormat\": \"Format d'hora\",\n  \"LabelShare\": \"Comparteix\",\n  \"LabelShareDownloadableHelp\": \"Permet els usuaris amb l'enllaç de compartició de baixar un fitxer ZIP amb l'element de la biblioteca.\",\n  \"LabelShareURL\": \"URL de compartició\",\n  \"LabelShowAll\": \"Mostra-ho tot\",\n  \"LabelShowSeconds\": \"Mostra segons\",\n  \"LabelShowSubtitles\": \"Mostra subtítols\",\n  \"LabelSize\": \"Mida\",\n  \"LabelSleepTimer\": \"Temporitzador de repòs\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Ascendent\",\n  \"LabelSortDescending\": \"Descendent\",\n  \"LabelStart\": \"Inicia\",\n  \"LabelStartTime\": \"Hora d'inici\",\n  \"LabelStarted\": \"Iniciat\",\n  \"LabelStartedAt\": \"Iniciat a\",\n  \"LabelStatsAudioTracks\": \"Pistes d'àudio\",\n  \"LabelStatsAuthors\": \"Autors\",\n  \"LabelStatsBestDay\": \"Millor dia\",\n  \"LabelStatsDailyAverage\": \"Mitjana diària\",\n  \"LabelStatsDays\": \"Dies\",\n  \"LabelStatsDaysListened\": \"Dies escoltats\",\n  \"LabelStatsHours\": \"Hores\",\n  \"LabelStatsInARow\": \"seguits\",\n  \"LabelStatsItemsFinished\": \"Elements acabats\",\n  \"LabelStatsItemsInLibrary\": \"Elements a la biblioteca\",\n  \"LabelStatsMinutes\": \"minuts\",\n  \"LabelStatsMinutesListening\": \"Minuts escoltant\",\n  \"LabelStatsOverallDays\": \"Total de dies\",\n  \"LabelStatsOverallHours\": \"Total d'hores\",\n  \"LabelStatsWeekListening\": \"Temps escoltat aquesta setmana\",\n  \"LabelSubtitle\": \"Subtítol\",\n  \"LabelSupportedFileTypes\": \"Tipus de fitxers compatibles\",\n  \"LabelTag\": \"Etiqueta\",\n  \"LabelTags\": \"Etiquetes\",\n  \"LabelTagsAccessibleToUser\": \"Etiquetes accessibles per a l'usuari\",\n  \"LabelTagsNotAccessibleToUser\": \"Etiquetes no accessibles per a l'usuari\",\n  \"LabelTasks\": \"Tasques en execució\",\n  \"LabelTextEditorBulletedList\": \"Llista amb punts\",\n  \"LabelTextEditorLink\": \"Enllaça\",\n  \"LabelTextEditorNumberedList\": \"Llista numerada\",\n  \"LabelTextEditorUnlink\": \"Desenllaça\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Fosc\",\n  \"LabelThemeLight\": \"Clar\",\n  \"LabelTimeBase\": \"Temps base\",\n  \"LabelTimeDurationXHours\": \"{0} hores\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuts\",\n  \"LabelTimeDurationXSeconds\": \"{0} segons\",\n  \"LabelTimeInMinutes\": \"Temps en minuts\",\n  \"LabelTimeLeft\": \"Queden {0}\",\n  \"LabelTimeListened\": \"Temps escoltat\",\n  \"LabelTimeListenedToday\": \"Temps escoltat avui\",\n  \"LabelTimeRemaining\": \"{0} restant\",\n  \"LabelTimeToShift\": \"Temps per canviar en segons\",\n  \"LabelTitle\": \"Títol\",\n  \"LabelToolsEmbedMetadata\": \"Incrusta metadades\",\n  \"LabelToolsEmbedMetadataDescription\": \"Incrusta metadades en els fitxers d'àudio, incloent la portada i capítols.\",\n  \"LabelToolsM4bEncoder\": \"Codificador M4B\",\n  \"LabelToolsMakeM4b\": \"Crea fitxer d'audiollibre M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Genera un fitxer d'audiollibre .M4B amb metadades, imatges de portada i capítols incrustats.\",\n  \"LabelToolsSplitM4b\": \"Divideix M4B en fitxers MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Divideix un M4B en fitxers MP3 i incrusta metadades, imatges de portada i capítols.\",\n  \"LabelTotalDuration\": \"Duració total\",\n  \"LabelTotalTimeListened\": \"Temps total escoltat\",\n  \"LabelTrackFromFilename\": \"Pista des del nom del fitxer\",\n  \"LabelTrackFromMetadata\": \"Pista des de metadades\",\n  \"LabelTracks\": \"Pistes\",\n  \"LabelTracksMultiTrack\": \"Diverses pistes\",\n  \"LabelTracksNone\": \"Cap pista\",\n  \"LabelTracksSingleTrack\": \"Una pista\",\n  \"LabelTrailer\": \"Tràiler\",\n  \"LabelType\": \"Tipus\",\n  \"LabelUnabridged\": \"No abreujat\",\n  \"LabelUndo\": \"Desfés\",\n  \"LabelUnknown\": \"Desconegut\",\n  \"LabelUnknownPublishDate\": \"Data de publicació desconeguda\",\n  \"LabelUpdateCover\": \"Actualitza la coberta\",\n  \"LabelUpdateCoverHelp\": \"Permet sobreescriure les portades existents dels llibres seleccionats quan es trobi una coincidència.\",\n  \"LabelUpdateDetails\": \"Actualitza detalls\",\n  \"LabelUpdateDetailsHelp\": \"Permet sobreescriure els detalls existents dels llibres seleccionats quan es trobin coincidències.\",\n  \"LabelUpdatedAt\": \"Actualitzat a\",\n  \"LabelUploaderDragAndDrop\": \"Arrossega i deixa anar fitxers o carpetes\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Arrossega i deixa anar fitxers\",\n  \"LabelUploaderDropFiles\": \"Deixa anar els fitxers\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Cerca títol, autor i sèries automàticament\",\n  \"LabelUseAdvancedOptions\": \"Utilitza opcions avançades\",\n  \"LabelUseChapterTrack\": \"Utilitza pista per capítol\",\n  \"LabelUseFullTrack\": \"Utilitza pista completa\",\n  \"LabelUseZeroForUnlimited\": \"Utilitza 0 per il·limitat\",\n  \"LabelUser\": \"Usuari\",\n  \"LabelUsername\": \"Nom d'usuari\",\n  \"LabelValue\": \"Valor\",\n  \"LabelVersion\": \"Versió\",\n  \"LabelViewBookmarks\": \"Mostra marcadors\",\n  \"LabelViewChapters\": \"Mostra capítols\",\n  \"LabelViewPlayerSettings\": \"Mostra els ajustaments del reproductor\",\n  \"LabelViewQueue\": \"Mostra cua del reproductor\",\n  \"LabelVolume\": \"Volum\",\n  \"LabelWebRedirectURLsDescription\": \"Autoritzeu aquests URL al vostre proveïdor OAuth per a permetre redirigir a l’aplicació web després d'iniciar sessió:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Subcarpeta per a URL de redirecció\",\n  \"LabelWeekdaysToRun\": \"Executar en dies de la setmana\",\n  \"LabelXBooks\": \"{0} llibres\",\n  \"LabelXItems\": \"{0} elements\",\n  \"LabelYearReviewHide\": \"Oculta resum de l'any\",\n  \"LabelYearReviewShow\": \"Mostra resum de l'any\",\n  \"LabelYourAudiobookDuration\": \"Duració del vostre audiollibre\",\n  \"LabelYourBookmarks\": \"Els vostres marcadors\",\n  \"LabelYourPlaylists\": \"Les vostres llistes\",\n  \"LabelYourProgress\": \"El vostre progrés\",\n  \"MessageAddToPlayerQueue\": \"Afegeix a la cua del reproductor\",\n  \"MessageAppriseDescription\": \"Per utilitzar aquesta funció, hauràs de tenir l'<a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">API d'Apprise</a> en funcionament o una API que gestioni resultats similars. <br/>La URL de l'API d'Apprise ha de tenir la mateixa ruta d'arxius que on s'envien les notificacions. Per exemple: si la teva API és a <code>http://192.168.1.1:8337</code>, llavors posaries <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Reengegueu el servidor després de desar perquè s'hi apliquin els canvis d'OIDC.\",\n  \"MessageBackupsDescription\": \"Les còpies de seguretat inclouen: usuaris, progrés dels usuaris, detalls dels elements de la biblioteca, configuració del servidor i imatges a <code>/metadata/items</code> i <code>/metadata/authors</code>. Les còpies de seguretat <strong>NO</strong> inclouen cap fitxer guardat a la carpeta de la teva biblioteca.\",\n  \"MessageBackupsLocationEditNote\": \"Nota: Actualitzar la ubicació de la còpia de seguretat no mourà ni modificarà les còpies existents\",\n  \"MessageBackupsLocationNoEditNote\": \"Nota: La ubicació de la còpia de seguretat es defineix mitjançant una variable d'entorn i no es pot modificar aquí.\",\n  \"MessageBackupsLocationPathEmpty\": \"La ruta de la còpia de seguretat no pot estar buida\",\n  \"MessageBatchQuickMatchDescription\": \"La funció \\\"Troba Ràpid\\\" intentarà afegir portades i metadades que falten als elements seleccionats. Activa l'opció següent perquè \\\"Troba Ràpid\\\" pugui sobreescriure portades i/o metadades existents.\",\n  \"MessageBookshelfNoCollections\": \"Encara no heu fet cap col·lecció\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Les col·leccions són públiques. Tots els usuaris amb accés a la biblioteca les podran veure.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Cap font RSS està oberta\",\n  \"MessageBookshelfNoResultsForFilter\": \"Cap resultat per al filtre «{0}: {1}»\",\n  \"MessageBookshelfNoResultsForQuery\": \"Cap resultat per a la consulta\",\n  \"MessageBookshelfNoSeries\": \"No teniu cap sèrie\",\n  \"MessageChapterEndIsAfter\": \"El final del capítol és després del final del teu audiollibre\",\n  \"MessageChapterErrorFirstNotZero\": \"El primer capítol ha de començar a 0\",\n  \"MessageChapterErrorStartGteDuration\": \"El temps d'inici no és vàlid: ha de ser inferior a la durada de l'audiollibre\",\n  \"MessageChapterErrorStartLtPrev\": \"El temps d'inici no és vàlid: ha de ser igual o més gran que el temps d'inici del capítol anterior\",\n  \"MessageChapterStartIsAfter\": \"L'inici del capítol és després del final del teu audiollibre\",\n  \"MessageChaptersNotFound\": \"No s'han trobat els capítols\",\n  \"MessageCheckingCron\": \"Comprovant cron...\",\n  \"MessageConfirmCloseFeed\": \"Segur que voleu tancar aquest canal?\",\n  \"MessageConfirmDeleteBackup\": \"Segur que voleu suprimir la còpia de seguretat de {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Segur que voleu suprimir el lector electrònic «{0}»?\",\n  \"MessageConfirmDeleteFile\": \"Això suprimirà el fitxer del vostre sistema de fitxers. N'esteu segur?\",\n  \"MessageConfirmDeleteLibrary\": \"Segur que voleu suprimir permanentment la biblioteca «{0}»?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Això suprimirà l’element de la base de dades i del sistema de fitxers. N’esteu segur?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Això suprimirà {0} element(s) de la base de dades i del sistema de fitxers. N'esteu segur?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Segur que voleu suprimir el proveïdor de metadades personalitzat «{0}»?\",\n  \"MessageConfirmDeleteNotification\": \"Segur que voleu suprimir aquesta notificació?\",\n  \"MessageConfirmDeleteSession\": \"Segur que voleu suprimir aquesta sessió?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Segur que voleu incrustar metadades a {0} fitxer(s) d'àudio?\",\n  \"MessageConfirmForceReScan\": \"Segur que voleu forçar un reescaneig?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Segur que voleu marcar tots els episodis com a acabats?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Segur que voleu marcar tots els episodis com a no acabats?\",\n  \"MessageConfirmMarkItemFinished\": \"Segur que voleu marcar «{0}» com a acabat?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Segur que voleu marcar «{0}» com a no acabat?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Segur que voleu marcar tots els llibres d'aquesta sèrie com a acabats?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Segur que voleu marcar tots els llibres d'aquesta sèrie com a no acabats?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Voleu activar aquesta notificació amb dades de prova?\",\n  \"MessageConfirmPurgeCache\": \"Purgar la memòria cau suprimirà tot el directori localitzat a <code>/metadata/cache</code>. <br /><br />Segur que voleu eliminar-lo?\",\n  \"MessageConfirmPurgeItemsCache\": \"Esborrar la memòria cau dels elements eliminarà el directori <code>/metadata/cache/items</code>.<br />Estàs segur?\",\n  \"MessageConfirmQuickEmbed\": \"Avís: la incrustació ràpida no fa còpies de seguretat dels vostres fitxers d'àudio. Assegureu-vos d'haver-ne fet una còpia abans. <br><br>Voleu continuar?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"El reconeixement ràpid sobreescriurà els detalls si es troba una coincidència. Estàs segur?\",\n  \"MessageConfirmReScanLibraryItems\": \"Segur que voleu reescanejar {0} element(s)?\",\n  \"MessageConfirmRemoveAllChapters\": \"Segur que voleu eliminar tots els capítols?\",\n  \"MessageConfirmRemoveAuthor\": \"Segur que voleu eliminar l'autor «{0}»?\",\n  \"MessageConfirmRemoveCollection\": \"Segur que voleu eliminar la col·lecció «{0}»?\",\n  \"MessageConfirmRemoveEpisode\": \"Segur que voleu eliminar l'episodi «{0}»?\",\n  \"MessageConfirmRemoveEpisodes\": \"Segur que voleu eliminar {0} episodis?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Segur que voleu eliminar {0} sessions d'escolta?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Segur que voleu eliminar tots els fitxers metadata.{0} de les carpetes dels elements de la vostra biblioteca?\",\n  \"MessageConfirmRemoveNarrator\": \"Segur que voleu eliminar el narrador «{0}»?\",\n  \"MessageConfirmRemovePlaylist\": \"Segur que voleu eliminar la llista de reproducció «{0}»?\",\n  \"MessageConfirmRenameGenre\": \"Segur que voleu canviar el nom del gènere «{0}» a «{1}» per a tots els elements?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Nota: Aquest gènere ja existeix, i es fusionarà.\",\n  \"MessageConfirmRenameGenreWarning\": \"Advertència! Ja existeix un gènere similar \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Segur que voleu canviar el nom de l'etiqueta «{0}» a «{1}» per a tots els elements?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Nota: Aquesta etiqueta ja existeix, i es fusionarà.\",\n  \"MessageConfirmRenameTagWarning\": \"Advertència! Ja existeix una etiqueta similar \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Segur que voleu reinicialitzar el vostre progrés?\",\n  \"MessageConfirmSendEbookToDevice\": \"Segur que voleu enviar {0} llibre(s) «{1}» al dispositiu «{2}»?\",\n  \"MessageConfirmUnlinkOpenId\": \"Segur que voleu desenllaçar aquest usuari d'OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dies escoltats l'any passat\",\n  \"MessageDownloadingEpisode\": \"S'està baixant l'episodi\",\n  \"MessageDragFilesIntoTrackOrder\": \"Arrossega els fitxers en l'ordre correcte de les pistes\",\n  \"MessageEmbedFailed\": \"Error en incrustar!\",\n  \"MessageEmbedFinished\": \"Incrustació acabada!\",\n  \"MessageEmbedQueue\": \"En cua per incrustar metadades ({0} en cua)\",\n  \"MessageFeedURLWillBe\": \"L'URL del canal serà {0}\",\n  \"MessageFetching\": \"S'està recuperant...\",\n  \"MessageImportantNotice\": \"Avís important\",\n  \"MessageInsertChapterBelow\": \"Insereix un capítol a sota\",\n  \"MessageInvalidAsin\": \"L'ASIN no és vàlid\",\n  \"MessageItemsSelected\": \"{0} elements seleccionats\",\n  \"MessageItemsUpdated\": \"{0} elements actualitzats\",\n  \"MessageJoinUsOn\": \"Uniu-vos a nosaltres a\",\n  \"MessageLoading\": \"S'està carregant...\",\n  \"MessageLoadingFolders\": \"S'estan carregant les carpetes...\",\n  \"MessageMarkAllEpisodesFinished\": \"Marca tots els episodis com a acabats\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Marca tots els episodis com a inacabats\",\n  \"MessageMarkAsFinished\": \"Marcar com acabat\",\n  \"MessageMarkAsNotFinished\": \"Marcar com no acabat\",\n  \"MessageMatchBooksDescription\": \"S'intentarà fer coincidir els llibres de la biblioteca amb un llibre del proveïdor de cerca seleccionat, i s'ompliran els detalls buits i la portada. No sobreescriu els detalls.\",\n  \"MessageNoAudioTracks\": \"Sense pistes d'àudio\",\n  \"MessageNoAuthors\": \"Sense autors\",\n  \"MessageNoBackups\": \"Sense còpies de seguretat\",\n  \"MessageNoBookmarks\": \"Sense marcadors\",\n  \"MessageNoChapters\": \"Cap capítol\",\n  \"MessageNoCollections\": \"Cap col·lecció\",\n  \"MessageNoCoversFound\": \"No s'ha trobat cap coberta\",\n  \"MessageNoDescription\": \"Sense descripció\",\n  \"MessageNoDevices\": \"Sense dispositius\",\n  \"MessageNoDownloadsInProgress\": \"No hi ha baixades en curs\",\n  \"MessageNoDownloadsQueued\": \"No hi ha cap baixada en cua\",\n  \"MessageNoEpisodeMatchesFound\": \"No s'han trobat episodis que coincideixin\",\n  \"MessageNoEpisodes\": \"Sense episodis\",\n  \"MessageNoFoldersAvailable\": \"No hi ha carpetes disponibles\",\n  \"MessageNoGenres\": \"Sense gèneres\",\n  \"MessageNoIssues\": \"Sense problemes\",\n  \"MessageNoItems\": \"Cap element\",\n  \"MessageNoItemsFound\": \"Cap element trobat\",\n  \"MessageNoListeningSessions\": \"Cap sessió d'escolta\",\n  \"MessageNoLogs\": \"Sense registres\",\n  \"MessageNoMediaProgress\": \"Sense progrés multimèdia\",\n  \"MessageNoNotifications\": \"Sense notificacions\",\n  \"MessageNoPodcastFeed\": \"Podcast no vàlid: sense font\",\n  \"MessageNoPodcastsFound\": \"No s'ha trobat cap pòdcast\",\n  \"MessageNoResults\": \"Sense resultats\",\n  \"MessageNoSearchResultsFor\": \"No hi ha resultats per a la cerca «{0}»\",\n  \"MessageNoSeries\": \"Sense sèries\",\n  \"MessageNoTags\": \"Sense etiquetes\",\n  \"MessageNoTasksRunning\": \"Sense tasques en execució\",\n  \"MessageNoUpdatesWereNecessary\": \"No calia cap actualització\",\n  \"MessageNoUserPlaylists\": \"No teniu cap llista de reproducció\",\n  \"MessageNotYetImplemented\": \"Encara no implementat\",\n  \"MessageOpmlPreviewNote\": \"Nota: aquesta és una previsualització del fitxer OPML analitzat. El títol real del pòdcast s'obtindrà del canal RSS.\",\n  \"MessageOr\": \"o\",\n  \"MessagePauseChapter\": \"Pausar la reproducció del capítol\",\n  \"MessagePlayChapter\": \"Escoltar l'inici del capítol\",\n  \"MessagePlaylistCreateFromCollection\": \"Crear una llista de reproducció a partir d'una col·lecció\",\n  \"MessagePleaseWait\": \"Espereu...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"El pòdcast no té un URL de canal RSS que es pugui utilitzar\",\n  \"MessagePodcastSearchField\": \"Introduïu el terme de cerca o l'URL del canal RSS\",\n  \"MessageQuickEmbedInProgress\": \"Integració ràpida en procés\",\n  \"MessageQuickEmbedQueue\": \"En cua per a inserció ràpida ({0} en cua)\",\n  \"MessageQuickMatchAllEpisodes\": \"Combina ràpidament tots els episodis\",\n  \"MessageQuickMatchDescription\": \"Emplena els detalls i la coberta dels elements buits amb el resultat de la primera coincidència de «{0}». No sobreescriu els detalls tret que s'activi el paràmetre del servidor «Prefereix metadades coincidents».\",\n  \"MessageRemoveChapter\": \"Elimina el capítol\",\n  \"MessageRemoveEpisodes\": \"Elimina {0} episodi(s)\",\n  \"MessageRemoveFromPlayerQueue\": \"Elimina de la cua del reproductor\",\n  \"MessageRemoveUserWarning\": \"Segur que voleu suprimir permanentment l'usuari «{0}»?\",\n  \"MessageReportBugsAndContribute\": \"Informa d'errors, sol·licita funcions i contribueix a\",\n  \"MessageResetChaptersConfirm\": \"Segur que voleu desfer els canvis i revertir els capítols al seu estat original?\",\n  \"MessageRestoreBackupConfirm\": \"Segur que voleu restaurar la còpia de seguretat creada a\",\n  \"MessageRestoreBackupWarning\": \"Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.<br /><br />La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.<br /><br />Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Executa cada {0} a les {1}\",\n  \"MessageSearchResultsFor\": \"Resultats de la cerca de\",\n  \"MessageSelected\": \"{0} seleccionat(s)\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"La seqüència de la sèrie no pot contenir espais\",\n  \"MessageServerCouldNotBeReached\": \"No es va poder establir la connexió amb el servidor\",\n  \"MessageSetChaptersFromTracksDescription\": \"Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio\",\n  \"MessageShareExpirationWillBe\": \"La caducitat serà <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Caduca en {0}\",\n  \"MessageShareURLWillBe\": \"La URL per compartir serà <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Voleu començar la reproducció per a «{0}» a {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"El fitxer d'àudio «{0}» no es pot escriure\",\n  \"MessageTaskCanceledByUser\": \"Tasca cancel·lada per l'usuari\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"S'està baixant l'episodi «{0}»\",\n  \"MessageTaskEmbeddingMetadata\": \"Inserint metadades\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Inserint metadades en l'audiollibre \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Codificant M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"S'està codificant l'audiollibre «{0}» en un únic fitxer M4B\",\n  \"MessageTaskFailed\": \"Fallada\",\n  \"MessageTaskFailedToBackupAudioFile\": \"No s'ha pogut fer una còpia de seguretat del fitxer d'àudio «{0}»\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Error en crear el directori de la memòria cau\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Error en incrustar metadades en el fitxer \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Error en fusionar fitxers d'àudio\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Error en moure el fitxer M4B\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Error en escriure el fitxer de metadades\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Coincidint llibres a la biblioteca \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Sense fitxers per escanejar\",\n  \"MessageTaskOpmlImport\": \"Importar OPML\",\n  \"MessageTaskOpmlImportDescription\": \"S'estan creant pòdcasts a partir de {0} canals RSS\",\n  \"MessageTaskOpmlImportFeed\": \"Importació d'un canal OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"S'està important el canal RSS «{0}»\",\n  \"MessageTaskOpmlImportFeedFailed\": \"No s'ha pogut obtenir el canal del pòdcast\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"S'està creant el pòdcast «{0}»\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"El pòdcast ja existeix al camí\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"No s'ha pogut crear el pòdcast\",\n  \"MessageTaskOpmlImportFinished\": \"S'han afegit {0} pòdcasts\",\n  \"MessageTaskOpmlParseFailed\": \"No s'ha pogut analitzar el fitxer OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"No s'ha trobat l'etiqueta <opml> o <outline> al fitxer OPML\",\n  \"MessageTaskOpmlParseNoneFound\": \"No s'han trobat fonts al fitxer OPML\",\n  \"MessageTaskScanItemsAdded\": \"{0} afegit\",\n  \"MessageTaskScanItemsMissing\": \"{0} faltant\",\n  \"MessageTaskScanItemsUpdated\": \"{0} actualitzat\",\n  \"MessageTaskScanNoChangesNeeded\": \"No calen canvis\",\n  \"MessageTaskScanningFileChanges\": \"Escanejant canvis al fitxer en \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Escanejant la biblioteca \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"El directori de destinació no es pot escriure\",\n  \"MessageThinking\": \"Pensant...\",\n  \"MessageUploaderItemFailed\": \"Error en pujar\",\n  \"MessageUploaderItemSuccess\": \"Pujada amb èxit!\",\n  \"MessageUploading\": \"Pujant...\",\n  \"MessageValidCronExpression\": \"Expressió de cron vàlida\",\n  \"MessageWatcherIsDisabledGlobally\": \"El watcher està desactivat globalment a la configuració del servidor\",\n  \"MessageXLibraryIsEmpty\": \"La biblioteca {0} està buida!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"La durada del vostre audiollibre és major que la durada trobada\",\n  \"MessageYourAudiobookDurationIsShorter\": \"La durada del vostre audiollibre és menor que la durada trobada\",\n  \"NoteChangeRootPassword\": \"L'usuari Root és l'únic usuari que pot no tenir una contrasenya\",\n  \"NoteChapterEditorTimes\": \"Nota: el temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.\",\n  \"NoteFolderPicker\": \"Nota: les carpetes ja assignades no es mostraran\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Avís: la majoria d'aplicacions de pòdcast requereixen que l'URL del canal RSS utilitzi HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Avís: un o més dels vostres episodis no tenen data de publicació. Algunes aplicacions de pòdcast ho requereixen.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Les carpetes amb fitxers multimèdia es gestionaran com a elements separats a la biblioteca.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Si només puges fitxers d'àudio, cada fitxer es gestionarà com un audiollibre separat.\",\n  \"NoteUploaderUnsupportedFiles\": \"Els fitxers no compatibles seran ignorats. Si selecciona o arrossega una carpeta, els fitxers que no estiguin dins d'una subcarpeta seran ignorats.\",\n  \"NotificationOnBackupCompletedDescription\": \"S'activa quan es completa una còpia de seguretat\",\n  \"NotificationOnBackupFailedDescription\": \"S'activa quan falla una còpia de seguretat\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"S'activa quan es descarrega automàticament un episodi d'un podcast\",\n  \"NotificationOnTestDescription\": \"Esdeveniment per provar el sistema de notificacions\",\n  \"PlaceholderNewCollection\": \"Nou nom de la col·lecció\",\n  \"PlaceholderNewFolderPath\": \"Camí de carpeta nou\",\n  \"PlaceholderNewPlaylist\": \"Nou nom de la llista de reproducció\",\n  \"PlaceholderSearch\": \"Cerca...\",\n  \"PlaceholderSearchEpisode\": \"Cerca d'episodis...\",\n  \"StatsAuthorsAdded\": \"autors afegits\",\n  \"StatsBooksAdded\": \"llibres afegits\",\n  \"StatsBooksAdditional\": \"Algunes addicions inclouen…\",\n  \"StatsBooksFinished\": \"llibres acabats\",\n  \"StatsBooksFinishedThisYear\": \"Alguns llibres acabats aquest any…\",\n  \"StatsBooksListenedTo\": \"llibres escoltats\",\n  \"StatsCollectionGrewTo\": \"La teva col·lecció de llibres ha crescut fins a…\",\n  \"StatsSessions\": \"sessions\",\n  \"StatsSpentListening\": \"dedicat a escoltar\",\n  \"StatsTopAuthor\": \"AUTOR DESTACAT\",\n  \"StatsTopAuthors\": \"AUTORS DESTACATS\",\n  \"StatsTopGenre\": \"GÈNERE PRINCIPAL\",\n  \"StatsTopGenres\": \"GÈNERES PRINCIPALS\",\n  \"StatsTopMonth\": \"DESTACAT DEL MES\",\n  \"StatsTopNarrator\": \"NARRADOR DESTACAT\",\n  \"StatsTopNarrators\": \"NARRADORS DESTACATS\",\n  \"StatsTotalDuration\": \"Amb una durada total de…\",\n  \"StatsYearInReview\": \"RESUM DE L'ANY\",\n  \"ToastAccountUpdateSuccess\": \"Compte actualitzat\",\n  \"ToastAppriseUrlRequired\": \"Cal introduir una URL de Apprise\",\n  \"ToastAsinRequired\": \"ASIN requerit\",\n  \"ToastAuthorImageRemoveSuccess\": \"S'ha eliminat la imatge de l'autor\",\n  \"ToastAuthorNotFound\": \"No s'ha trobat l'autor «{0}»\",\n  \"ToastAuthorRemoveSuccess\": \"Autor eliminat\",\n  \"ToastAuthorSearchNotFound\": \"No s'ha trobat l'autor\",\n  \"ToastAuthorUpdateMerged\": \"Autor combinat\",\n  \"ToastAuthorUpdateSuccess\": \"Autor actualitzat\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor actualitzat (Imatge no trobada)\",\n  \"ToastBackupAppliedSuccess\": \"Còpia de seguretat aplicada\",\n  \"ToastBackupCreateFailed\": \"Error en crear la còpia de seguretat\",\n  \"ToastBackupCreateSuccess\": \"Còpia de seguretat creada\",\n  \"ToastBackupDeleteFailed\": \"Error en eliminar la còpia de seguretat\",\n  \"ToastBackupDeleteSuccess\": \"Còpia de seguretat eliminada\",\n  \"ToastBackupInvalidMaxKeep\": \"Nombre no vàlid de còpies de seguretat a conservar\",\n  \"ToastBackupInvalidMaxSize\": \"Mida màxima de còpia de seguretat no vàlida\",\n  \"ToastBackupRestoreFailed\": \"Error en restaurar la còpia de seguretat\",\n  \"ToastBackupUploadFailed\": \"Error en carregar la còpia de seguretat\",\n  \"ToastBackupUploadSuccess\": \"Còpia de seguretat carregada\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"S'han aplicat els detalls als elements\",\n  \"ToastBatchDeleteFailed\": \"Error en l'eliminació per lots\",\n  \"ToastBatchDeleteSuccess\": \"Eliminació per lots correcte\",\n  \"ToastBatchQuickMatchFailed\": \"Error en la sincronització ràpida per lots!\",\n  \"ToastBatchQuickMatchStarted\": \"S'ha iniciat la sincronització ràpida per lots de {0} llibres!\",\n  \"ToastBatchUpdateFailed\": \"Error en l'actualització massiva\",\n  \"ToastBatchUpdateSuccess\": \"Actualització massiva completada\",\n  \"ToastBookmarkCreateFailed\": \"Error en crear marcador\",\n  \"ToastBookmarkCreateSuccess\": \"Marcador afegit\",\n  \"ToastBookmarkRemoveSuccess\": \"Marcador eliminat\",\n  \"ToastCachePurgeFailed\": \"Error en purgar la memòria cau\",\n  \"ToastCachePurgeSuccess\": \"Memòria cau purgada amb èxit\",\n  \"ToastChaptersHaveErrors\": \"Els capítols tenen errors\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"La quantitat de desplaçament no és vàlida. L'hora d'inici de l'últim capítol s'estendria més enllà de la durada d'aquest audiollibre.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"La quantitat de desplaçament no és vàlida. El primer capítol tindria una durada zero o negativa i el sobreescriuria el segon capítol. Augmenteu la durada inicial del segon capítol.\",\n  \"ToastChaptersMustHaveTitles\": \"Els capítols han de tenir un títol\",\n  \"ToastChaptersRemoved\": \"Capítols eliminats\",\n  \"ToastChaptersUpdated\": \"Capítols actualitzats\",\n  \"ToastCollectionItemsAddFailed\": \"Error en afegir elements a la col·lecció\",\n  \"ToastCollectionRemoveSuccess\": \"Col·lecció eliminada\",\n  \"ToastCollectionUpdateSuccess\": \"Col·lecció actualitzada\",\n  \"ToastCoverUpdateFailed\": \"Error en actualitzar la portada\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"La data i hora no és vàlida o està incompleta\",\n  \"ToastDeleteFileFailed\": \"No s'ha pogut suprimir el fitxer\",\n  \"ToastDeleteFileSuccess\": \"Fitxer suprimit\",\n  \"ToastDeviceAddFailed\": \"Error en afegir el dispositiu\",\n  \"ToastDeviceNameAlreadyExists\": \"Ja existeix un dispositiu amb aquest nom\",\n  \"ToastDeviceTestEmailFailed\": \"Error en enviar el correu de prova\",\n  \"ToastDeviceTestEmailSuccess\": \"Correu de prova enviat\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Configuració de correu electrònic actualitzada\",\n  \"ToastEncodeCancelFailed\": \"No s'ha pogut cancel·lar la codificació\",\n  \"ToastEncodeCancelSucces\": \"Codificació cancel·lada\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"No s'ha pogut buidar la cua de baixades\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"La cua d'episodis baixats s'ha netejat\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} episodi(s) actualitzat(s)\",\n  \"ToastErrorCannotShare\": \"No es pot compartir de manera nativa en aquest dispositiu\",\n  \"ToastFailedToLoadData\": \"Error en carregar les dades\",\n  \"ToastFailedToMatch\": \"Error en emparellar\",\n  \"ToastFailedToShare\": \"Error en compartir\",\n  \"ToastFailedToUpdate\": \"Error en actualitzar\",\n  \"ToastInvalidImageUrl\": \"URL de la imatge no vàlida\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Nombre màxim d'episodis per descarregar no vàlid\",\n  \"ToastInvalidUrl\": \"URL no vàlida\",\n  \"ToastItemCoverUpdateSuccess\": \"Portada de l'element actualitzada\",\n  \"ToastItemDeletedFailed\": \"Error en eliminar l'element\",\n  \"ToastItemDeletedSuccess\": \"Element eliminat\",\n  \"ToastItemDetailsUpdateSuccess\": \"Detalls de l'element actualitzats\",\n  \"ToastItemMarkedAsFinishedFailed\": \"No s'ha pogut marcar com a finalitzat\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Element marcat com a acabat\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"No s'ha pogut marcar com a no finalitzat\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Element marcat com a no acabat\",\n  \"ToastItemUpdateSuccess\": \"Element actualitzat\",\n  \"ToastLibraryCreateFailed\": \"Error en crear la biblioteca\",\n  \"ToastLibraryCreateSuccess\": \"S'ha creat la biblioteca «{0}»\",\n  \"ToastLibraryDeleteFailed\": \"Error en eliminar la biblioteca\",\n  \"ToastLibraryDeleteSuccess\": \"Biblioteca eliminada\",\n  \"ToastLibraryScanFailedToStart\": \"Error en iniciar l'escaneig\",\n  \"ToastLibraryScanStarted\": \"S'ha iniciat l'escaneig de la biblioteca\",\n  \"ToastLibraryUpdateSuccess\": \"S'ha actualitzat la biblioteca «{0}»\",\n  \"ToastMatchAllAuthorsFailed\": \"No coincideix amb tots els autors\",\n  \"ToastMetadataFilesRemovedError\": \"S’ha produït un error en eliminar els fitxers metadata.{0}\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"No hi ha cap fitxer metadata.{0} a la biblioteca\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"No s'ha eliminat cap fitxer metadata.{0}\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadades eliminades en {1} arxius\",\n  \"ToastMustHaveAtLeastOnePath\": \"Ha de tenir almenys una ruta\",\n  \"ToastNameEmailRequired\": \"El nom i el correu electrònic són obligatoris\",\n  \"ToastNameRequired\": \"Nom obligatori\",\n  \"ToastNewEpisodesFound\": \"{0} episodi(s) nou(s) trobat(s)\",\n  \"ToastNewUserCreatedFailed\": \"No s'ha pogut crear el compte: «{0}»\",\n  \"ToastNewUserCreatedSuccess\": \"Nou compte creat\",\n  \"ToastNewUserLibraryError\": \"S'ha de seleccionar almenys una biblioteca\",\n  \"ToastNewUserPasswordError\": \"Cal una contrasenya; només l'usuari primari pot estar sense contrasenya\",\n  \"ToastNewUserTagError\": \"S'ha de seleccionar almenys una etiqueta\",\n  \"ToastNewUserUsernameError\": \"Introduïu un nom d'usuari\",\n  \"ToastNoNewEpisodesFound\": \"No s'han trobat nous episodis\",\n  \"ToastNoRSSFeed\": \"El pòdcast no té canal RSS\",\n  \"ToastNoUpdatesNecessary\": \"No cal actualitzar\",\n  \"ToastNotificationCreateFailed\": \"No s'ha pogut crear la notificació\",\n  \"ToastNotificationDeleteFailed\": \"No s'ha pogut suprimir la notificació\",\n  \"ToastNotificationFailedMaximum\": \"El nombre màxim d'intents fallits ha de ser ≥ 0\",\n  \"ToastNotificationQueueMaximum\": \"La cua de notificació màxima ha de ser ≥ 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"S'han actualitzat els paràmetres de notificacions\",\n  \"ToastNotificationTestTriggerFailed\": \"No s'ha pogut activar la notificació de prova\",\n  \"ToastNotificationTestTriggerSuccess\": \"Notificació de prova activada\",\n  \"ToastNotificationUpdateSuccess\": \"Notificació actualitzada\",\n  \"ToastPlaylistCreateFailed\": \"No s'ha pogut crear la llista de reproducció\",\n  \"ToastPlaylistCreateSuccess\": \"Llista de reproducció creada\",\n  \"ToastPlaylistRemoveSuccess\": \"Llista de reproducció eliminada\",\n  \"ToastPlaylistUpdateSuccess\": \"Llista de reproducció actualitzada\",\n  \"ToastPodcastCreateFailed\": \"No s'ha pogut crear el pòdcast\",\n  \"ToastPodcastCreateSuccess\": \"S'ha creat el pòdcast correctament\",\n  \"ToastPodcastGetFeedFailed\": \"No s'ha pogut obtenir el canal del pòdcast\",\n  \"ToastPodcastNoEpisodesInFeed\": \"No s'ha trobat cap episodi al canal RSS\",\n  \"ToastPodcastNoRssFeed\": \"El pòdcast no té un canal RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"El progrés no s'està sincronitzant, reinicia la reproducció\",\n  \"ToastProviderCreatedFailed\": \"Error en afegir el proveïdor\",\n  \"ToastProviderCreatedSuccess\": \"Nou proveïdor afegit\",\n  \"ToastProviderNameAndUrlRequired\": \"Nom i URL obligatoris\",\n  \"ToastProviderRemoveSuccess\": \"Proveïdor eliminat\",\n  \"ToastRSSFeedCloseFailed\": \"No s'ha pogut tancar el canal RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"Canal RSS tancat\",\n  \"ToastRemoveFailed\": \"Error en eliminar\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Error en eliminar l'element de la col·lecció\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Element eliminat de la col·lecció\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Error en eliminar elements incorrectes de la biblioteca\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"S'han eliminat els elements incorrectes de la biblioteca\",\n  \"ToastRenameFailed\": \"Error en canviar el nom\",\n  \"ToastRescanFailed\": \"Error en reescanejar per a {0}\",\n  \"ToastRescanRemoved\": \"Element reescanejat eliminat\",\n  \"ToastRescanUpToDate\": \"Reescaneig completat, l'element ja estava actualitzat\",\n  \"ToastRescanUpdated\": \"Reescaneig completat, l'element ha estat actualitzat\",\n  \"ToastScanFailed\": \"No s'ha pogut escanejar l'element de la biblioteca\",\n  \"ToastSelectAtLeastOneUser\": \"Selecciona almenys un usuari\",\n  \"ToastSendEbookToDeviceFailed\": \"Error en enviar l'ebook al dispositiu\",\n  \"ToastSendEbookToDeviceSuccess\": \"El llibre electrònic s'ha enviat al dispositiu «{0}»\",\n  \"ToastSeriesSubmitFailedSameName\": \"No és possible afegir dues sèries amb el mateix nom\",\n  \"ToastSeriesUpdateFailed\": \"Error en actualitzar la sèrie\",\n  \"ToastSeriesUpdateSuccess\": \"Sèrie actualitzada\",\n  \"ToastServerSettingsUpdateSuccess\": \"Configuració del servidor actualitzada\",\n  \"ToastSessionCloseFailed\": \"Error en tancar la sessió\",\n  \"ToastSessionDeleteFailed\": \"Error en eliminar la sessió\",\n  \"ToastSessionDeleteSuccess\": \"Sessió eliminada\",\n  \"ToastSleepTimerDone\": \"Temporitzador d'apagada activat... zZzzZz\",\n  \"ToastSlugMustChange\": \"L'slug conté caràcters no vàlids\",\n  \"ToastSlugRequired\": \"Slug obligatori\",\n  \"ToastSocketConnected\": \"Socket connectat\",\n  \"ToastSocketDisconnected\": \"Socket desconnectat\",\n  \"ToastSocketFailedToConnect\": \"Error en connectar al Socket\",\n  \"ToastSortingPrefixesEmptyError\": \"Cal tenir almenys 1 prefix per ordenar\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Prefixos d'ordenació actualitzats ({0} elements)\",\n  \"ToastTitleRequired\": \"Títol obligatori\",\n  \"ToastUnknownError\": \"Error desconegut\",\n  \"ToastUnlinkOpenIdFailed\": \"Error en desvincular l'usuari d'OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Usuari desvinculat d'OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"El camí del fitxer «{0}» ja existeix al servidor\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"L'element «{0}» usa un subdirectori del camí de pujada.\",\n  \"ToastUserDeleteFailed\": \"Error en eliminar l'usuari\",\n  \"ToastUserDeleteSuccess\": \"Usuari eliminat\",\n  \"ToastUserPasswordChangeSuccess\": \"Contrasenya canviada correctament\",\n  \"ToastUserPasswordMismatch\": \"Les contrasenyes no coincideixen\",\n  \"ToastUserPasswordMustChange\": \"La nova contrasenya no pot ser igual a l'anterior\",\n  \"ToastUserRootRequireName\": \"Cal introduir un nom d'usuari root\"\n}\n"
  },
  {
    "path": "client/strings/cs.json",
    "content": "{\n  \"ButtonAdd\": \"Přidat\",\n  \"ButtonAddApiKey\": \"Přidat API klíč\",\n  \"ButtonAddChapters\": \"Přidat kapitoly\",\n  \"ButtonAddDevice\": \"Přidat zařízení\",\n  \"ButtonAddLibrary\": \"Přidat knihovnu\",\n  \"ButtonAddPodcasts\": \"Přidat podcasty\",\n  \"ButtonAddUser\": \"Přidat uživatele\",\n  \"ButtonAddYourFirstLibrary\": \"Vytvořte svou první knihovnu\",\n  \"ButtonApply\": \"Aplikovat\",\n  \"ButtonApplyChapters\": \"Aplikovat kapitoly\",\n  \"ButtonAuthors\": \"Autoři\",\n  \"ButtonBack\": \"Zpět\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Předvyplnit z existujících\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Předvyplnit podrobnosti mapování\",\n  \"ButtonBrowseForFolder\": \"Vyhledat složku\",\n  \"ButtonCancel\": \"Zrušit\",\n  \"ButtonCancelEncode\": \"Zrušit kódování\",\n  \"ButtonChangeRootPassword\": \"Změnit 'Root' heslo\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Zkontrolovat & stáhnout nové epizody\",\n  \"ButtonChooseAFolder\": \"Vybrat složku\",\n  \"ButtonChooseFiles\": \"Vybrat soubory\",\n  \"ButtonClearFilter\": \"Vymazat filtr\",\n  \"ButtonClose\": \"Zavřít\",\n  \"ButtonCloseFeed\": \"Zavřít kanál\",\n  \"ButtonCloseSession\": \"Zavřít otevřenou relaci\",\n  \"ButtonCollections\": \"Kolekce\",\n  \"ButtonConfigureScanner\": \"Konfigurovat Prohledávání\",\n  \"ButtonCreate\": \"Vytvořit\",\n  \"ButtonCreateBackup\": \"Vytvořit zálohu\",\n  \"ButtonDelete\": \"Smazat\",\n  \"ButtonDownloadQueue\": \"Fronta\",\n  \"ButtonEdit\": \"Upravit\",\n  \"ButtonEditChapters\": \"Upravit kapitoly\",\n  \"ButtonEditPodcast\": \"Upravit podcast\",\n  \"ButtonEnable\": \"Povolit\",\n  \"ButtonFireAndFail\": \"Spustit a selhat\",\n  \"ButtonFireOnTest\": \"Spustit událost onTest\",\n  \"ButtonForceReScan\": \"Vynutit opětovné prohledání\",\n  \"ButtonFullPath\": \"Úplná cesta\",\n  \"ButtonHide\": \"Skrýt\",\n  \"ButtonHome\": \"Domů\",\n  \"ButtonIssues\": \"Problémy\",\n  \"ButtonJumpBackward\": \"Skok Zpět\",\n  \"ButtonJumpForward\": \"Skok Dopředu\",\n  \"ButtonLatest\": \"Nejnovější\",\n  \"ButtonLibrary\": \"Knihovna\",\n  \"ButtonLogout\": \"Odhlásit\",\n  \"ButtonLookup\": \"Vyhledat\",\n  \"ButtonManageTracks\": \"Správa stop\",\n  \"ButtonMapChapterTitles\": \"Mapovat názvy kapitol\",\n  \"ButtonMatchAllAuthors\": \"Spárovat všechny autory\",\n  \"ButtonMatchBooks\": \"Spárovat Knihy\",\n  \"ButtonNevermind\": \"Nevadí\",\n  \"ButtonNext\": \"Další\",\n  \"ButtonNextChapter\": \"Další Kapitola\",\n  \"ButtonNextItemInQueue\": \"Žádná další položka ve frontě\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Otevřít kanál\",\n  \"ButtonOpenManager\": \"Otevřít správce\",\n  \"ButtonPause\": \"Pozastavit\",\n  \"ButtonPlay\": \"Přehrát\",\n  \"ButtonPlayAll\": \"Přehrát vše\",\n  \"ButtonPlaying\": \"Přehrává\",\n  \"ButtonPlaylists\": \"Seznamy skladeb\",\n  \"ButtonPrevious\": \"Předchozí\",\n  \"ButtonPreviousChapter\": \"Předchozí Kapitola\",\n  \"ButtonProbeAudioFile\": \"Prozkoumat audio soubor\",\n  \"ButtonPurgeAllCache\": \"Vyčistit veškerou mezipaměť\",\n  \"ButtonPurgeItemsCache\": \"Vyčistit mezipaměť položek\",\n  \"ButtonQueueAddItem\": \"Přidat do fronty\",\n  \"ButtonQueueRemoveItem\": \"Odstranit z fronty\",\n  \"ButtonQuickEmbed\": \"Rychle Zapsat\",\n  \"ButtonQuickEmbedMetadata\": \"Rychle Vložit Metadata\",\n  \"ButtonQuickMatch\": \"Rychlé přiřazení\",\n  \"ButtonReScan\": \"Znovu prohledat\",\n  \"ButtonRead\": \"Číst\",\n  \"ButtonReadLess\": \"Číst méně\",\n  \"ButtonReadMore\": \"Číst více\",\n  \"ButtonRefresh\": \"Obnovit\",\n  \"ButtonRemove\": \"Odstranit\",\n  \"ButtonRemoveAll\": \"Odstranit vše\",\n  \"ButtonRemoveAllLibraryItems\": \"Odstranit všechny položky knihovny\",\n  \"ButtonRemoveFromContinueListening\": \"Odstranit z Pokračovat v poslechu\",\n  \"ButtonRemoveFromContinueReading\": \"Odstranit z Pokračovat ve čtení\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Odstranit sérii z Pokračovat v sérii\",\n  \"ButtonReset\": \"Resetovat\",\n  \"ButtonResetToDefault\": \"Obnovit výchozí\",\n  \"ButtonRestore\": \"Obnovit\",\n  \"ButtonSave\": \"Uložit\",\n  \"ButtonSaveAndClose\": \"Uložit a zavřít\",\n  \"ButtonSaveTracklist\": \"Uložit seznam skladeb\",\n  \"ButtonScan\": \"Prohledat\",\n  \"ButtonScanLibrary\": \"Prohledat Knihovnu\",\n  \"ButtonScrollLeft\": \"Posunout vlevo\",\n  \"ButtonScrollRight\": \"Posunout vpravo\",\n  \"ButtonSearch\": \"Hledat\",\n  \"ButtonSelectFolderPath\": \"Vybrat cestu ke složce\",\n  \"ButtonSeries\": \"Série\",\n  \"ButtonSetChaptersFromTracks\": \"Nastavit kapitoly ze stop\",\n  \"ButtonShare\": \"Sdílet\",\n  \"ButtonShiftTimes\": \"Časy posunu\",\n  \"ButtonShow\": \"Zobrazit\",\n  \"ButtonStartM4BEncode\": \"Spustit kódování M4B\",\n  \"ButtonStartMetadataEmbed\": \"Spustit vkládání metadat\",\n  \"ButtonStats\": \"Statistiky\",\n  \"ButtonSubmit\": \"Odeslat\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Odpojit OpenID\",\n  \"ButtonUpload\": \"Nahrát\",\n  \"ButtonUploadBackup\": \"Nahrát zálohu\",\n  \"ButtonUploadCover\": \"Nahrát obálku\",\n  \"ButtonUploadOPMLFile\": \"Nahrát soubor OPML\",\n  \"ButtonUserDelete\": \"Smazat uživatelský {0}\",\n  \"ButtonUserEdit\": \"Upravit uživatelské {0}\",\n  \"ButtonViewAll\": \"Zobrazit vše\",\n  \"ButtonYes\": \"Ano\",\n  \"ErrorUploadFetchMetadataAPI\": \"Chyba stahování metadat\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Nepodařilo se načíst metadata - zkuste aktualizovat název a/nebo autora\",\n  \"ErrorUploadLacksTitle\": \"Musí mít titul\",\n  \"HeaderAccount\": \"Účet\",\n  \"HeaderAddCustomMetadataProvider\": \"Přidat vlastního poskytovatele metadat\",\n  \"HeaderAdvanced\": \"Pokročilé\",\n  \"HeaderApiKeys\": \"API klíče\",\n  \"HeaderAppriseNotificationSettings\": \"Nastavení oznámení Apprise\",\n  \"HeaderAudioTracks\": \"Zvukové stopy\",\n  \"HeaderAudiobookTools\": \"Nástroje pro správu souborů audioknih\",\n  \"HeaderAuthentication\": \"Autentizace\",\n  \"HeaderBackups\": \"Zálohy\",\n  \"HeaderBulkChapterModal\": \"Přidat více kapitol\",\n  \"HeaderChangePassword\": \"Změnit heslo\",\n  \"HeaderChapters\": \"Kapitoly\",\n  \"HeaderChooseAFolder\": \"Zvolte složku\",\n  \"HeaderCollection\": \"Kolekce\",\n  \"HeaderCollectionItems\": \"Položky kolekce\",\n  \"HeaderCover\": \"Obálka\",\n  \"HeaderCurrentDownloads\": \"Aktuální stahování\",\n  \"HeaderCustomMessageOnLogin\": \"Vlastní zpráva při přihlášení\",\n  \"HeaderCustomMetadataProviders\": \"Vlastní zprostředkovatelé metadat\",\n  \"HeaderDetails\": \"Podrobnosti\",\n  \"HeaderDownloadQueue\": \"Fronta stahování\",\n  \"HeaderEbookFiles\": \"Soubory elektronických knih\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"Nastavení e-mailu\",\n  \"HeaderEpisodes\": \"Epizody\",\n  \"HeaderEreaderDevices\": \"Čtečky elektronických knih\",\n  \"HeaderEreaderSettings\": \"Nastavení čtečky elektronických knih\",\n  \"HeaderFiles\": \"Soubory\",\n  \"HeaderFindChapters\": \"Najít kapitoly\",\n  \"HeaderIgnoredFiles\": \"Ignorované soubory\",\n  \"HeaderItemFiles\": \"Soubory položek\",\n  \"HeaderItemMetadataUtils\": \"Nástroje metadat položek\",\n  \"HeaderLastListeningSession\": \"Poslední poslechová relace\",\n  \"HeaderLatestEpisodes\": \"Nové epizody\",\n  \"HeaderLibraries\": \"Knihovny\",\n  \"HeaderLibraryFiles\": \"Soubory knihovny\",\n  \"HeaderLibraryStats\": \"Statistiky knihovny\",\n  \"HeaderListeningSessions\": \"Poslechové relace\",\n  \"HeaderListeningStats\": \"Statistiky poslechu\",\n  \"HeaderLogin\": \"Přihlásit\",\n  \"HeaderLogs\": \"Logy\",\n  \"HeaderManageGenres\": \"Spravovat žánry\",\n  \"HeaderManageTags\": \"Spravovat štítky\",\n  \"HeaderMapDetails\": \"Podrobnosti mapování\",\n  \"HeaderMatch\": \"Shoda\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Pořadí priorit metadat\",\n  \"HeaderMetadataToEmbed\": \"Metadata k vložení\",\n  \"HeaderNewAccount\": \"Nový účet\",\n  \"HeaderNewApiKey\": \"Nový API klíč\",\n  \"HeaderNewLibrary\": \"Nová knihovna\",\n  \"HeaderNotificationCreate\": \"Vytvořit notifikaci\",\n  \"HeaderNotificationUpdate\": \"Aktualizovat notifikaci\",\n  \"HeaderNotifications\": \"Oznámení\",\n  \"HeaderOpenIDConnectAuthentication\": \"Ověřování pomocí OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Otevřené relace přehrávače\",\n  \"HeaderOpenRSSFeed\": \"Otevřít RSS kanál\",\n  \"HeaderOtherFiles\": \"Ostatní soubory\",\n  \"HeaderPasswordAuthentication\": \"Autentizace heslem\",\n  \"HeaderPermissions\": \"Oprávnění\",\n  \"HeaderPlayerQueue\": \"Fronta přehrávače\",\n  \"HeaderPlayerSettings\": \"Nastavení přehrávače\",\n  \"HeaderPlaylist\": \"Seznam skladeb\",\n  \"HeaderPlaylistItems\": \"Položky seznamu přehrávání\",\n  \"HeaderPodcastsToAdd\": \"Podcasty k přidání\",\n  \"HeaderPresets\": \"Předvolba\",\n  \"HeaderPreviewCover\": \"Náhled obálky\",\n  \"HeaderRSSFeedGeneral\": \"Podrobnosti o RSS\",\n  \"HeaderRSSFeedIsOpen\": \"Informační kanál RSS je otevřený\",\n  \"HeaderRSSFeeds\": \"RSS kanály\",\n  \"HeaderRemoveEpisode\": \"Odstranit epizodu\",\n  \"HeaderRemoveEpisodes\": \"Odstranit {0} epizody\",\n  \"HeaderSavedMediaProgress\": \"Průběh uložených médií\",\n  \"HeaderSchedule\": \"Plán\",\n  \"HeaderScheduleEpisodeDownloads\": \"Naplánovat automatické stahování epizod\",\n  \"HeaderScheduleLibraryScans\": \"Naplánovat automatické prohledávání knihoven\",\n  \"HeaderSession\": \"Relace\",\n  \"HeaderSetBackupSchedule\": \"Nastavit plán zálohování\",\n  \"HeaderSettings\": \"Nastavení\",\n  \"HeaderSettingsDisplay\": \"Zobrazit\",\n  \"HeaderSettingsExperimental\": \"Experimentální funkce\",\n  \"HeaderSettingsGeneral\": \"Obecné\",\n  \"HeaderSettingsScanner\": \"Skener\",\n  \"HeaderSettingsSecurity\": \"Zabezpečení\",\n  \"HeaderSettingsWebClient\": \"Webový klient\",\n  \"HeaderSleepTimer\": \"Časovač vypnutí\",\n  \"HeaderStatsLargestItems\": \"Největší položky\",\n  \"HeaderStatsLongestItems\": \"Nejdelší položky (hod.)\",\n  \"HeaderStatsMinutesListeningChart\": \"Poslech za posledních 7 dní (min)\",\n  \"HeaderStatsRecentSessions\": \"Poslední relace\",\n  \"HeaderStatsTop10Authors\": \"Top 10 autorů\",\n  \"HeaderStatsTop5Genres\": \"Top 5 žánrů\",\n  \"HeaderTableOfContents\": \"Obsah\",\n  \"HeaderTools\": \"Nástroje\",\n  \"HeaderUpdateAccount\": \"Aktualizovat účet\",\n  \"HeaderUpdateApiKey\": \"Aktualizovat API klíč\",\n  \"HeaderUpdateAuthor\": \"Aktualizovat autora\",\n  \"HeaderUpdateDetails\": \"Aktualizovat podrobnosti\",\n  \"HeaderUpdateLibrary\": \"Aktualizovat knihovnu\",\n  \"HeaderUsers\": \"Uživatelé\",\n  \"HeaderYearReview\": \"Přehled {0} roku\",\n  \"HeaderYourStats\": \"Vaše statistiky\",\n  \"LabelAbridged\": \"Zkráceno\",\n  \"LabelAbridgedChecked\": \"Zkráceno (zaškrtnuto)\",\n  \"LabelAbridgedUnchecked\": \"Nezkrácené (nezaškrtnuté)\",\n  \"LabelAccessibleBy\": \"Přístupný prostřednictvím\",\n  \"LabelAccountType\": \"Typ účtu\",\n  \"LabelAccountTypeAdmin\": \"Správce\",\n  \"LabelAccountTypeGuest\": \"Host\",\n  \"LabelAccountTypeUser\": \"Uživatel\",\n  \"LabelActivities\": \"Aktivity\",\n  \"LabelActivity\": \"Aktivita\",\n  \"LabelAddToCollection\": \"Přidat do kolekce\",\n  \"LabelAddToCollectionBatch\": \"Přidat {0} knihy do kolekce\",\n  \"LabelAddToPlaylist\": \"Přidat do seznamu přehrávání\",\n  \"LabelAddToPlaylistBatch\": \"Přidat {0} položky do seznamu přehrávání\",\n  \"LabelAddedAt\": \"Přidáno v\",\n  \"LabelAddedDate\": \"Přidáno {0}\",\n  \"LabelAdminUsersOnly\": \"Pouze administrátoři\",\n  \"LabelAll\": \"Vše\",\n  \"LabelAllEpisodesDownloaded\": \"Všechny epizody staženy\",\n  \"LabelAllUsers\": \"Všichni uživatelé\",\n  \"LabelAllUsersExcludingGuests\": \"Všichni uživatelé kromě hostů\",\n  \"LabelAllUsersIncludingGuests\": \"Všichni uživatelé včetně hostů\",\n  \"LabelAlreadyInYourLibrary\": \"Již ve vaší knihovně\",\n  \"LabelApiKeyCreated\": \"API klíč \\\"{0}\\\" byl úspěšně vytvořen.\",\n  \"LabelApiKeyCreatedDescription\": \"Zkopírujte si API klíč nyní, později již nebude možné jej zobrazit.\",\n  \"LabelApiKeyUser\": \"Vydávat se za uživatele\",\n  \"LabelApiKeyUserDescription\": \"Tento API klíč bude mít stejná oprávnění jako uživatel za něhož vystupuje. V protokolech to bude vypadat jako kdyby požadavky vytvářel přímo daný uživatel.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Připojit\",\n  \"LabelAudioBitrate\": \"Bitový tok zvuku (např. 128k)\",\n  \"LabelAudioChannels\": \"Zvukové kanály (1 nebo 2)\",\n  \"LabelAudioCodec\": \"Audio Kodek\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (jméno a příjmení)\",\n  \"LabelAuthorLastFirst\": \"Autor (příjmení a jméno)\",\n  \"LabelAuthors\": \"Autoři\",\n  \"LabelAutoDownloadEpisodes\": \"Automaticky stahovat epizody\",\n  \"LabelAutoFetchMetadata\": \"Automatické stahování metadat\",\n  \"LabelAutoFetchMetadataHelp\": \"Stáhne metadata pro název, autora a sérii, aby se usnadnilo nahrávání. Další metadata může být nutné spárovat po nahrání.\",\n  \"LabelAutoLaunch\": \"Automatické spuštění\",\n  \"LabelAutoLaunchDescription\": \"Automaticky přesměrovat na zprostředkovatele ověření při vstupu na přihlašovací stránku (ruční přepsání cesty <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatická registrace\",\n  \"LabelAutoRegisterDescription\": \"Automaticky vytvářet nové uživatele po přihlášení\",\n  \"LabelBackToUser\": \"Zpět k uživateli\",\n  \"LabelBackupAudioFiles\": \"Zálohovat zvukové soubory\",\n  \"LabelBackupLocation\": \"Umístění zálohy\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatické zálohování\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Zálohy uložené v /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maximální velikost zálohy (v GB) (0 bez omezení)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Ochrana proti chybné konfiguraci: Zálohování se nezdaří, pokud překročí nastavenou velikost.\",\n  \"LabelBackupsNumberToKeep\": \"Počet záloh, které se mají uchovat\",\n  \"LabelBackupsNumberToKeepHelp\": \"Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.\",\n  \"LabelBitrate\": \"Datový tok\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Knihy\",\n  \"LabelButtonText\": \"Text tlačítka\",\n  \"LabelByAuthor\": \"od {0}\",\n  \"LabelChangePassword\": \"Změnit heslo\",\n  \"LabelChannels\": \"Kanály\",\n  \"LabelChapterCount\": \"{0} Kapitol\",\n  \"LabelChapterTitle\": \"Název kapitoly\",\n  \"LabelChapters\": \"Kapitoly\",\n  \"LabelChaptersFound\": \"Kapitoly nalezeny\",\n  \"LabelClickForMoreInfo\": \"Klikněte pro více informací\",\n  \"LabelClickToUseCurrentValue\": \"Klikni pro použití aktuální hodnoty\",\n  \"LabelClosePlayer\": \"Zavřít přehrávač\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Sbalit sérii\",\n  \"LabelCollapseSubSeries\": \"Sbalit podsérie\",\n  \"LabelCollection\": \"Kolekce\",\n  \"LabelCollections\": \"Kolekce\",\n  \"LabelComplete\": \"Dokončeno\",\n  \"LabelConfirmPassword\": \"Potvrdit heslo\",\n  \"LabelContinueListening\": \"Pokračovat v poslechu\",\n  \"LabelContinueReading\": \"Pokračovat ve čtení\",\n  \"LabelContinueSeries\": \"Pokračovat v sérii\",\n  \"LabelCorsAllowed\": \"Povolené CORS Origins\",\n  \"LabelCover\": \"Obálka\",\n  \"LabelCoverImageURL\": \"URL obrázku obálky\",\n  \"LabelCoverProvider\": \"Poskytovatel obálky\",\n  \"LabelCreatedAt\": \"Vytvořeno v\",\n  \"LabelCronExpression\": \"Výraz Cronu\",\n  \"LabelCurrent\": \"Aktuální\",\n  \"LabelCurrently\": \"Aktuálně:\",\n  \"LabelCustomCronExpression\": \"Vlastní výraz cronu:\",\n  \"LabelDatetime\": \"Datum a čas\",\n  \"LabelDays\": \"Dny\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Smazat ze souborového systému (zrušte zaškrtnutí pro odstranění pouze z databáze)\",\n  \"LabelDescription\": \"Popis\",\n  \"LabelDeselectAll\": \"Odznačit vše\",\n  \"LabelDetectedPattern\": \"Detekovaný vzor:\",\n  \"LabelDevice\": \"Zařízení\",\n  \"LabelDeviceInfo\": \"Informace o zařízení\",\n  \"LabelDeviceIsAvailableTo\": \"Zařízení je dostupné pro...\",\n  \"LabelDirectory\": \"Adresář\",\n  \"LabelDiscFromFilename\": \"Disk z názvu souboru\",\n  \"LabelDiscFromMetadata\": \"Disk z metadat\",\n  \"LabelDiscover\": \"Objevit\",\n  \"LabelDownload\": \"Stáhnout\",\n  \"LabelDownloadNEpisodes\": \"Stáhnout {0} epizody\",\n  \"LabelDownloadable\": \"Ke stažení\",\n  \"LabelDuration\": \"Délka trvání\",\n  \"LabelDurationComparisonExactMatch\": \"(přesná shoda)\",\n  \"LabelDurationComparisonLonger\": \"({0} delší)\",\n  \"LabelDurationComparisonShorter\": \"({0} kratší)\",\n  \"LabelDurationFound\": \"Doba trvání nalezena:\",\n  \"LabelEbook\": \"Elektronická kniha\",\n  \"LabelEbooks\": \"Elektronické knihy\",\n  \"LabelEdit\": \"Upravit\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Z adresy\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Odmítnout neautorizované certifikáty\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Vypnutím ověřování certifikátů SSL můžete své připojení vystavit bezpečnostním rizikům, například útokům typu man-in-the-middle. Tuto možnost zakažte pouze v případě, že rozumíte důsledkům a důvěřujete poštovnímu serveru, ke kterému se připojujete.\",\n  \"LabelEmailSettingsSecure\": \"Zabezpečené\",\n  \"LabelEmailSettingsSecureHelp\": \"Pokud je true, připojení bude při připojování k serveru používat TLS. Pokud je false, použije se protokol TLS, pokud server podporuje rozšíření STARTTLS. Ve většině případů nastavte tuto hodnotu na true, pokud se připojujete k portu 465. Pro port 587 nebo 25 ponechte hodnotu false. (z nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Testovací adresa\",\n  \"LabelEmbeddedCover\": \"Vložená obálka\",\n  \"LabelEnable\": \"Povolit\",\n  \"LabelEncodingBackupLocation\": \"Záloha původních audio souborů bude uložena v:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Kapitoly nejsou vloženy ve vícestopých audioknihách.\",\n  \"LabelEncodingClearItemCache\": \"Nezapomeňte pravidelně promazávat mezipaměť položek.\",\n  \"LabelEncodingFinishedM4B\": \"Výsledné M4B bude uloženo do složky s audioknihou v:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadata budou vložena do audio stop ve složce s audioknihou.\",\n  \"LabelEncodingStartedNavigation\": \"Po spuštění úlohy můžete opustit tuto stránku.\",\n  \"LabelEncodingTimeWarning\": \"Encoding může zabrat až 30 minut.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Varování: Neměňte toto nastavení pokud neznáte možnosti encodingu ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Pokud máte zakázaný watcher, budete po skončení muset znovu naskenovat tuto audioknihu.\",\n  \"LabelEnd\": \"Konec\",\n  \"LabelEndOfChapter\": \"Konec kapitoly\",\n  \"LabelEpisode\": \"Epizoda\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Epizoda není propojená s RSS feed\",\n  \"LabelEpisodeNumber\": \"Epizoda #{0}\",\n  \"LabelEpisodeTitle\": \"Název epizody\",\n  \"LabelEpisodeType\": \"Typ epizody\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL epizody z RSS feed\",\n  \"LabelEpisodes\": \"Epizody\",\n  \"LabelEpisodic\": \"Epizodické\",\n  \"LabelExample\": \"Příklad\",\n  \"LabelExpandSeries\": \"Rozbalit série\",\n  \"LabelExpandSubSeries\": \"Rozbalit podsérie\",\n  \"LabelExpired\": \"Expirovaný\",\n  \"LabelExpiresAt\": \"Expiruje v\",\n  \"LabelExpiresInSeconds\": \"Expiruje za (sekundy)\",\n  \"LabelExpiresNever\": \"Nikdy\",\n  \"LabelExplicit\": \"Explicitní\",\n  \"LabelExplicitChecked\": \"Explicitní (zaškrtnuto)\",\n  \"LabelExplicitUnchecked\": \"Není explicitní (nezaškrtnuto)\",\n  \"LabelExportOPML\": \"Export OPML\",\n  \"LabelFeedURL\": \"URL kanálu\",\n  \"LabelFetchingMetadata\": \"Získávání metadat\",\n  \"LabelFile\": \"Soubor\",\n  \"LabelFileBirthtime\": \"Čas vzniku souboru\",\n  \"LabelFileBornDate\": \"Vytvořeno {0}\",\n  \"LabelFileModified\": \"Soubor změněn\",\n  \"LabelFileModifiedDate\": \"Změněno {0}\",\n  \"LabelFilename\": \"Název souboru\",\n  \"LabelFilterByUser\": \"Filtrovat podle uživatele\",\n  \"LabelFindEpisodes\": \"Najít epizody\",\n  \"LabelFinished\": \"Dokončeno\",\n  \"LabelFinishedDate\": \"Dokončeno {0}\",\n  \"LabelFolder\": \"Složka\",\n  \"LabelFolders\": \"Složky\",\n  \"LabelFontBold\": \"Tučně\",\n  \"LabelFontBoldness\": \"Výraznost písma\",\n  \"LabelFontFamily\": \"Rodina písem\",\n  \"LabelFontItalic\": \"Kurzíva\",\n  \"LabelFontScale\": \"Velikost písma\",\n  \"LabelFontStrikethrough\": \"Přeškrtnutí\",\n  \"LabelFormat\": \"Formát\",\n  \"LabelFull\": \"Plné\",\n  \"LabelGenre\": \"Žánr\",\n  \"LabelGenres\": \"Žánry\",\n  \"LabelHardDeleteFile\": \"Trvale smazat soubor\",\n  \"LabelHasEbook\": \"Má e-knihu\",\n  \"LabelHasSupplementaryEbook\": \"Obsahuje doplňkovou e-knihu\",\n  \"LabelHideSubtitles\": \"Skrýt titulky\",\n  \"LabelHighestPriority\": \"Nejvyšší priorita\",\n  \"LabelHost\": \"Hostitel\",\n  \"LabelHour\": \"Hodina\",\n  \"LabelHours\": \"Hodiny\",\n  \"LabelIcon\": \"Ikona\",\n  \"LabelImageURLFromTheWeb\": \"URL obrázku z webu\",\n  \"LabelInProgress\": \"Probíhá\",\n  \"LabelIncludeInTracklist\": \"Zahrnout do seznamu stop\",\n  \"LabelIncomplete\": \"Neúplné\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Vlastní denně/týdně\",\n  \"LabelIntervalEvery12Hours\": \"Každých 12 hodin\",\n  \"LabelIntervalEvery15Minutes\": \"Každých 15 minut\",\n  \"LabelIntervalEvery2Hours\": \"Každé 2 hodiny\",\n  \"LabelIntervalEvery30Minutes\": \"Každých 30 minut\",\n  \"LabelIntervalEvery6Hours\": \"Každých 6 hodin\",\n  \"LabelIntervalEveryDay\": \"Každý den\",\n  \"LabelIntervalEveryHour\": \"Každou hodinu\",\n  \"LabelIntervalEveryMinute\": \"Každou minutu\",\n  \"LabelInvert\": \"Invertovat\",\n  \"LabelItem\": \"Položka\",\n  \"LabelJumpBackwardAmount\": \"Přeskočit zpět o\",\n  \"LabelJumpForwardAmount\": \"Přeskočit dopředu o\",\n  \"LabelLanguage\": \"Jazyk\",\n  \"LabelLanguageDefaultServer\": \"Výchozí jazyk serveru\",\n  \"LabelLanguages\": \"Jazyky\",\n  \"LabelLastBookAdded\": \"Poslední kniha přidána\",\n  \"LabelLastBookUpdated\": \"Poslední kniha aktualizována\",\n  \"LabelLastProgressDate\": \"Poslední pokrok: {0}\",\n  \"LabelLastSeen\": \"Naposledy viděno\",\n  \"LabelLastTime\": \"Naposledy\",\n  \"LabelLastUpdate\": \"Poslední aktualizace\",\n  \"LabelLayout\": \"Rozvržení\",\n  \"LabelLayoutSinglePage\": \"Jedna stránka\",\n  \"LabelLayoutSplitPage\": \"Rozdělit stránku\",\n  \"LabelLess\": \"Méně\",\n  \"LabelLibrariesAccessibleToUser\": \"Knihovny přístupné uživateli\",\n  \"LabelLibrary\": \"Knihovna\",\n  \"LabelLibraryFilterSublistEmpty\": \"Žádné {0}\",\n  \"LabelLibraryItem\": \"Položka knihovny\",\n  \"LabelLibraryName\": \"Název knihovny\",\n  \"LabelLibrarySortByProgress\": \"Pokrok: naposledy aktualizováno\",\n  \"LabelLibrarySortByProgressFinished\": \"Pokrok: dokončeno\",\n  \"LabelLibrarySortByProgressStarted\": \"Pokrok: začato\",\n  \"LabelLimit\": \"Omezit\",\n  \"LabelLineSpacing\": \"Řádkování\",\n  \"LabelListenAgain\": \"Poslouchat znovu\",\n  \"LabelLogLevelDebug\": \"Ladit\",\n  \"LabelLogLevelInfo\": \"Informace\",\n  \"LabelLogLevelWarn\": \"Varovat\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Hledat nové epizody po tomto datu\",\n  \"LabelLowestPriority\": \"Nejnižší priorita\",\n  \"LabelMatchConfidence\": \"Jistota\",\n  \"LabelMatchExistingUsersBy\": \"Přiřadit stávající uživatele podle\",\n  \"LabelMatchExistingUsersByDescription\": \"Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Maximální # epizod pro stažení. Použijte 0 pro bez omezení.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Maximální # nových epizod ke stažení při jedné kontrole\",\n  \"LabelMaxEpisodesToKeep\": \"Maximální počet epizod k zachování\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.\",\n  \"LabelMediaPlayer\": \"Přehrávač médií\",\n  \"LabelMediaType\": \"Typ média\",\n  \"LabelMetaTag\": \"Metaznačka\",\n  \"LabelMetaTags\": \"Metaznačky\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Zdroje metadat s vyšší prioritou budou mít přednost před zdroji metadat s nižší prioritou\",\n  \"LabelMetadataProvider\": \"Poskytovatel metadat\",\n  \"LabelMinute\": \"Minuta\",\n  \"LabelMinutes\": \"Minuty\",\n  \"LabelMissing\": \"Chybějící\",\n  \"LabelMissingEbook\": \"Nemá elektronickou knihu\",\n  \"LabelMissingSupplementaryEbook\": \"Nemá žádnou doplňkovou elektronickou knihu\",\n  \"LabelMobileRedirectURIs\": \"Povolené mobilní přesměrování URI\",\n  \"LabelMobileRedirectURIsDescription\": \"Toto je seznam povolených přesměrování URI pro mobilní aplikace. Výchozí je <code>audiobookshelf://oauth</code>, který můžete odstranit nebo doplnit o další URI pro integraci aplikací třetích stran. Použití hvězdičky (<code>*</code>) jako jediné položky povoluje libovolný URI.\",\n  \"LabelMore\": \"Více\",\n  \"LabelMoreInfo\": \"Více informací\",\n  \"LabelName\": \"Jméno\",\n  \"LabelNarrator\": \"Interpret\",\n  \"LabelNarrators\": \"Interpreti\",\n  \"LabelNew\": \"Nový\",\n  \"LabelNewPassword\": \"Nové heslo\",\n  \"LabelNewestAuthors\": \"Nejnovější autoři\",\n  \"LabelNewestEpisodes\": \"Nejnovější epizody\",\n  \"LabelNextBackupDate\": \"Datum příští zálohy\",\n  \"LabelNextChapters\": \"Další kapitola bude:\",\n  \"LabelNextScheduledRun\": \"Další naplánované spuštění\",\n  \"LabelNoApiKeys\": \"Žádné API klíče\",\n  \"LabelNoCustomMetadataProviders\": \"Žádní vlastní poskytovatelé metadat\",\n  \"LabelNoEpisodesSelected\": \"Nebyly vybrány žádné epizody\",\n  \"LabelNotFinished\": \"Nedokončeno\",\n  \"LabelNotStarted\": \"Nezahájeno\",\n  \"LabelNotes\": \"Poznámky\",\n  \"LabelNotificationAppriseURL\": \"URL adresy Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Dostupné proměnné\",\n  \"LabelNotificationBodyTemplate\": \"Šablona těla\",\n  \"LabelNotificationEvent\": \"Událost oznámení\",\n  \"LabelNotificationTitleTemplate\": \"Šablona názvu\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maximální počet neúspěšných pokusů\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Oznámení jsou vypnuta, pokud se jim to nepodaří odeslat\",\n  \"LabelNotificationsMaxQueueSize\": \"Maximální velikost fronty pro oznamovací události\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Události jsou omezeny na 1 za sekundu. Události budou ignorovány, pokud je fronta v maximální velikosti. Tím se zabrání spamování oznámení.\",\n  \"LabelNumberOfBooks\": \"Počet knih\",\n  \"LabelNumberOfChapters\": \"Počet kapitol:\",\n  \"LabelNumberOfEpisodes\": \"Počet epizod\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Název požadavku OpenID, který obsahuje rozšířená oprávnění pro akce uživatele v rámci aplikace, která se budou vztahovat na role, které nejsou administrátory (<b>pokud jsou nakonfigurovány</b>). Pokud požadavek v odpovědi chybí, přístup do systému ABS bude zamítnut. Pokud chybí jediná možnost, bude považována za <code>false</code>. Ujistěte se, že deklarace poskytovatele identity odpovídá očekávané struktuře:\",\n  \"LabelOpenIDClaims\": \"Následující možnosti ponechte prázdné, abyste zakázali pokročilé přiřazování skupin a oprávnění a automatické přiřazení skupiny \\\"User\\\".\",\n  \"LabelOpenIDGroupClaimDescription\": \"Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.\",\n  \"LabelOpenRSSFeed\": \"Otevřít RSS kanál\",\n  \"LabelOverwrite\": \"Přepsat\",\n  \"LabelPaginationPageXOfY\": \"Strana {0} z {1}\",\n  \"LabelPassword\": \"Heslo\",\n  \"LabelPath\": \"Cesta\",\n  \"LabelPermanent\": \"Trvalé\",\n  \"LabelPermissionsAccessAllLibraries\": \"Má přístup ke všem knihovnám\",\n  \"LabelPermissionsAccessAllTags\": \"Má přístup ke všem značkám\",\n  \"LabelPermissionsAccessExplicitContent\": \"Má přístup k explicitnímu obsahu\",\n  \"LabelPermissionsCreateEreader\": \"Může vytvořit Ereader\",\n  \"LabelPermissionsDelete\": \"Může mazat\",\n  \"LabelPermissionsDownload\": \"Může stahovat\",\n  \"LabelPermissionsUpdate\": \"Může aktualizovat\",\n  \"LabelPermissionsUpload\": \"Může nahrávat\",\n  \"LabelPersonalYearReview\": \"Váš přehled roku ({0})\",\n  \"LabelPhotoPathURL\": \"Cesta k fotografii/URL\",\n  \"LabelPlayMethod\": \"Metoda přehrávání\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Velikost kroku pro změnu rychlosti přehrávání\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} z {1}\",\n  \"LabelPlaylists\": \"Seznamy skladeb\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Oblast vyhledávání podcastu\",\n  \"LabelPodcastType\": \"Typ podcastu\",\n  \"LabelPodcasts\": \"Podcasty\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)\",\n  \"LabelPreventIndexing\": \"Zabránit indexování vašeho kanálu v adresářích podcastů iTunes a Google\",\n  \"LabelPrimaryEbook\": \"Hlavní e-kniha\",\n  \"LabelProgress\": \"Průběh\",\n  \"LabelProvider\": \"Poskytovatel\",\n  \"LabelProviderAuthorizationValue\": \"Hodnota autorizačního headeru\",\n  \"LabelPubDate\": \"Datum vydání\",\n  \"LabelPublishYear\": \"Rok vydání\",\n  \"LabelPublishedDate\": \"Vydáno {0}\",\n  \"LabelPublishedDecade\": \"Publikováno (dekáda)\",\n  \"LabelPublishedDecades\": \"Publikováno (dekády)\",\n  \"LabelPublisher\": \"Vydavatel\",\n  \"LabelPublishers\": \"Vydavatelé\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Vlastní e-mail vlastníka\",\n  \"LabelRSSFeedCustomOwnerName\": \"Vlastní jméno vlastníka\",\n  \"LabelRSSFeedOpen\": \"RSS kanál otevřen\",\n  \"LabelRSSFeedPreventIndexing\": \"Zabránit indexování\",\n  \"LabelRSSFeedSlug\": \"Klíčové slovo kanálu RSS\",\n  \"LabelRSSFeedURL\": \"URL RSS kanálu\",\n  \"LabelRandomly\": \"Náhodně\",\n  \"LabelReAddSeriesToContinueListening\": \"Znovu přidat sérii k pokračování poslechu\",\n  \"LabelRead\": \"Číst\",\n  \"LabelReadAgain\": \"Číst znovu\",\n  \"LabelReadEbookWithoutProgress\": \"Číst e-knihu bez zachování průběhu\",\n  \"LabelRecentSeries\": \"Nedávné série\",\n  \"LabelRecentlyAdded\": \"Nedávno přidané\",\n  \"LabelRecommended\": \"Doporučeno\",\n  \"LabelRedo\": \"Přepracovat\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Datum vydání\",\n  \"LabelRemoveAllMetadataAbs\": \"Odebrat všechny soubory metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Smazat všechny soubory metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Odebrat úvod a závěr Audible z kapitol\",\n  \"LabelRemoveCover\": \"Odstranit obálku\",\n  \"LabelRemoveMetadataFile\": \"Odstranit soubory metadat ve složkách položek knihovny\",\n  \"LabelRemoveMetadataFileHelp\": \"Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.\",\n  \"LabelRowsPerPage\": \"Řádky na stránku\",\n  \"LabelSearchTerm\": \"Vyhledat termín\",\n  \"LabelSearchTitle\": \"Vyhledat název\",\n  \"LabelSearchTitleOrASIN\": \"Vyhledat název nebo ASIN\",\n  \"LabelSeason\": \"Sezóna\",\n  \"LabelSeasonNumber\": \"Sezóna č.{0}\",\n  \"LabelSelectAll\": \"Vybrat vše\",\n  \"LabelSelectAllEpisodes\": \"Vybrat všechny epizody\",\n  \"LabelSelectEpisodesShowing\": \"Vyberte {0} epizody, které se zobrazují\",\n  \"LabelSelectUser\": \"Vybrat uživatele\",\n  \"LabelSelectUsers\": \"Vybrat uživatele\",\n  \"LabelSendEbookToDevice\": \"Odeslat e-knihu do...\",\n  \"LabelSequence\": \"Sekvence\",\n  \"LabelSerial\": \"Sériové\",\n  \"LabelSeries\": \"Série\",\n  \"LabelSeriesName\": \"Název série\",\n  \"LabelSeriesProgress\": \"Průběh série\",\n  \"LabelServerLogLevel\": \"Úroveň Logování serveru\",\n  \"LabelServerYearReview\": \"Přehled roku na serveru ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Nastavit jako primární\",\n  \"LabelSetEbookAsSupplementary\": \"Nastavit jako doplňkové\",\n  \"LabelSettingsAllowIframe\": \"Povolit vložení do rámce iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Pouze audioknihy\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorfní design s dřevěnými policemi\",\n  \"LabelSettingsChromecastSupport\": \"Podpora Chromecastu\",\n  \"LabelSettingsDateFormat\": \"Formát data\",\n  \"LabelSettingsEnableWatcher\": \"Automaticky skenovat změny v knihovnách\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Automaticky sledovat změny v knihovně\",\n  \"LabelSettingsEnableWatcherHelp\": \"Povoluje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Povolení skriptovaného obsahu v epubu\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Povolení spouštění skriptů v souborech epub. Doporučujeme toto nastavení vypnout, pokud nedůvěřujete zdroji souborů epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Experimentální funkce\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funkce ve vývoji, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.\",\n  \"LabelSettingsFindCovers\": \"Najít obálky\",\n  \"LabelSettingsFindCoversHelp\": \"Pokud vaše audiokniha nemá vloženou obálku nebo obrázek obálky uvnitř složky, skener se pokusí obálku najít.<br>Poznámka: Tím se prodlouží doba prohledávání\",\n  \"LabelSettingsHideSingleBookSeries\": \"Skrýt sérii s jedinou knihou\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Domovská stránka používá zobrazení police s knihami\",\n  \"LabelSettingsLibraryBookshelfView\": \"Knihovna používá zobrazení police s knihami\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Procento dokončení je vyšší než\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Zbývající čas je kratší než (sekund)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Označit položku médií jako dokončenou, když\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Přeskočit předchozí knihy v pokračování série\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.\",\n  \"LabelSettingsParseSubtitles\": \"Analyzovat podtitul\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \\\" - \\\"<br>tj. \\\"Název knihy - Zde Podtitul\\\" má podtitul \\\"Zde podtitul\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferovat spárovaná metadata\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Přeskočit párování knih, které již mají ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Přeskočit párování knih, které již mají ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorovat předpony při třídění\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"tj. pro předponu \\\"the\\\" název knihy \\\"Název knihy\\\" by se třídil jako \\\"Název knihy, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Použít čtvercové obálky knih\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Preferovat použití čtvercových obálek před standardními obálkami 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Uložit obálky s položkou\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Ve výchozím nastavení jsou obálky uloženy v adresáři /metadata/items, povolením tohoto nastavení se obálky uloží do složky položek knihovny. Zůstane zachován pouze jeden soubor s názvem \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Uložit metadata s položkou\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny\",\n  \"LabelSettingsTimeFormat\": \"Formát času\",\n  \"LabelShare\": \"Sdílet\",\n  \"LabelShareDownloadableHelp\": \"Umožňuje uživatelům s odkazem na sdílení stáhnout soubor zip.\",\n  \"LabelShareOpen\": \"Otevřít sdílení\",\n  \"LabelShareURL\": \"Sdílet URL\",\n  \"LabelShowAll\": \"Zobrazit vše\",\n  \"LabelShowSeconds\": \"Zobrazit sekundy\",\n  \"LabelShowSubtitles\": \"Zobrazit titulky\",\n  \"LabelSize\": \"Velikost\",\n  \"LabelSleepTimer\": \"Časovač vypnutí\",\n  \"LabelSlug\": \"URL název\",\n  \"LabelSortAscending\": \"Vzestupně\",\n  \"LabelSortDescending\": \"Sestupně\",\n  \"LabelSortPubDate\": \"Seřadit podle datumu publikování\",\n  \"LabelStart\": \"Spustit\",\n  \"LabelStartTime\": \"Čas Spuštění\",\n  \"LabelStarted\": \"Spuštěno\",\n  \"LabelStartedAt\": \"Spuštěno v\",\n  \"LabelStartedDate\": \"Spuštěno {0}\",\n  \"LabelStatsAudioTracks\": \"Zvukové stopy\",\n  \"LabelStatsAuthors\": \"Autoři\",\n  \"LabelStatsBestDay\": \"Nejlepší den\",\n  \"LabelStatsDailyAverage\": \"Denní průměr\",\n  \"LabelStatsDays\": \"Dny\",\n  \"LabelStatsDaysListened\": \"Dny poslechu\",\n  \"LabelStatsHours\": \"Hodiny\",\n  \"LabelStatsInARow\": \"v řadě\",\n  \"LabelStatsItemsFinished\": \"Dokončené Položky\",\n  \"LabelStatsItemsInLibrary\": \"Položky v knihovně\",\n  \"LabelStatsMinutes\": \"minut\",\n  \"LabelStatsMinutesListening\": \"Minuty poslechu\",\n  \"LabelStatsOverallDays\": \"Celkový počet dní\",\n  \"LabelStatsOverallHours\": \"Celkový počet hodin\",\n  \"LabelStatsWeekListening\": \"Týdenní poslech\",\n  \"LabelSubtitle\": \"Podtitul\",\n  \"LabelSupportedFileTypes\": \"Podporované typy souborů\",\n  \"LabelTag\": \"Značka\",\n  \"LabelTags\": \"Značky\",\n  \"LabelTagsAccessibleToUser\": \"Značky přístupné uživateli\",\n  \"LabelTagsNotAccessibleToUser\": \"Značky nepřístupné uživateli\",\n  \"LabelTasks\": \"Spuštěné Úlohy\",\n  \"LabelTextEditorBulletedList\": \"Seznam s odrážkami\",\n  \"LabelTextEditorLink\": \"Odkaz\",\n  \"LabelTextEditorNumberedList\": \"Seznam s čísly\",\n  \"LabelTextEditorUnlink\": \"Zrušit odkaz\",\n  \"LabelTheme\": \"Téma\",\n  \"LabelThemeDark\": \"Tmavé\",\n  \"LabelThemeLight\": \"Světlé\",\n  \"LabelThemeSepia\": \"Hnědé\",\n  \"LabelTimeBase\": \"Časová základna\",\n  \"LabelTimeDurationXHours\": \"{0} hodin\",\n  \"LabelTimeDurationXMinutes\": \"{0} minut\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekund\",\n  \"LabelTimeInMinutes\": \"Čas v minutách\",\n  \"LabelTimeLeft\": \"{0} zbývá\",\n  \"LabelTimeListened\": \"Čas poslechu\",\n  \"LabelTimeListenedToday\": \"Čas poslechu dnes\",\n  \"LabelTimeRemaining\": \"{0} zbývá\",\n  \"LabelTimeToShift\": \"Čas posunu v sekundách\",\n  \"LabelTitle\": \"Název\",\n  \"LabelToolsEmbedMetadata\": \"Vložit metadata\",\n  \"LabelToolsEmbedMetadataDescription\": \"Vložit metadata do zvukových souborů včetně obálky a kapitol.\",\n  \"LabelToolsM4bEncoder\": \"Enkodér M4B\",\n  \"LabelToolsMakeM4b\": \"Vytvořit soubor audioknihy M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.\",\n  \"LabelToolsSplitM4b\": \"Rozdělit M4B na MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Vytvořit soubory MP3 z M4B rozděleného podle kapitol s vloženými metadaty, obrázku obálky a kapitol.\",\n  \"LabelTotalDuration\": \"Celková doba trvání\",\n  \"LabelTotalTimeListened\": \"Celkový čas poslechu\",\n  \"LabelTrackFromFilename\": \"Stopa z názvu souboru\",\n  \"LabelTrackFromMetadata\": \"Stopa z metadat\",\n  \"LabelTracks\": \"Stopy\",\n  \"LabelTracksMultiTrack\": \"Více stop\",\n  \"LabelTracksNone\": \"Žádné stopy\",\n  \"LabelTracksSingleTrack\": \"Jedna stopa\",\n  \"LabelTrailer\": \"Upoutávka\",\n  \"LabelType\": \"Typ\",\n  \"LabelUnabridged\": \"Nezkráceno\",\n  \"LabelUndo\": \"Zpět\",\n  \"LabelUnknown\": \"Neznámý\",\n  \"LabelUnknownPublishDate\": \"Neznámé datum vydání\",\n  \"LabelUpdateCover\": \"Aktualizovat obálku\",\n  \"LabelUpdateCoverHelp\": \"Povolit přepsání existujících obálek pro vybrané knihy, pokud je nalezena shoda\",\n  \"LabelUpdateDetails\": \"Aktualizovat podrobnosti\",\n  \"LabelUpdateDetailsHelp\": \"Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda\",\n  \"LabelUpdatedAt\": \"Aktualizováno v\",\n  \"LabelUploaderDragAndDrop\": \"Přetáhnout soubory nebo složky\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Přetáhnout a upustit soubory\",\n  \"LabelUploaderDropFiles\": \"Odstranit soubory\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automaticky načíst název, autora a sérii\",\n  \"LabelUseAdvancedOptions\": \"Použít pokročilé možnosti\",\n  \"LabelUseChapterTrack\": \"Použít stopu kapitoly\",\n  \"LabelUseFullTrack\": \"Použít celou stopu\",\n  \"LabelUseZeroForUnlimited\": \"Použijte 0 pro neomezené\",\n  \"LabelUser\": \"Uživatel\",\n  \"LabelUsername\": \"Uživatelské jméno\",\n  \"LabelValue\": \"Hodnota\",\n  \"LabelVersion\": \"Verze\",\n  \"LabelViewBookmarks\": \"Zobrazit záložky\",\n  \"LabelViewChapters\": \"Zobrazit kapitoly\",\n  \"LabelViewPlayerSettings\": \"Zobrazit nastavení přehrávače\",\n  \"LabelViewQueue\": \"Zobrazit frontu přehrávače\",\n  \"LabelVolume\": \"Hlasitost\",\n  \"LabelWebRedirectURLsDescription\": \"Autorizujte tyto adresy URL ve zprostředkovateli OAuth, abyste po přihlášení umožnili přesměrování zpět do webové aplikace:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Podsložka pro přesměrování adres URL\",\n  \"LabelWeekdaysToRun\": \"Dny v týdnu ke spuštění\",\n  \"LabelXBooks\": \"{0} knih\",\n  \"LabelXItems\": \"{0} položky\",\n  \"LabelYearReviewHide\": \"Skrýt rok v přehledu\",\n  \"LabelYearReviewShow\": \"Zobrazit rok v přehledu\",\n  \"LabelYourAudiobookDuration\": \"Doba trvání vaší audioknihy\",\n  \"LabelYourBookmarks\": \"Vaše záložky\",\n  \"LabelYourPlaylists\": \"Vaše seznamy přehrávání\",\n  \"LabelYourProgress\": \"Váš pokrok\",\n  \"MessageAddToPlayerQueue\": \"Přidat do fronty přehrávače\",\n  \"MessageAppriseDescription\": \"Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Zastaralé API tokeny budou v budoucnu odstraněny. Použijte místo nich <a href=\\\"/config/api-keys\\\">API klíče</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Po uložení restartujte server, aby se změny OIDC použily.\",\n  \"MessageAuthenticationSecurityMessage\": \"Bezpečnost autentizace byla vylepšena. Všichni uživatelé se musí znovu přihlásit.\",\n  \"MessageBackupsDescription\": \"Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.\",\n  \"MessageBackupsLocationEditNote\": \"Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy\",\n  \"MessageBackupsLocationNoEditNote\": \"Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.\",\n  \"MessageBackupsLocationPathEmpty\": \"Umístění záloh nemůže být prázdné\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Předvyplnit vybraná pole datami ze všech položek. Pole s více hodnotami budou sloučena\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Předvyplnit povolená pole mapování daty z této položky\",\n  \"MessageBatchQuickMatchDescription\": \"Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.\",\n  \"MessageBookshelfNoCollections\": \"Ještě jste nevytvořili žádnou sbírku\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Kolekce jsou veřejné. Mohou je zobrazit všichni uživatelé s přístupem do knihovny.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Nejsou otevřeny žádné RSS kanály\",\n  \"MessageBookshelfNoResultsForFilter\": \"Filtr \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Žádné výsledky pro dotaz\",\n  \"MessageBookshelfNoSeries\": \"Nemáte žádnou sérii\",\n  \"MessageBulkChapterPattern\": \"Kolik kapitol chcete přidat s tímto vzorem číslování?\",\n  \"MessageChapterEndIsAfter\": \"Konec kapitoly přesahuje konec audioknihy\",\n  \"MessageChapterErrorFirstNotZero\": \"První kapitola musí začínat na 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Neplatný čas začátku, musí být kratší než doba trvání audioknihy\",\n  \"MessageChapterErrorStartLtPrev\": \"Neplatný čas začátku, musí být větší nebo roven času začátku předchozí kapitoly\",\n  \"MessageChapterStartIsAfter\": \"Začátek kapitoly přesahuje konec audioknihy\",\n  \"MessageChaptersNotFound\": \"Kapitoly nenalezeny\",\n  \"MessageCheckingCron\": \"Kontrola cronu...\",\n  \"MessageConfirmCloseFeed\": \"Opravdu chcete zavřít tento kanál?\",\n  \"MessageConfirmDeleteApiKey\": \"Opravdu chcete vymazat API klíč \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Opravdu chcete smazat zálohu pro {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Opravdu chcete vymazat zařízení e-reader \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Tento krok smaže soubor ze souborového systému. Jsi si jisti?\",\n  \"MessageConfirmDeleteLibrary\": \"Opravdu chcete trvale smazat knihovnu \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Tento krok odstraní položku knihovny z databáze a vašeho souborového systému. Jste si jisti?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Tímto smažete {0} položkek knihovny z databáze a vašeho souborového systému. Jsi si jisti?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Opravdu chcete vymazat vlastního poskytovatele metadat \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Opravdu chcete vymazat tuto notifikaci?\",\n  \"MessageConfirmDeleteSession\": \"Opravdu chcete smazat tuto relaci?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Jste si jisti, že chcete vložit metadata do {0} zvukových souborů?\",\n  \"MessageConfirmForceReScan\": \"Opravdu chcete vynutit opětovné prohledání?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Opravdu chcete označit všechny epizody jako dokončené?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Opravdu chcete označit všechny epizody jako nedokončené?\",\n  \"MessageConfirmMarkItemFinished\": \"Opravdu chcete označit \\\"{0}\\\" jako dokončené?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Opravdu chcete označit \\\"{0}\\\" jako nedokončené?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Opravdu chcete označit všechny knihy z této série jako dokončené?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Opravdu chcete označit všechny knihy z této série jako nedokončené?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Vyvolat tuto notifikaci s testovacími daty?\",\n  \"MessageConfirmPurgeCache\": \"Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?\",\n  \"MessageConfirmPurgeItemsCache\": \"Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?\",\n  \"MessageConfirmQuickEmbed\": \"Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Pokud je nalezena shoda při rychlém párování epizod, dojde k přepsání podrobností. Aktualizovány budou pouze nespárované epizody. Jste si jisti?\",\n  \"MessageConfirmReScanLibraryItems\": \"Opravdu chcete znovu prohledat {0} položky?\",\n  \"MessageConfirmRemoveAllChapters\": \"Opravdu chcete odstranit všechny kapitoly?\",\n  \"MessageConfirmRemoveAuthor\": \"Opravdu chcete odstranit autora \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Opravdu chcete odstranit kolekci \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Opravdu chcete odstranit epizodu \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru“\",\n  \"MessageConfirmRemoveEpisodes\": \"Opravdu chcete odstranit {0} epizody?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Opravdu chcete odebrat {0} poslechových relací?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?\",\n  \"MessageConfirmRemoveNarrator\": \"Opravdu chcete odebrat předčítání \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Opravdu chcete odstranit svůj playlist \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Opravdu chcete přejmenovat žánr \\\"{0}\\\" na \\\"{1}\\\" pro všechny položky?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Poznámka: Tento žánr již existuje, takže budou sloučeny.\",\n  \"MessageConfirmRenameGenreWarning\": \"Varování! Podobný žánr s jiným obalem již existuje \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Opravdu chcete přejmenovat tag \\\"{0}\\\" na \\\"{1}\\\" pro všechny položky?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Poznámka: Tato značka již existuje, takže budou sloučeny.\",\n  \"MessageConfirmRenameTagWarning\": \"Varování! Podobná značka s jinými velkými a malými písmeny již existuje \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Opravdu chcete zahodit svůj pokrok?\",\n  \"MessageConfirmSendEbookToDevice\": \"Opravdu chcete odeslat e-knihu {0} {1}\\\" do zařízení \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Opravdu chcete odpojit tohoto uživatele z OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} poslechových dní v minulém roce\",\n  \"MessageDownloadingEpisode\": \"Stahuji epizodu\",\n  \"MessageDragFilesIntoTrackOrder\": \"Přetáhněte soubory do správného pořadí stop\",\n  \"MessageEmbedFailed\": \"Vložení selhalo!\",\n  \"MessageEmbedFinished\": \"Vložení dokončeno!\",\n  \"MessageEmbedQueue\": \"Zařazeno do fronty pro vložení metadat ({0} ve frontě)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Epizody zařazené do fronty ke stažení\",\n  \"MessageEreaderDevices\": \"Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.\",\n  \"MessageFeedURLWillBe\": \"URL zdroje bude {0}\",\n  \"MessageFetching\": \"Načítání...\",\n  \"MessageForceReScanDescription\": \"znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} poslechnuto</strong> na {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Žádné relace poslouchání na {0}\",\n  \"MessageImportantNotice\": \"Důležité upozornění!\",\n  \"MessageInsertChapterBelow\": \"Vložit kapitolu níže\",\n  \"MessageInvalidAsin\": \"Neplatný ASIN\",\n  \"MessageItemsSelected\": \"{0} vybraných položek\",\n  \"MessageItemsUpdated\": \"{0} položky byly aktualizovány\",\n  \"MessageJoinUsOn\": \"Přidejte se k nám\",\n  \"MessageLoading\": \"Načítá se...\",\n  \"MessageLoadingFolders\": \"Načítám složky...\",\n  \"MessageLogsDescription\": \"Logy se ukládají do souborů JSON v <code>/metadata/logs</code>. Logy o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B se nezdařil!\",\n  \"MessageM4BFinished\": \"M4B dokončen!\",\n  \"MessageMapChapterTitles\": \"Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek\",\n  \"MessageMarkAllEpisodesFinished\": \"Označit všechny epizody za dokončené\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Označit všechny epizody jako nedokončené\",\n  \"MessageMarkAsFinished\": \"Označit jako dokončené\",\n  \"MessageMarkAsNotFinished\": \"Označit jako nedokončené\",\n  \"MessageMatchBooksDescription\": \"pokusí se spárovat knihy v knihovně s knihou od vybraného vyhledávače a vyplnit prázdné údaje a obálku. Nepřepisuje detaily.\",\n  \"MessageNoAudioTracks\": \"Žádné zvukové stopy\",\n  \"MessageNoAuthors\": \"Žádní autoři\",\n  \"MessageNoBackups\": \"Žádné zálohy\",\n  \"MessageNoBookmarks\": \"Žádné záložky\",\n  \"MessageNoChapters\": \"Žádné kapitoly\",\n  \"MessageNoCollections\": \"Žádné kolekce\",\n  \"MessageNoCoversFound\": \"Nebyly nalezeny žádné obálky\",\n  \"MessageNoDescription\": \"Bez popisu\",\n  \"MessageNoDevices\": \"Žádná zařízení\",\n  \"MessageNoDownloadsInProgress\": \"Momentálně neprobíhá žádné stahování\",\n  \"MessageNoDownloadsQueued\": \"Žádné stahování ve frontě\",\n  \"MessageNoEpisodeMatchesFound\": \"Nebyly nalezeny žádné odpovídající epizody\",\n  \"MessageNoEpisodes\": \"Žádné epizody\",\n  \"MessageNoFoldersAvailable\": \"Nejsou k dispozici žádné složky\",\n  \"MessageNoGenres\": \"Žádné žánry\",\n  \"MessageNoIssues\": \"Žádné problémy\",\n  \"MessageNoItems\": \"Žádné položky\",\n  \"MessageNoItemsFound\": \"Nebyly nalezeny žádné položky\",\n  \"MessageNoListeningSessions\": \"Žádné poslechové relace\",\n  \"MessageNoLogs\": \"Žádné záznamy událostí\",\n  \"MessageNoMediaProgress\": \"Žádný průběh médií\",\n  \"MessageNoNotifications\": \"Žádná oznámení\",\n  \"MessageNoPodcastFeed\": \"Neplatný podcast: Žádný kanál\",\n  \"MessageNoPodcastsFound\": \"Nebyly nalezeny žádné podcasty\",\n  \"MessageNoResults\": \"Žádné výsledky\",\n  \"MessageNoSearchResultsFor\": \"Nebyly nalezeny žádné výsledky hledání pro \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Žádné série\",\n  \"MessageNoTags\": \"Žádné značky\",\n  \"MessageNoTasksRunning\": \"Nejsou spuštěny žádné úlohy\",\n  \"MessageNoUpdatesWereNecessary\": \"Nebyly nutné žádné aktualizace\",\n  \"MessageNoUserPlaylists\": \"Nemáte žádné seznamy skladeb\",\n  \"MessageNoUserPlaylistsHelp\": \"Seznamy skladeb jsou soukromé. Zobrazit je může pouze uživatel, který je vytvořil.\",\n  \"MessageNotYetImplemented\": \"Ještě není implementováno\",\n  \"MessageOpmlPreviewNote\": \"Poznámka: Toto je náhled načteného OMPL souboru. Aktuální název podcastu bude načten z RSS feedu.\",\n  \"MessageOr\": \"nebo\",\n  \"MessagePauseChapter\": \"Pozastavit přehrávání kapitoly\",\n  \"MessagePlayChapter\": \"Poslechnout si začátek kapitoly\",\n  \"MessagePlaylistCreateFromCollection\": \"Vytvořit seznam skladeb z kolekce\",\n  \"MessagePleaseWait\": \"Čekejte prosím...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání\",\n  \"MessagePodcastSearchField\": \"Zadejte hledaný pojem pro RSS feed URL\",\n  \"MessageQuickEmbedInProgress\": \"Probíhá rychlé vkládání\",\n  \"MessageQuickEmbedQueue\": \"Zařazeno do fronty pro rychlé vložení ({0} ve frontě)\",\n  \"MessageQuickMatchAllEpisodes\": \"Rychlá shoda všech epizod\",\n  \"MessageQuickMatchDescription\": \"Vyplnit prázdné detaily položky a obálky prvním výsledkem shody z '{0}'. Nepřepisuje detaily, pokud není povoleno nastavení serveru 'Preferovat shodná metadata'.\",\n  \"MessageRemoveChapter\": \"Odstranit kapitolu\",\n  \"MessageRemoveEpisodes\": \"Odstranit {0} epizodu\",\n  \"MessageRemoveFromPlayerQueue\": \"Odstranit z fronty přehrávače\",\n  \"MessageRemoveUserWarning\": \"Opravdu chcete trvale smazat uživatele \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Nahlašte chyby, vyžádejte si funkce a přispěte na\",\n  \"MessageResetChaptersConfirm\": \"Opravdu chcete resetovat kapitoly a vrátit zpět provedené změny?\",\n  \"MessageRestoreBackupConfirm\": \"Opravdu chcete obnovit zálohu vytvořenou dne\",\n  \"MessageRestoreBackupWarning\": \"Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.\",\n  \"MessageScheduleLibraryScanNote\": \"Většině uživatelů se doporučuje ponechat tuto funkci vypnutou a ponechat zapnuté nastavení sledování složek. Sledování složek automaticky zjistí změny ve složkách vaší knihovny. Sledování složek nefunguje pro každý souborový systém (jako je NFS), takže místo toho lze použít plánované skenování knihoven.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Spusť každý {0} v {1}\",\n  \"MessageSearchResultsFor\": \"Výsledky hledání pro\",\n  \"MessageSelected\": \"{0} vybráno\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Sekvence série nesmí obsahovat mezery\",\n  \"MessageServerCouldNotBeReached\": \"Server je nedostupný\",\n  \"MessageSetChaptersFromTracksDescription\": \"Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru\",\n  \"MessageShareExpirationWillBe\": \"Expiruje <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Expiruje za {0}\",\n  \"MessageShareURLWillBe\": \"Sdílené URL bude <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Spustit přehrávání pro \\\"{0}\\\" v {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Nelze zapisovat do audio souboru \\\"{0}\\\"\",\n  \"MessageTaskCanceledByUser\": \"Příkaz zrušen uživatelem\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Stahování epizody \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Vkládání metadat\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Vkládání metadat do audioknihy \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Kódování M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Kódování audioknihy \\\"{0}\\\" do jednoho m4b souboru\",\n  \"MessageTaskFailed\": \"Selhalo\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Zálohování audio souboru \\\"{0}\\\" se selhalo\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Vytvoření cache adresáře selhalo\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Vkládání metadat do souboru \\\"{0}\\\" selhalo\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Spojení audio souborů selhalo\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Přesunutí m4b souboru selhalo\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Zápis souboru metadat selhal\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Párování knih v knihovně „{0}“\",\n  \"MessageTaskNoFilesToScan\": \"Žádné soubory k prohledání\",\n  \"MessageTaskOpmlImport\": \"Import OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Vytváření podcastů z {0} RSS feedů\",\n  \"MessageTaskOpmlImportFeed\": \"Import OPML feedu\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importování RSS feedu \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Nepodařilo se získat kanál podcastu\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Vytváření podcastu \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast se stejnou cestou již existuje\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Vytváření podcastu selhalo\",\n  \"MessageTaskOpmlImportFinished\": \"Přidáno {0} podcastů\",\n  \"MessageTaskOpmlParseFailed\": \"Selhalo parsování OPML souboru\",\n  \"MessageTaskOpmlParseFastFail\": \"Neplatný OPML soubor <opml> tag nenalezen NEBO <outline> tag nenalezen\",\n  \"MessageTaskOpmlParseNoneFound\": \"Feed nebyl nalezen v OPML souboru\",\n  \"MessageTaskScanItemsAdded\": \"{0} přidáno\",\n  \"MessageTaskScanItemsMissing\": \"{0} chybí\",\n  \"MessageTaskScanItemsUpdated\": \"{0} aktualizováno\",\n  \"MessageTaskScanNoChangesNeeded\": \"Žádné změny nejsou nutné\",\n  \"MessageTaskScanningFileChanges\": \"Skenování změn souborů v \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Skenování \\\"{0}\\\" knihovny\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Do cílové složky nelze zapisovat\",\n  \"MessageThinking\": \"Přemýšlím...\",\n  \"MessageUploaderItemFailed\": \"Nahrávání selhalo\",\n  \"MessageUploaderItemSuccess\": \"Úspěšně nahráno!\",\n  \"MessageUploading\": \"Nahrávám...\",\n  \"MessageValidCronExpression\": \"Platný výraz cronu\",\n  \"MessageWatcherIsDisabledGlobally\": \"Hlídač je globálně zakázán v nastavení serveru\",\n  \"MessageXLibraryIsEmpty\": \"{0} knihovna je prázdná!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Délka audioknihy je delší, než byla nalezena\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Délka audioknihy je kratší, než byla nalezena\",\n  \"NoteChangeRootPassword\": \"Uživatel root je jediný uživatel, který může mít prázdné heslo\",\n  \"NoteChapterEditorTimes\": \"Poznámka: Čas začátku první kapitoly musí zůstat na 0:00 a čas začátku poslední kapitoly nesmí překročit dobu trvání audioknihy.\",\n  \"NoteFolderPicker\": \"Poznámka: složky, které jsou již namapovány, nebudou zobrazeny\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce, ignorovány.\",\n  \"NotificationOnBackupCompletedDescription\": \"Spuštěno po dokončení zálohování\",\n  \"NotificationOnBackupFailedDescription\": \"Spuštěno pokud zálohování selže\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Spuštěno při automatickém stažení epizody podcastu\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Aktivováno když je automatické stahování pozastaveno z důvodu příliš mnoho neůspěšných pokusů\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Aktivováno když selže RSS kanál pro stahování epizod\",\n  \"NotificationOnTestDescription\": \"Akce pro otestování upozorňovacího systému\",\n  \"PlaceholderBulkChapterInput\": \"Zadejte název kapitoly nebo použije číslování (např. 'Epizoda 1', 'Kapitola 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Nový název kolekce\",\n  \"PlaceholderNewFolderPath\": \"Nová cesta ke složce\",\n  \"PlaceholderNewPlaylist\": \"Nový název seznamu přehrávání\",\n  \"PlaceholderSearch\": \"Hledat..\",\n  \"PlaceholderSearchEpisode\": \"Hledat epizodu..\",\n  \"StatsAuthorsAdded\": \"autoři přidáni\",\n  \"StatsBooksAdded\": \"knihy přidány\",\n  \"StatsBooksAdditional\": \"Některé další zahrnují…\",\n  \"StatsBooksFinished\": \"dokončené knihy\",\n  \"StatsBooksFinishedThisYear\": \"Některé knihy dokončené tento rok…\",\n  \"StatsBooksListenedTo\": \"knih poslechnuto\",\n  \"StatsCollectionGrewTo\": \"Vaše kolekce knih se rozrostla na…\",\n  \"StatsSessions\": \"sezóna\",\n  \"StatsSpentListening\": \"stráveno posloucháním\",\n  \"StatsTopAuthor\": \"TOP AUTOR\",\n  \"StatsTopAuthors\": \"TOP AUTOŘI\",\n  \"StatsTopGenre\": \"TOP ŽÁNR\",\n  \"StatsTopGenres\": \"TOP ŽÁNRY\",\n  \"StatsTopMonth\": \"TOP MĚSÍC\",\n  \"StatsTopNarrator\": \"NEJLEPŠÍ VYPRAVĚČ\",\n  \"StatsTopNarrators\": \"NEJLEPŠÍ VYPRAVĚČI\",\n  \"StatsTotalDuration\": \"S celkovou dobou…\",\n  \"StatsYearInReview\": \"ROK V PŘEHLEDU\",\n  \"ToastAccountUpdateSuccess\": \"Účet aktualizován\",\n  \"ToastAppriseUrlRequired\": \"Je nutné zadat Apprise URL\",\n  \"ToastAsinRequired\": \"ASIN vyžadován\",\n  \"ToastAuthorImageRemoveSuccess\": \"Obrázek autora odstraněn\",\n  \"ToastAuthorNotFound\": \"Author \\\"{0}\\\" nenalezen\",\n  \"ToastAuthorRemoveSuccess\": \"Autor odstraněn\",\n  \"ToastAuthorSearchNotFound\": \"Autor nenalezen\",\n  \"ToastAuthorUpdateMerged\": \"Autor sloučen\",\n  \"ToastAuthorUpdateSuccess\": \"Autor aktualizován\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor aktualizován (nebyl nalezen žádný obrázek)\",\n  \"ToastBackupAppliedSuccess\": \"Záloha obnovena\",\n  \"ToastBackupCreateFailed\": \"Vytvoření zálohy se nezdařilo\",\n  \"ToastBackupCreateSuccess\": \"Záloha vytvořena\",\n  \"ToastBackupDeleteFailed\": \"Nepodařilo se smazat zálohu\",\n  \"ToastBackupDeleteSuccess\": \"Záloha smazána\",\n  \"ToastBackupInvalidMaxKeep\": \"Neplatný počet záloh k zachování\",\n  \"ToastBackupInvalidMaxSize\": \"Neplatná maximální velikost zálohy\",\n  \"ToastBackupRestoreFailed\": \"Nepodařilo se obnovit zálohu\",\n  \"ToastBackupUploadFailed\": \"Nepodařilo se nahrát zálohu\",\n  \"ToastBackupUploadSuccess\": \"Záloha nahrána\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Detaily byly aplikované na položky\",\n  \"ToastBatchDeleteFailed\": \"Hromadné smazání selhalo\",\n  \"ToastBatchDeleteSuccess\": \"Hromadné smazání proběhlo úspěšně\",\n  \"ToastBatchQuickMatchFailed\": \"Rychlá schoda dávky se nezdařila!\",\n  \"ToastBatchQuickMatchStarted\": \"Začala rychlá shoda {0} knih!\",\n  \"ToastBatchUpdateFailed\": \"Dávková aktualizace se nezdařila\",\n  \"ToastBatchUpdateSuccess\": \"Dávková aktualizace proběhla úspěšně\",\n  \"ToastBookmarkCreateFailed\": \"Vytvoření záložky se nezdařilo\",\n  \"ToastBookmarkCreateSuccess\": \"Přidána záložka\",\n  \"ToastBookmarkRemoveSuccess\": \"Záložka odstraněna\",\n  \"ToastBulkChapterInvalidCount\": \"Zadejte číslo mezi 1 a 150\",\n  \"ToastCachePurgeFailed\": \"Nepodařilo se vyčistit mezipaměť\",\n  \"ToastCachePurgeSuccess\": \"Vyrovnávací paměť úspěšně vyčištěna\",\n  \"ToastChapterLocked\": \"Kapitola je uzamčena.\",\n  \"ToastChapterStartTimeAdjusted\": \"Začátek kapitoly posunut o {0} sekund\",\n  \"ToastChaptersAllLocked\": \"Všechny kapitoly jsou uzamčeny. Pro posun kapitol některé odemkněte.\",\n  \"ToastChaptersHaveErrors\": \"Kapitoly obsahují chyby\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Nesprávná délka posunu. Čas začátku poslední kapitoly by přesáhl dobu trvání této audioknihy.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Nesprávná délka posunu. První kapitola by měla nulovou nebo zápornou délku a byla by přepsána druhou kapitolou. Zvětšete čas začátku druhé kapitoly.\",\n  \"ToastChaptersMustHaveTitles\": \"Kapitoly musí mít názvy\",\n  \"ToastChaptersRemoved\": \"Kapitoly odstraněny\",\n  \"ToastChaptersUpdated\": \"Kapitola aktualizována\",\n  \"ToastCollectionItemsAddFailed\": \"Přidávání položek do kolekce selhalo\",\n  \"ToastCollectionRemoveSuccess\": \"Kolekce odstraněna\",\n  \"ToastCollectionUpdateSuccess\": \"Kolekce aktualizována\",\n  \"ToastConnectionNotAvailable\": \"Připojení není k dispozici. Zkuste to prosím znovu později\",\n  \"ToastCoverSearchFailed\": \"Hledání obálky se nezdařilo\",\n  \"ToastCoverUpdateFailed\": \"Aktualizace obálky selhala\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Datum a čas jsou chybné nebo nekompletní\",\n  \"ToastDeleteFileFailed\": \"Nepodařilo se smazat soubor\",\n  \"ToastDeleteFileSuccess\": \"Soubor smazán\",\n  \"ToastDeviceAddFailed\": \"Přidání zařízení selhalo\",\n  \"ToastDeviceNameAlreadyExists\": \"Zařízení se stejným jménem již existuje\",\n  \"ToastDeviceTestEmailFailed\": \"Odeslání testovacího emailu selhalo\",\n  \"ToastDeviceTestEmailSuccess\": \"Testovací email byl odeslán\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Nastavení emailu aktualizována\",\n  \"ToastEncodeCancelFailed\": \"Zrušení encodování selhalo\",\n  \"ToastEncodeCancelSucces\": \"Kódování zrušeno\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Vyčištění fronty selhalo\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Fronta stahování epizod je prázdná\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} epizod aktualizováno\",\n  \"ToastErrorCannotShare\": \"Na tomto zařízení nelze nativně sdílet\",\n  \"ToastFailedToCreate\": \"Nepodařilo se vytvořit\",\n  \"ToastFailedToDelete\": \"Nepodařilo se odstranit\",\n  \"ToastFailedToLoadData\": \"Nepodařilo se načíst data\",\n  \"ToastFailedToMatch\": \"Nepodařilo se spárovat\",\n  \"ToastFailedToShare\": \"Sdílení selhalo\",\n  \"ToastFailedToUpdate\": \"Aktualizace selhala\",\n  \"ToastInvalidImageUrl\": \"Neplatná URL obrázku\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Neplatný maximální počet epizod ke stažení\",\n  \"ToastInvalidUrl\": \"Neplatná URL\",\n  \"ToastInvalidUrls\": \"Alespoň jedna URL je neplatná\",\n  \"ToastItemCoverUpdateSuccess\": \"Obálka předmětu byl aktualizována\",\n  \"ToastItemDeletedFailed\": \"Smazání položky selhalo\",\n  \"ToastItemDeletedSuccess\": \"Položka smazána\",\n  \"ToastItemDetailsUpdateSuccess\": \"Podrobnosti o položce byly aktualizovány\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Nepodařilo se označit jako dokončené\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Položka označena jako dokončená\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Nepodařilo se označit jako nedokončené\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Položka označena jako nedokončená\",\n  \"ToastItemUpdateSuccess\": \"Položka aktualizována\",\n  \"ToastLibraryCreateFailed\": \"Vytvoření knihovny se nezdařilo\",\n  \"ToastLibraryCreateSuccess\": \"Knihovna \\\"{0}\\\" vytvořena\",\n  \"ToastLibraryDeleteFailed\": \"Nepodařilo se smazat knihovnu\",\n  \"ToastLibraryDeleteSuccess\": \"Knihovna smazána\",\n  \"ToastLibraryScanFailedToStart\": \"Nepodařilo se spustit kontrolu\",\n  \"ToastLibraryScanStarted\": \"Kontrola knihovny spuštěna\",\n  \"ToastLibraryUpdateSuccess\": \"Knihovna \\\"{0}\\\" aktualizována\",\n  \"ToastMatchAllAuthorsFailed\": \"Nepodařilo se přiřadit všechny autory\",\n  \"ToastMetadataFilesRemovedError\": \"Při odstraňování souborů metadat.{0} došlo k chybě\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Žádná metadata.{0} nebyla nalezena v knihovně\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Žádná metadata.{0} počet odstraněných souborů\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} soubor odstraněn\",\n  \"ToastMustHaveAtLeastOnePath\": \"Musí mít minimálně jednu cestu\",\n  \"ToastNameEmailRequired\": \"Jméno a email jsou vyžadovány\",\n  \"ToastNameRequired\": \"Jméno je vyžadováno\",\n  \"ToastNewApiKeyUserError\": \"Je nutné vybrat uživatele\",\n  \"ToastNewEpisodesFound\": \"{0} nových epizod bylo nalezeno\",\n  \"ToastNewUserCreatedFailed\": \"Chyba při vytváření účtu: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Vytvořen nový účet\",\n  \"ToastNewUserLibraryError\": \"Musíte vybrat alespoň jednu knihovnu\",\n  \"ToastNewUserPasswordError\": \"Musí mít heslo, pouze uživatel root může mít prázdné heslo\",\n  \"ToastNewUserTagError\": \"Musíte vybrat alespoň jeden tag\",\n  \"ToastNewUserUsernameError\": \"Zadej uživatelské jméno\",\n  \"ToastNoNewEpisodesFound\": \"Nebyla nalezena žádná nová epizoda\",\n  \"ToastNoRSSFeed\": \"Podcast nemá RSS Feed\",\n  \"ToastNoUpdatesNecessary\": \"Nejsou potřeba žádné aktualizace\",\n  \"ToastNotificationCreateFailed\": \"Chyba při vytváření upozornění\",\n  \"ToastNotificationDeleteFailed\": \"Chyba při odstranění upozornění\",\n  \"ToastNotificationFailedMaximum\": \"Maximální počet chybných pokusů >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Maximální počet upozornění ve frontě musí být >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Nastavení upozornění aktualizováno\",\n  \"ToastNotificationTestTriggerFailed\": \"Chyba při spuštění testovacího upozornění\",\n  \"ToastNotificationTestTriggerSuccess\": \"Spuštěno testovací upozornění\",\n  \"ToastNotificationUpdateSuccess\": \"Upozornění aktualizováno\",\n  \"ToastPlaylistCreateFailed\": \"Vytvoření seznamu přehrávání se nezdařilo\",\n  \"ToastPlaylistCreateSuccess\": \"Seznam přehrávání vytvořen\",\n  \"ToastPlaylistRemoveSuccess\": \"Seznam přehrávání odstraněn\",\n  \"ToastPlaylistUpdateSuccess\": \"Seznam přehrávání aktualizován\",\n  \"ToastPodcastCreateFailed\": \"Vytvoření podcastu se nezdařilo\",\n  \"ToastPodcastCreateSuccess\": \"Podcast byl úspěšně vytvořen\",\n  \"ToastPodcastEpisodeUpdated\": \"Epizoda aktualizována\",\n  \"ToastPodcastGetFeedFailed\": \"Chyba při získání podcastového feedu\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Žádné epizody nenalezeny v RSS feedu\",\n  \"ToastPodcastNoRssFeed\": \"Podcast nemá RSS feed\",\n  \"ToastProgressIsNotBeingSynced\": \"Progres není synchronizován, restartujte přehrávání\",\n  \"ToastProviderCreatedFailed\": \"Chyba při zadání poskytovatele\",\n  \"ToastProviderCreatedSuccess\": \"Nový poskytovatel přidán\",\n  \"ToastProviderNameAndUrlRequired\": \"Jméno a Url jsou vyžadovány\",\n  \"ToastProviderRemoveSuccess\": \"Poskytovatel odstraněn\",\n  \"ToastRSSFeedCloseFailed\": \"Nepodařilo se zavřít RSS kanál\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS kanál uzavřen\",\n  \"ToastRemoveFailed\": \"Chyba při odstranění\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Nepodařilo se odebrat položku z kolekce\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Položka odstraněna z kolekce\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Chyba při odstranění položek v knihovně s chybami\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Odstraněny položky knihovny s chybami\",\n  \"ToastRenameFailed\": \"Chyba při přejmenování\",\n  \"ToastRescanFailed\": \"Znovu prohledání selhalo z důvodu {0}\",\n  \"ToastRescanRemoved\": \"Znova skenování komplení - položka byla odsraněna\",\n  \"ToastRescanUpToDate\": \"Znovu prohledání kompletní - položka aktualizována\",\n  \"ToastRescanUpdated\": \"Znovu skenování komplení - položka byla aktualizována\",\n  \"ToastScanFailed\": \"Prohledání položek knihovny selhalo\",\n  \"ToastSelectAtLeastOneUser\": \"Vyberte alespoň jednoho uživatele\",\n  \"ToastSendEbookToDeviceFailed\": \"Odeslání e-knihy do zařízení se nezdařilo\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-kniha odeslána do zařízení \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Nelze přidat dvě série se stejným názvem\",\n  \"ToastSeriesUpdateFailed\": \"Aktualizace série se nezdařila\",\n  \"ToastSeriesUpdateSuccess\": \"Aktualizace série byla úspěšná\",\n  \"ToastServerSettingsUpdateSuccess\": \"Nastavení serveru aktualizováno\",\n  \"ToastSessionCloseFailed\": \"Chyba při ukončení\",\n  \"ToastSessionDeleteFailed\": \"Nepodařilo se smazat relaci\",\n  \"ToastSessionDeleteSuccess\": \"Relace smazána\",\n  \"ToastSleepTimerDone\": \"Uspání knížky ... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug obsahuje chybné znaky\",\n  \"ToastSlugRequired\": \"Slug (URL) je vyžadována\",\n  \"ToastSocketConnected\": \"Socket připojen\",\n  \"ToastSocketDisconnected\": \"Socket odpojen\",\n  \"ToastSocketFailedToConnect\": \"Socket se nepodařilo připojit\",\n  \"ToastSortingPrefixesEmptyError\": \"Musí mít alespoň 1 třídicí předponu\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Aktualizovány předpony třídění ({0} položek)\",\n  \"ToastTitleRequired\": \"Titul je vyžadován\",\n  \"ToastUnknownError\": \"Neznámý error\",\n  \"ToastUnlinkOpenIdFailed\": \"Chyba při odpárování uživatele z OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Uživatel odpárován z uživatele z OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Soubor \\\"{0}\\\" na serveru již existuje\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Položka \\\"{0}\\\" používá podadresář cesty pro nahrání.\",\n  \"ToastUserDeleteFailed\": \"Nepodařilo se smazat uživatele\",\n  \"ToastUserDeleteSuccess\": \"Uživatel smazán\",\n  \"ToastUserPasswordChangeSuccess\": \"Heslo bylo změněno úspěšně\",\n  \"ToastUserPasswordMismatch\": \"Hesla se neschodují\",\n  \"ToastUserPasswordMustChange\": \"Nové heslo se musí lišit od předchozího\",\n  \"ToastUserRootRequireName\": \"Musíte zadat uživatelské jméno root\",\n  \"TooltipAddChapters\": \"Přidat kapitolu/y\",\n  \"TooltipAddOneSecond\": \"Přidat 1 sekundu\",\n  \"TooltipAdjustChapterStart\": \"Kliknutím upravte začátek\",\n  \"TooltipLockAllChapters\": \"Uzamknout všechny kapitoly\",\n  \"TooltipLockChapter\": \"Uzamknout kapitolu (Shift+klik pro rozsah)\",\n  \"TooltipSubtractOneSecond\": \"Odečíst 1 sekundu\",\n  \"TooltipUnlockAllChapters\": \"Odemknout všechny kapitoly\",\n  \"TooltipUnlockChapter\": \"Odemknout kapitolu (Shift+klik pro rozsah)\"\n}\n"
  },
  {
    "path": "client/strings/da.json",
    "content": "{\n  \"ButtonAdd\": \"Tilføj\",\n  \"ButtonAddApiKey\": \"Tilføj API-nøgle\",\n  \"ButtonAddChapters\": \"Tilføj kapitler\",\n  \"ButtonAddDevice\": \"Tilføj enhed\",\n  \"ButtonAddLibrary\": \"Tilføj Bibliotek\",\n  \"ButtonAddPodcasts\": \"Tilføj podcasts\",\n  \"ButtonAddUser\": \"Tilføj bruger\",\n  \"ButtonAddYourFirstLibrary\": \"Tilføj dit første bibliotek\",\n  \"ButtonApply\": \"Anvend\",\n  \"ButtonApplyChapters\": \"Anvend kapitler\",\n  \"ButtonAuthors\": \"Forfattere\",\n  \"ButtonBack\": \"Tilbage\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Opret fra eksisterende\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Opret fra kortlægnings detaljer\",\n  \"ButtonBrowseForFolder\": \"Gennemse mappe\",\n  \"ButtonCancel\": \"Annuller\",\n  \"ButtonCancelEncode\": \"Annuller kodning\",\n  \"ButtonChangeRootPassword\": \"Ændr rodadgangskode\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Tjek og download nye episoder\",\n  \"ButtonChooseAFolder\": \"Vælg en mappe\",\n  \"ButtonChooseFiles\": \"Vælg filer\",\n  \"ButtonClearFilter\": \"Ryd filter\",\n  \"ButtonClose\": \"Luk\",\n  \"ButtonCloseFeed\": \"Luk feed\",\n  \"ButtonCloseSession\": \"Luk Åben Session\",\n  \"ButtonCollections\": \"Samlinger\",\n  \"ButtonConfigureScanner\": \"Konfigurer scanner\",\n  \"ButtonCreate\": \"Opret\",\n  \"ButtonCreateBackup\": \"Opret sikkerhedskopi\",\n  \"ButtonDelete\": \"Slet\",\n  \"ButtonDownloadQueue\": \"Kø\",\n  \"ButtonEdit\": \"Rediger\",\n  \"ButtonEditChapters\": \"Rediger kapitler\",\n  \"ButtonEditPodcast\": \"Rediger podcast\",\n  \"ButtonEnable\": \"Aktiver\",\n  \"ButtonFireAndFail\": \"Affyring Og Fejl\",\n  \"ButtonFireOnTest\": \"Affyring vedTest begivenhed\",\n  \"ButtonForceReScan\": \"Tving genindlæsning\",\n  \"ButtonFullPath\": \"Fuld sti\",\n  \"ButtonHide\": \"Skjul\",\n  \"ButtonHome\": \"Hjem\",\n  \"ButtonIssues\": \"Problemer\",\n  \"ButtonJumpBackward\": \"Hop Tilbage\",\n  \"ButtonJumpForward\": \"Hop Fremad\",\n  \"ButtonLatest\": \"Seneste\",\n  \"ButtonLibrary\": \"Bibliotek\",\n  \"ButtonLogout\": \"Log ud\",\n  \"ButtonLookup\": \"Slå op\",\n  \"ButtonManageTracks\": \"Administrer spor\",\n  \"ButtonMapChapterTitles\": \"Kortlæg kapiteloverskrifter\",\n  \"ButtonMatchAllAuthors\": \"Match alle forfattere\",\n  \"ButtonMatchBooks\": \"Match bøger\",\n  \"ButtonNevermind\": \"Glem det\",\n  \"ButtonNext\": \"Næste\",\n  \"ButtonNextChapter\": \"Næste Kapitel\",\n  \"ButtonNextItemInQueue\": \"Næste Element i Køen\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Åbn feed\",\n  \"ButtonOpenManager\": \"Åbn manager\",\n  \"ButtonPause\": \"Pause\",\n  \"ButtonPlay\": \"Afspil\",\n  \"ButtonPlayAll\": \"Afspil Alle\",\n  \"ButtonPlaying\": \"Afspiller\",\n  \"ButtonPlaylists\": \"Afspilningslister\",\n  \"ButtonPrevious\": \"Sidste\",\n  \"ButtonPreviousChapter\": \"Sidste Kapitel\",\n  \"ButtonProbeAudioFile\": \"Undersøg Lydfil\",\n  \"ButtonPurgeAllCache\": \"Ryd al cache\",\n  \"ButtonPurgeItemsCache\": \"Ryd elementcache\",\n  \"ButtonQueueAddItem\": \"Tilføj til kø\",\n  \"ButtonQueueRemoveItem\": \"Fjern fra kø\",\n  \"ButtonQuickEmbed\": \"Hurtig Indlejring\",\n  \"ButtonQuickEmbedMetadata\": \"Hurtig Indlejring af Metadata\",\n  \"ButtonQuickMatch\": \"Hurtig Match\",\n  \"ButtonReScan\": \"Gen-scan\",\n  \"ButtonRead\": \"Læs\",\n  \"ButtonReadLess\": \"Se mindre\",\n  \"ButtonReadMore\": \"Se mere\",\n  \"ButtonRefresh\": \"Genindlæs\",\n  \"ButtonRemove\": \"Fjern\",\n  \"ButtonRemoveAll\": \"Fjern Alle\",\n  \"ButtonRemoveAllLibraryItems\": \"Fjern Alle Bibliotekselementer\",\n  \"ButtonRemoveFromContinueListening\": \"Fjern fra Fortsæt Lytning\",\n  \"ButtonRemoveFromContinueReading\": \"Fjern fra Fortsæt Læsning\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Fjern Serie fra Fortsæt Serie\",\n  \"ButtonReset\": \"Nulstil\",\n  \"ButtonResetToDefault\": \"Nulstil til standard\",\n  \"ButtonRestore\": \"Gendan\",\n  \"ButtonSave\": \"Gem\",\n  \"ButtonSaveAndClose\": \"Gem & Luk\",\n  \"ButtonSaveTracklist\": \"Gem Sporliste\",\n  \"ButtonScan\": \"Scan\",\n  \"ButtonScanLibrary\": \"Scan Bibliotek\",\n  \"ButtonScrollLeft\": \"Rul til Venstre\",\n  \"ButtonScrollRight\": \"Rul til Højre\",\n  \"ButtonSearch\": \"Søg\",\n  \"ButtonSelectFolderPath\": \"Vælg Mappe Sti\",\n  \"ButtonSeries\": \"Serier\",\n  \"ButtonSetChaptersFromTracks\": \"Sæt kapitler fra spor\",\n  \"ButtonShare\": \"Del\",\n  \"ButtonShiftTimes\": \"Skift Tider\",\n  \"ButtonShow\": \"Vis\",\n  \"ButtonStartM4BEncode\": \"Start M4B Kode\",\n  \"ButtonStartMetadataEmbed\": \"Start Metadata Indlejring\",\n  \"ButtonStats\": \"Statistik\",\n  \"ButtonSubmit\": \"Send\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Afkobl OpenID\",\n  \"ButtonUpload\": \"Upload\",\n  \"ButtonUploadBackup\": \"Upload Backup\",\n  \"ButtonUploadCover\": \"Upload Omslag\",\n  \"ButtonUploadOPMLFile\": \"Upload OPML Fil\",\n  \"ButtonUserDelete\": \"Slet bruger {0}\",\n  \"ButtonUserEdit\": \"Rediger bruger {0}\",\n  \"ButtonViewAll\": \"Vis Alle\",\n  \"ButtonYes\": \"Ja\",\n  \"ErrorUploadFetchMetadataAPI\": \"Fejl henter metadata\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Kunne ikke hente metadata - prøv at uploade title og/eller forfatter\",\n  \"ErrorUploadLacksTitle\": \"Skal have en title\",\n  \"HeaderAccount\": \"Konto\",\n  \"HeaderAddCustomMetadataProvider\": \"Tilføj Brugerdefineret Metadataudbyder\",\n  \"HeaderAdvanced\": \"Avanceret\",\n  \"HeaderApiKeys\": \"API-nøgler\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise Notifikationsindstillinger\",\n  \"HeaderAudioTracks\": \"Lydspor\",\n  \"HeaderAudiobookTools\": \"Audiobog Filhåndteringsværktøjer\",\n  \"HeaderAuthentication\": \"Autentificering\",\n  \"HeaderBackups\": \"Sikkerhedskopier\",\n  \"HeaderBulkChapterModal\": \"Tilføj flere kapitler\",\n  \"HeaderChangePassword\": \"Skift Adgangskode\",\n  \"HeaderChapters\": \"Kapitler\",\n  \"HeaderChooseAFolder\": \"Vælg en Mappe\",\n  \"HeaderCollection\": \"Samling\",\n  \"HeaderCollectionItems\": \"Samlingselementer\",\n  \"HeaderCover\": \"Omslag\",\n  \"HeaderCurrentDownloads\": \"Nuværende Downloads\",\n  \"HeaderCustomMessageOnLogin\": \"Brugerdefineret Besked ved Login\",\n  \"HeaderCustomMetadataProviders\": \"Brugerdefineret Metadataudbyder\",\n  \"HeaderDetails\": \"Detaljer\",\n  \"HeaderDownloadQueue\": \"Download Kø\",\n  \"HeaderEbookFiles\": \"E-bogsfiler\",\n  \"HeaderEmail\": \"Email\",\n  \"HeaderEmailSettings\": \"Email Indstillinger\",\n  \"HeaderEpisodes\": \"Episoder\",\n  \"HeaderEreaderDevices\": \"E-læser Enheder\",\n  \"HeaderEreaderSettings\": \"E-læser Indstillinger\",\n  \"HeaderFiles\": \"Filer\",\n  \"HeaderFindChapters\": \"Find Kapitler\",\n  \"HeaderIgnoredFiles\": \"Ignorerede Filer\",\n  \"HeaderItemFiles\": \"Emnefiler\",\n  \"HeaderItemMetadataUtils\": \"Emne Metadata Værktøjer\",\n  \"HeaderLastListeningSession\": \"Seneste Lyttesession\",\n  \"HeaderLatestEpisodes\": \"Seneste episoder\",\n  \"HeaderLibraries\": \"Biblioteker\",\n  \"HeaderLibraryFiles\": \"Biblioteksfiler\",\n  \"HeaderLibraryStats\": \"Biblioteksstatistik\",\n  \"HeaderListeningSessions\": \"Lyttesessioner\",\n  \"HeaderListeningStats\": \"Lyttestatistik\",\n  \"HeaderLogin\": \"Log ind\",\n  \"HeaderLogs\": \"Logs\",\n  \"HeaderManageGenres\": \"Administrer Genrer\",\n  \"HeaderManageTags\": \"Administrer Tags\",\n  \"HeaderMapDetails\": \"Kort Detaljer\",\n  \"HeaderMatch\": \"Match\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metadata-prioritet\",\n  \"HeaderMetadataToEmbed\": \"Metadata til indlejring\",\n  \"HeaderNewAccount\": \"Ny Konto\",\n  \"HeaderNewApiKey\": \"Ny API-nøgle\",\n  \"HeaderNewLibrary\": \"Nyt Bibliotek\",\n  \"HeaderNotificationCreate\": \"Opret Notifikation\",\n  \"HeaderNotificationUpdate\": \"Updater Notifikation\",\n  \"HeaderNotifications\": \"Meddelelser\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect-autentificering\",\n  \"HeaderOpenListeningSessions\": \"Åbne lyttesessioner\",\n  \"HeaderOpenRSSFeed\": \"Åbn RSS Feed\",\n  \"HeaderOtherFiles\": \"Andre Filer\",\n  \"HeaderPasswordAuthentication\": \"Adgangskodeautentificering\",\n  \"HeaderPermissions\": \"Tilladelser\",\n  \"HeaderPlayerQueue\": \"Afspilningskø\",\n  \"HeaderPlayerSettings\": \"Afspiller Indstillinger\",\n  \"HeaderPlaylist\": \"Afspilningsliste\",\n  \"HeaderPlaylistItems\": \"Afspilningsliste Elementer\",\n  \"HeaderPodcastsToAdd\": \"Podcasts til Tilføjelse\",\n  \"HeaderPresets\": \"Forudindstillinger\",\n  \"HeaderPreviewCover\": \"Forhåndsvis Omslag\",\n  \"HeaderRSSFeedGeneral\": \"RSS Detaljer\",\n  \"HeaderRSSFeedIsOpen\": \"RSS Feed er Åben\",\n  \"HeaderRSSFeeds\": \"RSS-Feeds\",\n  \"HeaderRemoveEpisode\": \"Fjern Episode\",\n  \"HeaderRemoveEpisodes\": \"Fjern {0} Episoder\",\n  \"HeaderSavedMediaProgress\": \"Gemt Medieforløb\",\n  \"HeaderSchedule\": \"Planlæg\",\n  \"HeaderScheduleEpisodeDownloads\": \"Planlæg Automatisk Episode-Download\",\n  \"HeaderScheduleLibraryScans\": \"Planlæg Automatiske Biblioteksscanninger\",\n  \"HeaderSession\": \"Session\",\n  \"HeaderSetBackupSchedule\": \"Indstil Sikkerhedskopieringsplan\",\n  \"HeaderSettings\": \"Indstillinger\",\n  \"HeaderSettingsDisplay\": \"Skærm\",\n  \"HeaderSettingsExperimental\": \"Eksperimentelle Funktioner\",\n  \"HeaderSettingsGeneral\": \"Generelt\",\n  \"HeaderSettingsScanner\": \"Scanner\",\n  \"HeaderSettingsSecurity\": \"Sikkerhed\",\n  \"HeaderSettingsWebClient\": \"Webklient\",\n  \"HeaderSleepTimer\": \"Søvntimer\",\n  \"HeaderStatsLargestItems\": \"Største Elementer\",\n  \"HeaderStatsLongestItems\": \"Længste Elementer (timer)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minutter Lyttet (sidste 7 dage)\",\n  \"HeaderStatsRecentSessions\": \"Seneste Sessions\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Forfattere\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Genrer\",\n  \"HeaderTableOfContents\": \"Indholdsfortegnelse\",\n  \"HeaderTools\": \"Værktøjer\",\n  \"HeaderUpdateAccount\": \"Opdater Konto\",\n  \"HeaderUpdateApiKey\": \"Opdater API-nøgle\",\n  \"HeaderUpdateAuthor\": \"Opdater Forfatter\",\n  \"HeaderUpdateDetails\": \"Opdater Detaljer\",\n  \"HeaderUpdateLibrary\": \"Opdater Bibliotek\",\n  \"HeaderUsers\": \"Brugere\",\n  \"HeaderYearReview\": \"Gennemgang af År {0}\",\n  \"HeaderYourStats\": \"Dine Statistikker\",\n  \"LabelAbridged\": \"Forkortet\",\n  \"LabelAbridgedChecked\": \"Forkortet (kontrolleret)\",\n  \"LabelAbridgedUnchecked\": \"Uforkortet (ikke kontrolleret)\",\n  \"LabelAccessibleBy\": \"Tilgængelig af\",\n  \"LabelAccountType\": \"Brugertype\",\n  \"LabelAccountTypeAdmin\": \"Administrator\",\n  \"LabelAccountTypeGuest\": \"Gæst\",\n  \"LabelAccountTypeUser\": \"Bruger\",\n  \"LabelActivities\": \"Aktiviteter\",\n  \"LabelActivity\": \"Aktivitet\",\n  \"LabelAddToCollection\": \"Tilføj til Samling\",\n  \"LabelAddToCollectionBatch\": \"Tilføj {0} Bøger til Samling\",\n  \"LabelAddToPlaylist\": \"Tilføj til Afspilningsliste\",\n  \"LabelAddToPlaylistBatch\": \"Tilføj {0} Elementer til Afspilningsliste\",\n  \"LabelAddedAt\": \"Tilføjet\",\n  \"LabelAddedDate\": \"Tilføjet {0}\",\n  \"LabelAdminUsersOnly\": \"Kun Administratorer\",\n  \"LabelAll\": \"Alle\",\n  \"LabelAllEpisodesDownloaded\": \"Alle episoder hentet\",\n  \"LabelAllUsers\": \"Alle Brugere\",\n  \"LabelAllUsersExcludingGuests\": \"Alle bruger eksklusiv gæster\",\n  \"LabelAllUsersIncludingGuests\": \"Alle bruger inklusiv gæster\",\n  \"LabelAlreadyInYourLibrary\": \"Allerede i dit bibliotek\",\n  \"LabelApiKeyCreated\": \"API-nøgle\\\"{0}\\\" oprettet succesfuldt.\",\n  \"LabelApiKeyCreatedDescription\": \"Sørg for at kopiere API-nøglen nu, da du ikke vil kunne se den igen.\",\n  \"LabelApiKeyUser\": \"Ret på vegne af brugeren\",\n  \"LabelApiKeyUserDescription\": \"Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren foretog anmodningen.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Tilføj\",\n  \"LabelAudioBitrate\": \"Lydbitrate (f.eks. 128k)\",\n  \"LabelAudioChannels\": \"Lydkanaler (1 eller 2)\",\n  \"LabelAudioCodec\": \"Lydkodek\",\n  \"LabelAuthor\": \"Forfatter\",\n  \"LabelAuthorFirstLast\": \"Forfatter (Fornavn Efternavn)\",\n  \"LabelAuthorLastFirst\": \"Forfatter (Efternavn, Fornavn)\",\n  \"LabelAuthors\": \"Forfattere\",\n  \"LabelAutoDownloadEpisodes\": \"Auto Download Episoder\",\n  \"LabelAutoFetchMetadata\": \"Automatisk Hent Metadata\",\n  \"LabelAutoFetchMetadataHelp\": \"Henter metadata for titler, forfatter og serier for at strømligne uploading. Ekstra metadata har måske brug for at blive matchet efter upload.\",\n  \"LabelAutoLaunch\": \"Åben Automatisk\",\n  \"LabelAutoLaunchDescription\": \"Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Registrer Automatisk\",\n  \"LabelAutoRegisterDescription\": \"Automatisk oprettelse af nye brugere efter login\",\n  \"LabelBackToUser\": \"Tilbage til Bruger\",\n  \"LabelBackupAudioFiles\": \"Sikkerhedskopier lydfiler\",\n  \"LabelBackupLocation\": \"Backup Placering\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatisk sikkerhedskopiering\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Sikkerhedskopier gemt i /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maksimal sikkerhedskopistørrelse (i GB) (0 for ubegrænset)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.\",\n  \"LabelBackupsNumberToKeep\": \"Antal sikkerhedskopier at beholde\",\n  \"LabelBackupsNumberToKeepHelp\": \"Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Bøger\",\n  \"LabelButtonText\": \"Knap tekst\",\n  \"LabelByAuthor\": \"Efter Forfatter\",\n  \"LabelChangePassword\": \"Ændre Adgangskode\",\n  \"LabelChannels\": \"Kanaler\",\n  \"LabelChapterCount\": \"{0} Kapitler\",\n  \"LabelChapterTitle\": \"Kapitel Titel\",\n  \"LabelChapters\": \"Kapitler\",\n  \"LabelChaptersFound\": \"fundne kapitler\",\n  \"LabelClickForMoreInfo\": \"Klik for mere info\",\n  \"LabelClickToUseCurrentValue\": \"Klik for at bruge nuværende værdi\",\n  \"LabelClosePlayer\": \"Luk afspiller\",\n  \"LabelCodec\": \"Kodeks\",\n  \"LabelCollapseSeries\": \"Fold Serier Sammen\",\n  \"LabelCollapseSubSeries\": \"Fold underserie sammen\",\n  \"LabelCollection\": \"Samling\",\n  \"LabelCollections\": \"Samlinger\",\n  \"LabelComplete\": \"Fuldfør\",\n  \"LabelConfirmPassword\": \"Bekræft Adgangskode\",\n  \"LabelContinueListening\": \"Fortsæt med at lytte\",\n  \"LabelContinueReading\": \"Fortsæt med at læse\",\n  \"LabelContinueSeries\": \"Fortsæt Serien\",\n  \"LabelCorsAllowed\": \"Tilladte CORS-oprindelser\",\n  \"LabelCover\": \"Omslag\",\n  \"LabelCoverImageURL\": \"Omslagsbillede URL\",\n  \"LabelCoverProvider\": \"Cover billede udbyder\",\n  \"LabelCreatedAt\": \"Oprettet d.\",\n  \"LabelCronExpression\": \"Cron Udtryk\",\n  \"LabelCurrent\": \"Aktuel\",\n  \"LabelCurrently\": \"Aktuelt:\",\n  \"LabelCustomCronExpression\": \"Brugerdefineret Cron Udtryk:\",\n  \"LabelDatetime\": \"Dato og Tid\",\n  \"LabelDays\": \"Dage\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Slet fra filsystem (afmarker kun for at fjerne fra databasen)\",\n  \"LabelDescription\": \"Beskrivelse\",\n  \"LabelDeselectAll\": \"Fravælg Alle\",\n  \"LabelDetectedPattern\": \"Identificeret mønster:\",\n  \"LabelDevice\": \"Enheds\",\n  \"LabelDeviceInfo\": \"Enhedsinformation\",\n  \"LabelDeviceIsAvailableTo\": \"Enhed er tilgængelig for...\",\n  \"LabelDirectory\": \"Mappe\",\n  \"LabelDiscFromFilename\": \"Disk fra Filnavn\",\n  \"LabelDiscFromMetadata\": \"Disk fra Metadata\",\n  \"LabelDiscover\": \"Opdag\",\n  \"LabelDownload\": \"Download\",\n  \"LabelDownloadNEpisodes\": \"Download {0} episoder\",\n  \"LabelDownloadable\": \"Downloadbar\",\n  \"LabelDuration\": \"Varighed\",\n  \"LabelDurationComparisonExactMatch\": \"(præcis match)\",\n  \"LabelDurationComparisonLonger\": \"({0} længere)\",\n  \"LabelDurationComparisonShorter\": \"({0} kortere)\",\n  \"LabelDurationFound\": \"Fundet varighed:\",\n  \"LabelEbook\": \"E-bog\",\n  \"LabelEbooks\": \"E-bøger\",\n  \"LabelEdit\": \"Rediger\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Fra Adresse\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Afvis uautoriserede certifikater\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Deaktivering af SSL certifikat validering kan udsætte din forbindelse for sikkerhedsrisici, eksempelvis man-in-the-middle angreb. Deaktiver kun denne indstilling hvis du forstår de potentielle implikationer og stoler på den mailserver du forbinder til.\",\n  \"LabelEmailSettingsSecure\": \"Sikker\",\n  \"LabelEmailSettingsSecureHelp\": \"Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Test Adresse\",\n  \"LabelEmbeddedCover\": \"Indlejret Omslag\",\n  \"LabelEnable\": \"Aktivér\",\n  \"LabelEncodingBackupLocation\": \"En sikkerhedskopi af dine originale lydfiler vil blive gemt under:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Kapitler er ikke indlejret i multi spors lydbøger.\",\n  \"LabelEncodingClearItemCache\": \"Sørg for periodisk at rense indholdscachen.\",\n  \"LabelEncodingFinishedM4B\": \"Færdiggjort M4B som vil blive placeret i din lydbogsmappe ved:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadata vil blive indlejret i lydfiler i lydbogsmappen.\",\n  \"LabelEncodingStartedNavigation\": \"Når opgaven er startet kan du navigere væk fra denne side.\",\n  \"LabelEncodingTimeWarning\": \"Indkodning kan tage op til 30 minutter.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Advarsel: Opdater ikke disse indstillinger med mindre du kender til ffmpeg indkodningsindstillinger.\",\n  \"LabelEncodingWatcherDisabled\": \"Hvis du har watcheren deaktiveret skal du gen-scanne denne lydbog bagefter.\",\n  \"LabelEnd\": \"Slut\",\n  \"LabelEndOfChapter\": \"Slutningen af kapitel\",\n  \"LabelEpisode\": \"Afsnit\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Afsnit er ikke koblet til RSS feed\",\n  \"LabelEpisodeNumber\": \"Afsnit #{0}\",\n  \"LabelEpisodeTitle\": \"Episodetitel\",\n  \"LabelEpisodeType\": \"Episodetype\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Afsnit URL fra RSS feed\",\n  \"LabelEpisodes\": \"Afsnit\",\n  \"LabelEpisodic\": \"Afsnit\",\n  \"LabelExample\": \"Eksempel\",\n  \"LabelExpandSeries\": \"Udfold serie\",\n  \"LabelExpandSubSeries\": \"Udfold underserie\",\n  \"LabelExpired\": \"Udløbet\",\n  \"LabelExpiresAt\": \"Udløbsdato\",\n  \"LabelExpiresInSeconds\": \"Udløber om (seconds)\",\n  \"LabelExpiresNever\": \"Aldrig\",\n  \"LabelExplicit\": \"Eksplisit\",\n  \"LabelExplicitChecked\": \"Eksplicit (markeret)\",\n  \"LabelExplicitUnchecked\": \"Ikke eksplicit (ikke markeret)\",\n  \"LabelExportOPML\": \"Eksport OPML\",\n  \"LabelFeedURL\": \"Feed URL\",\n  \"LabelFetchingMetadata\": \"Henter metadata\",\n  \"LabelFile\": \"Fil\",\n  \"LabelFileBirthtime\": \"Oprettelsestidspunkt for fil\",\n  \"LabelFileBornDate\": \"Født {0}\",\n  \"LabelFileModified\": \"Fil ændret\",\n  \"LabelFileModifiedDate\": \"Opdateret {0}\",\n  \"LabelFilename\": \"Filnavn\",\n  \"LabelFilterByUser\": \"Filtrér efter bruger\",\n  \"LabelFindEpisodes\": \"Find episoder\",\n  \"LabelFinished\": \"Færdig\",\n  \"LabelFinishedDate\": \"Færdig {0}\",\n  \"LabelFolder\": \"Mappe\",\n  \"LabelFolders\": \"Mapper\",\n  \"LabelFontBold\": \"Fed\",\n  \"LabelFontBoldness\": \"Skrift tykkelse\",\n  \"LabelFontFamily\": \"Skrifttypefamilie\",\n  \"LabelFontItalic\": \"Kursiv\",\n  \"LabelFontScale\": \"Skriftstørrelse\",\n  \"LabelFontStrikethrough\": \"Gennemstreget\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Fuld\",\n  \"LabelGenre\": \"Genre\",\n  \"LabelGenres\": \"Genrer\",\n  \"LabelHardDeleteFile\": \"Permanent slet fil\",\n  \"LabelHasEbook\": \"Har e-bog\",\n  \"LabelHasSupplementaryEbook\": \"Har supplerende e-bog\",\n  \"LabelHideSubtitles\": \"Skjul undertitler\",\n  \"LabelHighestPriority\": \"Højeste prioritet\",\n  \"LabelHost\": \"Vært\",\n  \"LabelHour\": \"Time\",\n  \"LabelHours\": \"Timer\",\n  \"LabelIcon\": \"Ikon\",\n  \"LabelImageURLFromTheWeb\": \"Billede URL fra nettet\",\n  \"LabelInProgress\": \"I gang\",\n  \"LabelIncludeInTracklist\": \"Inkluder i afspilningsliste\",\n  \"LabelIncomplete\": \"Ufuldstændig\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Tilpasset dagligt/ugentligt\",\n  \"LabelIntervalEvery12Hours\": \"Hver 12. time\",\n  \"LabelIntervalEvery15Minutes\": \"Hver 15. minut\",\n  \"LabelIntervalEvery2Hours\": \"Hver 2. time\",\n  \"LabelIntervalEvery30Minutes\": \"Hver 30. minut\",\n  \"LabelIntervalEvery6Hours\": \"Hver 6. time\",\n  \"LabelIntervalEveryDay\": \"Hver dag\",\n  \"LabelIntervalEveryHour\": \"Hver time\",\n  \"LabelIntervalEveryMinute\": \"Hvert minut\",\n  \"LabelInvert\": \"Inverter\",\n  \"LabelItem\": \"Element\",\n  \"LabelJumpBackwardAmount\": \"Spring bagud mængde\",\n  \"LabelJumpForwardAmount\": \"Spring fremad mængde\",\n  \"LabelLanguage\": \"Sprog\",\n  \"LabelLanguageDefaultServer\": \"Standard server sprog\",\n  \"LabelLanguages\": \"Sprog\",\n  \"LabelLastBookAdded\": \"Senest tilføjede bog\",\n  \"LabelLastBookUpdated\": \"Senest opdaterede bog\",\n  \"LabelLastProgressDate\": \"Sidste fremgang: {0}\",\n  \"LabelLastSeen\": \"Sidst set\",\n  \"LabelLastTime\": \"Sidste gang\",\n  \"LabelLastUpdate\": \"Seneste opdatering\",\n  \"LabelLayout\": \"Layout\",\n  \"LabelLayoutSinglePage\": \"Enkeltside\",\n  \"LabelLayoutSplitPage\": \"Opdelt side\",\n  \"LabelLess\": \"Mindre\",\n  \"LabelLibrariesAccessibleToUser\": \"Biblioteker tilgængelige for bruger\",\n  \"LabelLibrary\": \"Bibliotek\",\n  \"LabelLibraryFilterSublistEmpty\": \"Nej {0}\",\n  \"LabelLibraryItem\": \"Bibliotekselement\",\n  \"LabelLibraryName\": \"Biblioteksnavn\",\n  \"LabelLibrarySortByProgress\": \"Fremgang: Sidst opdateret\",\n  \"LabelLibrarySortByProgressFinished\": \"Fremgang: Afsluttet\",\n  \"LabelLibrarySortByProgressStarted\": \"Fremgang: Startet\",\n  \"LabelLimit\": \"Grænse\",\n  \"LabelLineSpacing\": \"Linjeafstand\",\n  \"LabelListenAgain\": \"Lyt igen\",\n  \"LabelLogLevelDebug\": \"Fejlsøgning\",\n  \"LabelLogLevelInfo\": \"Information\",\n  \"LabelLogLevelWarn\": \"Advarsel\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Søg efter nye episoder efter denne dato\",\n  \"LabelLowestPriority\": \"Laveste prioritet\",\n  \"LabelMatchConfidence\": \"Confidens\",\n  \"LabelMatchExistingUsersBy\": \"Match eksisterende brugere ved\",\n  \"LabelMatchExistingUsersByDescription\": \"Anvendt for at forbinde brugere. Når forbundet, brugere vil blive matchet ved unikt id fra din SSO udbyder\",\n  \"LabelMaxEpisodesToDownload\": \"Max # afsnit for at downloade. Anvend 0 for ubegrænset.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Max # afsnit til at downloade per check\",\n  \"LabelMaxEpisodesToKeep\": \"Max # afsnit at beholde\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Værdi af 0 sætter intet maks begrænsning. After et nyt afsnit er automatisk downloaded vil det ældste afsnit blive slettet hvis du har mere end X afsnit. Dette vil kun slette 1 afsnit for hvert nye download.\",\n  \"LabelMediaPlayer\": \"Medieafspiller\",\n  \"LabelMediaType\": \"Medietype\",\n  \"LabelMetaTag\": \"Meta-tag\",\n  \"LabelMetaTags\": \"Meta-tags\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Højeste prioritet metadata kilder vil overskrive de lavest prioriterede metadata kilder\",\n  \"LabelMetadataProvider\": \"Metadataudbyder\",\n  \"LabelMinute\": \"Minut\",\n  \"LabelMinutes\": \"Minutter\",\n  \"LabelMissing\": \"Mangler\",\n  \"LabelMissingEbook\": \"Har ingen ebog\",\n  \"LabelMissingSupplementaryEbook\": \"Har ingen tillægsbog\",\n  \"LabelMobileRedirectURIs\": \"Godkendte mobil redirect URI'er\",\n  \"LabelMobileRedirectURIsDescription\": \"Dete vil whiteliste en gyldig omdirigerings URL for mobile apps. Den standarde er <code>audiobookshelf://oauth</code> som du kan fjerne eller supplere med flere URI'er for tredjeparts app integration. Anvend en stjerne (<code>*</code>) som den eneste indstilling for at tilade en hvilkensomhelst URI.\",\n  \"LabelMore\": \"Mere\",\n  \"LabelMoreInfo\": \"Mere info\",\n  \"LabelName\": \"Navn\",\n  \"LabelNarrator\": \"Fortæller\",\n  \"LabelNarrators\": \"Fortællere\",\n  \"LabelNew\": \"Ny\",\n  \"LabelNewPassword\": \"Ny adgangskode\",\n  \"LabelNewestAuthors\": \"Nyeste forfattere\",\n  \"LabelNewestEpisodes\": \"Nyeste episoder\",\n  \"LabelNextBackupDate\": \"Næste sikkerhedskopi dato\",\n  \"LabelNextChapters\": \"Næste kapitler vil være:\",\n  \"LabelNextScheduledRun\": \"Næste planlagte kørsel\",\n  \"LabelNoApiKeys\": \"Ingen API-nøgler\",\n  \"LabelNoCustomMetadataProviders\": \"Ingen brugerdefinerede metadata udbydere\",\n  \"LabelNoEpisodesSelected\": \"Ingen episoder valgt\",\n  \"LabelNotFinished\": \"Ikke færdig\",\n  \"LabelNotStarted\": \"Ikke påbegyndt\",\n  \"LabelNotes\": \"Noter\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL'er\",\n  \"LabelNotificationAvailableVariables\": \"Tilgængelige variabler\",\n  \"LabelNotificationBodyTemplate\": \"Kropsskabelon\",\n  \"LabelNotificationEvent\": \"Meddelelseshændelse\",\n  \"LabelNotificationTitleTemplate\": \"Titelskabelon\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maksimalt antal mislykkede forsøg\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Meddelelser deaktiveres, når de mislykkes med at sende så mange gange\",\n  \"LabelNotificationsMaxQueueSize\": \"Maksimal køstørrelse for meddelelseshændelser\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Hændelser begrænses til at udløse en gang pr. sekund. Hændelser ignoreres, hvis køen er fyldt. Dette forhindrer meddelelsesspam.\",\n  \"LabelNumberOfBooks\": \"Antal bøger\",\n  \"LabelNumberOfChapters\": \"Antal kapitler:\",\n  \"LabelNumberOfEpisodes\": \"# afsnit\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Navnet af OpenID claimet som indeholder avancerede brugerhandlinger inden i applikationen som vil gælde for ikke administrative roller (<b>hvis konfigureret</b>). Hvis et claim mangler fra svaret vil adgang til ABS blive nægtet. Hvis en enkelt indstilling/option mangler, vil det bliver behandlet som <code>false</code>. Sørg for at identity provider's claim matcher den forventede struktur:\",\n  \"LabelOpenIDClaims\": \"Efterlad de følgende indstillinger tomme for at deaktivere avanceret gruppe og adgangsindstilling, ved automatisk at assigne 'Bruger' grupper.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Navnet af det OpenID claim som skal indeholde brugerens grupper. Mest kendt som <code>groups</code>. <b>hvis konfigureret</b>, vil applikationen automatiske tildele roller baseret p[ brugerens gruppemedlemsskaber, givet disse grupper er navngivet (uden forbehold for store og små bogstaver) 'admin', 'user' eller 'guest' i claimet. Claimet burde indeholde en liste (og hvis brugeren tilhøre flere grupper) som applikationen vil tildele roller med højeste adgangsnvieau. Hvis ingen grupper matcher vil adgang blive nægtet.\",\n  \"LabelOpenRSSFeed\": \"Åbn RSS-feed\",\n  \"LabelOverwrite\": \"Overskriv\",\n  \"LabelPaginationPageXOfY\": \"Side {0} af {1}\",\n  \"LabelPassword\": \"Adgangskode\",\n  \"LabelPath\": \"Sti\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Kan få adgang til alle biblioteker\",\n  \"LabelPermissionsAccessAllTags\": \"Kan få adgang til alle tags\",\n  \"LabelPermissionsAccessExplicitContent\": \"Kan få adgang til eksplicit indhold\",\n  \"LabelPermissionsCreateEreader\": \"Kan oprette elæser\",\n  \"LabelPermissionsDelete\": \"Kan slette\",\n  \"LabelPermissionsDownload\": \"Kan downloade\",\n  \"LabelPermissionsUpdate\": \"Kan opdatere\",\n  \"LabelPermissionsUpload\": \"Kan uploade\",\n  \"LabelPersonalYearReview\": \"Dit år i review ({0})\",\n  \"LabelPhotoPathURL\": \"Foto sti/URL\",\n  \"LabelPlayMethod\": \"Afspilningsmetode\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Afspilningshastighed øges/sænkes med\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} af {1}\",\n  \"LabelPlaylists\": \"Afspilningslister\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast søgeområde\",\n  \"LabelPodcastType\": \"Podcast type\",\n  \"LabelPodcasts\": \"Podcast\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)\",\n  \"LabelPreventIndexing\": \"Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger\",\n  \"LabelPrimaryEbook\": \"Primær e-bog\",\n  \"LabelProgress\": \"Fremskridt\",\n  \"LabelProvider\": \"Udbyder\",\n  \"LabelProviderAuthorizationValue\": \"Authorization Header værdi\",\n  \"LabelPubDate\": \"Udgivelsesdato\",\n  \"LabelPublishYear\": \"Udgivelsesår\",\n  \"LabelPublishedDate\": \"Publiceret {0}\",\n  \"LabelPublishedDecade\": \"Publiceret årti\",\n  \"LabelPublishedDecades\": \"Publiceret årtier\",\n  \"LabelPublisher\": \"Forlag\",\n  \"LabelPublishers\": \"Forlag\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Brugerdefineret ejerens e-mail\",\n  \"LabelRSSFeedCustomOwnerName\": \"Brugerdefineret ejerens navn\",\n  \"LabelRSSFeedOpen\": \"RSS-feed åbent\",\n  \"LabelRSSFeedPreventIndexing\": \"Forhindrer indeksering\",\n  \"LabelRSSFeedSlug\": \"RSS-feed-slug\",\n  \"LabelRSSFeedURL\": \"RSS-feed-URL\",\n  \"LabelRandomly\": \"Tilfældigt\",\n  \"LabelReAddSeriesToContinueListening\": \"Gentilføj serier til Fortsæt Lytning\",\n  \"LabelRead\": \"Læst\",\n  \"LabelReadAgain\": \"Læs Igen\",\n  \"LabelReadEbookWithoutProgress\": \"Læs e-bog uden at følge fremskridt\",\n  \"LabelRecentSeries\": \"Seneste serier\",\n  \"LabelRecentlyAdded\": \"Senest tilføjet\",\n  \"LabelRecommended\": \"Anbefalet\",\n  \"LabelRedo\": \"Gøre igen\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Udgivelsesdato\",\n  \"LabelRemoveAllMetadataAbs\": \"Fjern alle metadata.abs filer\",\n  \"LabelRemoveAllMetadataJson\": \"Fjern alle metadata.json filer\",\n  \"LabelRemoveAudibleBranding\": \"Fjern Audible intro og outro fra kapitler\",\n  \"LabelRemoveCover\": \"Fjern omslag\",\n  \"LabelRemoveMetadataFile\": \"Fjern alle metadata filer i biblioteksmapper\",\n  \"LabelRemoveMetadataFileHelp\": \"Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.\",\n  \"LabelRowsPerPage\": \"Rækker per side\",\n  \"LabelSearchTerm\": \"Søgeterm\",\n  \"LabelSearchTitle\": \"Søg efter titel\",\n  \"LabelSearchTitleOrASIN\": \"Søg efter titel eller ASIN\",\n  \"LabelSeason\": \"Sæson\",\n  \"LabelSeasonNumber\": \"Sæson {0}\",\n  \"LabelSelectAll\": \"Vælg alle\",\n  \"LabelSelectAllEpisodes\": \"Vælg alle episoder\",\n  \"LabelSelectEpisodesShowing\": \"Vælg {0} episoder vist\",\n  \"LabelSelectUser\": \"Vælg bruger\",\n  \"LabelSelectUsers\": \"Valgte brugere\",\n  \"LabelSendEbookToDevice\": \"Send e-bog til...\",\n  \"LabelSequence\": \"Sekvens\",\n  \"LabelSerial\": \"Seriel\",\n  \"LabelSeries\": \"Serie\",\n  \"LabelSeriesName\": \"Serienavn\",\n  \"LabelSeriesProgress\": \"Seriefremskridt\",\n  \"LabelServerLogLevel\": \"Server log niveau\",\n  \"LabelServerYearReview\": \"Server år i review ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Indstil som primær\",\n  \"LabelSetEbookAsSupplementary\": \"Indstil som supplerende\",\n  \"LabelSettingsAllowIframe\": \"Tillad embedding i en iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Kun lydbøger\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorfisk design med træhylder\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast-understøttelse\",\n  \"LabelSettingsDateFormat\": \"Datoformat\",\n  \"LabelSettingsEnableWatcher\": \"Automatisk biblioteksovervåger\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Automatisk biblioteksovervåger\",\n  \"LabelSettingsEnableWatcherHelp\": \"Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Tillad scriptet indhold i epub\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Tillad epub filer at køre scripts. Det anbefales at holde denne indstilling deaktiveret med mindre du stoler på kilderne af epub filerne.\",\n  \"LabelSettingsExperimentalFeatures\": \"Eksperimentelle funktioner\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.\",\n  \"LabelSettingsFindCovers\": \"Find omslag\",\n  \"LabelSettingsFindCoversHelp\": \"Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge scanntiden\",\n  \"LabelSettingsHideSingleBookSeries\": \"Skjul enkeltbogsserier\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Brug bogreolvisning på startside\",\n  \"LabelSettingsLibraryBookshelfView\": \"Brug bogreolvisning i biblioteket\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Procent gennemført er større end\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Tid tilbage er mindre end (sekunder)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Marker medie indhold som færdigt når\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Spring tidligere bøger i Fortsæt serie over\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Fortsæt Serien siden viser den første bog som ikke er startet i serier med mindst en bog som ikke er startet og hvor ingen bøger i gang. Aktivering af denne indstilling vil fortsætte serien fra den sidst gennemførte bog i stedet for fra den første ikke startede bog.\",\n  \"LabelSettingsParseSubtitles\": \"Fortolk undertitler\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Udtræk undertekster fra lydbogsmappenavne.<br>Undertitler skal adskilles af \\\" - \\\"<br>f.eks. \\\"Bogtitel - En undertitel her\\\" har undertitlen \\\"En undertitel her\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Foretræk matchede metadata\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Ignorer matchende bøger, der allerede har en ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Ignorer matchende bøger, som allerede har et ISBN-nummer\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorer præfikser ved sortering\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"f.eks. for præfikset \\\"the\\\" vil bogtitlen \\\"The Book Title\\\" blive sorteret som \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Brug kvadratiske bogomslag\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Foretræk at bruge kvadratiske omslag frem for standard 1,6:1 bogomslag\",\n  \"LabelSettingsStoreCoversWithItem\": \"Gem omslag med element\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Som standard gemmes omslag i /metadata/items, aktivering af denne indstilling vil gemme omslag i mappen for dit bibliotekselement. Kun én fil med navnet \\\"cover\\\" vil blive bevaret\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Gem metadata med element\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper\",\n  \"LabelSettingsTimeFormat\": \"Tidsformat\",\n  \"LabelShare\": \"Del\",\n  \"LabelShareDownloadableHelp\": \"Tillad brugere at dele link til at downloade en zip fil af dette biblioteksindhold.\",\n  \"LabelShareOpen\": \"Del åben\",\n  \"LabelShareURL\": \"Del URL\",\n  \"LabelShowAll\": \"Vis alle\",\n  \"LabelShowSeconds\": \"Vis sekunder\",\n  \"LabelShowSubtitles\": \"Vis undertitler\",\n  \"LabelSize\": \"Størrelse\",\n  \"LabelSleepTimer\": \"Søvntimer\",\n  \"LabelSlug\": \"Snegl\",\n  \"LabelSortAscending\": \"Stigende\",\n  \"LabelSortDescending\": \"Faldende\",\n  \"LabelSortPubDate\": \"Sortér Pub Dato\",\n  \"LabelStart\": \"Start\",\n  \"LabelStartTime\": \"Starttid\",\n  \"LabelStarted\": \"Startet\",\n  \"LabelStartedAt\": \"Startet klokken\",\n  \"LabelStartedDate\": \"Startet {0}\",\n  \"LabelStatsAudioTracks\": \"Lydspor\",\n  \"LabelStatsAuthors\": \"Forfattere\",\n  \"LabelStatsBestDay\": \"Bedste dag\",\n  \"LabelStatsDailyAverage\": \"Daglig gennemsnit\",\n  \"LabelStatsDays\": \"Dage\",\n  \"LabelStatsDaysListened\": \"Dage hørt\",\n  \"LabelStatsHours\": \"Timer\",\n  \"LabelStatsInARow\": \"i træk\",\n  \"LabelStatsItemsFinished\": \"Elementer færdige\",\n  \"LabelStatsItemsInLibrary\": \"Elementer i biblioteket\",\n  \"LabelStatsMinutes\": \"minutter\",\n  \"LabelStatsMinutesListening\": \"Minutter hørt\",\n  \"LabelStatsOverallDays\": \"Samlede dage\",\n  \"LabelStatsOverallHours\": \"Samlede timer\",\n  \"LabelStatsWeekListening\": \"Ugens lytning\",\n  \"LabelSubtitle\": \"Undertekst\",\n  \"LabelSupportedFileTypes\": \"Understøttede filtyper\",\n  \"LabelTag\": \"Mærke\",\n  \"LabelTags\": \"Mærker\",\n  \"LabelTagsAccessibleToUser\": \"Mærker tilgængelige for bruger\",\n  \"LabelTagsNotAccessibleToUser\": \"Mærker ikke tilgængelige for bruger\",\n  \"LabelTasks\": \"Kører opgaver\",\n  \"LabelTextEditorBulletedList\": \"Punktopstilling\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"Nummeropstilling\",\n  \"LabelTextEditorUnlink\": \"Aflink\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Mørk\",\n  \"LabelThemeLight\": \"Lys\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Tidsbase\",\n  \"LabelTimeDurationXHours\": \"{0} timer\",\n  \"LabelTimeDurationXMinutes\": \"{0} minutter\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekunder\",\n  \"LabelTimeInMinutes\": \"Tid i minutter\",\n  \"LabelTimeLeft\": \"{0} tilbage\",\n  \"LabelTimeListened\": \"Tid hørt\",\n  \"LabelTimeListenedToday\": \"Tid hørt i dag\",\n  \"LabelTimeRemaining\": \"{0} tilbage\",\n  \"LabelTimeToShift\": \"Tid til skift i sekunder\",\n  \"LabelTitle\": \"Titel\",\n  \"LabelToolsEmbedMetadata\": \"Indlejre metadata\",\n  \"LabelToolsEmbedMetadataDescription\": \"Indlejr metadata i lydfiler, inklusive omslag og kapitler.\",\n  \"LabelToolsM4bEncoder\": \"M4B indkoder\",\n  \"LabelToolsMakeM4b\": \"Lav M4B lydbogsfil\",\n  \"LabelToolsMakeM4bDescription\": \"Generer en .M4B lydbogsfil med indlejret metadata, omslag og kapitler.\",\n  \"LabelToolsSplitM4b\": \"Opdel M4B til MP3'er\",\n  \"LabelToolsSplitM4bDescription\": \"Opret MP3'er fra en M4B opdelt efter kapitler med indlejret metadata, omslag og kapitler.\",\n  \"LabelTotalDuration\": \"Samlet varighed\",\n  \"LabelTotalTimeListened\": \"Samlet lyttetid\",\n  \"LabelTrackFromFilename\": \"Spor fra filnavn\",\n  \"LabelTrackFromMetadata\": \"Spor fra metadata\",\n  \"LabelTracks\": \"Spor\",\n  \"LabelTracksMultiTrack\": \"Flerspors\",\n  \"LabelTracksNone\": \"Ingen spor\",\n  \"LabelTracksSingleTrack\": \"Enkeltspors\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Type\",\n  \"LabelUnabridged\": \"Uforkortet\",\n  \"LabelUndo\": \"Fortryd\",\n  \"LabelUnknown\": \"Ukendt\",\n  \"LabelUnknownPublishDate\": \"Ukendt publiceringsdato\",\n  \"LabelUpdateCover\": \"Opdater omslag\",\n  \"LabelUpdateCoverHelp\": \"Tillad overskrivning af eksisterende omslag for de valgte bøger, når der findes en match\",\n  \"LabelUpdateDetails\": \"Opdater detaljer\",\n  \"LabelUpdateDetailsHelp\": \"Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match\",\n  \"LabelUpdatedAt\": \"Opdateret ved\",\n  \"LabelUploaderDragAndDrop\": \"Træk og slip filer eller mapper\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Træk og slip filer\",\n  \"LabelUploaderDropFiles\": \"Smid filer\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automatisk hent titel, forfatter og serie\",\n  \"LabelUseAdvancedOptions\": \"Anvend avancerede indstillinger\",\n  \"LabelUseChapterTrack\": \"Brug kapitel-spor\",\n  \"LabelUseFullTrack\": \"Brug fuldt spor\",\n  \"LabelUseZeroForUnlimited\": \"Anvend 0 for ubegrænset\",\n  \"LabelUser\": \"Bruger\",\n  \"LabelUsername\": \"Brugernavn\",\n  \"LabelValue\": \"Værdi\",\n  \"LabelVersion\": \"Version\",\n  \"LabelViewBookmarks\": \"Se bogmærker\",\n  \"LabelViewChapters\": \"Se kapitler\",\n  \"LabelViewPlayerSettings\": \"Vis afspiller indstillinger\",\n  \"LabelViewQueue\": \"Se afspilningskø\",\n  \"LabelVolume\": \"Volumen\",\n  \"LabelWebRedirectURLsDescription\": \"Godkend disse URL'er i din OAuth udgiver for at tillade omdirigering tilbage til hjemmesiden efter login:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Undermapper for omdirigerings URL'er\",\n  \"LabelWeekdaysToRun\": \"Ugedage til kørsel\",\n  \"LabelXBooks\": \"{0} bøger\",\n  \"LabelXItems\": \"{0} genstande\",\n  \"LabelYearReviewHide\": \"Skjul år i review\",\n  \"LabelYearReviewShow\": \"Vis år i review\",\n  \"LabelYourAudiobookDuration\": \"Din lydbogsvarighed\",\n  \"LabelYourBookmarks\": \"Dine bogmærker\",\n  \"LabelYourPlaylists\": \"Dine spillelister\",\n  \"LabelYourProgress\": \"Din fremgang\",\n  \"MessageAddToPlayerQueue\": \"Tilføj til afspilningskø\",\n  \"MessageAppriseDescription\": \"For at bruge denne funktion skal du have en instans af <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Sikr dig at du bruger ASIN fra den korrekte Audible region, ikke Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Ældre API tokens vil blive fjernet i fremtiden. Brug <a href=\\\"/config/api-keys\\\">API-nøgler</a> i stedet.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.\",\n  \"MessageAuthenticationSecurityMessage\": \"Autentificeringen er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.\",\n  \"MessageBackupsDescription\": \"Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.\",\n  \"MessageBackupsLocationEditNote\": \"Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups\",\n  \"MessageBackupsLocationNoEditNote\": \"Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.\",\n  \"MessageBackupsLocationPathEmpty\": \"Backup sti kan ikke være tom\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Opret felter slået til med data fra alle genstande. Felter med flere værdier vil blive sammenflettet\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Opret kort med værdier der er slået til fra felter med data fra denne genstand\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.\",\n  \"MessageBookshelfNoCollections\": \"Du har ikke oprettet nogen samlinger endnu\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Samlinger er offentlige. Alle brugere med adgang til biblioteket kan se dem.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Ingen RSS-feeds er åbne\",\n  \"MessageBookshelfNoResultsForFilter\": \"Ingen resultater for filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Intet resultat for query\",\n  \"MessageBookshelfNoSeries\": \"Du har ingen serier\",\n  \"MessageBulkChapterPattern\": \"Hvor mange kapitler vil du tilføje med dette nummereringsmønster?\",\n  \"MessageChapterEndIsAfter\": \"Kapitelslutningen er efter slutningen af din lydbog\",\n  \"MessageChapterErrorFirstNotZero\": \"Første kapitel skal starte ved 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Ugyldig starttid skal være mindre end lydbogens varighed\",\n  \"MessageChapterErrorStartLtPrev\": \"Ugyldig starttid skal være større end eller lig med den foregående kapitels starttid\",\n  \"MessageChapterStartIsAfter\": \"Kapitelstarten er efter slutningen af din lydbog\",\n  \"MessageChaptersNotFound\": \"Kapitler ikke fundet\",\n  \"MessageCheckingCron\": \"Tjekker cron...\",\n  \"MessageConfirmCloseFeed\": \"Er du sikker på, at du vil lukke dette feed?\",\n  \"MessageConfirmDeleteApiKey\": \"Er du sikker på at du vil slette API-nøglen \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Er du sikker på, at du vil slette backup for {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Er du sikker på at du vil fjerne elæser enhed \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Dette vil slette filen fra dit filsystem. Er du sikker?\",\n  \"MessageConfirmDeleteLibrary\": \"Er du sikker på, at du vil slette biblioteket permanent \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Dette vil slette biblioteksgenstanden fra databasen og dit filsystem. Er du sikker?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Dette vil slette {0} biblioteksgenstande fra din database og filsystem. Er du sikker?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Er du sikker på at du vil fjerne brugerdefineret metadata udgiver \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Er du sikker på at du vil fjerne denne notifikation?\",\n  \"MessageConfirmDeleteSession\": \"Er du sikker på, at du vil slette denne session?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Er du sikker på at du vil indlejre metadata i {0} lydbogsfiler?\",\n  \"MessageConfirmForceReScan\": \"Er du sikker på, at du vil tvinge en genindlæsning?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Er du sikker på, at du vil markere alle episoder som afsluttet?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Er du sikker på, at du vil markere alle episoder som ikke afsluttet?\",\n  \"MessageConfirmMarkItemFinished\": \"Er du sikker på at du vil markere \\\"{0}\\\" som færdig?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Er du sikker på at du vil markere \\\"{0}\\\" som ikke færdige?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Er du sikker på, at du vil markere alle bøger i denne serie som afsluttet?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Er du sikker på, at du vil markere alle bøger i denne serie som ikke afsluttet?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Trigger denne notifikation med testdata?\",\n  \"MessageConfirmPurgeCache\": \"Rensning af cache vil slette hele mappen ved <code>/metadata/cache</code>.<br /><br />Er dy sikker på at du vil fjerne cache mappen?\",\n  \"MessageConfirmPurgeItemsCache\": \"Rensning af cache vil slette hele mappen ved <code>/metadata/cache/items</code>.<br />Er du sikker?\",\n  \"MessageConfirmQuickEmbed\": \"Advarsel! Hurtigindlejring vil ikke backe dine lydfiler op. S'rg for at du har en backup af dine lydfiler. <br /><br />Vil du fortsætte?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Hurtig match af afsnit vil overskrive detaljer givet et match kan findes. Kun ikke-matchede vil blive opdateret. Er du sikker?\",\n  \"MessageConfirmReScanLibraryItems\": \"Er du sikker på at du vil genscanne {0} genstande?\",\n  \"MessageConfirmRemoveAllChapters\": \"Er du sikker på, at du vil fjerne alle kapitler?\",\n  \"MessageConfirmRemoveAuthor\": \"Er du sikker på, at du vil fjerne forfatteren \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Er du sikker på, at du vil fjerne samlingen \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Er du sikker på, at du vil fjerne episoden \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Obs: Dette sletter ikke lydfilen medmindre \\\"Permanent sletning af fil\\\" er aktiveret\",\n  \"MessageConfirmRemoveEpisodes\": \"Er du sikker på, at du vil fjerne {0} episoder?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Er du sikker på at du vil fjerne {0} lytte sessioner?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Er du sikker på at du vil fjerne alle metadata.{0} filer i dine biblioteksfoldere?\",\n  \"MessageConfirmRemoveNarrator\": \"Er du sikker på, at du vil fjerne fortælleren \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Er du sikker på, at du vil fjerne din spilleliste \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Er du sikker på, at du vil omdøbe genre \\\"{0}\\\" til \\\"{1}\\\" for alle elementer?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Bemærk: Denne genre findes allerede, så de vil blive fusioneret.\",\n  \"MessageConfirmRenameGenreWarning\": \"Advarsel! En lignende genre med en anden skrivemåde eksisterer allerede \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Er du sikker på, at du vil omdøbe tag \\\"{0}\\\" til \\\"{1}\\\" for alle elementer?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Bemærk: Dette tag findes allerede, så de vil blive fusioneret.\",\n  \"MessageConfirmRenameTagWarning\": \"Advarsel! Et lignende tag med en anden skrivemåde eksisterer allerede \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Er du sikker på at du vil nulstille dit fremskridt?\",\n  \"MessageConfirmSendEbookToDevice\": \"Er du sikker på, at du vil sende {0} e-bog \\\"{1}\\\" til enhed \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Er du sikker på at du vil fjerne linket mellem denne bruger og OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dage lyttet i løbet af det sidste år\",\n  \"MessageDownloadingEpisode\": \"Downloader episode\",\n  \"MessageDragFilesIntoTrackOrder\": \"Træk filer ind i korrekt spororden\",\n  \"MessageEmbedFailed\": \"Indlejring fejlede!\",\n  \"MessageEmbedFinished\": \"Indlejring færdig!\",\n  \"MessageEmbedQueue\": \"Sat i kø for metadata indlejring ({0} i kø)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} episoder er sat i kø til download\",\n  \"MessageEreaderDevices\": \"For at sikre levering af ebøger, skal du eventuelt tilføje mailadressen som en gyldig afsender for hver enhed angivet forneden.\",\n  \"MessageFeedURLWillBe\": \"Feed-URL vil være {0}\",\n  \"MessageFetching\": \"Henter...\",\n  \"MessageForceReScanDescription\": \"vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} lytter</strong> på {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Ingen lyttesessioner på {0}\",\n  \"MessageImportantNotice\": \"Vigtig besked!\",\n  \"MessageInsertChapterBelow\": \"Indsæt kapitel nedenfor\",\n  \"MessageInvalidAsin\": \"Ugyldig ASIN\",\n  \"MessageItemsSelected\": \"{0} elementer valgt\",\n  \"MessageItemsUpdated\": \"{0} elementer opdateret\",\n  \"MessageJoinUsOn\": \"Deltag i os på\",\n  \"MessageLoading\": \"Indlæser...\",\n  \"MessageLoadingFolders\": \"Indlæser mapper...\",\n  \"MessageLogsDescription\": \"Logfiler er gemt i <code>/metadata/logs</code> som JSON filer. Crash log er gemt i <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B mislykkedes!\",\n  \"MessageM4BFinished\": \"M4B afsluttet!\",\n  \"MessageMapChapterTitles\": \"Tilknyt kapiteloverskrifter til dine eksisterende lydbogskapitler uden at justere tidsstempler\",\n  \"MessageMarkAllEpisodesFinished\": \"Markér alle episoder som afsluttet\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Markér alle episoder som ikke afsluttet\",\n  \"MessageMarkAsFinished\": \"Markér som afsluttet\",\n  \"MessageMarkAsNotFinished\": \"Markér som ikke afsluttet\",\n  \"MessageMatchBooksDescription\": \"vil forsøge at matche bøger i biblioteket med en bog fra den valgte søgeudbyder og udfylde tomme detaljer og omslag. Overskriver ikke detaljer.\",\n  \"MessageNoAudioTracks\": \"Ingen lydspor\",\n  \"MessageNoAuthors\": \"Ingen forfattere\",\n  \"MessageNoBackups\": \"Ingen sikkerhedskopier\",\n  \"MessageNoBookmarks\": \"Ingen bogmærker\",\n  \"MessageNoChapters\": \"Ingen kapitler\",\n  \"MessageNoCollections\": \"Ingen samlinger\",\n  \"MessageNoCoversFound\": \"Ingen omslag fundet\",\n  \"MessageNoDescription\": \"Ingen beskrivelse\",\n  \"MessageNoDevices\": \"Ingen enheder\",\n  \"MessageNoDownloadsInProgress\": \"Ingen downloads i gang lige nu\",\n  \"MessageNoDownloadsQueued\": \"Ingen downloads i kø\",\n  \"MessageNoEpisodeMatchesFound\": \"Ingen episode-matcher fundet\",\n  \"MessageNoEpisodes\": \"Ingen episoder\",\n  \"MessageNoFoldersAvailable\": \"Ingen mapper tilgængelige\",\n  \"MessageNoGenres\": \"Ingen genrer\",\n  \"MessageNoIssues\": \"Ingen problemer\",\n  \"MessageNoItems\": \"Ingen elementer\",\n  \"MessageNoItemsFound\": \"Ingen elementer fundet\",\n  \"MessageNoListeningSessions\": \"Ingen lyttesessioner\",\n  \"MessageNoLogs\": \"Ingen logfiler\",\n  \"MessageNoMediaProgress\": \"Ingen medieforløb\",\n  \"MessageNoNotifications\": \"Ingen meddelelser\",\n  \"MessageNoPodcastFeed\": \"Invalid podcast: Intet feed\",\n  \"MessageNoPodcastsFound\": \"Ingen podcasts fundet\",\n  \"MessageNoResults\": \"Ingen resultater\",\n  \"MessageNoSearchResultsFor\": \"Ingen søgeresultater for \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Ingen serier\",\n  \"MessageNoTags\": \"Ingen tags\",\n  \"MessageNoTasksRunning\": \"Ingen opgaver kører\",\n  \"MessageNoUpdatesWereNecessary\": \"Ingen opdateringer var nødvendige\",\n  \"MessageNoUserPlaylists\": \"Du har ingen afspilningslister\",\n  \"MessageNoUserPlaylistsHelp\": \"Playlister er private. Kun brugere som opretter dem kan se dem.\",\n  \"MessageNotYetImplemented\": \"Endnu ikke implementeret\",\n  \"MessageOpmlPreviewNote\": \"Note: Dette er en forhåndsvisning af den indlæste OPML fil. Podcast titel vil blive taget fra RSS feedet.\",\n  \"MessageOr\": \"eller\",\n  \"MessagePauseChapter\": \"Pause kapitelafspilning\",\n  \"MessagePlayChapter\": \"Lyt til begyndelsen af kapitlet\",\n  \"MessagePlaylistCreateFromCollection\": \"Opret afspilningsliste fra samling\",\n  \"MessagePleaseWait\": \"Vent venligst...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast har ingen RSS-feed-URL at bruge til matchning\",\n  \"MessagePodcastSearchField\": \"Indtast søgeterm eller RSS URL\",\n  \"MessageQuickEmbedInProgress\": \"Hurtig indlejring igang\",\n  \"MessageQuickEmbedQueue\": \"I kø for hurtigindlejring ({0} i kø)\",\n  \"MessageQuickMatchAllEpisodes\": \"Hurtig match alle afsnit\",\n  \"MessageQuickMatchDescription\": \"Udfyld tomme elementoplysninger og omslag med første matchresultat fra '{0}'. Overskriver ikke oplysninger, medmindre serverindstillingen 'Foretræk matchet metadata' er aktiveret.\",\n  \"MessageRemoveChapter\": \"Fjern kapitel\",\n  \"MessageRemoveEpisodes\": \"Fjern {0} episode(r)\",\n  \"MessageRemoveFromPlayerQueue\": \"Fjern fra afspillingskøen\",\n  \"MessageRemoveUserWarning\": \"Er du sikker på, at du vil slette brugeren permanent \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Rapporter fejl, anmod om funktioner og bidrag på\",\n  \"MessageResetChaptersConfirm\": \"Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?\",\n  \"MessageRestoreBackupConfirm\": \"Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den\",\n  \"MessageRestoreBackupWarning\": \"Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.\",\n  \"MessageScheduleLibraryScanNote\": \"For de fleste brugere er det anbefalet, at efterlade denne funktion deaktiveret, og lade biblioteksovervågeren være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågeren ikke virker med dit filsystem (f. eks. NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Kør hvert {0} af {1}\",\n  \"MessageSearchResultsFor\": \"Søgeresultater for\",\n  \"MessageSelected\": \"{0} valgt\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Serie sekvens kan ikke indeholde mellemrum\",\n  \"MessageServerCouldNotBeReached\": \"Serveren kunne ikke nås\",\n  \"MessageSetChaptersFromTracksDescription\": \"Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn\",\n  \"MessageShareExpirationWillBe\": \"Udløb vil være <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Udløber om {0}\",\n  \"MessageShareURLWillBe\": \"Del URL vil være <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Start afspilning for \\\"{0}\\\" kl. {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Lydbogsfil \\\"{0}\\\" er ikke skrivebar\",\n  \"MessageTaskCanceledByUser\": \"Opgave annulleret af bruger\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Download afsnit \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Indlejring af metadata\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Indlejring af metadata i lydbog \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Indkodning M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Indkodning lydog \\\"{0}\\\" ind i en enkelt M4B fil\",\n  \"MessageTaskFailed\": \"Fejlet\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Fejlede backup af lydbogsfil \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Fejlede at oprette cache mappe\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Fejlede at indkode metadata i fil \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Fejlede at sammenflette lydbogsfiler\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Fejlede i at flytte M4B fil\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Fejlede i at skrive metadata fil\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Matchede bøger i bibliotek \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Ingen filer at scanne\",\n  \"MessageTaskOpmlImport\": \"OPML import\",\n  \"MessageTaskOpmlImportDescription\": \"Oprettelse af podcasts fra {0} RSS feeds\",\n  \"MessageTaskOpmlImportFeed\": \"OPML importering fejlede\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importering af RSS feed \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Fejlede at hente podcast feed\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Opretter podcast \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast ligger allerede på filsti\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Fejlede i at oprette podcast\",\n  \"MessageTaskOpmlImportFinished\": \"Tilføjede {0} podcasts\",\n  \"MessageTaskOpmlParseFailed\": \"Fejlede i at læse OPML fil\",\n  \"MessageTaskOpmlParseFastFail\": \"Forkert OPML <opml> tag ikke fundet ELLER et <outline> tag var ikke fundet\",\n  \"MessageTaskOpmlParseNoneFound\": \"Ingen feeds fundet i OPML fil\",\n  \"MessageTaskScanItemsAdded\": \"{0} tilføjet\",\n  \"MessageTaskScanItemsMissing\": \"{0} mangler\",\n  \"MessageTaskScanItemsUpdated\": \"{0} opdateret\",\n  \"MessageTaskScanNoChangesNeeded\": \"Ingen ændringer nødvendigt\",\n  \"MessageTaskScanningFileChanges\": \"Scanner filændringer i \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Scanning af \\\"{0}\\\" bibliotek\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Mål sti er ikke skrivebar\",\n  \"MessageThinking\": \"Tænker...\",\n  \"MessageUploaderItemFailed\": \"Fejl ved upload\",\n  \"MessageUploaderItemSuccess\": \"Uploadet med succes!\",\n  \"MessageUploading\": \"Uploader...\",\n  \"MessageValidCronExpression\": \"Gyldigt cron-udtryk\",\n  \"MessageWatcherIsDisabledGlobally\": \"Watcher er deaktiveret globalt i serverindstillinger\",\n  \"MessageXLibraryIsEmpty\": \"{0} bibliotek er tomt!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Din lydbogsvarighed er længere end den fundne varighed\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Din lydbogsvarighed er kortere end den fundne varighed\",\n  \"NoteChangeRootPassword\": \"Root-brugeren er den eneste bruger, der kan have en tom adgangskode\",\n  \"NoteChapterEditorTimes\": \"Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.\",\n  \"NoteFolderPicker\": \"Bemærk: Mapper, der allerede er mappet, vises ikke\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mapper med mediefiler håndteres som separate bibliotekselementer.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Hvis du kun uploader lydfiler, håndteres hver lydfil som en separat lydbog.\",\n  \"NoteUploaderUnsupportedFiles\": \"Ikke-understøttede filer ignoreres. Når du vælger eller slipper en mappe, ignoreres andre filer, der ikke er i en emnemappe.\",\n  \"NotificationOnBackupCompletedDescription\": \"Udløst når backup er færdig\",\n  \"NotificationOnBackupFailedDescription\": \"Udløst når backup fejler\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Udløst når et podcast afsnit er automatisk downloadet\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Aktiveret når automatiske episode-downloads er slået fra, på grund af for mange forsøg\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Aktiveret når anmodning om RSS-feedet fejler for en automatisk episode-download\",\n  \"NotificationOnTestDescription\": \"Event for test af notifikationssystemet\",\n  \"PlaceholderBulkChapterInput\": \"Indtast kapiteltitel eller brug nummerering (f.eks. 'Episode 1', 'Kapitel 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Nyt samlingnavn\",\n  \"PlaceholderNewFolderPath\": \"Ny mappes sti\",\n  \"PlaceholderNewPlaylist\": \"Nyt afspilningslistnavn\",\n  \"PlaceholderSearch\": \"Søg..\",\n  \"PlaceholderSearchEpisode\": \"Søg efter episode..\",\n  \"StatsAuthorsAdded\": \"forfattere tilføjet\",\n  \"StatsBooksAdded\": \"bøger tilføjet\",\n  \"StatsBooksAdditional\": \"Nogle tilføjelser inkludere…\",\n  \"StatsBooksFinished\": \"bøger færdige\",\n  \"StatsBooksFinishedThisYear\": \"Nogle bøger færdiggjort i år.…\",\n  \"StatsBooksListenedTo\": \"bøger lyttet til\",\n  \"StatsCollectionGrewTo\": \"Din bog kollektion voksede til…\",\n  \"StatsSessions\": \"sessioner\",\n  \"StatsSpentListening\": \"brugt at lytte\",\n  \"StatsTopAuthor\": \"TOP FORFATTER\",\n  \"StatsTopAuthors\": \"TOP FORFATTERE\",\n  \"StatsTopGenre\": \"TOP GENRE\",\n  \"StatsTopGenres\": \"TOP GENRER\",\n  \"StatsTopMonth\": \"TOP MÅNED\",\n  \"StatsTopNarrator\": \"TOP OPLÆSER\",\n  \"StatsTopNarrators\": \"TOP OPLÆSERE\",\n  \"StatsTotalDuration\": \"Med den totale varighed af…\",\n  \"StatsYearInReview\": \"ÅR I REVIEW\",\n  \"ToastAccountUpdateSuccess\": \"Konto opdateret\",\n  \"ToastAppriseUrlRequired\": \"Skal indtaste en Apprise URL\",\n  \"ToastAsinRequired\": \"ASIN er påkrævet\",\n  \"ToastAuthorImageRemoveSuccess\": \"Forfatterbillede fjernet\",\n  \"ToastAuthorNotFound\": \"Forfatter \\\"{0}\\\" ikke fundet\",\n  \"ToastAuthorRemoveSuccess\": \"Forfatter fjernet\",\n  \"ToastAuthorSearchNotFound\": \"Forfatter ikke fundet\",\n  \"ToastAuthorUpdateMerged\": \"Forfatter fusioneret\",\n  \"ToastAuthorUpdateSuccess\": \"Forfatter opdateret\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Forfatter opdateret (ingen billede fundet)\",\n  \"ToastBackupAppliedSuccess\": \"Backup indlæst\",\n  \"ToastBackupCreateFailed\": \"Mislykkedes oprettelse af sikkerhedskopi\",\n  \"ToastBackupCreateSuccess\": \"Sikkerhedskopi oprettet\",\n  \"ToastBackupDeleteFailed\": \"Mislykkedes sletning af sikkerhedskopi\",\n  \"ToastBackupDeleteSuccess\": \"Sikkerhedskopi slettet\",\n  \"ToastBackupInvalidMaxKeep\": \"Forkert antal backups at beholde\",\n  \"ToastBackupInvalidMaxSize\": \"Forkert maks backup størrelse\",\n  \"ToastBackupRestoreFailed\": \"Mislykkedes gendannelse af sikkerhedskopi\",\n  \"ToastBackupUploadFailed\": \"Mislykkedes upload af sikkerhedskopi\",\n  \"ToastBackupUploadSuccess\": \"Sikkerhedskopi uploadet\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Detaljer bekræftet på element\",\n  \"ToastBatchDeleteFailed\": \"Batch slet fejlede\",\n  \"ToastBatchDeleteSuccess\": \"Batch slet succes\",\n  \"ToastBatchQuickMatchFailed\": \"Batch Hurtig Match fejlede!\",\n  \"ToastBatchQuickMatchStarted\": \"Batch Hurtig Match af {0} bøger startet!\",\n  \"ToastBatchUpdateFailed\": \"Mislykkedes batchopdatering\",\n  \"ToastBatchUpdateSuccess\": \"Batchopdatering lykkedes\",\n  \"ToastBookmarkCreateFailed\": \"Mislykkedes oprettelse af bogmærke\",\n  \"ToastBookmarkCreateSuccess\": \"Bogmærke tilføjet\",\n  \"ToastBookmarkRemoveSuccess\": \"Bogmærke fjernet\",\n  \"ToastBulkChapterInvalidCount\": \"Indtast et tal mellem 1 og 150\",\n  \"ToastCachePurgeFailed\": \"Fejlede at opryde cache\",\n  \"ToastCachePurgeSuccess\": \"Cache ryddet op i succesfuldt\",\n  \"ToastChapterLocked\": \"Kapitel er låst.\",\n  \"ToastChapterStartTimeAdjusted\": \"Kapitelstarttid justeret med {0} sekunder\",\n  \"ToastChaptersAllLocked\": \"Alle kapitler er låst. Lås op for nogle kapitler for at ændre deres tider.\",\n  \"ToastChaptersHaveErrors\": \"Kapitler har fejl\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Ugyldig ændring. Det sidste kapitels starttid ville fortsætte længere end varigheden på denne lydbog.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Ugyldig ændring. Første kapitel ville have en længde på nul eller negativt og ville blive overskrevet af andet kapitel. Udvid startvarigheden på andet kapitel.\",\n  \"ToastChaptersMustHaveTitles\": \"Kapitler skal have titler\",\n  \"ToastChaptersRemoved\": \"Kapitler fjernet\",\n  \"ToastChaptersUpdated\": \"Kapitler opdateret\",\n  \"ToastCollectionItemsAddFailed\": \"Genstand(e) tilføjet til kollektion fejlet\",\n  \"ToastCollectionRemoveSuccess\": \"Samling fjernet\",\n  \"ToastCollectionUpdateSuccess\": \"Samling opdateret\",\n  \"ToastConnectionNotAvailable\": \"Forbindelse mislykkedes. Prøv igen senere\",\n  \"ToastCoverSearchFailed\": \"Cover-søgning mislykkedes\",\n  \"ToastCoverUpdateFailed\": \"Cover opdatering fejlede\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Dato og tid er ugyldig eller ufærdig\",\n  \"ToastDeleteFileFailed\": \"Sletning af fil fejlede\",\n  \"ToastDeleteFileSuccess\": \"Fil slettet\",\n  \"ToastDeviceAddFailed\": \"Tilføjelse af enhed Fejlede\",\n  \"ToastDeviceNameAlreadyExists\": \"E-læser enhed med det navn eksistere allerede\",\n  \"ToastDeviceTestEmailFailed\": \"Afsendelse af test mail fejlede\",\n  \"ToastDeviceTestEmailSuccess\": \"Test mail sendt\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Mail indstillinger opdateret\",\n  \"ToastEncodeCancelFailed\": \"Fejlede at afbryde indkodning\",\n  \"ToastEncodeCancelSucces\": \"Indkodning afbrudt\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Fejlede at rydde op i kø\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Afsnit download kø renset\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} afsnit opdateret\",\n  \"ToastErrorCannotShare\": \"Kan ikke dele på denne enhed\",\n  \"ToastFailedToCreate\": \"Oprettelsen mislykkedes\",\n  \"ToastFailedToDelete\": \"Sletning fejlede\",\n  \"ToastFailedToLoadData\": \"Indlæsning af data fejlede\",\n  \"ToastFailedToMatch\": \"Fejlet match\",\n  \"ToastFailedToShare\": \"Deling fejlede\",\n  \"ToastFailedToUpdate\": \"Fejlet opdatering\",\n  \"ToastInvalidImageUrl\": \"Ugyldig billede URL\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Ugyldigt maks afsnit at hente\",\n  \"ToastInvalidUrl\": \"Ugyldig URL\",\n  \"ToastInvalidUrls\": \"En eller flere URLer er ugyldige\",\n  \"ToastItemCoverUpdateSuccess\": \"Omslag opdateret\",\n  \"ToastItemDeletedFailed\": \"Sletning af genstand fejlede\",\n  \"ToastItemDeletedSuccess\": \"Genstand slettet\",\n  \"ToastItemDetailsUpdateSuccess\": \"Detaljer opdateret\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Markering som afsluttet mislykkedes\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Element markeret som afsluttet\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Markering som ikke afsluttet mislykkedes\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Element markeret som ikke afsluttet\",\n  \"ToastItemUpdateSuccess\": \"Genstand opdateret\",\n  \"ToastLibraryCreateFailed\": \"Oprettelse af bibliotek mislykkedes\",\n  \"ToastLibraryCreateSuccess\": \"Bibliotek \\\"{0}\\\" oprettet\",\n  \"ToastLibraryDeleteFailed\": \"Sletning af bibliotek mislykkedes\",\n  \"ToastLibraryDeleteSuccess\": \"Bibliotek slettet\",\n  \"ToastLibraryScanFailedToStart\": \"Start af skanning mislykkedes\",\n  \"ToastLibraryScanStarted\": \"Biblioteksskanning startet\",\n  \"ToastLibraryUpdateSuccess\": \"Bibliotek \\\"{0}\\\" opdateret\",\n  \"ToastMatchAllAuthorsFailed\": \"Fejlede at matche alle forfattere\",\n  \"ToastMetadataFilesRemovedError\": \"Fejlet at fjerne metadata.{0} filer\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Ingen metadata.{0} filer fundet i bibliotek\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Ingen metadata.{0} filer slettet\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} filer slettet\",\n  \"ToastMustHaveAtLeastOnePath\": \"Skal have mindst en sti\",\n  \"ToastNameEmailRequired\": \"Navn og email påkrævet\",\n  \"ToastNameRequired\": \"Navn påkrævet\",\n  \"ToastNewApiKeyUserError\": \"En bruger skal vælges\",\n  \"ToastNewEpisodesFound\": \"{0} nye afsnit fundet\",\n  \"ToastNewUserCreatedFailed\": \"Fejlede at oprette konto: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Ny konto oprettet\",\n  \"ToastNewUserLibraryError\": \"Skal vælge mindst et bibliotek\",\n  \"ToastNewUserPasswordError\": \"Skal have et password, kun root brugeren kan have et tomt password\",\n  \"ToastNewUserTagError\": \"Skal vælge mindst et tag\",\n  \"ToastNewUserUsernameError\": \"Angiv brugernavn\",\n  \"ToastNoNewEpisodesFound\": \"Ingen nye afsnit fundet\",\n  \"ToastNoRSSFeed\": \"Podcast har ingen RSS feed\",\n  \"ToastNoUpdatesNecessary\": \"Ingen opdateringer nødvendige\",\n  \"ToastNotificationCreateFailed\": \"Fejlede at oprette notifikation\",\n  \"ToastNotificationDeleteFailed\": \"Fejlede at slette notifikation\",\n  \"ToastNotificationFailedMaximum\": \"Maks forsøg skal være >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Maks notifikationskø skal være >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Notifikationsindstillinger opdateret\",\n  \"ToastNotificationTestTriggerFailed\": \"Fejlede at oprette en test notifikation\",\n  \"ToastNotificationTestTriggerSuccess\": \"Test notifikation oprettet\",\n  \"ToastNotificationUpdateSuccess\": \"Notifikation opdateret\",\n  \"ToastPlaylistCreateFailed\": \"Mislykkedes oprettelse af afspilningsliste\",\n  \"ToastPlaylistCreateSuccess\": \"Afspilningsliste oprettet\",\n  \"ToastPlaylistRemoveSuccess\": \"Afspilningsliste fjernet\",\n  \"ToastPlaylistUpdateSuccess\": \"Afspilningsliste opdateret\",\n  \"ToastPodcastCreateFailed\": \"Mislykkedes oprettelse af podcast\",\n  \"ToastPodcastCreateSuccess\": \"Podcast oprettet med succes\",\n  \"ToastPodcastEpisodeUpdated\": \"Episode opdateret\",\n  \"ToastPodcastGetFeedFailed\": \"Fejlede at hente podcast feed\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Ingen nye afsnit fundet i RSS feed\",\n  \"ToastPodcastNoRssFeed\": \"Podcast har ingen RSS feed\",\n  \"ToastProgressIsNotBeingSynced\": \"Fremskridt ikke synkroniseret, genstart afspilning\",\n  \"ToastProviderCreatedFailed\": \"Fejlede at tilføje udbyder\",\n  \"ToastProviderCreatedSuccess\": \"Ny udbyder tilføjet\",\n  \"ToastProviderNameAndUrlRequired\": \"Navn og URL påkrævet\",\n  \"ToastProviderRemoveSuccess\": \"Udbyder fjernet\",\n  \"ToastRSSFeedCloseFailed\": \"Mislykkedes lukning af RSS-feed\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-feed lukket\",\n  \"ToastRemoveFailed\": \"Fejlede at slette\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Mislykkedes fjernelse af element fra samling\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Element fjernet fra samling\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Fejlede at slette genstande med fejl\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Slettede genstande med fejl\",\n  \"ToastRenameFailed\": \"Fejlede at omdøbe\",\n  \"ToastRescanFailed\": \"Genscan fejlede for {0}\",\n  \"ToastRescanRemoved\": \"Genscan gennemført, genstand blev fjernet\",\n  \"ToastRescanUpToDate\": \"Genscan gennemført, genstand var opdateret\",\n  \"ToastRescanUpdated\": \"Genscan gennemført, genstand blev opdateret\",\n  \"ToastScanFailed\": \"Fejlede at scanne biblioteksgenstand\",\n  \"ToastSelectAtLeastOneUser\": \"Vælg mindst en bruger\",\n  \"ToastSendEbookToDeviceFailed\": \"Mislykkedes afsendelse af e-bog til enhed\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-bog afsendt til enhed \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Kan ikke tilføje to serier med samme navn\",\n  \"ToastSeriesUpdateFailed\": \"Mislykkedes opdatering af serie\",\n  \"ToastSeriesUpdateSuccess\": \"Serieopdatering lykkedes\",\n  \"ToastServerSettingsUpdateSuccess\": \"Server indstillinger opdateret\",\n  \"ToastSessionCloseFailed\": \"Luk session fejlede\",\n  \"ToastSessionDeleteFailed\": \"Mislykkedes sletning af session\",\n  \"ToastSessionDeleteSuccess\": \"Session slettet\",\n  \"ToastSleepTimerDone\": \"Sleep timer færdig... zZzzZz\",\n  \"ToastSlugMustChange\": \"Snegl indeholder ugyldige karakterer\",\n  \"ToastSlugRequired\": \"Snegl påkrævet\",\n  \"ToastSocketConnected\": \"Socket forbundet\",\n  \"ToastSocketDisconnected\": \"Socket afbrudt\",\n  \"ToastSocketFailedToConnect\": \"Socket kunne ikke oprettes\",\n  \"ToastSortingPrefixesEmptyError\": \"Skal indeholde mindst 1 sorteringspræfiks\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Sortering af præfiks opdateret ({0} genstande)\",\n  \"ToastTitleRequired\": \"Titel påkrævet\",\n  \"ToastUnknownError\": \"Ukendt fejl\",\n  \"ToastUnlinkOpenIdFailed\": \"Fejlede i af afkoble bruger fra OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Bruger afkoblet fra OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Filsti \\\"{0}\\\" findes allerede på serveren\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Genstand \\\"{0}\\\" benytter en undermappe af upload stien.\",\n  \"ToastUserDeleteFailed\": \"Mislykkedes sletning af bruger\",\n  \"ToastUserDeleteSuccess\": \"Bruger slettet\",\n  \"ToastUserPasswordChangeSuccess\": \"Password ændret\",\n  \"ToastUserPasswordMismatch\": \"Passwords passer ikke sammen\",\n  \"ToastUserPasswordMustChange\": \"Nyt password må ikke være det gamle\",\n  \"ToastUserRootRequireName\": \"Skal indholde et root brugernavn\",\n  \"TooltipAddChapters\": \"Tilføj kapitler\",\n  \"TooltipAddOneSecond\": \"Tilføj 1 sekund\",\n  \"TooltipAdjustChapterStart\": \"Klik for at ændre starttiden\",\n  \"TooltipLockAllChapters\": \"Lås alle kapitler\",\n  \"TooltipLockChapter\": \"Lås kapitel (Shift+click for at markere flere)\",\n  \"TooltipSubtractOneSecond\": \"Fratag 1 sekund\",\n  \"TooltipUnlockAllChapters\": \"Lås alle kapitaler op\",\n  \"TooltipUnlockChapter\": \"Lås kapitel op (Shift+click for at markere flere)\"\n}\n"
  },
  {
    "path": "client/strings/de.json",
    "content": "{\n  \"ButtonAdd\": \"Hinzufügen\",\n  \"ButtonAddApiKey\": \"API-Schlüssel hinzufügen\",\n  \"ButtonAddChapters\": \"Kapitel hinzufügen\",\n  \"ButtonAddDevice\": \"Gerät hinzufügen\",\n  \"ButtonAddLibrary\": \"Bibliothek hinzufügen\",\n  \"ButtonAddPodcasts\": \"Podcasts hinzufügen\",\n  \"ButtonAddUser\": \"Benutzer hinzufügen\",\n  \"ButtonAddYourFirstLibrary\": \"Erstelle deine erste Bibliothek\",\n  \"ButtonApply\": \"Übernehmen\",\n  \"ButtonApplyChapters\": \"Kapitel anwenden\",\n  \"ButtonAuthors\": \"Autoren\",\n  \"ButtonBack\": \"Zurück\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Auffüllen aus vorhandenem\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Kartendetails auffüllen\",\n  \"ButtonBrowseForFolder\": \"Ordner auswählen\",\n  \"ButtonCancel\": \"Abbrechen\",\n  \"ButtonCancelEncode\": \"Konvertierung abbrechen\",\n  \"ButtonChangeRootPassword\": \"Hauptpasswort ändern\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Überprüfe & lade neue Episoden herunter\",\n  \"ButtonChooseAFolder\": \"Wähle einen Ordner\",\n  \"ButtonChooseFiles\": \"Wähle eine Datei\",\n  \"ButtonClearFilter\": \"Filter löschen\",\n  \"ButtonClose\": \"Schließen\",\n  \"ButtonCloseFeed\": \"Feed schließen\",\n  \"ButtonCloseSession\": \"Offene Sitzung schließen\",\n  \"ButtonCollections\": \"Sammlungen\",\n  \"ButtonConfigureScanner\": \"Scannereinstellungen\",\n  \"ButtonCreate\": \"Erstellen\",\n  \"ButtonCreateBackup\": \"Sicherung erstellen\",\n  \"ButtonDelete\": \"Löschen\",\n  \"ButtonDownloadQueue\": \"Warteschlange\",\n  \"ButtonEdit\": \"Bearbeiten\",\n  \"ButtonEditChapters\": \"Kapitel bearbeiten\",\n  \"ButtonEditPodcast\": \"Podcast bearbeiten\",\n  \"ButtonEnable\": \"Aktivieren\",\n  \"ButtonFireAndFail\": \"Abschicken und fehlschlagen\",\n  \"ButtonFireOnTest\": \"Test-Event abfeuern\",\n  \"ButtonForceReScan\": \"Komplett-Scan (alle Medien)\",\n  \"ButtonFullPath\": \"Vollständiger Pfad\",\n  \"ButtonHide\": \"Ausblenden\",\n  \"ButtonHome\": \"Startseite\",\n  \"ButtonIssues\": \"Probleme\",\n  \"ButtonJumpBackward\": \"Zurück springen\",\n  \"ButtonJumpForward\": \"Vorwärts springen\",\n  \"ButtonLatest\": \"Neueste\",\n  \"ButtonLibrary\": \"Bibliothek\",\n  \"ButtonLogout\": \"Abmelden\",\n  \"ButtonLookup\": \"Online-Suche\",\n  \"ButtonManageTracks\": \"Tracks verwalten\",\n  \"ButtonMapChapterTitles\": \"Kapitelüberschriften zuordnen\",\n  \"ButtonMatchAllAuthors\": \"Online Metadaten-Abgleich (alle Autoren)\",\n  \"ButtonMatchBooks\": \"Online Metadaten-Abgleich (alle Medien)\",\n  \"ButtonNevermind\": \"Abbrechen\",\n  \"ButtonNext\": \"Vor\",\n  \"ButtonNextChapter\": \"Nächstes Kapitel\",\n  \"ButtonNextItemInQueue\": \"Das nächste Element in der Warteschlange\",\n  \"ButtonOk\": \"OK\",\n  \"ButtonOpenFeed\": \"Feed öffnen\",\n  \"ButtonOpenManager\": \"Manager öffnen\",\n  \"ButtonPause\": \"Pausieren\",\n  \"ButtonPlay\": \"Abspielen\",\n  \"ButtonPlayAll\": \"Alles abspielen\",\n  \"ButtonPlaying\": \"Spielt\",\n  \"ButtonPlaylists\": \"Wiedergabelisten\",\n  \"ButtonPrevious\": \"Zurück\",\n  \"ButtonPreviousChapter\": \"Vorheriges Kapitel\",\n  \"ButtonProbeAudioFile\": \"Audiodatei untersuchen\",\n  \"ButtonPurgeAllCache\": \"Cache leeren\",\n  \"ButtonPurgeItemsCache\": \"Lösche Medien-Cache\",\n  \"ButtonQueueAddItem\": \"Zur Warteschlange hinzufügen\",\n  \"ButtonQueueRemoveItem\": \"Aus der Warteschlange entfernen\",\n  \"ButtonQuickEmbed\": \"Schnelles Hinzufügen\",\n  \"ButtonQuickEmbedMetadata\": \"Schnelles Hinzufügen von Metadaten\",\n  \"ButtonQuickMatch\": \"Schnellabgleich\",\n  \"ButtonReScan\": \"Neu scannen\",\n  \"ButtonRead\": \"Lesen\",\n  \"ButtonReadLess\": \"Weniger Anzeigen\",\n  \"ButtonReadMore\": \"Mehr Anzeigen\",\n  \"ButtonRefresh\": \"Neu Laden\",\n  \"ButtonRemove\": \"Entfernen\",\n  \"ButtonRemoveAll\": \"Alles entfernen\",\n  \"ButtonRemoveAllLibraryItems\": \"Entferne alle Bibliothekseinträge\",\n  \"ButtonRemoveFromContinueListening\": \"Entferne den Eintrag aus der Fortsetzungsliste\",\n  \"ButtonRemoveFromContinueReading\": \"Entferne die Serie aus der Lesefortsetzungsliste\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Entferne die Serie aus der Serienfortsetzungsliste\",\n  \"ButtonReset\": \"Zurücksetzen\",\n  \"ButtonResetToDefault\": \"Zurücksetzen auf Standard\",\n  \"ButtonRestore\": \"Wiederherstellen\",\n  \"ButtonSave\": \"Speichern\",\n  \"ButtonSaveAndClose\": \"Speichern & Schließen\",\n  \"ButtonSaveTracklist\": \"Speichere die Titelliste\",\n  \"ButtonScan\": \"Scannen\",\n  \"ButtonScanLibrary\": \"Bibliothek scannen\",\n  \"ButtonScrollLeft\": \"Nach Links scrollen\",\n  \"ButtonScrollRight\": \"Nach Rechts scrollen\",\n  \"ButtonSearch\": \"Suchen\",\n  \"ButtonSelectFolderPath\": \"Ordnerpfad auswählen\",\n  \"ButtonSeries\": \"Serien\",\n  \"ButtonSetChaptersFromTracks\": \"Kapitelerstellung aus Audiodateien\",\n  \"ButtonShare\": \"Freigeben\",\n  \"ButtonShiftTimes\": \"Zeitverschiebung\",\n  \"ButtonShow\": \"Anzeigen\",\n  \"ButtonStartM4BEncode\": \"M4B-Kodierung starten\",\n  \"ButtonStartMetadataEmbed\": \"Metadateneinbettung starten\",\n  \"ButtonStats\": \"Statistiken\",\n  \"ButtonSubmit\": \"Absenden\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"OpenID trennen\",\n  \"ButtonUpload\": \"Hochladen\",\n  \"ButtonUploadBackup\": \"Sicherung hochladen\",\n  \"ButtonUploadCover\": \"Titelbild hochladen\",\n  \"ButtonUploadOPMLFile\": \"OPML-Datei hochladen\",\n  \"ButtonUserDelete\": \"Benutzer {0} löschen\",\n  \"ButtonUserEdit\": \"Benutzer {0} bearbeiten\",\n  \"ButtonViewAll\": \"Alles anzeigen\",\n  \"ButtonYes\": \"Ja\",\n  \"ErrorUploadFetchMetadataAPI\": \"Fehler beim Abrufen der Metadaten\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Metadaten konnten nicht abgerufen werden. Versuche, den Titel und/oder den Autor zu aktualisieren.\",\n  \"ErrorUploadLacksTitle\": \"Es muss ein Titel eingegeben werden\",\n  \"HeaderAccount\": \"Konto\",\n  \"HeaderAddCustomMetadataProvider\": \"Benutzerdefinierten Metadatenanbieter hinzufügen\",\n  \"HeaderAdvanced\": \"Erweitert\",\n  \"HeaderApiKeys\": \"API-Schlüssel\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise Benachrichtigungseinstellungen\",\n  \"HeaderAudioTracks\": \"Audiodateien\",\n  \"HeaderAudiobookTools\": \"Hörbuch-Dateiverwaltungswerkzeuge\",\n  \"HeaderAuthentication\": \"Authentifizierung\",\n  \"HeaderBackups\": \"Sicherungen\",\n  \"HeaderBulkChapterModal\": \"Mehrere Kapitel hinzufügen\",\n  \"HeaderChangePassword\": \"Passwort ändern\",\n  \"HeaderChapters\": \"Kapitel\",\n  \"HeaderChooseAFolder\": \"Wähle einen Ordner\",\n  \"HeaderCollection\": \"Sammlung\",\n  \"HeaderCollectionItems\": \"Sammlungseinträge\",\n  \"HeaderCover\": \"Titelbild\",\n  \"HeaderCurrentDownloads\": \"Aktuelle Downloads\",\n  \"HeaderCustomMessageOnLogin\": \"Benutzerdefinierte Nachricht für die Anmeldung\",\n  \"HeaderCustomMetadataProviders\": \"Benutzerdefinierte Metadatenanbieter\",\n  \"HeaderDetails\": \"Details\",\n  \"HeaderDownloadQueue\": \"Download-Warteschlange\",\n  \"HeaderEbookFiles\": \"E-Book-Dateien\",\n  \"HeaderEmail\": \"E-Mail\",\n  \"HeaderEmailSettings\": \"E-Mail-Einstellungen\",\n  \"HeaderEpisodes\": \"Episoden\",\n  \"HeaderEreaderDevices\": \"E-Reader Geräte\",\n  \"HeaderEreaderSettings\": \"E-Reader-Einstellungen\",\n  \"HeaderFiles\": \"Dateien\",\n  \"HeaderFindChapters\": \"Kapitel suchen\",\n  \"HeaderIgnoredFiles\": \"Ignorierte Dateien\",\n  \"HeaderItemFiles\": \"Medien-Dateien\",\n  \"HeaderItemMetadataUtils\": \"Metadaten\",\n  \"HeaderLastListeningSession\": \"Letzte Hörsitzung\",\n  \"HeaderLatestEpisodes\": \"Neueste Episoden\",\n  \"HeaderLibraries\": \"Bibliotheken\",\n  \"HeaderLibraryFiles\": \"Alle Dateien\",\n  \"HeaderLibraryStats\": \"Bibliotheksstatistiken\",\n  \"HeaderListeningSessions\": \"Ereignisse\",\n  \"HeaderListeningStats\": \"Hörstatistiken\",\n  \"HeaderLogin\": \"Anmeldung\",\n  \"HeaderLogs\": \"Protokolle\",\n  \"HeaderManageGenres\": \"Kategorien verwalten\",\n  \"HeaderManageTags\": \"Tags verwalten\",\n  \"HeaderMapDetails\": \"Stapelverarbeitung\",\n  \"HeaderMatch\": \"Treffer\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metadaten Rangfolge\",\n  \"HeaderMetadataToEmbed\": \"Einzubettende Metadaten\",\n  \"HeaderNewAccount\": \"Neues Konto\",\n  \"HeaderNewApiKey\": \"Neuen API-Schlüssel erstellen\",\n  \"HeaderNewLibrary\": \"Neue Bibliothek\",\n  \"HeaderNotificationCreate\": \"Benachrichtigung erstellen\",\n  \"HeaderNotificationUpdate\": \"Benachrichtigung bearbeiten\",\n  \"HeaderNotifications\": \"Benachrichtigungen\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect Authentifizierung\",\n  \"HeaderOpenListeningSessions\": \"Aktive Hörbuch-Sitzungen\",\n  \"HeaderOpenRSSFeed\": \"RSS-Feed öffnen\",\n  \"HeaderOtherFiles\": \"Sonstige Dateien\",\n  \"HeaderPasswordAuthentication\": \"Passwortauthentifizierung\",\n  \"HeaderPermissions\": \"Berechtigungen\",\n  \"HeaderPlayerQueue\": \"Player Warteschlange\",\n  \"HeaderPlayerSettings\": \"Player Einstellungen\",\n  \"HeaderPlaylist\": \"Wiedergabeliste\",\n  \"HeaderPlaylistItems\": \"Einträge in der Wiedergabeliste\",\n  \"HeaderPodcastsToAdd\": \"Podcasts zum Hinzufügen\",\n  \"HeaderPresets\": \"Voreinstellungen\",\n  \"HeaderPreviewCover\": \"Vorschau Titelbild\",\n  \"HeaderRSSFeedGeneral\": \"RSS Details\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-Feed ist geöffnet\",\n  \"HeaderRSSFeeds\": \"RSS-Feeds\",\n  \"HeaderRemoveEpisode\": \"Episode entfernen\",\n  \"HeaderRemoveEpisodes\": \"Entferne {0} Episoden\",\n  \"HeaderSavedMediaProgress\": \"Gespeicherte Hörfortschritte\",\n  \"HeaderSchedule\": \"Zeitplan\",\n  \"HeaderScheduleEpisodeDownloads\": \"Automatische Episoden-Downloads planen\",\n  \"HeaderScheduleLibraryScans\": \"Automatische Bibliotheksscans\",\n  \"HeaderSession\": \"Sitzung\",\n  \"HeaderSetBackupSchedule\": \"Zeitplan für die Datensicherung festlegen\",\n  \"HeaderSettings\": \"Einstellungen\",\n  \"HeaderSettingsDisplay\": \"Anzeige\",\n  \"HeaderSettingsExperimental\": \"Experimentelle Funktionen\",\n  \"HeaderSettingsGeneral\": \"Allgemein\",\n  \"HeaderSettingsScanner\": \"Scanner\",\n  \"HeaderSettingsSecurity\": \"Sicherheit\",\n  \"HeaderSettingsWebClient\": \"Web-Client\",\n  \"HeaderSleepTimer\": \"Sleep-Timer\",\n  \"HeaderStatsLargestItems\": \"Größte Medien\",\n  \"HeaderStatsLongestItems\": \"Längste Medien (h)\",\n  \"HeaderStatsMinutesListeningChart\": \"Hörminuten (letzte 7 Tage)\",\n  \"HeaderStatsRecentSessions\": \"Neueste Ereignisse\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autoren\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Kategorien\",\n  \"HeaderTableOfContents\": \"Inhaltsverzeichnis\",\n  \"HeaderTools\": \"Werkzeuge\",\n  \"HeaderUpdateAccount\": \"Konto aktualisieren\",\n  \"HeaderUpdateApiKey\": \"API-Schlüssel aktualisieren\",\n  \"HeaderUpdateAuthor\": \"Autor aktualisieren\",\n  \"HeaderUpdateDetails\": \"Details aktualisieren\",\n  \"HeaderUpdateLibrary\": \"Bibliothek aktualisieren\",\n  \"HeaderUsers\": \"Benutzer\",\n  \"HeaderYearReview\": \"Jahr {0} in Übersicht\",\n  \"HeaderYourStats\": \"Eigene Statistiken\",\n  \"LabelAbridged\": \"Gekürzt\",\n  \"LabelAbridgedChecked\": \"Gekürzt (angehakt)\",\n  \"LabelAbridgedUnchecked\": \"Ungekürzt (nicht angehakt)\",\n  \"LabelAccessibleBy\": \"Zugänglich für\",\n  \"LabelAccountType\": \"Kontoart\",\n  \"LabelAccountTypeAdmin\": \"Admin\",\n  \"LabelAccountTypeGuest\": \"Gast\",\n  \"LabelAccountTypeUser\": \"Benutzer\",\n  \"LabelActivities\": \"Aktivitäten\",\n  \"LabelActivity\": \"Aktivität\",\n  \"LabelAddToCollection\": \"Zur Sammlung hinzufügen\",\n  \"LabelAddToCollectionBatch\": \"Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu\",\n  \"LabelAddToPlaylist\": \"Zur Wiedergabeliste hinzufügen\",\n  \"LabelAddToPlaylistBatch\": \"Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu\",\n  \"LabelAddedAt\": \"Hinzugefügt am\",\n  \"LabelAddedDate\": \"{0} Hinzugefügt\",\n  \"LabelAdminUsersOnly\": \"Nur Admin Benutzer\",\n  \"LabelAll\": \"Alle\",\n  \"LabelAllEpisodesDownloaded\": \"Alle Episoden heruntergeladen\",\n  \"LabelAllUsers\": \"Alle Benutzer\",\n  \"LabelAllUsersExcludingGuests\": \"Alle Benutzer außer Gästen\",\n  \"LabelAllUsersIncludingGuests\": \"Alle Benutzer und Gäste\",\n  \"LabelAlreadyInYourLibrary\": \"Bereits in der Bibliothek\",\n  \"LabelApiKeyCreated\": \"API-Schlüssel \\\"{0}\\\" erfolgreich erstellt.\",\n  \"LabelApiKeyCreatedDescription\": \"Speichere den API-Schlüssel an einem sicheren Ort, du wirst ihn später nicht mehr abrufen können.\",\n  \"LabelApiKeyUser\": \"Im Kontext eines Nutzers agieren\",\n  \"LabelApiKeyUserDescription\": \"Dieser API-Schlüssel hat die gleichen Berechtigungen wie der Benutzer, in dessen Namen er erstellt wurde .In den Protokollen wird es aussehen, als ob der Benutzer die Anfrage durchführte.\",\n  \"LabelApiToken\": \"API Schlüssel\",\n  \"LabelAppend\": \"Anhängen\",\n  \"LabelAudioBitrate\": \"Audiobitrate (z. B. 128 kbit/s)\",\n  \"LabelAudioChannels\": \"Audiokanäle (1 oder 2)\",\n  \"LabelAudioCodec\": \"Audiocodec\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Vorname Nachname)\",\n  \"LabelAuthorLastFirst\": \"Autor (Nachname, Vorname)\",\n  \"LabelAuthors\": \"Autoren\",\n  \"LabelAutoDownloadEpisodes\": \"Episoden automatisch herunterladen\",\n  \"LabelAutoFetchMetadata\": \"Automatisches Abholen der Metadaten\",\n  \"LabelAutoFetchMetadataHelp\": \"Abholen der Metadaten von Titel, Autor und Serien, um das Hochladen zu optimieren. Möglicherweise müssen zusätzliche Metadaten nach dem Hochladen abgeglichen werden.\",\n  \"LabelAutoLaunch\": \"Automatischer Start\",\n  \"LabelAutoLaunchDescription\": \"Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatische Registrierung\",\n  \"LabelAutoRegisterDescription\": \"Automatische neue Neutzer anlegen nach dem Registrieren\",\n  \"LabelBackToUser\": \"Zurück zum Benutzer\",\n  \"LabelBackupAudioFiles\": \"Audio-Dateien sichern\",\n  \"LabelBackupLocation\": \"Backup-Ort\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatische Sicherung\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Backups werden in /metadata/backups gespeichert\",\n  \"LabelBackupsMaxBackupSize\": \"Maximale Sicherungsgröße (in GB) (0 gleich ohne Begrenzung)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.\",\n  \"LabelBackupsNumberToKeep\": \"Anzahl der aufzubewahrenden Sicherungen\",\n  \"LabelBackupsNumberToKeepHelp\": \"Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn du bereits mehrere Sicherungen als die definierte max. Anzahl hast, solltest du diese manuell entfernen.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Bücher\",\n  \"LabelButtonText\": \"Knopftext\",\n  \"LabelByAuthor\": \"von {0}\",\n  \"LabelChangePassword\": \"Passwort ändern\",\n  \"LabelChannels\": \"Kanäle\",\n  \"LabelChapterCount\": \"{0} Kapitel\",\n  \"LabelChapterTitle\": \"Kapitelüberschrift\",\n  \"LabelChapters\": \"Kapitel\",\n  \"LabelChaptersFound\": \"Gefundene Kapitel\",\n  \"LabelClickForMoreInfo\": \"Klicken für mehr Informationen\",\n  \"LabelClickToUseCurrentValue\": \"Anklicken um aktuellen Wert zu verwenden\",\n  \"LabelClosePlayer\": \"Player schließen\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Serien einklappen\",\n  \"LabelCollapseSubSeries\": \"Unterserien einklappen\",\n  \"LabelCollection\": \"Sammlung\",\n  \"LabelCollections\": \"Sammlungen\",\n  \"LabelComplete\": \"Vollständig\",\n  \"LabelConfirmPassword\": \"Passwort bestätigen\",\n  \"LabelContinueListening\": \"Weiterhören\",\n  \"LabelContinueReading\": \"Weiterlesen\",\n  \"LabelContinueSeries\": \"Serien fortsetzen\",\n  \"LabelCorsAllowed\": \"Erlaubte CORS Quellen\",\n  \"LabelCover\": \"Titelbild\",\n  \"LabelCoverImageURL\": \"URL des Titelbildes\",\n  \"LabelCoverProvider\": \"Titelbildanbieter\",\n  \"LabelCreatedAt\": \"Erstellt am\",\n  \"LabelCronExpression\": \"Cron-Ausdruck\",\n  \"LabelCurrent\": \"Aktuell\",\n  \"LabelCurrently\": \"Aktuell:\",\n  \"LabelCustomCronExpression\": \"Benutzerdefinierter Cron-Ausdruck:\",\n  \"LabelDatetime\": \"Datum & Uhrzeit\",\n  \"LabelDays\": \"Tage\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu entfernen)\",\n  \"LabelDescription\": \"Beschreibung\",\n  \"LabelDeselectAll\": \"Alles abwählen\",\n  \"LabelDetectedPattern\": \"Erkanntes Muster:\",\n  \"LabelDevice\": \"Gerät\",\n  \"LabelDeviceInfo\": \"Geräteinformationen\",\n  \"LabelDeviceIsAvailableTo\": \"Dem Gerät ist es möglich zu ...\",\n  \"LabelDirectory\": \"Verzeichnis\",\n  \"LabelDiscFromFilename\": \"CD aus dem Dateinamen\",\n  \"LabelDiscFromMetadata\": \"CD aus den Metadaten\",\n  \"LabelDiscover\": \"Entdecken\",\n  \"LabelDownload\": \"Herunterladen\",\n  \"LabelDownloadNEpisodes\": \"Download {0} Episoden\",\n  \"LabelDownloadable\": \"Herunterladbar\",\n  \"LabelDuration\": \"Laufzeit\",\n  \"LabelDurationComparisonExactMatch\": \"(genauer Treffer)\",\n  \"LabelDurationComparisonLonger\": \"({0} länger)\",\n  \"LabelDurationComparisonShorter\": \"({0} kürzer)\",\n  \"LabelDurationFound\": \"Gefundene Laufzeit:\",\n  \"LabelEbook\": \"E-Buch\",\n  \"LabelEbooks\": \"E-Bücher\",\n  \"LabelEdit\": \"Bearbeiten\",\n  \"LabelEmail\": \"E-Mail\",\n  \"LabelEmailSettingsFromAddress\": \"Sender\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Nicht autorisierte Zertifikate ablehnen\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn du die Auswirkungen verstehst und dem E-Mail-Server vertraust, mit dem eine Verbindung hergestellt wird.\",\n  \"LabelEmailSettingsSecure\": \"Sicher\",\n  \"LabelEmailSettingsSecureHelp\": \"Wenn an, verwendet die Verbindung TLS, wenn du eine Verbindung zum Server herstellst. Bei „aus“ wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen solltest du diesen Wert auf „an“ schalten, wenn du eine Verbindung zu Port 465 herstellst. Für Port 587 oder 25 behalte den Wert „aus“ bei. (von nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Test-Adresse\",\n  \"LabelEmbeddedCover\": \"Eingebettetes Cover\",\n  \"LabelEnable\": \"Aktivieren\",\n  \"LabelEncodingBackupLocation\": \"Eine Sicherungskopie der originalen Audiodateien wird gespeichert in:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Kapitel sind in mehrspurigen Hörbüchern nicht eingebettet.\",\n  \"LabelEncodingClearItemCache\": \"Stelle sicher, dass der Cache regelmäßig geleert wird.\",\n  \"LabelEncodingFinishedM4B\": \"Die fertige M4B-Datei wird im Hörbuch-Ordner unter folgendem Pfad abgelegt:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadaten werden in die Audiodateien innerhalb des Audiobook Ordners eingebunden.\",\n  \"LabelEncodingStartedNavigation\": \"Sobald die Aufgabe gestartet ist, kann die Seite verlassen werden.\",\n  \"LabelEncodingTimeWarning\": \"Kodierung kann bis zu 30 Minuten dauern.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Achtung: Ändere diese Einstellungen nur, wenn du dich mit ffmpeg Kodierung auskennst.\",\n  \"LabelEncodingWatcherDisabled\": \"Wenn der Watcher deaktiviert ist musst du das Hörbuch danach erneut scannen.\",\n  \"LabelEnd\": \"Ende\",\n  \"LabelEndOfChapter\": \"Ende des Kapitels\",\n  \"LabelEpisode\": \"Episode\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episode nicht mit RSS-Feed verknüpft\",\n  \"LabelEpisodeNumber\": \"Episode #{0}\",\n  \"LabelEpisodeTitle\": \"Episodentitel\",\n  \"LabelEpisodeType\": \"Episodentyp\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Episoden URL vom RSS-Feed\",\n  \"LabelEpisodes\": \"Episoden\",\n  \"LabelEpisodic\": \"Episodisch\",\n  \"LabelExample\": \"Beispiel\",\n  \"LabelExpandSeries\": \"Serie ausklappen\",\n  \"LabelExpandSubSeries\": \"Unterserie ausklappen\",\n  \"LabelExpired\": \"Abgelaufen\",\n  \"LabelExpiresAt\": \"Läuft ab am\",\n  \"LabelExpiresInSeconds\": \"Ablauf in (seconds) Sekunden\",\n  \"LabelExpiresNever\": \"Niemals\",\n  \"LabelExplicit\": \"Explizit\",\n  \"LabelExplicitChecked\": \"Explicit (Altersbeschränkung) (angehakt)\",\n  \"LabelExplicitUnchecked\": \"Not Explicit (Altersbeschränkung) (nicht angehakt)\",\n  \"LabelExportOPML\": \"OPML exportieren\",\n  \"LabelFeedURL\": \"Feed-URL\",\n  \"LabelFetchingMetadata\": \"Abholen der Metadaten\",\n  \"LabelFile\": \"Datei\",\n  \"LabelFileBirthtime\": \"Datei erstellt\",\n  \"LabelFileBornDate\": \"Geboren {0}\",\n  \"LabelFileModified\": \"Datei geändert\",\n  \"LabelFileModifiedDate\": \"Geändert {0}\",\n  \"LabelFilename\": \"Dateiname\",\n  \"LabelFilterByUser\": \"Nach Benutzern filtern\",\n  \"LabelFindEpisodes\": \"Episoden suchen\",\n  \"LabelFinished\": \"Beendet\",\n  \"LabelFinishedDate\": \"Beendet {0}\",\n  \"LabelFolder\": \"Ordner\",\n  \"LabelFolders\": \"Verzeichnisse\",\n  \"LabelFontBold\": \"Fett\",\n  \"LabelFontBoldness\": \"Schriftstärke\",\n  \"LabelFontFamily\": \"Schriftfamilie\",\n  \"LabelFontItalic\": \"Kursiv\",\n  \"LabelFontScale\": \"Schriftgröße\",\n  \"LabelFontStrikethrough\": \"Durchgestrichen\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Voll\",\n  \"LabelGenre\": \"Kategorie\",\n  \"LabelGenres\": \"Kategorien\",\n  \"LabelHardDeleteFile\": \"Datei dauerhaft löschen\",\n  \"LabelHasEbook\": \"E-Buch verfügbar\",\n  \"LabelHasSupplementaryEbook\": \"Ergänzendes E-Buch verfügbar\",\n  \"LabelHideSubtitles\": \"Untertitel ausblenden\",\n  \"LabelHighestPriority\": \"Höchste Priorität\",\n  \"LabelHost\": \"Anbieter\",\n  \"LabelHour\": \"Stunde\",\n  \"LabelHours\": \"Stunden\",\n  \"LabelIcon\": \"Symbol\",\n  \"LabelImageURLFromTheWeb\": \"Bild-URL vom Internet\",\n  \"LabelInProgress\": \"In Bearbeitung\",\n  \"LabelIncludeInTracklist\": \"In die Titelliste aufnehmen\",\n  \"LabelIncomplete\": \"Unvollständig\",\n  \"LabelInterval\": \"Intervall\",\n  \"LabelIntervalCustomDailyWeekly\": \"Benutzerdefiniert Täglich/Wöchentlich\",\n  \"LabelIntervalEvery12Hours\": \"Alle 12 Stunden\",\n  \"LabelIntervalEvery15Minutes\": \"Alle 15 Minuten\",\n  \"LabelIntervalEvery2Hours\": \"Alle 2 Stunden\",\n  \"LabelIntervalEvery30Minutes\": \"Alle 30 Minuten\",\n  \"LabelIntervalEvery6Hours\": \"Alle 6 Stunden\",\n  \"LabelIntervalEveryDay\": \"Jeden Tag\",\n  \"LabelIntervalEveryHour\": \"Jede Stunde\",\n  \"LabelIntervalEveryMinute\": \"Jede Minute\",\n  \"LabelInvert\": \"Umkehren\",\n  \"LabelItem\": \"Medium\",\n  \"LabelJumpBackwardAmount\": \"Zurückspringen Zeit\",\n  \"LabelJumpForwardAmount\": \"Vorwärtsspringen Zeit\",\n  \"LabelLanguage\": \"Sprache\",\n  \"LabelLanguageDefaultServer\": \"Standard-Server-Sprache\",\n  \"LabelLanguages\": \"Sprachen\",\n  \"LabelLastBookAdded\": \"Zuletzt hinzugefügtes Buch\",\n  \"LabelLastBookUpdated\": \"Zuletzt aktualisiertes Buch\",\n  \"LabelLastProgressDate\": \"Letzter Fortschritt: {0}\",\n  \"LabelLastSeen\": \"Zuletzt gesehen\",\n  \"LabelLastTime\": \"Letztes Mal\",\n  \"LabelLastUpdate\": \"Letzte Aktualisierung\",\n  \"LabelLayout\": \"Ansicht\",\n  \"LabelLayoutSinglePage\": \"Eine Seite\",\n  \"LabelLayoutSplitPage\": \"Geteilte Seite\",\n  \"LabelLess\": \"Weniger\",\n  \"LabelLibrariesAccessibleToUser\": \"Für Benutzer zugängliche Bibliotheken\",\n  \"LabelLibrary\": \"Bibliothek\",\n  \"LabelLibraryFilterSublistEmpty\": \"Keine {0}\",\n  \"LabelLibraryItem\": \"Bibliothekseintrag\",\n  \"LabelLibraryName\": \"Bibliotheksname\",\n  \"LabelLibrarySortByProgress\": \"Fortschritt: Letzte Aktualisierung\",\n  \"LabelLibrarySortByProgressFinished\": \"Fortschritt: Beendet\",\n  \"LabelLibrarySortByProgressStarted\": \"Fortschritt: Gestartet\",\n  \"LabelLimit\": \"Begrenzung\",\n  \"LabelLineSpacing\": \"Zeilenabstand\",\n  \"LabelListenAgain\": \"Erneut anhören\",\n  \"LabelLogLevelDebug\": \"Fehlersuche\",\n  \"LabelLogLevelInfo\": \"Informationen\",\n  \"LabelLogLevelWarn\": \"Warnungen\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Suche nach neuen Episoden nach diesem Datum\",\n  \"LabelLowestPriority\": \"Niedrigste Priorität\",\n  \"LabelMatchConfidence\": \"Vertrauenswert\",\n  \"LabelMatchExistingUsersBy\": \"Zuordnen existierender Benutzer mit\",\n  \"LabelMatchExistingUsersByDescription\": \"Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet\",\n  \"LabelMaxEpisodesToDownload\": \"Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Max. Anzahl neuer Episoden zum Herunterladen pro Abfrage\",\n  \"LabelMaxEpisodesToKeep\": \"Max. Anzahl zu behaltender Episoden\",\n  \"LabelMaxEpisodesToKeepHelp\": \"0 setzt keine Begrenzung. Wenn eine neue Episode automatisch heruntergeladen wird, wird die älteste Episode gelöscht, wenn du mehr als X Episoden gespeichert hast. Es wird nur eine Episode pro neuem Download gelöscht.\",\n  \"LabelMediaPlayer\": \"Mediaplayer\",\n  \"LabelMediaType\": \"Medientyp\",\n  \"LabelMetaTag\": \"Meta Tag\",\n  \"LabelMetaTags\": \"Meta Tags\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Höher priorisierte Quellen für Metadaten überschreiben Metadaten aus Quellen mit niedrigerer Priorität\",\n  \"LabelMetadataProvider\": \"Metadatenanbieter\",\n  \"LabelMinute\": \"Minute\",\n  \"LabelMinutes\": \"Minuten\",\n  \"LabelMissing\": \"Fehlend\",\n  \"LabelMissingEbook\": \"E-Buch fehlt\",\n  \"LabelMissingSupplementaryEbook\": \"Ergänzendes E-Buch fehlt\",\n  \"LabelMobileRedirectURIs\": \"Erlaubte Weiterleitungs-URIs für die mobile App\",\n  \"LabelMobileRedirectURIsDescription\": \"Dies ist eine weiße Liste gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist <code>audiobookshelf://oauth</code>, den du entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen kannst. Die Verwendung eines Sternchens (<code>*</code>) als alleiniger Eintrag erlaubt jede URI.\",\n  \"LabelMore\": \"Mehr\",\n  \"LabelMoreInfo\": \"Mehr Infos\",\n  \"LabelName\": \"Name\",\n  \"LabelNarrator\": \"Erzähler\",\n  \"LabelNarrators\": \"Erzähler\",\n  \"LabelNew\": \"Neu\",\n  \"LabelNewPassword\": \"Neues Passwort\",\n  \"LabelNewestAuthors\": \"Neueste Autoren\",\n  \"LabelNewestEpisodes\": \"Neueste Episoden\",\n  \"LabelNextBackupDate\": \"Nächstes Sicherungsdatum\",\n  \"LabelNextChapters\": \"Das nächste Kapitel ist:\",\n  \"LabelNextScheduledRun\": \"Nächster planmäßiger Durchlauf\",\n  \"LabelNoApiKeys\": \"Keine API-Schlüssel vorhanden\",\n  \"LabelNoCustomMetadataProviders\": \"Keine benutzerdefinierten Metadata Anbieter\",\n  \"LabelNoEpisodesSelected\": \"Keine Episoden ausgewählt\",\n  \"LabelNotFinished\": \"Nicht beendet\",\n  \"LabelNotStarted\": \"Nicht begonnen\",\n  \"LabelNotes\": \"Notizen\",\n  \"LabelNotificationAppriseURL\": \"Apprise-URL(s)\",\n  \"LabelNotificationAvailableVariables\": \"Verfügbare Variablen\",\n  \"LabelNotificationBodyTemplate\": \"Textvorlage\",\n  \"LabelNotificationEvent\": \"Benachrichtigungs Event\",\n  \"LabelNotificationTitleTemplate\": \"Titelvorlage\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maximale Fehlversuche\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Benachrichtigungen werden deaktiviert, wenn sie mehrmals nicht gesendet werden können\",\n  \"LabelNotificationsMaxQueueSize\": \"Maximale Größe der Warteschlange für die Benachrichtigungsereignisse\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Es wird nur 1 Ereignis pro Sekunde ausgelöst. Ereignisse werden ignoriert, wenn die Warteschlange die maximale Größe erreicht hat. Dies verhindert Benachrichtigungsspamming.\",\n  \"LabelNumberOfBooks\": \"Anzahl der Hörbücher\",\n  \"LabelNumberOfChapters\": \"Anzahl an Kapiteln:\",\n  \"LabelNumberOfEpisodes\": \"Anzahl der Episoden\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Name des OpenID-Claims, der erweiterte Berechtigungen für Benutzeraktionen innerhalb der Anwendung enthält, die auf Nicht-Admin-Rollen angewendet werden (<b>wenn konfiguriert</b>). Wenn der Claim in der Antwort fehlt, wird der Zugang zu ABS verweigert. Fehlt eine einzelne Option, wird sie als <code>false</code> behandelt. Stelle sicher, dass der Claim des Identitätsanbieters der erwarteten Struktur entspricht:\",\n  \"LabelOpenIDClaims\": \"Lass die folgenden Optionen leer, um die erweiterte Zuweisung von Gruppen und Berechtigungen zu deaktivieren und automatisch die 'User'-Gruppe zuzuweisen.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Name des OpenID-Claims, der eine Liste der Benutzergruppen enthält. Wird häufig als <code>groups</code> bezeichnet. <b>Wenn konfiguriert</b>, wird die Anwendung automatisch Rollen basierend auf den Gruppenmitgliedschaften des Benutzers zuweisen, vorausgesetzt, dass diese Gruppen im Claim als 'admin', 'user' oder 'guest' benannt sind (Groß/Kleinschreibung ist irrelevant). Der Claim eine Liste sein, und wenn ein Benutzer mehreren Gruppen angehört, wird die Anwendung die Rolle zuordnen, die dem höchsten Zugriffslevel entspricht. Wenn keine Gruppe übereinstimmt, wird der Zugang verweigert.\",\n  \"LabelOpenRSSFeed\": \"Öffne RSS-Feed\",\n  \"LabelOverwrite\": \"Überschreiben\",\n  \"LabelPaginationPageXOfY\": \"Seite {0} von {1}\",\n  \"LabelPassword\": \"Passwort\",\n  \"LabelPath\": \"Pfad\",\n  \"LabelPermanent\": \"Dauerhaft\",\n  \"LabelPermissionsAccessAllLibraries\": \"Zugriff auf alle Bibliotheken\",\n  \"LabelPermissionsAccessAllTags\": \"Zugriff auf alle Schlagwörter\",\n  \"LabelPermissionsAccessExplicitContent\": \"Zugriff auf explizite (alterbeschränkte) Inhalte\",\n  \"LabelPermissionsCreateEreader\": \"Kann E-Reader erstellen\",\n  \"LabelPermissionsDelete\": \"Darf Löschen\",\n  \"LabelPermissionsDownload\": \"Herunterladen\",\n  \"LabelPermissionsUpdate\": \"Aktualisieren\",\n  \"LabelPermissionsUpload\": \"Hochladen\",\n  \"LabelPersonalYearReview\": \"Dein Jahr in Übersicht ({0})\",\n  \"LabelPhotoPathURL\": \"Foto Pfad/URL\",\n  \"LabelPlayMethod\": \"Abspielmethode\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Wiedergaberate der Erhöhung/Verminderung\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} von {1}\",\n  \"LabelPlaylists\": \"Wiedergabelisten\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast-Suchregion\",\n  \"LabelPodcastType\": \"Podcast Typ\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)\",\n  \"LabelPreventIndexing\": \"Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird\",\n  \"LabelPrimaryEbook\": \"Primäres E-Buch\",\n  \"LabelProgress\": \"Fortschritt\",\n  \"LabelProvider\": \"Anbieter\",\n  \"LabelProviderAuthorizationValue\": \"Autorisierungsheader-Wert\",\n  \"LabelPubDate\": \"Veröffentlichungsdatum\",\n  \"LabelPublishYear\": \"Jahr\",\n  \"LabelPublishedDate\": \"Veröffentlicht {0}\",\n  \"LabelPublishedDecade\": \"Jahrzehnt\",\n  \"LabelPublishedDecades\": \"Jahrzehnte\",\n  \"LabelPublisher\": \"Herausgeber\",\n  \"LabelPublishers\": \"Herausgeber\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Benutzerdefinierte Eigentümer-E-Mail\",\n  \"LabelRSSFeedCustomOwnerName\": \"Benutzerdefinierter Name des Eigentümers\",\n  \"LabelRSSFeedOpen\": \"RSS-Feed offen\",\n  \"LabelRSSFeedPreventIndexing\": \"Indizierung verhindern\",\n  \"LabelRSSFeedSlug\": \"RSS-Feed-Schlagwort\",\n  \"LabelRSSFeedURL\": \"RSS-Feed-URL\",\n  \"LabelRandomly\": \"Zufällig\",\n  \"LabelReAddSeriesToContinueListening\": \"Serien erneut zur Fortsetzungsliste hinzufügen\",\n  \"LabelRead\": \"Lesen\",\n  \"LabelReadAgain\": \"Noch einmal Lesen\",\n  \"LabelReadEbookWithoutProgress\": \"E-Buch lesen und Fortschritt verwerfen\",\n  \"LabelRecentSeries\": \"Aktuelle Serien\",\n  \"LabelRecentlyAdded\": \"Kürzlich hinzugefügt\",\n  \"LabelRecommended\": \"Empfohlen\",\n  \"LabelRedo\": \"Wiederholen\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Veröffentlichungsdatum\",\n  \"LabelRemoveAllMetadataAbs\": \"Alle metadata.abs Dateien löschen\",\n  \"LabelRemoveAllMetadataJson\": \"Alle metadata.json Dateien löschen\",\n  \"LabelRemoveAudibleBranding\": \"Audible Intro sowie Outro aus Kapiteln entfernen\",\n  \"LabelRemoveCover\": \"Entferne Titelbild\",\n  \"LabelRemoveMetadataFile\": \"Metadaten-Dateien in Bibliotheksordnern löschen\",\n  \"LabelRemoveMetadataFileHelp\": \"Alle metadata.json und metadata.abs Dateien aus den Ordnern {0} löschen.\",\n  \"LabelRowsPerPage\": \"Zeilen pro Seite\",\n  \"LabelSearchTerm\": \"Begriff suchen\",\n  \"LabelSearchTitle\": \"Titel suchen\",\n  \"LabelSearchTitleOrASIN\": \"Titel oder ASIN suchen\",\n  \"LabelSeason\": \"Staffel\",\n  \"LabelSeasonNumber\": \"Staffel #{0}\",\n  \"LabelSelectAll\": \"Alles auswählen\",\n  \"LabelSelectAllEpisodes\": \"Alle Episoden auswählen\",\n  \"LabelSelectEpisodesShowing\": \"{0} ausgewählte Episoden werden angezeigt\",\n  \"LabelSelectUser\": \"Ausgewählter Benutzer\",\n  \"LabelSelectUsers\": \"Benutzer auswählen\",\n  \"LabelSendEbookToDevice\": \"E-Buch senden an …\",\n  \"LabelSequence\": \"Reihenfolge\",\n  \"LabelSerial\": \"fortlaufend\",\n  \"LabelSeries\": \"Serien\",\n  \"LabelSeriesName\": \"Serienname\",\n  \"LabelSeriesProgress\": \"Serienfortschritt\",\n  \"LabelServerLogLevel\": \"Server Log Level\",\n  \"LabelServerYearReview\": \"Server Jahr in Übersicht ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Als Hauptbuch setzen\",\n  \"LabelSetEbookAsSupplementary\": \"Als Ergänzung setzen\",\n  \"LabelSettingsAllowIframe\": \"Einbetten in einem iFrame erlauben\",\n  \"LabelSettingsAudiobooksOnly\": \"Nur Hörbücher\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorphes Design mit Holzeinlegeböden\",\n  \"LabelSettingsChromecastSupport\": \"Chromecastunterstützung\",\n  \"LabelSettingsDateFormat\": \"Datumsformat\",\n  \"LabelSettingsEnableWatcher\": \"Bibliotheken automatisch nach Änderungen überwachen\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Bibliothek automatisch auf Änderungen überwachen\",\n  \"LabelSettingsEnableWatcherHelp\": \"Aktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Skriptinhalte in Epubs zulassen\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Erlaube Epub-Dateien, Skripte auszuführen. Es wird empfohlen, diese Einstellung deaktiviert zu lassen, es sei denn, du vertraust der Quelle der Epub-Dateien.\",\n  \"LabelSettingsExperimentalFeatures\": \"Experimentelle Funktionen\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funktionen welche sich in der Entwicklung befinden, benötigen dein Feedback und deine Hilfe beim Testen. Klicke hier, um die Github-Diskussion zu öffnen.\",\n  \"LabelSettingsFindCovers\": \"Suche Titelbilder\",\n  \"LabelSettingsFindCoversHelp\": \"Wenn dein Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer\",\n  \"LabelSettingsHideSingleBookSeries\": \"Ausblenden einzelner Bücher\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serien, die nur ein einzelnes Buch enthalten, werden auf der Startseite und in der Serienansicht ausgeblendet.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Startseite verwendet die Bücherregalansicht\",\n  \"LabelSettingsLibraryBookshelfView\": \"Bibliothek verwendet die Bücherregalansicht\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"In Prozent gehört größer als\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Verbleibende Zeit ist weniger als (Sekunden)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Markiere Mediendateien als fertig, wenn\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Überspringe vorherige Bücher in fortführender Serie\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Die Startseite von \\\"Fortführende Serien\\\" zeigt das erste noch nicht begonnene Buch in Serien an, die mindestens ein Buch abgeschlossen und keine Bücher begonnen haben. Wenn diese Einstellung aktiviert wird, werden Serien ab dem letzten abgeschlossenen Buch fortgesetzt und nicht ab dem ersten nicht begonnenen Buch.\",\n  \"LabelSettingsParseSubtitles\": \"Analysiere Untertitel\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Extrahiere die Untertitel von Medium-Ordnernamen.<br>Untertitel müssen vom eigentlichem Titel durch ein \\\" - \\\" getrennt sein. <br>Beispiel: \\\"Titel - Untertitel\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Bevorzuge online abgestimmte Metadaten\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Bei einem Schnellabgleich überschreiben online neu abgestimmte Metadaten alle schon vorhandenen Metadaten eines Mediums. Standardmäßig werden bei einem Schnellabgleich nur fehlende Metadaten ersetzt.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Überspringe beim Online-Abgleich alle Bücher die bereits eine ASIN haben\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Vorwort/Artikel beim Sortieren ignorieren\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"Beispiel: für den Artikel \\\"der\\\" würde der Mediumtitel \\\"Der Buchtitel\\\" als \\\"Buchtitel, Der\\\" sortiert werden\",\n  \"LabelSettingsSquareBookCovers\": \"Benutze quadratische Titelbilder\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Bevorzuge quadratische Titelbilder gegenüber den Standardtielbildern im Verhältnis 1,6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Titelbilder im Medienordner speichern\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \\\"cover.jpg\\\" gespeichert\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Metadaten als OPF-Datei im Medienordner speichern\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet\",\n  \"LabelSettingsTimeFormat\": \"Zeitformat\",\n  \"LabelShare\": \"Freigeben\",\n  \"LabelShareDownloadableHelp\": \"Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.\",\n  \"LabelShareOpen\": \"Freigeben\",\n  \"LabelShareURL\": \"Freigabe URL\",\n  \"LabelShowAll\": \"Alles anzeigen\",\n  \"LabelShowSeconds\": \"Zeige Sekunden\",\n  \"LabelShowSubtitles\": \"Untertitel anzeigen\",\n  \"LabelSize\": \"Größe\",\n  \"LabelSleepTimer\": \"Schlummerfunktion\",\n  \"LabelSlug\": \"URL Teil\",\n  \"LabelSortAscending\": \"Aufsteigend\",\n  \"LabelSortDescending\": \"Absteigend\",\n  \"LabelSortPubDate\": \"Erscheinungsdatum\",\n  \"LabelStart\": \"Start\",\n  \"LabelStartTime\": \"Startzeit\",\n  \"LabelStarted\": \"Gestartet\",\n  \"LabelStartedAt\": \"Gestartet am\",\n  \"LabelStartedDate\": \"Angefangen am {0}\",\n  \"LabelStatsAudioTracks\": \"Audiodateien\",\n  \"LabelStatsAuthors\": \"Autoren\",\n  \"LabelStatsBestDay\": \"Bester Tag\",\n  \"LabelStatsDailyAverage\": \"Tagesdurchschnitt\",\n  \"LabelStatsDays\": \"Tage\",\n  \"LabelStatsDaysListened\": \"Gehörte Tage\",\n  \"LabelStatsHours\": \"Stunden\",\n  \"LabelStatsInARow\": \"Nacheinander\",\n  \"LabelStatsItemsFinished\": \"Gehörte Medien\",\n  \"LabelStatsItemsInLibrary\": \"Bibliothekseinträge\",\n  \"LabelStatsMinutes\": \"Minuten\",\n  \"LabelStatsMinutesListening\": \"Gehörte Minuten\",\n  \"LabelStatsOverallDays\": \"Gesamte Tage\",\n  \"LabelStatsOverallHours\": \"Gesamte Stunden\",\n  \"LabelStatsWeekListening\": \"Wochenhördauer\",\n  \"LabelSubtitle\": \"Untertitel\",\n  \"LabelSupportedFileTypes\": \"Unterstützte Dateitypen\",\n  \"LabelTag\": \"Schlagwort\",\n  \"LabelTags\": \"Schlagwörter\",\n  \"LabelTagsAccessibleToUser\": \"Für Benutzer zugängliche Schlagwörter\",\n  \"LabelTagsNotAccessibleToUser\": \"Für Benutzer nicht zugängliche Schlagwörter\",\n  \"LabelTasks\": \"Laufende Aufgaben\",\n  \"LabelTextEditorBulletedList\": \"Aufzählungsliste\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"nummerierte Liste\",\n  \"LabelTextEditorUnlink\": \"entkoppeln\",\n  \"LabelTheme\": \"Farbschema\",\n  \"LabelThemeDark\": \"Dunkel\",\n  \"LabelThemeLight\": \"Hell\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Basiszeit\",\n  \"LabelTimeDurationXHours\": \"{0} Stunden\",\n  \"LabelTimeDurationXMinutes\": \"{0} Minuten\",\n  \"LabelTimeDurationXSeconds\": \"{0} Sekunden\",\n  \"LabelTimeInMinutes\": \"Zeit in Minuten\",\n  \"LabelTimeLeft\": \"{0} verbleibend\",\n  \"LabelTimeListened\": \"Gehörte Zeit\",\n  \"LabelTimeListenedToday\": \"Heute gehörte Zeit\",\n  \"LabelTimeRemaining\": \"{0} verbleibend\",\n  \"LabelTimeToShift\": \"Zeit bis zum Wechsel in Sekunden\",\n  \"LabelTitle\": \"Titel\",\n  \"LabelToolsEmbedMetadata\": \"Metadaten einbetten\",\n  \"LabelToolsEmbedMetadataDescription\": \"Bettet die Metadaten einschließlich des Titelbildes und der Kapitel in die Audiodateien ein.\",\n  \"LabelToolsM4bEncoder\": \"M4B Kodierer\",\n  \"LabelToolsMakeM4b\": \"M4B-Datei erstellen\",\n  \"LabelToolsMakeM4bDescription\": \"Erstellt eine M4B-Datei (Endung \\\".m4b\\\") welche mehrere mp3-Dateien in einer einzigen Datei inkl. derer Metadaten (Beschreibung, Titelbild, Kapitel, ...) zusammenfasst. M4B-Datei können darüber hinaus Lesezeichen speichern und mit einem Abspielschutz (Passwort) versehen werden.\",\n  \"LabelToolsSplitM4b\": \"M4B in MP3s aufteilen\",\n  \"LabelToolsSplitM4bDescription\": \"Erstellt aus einer mit Metadaten und nach Kapiteln aufgeteilten M4B-Datei seperate MP3's mit eingebetteten Metadaten, Coverbild und Kapiteln.\",\n  \"LabelTotalDuration\": \"Gesamtdauer\",\n  \"LabelTotalTimeListened\": \"Gehörte Gesamtzeit\",\n  \"LabelTrackFromFilename\": \"Titel aus Dateiname\",\n  \"LabelTrackFromMetadata\": \"Titel aus Metadaten\",\n  \"LabelTracks\": \"Titel\",\n  \"LabelTracksMultiTrack\": \"Mehrfachdatei\",\n  \"LabelTracksNone\": \"Keine Dateien\",\n  \"LabelTracksSingleTrack\": \"Einzeldatei\",\n  \"LabelTrailer\": \"Vorschau\",\n  \"LabelType\": \"Typ\",\n  \"LabelUnabridged\": \"Ungekürzt\",\n  \"LabelUndo\": \"Rückgängig machen\",\n  \"LabelUnknown\": \"Unbekannt\",\n  \"LabelUnknownPublishDate\": \"Unbekanntes Veröffentlichungsdatum\",\n  \"LabelUpdateCover\": \"Titelbild aktualisieren\",\n  \"LabelUpdateCoverHelp\": \"Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird\",\n  \"LabelUpdateDetails\": \"Details aktualisieren\",\n  \"LabelUpdateDetailsHelp\": \"Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird\",\n  \"LabelUpdatedAt\": \"Aktualisiert am\",\n  \"LabelUploaderDragAndDrop\": \"Ziehen und Ablegen von Dateien oder Ordnern\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Dateien per Drag & Drop hierher ziehen\",\n  \"LabelUploaderDropFiles\": \"Dateien löschen\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automatisches Aktualisieren von Titel, Autor und Serie\",\n  \"LabelUseAdvancedOptions\": \"Erweiterte Optionen verwenden\",\n  \"LabelUseChapterTrack\": \"Kapiteldatei verwenden\",\n  \"LabelUseFullTrack\": \"Gesamte Datei verwenden\",\n  \"LabelUseZeroForUnlimited\": \"0 für unbegrenzt\",\n  \"LabelUser\": \"Benutzer\",\n  \"LabelUsername\": \"Benutzername\",\n  \"LabelValue\": \"Wert\",\n  \"LabelVersion\": \"Version\",\n  \"LabelViewBookmarks\": \"Lesezeichen anzeigen\",\n  \"LabelViewChapters\": \"Kapitel anzeigen\",\n  \"LabelViewPlayerSettings\": \"Zeige player Einstellungen\",\n  \"LabelViewQueue\": \"Player-Warteschlange anzeigen\",\n  \"LabelVolume\": \"Lautstärke\",\n  \"LabelWebRedirectURLsDescription\": \"Autorisiere diese URLs bei deinem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Unterordner für Weiterleitung-URLs\",\n  \"LabelWeekdaysToRun\": \"Wochentage für die Ausführung\",\n  \"LabelXBooks\": \"{0} Bücher\",\n  \"LabelXItems\": \"{0} Medien\",\n  \"LabelYearReviewHide\": \"Jahresrückblick verbergen\",\n  \"LabelYearReviewShow\": \"Jahresrückblick anzeigen\",\n  \"LabelYourAudiobookDuration\": \"Laufzeit deines Mediums\",\n  \"LabelYourBookmarks\": \"Lesezeichen\",\n  \"LabelYourPlaylists\": \"Eigene Wiedergabelisten\",\n  \"LabelYourProgress\": \"Fortschritt\",\n  \"MessageAddToPlayerQueue\": \"Zur Abspielwarteliste hinzufügen\",\n  \"MessageAppriseDescription\": \"Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.\",\n  \"MessageAsinCheck\": \"Stelle sicher, dass die ASIN aus der richtigen Audible Region verwendet wird, nicht Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Alte API-Token werden in Zukunft entfernt. Benutze stattdessen <a href=\\\"/config/api-keys\\\">API Keys</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.\",\n  \"MessageAuthenticationSecurityMessage\": \"Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.\",\n  \"MessageBackupsDescription\": \"In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.\",\n  \"MessageBackupsLocationEditNote\": \"Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert\",\n  \"MessageBackupsLocationNoEditNote\": \"Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.\",\n  \"MessageBackupsLocationPathEmpty\": \"Der Backup-Pfad darf nicht leer sein\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Fülle die aktivierten Felder mit Daten aus allen Elementen. Felder mit mehreren Werten werden zusammengeführt\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Aktivierte Felder für Kartendetails mit Daten aus diesem Element füllen\",\n  \"MessageBatchQuickMatchDescription\": \"Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.\",\n  \"MessageBookshelfNoCollections\": \"Es wurden noch keine Sammlungen erstellt\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Sammlungen sind öffentlich. Alle Benutzer mit Zugriff auf die Bibliothek können sie sehen.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Keine RSS-Feeds geöffnet\",\n  \"MessageBookshelfNoResultsForFilter\": \"Keine Ergebnisse für Filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Keine Ergebnisse für die Abfrage\",\n  \"MessageBookshelfNoSeries\": \"Keine Serien vorhanden\",\n  \"MessageBulkChapterPattern\": \"Wie viele Kapitel mit diesem Nummerierungs-Muster sollen hinzugefügt werden?\",\n  \"MessageChapterEndIsAfter\": \"Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)\",\n  \"MessageChapterErrorFirstNotZero\": \"Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen\",\n  \"MessageChapterErrorStartGteDuration\": \"Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)\",\n  \"MessageChapterErrorStartLtPrev\": \"Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)\",\n  \"MessageChapterStartIsAfter\": \"Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)\",\n  \"MessageChaptersNotFound\": \"Kapitel gefunden nicht\",\n  \"MessageCheckingCron\": \"Überprüfe Cron...\",\n  \"MessageConfirmCloseFeed\": \"Feed wird geschlossen! Bist du dir sicher?\",\n  \"MessageConfirmDeleteApiKey\": \"Möchtest du den API-Schlüssel \\\"{0}\\\" wirklich entfernen ?\",\n  \"MessageConfirmDeleteBackup\": \"Sicherung für {0} wird gelöscht! Bist du dir sicher?\",\n  \"MessageConfirmDeleteDevice\": \"Möchtest du das Lesegerät „{0}“ wirklich löschen?\",\n  \"MessageConfirmDeleteFile\": \"Datei wird vom System gelöscht! Bist du dir sicher?\",\n  \"MessageConfirmDeleteLibrary\": \"Bibliothek \\\"{0}\\\" wird dauerhaft gelöscht! Bist du dir sicher?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Bibliothekselement wird aus der Datenbank + Festplatte gelöscht? Bist du dir sicher?\",\n  \"MessageConfirmDeleteLibraryItems\": \"{0} Bibliothekselemente werden aus der Datenbank + Festplatte gelöscht? Bist du dir sicher?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Möchtest du den benutzerdefinierten Metadatenanbieter \\\"{0}\\\" wirklich löschen?\",\n  \"MessageConfirmDeleteNotification\": \"Möchtest du diese Benachrichtigung wirklich löschen?\",\n  \"MessageConfirmDeleteSession\": \"Sitzung wird gelöscht! Bist du dir sicher?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Bist du dir sicher, dass die Metadaten in {0} Audiodateien eingebettet werden sollen?\",\n  \"MessageConfirmForceReScan\": \"Scanvorgang erzwingen! Bist du dir sicher?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Alle Episoden werden als abgeschlossen markiert! Bist du dir sicher?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Alle Episoden werden als nicht abgeschlossen markiert! Bist du dir sicher?\",\n  \"MessageConfirmMarkItemFinished\": \"Möchtest du \\\"{0}\\\" wirklich als fertig markieren?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Möchtest du \\\"{0}\\\" wirklich als nicht fertig markieren?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Alle Medien dieser Reihe werden als abgeschlossen markiert! Bist du dir sicher?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Alle Medien dieser Reihe werden als nicht abgeschlossen markiert! Bist du dir sicher?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Diese Benachrichtigung mit Testdaten abfeuern?\",\n  \"MessageConfirmPurgeCache\": \"Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?\",\n  \"MessageConfirmPurgeItemsCache\": \"Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?\",\n  \"MessageConfirmQuickEmbed\": \"Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?\",\n  \"MessageConfirmReScanLibraryItems\": \"{0} Elemente werden erneut gescannt! Bist du dir sicher?\",\n  \"MessageConfirmRemoveAllChapters\": \"Alle Kapitel werden entfernt! Bist du dir sicher?\",\n  \"MessageConfirmRemoveAuthor\": \"Autor \\\"{0}\\\" wird enfernt! Bist du dir sicher?\",\n  \"MessageConfirmRemoveCollection\": \"Sammlung \\\"{0}\\\" wird entfernt! Bist du dir sicher?\",\n  \"MessageConfirmRemoveEpisode\": \"Episode \\\"{0}\\\" wird entfernt! Bist du dir sicher?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \\\"Datei dauerhaft löschen\\\" ist aktiviert\",\n  \"MessageConfirmRemoveEpisodes\": \"{0} Episoden werden entfernt! Bist du dir sicher?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?\",\n  \"MessageConfirmRemoveNarrator\": \"Erzähler \\\"{0}\\\" wird entfernt! Bist du dir sicher?\",\n  \"MessageConfirmRemovePlaylist\": \"Wiedergabeliste \\\"{0}\\\" wird entfernt! Bist du dir sicher?\",\n  \"MessageConfirmRenameGenre\": \"Kategorie \\\"{0}\\\" in \\\"{1}\\\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.\",\n  \"MessageConfirmRenameGenreWarning\": \"Warnung! Ein ähnliche Kategorie mit einem anderen Wortlaut existiert bereits: \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Tag \\\"{0}\\\" in \\\"{1}\\\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.\",\n  \"MessageConfirmRenameTagWarning\": \"Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Möchtest du Ihren Fortschritt wirklich zurücksetzen?\",\n  \"MessageConfirmSendEbookToDevice\": \"{0} E-Buch „{1}“ wird auf das Gerät „{2}“ gesendet! Bist du dir sicher?\",\n  \"MessageConfirmUnlinkOpenId\": \"Möchtest du die Verknüpfung dieses Benutzers mit OpenID wirklich löschen?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} Tage in dem letzten Jahr gehört\",\n  \"MessageDownloadingEpisode\": \"Episode wird heruntergeladen\",\n  \"MessageDragFilesIntoTrackOrder\": \"Verschiebe die Dateien in die richtige Reihenfolge\",\n  \"MessageEmbedFailed\": \"Einbetten fehlgeschlagen!\",\n  \"MessageEmbedFinished\": \"Einbettung abgeschlossen!\",\n  \"MessageEmbedQueue\": \"Eingereiht zum einbinden von Metadaten ({0} in Warteschlange)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Episode(n) in der Warteschlange zum Herunterladen\",\n  \"MessageEreaderDevices\": \"Um die Zustellung von E-Büchern sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.\",\n  \"MessageFeedURLWillBe\": \"Feed-URL wird {0} sein\",\n  \"MessageFetching\": \"Wird abgerufen …\",\n  \"MessageForceReScanDescription\": \"Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} </strong> auf {1} gehört\",\n  \"MessageHeatmapNoListeningSessions\": \"Keine Hörsitzungen am {0}\",\n  \"MessageImportantNotice\": \"Wichtiger Hinweis!\",\n  \"MessageInsertChapterBelow\": \"Kapitel unten einfügen\",\n  \"MessageInvalidAsin\": \"Ungültige ASIN\",\n  \"MessageItemsSelected\": \"{0} ausgewählte Medien\",\n  \"MessageItemsUpdated\": \"{0} Medien aktualisiert\",\n  \"MessageJoinUsOn\": \"Besuche uns auf\",\n  \"MessageLoading\": \"Wird geladen …\",\n  \"MessageLoadingFolders\": \"Lade Ordner...\",\n  \"MessageLogsDescription\": \"Die Logs werdern in <code>/metadata/logs</code> als JSON Dateien gespeichert. Crash logs werden in <code>/metadata/logs/crash_logs.txt</code> gespeichert.\",\n  \"MessageM4BFailed\": \"M4B fehlgeschlagen!\",\n  \"MessageM4BFinished\": \"M4B beendet!\",\n  \"MessageMapChapterTitles\": \"Zuordnen von Kapiteltiteln zu deinen vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben\",\n  \"MessageMarkAllEpisodesFinished\": \"Alle Episoden als beendet markieren\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Alle Episoden als ungehört markieren\",\n  \"MessageMarkAsFinished\": \"Als beendet markieren\",\n  \"MessageMarkAsNotFinished\": \"Als nicht beendet markieren\",\n  \"MessageMatchBooksDescription\": \"Es wird versucht die Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen um leere Details und das Titelbild auszufüllen. Vorhandene Details werden nicht überschrieben.\",\n  \"MessageNoAudioTracks\": \"Keine Audiodateien\",\n  \"MessageNoAuthors\": \"Keine Autoren\",\n  \"MessageNoBackups\": \"Keine Sicherungen\",\n  \"MessageNoBookmarks\": \"Keine Lesezeichen\",\n  \"MessageNoChapters\": \"Keine Kapitel\",\n  \"MessageNoCollections\": \"Keine Sammlungen\",\n  \"MessageNoCoversFound\": \"Keine Titelbilder gefunden\",\n  \"MessageNoDescription\": \"Keine Beschreibung\",\n  \"MessageNoDevices\": \"Keine Geräte\",\n  \"MessageNoDownloadsInProgress\": \"Derzeit keine Downloads in Arbeit\",\n  \"MessageNoDownloadsQueued\": \"Keine Downloads in der Warteschlange\",\n  \"MessageNoEpisodeMatchesFound\": \"Keine Episodenübereinstimmungen gefunden\",\n  \"MessageNoEpisodes\": \"Keine Episoden\",\n  \"MessageNoFoldersAvailable\": \"Keine Ordner verfügbar\",\n  \"MessageNoGenres\": \"Keine Kategorien\",\n  \"MessageNoIssues\": \"Keine Probleme\",\n  \"MessageNoItems\": \"Keine Medien\",\n  \"MessageNoItemsFound\": \"Keine Medien gefunden\",\n  \"MessageNoListeningSessions\": \"Keine Hörsitzungen\",\n  \"MessageNoLogs\": \"Keine Protokolle\",\n  \"MessageNoMediaProgress\": \"Kein Medienfortschritt\",\n  \"MessageNoNotifications\": \"Keine Benachrichtigungen\",\n  \"MessageNoPodcastFeed\": \"Ungültiger Podcast: Kein Feed\",\n  \"MessageNoPodcastsFound\": \"Keine Podcasts gefunden\",\n  \"MessageNoResults\": \"Keine Ergebnisse\",\n  \"MessageNoSearchResultsFor\": \"Keine Suchergebnisse für \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Keine Serien\",\n  \"MessageNoTags\": \"Keine Tags\",\n  \"MessageNoTasksRunning\": \"Keine laufenden Aufgaben\",\n  \"MessageNoUpdatesWereNecessary\": \"Keine Aktualisierungen waren notwendig\",\n  \"MessageNoUserPlaylists\": \"Keine Wiedergabelisten vorhanden\",\n  \"MessageNoUserPlaylistsHelp\": \"Wiedergabelisten sind privat. Nur der Benutzer, der sie erstellt hat, kann sie sehen.\",\n  \"MessageNotYetImplemented\": \"Noch nicht implementiert\",\n  \"MessageOpmlPreviewNote\": \"Hinweis: Dies ist nur eine Vorschau der geparsten OPML Datei. Der eigentliche Podcast-Titel wird aus dem RSS-Feed übernommen.\",\n  \"MessageOr\": \"Oder\",\n  \"MessagePauseChapter\": \"Kapitelwiedergabe pausieren\",\n  \"MessagePlayChapter\": \"Kapitelanfang anhören\",\n  \"MessagePlaylistCreateFromCollection\": \"Erstelle eine Wiedergabeliste aus der Sammlung\",\n  \"MessagePleaseWait\": \"Bitte warten...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann\",\n  \"MessagePodcastSearchField\": \"Suchbegriff oder RSS-Feed URL eingeben\",\n  \"MessageQuickEmbedInProgress\": \"Schnellabgleich läuft\",\n  \"MessageQuickEmbedQueue\": \"In Warteschlange für Schnelles einbinden ({0} eingereiht)\",\n  \"MessageQuickMatchAllEpisodes\": \"Quick Match aller Episoden\",\n  \"MessageQuickMatchDescription\": \"Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \\\"Passende Metadaten bevorzugen\\\" ist aktiviert.\",\n  \"MessageRemoveChapter\": \"Kapitel entfernen\",\n  \"MessageRemoveEpisodes\": \"Entferne {0} Episode(n)\",\n  \"MessageRemoveFromPlayerQueue\": \"Aus der Abspielwarteliste entfernen\",\n  \"MessageRemoveUserWarning\": \"Benutzer \\\"{0}\\\" wird dauerhaft gelöscht! Bist du dir sicher?\",\n  \"MessageReportBugsAndContribute\": \"Fehler melden, Funktionen anfordern und mitwirken\",\n  \"MessageResetChaptersConfirm\": \"Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?\",\n  \"MessageRestoreBackupConfirm\": \"Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am\",\n  \"MessageRestoreBackupWarning\": \"Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.\",\n  \"MessageScheduleLibraryScanNote\": \"Für die meisten Benutzer wird empfohlen, diese Funktion deaktiviert und die Einstellung „Bibliothek automatisch auf Änderungen überwachen“ aktiviert zu lassen – dadurch werden Änderungen in Ihren Bibliotheksordnern automatisch erkannt. Aktivieren Sie diese Funktion, wenn „Bibliothek automatisch auf Änderungen überwachen“ für Ihr Dateisystem (wie NFS) nicht funktioniert.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Immer {0} um {1} ausführen\",\n  \"MessageSearchResultsFor\": \"Suchergebnisse für\",\n  \"MessageSelected\": \"{0} ausgewählt\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Serie Abfolge kann keine Leerzeichen enthalten\",\n  \"MessageServerCouldNotBeReached\": \"Server kann nicht erreicht werden\",\n  \"MessageSetChaptersFromTracksDescription\": \"Kapitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird\",\n  \"MessageShareExpirationWillBe\": \"Läuft am <strong>{0}</strong> ab\",\n  \"MessageShareExpiresIn\": \"Läuft in {0} ab\",\n  \"MessageShareURLWillBe\": \"Der Freigabe Link wird <strong>{0}</strong> sein\",\n  \"MessageStartPlaybackAtTime\": \"Start der Wiedergabe für \\\"{0}\\\" bei {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Die Audiodatei \\\"{0}\\\" ist schreibgeschützt\",\n  \"MessageTaskCanceledByUser\": \"Aufgabe vom Benutzer abgebrochen\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Folge \\\"{0}\\\" wird heruntergeladen\",\n  \"MessageTaskEmbeddingMetadata\": \"Metadaten werden eingebettet\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Metadaten werden in Hörbuch \\\"{0}\\\" eingebettet\",\n  \"MessageTaskEncodingM4b\": \"M4B wird encodiert\",\n  \"MessageTaskEncodingM4bDescription\": \"Hörbuch \\\"{0}\\\" wird in eine einzelne m4b Datei encodiert\",\n  \"MessageTaskFailed\": \"Fehlgeschlagen\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Sicherung der Audiodatei \\\"{0}\\\" fehlgeschlagen\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Fehler beim erstellen des Cache-Verzeichnisses\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Einbetten der Metadaten in die Datei \\\"{0}\\\" fehlgeschlagen\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Fehler beim zusammenführen der Audiodateien\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Fehler beim verschieben der m4b Datei\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Fehler beim schreiben der Metadaten-Datei\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Vergleiche Bücher in Bibliothek \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Keine Dateien zum scannen\",\n  \"MessageTaskOpmlImport\": \"OPML-Import\",\n  \"MessageTaskOpmlImportDescription\": \"Podcasts von {0} RSS-Feeds werden ersrtellt\",\n  \"MessageTaskOpmlImportFeed\": \"OPML-Feed importieren\",\n  \"MessageTaskOpmlImportFeedDescription\": \"RSS-Feed \\\"{0}\\\" wird importiert\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Podcast Feed konnte nicht geladen werden\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Podcast \\\"{0}\\\" wird erstellt\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Der Podcast ist bereits im Pfad vorhanden\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Erstellen des Podcasts fehlgeschlagen\",\n  \"MessageTaskOpmlImportFinished\": \"{0} Podcasts hinzugefügt\",\n  \"MessageTaskOpmlParseFailed\": \"Fehler beim lesen der OPML Datei\",\n  \"MessageTaskOpmlParseFastFail\": \"Ungültie OPML Datei: <opml> ODER <outline> tag wurde nicht gefunden\",\n  \"MessageTaskOpmlParseNoneFound\": \"Keine feeds in der OPML Datei gefunden\",\n  \"MessageTaskScanItemsAdded\": \"{0} hinzugefügt\",\n  \"MessageTaskScanItemsMissing\": \"{0} fehlend\",\n  \"MessageTaskScanItemsUpdated\": \"{0} aktualisiert\",\n  \"MessageTaskScanNoChangesNeeded\": \"Keine Änderungen nötig\",\n  \"MessageTaskScanningFileChanges\": \"Überprüfe \\\"{0}\\\" nach geänderten Dateien\",\n  \"MessageTaskScanningLibrary\": \"Bibliothek \\\"{0}\\\" wird durchsucht\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Das Zielverzeichnis ist schreibgeschützt\",\n  \"MessageThinking\": \"Nachdenken...\",\n  \"MessageUploaderItemFailed\": \"Hochladen fehlgeschlagen\",\n  \"MessageUploaderItemSuccess\": \"Erfolgreich hochgeladen!\",\n  \"MessageUploading\": \"Hochladen...\",\n  \"MessageValidCronExpression\": \"Gültiger Cron-Ausdruck\",\n  \"MessageWatcherIsDisabledGlobally\": \"Überwachung ist in den Servereinstellungen global deaktiviert\",\n  \"MessageXLibraryIsEmpty\": \"{0} Bibliothek ist leer!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Die Dauer deines Mediums ist länger als die gefundene Dauer\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Die Dauer deines Mediums ist kürzer als die gefundene Dauer\",\n  \"NoteChangeRootPassword\": \"Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann\",\n  \"NoteChapterEditorTimes\": \"Hinweis: Die Anfangszeit des ersten Kapitels muss bei 0:00 beginnen und die Anfangszeit des letzten Kapitels darf die Dauer des Mediums nicht überschreiten.\",\n  \"NoteFolderPicker\": \"Hinweis: Bereits zugeordnete Ordner werden nicht angezeigt\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Warnung: 1 oder mehrere deiner Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Wenn du nur Audiodateien hochlädst, wird jede Audiodatei als ein separates Medium behandelt.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.\",\n  \"NotificationOnBackupCompletedDescription\": \"Wird ausgeführt wenn ein Backup erstellt wurde\",\n  \"NotificationOnBackupFailedDescription\": \"Wird ausgeführt wenn ein Backup fehlgeschlagen ist\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Wird ausgeführt wenn eine Podcast Folge automatisch heruntergeladen wird\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Wird ausgeführt wenn automatische Downloads von Episoden wegen zu vielen fehlgeschlagenen Versuchen deaktiviert sind\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Wird ausgelöst, wenn die RSS-Feed-Anforderung für einen automatischen Episoden-Download fehlschlägt\",\n  \"NotificationOnTestDescription\": \"Wird ausgeführt wenn das Benachrichtigungssystem getestet wird\",\n  \"PlaceholderBulkChapterInput\": \"Kapitelbezeichnung eingeben oder Nummerierung verwenden (z.B. 'Episode 1', 'Kapitel 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Neuer Sammlungsname\",\n  \"PlaceholderNewFolderPath\": \"Neuer Ordnerpfad\",\n  \"PlaceholderNewPlaylist\": \"Neuer Wiedergabelistenname\",\n  \"PlaceholderSearch\": \"Suche...\",\n  \"PlaceholderSearchEpisode\": \"Suche Episode...\",\n  \"StatsAuthorsAdded\": \"Autoren hinzugefügt\",\n  \"StatsBooksAdded\": \"Bücher hinzugefügt\",\n  \"StatsBooksAdditional\": \"Einige der Neuzugänge umfassen…\",\n  \"StatsBooksFinished\": \"Beendete Bücher\",\n  \"StatsBooksFinishedThisYear\": \"Einige Bücher, die dieses Jahr beendet wurden…\",\n  \"StatsBooksListenedTo\": \"gehörte Bücher\",\n  \"StatsCollectionGrewTo\": \"Deine Büchersammlung ist gewachsen auf…\",\n  \"StatsSessions\": \"Sitzungen\",\n  \"StatsSpentListening\": \"zugehört\",\n  \"StatsTopAuthor\": \"TOP AUTOR\",\n  \"StatsTopAuthors\": \"TOP AUTOREN\",\n  \"StatsTopGenre\": \"TOP GENRE\",\n  \"StatsTopGenres\": \"TOP GENRES\",\n  \"StatsTopMonth\": \"TOP MONAT\",\n  \"StatsTopNarrator\": \"TOP SPRECHER\",\n  \"StatsTopNarrators\": \"TOP SPRECHER\",\n  \"StatsTotalDuration\": \"Mit einer totalen Dauer von…\",\n  \"StatsYearInReview\": \"DAS JAHR IM RÜCKBLICK\",\n  \"ToastAccountUpdateSuccess\": \"Konto aktualisiert\",\n  \"ToastAppriseUrlRequired\": \"Eine Apprise-URL ist notwendig\",\n  \"ToastAsinRequired\": \"ASIN ist erforderlich\",\n  \"ToastAuthorImageRemoveSuccess\": \"Autorenbild entfernt\",\n  \"ToastAuthorNotFound\": \"Autor \\\"{0}\\\" nicht gefunden\",\n  \"ToastAuthorRemoveSuccess\": \"Autor entfernt\",\n  \"ToastAuthorSearchNotFound\": \"Autor nicht gefunden\",\n  \"ToastAuthorUpdateMerged\": \"Autor zusammengeführt\",\n  \"ToastAuthorUpdateSuccess\": \"Autor aktualisiert\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor aktualisiert (kein Bild gefunden)\",\n  \"ToastBackupAppliedSuccess\": \"Backup anwenden\",\n  \"ToastBackupCreateFailed\": \"Sicherung konnte nicht erstellt werden\",\n  \"ToastBackupCreateSuccess\": \"Sicherung erstellt\",\n  \"ToastBackupDeleteFailed\": \"Sicherung konnte nicht gelöscht werden\",\n  \"ToastBackupDeleteSuccess\": \"Sicherung gelöscht\",\n  \"ToastBackupInvalidMaxKeep\": \"Ungültige Anzahl aufzubewahrender Backups\",\n  \"ToastBackupInvalidMaxSize\": \"Ungültige maximale Backupgröße\",\n  \"ToastBackupRestoreFailed\": \"Sicherung konnte nicht wiederhergestellt werden\",\n  \"ToastBackupUploadFailed\": \"Sicherung konnte nicht hochgeladen werden\",\n  \"ToastBackupUploadSuccess\": \"Sicherung hochgeladen\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Details auf Medien anwenden\",\n  \"ToastBatchDeleteFailed\": \"Batch-Löschen fehlgeschlagen\",\n  \"ToastBatchDeleteSuccess\": \"Batch-Löschung erfolgreich\",\n  \"ToastBatchQuickMatchFailed\": \"Batch-Schnellabgleich fehlgeschlagen!\",\n  \"ToastBatchQuickMatchStarted\": \"Batch-Schnellabgleich für {0} Bücher gestartet!\",\n  \"ToastBatchUpdateFailed\": \"Stapelaktualisierung fehlgeschlagen\",\n  \"ToastBatchUpdateSuccess\": \"Stapelaktualisierung erfolgreich\",\n  \"ToastBookmarkCreateFailed\": \"Lesezeichen konnte nicht erstellt werden\",\n  \"ToastBookmarkCreateSuccess\": \"Lesezeichen hinzugefügt\",\n  \"ToastBookmarkRemoveSuccess\": \"Lesezeichen entfernt\",\n  \"ToastBulkChapterInvalidCount\": \"Gebe eine Zahl zwischen 1 und 150 ein\",\n  \"ToastCachePurgeFailed\": \"Cache leeren fehlgeschlagen\",\n  \"ToastCachePurgeSuccess\": \"Cache geleert\",\n  \"ToastChapterLocked\": \"Kapitel ist freigegeben.\",\n  \"ToastChapterStartTimeAdjusted\": \"Kapitelbeginn um {0} Sekunden angepasst\",\n  \"ToastChaptersAllLocked\": \"Alle Kapitel sind gesperrt. Gebe einige Kapitel frei um die Zeiten anzupassen.\",\n  \"ToastChaptersHaveErrors\": \"Kapitel sind fehlerhaft\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Die Verschiebung ist nicht möglich, da die Startzeit des letzten Kapitels über die Gesamtdauer dieses Hörbuchs hinausgehen würde.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Ungültige Höhe der Verschiebung. Das erste Kapitel hätte eine Länge von Null oder eine negative Länge und würde vom zweiten Kapitel überschrieben werden. Erhöhe die Startdauer des zweiten Kapitels.\",\n  \"ToastChaptersMustHaveTitles\": \"Kapitel benötigen eindeutige Namen\",\n  \"ToastChaptersRemoved\": \"Kapitel entfernt\",\n  \"ToastChaptersUpdated\": \"Kapitel aktualisiert\",\n  \"ToastCollectionItemsAddFailed\": \"Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen\",\n  \"ToastCollectionRemoveSuccess\": \"Sammlung entfernt\",\n  \"ToastCollectionUpdateSuccess\": \"Sammlung aktualisiert\",\n  \"ToastConnectionNotAvailable\": \"Verbindung nicht möglich. Bitte später erneut versuchen\",\n  \"ToastCoverSearchFailed\": \"Cover-Suche fehlgeschlagen\",\n  \"ToastCoverUpdateFailed\": \"Cover-Update fehlgeschlagen\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Datum und Zeit sind ungültig oder unvollständig\",\n  \"ToastDeleteFileFailed\": \"Die Datei konnte nicht gelöscht werden\",\n  \"ToastDeleteFileSuccess\": \"Datei gelöscht\",\n  \"ToastDeviceAddFailed\": \"Gerät konnte nicht hinzugefügt werden\",\n  \"ToastDeviceNameAlreadyExists\": \"E-Reader-Gerät mit diesem Namen existiert bereits\",\n  \"ToastDeviceTestEmailFailed\": \"Senden der Test-E-Mail fehlgeschlagen\",\n  \"ToastDeviceTestEmailSuccess\": \"Test-E-Mail gesendet\",\n  \"ToastEmailSettingsUpdateSuccess\": \"E-Mail-Einstellungen aktualisiert\",\n  \"ToastEncodeCancelFailed\": \"Das Encoding konnte nicht abgebrochen werden\",\n  \"ToastEncodeCancelSucces\": \"Encoding abgebrochen\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Warteschlange konnte nicht gelöscht werden\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Warteschlange für Episoden-Downloads gelöscht\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} Episoden aktualisiert\",\n  \"ToastErrorCannotShare\": \"Das kann nicht nativ auf diesem Gerät freigegeben werden\",\n  \"ToastFailedToCreate\": \"Fehler beim Erzeugen\",\n  \"ToastFailedToDelete\": \"Fehler beim Löschen\",\n  \"ToastFailedToLoadData\": \"Fehler beim laden der Daten\",\n  \"ToastFailedToMatch\": \"Fehler beim Abgleich\",\n  \"ToastFailedToShare\": \"Fehler beim Teilen\",\n  \"ToastFailedToUpdate\": \"Aktualisierung ist fehlgeschlagen\",\n  \"ToastInvalidImageUrl\": \"Ungültiger Bild URL\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Ungültige Max. Anzahl an Episoden zum Herunterladen\",\n  \"ToastInvalidUrl\": \"Ungültiger URL\",\n  \"ToastInvalidUrls\": \"Eine oder mehrere URLs sind ungültig\",\n  \"ToastItemCoverUpdateSuccess\": \"Titelbild aktualisiert\",\n  \"ToastItemDeletedFailed\": \"Fehler beim löschen des Artikels\",\n  \"ToastItemDeletedSuccess\": \"Artikel gelöscht\",\n  \"ToastItemDetailsUpdateSuccess\": \"Artikeldetails aktualisiert\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Fehler bei der Markierung des Artikels als \\\"Beendet\\\"\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Artikel als \\\"Beendet\\\" markiert\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Fehler bei der Markierung des Artikels als \\\"Nicht Beendet\\\"\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Artikel als \\\"Nicht Beendet\\\" markiert\",\n  \"ToastItemUpdateSuccess\": \"Artikel wurde verändert\",\n  \"ToastLibraryCreateFailed\": \"Bibliothek konnte nicht erstellt werden\",\n  \"ToastLibraryCreateSuccess\": \"Bibliothek \\\"{0}\\\" erstellt\",\n  \"ToastLibraryDeleteFailed\": \"Bibliothek konnte nicht gelöscht werden\",\n  \"ToastLibraryDeleteSuccess\": \"Bibliothek gelöscht\",\n  \"ToastLibraryScanFailedToStart\": \"Scan konnte nicht gestartet werden\",\n  \"ToastLibraryScanStarted\": \"Bibliotheksscan gestartet\",\n  \"ToastLibraryUpdateSuccess\": \"Bibliothek \\\"{0}\\\" aktualisiert\",\n  \"ToastMatchAllAuthorsFailed\": \"Nicht alle Autoren konnten zugeordnet werden\",\n  \"ToastMetadataFilesRemovedError\": \"Fehler beim löschen von metadata.{0} Dateien\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Keine metadata.{0} Dateien in Bibliothek gefunden\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Keine metadata.{0} Dateien gelöscht\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} Datei(en) gelöscht\",\n  \"ToastMustHaveAtLeastOnePath\": \"Es muss mindestens ein Pfad angegeben werden\",\n  \"ToastNameEmailRequired\": \"Name und E-Mail sind erforderlich\",\n  \"ToastNameRequired\": \"Name ist erforderlich\",\n  \"ToastNewApiKeyUserError\": \"Bitte wähle einen Benutzer aus (Pflichtfeld)\",\n  \"ToastNewEpisodesFound\": \"{0} neue Episoden gefunden\",\n  \"ToastNewUserCreatedFailed\": \"Fehler beim erstellen des Accounts: \\\"{ 0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Neuer Account erstellt\",\n  \"ToastNewUserLibraryError\": \"Mindestens eine Bibliothek muss ausgewählt werden\",\n  \"ToastNewUserPasswordError\": \"Passwort erforderlich, nur der root Benutzer darf ein leeres Passwort haben\",\n  \"ToastNewUserTagError\": \"Mindestens ein Tag muss ausgewählt sein\",\n  \"ToastNewUserUsernameError\": \"Nutzername eingeben\",\n  \"ToastNoNewEpisodesFound\": \"Keine neuen Episoden gefunden\",\n  \"ToastNoRSSFeed\": \"Podcast hat keinen RSS-Feed\",\n  \"ToastNoUpdatesNecessary\": \"Keine Änderungen nötig\",\n  \"ToastNotificationCreateFailed\": \"Fehler beim erstellen der Benachrichtig\",\n  \"ToastNotificationDeleteFailed\": \"Fehler beim löschen der Benachrichtigung\",\n  \"ToastNotificationFailedMaximum\": \"Maximale Fehlversuche muss >= 0 sein\",\n  \"ToastNotificationQueueMaximum\": \"Maximale Benachrichtigungswarteschlange muss >= 0 sein\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Benachrichtigungseinstellungen geändert\",\n  \"ToastNotificationTestTriggerFailed\": \"Fehler beim Auslösen der Testbenachrichtigung\",\n  \"ToastNotificationTestTriggerSuccess\": \"Testbenachrichtigung ausgelöst\",\n  \"ToastNotificationUpdateSuccess\": \"Benachrichtigung geändert\",\n  \"ToastPlaylistCreateFailed\": \"Erstellen der Wiedergabeliste fehlgeschlagen\",\n  \"ToastPlaylistCreateSuccess\": \"Wiedergabeliste erstellt\",\n  \"ToastPlaylistRemoveSuccess\": \"Wiedergabeliste gelöscht\",\n  \"ToastPlaylistUpdateSuccess\": \"Wiedergabeliste aktualisiert\",\n  \"ToastPodcastCreateFailed\": \"Podcast konnte nicht erstellt werden\",\n  \"ToastPodcastCreateSuccess\": \"Podcast erstellt\",\n  \"ToastPodcastEpisodeUpdated\": \"Podcast-Folge aktualisiert\",\n  \"ToastPodcastGetFeedFailed\": \"Fehler beim abrufen des Podcast-Feeds\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Keine Episoden in RSS Feed gefunden\",\n  \"ToastPodcastNoRssFeed\": \"Podcast enthält keinen RSS Feed\",\n  \"ToastProgressIsNotBeingSynced\": \"Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet\",\n  \"ToastProviderCreatedFailed\": \"Fehler beim Hinzufügen des Anbieters\",\n  \"ToastProviderCreatedSuccess\": \"Neuer Anbieter hinzugefügt\",\n  \"ToastProviderNameAndUrlRequired\": \"Name und URL notwendig\",\n  \"ToastProviderRemoveSuccess\": \"Anbieter entfernt\",\n  \"ToastRSSFeedCloseFailed\": \"RSS-Feed konnte nicht geschlossen werden\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-Feed geschlossen\",\n  \"ToastRemoveFailed\": \"Fehler beim entfernen\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Löschen des Mediums aus der Sammlung fehlgeschlagen\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Medium aus der Sammlung gelöscht\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Entfernen von fehlerhaften Bibliotheksartikeln fehlgeschlagenen\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Fehlerhafte Bibliotheksartikel entfernt\",\n  \"ToastRenameFailed\": \"Umbenennen fehlgeschlagen\",\n  \"ToastRescanFailed\": \"Erneut scannen fehlgeschlagen für {0}\",\n  \"ToastRescanRemoved\": \"Erneut scannen erledigt, Artikel wurde entfernt\",\n  \"ToastRescanUpToDate\": \"Erneut scannen erledigt, Artikel war auf dem neusten Stand\",\n  \"ToastRescanUpdated\": \"Erneut scannen erledigt, Artikel wurde verändert\",\n  \"ToastScanFailed\": \"Fehler beim scannen des Artikels der Bibliothek\",\n  \"ToastSelectAtLeastOneUser\": \"Wähle mindestens einen Benutzer aus\",\n  \"ToastSendEbookToDeviceFailed\": \"E-Buch konnte nicht auf Gerät übertragen werden\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-Buch an Gerät „{0}“ gesendet\",\n  \"ToastSeriesSubmitFailedSameName\": \"Serien mit dem selben Namen können nicht hinzugefügt werden\",\n  \"ToastSeriesUpdateFailed\": \"Aktualisierung der Serien fehlgeschlagen\",\n  \"ToastSeriesUpdateSuccess\": \"Serien aktualisiert\",\n  \"ToastServerSettingsUpdateSuccess\": \"Die Server-Einstellungen wurden geupdated\",\n  \"ToastSessionCloseFailed\": \"Fehler beim schließen der Sitzung\",\n  \"ToastSessionDeleteFailed\": \"Sitzung konnte nicht gelöscht werden\",\n  \"ToastSessionDeleteSuccess\": \"Sitzung gelöscht\",\n  \"ToastSleepTimerDone\": \"Einschlaf-Timer aktiviert... zZzzZz\",\n  \"ToastSlugMustChange\": \"URL-Schlüssel enthält ungültige Zeichen\",\n  \"ToastSlugRequired\": \"URL-Schlüssel erforderlich\",\n  \"ToastSocketConnected\": \"Verbindung zum WebSocket hergestellt\",\n  \"ToastSocketDisconnected\": \"Verbindung zum WebSocket verloren\",\n  \"ToastSocketFailedToConnect\": \"Verbindung zum WebSocket fehlgeschlagen\",\n  \"ToastSortingPrefixesEmptyError\": \"Es muss mindestens ein Sortier-Prefix vorhanden sein\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Die Sortier-Prefixe wirden geupdated ({0} Einträge)\",\n  \"ToastTitleRequired\": \"Titel erforderlich\",\n  \"ToastUnknownError\": \"Unbekannter Fehler\",\n  \"ToastUnlinkOpenIdFailed\": \"Fehler beim entkoppeln des Benutzers von OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Benutzer entkoppelt von OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Dateipfad \\\"{0}\\\" ist bereits auf dem Server horhanden\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Element \\\"{0}\\\" verwendet ein Unterverzeichnis des Upload-Pfads.\",\n  \"ToastUserDeleteFailed\": \"Benutzer konnte nicht gelöscht werden\",\n  \"ToastUserDeleteSuccess\": \"Benutzer gelöscht\",\n  \"ToastUserPasswordChangeSuccess\": \"Passwort erfolgreich verändert\",\n  \"ToastUserPasswordMismatch\": \"Passwörter stimmen nicht überein\",\n  \"ToastUserPasswordMustChange\": \"Neues Passwort muss sich von altem Passwort unterscheiden\",\n  \"ToastUserRootRequireName\": \"Root Benutzername muss angegeben werden\",\n  \"TooltipAddChapters\": \"Kapitel hinzufügen\",\n  \"TooltipAddOneSecond\": \"1 Sekunde hinzufügen\",\n  \"TooltipAdjustChapterStart\": \"Klicke um die Startzeit anzupassen\",\n  \"TooltipLockAllChapters\": \"Alle Kapitel sperren\",\n  \"TooltipLockChapter\": \"Kapitel sperren (Shift+Klick für mehrere)\",\n  \"TooltipSubtractOneSecond\": \"1 Sekunde abziehen\",\n  \"TooltipUnlockAllChapters\": \"Alle Kapitel freigeben\",\n  \"TooltipUnlockChapter\": \"Kapitel freigeben (Shift+Klick für mehrere)\"\n}\n"
  },
  {
    "path": "client/strings/el.json",
    "content": "{\n  \"ButtonAdd\": \"Προσθήκη\",\n  \"ButtonAddApiKey\": \"Προσθήκη Κλειδιού API\",\n  \"ButtonAddChapters\": \"Προσθήκη Κεφαλαίων\",\n  \"ButtonAddDevice\": \"Προσθήκη Συσκευής\",\n  \"ButtonAddLibrary\": \"Προσθήκη Βιβλιοθήκης\",\n  \"ButtonAddPodcasts\": \"Προσθήκη Podcasts\",\n  \"ButtonAddUser\": \"Προσθήκη Χρήστη\",\n  \"ButtonAddYourFirstLibrary\": \"Πρόσθεσε την πρώτη σου βιβλιοθήκη\",\n  \"ButtonApply\": \"Εφαρμογή\",\n  \"ButtonApplyChapters\": \"Εφαρμογή Κεφαλαίων\",\n  \"ButtonAuthors\": \"Συγγραφείς\",\n  \"ButtonBack\": \"Πίσω\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Συμπλήρωση από υπάρχοντα\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Συμπλήρωση λεπτομερειών χάρτη\",\n  \"ButtonBrowseForFolder\": \"Περιήγηση για Φάκελο\",\n  \"ButtonCancel\": \"Ακύρωση\",\n  \"ButtonCancelEncode\": \"Ακύρωση Κωδικοποίησης\",\n  \"ButtonChangeRootPassword\": \"Αλλαγή Κωδικού Πρόσβασης Root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Έλεγχος και Κατέβασμα Νέων Επεισοδίων\",\n  \"ButtonChooseAFolder\": \"Επιλογή φακέλου\",\n  \"ButtonChooseFiles\": \"Επιλογή αρχείων\",\n  \"ButtonClearFilter\": \"Διαγραφή Φίλτρου\",\n  \"ButtonClose\": \"Κλείσιμο\",\n  \"ButtonCloseFeed\": \"Κλείσιμο Τροφοδοσίας\",\n  \"ButtonCloseSession\": \"Κλείσιμο Ανοιχτής Συνεδρίας\",\n  \"ButtonCollections\": \"Συλλογές\",\n  \"ButtonConfigureScanner\": \"Ρύθμιση Παραμέτρων Σαρωτή\",\n  \"ButtonCreate\": \"Δημιουργία\",\n  \"ButtonCreateBackup\": \"Δημιουργία Αντιγράφου Ασφαλείας\",\n  \"ButtonDelete\": \"Διαγραφή\",\n  \"ButtonDownloadQueue\": \"Ουρά\",\n  \"ButtonEdit\": \"Επεξεργασία\",\n  \"ButtonEditChapters\": \"Επεξεργασία Κεφαλαίων\",\n  \"ButtonEditPodcast\": \"Επεξεργασία Podcast\",\n  \"ButtonEnable\": \"Ενεργοποίηση\",\n  \"ButtonForceReScan\": \"Αναγκαστική Επανάληψη Σάρωσης\",\n  \"ButtonFullPath\": \"Πλήρης Διαδρομή\",\n  \"ButtonHide\": \"Απόκρυψη\",\n  \"ButtonHome\": \"Αρχική\",\n  \"ButtonIssues\": \"Θέματα\",\n  \"ButtonJumpBackward\": \"Μεταπήδηση Πίσω\",\n  \"ButtonJumpForward\": \"Μεταπήδηση Μπροστά\",\n  \"ButtonLatest\": \"Τελευταία\",\n  \"ButtonLibrary\": \"Βιβλιοθήκη\",\n  \"ButtonLogout\": \"Αποσύνδεση\",\n  \"ButtonLookup\": \"Εύρεση\",\n  \"ButtonManageTracks\": \"Διαχείριση Κομματιών\",\n  \"ButtonMapChapterTitles\": \"Χαρτογράφηση Τίτλων Κεφαλαίων\",\n  \"ButtonMatchAllAuthors\": \"Αντιστοίχιση Όλων των Συγγραφέων\",\n  \"ButtonMatchBooks\": \"Αντιστοίχιση Βιβλίων\",\n  \"ButtonNevermind\": \"Άστο\",\n  \"ButtonNext\": \"Επόμενο\",\n  \"ButtonNextChapter\": \"Επόμενο Κεφάλαιο\",\n  \"ButtonNextItemInQueue\": \"Επόμενο Αντικείμενο στην Ουρά\",\n  \"ButtonOk\": \"Εντάξει\",\n  \"ButtonOpenFeed\": \"Άνοιγμα Τροφοδοσίας\",\n  \"ButtonOpenManager\": \"Άνοιγμα Διαχειριστή\",\n  \"ButtonPause\": \"Παύση\",\n  \"ButtonPlay\": \"Αναπαραγωγή\",\n  \"ButtonPlayAll\": \"Αναπαραγωγή Όλων\",\n  \"ButtonPlaying\": \"Αναπαράγεται\",\n  \"ButtonPlaylists\": \"Λίστες Αναπαραγωγής\",\n  \"ButtonPrevious\": \"Προηγούμενο\",\n  \"ButtonPreviousChapter\": \"Προηγούμενο Κεφάλαιο\",\n  \"ButtonProbeAudioFile\": \"Ανάλυση Αρχείου Ήχου\",\n  \"ButtonPurgeAllCache\": \"Εκκαθάριση Όλης της Προσωρινής Μνήμης\",\n  \"ButtonPurgeItemsCache\": \"Εκκαθάριση της Μνήμης Αντικειμένων\",\n  \"ButtonQueueAddItem\": \"Προσθήκη στην ουρά\",\n  \"ButtonQueueRemoveItem\": \"Αφαίρεση απ'την ουρά\",\n  \"ButtonQuickEmbed\": \"Γρήγορη Ενσωμάτωση\",\n  \"ButtonQuickEmbedMetadata\": \"Γρήγορη Ενσωμάτωση Μεταδεδομένων\",\n  \"ButtonQuickMatch\": \"Γρήγορη Αντιστοίχηση\",\n  \"ButtonReScan\": \"Επανασάρωση\",\n  \"ButtonRead\": \"Ανάγνωση\",\n  \"ButtonReadLess\": \"Ανάγνωση λιγότερων\",\n  \"ButtonReadMore\": \"Διάβασε περισσότερα\",\n  \"ButtonRefresh\": \"Ανανέωση\",\n  \"ButtonRemove\": \"Αφαίρεση\",\n  \"ButtonRemoveAll\": \"Αφαίρεση Όλων\",\n  \"ButtonRemoveAllLibraryItems\": \"Αφαίρεση Όλων των Αντικειμέων Βιβλιοθήκης\",\n  \"ButtonRemoveFromContinueListening\": \"Αφαίρεση από τη Συνέχεια Ακρόασης\",\n  \"ButtonRemoveFromContinueReading\": \"Αφαίρεση από τη Συνέχεια Ανάγνωσης\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Αφαίρεση Σειράς από τη Συνέχεια Σειράς\",\n  \"ButtonReset\": \"Επαναφορά\",\n  \"ButtonResetToDefault\": \"Επαναφορά στις προεπιλογές\",\n  \"ButtonRestore\": \"Επαναφορά\",\n  \"ButtonSave\": \"Αποθήκευση\",\n  \"ButtonSaveAndClose\": \"Αποθήκευση και Κλείσιμο\",\n  \"ButtonSaveTracklist\": \"Αποθήκευση Λίστας Κομματιών\",\n  \"ButtonScan\": \"Σάρψση\",\n  \"ButtonScanLibrary\": \"Σάρωση Βιβλιοθήκης\",\n  \"ButtonScrollLeft\": \"Κύλιση Αριστερά\",\n  \"ButtonScrollRight\": \"Κύλιση Δεξιά\",\n  \"ButtonSearch\": \"Αναζήτηση\",\n  \"ButtonSelectFolderPath\": \"Επιλογή Διαδρομής Φακέλου\",\n  \"ButtonSeries\": \"Σειρά\",\n  \"ButtonSetChaptersFromTracks\": \"Ορισμός κεφαλαίων από τα κομμάτια\",\n  \"ButtonShare\": \"Κοινοποίηση\",\n  \"ButtonShiftTimes\": \"Χρόνοι Μετακίνησης\",\n  \"ButtonShow\": \"Εμφάνιση\",\n  \"ButtonStartM4BEncode\": \"Έναρξη Κωδικοποίησης M4B\",\n  \"ButtonStartMetadataEmbed\": \"Έναρξη Ενσωμάτωσης Μεταδεδομένων\",\n  \"ButtonStats\": \"Στατιστικά\",\n  \"ButtonSubmit\": \"Υποβολή\",\n  \"ButtonTest\": \"Δοκιμή\",\n  \"ButtonUnlinkOpenId\": \"Αποσύνδεση OpenID\",\n  \"ButtonUpload\": \"Μεταφόρτωση\",\n  \"ButtonUploadBackup\": \"Μεταφόρτωση Αντιγράφου Ασφαλείας\",\n  \"ButtonUploadCover\": \"Μεταφόρτωση Εξωφύλλου\",\n  \"ButtonUploadOPMLFile\": \"Μεταφόρτωση Αρχείου OPML\",\n  \"ButtonUserDelete\": \"Διαγραφή Χρήστη {0}\",\n  \"ButtonUserEdit\": \"Επεξεργασίας χρήστη {0}\",\n  \"ButtonViewAll\": \"Εμφάνιση Όλων\",\n  \"ButtonYes\": \"Ναι\",\n  \"ErrorUploadFetchMetadataAPI\": \"Σφάλμα κατά την ανάκτηση μεταδεδομένων\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Δεν ήταν δυνατή η ανάκτηση των μεταδεδομένων - δοκιμάστε να ενημερώσετε τον τίτλο και/ή τον συγγραφέα\",\n  \"ErrorUploadLacksTitle\": \"Πρέπει να έχει τίτλο\",\n  \"HeaderAccount\": \"Λογαριασμός\",\n  \"HeaderAddCustomMetadataProvider\": \"Προσθήκη Προσαρμοσμένου Παρόχου Μεταδεδομένων\",\n  \"HeaderAdvanced\": \"Για Προχωρημένους\",\n  \"HeaderApiKeys\": \"Κλειδιά API\",\n  \"HeaderAppriseNotificationSettings\": \"Ρυθμίσεις Ειδοποιήσεων Apprise\",\n  \"HeaderAudioTracks\": \"Κομμάτια Ήχου\",\n  \"HeaderAudiobookTools\": \"Εργαλεία Διαχείρισης Αρχείων Audiobooks\",\n  \"HeaderAuthentication\": \"Αυθεντικοποίηση\",\n  \"HeaderBackups\": \"Αντίγραφα Ασφαλείας\",\n  \"HeaderBulkChapterModal\": \"Προσθήκη Πολλαπλών Κεφαλαίων\",\n  \"HeaderChangePassword\": \"Αλλαγή Κωδικού Πρόσβασης\",\n  \"HeaderChapters\": \"Κεφάλαια\",\n  \"HeaderChooseAFolder\": \"Επιλογή Φακέλου\",\n  \"HeaderCollection\": \"Συλλογή\",\n  \"HeaderCollectionItems\": \"Αντικείμενα Συλλογής\",\n  \"HeaderCover\": \"Εξώφυλλο\",\n  \"HeaderCurrentDownloads\": \"Τρέχουσες Λήψεις\",\n  \"HeaderDetails\": \"Λεπτομέρειες\",\n  \"HeaderDownloadQueue\": \"Ουρά Λήψης\",\n  \"HeaderEbookFiles\": \"Αρχεία Ebook\",\n  \"HeaderEmail\": \"Ηλεκτρονικό Ταχυδρομίο\",\n  \"HeaderEmailSettings\": \"Ρυθμίσεις Ηλεκτρονικού Ταχυδρομίου\",\n  \"HeaderEpisodes\": \"Επεισόδια\",\n  \"HeaderEreaderSettings\": \"Ρυθμίσεις Ereader\",\n  \"HeaderFiles\": \"Αρχεία\",\n  \"HeaderFindChapters\": \"Εύρεση Κεφαλαίων\",\n  \"HeaderItemFiles\": \"Αρχεία Αντικειμένων\",\n  \"HeaderLastListeningSession\": \"Τελευταία Συνεδρία Ακρόασης\",\n  \"HeaderLatestEpisodes\": \"Τελευταία Επεισόδια\",\n  \"HeaderLibraries\": \"Βιβλιοθήκες\",\n  \"HeaderLibraryFiles\": \"Αρχεία Βιβλιοθήκης\",\n  \"HeaderLibraryStats\": \"Στατιστικά Βιβλιοθήκης\",\n  \"HeaderListeningSessions\": \"Συνεδρίες Ακρόασης\",\n  \"HeaderListeningStats\": \"Στατιστικά Ακρόασης\",\n  \"HeaderMatch\": \"Ταύτιση\",\n  \"HeaderNewAccount\": \"Νέος Λογαριασμός\",\n  \"HeaderNewApiKey\": \"Νέο Κλειδί API\",\n  \"HeaderNewLibrary\": \"Νέα Βιβλιοθήκη\",\n  \"HeaderNotificationCreate\": \"Δημιουργία Ειδοποίησης\",\n  \"HeaderNotificationUpdate\": \"Ενημέρωση Ειδοποίησης\",\n  \"HeaderNotifications\": \"Ειδοποιήσεις\",\n  \"HeaderOpenRSSFeed\": \"Άνοιγμα Τροφοδοσίας RSS\",\n  \"HeaderOtherFiles\": \"Άλλα Αρχεία\",\n  \"HeaderPermissions\": \"Δικαιώματα\",\n  \"HeaderPlayerSettings\": \"Ρυθμίσεις Αναπαραγωγής\",\n  \"HeaderPlaylist\": \"Λίστα Αναπαραγωγής\",\n  \"HeaderPlaylistItems\": \"Αντικείμενα Λίστας Αναπαραγωγής\",\n  \"HeaderPresets\": \"Προεπιλογές\",\n  \"HeaderRSSFeedGeneral\": \"Λεπτομέρειες RSS\",\n  \"HeaderRSSFeedIsOpen\": \"Η Τροφοδοσία RSS είναι Ανοιχτή\",\n  \"HeaderRemoveEpisode\": \"Αφαίρεση Επεισοδίου\",\n  \"HeaderSession\": \"Συνεδρία\",\n  \"HeaderSetBackupSchedule\": \"Ορισμός Προγράμματος Αντιγράφων Ασφαλείας\",\n  \"HeaderSettings\": \"Ρυθμίσεις\",\n  \"HeaderSettingsDisplay\": \"Προβολή\",\n  \"HeaderSettingsGeneral\": \"Γενικά\",\n  \"HeaderSettingsSecurity\": \"Ασφάλεια\",\n  \"HeaderSleepTimer\": \"Χρονοδιακόπτης Ύπνου\",\n  \"HeaderStatsLargestItems\": \"Μεγαλύτερα Αντικείμενα\",\n  \"HeaderStatsLongestItems\": \"Μεγαλύτερα Αντικείμενα (ώρες)\",\n  \"HeaderStatsMinutesListeningChart\": \"Λεπτά Ακρόασης (τελευταίες 7 ημέρες)\",\n  \"HeaderStatsRecentSessions\": \"Πρόσφατες Συνεδρίες\",\n  \"HeaderStatsTop10Authors\": \"10 Κορυφαίου Συγγραφείς\",\n  \"HeaderStatsTop5Genres\": \"5 Κορυφαία Είδη\",\n  \"HeaderTableOfContents\": \"Πίνακας Περιεχομένων\",\n  \"HeaderTools\": \"Εργαλεία\",\n  \"HeaderUpdateAccount\": \"Ενημέρωση Λογαριασμού\",\n  \"HeaderUpdateApiKey\": \"Ενημέρωση Κλειδιού API\",\n  \"HeaderUpdateAuthor\": \"Ενημέρωση Συγγραφέα\",\n  \"HeaderUpdateDetails\": \"Ενημέρωση Λεπτομερειεών\",\n  \"HeaderUpdateLibrary\": \"Ενημέρωση Βιβλιοθήκης\",\n  \"HeaderUsers\": \"Χρήστες\",\n  \"HeaderYourStats\": \"Τα Στατιστικά Σας\",\n  \"LabelAbridged\": \"Συνοπτικό\",\n  \"LabelAccessibleBy\": \"Προσβάσιμο από\",\n  \"LabelAccountType\": \"Τύπος Λογαριασμού\",\n  \"LabelAccountTypeAdmin\": \"Διαχειριστής\",\n  \"LabelAccountTypeGuest\": \"Επισκέπτης\",\n  \"LabelAccountTypeUser\": \"Χρήστης\",\n  \"LabelAddToCollection\": \"Προσθήκη σε Συλλογή\",\n  \"LabelAddToCollectionBatch\": \"Προσθήκη {0} Βιβλίων στην Συλλογή\",\n  \"LabelAddToPlaylist\": \"Προσθήκη στην Λίστα Αναπαραγωγής\",\n  \"LabelAddedAt\": \"Προστέθηκε Στις\",\n  \"LabelAddedDate\": \"Προστέθηκε {0}\",\n  \"LabelAll\": \"Όλα\",\n  \"LabelAllEpisodesDownloaded\": \"Όλα τα επεισόδια λήφθηκαν\",\n  \"LabelAllUsers\": \"Όλοι οι Χρήστες\",\n  \"LabelAlreadyInYourLibrary\": \"Υπάρχει ήδη στην βιβλιοθήκη\",\n  \"LabelAudioChannels\": \"Κανάλια Ήχου (1 ή 2)\",\n  \"LabelAuthor\": \"Συγγραφέας\",\n  \"LabelAuthorFirstLast\": \"Συγγραφέας (Όνομα Επώνυμο)\",\n  \"LabelAuthorLastFirst\": \"Συγγραφέας (Επώνυμο, Όνομα)\",\n  \"LabelAuthors\": \"Συγγραφείς\",\n  \"LabelAutoDownloadEpisodes\": \"Αυτόματο Κατέβασμα Επεισοδίων\",\n  \"LabelAutoLaunch\": \"Αυτόματη Εκκίνηση\",\n  \"LabelBackupLocation\": \"Τοποθεσία Αντιγράφου Ασφαλείας\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Αυτόματα αντίγραφα ασφαλείας\",\n  \"LabelBackupsNumberToKeep\": \"Αριθμός αντιγράφων ασφαλείας προς διατήρηση\",\n  \"LabelBooks\": \"Βιβλία\",\n  \"LabelButtonText\": \"Κείμενο Κουμπιού\",\n  \"LabelByAuthor\": \"κατά {0}\",\n  \"LabelChangePassword\": \"Αλλαγή Κωδικού Πρόσβασης\",\n  \"LabelChannels\": \"Κανάλια\",\n  \"LabelChapterCount\": \"{0} Κεφάλαια\",\n  \"LabelChapterTitle\": \"Τίτλος Κεφαλαίου\",\n  \"LabelChapters\": \"Κεφάλαια\",\n  \"LabelChaptersFound\": \"κεφάλαια βρέθηκαν\",\n  \"LabelClosePlayer\": \"Κλείσιμο αναπαραγωγής\",\n  \"LabelCollapseSeries\": \"Σύμπτυξη Σειράς\",\n  \"LabelCollection\": \"Συλλογή\",\n  \"LabelCollections\": \"Συλλογές\",\n  \"LabelComplete\": \"Ολοκλήρωση\",\n  \"LabelConfirmPassword\": \"Επιβεβαίωση Κωδικού Πρόσβασης\",\n  \"LabelContinueListening\": \"Συνέχεια Ακρόασης\",\n  \"LabelContinueReading\": \"Συνέχεια Ανάγνωσης\",\n  \"LabelContinueSeries\": \"Συνέχεια Σειράς\",\n  \"LabelCover\": \"Εξώφυλλο\",\n  \"LabelCoverImageURL\": \"URL Εικόνας Εξωφύλλου\",\n  \"LabelCoverProvider\": \"Πάροχος Εξωφύλλου\",\n  \"LabelCreatedAt\": \"Δημιουρήθηκε Στις\",\n  \"LabelCurrent\": \"Τρέχων\",\n  \"LabelCurrently\": \"Τρέχων:\",\n  \"LabelDays\": \"Ημέρες\",\n  \"LabelDescription\": \"Περιγραφή\",\n  \"LabelDevice\": \"Συσκευή\",\n  \"LabelDeviceInfo\": \"Πληροφορίες Συσκευής\",\n  \"LabelDownload\": \"Λήψη\",\n  \"LabelDownloadNEpisodes\": \"Λήψη {0} επεισοδίων\",\n  \"LabelDuration\": \"Διάρκεια\",\n  \"LabelDurationComparisonExactMatch\": \"(ακριβής ταύτιση)\",\n  \"LabelEbook\": \"Ebook\",\n  \"LabelEbooks\": \"Ebooks\",\n  \"LabelEdit\": \"Επεξεργασία\",\n  \"LabelEmail\": \"Ηλεκτρονικό Ταχυδρομίο\",\n  \"LabelEmailSettingsFromAddress\": \"Από Διεύθυνση\",\n  \"LabelEmailSettingsSecure\": \"Ασφαλές\",\n  \"LabelEmailSettingsTestAddress\": \"Δοκιμή Διεύθυνσης\",\n  \"LabelEmbeddedCover\": \"Ενσωματωμένο Εξώφυλλο\",\n  \"LabelEnable\": \"Ενεργοποίηση\",\n  \"LabelEnd\": \"Τέλος\",\n  \"LabelEndOfChapter\": \"Τέλος Κεφαλαίου\",\n  \"LabelEpisode\": \"Επεισόδιο\",\n  \"LabelFile\": \"Αρχείο\",\n  \"LabelFilename\": \"Όνομα Αρχείου\",\n  \"LabelFinished\": \"Ολοκληρώθηκε\",\n  \"LabelFolder\": \"Φάκελος\",\n  \"LabelFontFamily\": \"Οικογένεια Γραμματοσειράς\",\n  \"LabelGenre\": \"Είδος\",\n  \"LabelGenres\": \"Είδη\",\n  \"LabelHost\": \"Διακομιστής\",\n  \"LabelInProgress\": \"Σε Εξέλιξη\",\n  \"LabelLanguage\": \"Γλώσσα\",\n  \"LabelLayoutSinglePage\": \"Μονή Σελίδα\",\n  \"LabelListenAgain\": \"Επανάληψη Ακρόασης\",\n  \"LabelMediaType\": \"Τύπος Πολυμέσων\",\n  \"LabelMore\": \"Περισσότερα\",\n  \"LabelMoreInfo\": \"Περισσότερες Πληροφορίες\",\n  \"LabelName\": \"Όνομα\",\n  \"LabelNarrator\": \"Αφηγητής\",\n  \"LabelNarrators\": \"Αφηγητές\",\n  \"LabelNewestAuthors\": \"Πρόσφατοι Συγγραφείς\",\n  \"LabelNewestEpisodes\": \"Πρόσφατα Επεισόδια\",\n  \"LabelNotStarted\": \"Δεν Έχει Ξεκινήσει\",\n  \"LabelNumberOfEpisodes\": \"# Επεισοδίων\",\n  \"LabelPassword\": \"Κωδικός Πρόσβασης\",\n  \"LabelPath\": \"Διαδρομή\",\n  \"LabelProgress\": \"Πρόοδος\",\n  \"LabelPublishYear\": \"Χρονολογία Έκδοσης\",\n  \"LabelPublishedDate\": \"Εκδόθηκε {0}\",\n  \"LabelRandomly\": \"Τυχαία\",\n  \"LabelRead\": \"Ανάγνωση\",\n  \"LabelReadAgain\": \"Ανάγνωση Ξανά\",\n  \"LabelRecentSeries\": \"Πρόσφατη Σειρά\",\n  \"LabelRecentlyAdded\": \"Προστέθηκαν Πρόσφατα\",\n  \"LabelSeries\": \"Σειρά\",\n  \"LabelSetEbookAsPrimary\": \"Ορισμός ως πρωτεύων\",\n  \"LabelShowAll\": \"Εμφάνιση Όλων\",\n  \"LabelSize\": \"Μέγεθος\",\n  \"LabelSleepTimer\": \"Χρονοδιακόπτης Ύπνου\",\n  \"LabelStart\": \"Έναρξη\",\n  \"LabelStatsBestDay\": \"Καλύτερη Ημέρα\",\n  \"LabelStatsDailyAverage\": \"Ημερήσιος Μέσος Όρος\",\n  \"LabelStatsDays\": \"Ημέρες\",\n  \"LabelStatsDaysListened\": \"Ημέρες Ακρόασης\",\n  \"LabelStatsInARow\": \"Σε σειρά\",\n  \"LabelStatsItemsFinished\": \"Ολοκληρωμένα Αντικείμενα\",\n  \"LabelStatsMinutes\": \"λεπτά\",\n  \"LabelStatsMinutesListening\": \"Λεπτά Ακρόασης\",\n  \"LabelStatsWeekListening\": \"Εβδομαδιαία Ακρόαση\",\n  \"LabelTheme\": \"Θέμα\",\n  \"LabelThemeDark\": \"Σκοτεινό\",\n  \"LabelThemeLight\": \"Φωτεινό\",\n  \"LabelTimeRemaining\": \"{0} απομένουν\",\n  \"LabelTitle\": \"Τίτλος\",\n  \"LabelTracks\": \"Κομμάτια\",\n  \"LabelType\": \"Τύπος\",\n  \"LabelUnknown\": \"Άγνωστο\",\n  \"LabelUser\": \"Χρήστης\",\n  \"LabelUsername\": \"Όνομα Χρήστη\",\n  \"LabelYourProgress\": \"Η Πρόοδος Σας\",\n  \"MessageDownloadingEpisode\": \"Λήψη επεισοδίου\",\n  \"MessageLoading\": \"Φόρτωση...\",\n  \"MessageMarkAsFinished\": \"Σήμανση ως Ολοκληρωμένο\",\n  \"MessageNoItemsFound\": \"Δεν βρέθηκαν αντικείμενα\",\n  \"MessageNoUserPlaylists\": \"Δεν έχετε λίστες αναπαραγωγής\"\n}\n"
  },
  {
    "path": "client/strings/en-us.json",
    "content": "{\n  \"ButtonAdd\": \"Add\",\n  \"ButtonAddApiKey\": \"Add API Key\",\n  \"ButtonAddChapters\": \"Add Chapters\",\n  \"ButtonAddDevice\": \"Add Device\",\n  \"ButtonAddLibrary\": \"Add Library\",\n  \"ButtonAddPodcasts\": \"Add Podcasts\",\n  \"ButtonAddUser\": \"Add User\",\n  \"ButtonAddYourFirstLibrary\": \"Add your first library\",\n  \"ButtonApply\": \"Apply\",\n  \"ButtonApplyChapters\": \"Apply Chapters\",\n  \"ButtonAuthors\": \"Authors\",\n  \"ButtonBack\": \"Back\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Populate from existing\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Populate map details\",\n  \"ButtonBrowseForFolder\": \"Browse for Folder\",\n  \"ButtonCancel\": \"Cancel\",\n  \"ButtonCancelEncode\": \"Cancel Encode\",\n  \"ButtonChangeRootPassword\": \"Change Root Password\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Check & Download New Episodes\",\n  \"ButtonChooseAFolder\": \"Choose a folder\",\n  \"ButtonChooseFiles\": \"Choose files\",\n  \"ButtonClearFilter\": \"Clear Filter\",\n  \"ButtonClose\": \"Close\",\n  \"ButtonCloseFeed\": \"Close Feed\",\n  \"ButtonCloseSession\": \"Close Open Session\",\n  \"ButtonCollections\": \"Collections\",\n  \"ButtonConfigureScanner\": \"Configure Scanner\",\n  \"ButtonCreate\": \"Create\",\n  \"ButtonCreateBackup\": \"Create Backup\",\n  \"ButtonDelete\": \"Delete\",\n  \"ButtonDownloadQueue\": \"Queue\",\n  \"ButtonEdit\": \"Edit\",\n  \"ButtonEditChapters\": \"Edit Chapters\",\n  \"ButtonEditPodcast\": \"Edit Podcast\",\n  \"ButtonEnable\": \"Enable\",\n  \"ButtonFireAndFail\": \"Fire and Fail\",\n  \"ButtonFireOnTest\": \"Fire onTest event\",\n  \"ButtonForceReScan\": \"Force Re-Scan\",\n  \"ButtonFullPath\": \"Full Path\",\n  \"ButtonHide\": \"Hide\",\n  \"ButtonHome\": \"Home\",\n  \"ButtonIssues\": \"Issues\",\n  \"ButtonJumpBackward\": \"Jump Backward\",\n  \"ButtonJumpForward\": \"Jump Forward\",\n  \"ButtonLatest\": \"Latest\",\n  \"ButtonLibrary\": \"Library\",\n  \"ButtonLogout\": \"Logout\",\n  \"ButtonLookup\": \"Lookup\",\n  \"ButtonManageTracks\": \"Manage Tracks\",\n  \"ButtonMapChapterTitles\": \"Map Chapter Titles\",\n  \"ButtonMatchAllAuthors\": \"Match All Authors\",\n  \"ButtonMatchBooks\": \"Match Books\",\n  \"ButtonNevermind\": \"Nevermind\",\n  \"ButtonNext\": \"Next\",\n  \"ButtonNextChapter\": \"Next Chapter\",\n  \"ButtonNextItemInQueue\": \"Next Item in Queue\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Open Feed\",\n  \"ButtonOpenManager\": \"Open Manager\",\n  \"ButtonPause\": \"Pause\",\n  \"ButtonPlay\": \"Play\",\n  \"ButtonPlayAll\": \"Play All\",\n  \"ButtonPlaying\": \"Playing\",\n  \"ButtonPlaylists\": \"Playlists\",\n  \"ButtonPrevious\": \"Previous\",\n  \"ButtonPreviousChapter\": \"Previous Chapter\",\n  \"ButtonProbeAudioFile\": \"Probe Audio File\",\n  \"ButtonPurgeAllCache\": \"Purge All Cache\",\n  \"ButtonPurgeItemsCache\": \"Purge Items Cache\",\n  \"ButtonQueueAddItem\": \"Add to queue\",\n  \"ButtonQueueRemoveItem\": \"Remove from queue\",\n  \"ButtonQuickEmbed\": \"Quick Embed\",\n  \"ButtonQuickEmbedMetadata\": \"Quick Embed Metadata\",\n  \"ButtonQuickMatch\": \"Quick Match\",\n  \"ButtonReScan\": \"Re-Scan\",\n  \"ButtonRead\": \"Read\",\n  \"ButtonReadLess\": \"Read less\",\n  \"ButtonReadMore\": \"Read more\",\n  \"ButtonRefresh\": \"Refresh\",\n  \"ButtonRemove\": \"Remove\",\n  \"ButtonRemoveAll\": \"Remove All\",\n  \"ButtonRemoveAllLibraryItems\": \"Remove All Library Items\",\n  \"ButtonRemoveFromContinueListening\": \"Remove from Continue Listening\",\n  \"ButtonRemoveFromContinueReading\": \"Remove from Continue Reading\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Remove Series from Continue Series\",\n  \"ButtonReset\": \"Reset\",\n  \"ButtonResetToDefault\": \"Reset to default\",\n  \"ButtonRestore\": \"Restore\",\n  \"ButtonSave\": \"Save\",\n  \"ButtonSaveAndClose\": \"Save & Close\",\n  \"ButtonSaveTracklist\": \"Save Tracklist\",\n  \"ButtonScan\": \"Scan\",\n  \"ButtonScanLibrary\": \"Scan Library\",\n  \"ButtonScrollLeft\": \"Scroll Left\",\n  \"ButtonScrollRight\": \"Scroll Right\",\n  \"ButtonSearch\": \"Search\",\n  \"ButtonSelectFolderPath\": \"Select Folder Path\",\n  \"ButtonSeries\": \"Series\",\n  \"ButtonSetChaptersFromTracks\": \"Set chapters from tracks\",\n  \"ButtonShare\": \"Share\",\n  \"ButtonShiftTimes\": \"Shift Times\",\n  \"ButtonShow\": \"Show\",\n  \"ButtonStartM4BEncode\": \"Start M4B Encode\",\n  \"ButtonStartMetadataEmbed\": \"Start Metadata Embed\",\n  \"ButtonStats\": \"Stats\",\n  \"ButtonSubmit\": \"Submit\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Unlink OpenID\",\n  \"ButtonUpload\": \"Upload\",\n  \"ButtonUploadBackup\": \"Upload Backup\",\n  \"ButtonUploadCover\": \"Upload Cover\",\n  \"ButtonUploadOPMLFile\": \"Upload OPML File\",\n  \"ButtonUserDelete\": \"Delete user {0}\",\n  \"ButtonUserEdit\": \"Edit user {0}\",\n  \"ButtonViewAll\": \"View All\",\n  \"ButtonYes\": \"Yes\",\n  \"ErrorUploadFetchMetadataAPI\": \"Error fetching metadata\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Could not fetch metadata - try updating title and/or author\",\n  \"ErrorUploadLacksTitle\": \"Must have a title\",\n  \"HeaderAccount\": \"Account\",\n  \"HeaderAddCustomMetadataProvider\": \"Add Custom Metadata Provider\",\n  \"HeaderAdvanced\": \"Advanced\",\n  \"HeaderApiKeys\": \"API Keys\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise Notification Settings\",\n  \"HeaderAudioTracks\": \"Audio Tracks\",\n  \"HeaderAudiobookTools\": \"Audiobook File Management Tools\",\n  \"HeaderAuthentication\": \"Authentication\",\n  \"HeaderBackups\": \"Backups\",\n  \"HeaderBulkChapterModal\": \"Add Multiple Chapters\",\n  \"HeaderChangePassword\": \"Change Password\",\n  \"HeaderChapters\": \"Chapters\",\n  \"HeaderChooseAFolder\": \"Choose a Folder\",\n  \"HeaderCollection\": \"Collection\",\n  \"HeaderCollectionItems\": \"Collection Items\",\n  \"HeaderCover\": \"Cover\",\n  \"HeaderCurrentDownloads\": \"Current Downloads\",\n  \"HeaderCustomMessageOnLogin\": \"Custom Message on Login\",\n  \"HeaderCustomMetadataProviders\": \"Custom Metadata Providers\",\n  \"HeaderDetails\": \"Details\",\n  \"HeaderDownloadQueue\": \"Download Queue\",\n  \"HeaderEbookFiles\": \"Ebook Files\",\n  \"HeaderEmail\": \"Email\",\n  \"HeaderEmailSettings\": \"Email Settings\",\n  \"HeaderEpisodes\": \"Episodes\",\n  \"HeaderEreaderDevices\": \"Ereader Devices\",\n  \"HeaderEreaderSettings\": \"Ereader Settings\",\n  \"HeaderFiles\": \"Files\",\n  \"HeaderFindChapters\": \"Find Chapters\",\n  \"HeaderIgnoredFiles\": \"Ignored Files\",\n  \"HeaderItemFiles\": \"Item Files\",\n  \"HeaderItemMetadataUtils\": \"Item Metadata Utils\",\n  \"HeaderLastListeningSession\": \"Last Listening Session\",\n  \"HeaderLatestEpisodes\": \"Latest episodes\",\n  \"HeaderLibraries\": \"Libraries\",\n  \"HeaderLibraryFiles\": \"Library Files\",\n  \"HeaderLibraryStats\": \"Library Stats\",\n  \"HeaderListeningSessions\": \"Listening Sessions\",\n  \"HeaderListeningStats\": \"Listening Stats\",\n  \"HeaderLogin\": \"Login\",\n  \"HeaderLogs\": \"Logs\",\n  \"HeaderManageGenres\": \"Manage Genres\",\n  \"HeaderManageTags\": \"Manage Tags\",\n  \"HeaderMapDetails\": \"Map details\",\n  \"HeaderMatch\": \"Match\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metadata order of precedence\",\n  \"HeaderMetadataToEmbed\": \"Metadata to embed\",\n  \"HeaderNewAccount\": \"New Account\",\n  \"HeaderNewApiKey\": \"New API Key\",\n  \"HeaderNewLibrary\": \"New Library\",\n  \"HeaderNotificationCreate\": \"Create Notification\",\n  \"HeaderNotificationUpdate\": \"Update Notification\",\n  \"HeaderNotifications\": \"Notifications\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect Authentication\",\n  \"HeaderOpenListeningSessions\": \"Open Listening Sessions\",\n  \"HeaderOpenRSSFeed\": \"Open RSS Feed\",\n  \"HeaderOtherFiles\": \"Other Files\",\n  \"HeaderPasswordAuthentication\": \"Password Authentication\",\n  \"HeaderPermissions\": \"Permissions\",\n  \"HeaderPlayerQueue\": \"Player Queue\",\n  \"HeaderPlayerSettings\": \"Player Settings\",\n  \"HeaderPlaylist\": \"Playlist\",\n  \"HeaderPlaylistItems\": \"Playlist Items\",\n  \"HeaderPodcastsToAdd\": \"Podcasts to Add\",\n  \"HeaderPresets\": \"Presets\",\n  \"HeaderPreviewCover\": \"Preview Cover\",\n  \"HeaderRSSFeedGeneral\": \"RSS Details\",\n  \"HeaderRSSFeedIsOpen\": \"RSS Feed is Open\",\n  \"HeaderRSSFeeds\": \"RSS Feeds\",\n  \"HeaderRemoveEpisode\": \"Remove Episode\",\n  \"HeaderRemoveEpisodes\": \"Remove {0} Episodes\",\n  \"HeaderSavedMediaProgress\": \"Saved Media Progress\",\n  \"HeaderSchedule\": \"Schedule\",\n  \"HeaderScheduleEpisodeDownloads\": \"Schedule Automatic Episode Downloads\",\n  \"HeaderScheduleLibraryScans\": \"Schedule Automatic Library Scans\",\n  \"HeaderSession\": \"Session\",\n  \"HeaderSetBackupSchedule\": \"Set Backup Schedule\",\n  \"HeaderSettings\": \"Settings\",\n  \"HeaderSettingsDisplay\": \"Display\",\n  \"HeaderSettingsExperimental\": \"Experimental Features\",\n  \"HeaderSettingsGeneral\": \"General\",\n  \"HeaderSettingsScanner\": \"Scanner\",\n  \"HeaderSettingsSecurity\": \"Security\",\n  \"HeaderSettingsWebClient\": \"Web Client\",\n  \"HeaderSleepTimer\": \"Sleep Timer\",\n  \"HeaderStatsLargestItems\": \"Largest Items\",\n  \"HeaderStatsLongestItems\": \"Longest Items (hrs)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minutes Listening (last 7 days)\",\n  \"HeaderStatsRecentSessions\": \"Recent Sessions\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Authors\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Genres\",\n  \"HeaderTableOfContents\": \"Table of Contents\",\n  \"HeaderTools\": \"Tools\",\n  \"HeaderUpdateAccount\": \"Update Account\",\n  \"HeaderUpdateApiKey\": \"Update API Key\",\n  \"HeaderUpdateAuthor\": \"Update Author\",\n  \"HeaderUpdateDetails\": \"Update Details\",\n  \"HeaderUpdateLibrary\": \"Update Library\",\n  \"HeaderUsers\": \"Users\",\n  \"HeaderYearReview\": \"Year {0} in Review\",\n  \"HeaderYourStats\": \"Your Stats\",\n  \"LabelAbridged\": \"Abridged\",\n  \"LabelAbridgedChecked\": \"Abridged (checked)\",\n  \"LabelAbridgedUnchecked\": \"Unabridged (unchecked)\",\n  \"LabelAccessibleBy\": \"Accessible by\",\n  \"LabelAccountType\": \"Account Type\",\n  \"LabelAccountTypeAdmin\": \"Admin\",\n  \"LabelAccountTypeGuest\": \"Guest\",\n  \"LabelAccountTypeUser\": \"User\",\n  \"LabelActivities\": \"Activities\",\n  \"LabelActivity\": \"Activity\",\n  \"LabelAddToCollection\": \"Add to Collection\",\n  \"LabelAddToCollectionBatch\": \"Add {0} Books to Collection\",\n  \"LabelAddToPlaylist\": \"Add to Playlist\",\n  \"LabelAddToPlaylistBatch\": \"Add {0} Items to Playlist\",\n  \"LabelAddedAt\": \"Added At\",\n  \"LabelAddedDate\": \"Added {0}\",\n  \"LabelAdminUsersOnly\": \"Admin users only\",\n  \"LabelAll\": \"All\",\n  \"LabelAllEpisodesDownloaded\": \"All episodes downloaded\",\n  \"LabelAllUsers\": \"All Users\",\n  \"LabelAllUsersExcludingGuests\": \"All users excluding guests\",\n  \"LabelAllUsersIncludingGuests\": \"All users including guests\",\n  \"LabelAlreadyInYourLibrary\": \"Already in your library\",\n  \"LabelApiKeyCreated\": \"API Key \\\"{0}\\\" created successfully.\",\n  \"LabelApiKeyCreatedDescription\": \"Make sure to copy the API key now as you will not be able to see this again.\",\n  \"LabelApiKeyUser\": \"Act on behalf of user\",\n  \"LabelApiKeyUserDescription\": \"This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Append\",\n  \"LabelAudioBitrate\": \"Audio Bitrate (e.g. 128k)\",\n  \"LabelAudioChannels\": \"Audio Channels (1 or 2)\",\n  \"LabelAudioCodec\": \"Audio Codec\",\n  \"LabelAuthor\": \"Author\",\n  \"LabelAuthorFirstLast\": \"Author (First Last)\",\n  \"LabelAuthorLastFirst\": \"Author (Last, First)\",\n  \"LabelAuthors\": \"Authors\",\n  \"LabelAutoDownloadEpisodes\": \"Auto Download Episodes\",\n  \"LabelAutoFetchMetadata\": \"Auto Fetch Metadata\",\n  \"LabelAutoFetchMetadataHelp\": \"Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.\",\n  \"LabelAutoLaunch\": \"Auto Launch\",\n  \"LabelAutoLaunchDescription\": \"Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Auto Register\",\n  \"LabelAutoRegisterDescription\": \"Automatically create new users after logging in\",\n  \"LabelBackToUser\": \"Back to User\",\n  \"LabelBackupAudioFiles\": \"Backup Audio Files\",\n  \"LabelBackupLocation\": \"Backup Location\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatic backups\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Backups saved in /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maximum backup size (in GB) (0 for unlimited)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"As a safeguard against misconfiguration, backups will fail if they exceed the configured size.\",\n  \"LabelBackupsNumberToKeep\": \"Number of backups to keep\",\n  \"LabelBackupsNumberToKeepHelp\": \"Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Books\",\n  \"LabelButtonText\": \"Button Text\",\n  \"LabelByAuthor\": \"by {0}\",\n  \"LabelChangePassword\": \"Change Password\",\n  \"LabelChannels\": \"Channels\",\n  \"LabelChapterCount\": \"{0} Chapters\",\n  \"LabelChapterTitle\": \"Chapter Title\",\n  \"LabelChapters\": \"Chapters\",\n  \"LabelChaptersFound\": \"chapters found\",\n  \"LabelClickForMoreInfo\": \"Click for more info\",\n  \"LabelClickToUseCurrentValue\": \"Click to use current value\",\n  \"LabelClosePlayer\": \"Close player\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Collapse Series\",\n  \"LabelCollapseSubSeries\": \"Collapse Sub Series\",\n  \"LabelCollection\": \"Collection\",\n  \"LabelCollections\": \"Collections\",\n  \"LabelComplete\": \"Complete\",\n  \"LabelConfirmPassword\": \"Confirm Password\",\n  \"LabelContinueListening\": \"Continue Listening\",\n  \"LabelContinueReading\": \"Continue Reading\",\n  \"LabelContinueSeries\": \"Continue Series\",\n  \"LabelCorsAllowed\": \"Allowed CORS Origins\",\n  \"LabelCover\": \"Cover\",\n  \"LabelCoverImageURL\": \"Cover Image URL\",\n  \"LabelCoverProvider\": \"Cover Provider\",\n  \"LabelCreatedAt\": \"Created At\",\n  \"LabelCronExpression\": \"Cron Expression\",\n  \"LabelCurrent\": \"Current\",\n  \"LabelCurrently\": \"Currently:\",\n  \"LabelCustomCronExpression\": \"Custom Cron Expression:\",\n  \"LabelDatetime\": \"Datetime\",\n  \"LabelDays\": \"Days\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Delete from file system (uncheck to only remove from database)\",\n  \"LabelDescription\": \"Description\",\n  \"LabelDeselectAll\": \"Deselect All\",\n  \"LabelDetectedPattern\": \"Detected pattern:\",\n  \"LabelDevice\": \"Device\",\n  \"LabelDeviceInfo\": \"Device Info\",\n  \"LabelDeviceIsAvailableTo\": \"Device is available to...\",\n  \"LabelDirectory\": \"Directory\",\n  \"LabelDiscFromFilename\": \"Disc from Filename\",\n  \"LabelDiscFromMetadata\": \"Disc from Metadata\",\n  \"LabelDiscover\": \"Discover\",\n  \"LabelDownload\": \"Download\",\n  \"LabelDownloadNEpisodes\": \"Download {0} episodes\",\n  \"LabelDownloadable\": \"Downloadable\",\n  \"LabelDuration\": \"Duration\",\n  \"LabelDurationComparisonExactMatch\": \"(exact match)\",\n  \"LabelDurationComparisonLonger\": \"({0} longer)\",\n  \"LabelDurationComparisonShorter\": \"({0} shorter)\",\n  \"LabelDurationFound\": \"Duration found:\",\n  \"LabelEbook\": \"Ebook\",\n  \"LabelEbooks\": \"Ebooks\",\n  \"LabelEdit\": \"Edit\",\n  \"LabelEmail\": \"Email\",\n  \"LabelEmailSettingsFromAddress\": \"From Address\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Reject unauthorized certificates\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.\",\n  \"LabelEmailSettingsSecure\": \"Secure\",\n  \"LabelEmailSettingsSecureHelp\": \"If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Test Address\",\n  \"LabelEmbeddedCover\": \"Embedded Cover\",\n  \"LabelEnable\": \"Enable\",\n  \"LabelEncodingBackupLocation\": \"A backup of your original audio files will be stored in:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Chapters are not embedded in multi-track audiobooks.\",\n  \"LabelEncodingClearItemCache\": \"Make sure to periodically purge items cache.\",\n  \"LabelEncodingFinishedM4B\": \"Finished M4B will be put into your audiobook folder at:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadata will be embedded in the audio tracks inside your audiobook folder.\",\n  \"LabelEncodingStartedNavigation\": \"Once the task is started you can navigate away from this page.\",\n  \"LabelEncodingTimeWarning\": \"Encoding can take up to 30 minutes.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.\",\n  \"LabelEncodingWatcherDisabled\": \"If you have the watcher disabled you will need to re-scan this audiobook afterwards.\",\n  \"LabelEnd\": \"End\",\n  \"LabelEndOfChapter\": \"End of Chapter\",\n  \"LabelEpisode\": \"Episode\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episode not linked to RSS feed\",\n  \"LabelEpisodeNumber\": \"Episode #{0}\",\n  \"LabelEpisodeTitle\": \"Episode Title\",\n  \"LabelEpisodeType\": \"Episode Type\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Episode URL from RSS feed\",\n  \"LabelEpisodes\": \"Episodes\",\n  \"LabelEpisodic\": \"Episodic\",\n  \"LabelExample\": \"Example\",\n  \"LabelExpandSeries\": \"Expand Series\",\n  \"LabelExpandSubSeries\": \"Expand Sub Series\",\n  \"LabelExpired\": \"Expired\",\n  \"LabelExpiresAt\": \"Expires At\",\n  \"LabelExpiresInSeconds\": \"Expires in (seconds)\",\n  \"LabelExpiresNever\": \"Never\",\n  \"LabelExplicit\": \"Explicit\",\n  \"LabelExplicitChecked\": \"Explicit (checked)\",\n  \"LabelExplicitUnchecked\": \"Not Explicit (unchecked)\",\n  \"LabelExportOPML\": \"Export OPML\",\n  \"LabelFeedURL\": \"Feed URL\",\n  \"LabelFetchingMetadata\": \"Fetching Metadata\",\n  \"LabelFile\": \"File\",\n  \"LabelFileBirthtime\": \"File Birthtime\",\n  \"LabelFileBornDate\": \"Born {0}\",\n  \"LabelFileModified\": \"File Modified\",\n  \"LabelFileModifiedDate\": \"Modified {0}\",\n  \"LabelFilename\": \"Filename\",\n  \"LabelFilterByUser\": \"Filter by User\",\n  \"LabelFindEpisodes\": \"Find Episodes\",\n  \"LabelFinished\": \"Finished\",\n  \"LabelFinishedDate\": \"Finished {0}\",\n  \"LabelFolder\": \"Folder\",\n  \"LabelFolders\": \"Folders\",\n  \"LabelFontBold\": \"Bold\",\n  \"LabelFontBoldness\": \"Font Boldness\",\n  \"LabelFontFamily\": \"Font family\",\n  \"LabelFontItalic\": \"Italic\",\n  \"LabelFontScale\": \"Font scale\",\n  \"LabelFontStrikethrough\": \"Strikethrough\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Full\",\n  \"LabelGenre\": \"Genre\",\n  \"LabelGenres\": \"Genres\",\n  \"LabelHardDeleteFile\": \"Hard delete file\",\n  \"LabelHasEbook\": \"Has ebook\",\n  \"LabelHasSupplementaryEbook\": \"Has supplementary ebook\",\n  \"LabelHideSubtitles\": \"Hide Subtitles\",\n  \"LabelHighestPriority\": \"Highest priority\",\n  \"LabelHost\": \"Host\",\n  \"LabelHour\": \"Hour\",\n  \"LabelHours\": \"Hours\",\n  \"LabelIcon\": \"Icon\",\n  \"LabelImageURLFromTheWeb\": \"Image URL from the web\",\n  \"LabelInProgress\": \"In Progress\",\n  \"LabelIncludeInTracklist\": \"Include in Tracklist\",\n  \"LabelIncomplete\": \"Incomplete\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Custom daily/weekly\",\n  \"LabelIntervalEvery12Hours\": \"Every 12 hours\",\n  \"LabelIntervalEvery15Minutes\": \"Every 15 minutes\",\n  \"LabelIntervalEvery2Hours\": \"Every 2 hours\",\n  \"LabelIntervalEvery30Minutes\": \"Every 30 minutes\",\n  \"LabelIntervalEvery6Hours\": \"Every 6 hours\",\n  \"LabelIntervalEveryDay\": \"Every day\",\n  \"LabelIntervalEveryHour\": \"Every hour\",\n  \"LabelIntervalEveryMinute\": \"Every minute\",\n  \"LabelInvert\": \"Invert\",\n  \"LabelItem\": \"Item\",\n  \"LabelJumpBackwardAmount\": \"Jump backward amount\",\n  \"LabelJumpForwardAmount\": \"Jump forward amount\",\n  \"LabelLanguage\": \"Language\",\n  \"LabelLanguageDefaultServer\": \"Default Server Language\",\n  \"LabelLanguages\": \"Languages\",\n  \"LabelLastBookAdded\": \"Last Book Added\",\n  \"LabelLastBookUpdated\": \"Last Book Updated\",\n  \"LabelLastProgressDate\": \"Last progress: {0}\",\n  \"LabelLastSeen\": \"Last Seen\",\n  \"LabelLastTime\": \"Last Time\",\n  \"LabelLastUpdate\": \"Last Update\",\n  \"LabelLayout\": \"Layout\",\n  \"LabelLayoutSinglePage\": \"Single page\",\n  \"LabelLayoutSplitPage\": \"Split page\",\n  \"LabelLess\": \"Less\",\n  \"LabelLibrariesAccessibleToUser\": \"Libraries Accessible to User\",\n  \"LabelLibrary\": \"Library\",\n  \"LabelLibraryFilterSublistEmpty\": \"No {0}\",\n  \"LabelLibraryItem\": \"Library Item\",\n  \"LabelLibraryName\": \"Library Name\",\n  \"LabelLibrarySortByProgress\": \"Progress: Last Updated\",\n  \"LabelLibrarySortByProgressFinished\": \"Progress: Finished\",\n  \"LabelLibrarySortByProgressStarted\": \"Progress: Started\",\n  \"LabelLimit\": \"Limit\",\n  \"LabelLineSpacing\": \"Line spacing\",\n  \"LabelListenAgain\": \"Listen Again\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Warn\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Look for new episodes after this date\",\n  \"LabelLowestPriority\": \"Lowest Priority\",\n  \"LabelMatchConfidence\": \"Confidence\",\n  \"LabelMatchExistingUsersBy\": \"Match existing users by\",\n  \"LabelMatchExistingUsersByDescription\": \"Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider\",\n  \"LabelMaxEpisodesToDownload\": \"Max # of episodes to download. Use 0 for unlimited.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Max # of new episodes to download per check\",\n  \"LabelMaxEpisodesToKeep\": \"Max # of episodes to keep\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. This will only delete 1 episode per new download.\",\n  \"LabelMediaPlayer\": \"Media Player\",\n  \"LabelMediaType\": \"Media Type\",\n  \"LabelMetaTag\": \"Meta Tag\",\n  \"LabelMetaTags\": \"Meta Tags\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Higher priority metadata sources will override lower priority metadata sources\",\n  \"LabelMetadataProvider\": \"Metadata Provider\",\n  \"LabelMinute\": \"Minute\",\n  \"LabelMinutes\": \"Minutes\",\n  \"LabelMissing\": \"Missing\",\n  \"LabelMissingEbook\": \"Has no ebook\",\n  \"LabelMissingSupplementaryEbook\": \"Has no supplementary ebook\",\n  \"LabelMobileRedirectURIs\": \"Allowed Mobile Redirect URIs\",\n  \"LabelMobileRedirectURIsDescription\": \"This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.\",\n  \"LabelMore\": \"More\",\n  \"LabelMoreInfo\": \"More Info\",\n  \"LabelName\": \"Name\",\n  \"LabelNarrator\": \"Narrator\",\n  \"LabelNarrators\": \"Narrators\",\n  \"LabelNew\": \"New\",\n  \"LabelNewPassword\": \"New Password\",\n  \"LabelNewestAuthors\": \"Newest Authors\",\n  \"LabelNewestEpisodes\": \"Newest Episodes\",\n  \"LabelNextBackupDate\": \"Next backup date\",\n  \"LabelNextChapters\": \"Next chapters will be:\",\n  \"LabelNextScheduledRun\": \"Next scheduled run\",\n  \"LabelNoApiKeys\": \"No API keys\",\n  \"LabelNoCustomMetadataProviders\": \"No custom metadata providers\",\n  \"LabelNoEpisodesSelected\": \"No episodes selected\",\n  \"LabelNotFinished\": \"Not Finished\",\n  \"LabelNotStarted\": \"Not Started\",\n  \"LabelNotes\": \"Notes\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(s)\",\n  \"LabelNotificationAvailableVariables\": \"Available variables\",\n  \"LabelNotificationBodyTemplate\": \"Body Template\",\n  \"LabelNotificationEvent\": \"Notification Event\",\n  \"LabelNotificationTitleTemplate\": \"Title Template\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Max failed attempts\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Notifications are disabled once they fail to send this many times\",\n  \"LabelNotificationsMaxQueueSize\": \"Max queue size for notification events\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.\",\n  \"LabelNumberOfBooks\": \"Number of Books\",\n  \"LabelNumberOfChapters\": \"Number of chapters:\",\n  \"LabelNumberOfEpisodes\": \"# of Episodes\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:\",\n  \"LabelOpenIDClaims\": \"Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.\",\n  \"LabelOpenRSSFeed\": \"Open RSS Feed\",\n  \"LabelOverwrite\": \"Overwrite\",\n  \"LabelPaginationPageXOfY\": \"Page {0} of {1}\",\n  \"LabelPassword\": \"Password\",\n  \"LabelPath\": \"Path\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Can Access All Libraries\",\n  \"LabelPermissionsAccessAllTags\": \"Can Access All Tags\",\n  \"LabelPermissionsAccessExplicitContent\": \"Can Access Explicit Content\",\n  \"LabelPermissionsCreateEreader\": \"Can Create Ereader\",\n  \"LabelPermissionsDelete\": \"Can Delete\",\n  \"LabelPermissionsDownload\": \"Can Download\",\n  \"LabelPermissionsUpdate\": \"Can Update\",\n  \"LabelPermissionsUpload\": \"Can Upload\",\n  \"LabelPersonalYearReview\": \"Your Year in Review ({0})\",\n  \"LabelPhotoPathURL\": \"Photo Path/URL\",\n  \"LabelPlayMethod\": \"Play Method\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Playback Rate Increment/Decrement Amount\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} of {1}\",\n  \"LabelPlaylists\": \"Playlists\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast search region\",\n  \"LabelPodcastType\": \"Podcast Type\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Prefixes to Ignore (case insensitive)\",\n  \"LabelPreventIndexing\": \"Prevent your feed from being indexed by iTunes and Google podcast directories\",\n  \"LabelPrimaryEbook\": \"Primary ebook\",\n  \"LabelProgress\": \"Progress\",\n  \"LabelProvider\": \"Provider\",\n  \"LabelProviderAuthorizationValue\": \"Authorization Header Value\",\n  \"LabelPubDate\": \"Pub Date\",\n  \"LabelPublishYear\": \"Publish Year\",\n  \"LabelPublishedDate\": \"Published {0}\",\n  \"LabelPublishedDecade\": \"Published Decade\",\n  \"LabelPublishedDecades\": \"Published Decades\",\n  \"LabelPublisher\": \"Publisher\",\n  \"LabelPublishers\": \"Publishers\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Custom owner Email\",\n  \"LabelRSSFeedCustomOwnerName\": \"Custom owner Name\",\n  \"LabelRSSFeedOpen\": \"RSS Feed Open\",\n  \"LabelRSSFeedPreventIndexing\": \"Prevent Indexing\",\n  \"LabelRSSFeedSlug\": \"RSS Feed Slug\",\n  \"LabelRSSFeedURL\": \"RSS Feed URL\",\n  \"LabelRandomly\": \"Randomly\",\n  \"LabelReAddSeriesToContinueListening\": \"Re-add series to Continue Listening\",\n  \"LabelRead\": \"Read\",\n  \"LabelReadAgain\": \"Read Again\",\n  \"LabelReadEbookWithoutProgress\": \"Read ebook without keeping progress\",\n  \"LabelRecentSeries\": \"Recent Series\",\n  \"LabelRecentlyAdded\": \"Recently Added\",\n  \"LabelRecommended\": \"Recommended\",\n  \"LabelRedo\": \"Redo\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Release Date\",\n  \"LabelRemoveAllMetadataAbs\": \"Remove all metadata.abs files\",\n  \"LabelRemoveAllMetadataJson\": \"Remove all metadata.json files\",\n  \"LabelRemoveAudibleBranding\": \"Remove Audible intro and outro from chapters\",\n  \"LabelRemoveCover\": \"Remove cover\",\n  \"LabelRemoveMetadataFile\": \"Remove metadata files in library item folders\",\n  \"LabelRemoveMetadataFileHelp\": \"Remove all metadata.json and metadata.abs files in your {0} folders.\",\n  \"LabelRowsPerPage\": \"Rows per page\",\n  \"LabelSearchTerm\": \"Search Term\",\n  \"LabelSearchTitle\": \"Search Title\",\n  \"LabelSearchTitleOrASIN\": \"Search Title or ASIN\",\n  \"LabelSeason\": \"Season\",\n  \"LabelSeasonNumber\": \"Season #{0}\",\n  \"LabelSelectAll\": \"Select all\",\n  \"LabelSelectAllEpisodes\": \"Select all episodes\",\n  \"LabelSelectEpisodesShowing\": \"Select {0} episodes showing\",\n  \"LabelSelectUser\": \"Select user\",\n  \"LabelSelectUsers\": \"Select users\",\n  \"LabelSendEbookToDevice\": \"Send Ebook to...\",\n  \"LabelSequence\": \"Sequence\",\n  \"LabelSerial\": \"Serial\",\n  \"LabelSeries\": \"Series\",\n  \"LabelSeriesName\": \"Series Name\",\n  \"LabelSeriesProgress\": \"Series Progress\",\n  \"LabelServerLogLevel\": \"Server Log Level\",\n  \"LabelServerYearReview\": \"Server Year in Review ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Set as primary\",\n  \"LabelSetEbookAsSupplementary\": \"Set as supplementary\",\n  \"LabelSettingsAllowIframe\": \"Allow embedding in an iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Audiobooks only\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorphic design with wooden shelves\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast support\",\n  \"LabelSettingsDateFormat\": \"Date Format\",\n  \"LabelSettingsEnableWatcher\": \"Automatically watch libraries for changes\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Automatically watch library for changes\",\n  \"LabelSettingsEnableWatcherHelp\": \"Enables the automatic adding/updating of items when file changes are detected. *Requires server restart\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Allow scripted content in epubs\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.\",\n  \"LabelSettingsExperimentalFeatures\": \"Experimental features\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Features in development that could use your feedback and help testing. Click to open github discussion.\",\n  \"LabelSettingsFindCovers\": \"Find covers\",\n  \"LabelSettingsFindCoversHelp\": \"If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time\",\n  \"LabelSettingsHideSingleBookSeries\": \"Hide single book series\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Series that have a single book will be hidden from the series page and home page shelves.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Home page use bookshelf view\",\n  \"LabelSettingsLibraryBookshelfView\": \"Library use bookshelf view\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Percent complete is greater than\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Time remaining is less than (seconds)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Mark media item as finished when\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Skip earlier books in Continue Series\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"The Continue Series home page shelf shows the first book not started in series that have at least one book finished and no books in progress. Enabling this setting will continue series from the furthest completed book instead of the first book not started.\",\n  \"LabelSettingsParseSubtitles\": \"Parse subtitles\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Extract subtitles from audiobook folder names.<br>Subtitle must be separated by \\\" - \\\"<br>i.e. \\\"Book Title - A Subtitle Here\\\" has the subtitle \\\"A Subtitle Here\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Prefer matched metadata\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Matched data will override item details when using Quick Match. By default Quick Match will only fill in missing details.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Skip matching books that already have an ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Skip matching books that already have an ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignore prefixes when sorting\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"i.e. for prefix \\\"the\\\" book title \\\"The Book Title\\\" would sort as \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Use square book covers\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Prefer to use square covers over standard 1.6:1 book covers\",\n  \"LabelSettingsStoreCoversWithItem\": \"Store covers with item\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \\\"cover\\\" will be kept\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Store metadata with item\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders\",\n  \"LabelSettingsTimeFormat\": \"Time Format\",\n  \"LabelShare\": \"Share\",\n  \"LabelShareDownloadableHelp\": \"Allows users with the share link to download a zip file of the library item.\",\n  \"LabelShareOpen\": \"Share Open\",\n  \"LabelShareURL\": \"Share URL\",\n  \"LabelShowAll\": \"Show All\",\n  \"LabelShowSeconds\": \"Show seconds\",\n  \"LabelShowSubtitles\": \"Show Subtitles\",\n  \"LabelSize\": \"Size\",\n  \"LabelSleepTimer\": \"Sleep timer\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Ascending\",\n  \"LabelSortDescending\": \"Descending\",\n  \"LabelSortPubDate\": \"Sort Pub Date\",\n  \"LabelStart\": \"Start\",\n  \"LabelStartTime\": \"Start Time\",\n  \"LabelStarted\": \"Started\",\n  \"LabelStartedAt\": \"Started At\",\n  \"LabelStartedDate\": \"Started {0}\",\n  \"LabelStatsAudioTracks\": \"Audio Tracks\",\n  \"LabelStatsAuthors\": \"Authors\",\n  \"LabelStatsBestDay\": \"Best Day\",\n  \"LabelStatsDailyAverage\": \"Daily Average\",\n  \"LabelStatsDays\": \"Days\",\n  \"LabelStatsDaysListened\": \"Days Listened\",\n  \"LabelStatsHours\": \"Hours\",\n  \"LabelStatsInARow\": \"in a row\",\n  \"LabelStatsItemsFinished\": \"Items Finished\",\n  \"LabelStatsItemsInLibrary\": \"Items in Library\",\n  \"LabelStatsMinutes\": \"minutes\",\n  \"LabelStatsMinutesListening\": \"Minutes Listening\",\n  \"LabelStatsOverallDays\": \"Overall Days\",\n  \"LabelStatsOverallHours\": \"Overall Hours\",\n  \"LabelStatsWeekListening\": \"Week Listening\",\n  \"LabelSubtitle\": \"Subtitle\",\n  \"LabelSupportedFileTypes\": \"Supported File Types\",\n  \"LabelTag\": \"Tag\",\n  \"LabelTags\": \"Tags\",\n  \"LabelTagsAccessibleToUser\": \"Tags Accessible to User\",\n  \"LabelTagsNotAccessibleToUser\": \"Tags not Accessible to User\",\n  \"LabelTasks\": \"Tasks Running\",\n  \"LabelTextEditorBulletedList\": \"Bulleted list\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"Numbered list\",\n  \"LabelTextEditorUnlink\": \"Unlink\",\n  \"LabelTheme\": \"Theme\",\n  \"LabelThemeDark\": \"Dark\",\n  \"LabelThemeLight\": \"Light\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Time Base\",\n  \"LabelTimeDurationXHours\": \"{0} hours\",\n  \"LabelTimeDurationXMinutes\": \"{0} minutes\",\n  \"LabelTimeDurationXSeconds\": \"{0} seconds\",\n  \"LabelTimeInMinutes\": \"Time in minutes\",\n  \"LabelTimeLeft\": \"{0} left\",\n  \"LabelTimeListened\": \"Time Listened\",\n  \"LabelTimeListenedToday\": \"Time Listened Today\",\n  \"LabelTimeRemaining\": \"{0} remaining\",\n  \"LabelTimeToShift\": \"Time to shift in seconds\",\n  \"LabelTitle\": \"Title\",\n  \"LabelToolsEmbedMetadata\": \"Embed Metadata\",\n  \"LabelToolsEmbedMetadataDescription\": \"Embed metadata into audio files including cover image and chapters.\",\n  \"LabelToolsM4bEncoder\": \"M4B Encoder\",\n  \"LabelToolsMakeM4b\": \"Make M4B Audiobook File\",\n  \"LabelToolsMakeM4bDescription\": \"Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.\",\n  \"LabelToolsSplitM4b\": \"Split M4B to MP3's\",\n  \"LabelToolsSplitM4bDescription\": \"Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.\",\n  \"LabelTotalDuration\": \"Total Duration\",\n  \"LabelTotalTimeListened\": \"Total Time Listened\",\n  \"LabelTrackFromFilename\": \"Track from Filename\",\n  \"LabelTrackFromMetadata\": \"Track from Metadata\",\n  \"LabelTracks\": \"Tracks\",\n  \"LabelTracksMultiTrack\": \"Multi-track\",\n  \"LabelTracksNone\": \"No tracks\",\n  \"LabelTracksSingleTrack\": \"Single-track\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Type\",\n  \"LabelUnabridged\": \"Unabridged\",\n  \"LabelUndo\": \"Undo\",\n  \"LabelUnknown\": \"Unknown\",\n  \"LabelUnknownPublishDate\": \"Unknown publish date\",\n  \"LabelUpdateCover\": \"Update Cover\",\n  \"LabelUpdateCoverHelp\": \"Allow overwriting of existing covers for the selected books when a match is located\",\n  \"LabelUpdateDetails\": \"Update Details\",\n  \"LabelUpdateDetailsHelp\": \"Allow overwriting of existing details for the selected books when a match is located\",\n  \"LabelUpdatedAt\": \"Updated At\",\n  \"LabelUploaderDragAndDrop\": \"Drag & drop files or folders\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Drag & drop files\",\n  \"LabelUploaderDropFiles\": \"Drop files\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automatically fetch title, author, and series\",\n  \"LabelUseAdvancedOptions\": \"Use Advanced Options\",\n  \"LabelUseChapterTrack\": \"Use chapter track\",\n  \"LabelUseFullTrack\": \"Use full track\",\n  \"LabelUseZeroForUnlimited\": \"Use 0 for unlimited\",\n  \"LabelUser\": \"User\",\n  \"LabelUsername\": \"Username\",\n  \"LabelValue\": \"Value\",\n  \"LabelVersion\": \"Version\",\n  \"LabelViewBookmarks\": \"View bookmarks\",\n  \"LabelViewChapters\": \"View chapters\",\n  \"LabelViewPlayerSettings\": \"View player settings\",\n  \"LabelViewQueue\": \"View player queue\",\n  \"LabelVolume\": \"Volume\",\n  \"LabelWebRedirectURLsDescription\": \"Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Subfolder for Redirect URLs\",\n  \"LabelWeekdaysToRun\": \"Weekdays to run\",\n  \"LabelXBooks\": \"{0} books\",\n  \"LabelXItems\": \"{0} items\",\n  \"LabelYearReviewHide\": \"Hide Year in Review\",\n  \"LabelYearReviewShow\": \"See Year in Review\",\n  \"LabelYourAudiobookDuration\": \"Your audiobook duration\",\n  \"LabelYourBookmarks\": \"Your Bookmarks\",\n  \"LabelYourPlaylists\": \"Your Playlists\",\n  \"LabelYourProgress\": \"Your Progress\",\n  \"MessageAddToPlayerQueue\": \"Add to player queue\",\n  \"MessageAppriseDescription\": \"To use this feature you will need to have an instance of <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Ensure you are using the ASIN from the correct Audible region, not Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Legacy API tokens will be removed in the future. Use <a href=\\\"/config/api-keys\\\">API Keys</a> instead.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Restart your server after saving to apply OIDC changes.\",\n  \"MessageAuthenticationSecurityMessage\": \"Authentication has been improved for security. All users are required to re-login.\",\n  \"MessageBackupsDescription\": \"Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.\",\n  \"MessageBackupsLocationEditNote\": \"Note: Updating the backup location will not move or modify existing backups\",\n  \"MessageBackupsLocationNoEditNote\": \"Note: The backup location is set through an environment variable and cannot be changed here.\",\n  \"MessageBackupsLocationPathEmpty\": \"Backup location path cannot be empty\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Populate enabled fields with data from all items. Fields with multiple values will be merged\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Populate enabled map details fields with data from this item\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.\",\n  \"MessageBookshelfNoCollections\": \"You haven't made any collections yet\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Collections are public. All users with access to the library can see them.\",\n  \"MessageBookshelfNoRSSFeeds\": \"No RSS feeds are open\",\n  \"MessageBookshelfNoResultsForFilter\": \"No results for filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"No results for query\",\n  \"MessageBookshelfNoSeries\": \"You have no series\",\n  \"MessageBulkChapterPattern\": \"How many chapters would you like to add with this numbering pattern?\",\n  \"MessageChapterEndIsAfter\": \"Chapter end is after the end of your audiobook\",\n  \"MessageChapterErrorFirstNotZero\": \"First chapter must start at 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Invalid start time must be less than audiobook duration\",\n  \"MessageChapterErrorStartLtPrev\": \"Invalid start time must be greater than or equal to previous chapter start time\",\n  \"MessageChapterStartIsAfter\": \"Chapter start is after the end of your audiobook\",\n  \"MessageChaptersNotFound\": \"Chapters not found\",\n  \"MessageCheckingCron\": \"Checking cron...\",\n  \"MessageConfirmCloseFeed\": \"Are you sure you want to close this feed?\",\n  \"MessageConfirmDeleteApiKey\": \"Are you sure you want to delete API key \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Are you sure you want to delete backup for {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Are you sure you want to delete e-reader device \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"This will delete the file from your file system. Are you sure?\",\n  \"MessageConfirmDeleteLibrary\": \"Are you sure you want to permanently delete library \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"This will delete the library item from the database and your file system. Are you sure?\",\n  \"MessageConfirmDeleteLibraryItems\": \"This will delete {0} library items from the database and your file system. Are you sure?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Are you sure you want to delete custom metadata provider \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Are you sure you want to delete this notification?\",\n  \"MessageConfirmDeleteSession\": \"Are you sure you want to delete this session?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Are you sure you want to embed metadata in {0} audio files?\",\n  \"MessageConfirmForceReScan\": \"Are you sure you want to force re-scan?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Are you sure you want to mark all episodes as finished?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Are you sure you want to mark all episodes as not finished?\",\n  \"MessageConfirmMarkItemFinished\": \"Are you sure you want to mark \\\"{0}\\\" as finished?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Are you sure you want to mark \\\"{0}\\\" as not finished?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Are you sure you want to mark all books in this series as finished?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Are you sure you want to mark all books in this series as not finished?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Trigger this notification with test data?\",\n  \"MessageConfirmPurgeCache\": \"Purge cache will delete the entire directory at <code>/metadata/cache</code>. <br /><br />Are you sure you want to remove the cache directory?\",\n  \"MessageConfirmPurgeItemsCache\": \"Purge items cache will delete the entire directory at <code>/metadata/cache/items</code>.<br />Are you sure?\",\n  \"MessageConfirmQuickEmbed\": \"Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?\",\n  \"MessageConfirmReScanLibraryItems\": \"Are you sure you want to re-scan {0} items?\",\n  \"MessageConfirmRemoveAllChapters\": \"Are you sure you want to remove all chapters?\",\n  \"MessageConfirmRemoveAuthor\": \"Are you sure you want to remove author \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Are you sure you want to remove collection \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Are you sure you want to remove episode \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Note: This does not delete the audio file unless toggling \\\"Hard delete file\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Are you sure you want to remove {0} episodes?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Are you sure you want to remove {0} listening sessions?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Are you sure you want to remove all metadata.{0} files in your library item folders?\",\n  \"MessageConfirmRemoveNarrator\": \"Are you sure you want to remove narrator \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Are you sure you want to remove your playlist \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Are you sure you want to rename genre \\\"{0}\\\" to \\\"{1}\\\" for all items?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Note: This genre already exists so they will be merged.\",\n  \"MessageConfirmRenameGenreWarning\": \"Warning! A similar genre with a different casing already exists \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Are you sure you want to rename tag \\\"{0}\\\" to \\\"{1}\\\" for all items?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Note: This tag already exists so they will be merged.\",\n  \"MessageConfirmRenameTagWarning\": \"Warning! A similar tag with a different casing already exists \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Are you sure you want to reset your progress?\",\n  \"MessageConfirmSendEbookToDevice\": \"Are you sure you want to send {0} ebook \\\"{1}\\\" to device \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Are you sure you want to unlink this user from OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} days listened in the last year\",\n  \"MessageDownloadingEpisode\": \"Downloading episode\",\n  \"MessageDragFilesIntoTrackOrder\": \"Drag files into correct track order\",\n  \"MessageEmbedFailed\": \"Embed Failed!\",\n  \"MessageEmbedFinished\": \"Embed Finished!\",\n  \"MessageEmbedQueue\": \"Queued for metadata embed ({0} in queue)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Episode(s) queued for download\",\n  \"MessageEreaderDevices\": \"To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.\",\n  \"MessageFeedURLWillBe\": \"Feed URL will be {0}\",\n  \"MessageFetching\": \"Fetching...\",\n  \"MessageForceReScanDescription\": \"will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} listening</strong> on {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"No listening sessions on {0}\",\n  \"MessageImportantNotice\": \"Important Notice!\",\n  \"MessageInsertChapterBelow\": \"Insert chapter below\",\n  \"MessageInvalidAsin\": \"Invalid ASIN\",\n  \"MessageItemsSelected\": \"{0} items selected\",\n  \"MessageItemsUpdated\": \"{0} items updated\",\n  \"MessageJoinUsOn\": \"Join us on\",\n  \"MessageLoading\": \"Loading...\",\n  \"MessageLoadingFolders\": \"Loading folders...\",\n  \"MessageLogsDescription\": \"Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B Failed!\",\n  \"MessageM4BFinished\": \"M4B Finished!\",\n  \"MessageMapChapterTitles\": \"Map chapter titles to your existing audiobook chapters without adjusting timestamps\",\n  \"MessageMarkAllEpisodesFinished\": \"Mark all episodes finished\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Mark all episodes not finished\",\n  \"MessageMarkAsFinished\": \"Mark as Finished\",\n  \"MessageMarkAsNotFinished\": \"Mark as Not Finished\",\n  \"MessageMatchBooksDescription\": \"will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.\",\n  \"MessageNoAudioTracks\": \"No audio tracks\",\n  \"MessageNoAuthors\": \"No Authors\",\n  \"MessageNoBackups\": \"No Backups\",\n  \"MessageNoBookmarks\": \"No Bookmarks\",\n  \"MessageNoChapters\": \"No Chapters\",\n  \"MessageNoCollections\": \"No Collections\",\n  \"MessageNoCoversFound\": \"No Covers Found\",\n  \"MessageNoDescription\": \"No description\",\n  \"MessageNoDevices\": \"No devices\",\n  \"MessageNoDownloadsInProgress\": \"No downloads currently in progress\",\n  \"MessageNoDownloadsQueued\": \"No downloads queued\",\n  \"MessageNoEpisodeMatchesFound\": \"No episode matches found\",\n  \"MessageNoEpisodes\": \"No Episodes\",\n  \"MessageNoFoldersAvailable\": \"No Folders Available\",\n  \"MessageNoGenres\": \"No Genres\",\n  \"MessageNoIssues\": \"No Issues\",\n  \"MessageNoItems\": \"No Items\",\n  \"MessageNoItemsFound\": \"No items found\",\n  \"MessageNoListeningSessions\": \"No Listening Sessions\",\n  \"MessageNoLogs\": \"No Logs\",\n  \"MessageNoMediaProgress\": \"No Media Progress\",\n  \"MessageNoNotifications\": \"No Notifications\",\n  \"MessageNoPodcastFeed\": \"Invalid podcast: No Feed\",\n  \"MessageNoPodcastsFound\": \"No podcasts found\",\n  \"MessageNoResults\": \"No Results\",\n  \"MessageNoSearchResultsFor\": \"No search results for \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"No Series\",\n  \"MessageNoTags\": \"No Tags\",\n  \"MessageNoTasksRunning\": \"No Tasks Running\",\n  \"MessageNoUpdatesWereNecessary\": \"No updates were necessary\",\n  \"MessageNoUserPlaylists\": \"You have no playlists\",\n  \"MessageNoUserPlaylistsHelp\": \"Playlists are private. Only the user who creates them can see them.\",\n  \"MessageNotYetImplemented\": \"Not yet implemented\",\n  \"MessageOpmlPreviewNote\": \"Note: This is a preview of the parsed OPML file. The actual podcast title will be taken from the RSS feed.\",\n  \"MessageOr\": \"or\",\n  \"MessagePauseChapter\": \"Pause chapter playback\",\n  \"MessagePlayChapter\": \"Listen to beginning of chapter\",\n  \"MessagePlaylistCreateFromCollection\": \"Create playlist from collection\",\n  \"MessagePleaseWait\": \"Please wait...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast has no RSS feed url to use for matching\",\n  \"MessagePodcastSearchField\": \"Enter search term or RSS feed URL\",\n  \"MessageQuickEmbedInProgress\": \"Quick embed in progress\",\n  \"MessageQuickEmbedQueue\": \"Queued for quick embed ({0} in queue)\",\n  \"MessageQuickMatchAllEpisodes\": \"Quick Match All Episodes\",\n  \"MessageQuickMatchDescription\": \"Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.\",\n  \"MessageRemoveChapter\": \"Remove chapter\",\n  \"MessageRemoveEpisodes\": \"Remove {0} episode(s)\",\n  \"MessageRemoveFromPlayerQueue\": \"Remove from player queue\",\n  \"MessageRemoveUserWarning\": \"Are you sure you want to permanently delete user \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Report bugs, request features, and contribute on\",\n  \"MessageResetChaptersConfirm\": \"Are you sure you want to reset chapters and undo the changes you made?\",\n  \"MessageRestoreBackupConfirm\": \"Are you sure you want to restore the backup created on\",\n  \"MessageRestoreBackupWarning\": \"Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.\",\n  \"MessageScheduleLibraryScanNote\": \"For most users, it is recommended to leave this feature disabled and keep the \\\"Automatically watch library for changes\\\" setting enabled - it will automatically detect changes in your library folders. Enable this feature if \\\"Automatically watch library for changes\\\" does not work for your file system (like NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Run every {0} at {1}\",\n  \"MessageSearchResultsFor\": \"Search results for\",\n  \"MessageSelected\": \"{0} selected\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Series sequence cannot contain spaces\",\n  \"MessageServerCouldNotBeReached\": \"Server could not be reached\",\n  \"MessageSetChaptersFromTracksDescription\": \"Set chapters using each audio file as a chapter and chapter title as the audio file name\",\n  \"MessageShareExpirationWillBe\": \"Expiration will be <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Expires in {0}\",\n  \"MessageShareURLWillBe\": \"Share URL will be <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Start playback for \\\"{0}\\\" at {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Audio file \\\"{0}\\\" is not writable\",\n  \"MessageTaskCanceledByUser\": \"Task canceled by user\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Downloading episode \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Embedding metadata\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Embedding metadata in audiobook \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Encoding M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Encoding audiobook \\\"{0}\\\" into a single m4b file\",\n  \"MessageTaskFailed\": \"Failed\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Failed to backup audio file \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Failed to create cache directory\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Failed to embed metadata in file \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Failed to merge audio files\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Failed to move m4b file\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Failed to write metadata file\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Matching books in library \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"No files to scan\",\n  \"MessageTaskOpmlImport\": \"OPML import\",\n  \"MessageTaskOpmlImportDescription\": \"Creating podcasts from {0} RSS feeds\",\n  \"MessageTaskOpmlImportFeed\": \"OPML import feed\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importing RSS feed \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Failed to get podcast feed\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Creating podcast \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast already exists at path\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Failed to create podcast\",\n  \"MessageTaskOpmlImportFinished\": \"Added {0} podcasts\",\n  \"MessageTaskOpmlParseFailed\": \"Failed to parse OPML file\",\n  \"MessageTaskOpmlParseFastFail\": \"Invalid OPML file <opml> tag not found OR an <outline> tag was not found\",\n  \"MessageTaskOpmlParseNoneFound\": \"No feeds found in OPML file\",\n  \"MessageTaskScanItemsAdded\": \"{0} added\",\n  \"MessageTaskScanItemsMissing\": \"{0} missing\",\n  \"MessageTaskScanItemsUpdated\": \"{0} updated\",\n  \"MessageTaskScanNoChangesNeeded\": \"No changes needed\",\n  \"MessageTaskScanningFileChanges\": \"Scanning file changes in \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Scanning \\\"{0}\\\" library\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Target directory is not writable\",\n  \"MessageThinking\": \"Thinking...\",\n  \"MessageUploaderItemFailed\": \"Failed to upload\",\n  \"MessageUploaderItemSuccess\": \"Successfully Uploaded!\",\n  \"MessageUploading\": \"Uploading...\",\n  \"MessageValidCronExpression\": \"Valid cron expression\",\n  \"MessageWatcherIsDisabledGlobally\": \"Watcher is disabled globally in server settings\",\n  \"MessageXLibraryIsEmpty\": \"{0} Library is empty!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Your audiobook duration is longer than the duration found\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Your audiobook duration is shorter than duration found\",\n  \"NoteChangeRootPassword\": \"Root user is the only user that can have an empty password\",\n  \"NoteChapterEditorTimes\": \"Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.\",\n  \"NoteFolderPicker\": \"Note: folders already mapped will not be shown\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Warning: Most podcast apps will require the RSS feed URL is using HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Folders with media files will be handled as separate library items.\",\n  \"NoteUploaderOnlyAudioFiles\": \"If uploading only audio files then each audio file will be handled as a separate audiobook.\",\n  \"NoteUploaderUnsupportedFiles\": \"Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.\",\n  \"NotificationOnBackupCompletedDescription\": \"Triggered when a backup is completed\",\n  \"NotificationOnBackupFailedDescription\": \"Triggered when a backup fails\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Triggered when a podcast episode is auto-downloaded\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Triggered when automatic episode downloads are disabled due to too many failed attempts\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Triggered when the RSS feed request fails for an automatic episode download\",\n  \"NotificationOnTestDescription\": \"Event for testing the notification system\",\n  \"PlaceholderBulkChapterInput\": \"Enter chapter title or use numbering (e.g., 'Episode 1', 'Chapter 10', '1.')\",\n  \"PlaceholderNewCollection\": \"New collection name\",\n  \"PlaceholderNewFolderPath\": \"New folder path\",\n  \"PlaceholderNewPlaylist\": \"New playlist name\",\n  \"PlaceholderSearch\": \"Search..\",\n  \"PlaceholderSearchEpisode\": \"Search episode..\",\n  \"StatsAuthorsAdded\": \"authors added\",\n  \"StatsBooksAdded\": \"books added\",\n  \"StatsBooksAdditional\": \"Some additions include…\",\n  \"StatsBooksFinished\": \"books finished\",\n  \"StatsBooksFinishedThisYear\": \"Some books finished this year…\",\n  \"StatsBooksListenedTo\": \"books listened to\",\n  \"StatsCollectionGrewTo\": \"Your book collection grew to…\",\n  \"StatsSessions\": \"sessions\",\n  \"StatsSpentListening\": \"spent listening\",\n  \"StatsTopAuthor\": \"TOP AUTHOR\",\n  \"StatsTopAuthors\": \"TOP AUTHORS\",\n  \"StatsTopGenre\": \"TOP GENRE\",\n  \"StatsTopGenres\": \"TOP GENRES\",\n  \"StatsTopMonth\": \"TOP MONTH\",\n  \"StatsTopNarrator\": \"TOP NARRATOR\",\n  \"StatsTopNarrators\": \"TOP NARRATORS\",\n  \"StatsTotalDuration\": \"With a total duration of…\",\n  \"StatsYearInReview\": \"YEAR IN REVIEW\",\n  \"ToastAccountUpdateSuccess\": \"Account updated\",\n  \"ToastAppriseUrlRequired\": \"Must enter an Apprise URL\",\n  \"ToastAsinRequired\": \"ASIN is required\",\n  \"ToastAuthorImageRemoveSuccess\": \"Author image removed\",\n  \"ToastAuthorNotFound\": \"Author \\\"{0}\\\" not found\",\n  \"ToastAuthorRemoveSuccess\": \"Author removed\",\n  \"ToastAuthorSearchNotFound\": \"Author not found\",\n  \"ToastAuthorUpdateMerged\": \"Author merged\",\n  \"ToastAuthorUpdateSuccess\": \"Author updated\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Author updated (no image found)\",\n  \"ToastBackupAppliedSuccess\": \"Backup applied\",\n  \"ToastBackupCreateFailed\": \"Failed to create backup\",\n  \"ToastBackupCreateSuccess\": \"Backup created\",\n  \"ToastBackupDeleteFailed\": \"Failed to delete backup\",\n  \"ToastBackupDeleteSuccess\": \"Backup deleted\",\n  \"ToastBackupInvalidMaxKeep\": \"Invalid number of backups to keep\",\n  \"ToastBackupInvalidMaxSize\": \"Invalid maximum backup size\",\n  \"ToastBackupRestoreFailed\": \"Failed to restore backup\",\n  \"ToastBackupUploadFailed\": \"Failed to upload backup\",\n  \"ToastBackupUploadSuccess\": \"Backup uploaded\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Details applied to items\",\n  \"ToastBatchDeleteFailed\": \"Batch delete failed\",\n  \"ToastBatchDeleteSuccess\": \"Batch delete success\",\n  \"ToastBatchQuickMatchFailed\": \"Batch Quick Match failed!\",\n  \"ToastBatchQuickMatchStarted\": \"Batch Quick Match of {0} books started!\",\n  \"ToastBatchUpdateFailed\": \"Batch update failed\",\n  \"ToastBatchUpdateSuccess\": \"Batch update success\",\n  \"ToastBookmarkCreateFailed\": \"Failed to create bookmark\",\n  \"ToastBookmarkCreateSuccess\": \"Bookmark added\",\n  \"ToastBookmarkRemoveSuccess\": \"Bookmark removed\",\n  \"ToastBulkChapterInvalidCount\": \"Enter a number between 1 and 150\",\n  \"ToastCachePurgeFailed\": \"Failed to purge cache\",\n  \"ToastCachePurgeSuccess\": \"Cache purged successfully\",\n  \"ToastChapterLocked\": \"Chapter is locked.\",\n  \"ToastChapterStartTimeAdjusted\": \"Chapter start time adjusted by {0} seconds\",\n  \"ToastChaptersAllLocked\": \"All chapters are locked. Unlock some chapters to shift their times.\",\n  \"ToastChaptersHaveErrors\": \"Chapters have errors\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Invalid shift amount. The last chapter start time would extend beyond the duration of this audiobook.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Invalid shift amount. The first chapter would have zero or negative length and would be overwritten by the second chapter. Increase the start duration of second chapter.\",\n  \"ToastChaptersMustHaveTitles\": \"Chapters must have titles\",\n  \"ToastChaptersRemoved\": \"Chapters removed\",\n  \"ToastChaptersUpdated\": \"Chapters updated\",\n  \"ToastCollectionItemsAddFailed\": \"Item(s) added to collection failed\",\n  \"ToastCollectionRemoveSuccess\": \"Collection removed\",\n  \"ToastCollectionUpdateSuccess\": \"Collection updated\",\n  \"ToastConnectionNotAvailable\": \"Connection not available. Please try again later\",\n  \"ToastCoverSearchFailed\": \"Cover search failed\",\n  \"ToastCoverUpdateFailed\": \"Cover update failed\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Date and time is invalid or incomplete\",\n  \"ToastDeleteFileFailed\": \"Failed to delete file\",\n  \"ToastDeleteFileSuccess\": \"File deleted\",\n  \"ToastDeviceAddFailed\": \"Failed to add device\",\n  \"ToastDeviceNameAlreadyExists\": \"Ereader device with that name already exists\",\n  \"ToastDeviceTestEmailFailed\": \"Failed to send test email\",\n  \"ToastDeviceTestEmailSuccess\": \"Test email sent\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Email settings updated\",\n  \"ToastEncodeCancelFailed\": \"Failed to cancel encode\",\n  \"ToastEncodeCancelSucces\": \"Encode canceled\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Failed to clear queue\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Episode download queue cleared\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} episodes updated\",\n  \"ToastErrorCannotShare\": \"Cannot share natively on this device\",\n  \"ToastFailedToCreate\": \"Failed to create\",\n  \"ToastFailedToDelete\": \"Failed to delete\",\n  \"ToastFailedToLoadData\": \"Failed to load data\",\n  \"ToastFailedToMatch\": \"Failed to match\",\n  \"ToastFailedToShare\": \"Failed to share\",\n  \"ToastFailedToUpdate\": \"Failed to update\",\n  \"ToastInvalidImageUrl\": \"Invalid image URL\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Invalid max episodes to download\",\n  \"ToastInvalidUrl\": \"Invalid URL\",\n  \"ToastInvalidUrls\": \"One or more URLs are invalid\",\n  \"ToastItemCoverUpdateSuccess\": \"Item cover updated\",\n  \"ToastItemDeletedFailed\": \"Failed to delete item\",\n  \"ToastItemDeletedSuccess\": \"Deleted item\",\n  \"ToastItemDetailsUpdateSuccess\": \"Item details updated\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Failed to mark as Finished\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Item marked as Finished\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Failed to mark as Not Finished\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Item marked as Not Finished\",\n  \"ToastItemUpdateSuccess\": \"Item updated\",\n  \"ToastLibraryCreateFailed\": \"Failed to create library\",\n  \"ToastLibraryCreateSuccess\": \"Library \\\"{0}\\\" created\",\n  \"ToastLibraryDeleteFailed\": \"Failed to delete library\",\n  \"ToastLibraryDeleteSuccess\": \"Library deleted\",\n  \"ToastLibraryScanFailedToStart\": \"Failed to start scan\",\n  \"ToastLibraryScanStarted\": \"Library scan started\",\n  \"ToastLibraryUpdateSuccess\": \"Library \\\"{0}\\\" updated\",\n  \"ToastMatchAllAuthorsFailed\": \"Failed to match all authors\",\n  \"ToastMetadataFilesRemovedError\": \"Error removing metadata.{0} files\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"No metadata.{0} files found in library\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"No metadata.{0} files removed\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} files removed\",\n  \"ToastMustHaveAtLeastOnePath\": \"Must have at least one path\",\n  \"ToastNameEmailRequired\": \"Name and email are required\",\n  \"ToastNameRequired\": \"Name is required\",\n  \"ToastNewApiKeyUserError\": \"Must select a user\",\n  \"ToastNewEpisodesFound\": \"{0} new episodes found\",\n  \"ToastNewUserCreatedFailed\": \"Failed to create account: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"New account created\",\n  \"ToastNewUserLibraryError\": \"Must select at least one library\",\n  \"ToastNewUserPasswordError\": \"Must have a password, only root user can have an empty password\",\n  \"ToastNewUserTagError\": \"Must select at least one tag\",\n  \"ToastNewUserUsernameError\": \"Enter a username\",\n  \"ToastNoNewEpisodesFound\": \"No new episodes found\",\n  \"ToastNoRSSFeed\": \"Podcast does not have an RSS Feed\",\n  \"ToastNoUpdatesNecessary\": \"No updates necessary\",\n  \"ToastNotificationCreateFailed\": \"Failed to create notification\",\n  \"ToastNotificationDeleteFailed\": \"Failed to delete notification\",\n  \"ToastNotificationFailedMaximum\": \"Max failed attempts must be >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Max notification queue must be >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Notification settings updated\",\n  \"ToastNotificationTestTriggerFailed\": \"Failed to trigger test notification\",\n  \"ToastNotificationTestTriggerSuccess\": \"Triggered test notification\",\n  \"ToastNotificationUpdateSuccess\": \"Notification updated\",\n  \"ToastPlaylistCreateFailed\": \"Failed to create playlist\",\n  \"ToastPlaylistCreateSuccess\": \"Playlist created\",\n  \"ToastPlaylistRemoveSuccess\": \"Playlist removed\",\n  \"ToastPlaylistUpdateSuccess\": \"Playlist updated\",\n  \"ToastPodcastCreateFailed\": \"Failed to create podcast\",\n  \"ToastPodcastCreateSuccess\": \"Podcast created successfully\",\n  \"ToastPodcastEpisodeUpdated\": \"Episode updated\",\n  \"ToastPodcastGetFeedFailed\": \"Failed to get podcast feed\",\n  \"ToastPodcastNoEpisodesInFeed\": \"No episodes found in RSS feed\",\n  \"ToastPodcastNoRssFeed\": \"Podcast does not have an RSS feed\",\n  \"ToastProgressIsNotBeingSynced\": \"Progress is not being synced, restart playback\",\n  \"ToastProviderCreatedFailed\": \"Failed to add provider\",\n  \"ToastProviderCreatedSuccess\": \"New provider added\",\n  \"ToastProviderNameAndUrlRequired\": \"Name and Url required\",\n  \"ToastProviderRemoveSuccess\": \"Provider removed\",\n  \"ToastRSSFeedCloseFailed\": \"Failed to close RSS feed\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS feed closed\",\n  \"ToastRemoveFailed\": \"Failed to remove\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Failed to remove item from collection\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Item removed from collection\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Failed to remove library items with issues\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Removed library items with issues\",\n  \"ToastRenameFailed\": \"Failed to rename\",\n  \"ToastRescanFailed\": \"Re-Scan Failed for {0}\",\n  \"ToastRescanRemoved\": \"Re-Scan complete item was removed\",\n  \"ToastRescanUpToDate\": \"Re-Scan complete item was up to date\",\n  \"ToastRescanUpdated\": \"Re-Scan complete item was updated\",\n  \"ToastScanFailed\": \"Failed to scan library item\",\n  \"ToastSelectAtLeastOneUser\": \"Select at least one user\",\n  \"ToastSendEbookToDeviceFailed\": \"Failed to send ebook to device\",\n  \"ToastSendEbookToDeviceSuccess\": \"Ebook sent to device \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Cannot add two series with the same name\",\n  \"ToastSeriesUpdateFailed\": \"Series update failed\",\n  \"ToastSeriesUpdateSuccess\": \"Series update success\",\n  \"ToastServerSettingsUpdateSuccess\": \"Server settings updated\",\n  \"ToastSessionCloseFailed\": \"Failed to close session\",\n  \"ToastSessionDeleteFailed\": \"Failed to delete session\",\n  \"ToastSessionDeleteSuccess\": \"Session deleted\",\n  \"ToastSleepTimerDone\": \"Sleep timer done... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug contains invalid characters\",\n  \"ToastSlugRequired\": \"Slug is required\",\n  \"ToastSocketConnected\": \"Socket connected\",\n  \"ToastSocketDisconnected\": \"Socket disconnected\",\n  \"ToastSocketFailedToConnect\": \"Socket failed to connect\",\n  \"ToastSortingPrefixesEmptyError\": \"Must have at least 1 sorting prefix\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Sorting prefixes updated ({0} items)\",\n  \"ToastTitleRequired\": \"Title is required\",\n  \"ToastUnknownError\": \"Unknown error\",\n  \"ToastUnlinkOpenIdFailed\": \"Failed to unlink user from OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"User unlinked from OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Filepath \\\"{0}\\\" already exists on server\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Item \\\"{0}\\\" is using a subdirectory of the upload path.\",\n  \"ToastUserDeleteFailed\": \"Failed to delete user\",\n  \"ToastUserDeleteSuccess\": \"User deleted\",\n  \"ToastUserPasswordChangeSuccess\": \"Password changed successfully\",\n  \"ToastUserPasswordMismatch\": \"Passwords do not match\",\n  \"ToastUserPasswordMustChange\": \"New password cannot match old password\",\n  \"ToastUserRootRequireName\": \"Must enter a root username\",\n  \"TooltipAddChapters\": \"Add chapter(s)\",\n  \"TooltipAddOneSecond\": \"Add 1 second\",\n  \"TooltipAdjustChapterStart\": \"Click to adjust start time\",\n  \"TooltipLockAllChapters\": \"Lock all chapters\",\n  \"TooltipLockChapter\": \"Lock chapter (Shift+click for range)\",\n  \"TooltipSubtractOneSecond\": \"Subtract 1 second\",\n  \"TooltipUnlockAllChapters\": \"Unlock all chapters\",\n  \"TooltipUnlockChapter\": \"Unlock chapter (Shift+click for range)\"\n}\n"
  },
  {
    "path": "client/strings/es.json",
    "content": "{\n  \"ButtonAdd\": \"Añadir\",\n  \"ButtonAddApiKey\": \"Añadir clave API\",\n  \"ButtonAddChapters\": \"Añadir capítulos\",\n  \"ButtonAddDevice\": \"Añadir dispositivo\",\n  \"ButtonAddLibrary\": \"Añadir biblioteca\",\n  \"ButtonAddPodcasts\": \"Añadir pódcast\",\n  \"ButtonAddUser\": \"Añadir usuario\",\n  \"ButtonAddYourFirstLibrary\": \"Añada su primera biblioteca\",\n  \"ButtonApply\": \"Aplicar\",\n  \"ButtonApplyChapters\": \"Aplicar capítulos\",\n  \"ButtonAuthors\": \"Autores\",\n  \"ButtonBack\": \"Atrás\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Rellenar desde existentes\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Rellenar detalles de mapa\",\n  \"ButtonBrowseForFolder\": \"Buscar carpeta\",\n  \"ButtonCancel\": \"Cancelar\",\n  \"ButtonCancelEncode\": \"Cancelar codificación\",\n  \"ButtonChangeRootPassword\": \"Cambiar contraseña administrativa\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Comprobar y descargar episodios nuevos\",\n  \"ButtonChooseAFolder\": \"Elegir una carpeta\",\n  \"ButtonChooseFiles\": \"Elegir archivos\",\n  \"ButtonClearFilter\": \"Vaciar filtro\",\n  \"ButtonClose\": \"Cerrar\",\n  \"ButtonCloseFeed\": \"Cerrar suministro\",\n  \"ButtonCloseSession\": \"Cerrar sesión abierta\",\n  \"ButtonCollections\": \"Colecciones\",\n  \"ButtonConfigureScanner\": \"Configurar Escáner\",\n  \"ButtonCreate\": \"Crear\",\n  \"ButtonCreateBackup\": \"Crear respaldo\",\n  \"ButtonDelete\": \"Eliminar\",\n  \"ButtonDownloadQueue\": \"Cola\",\n  \"ButtonEdit\": \"Editar\",\n  \"ButtonEditChapters\": \"Editar capítulos\",\n  \"ButtonEditPodcast\": \"Editar pódcast\",\n  \"ButtonEnable\": \"Habilitar\",\n  \"ButtonFireAndFail\": \"Ejecutado y fallido\",\n  \"ButtonFireOnTest\": \"Activar evento de prueba\",\n  \"ButtonForceReScan\": \"Forzar Re-Escaneo\",\n  \"ButtonFullPath\": \"Ruta completa\",\n  \"ButtonHide\": \"Ocultar\",\n  \"ButtonHome\": \"Inicio\",\n  \"ButtonIssues\": \"Cuestiones\",\n  \"ButtonJumpBackward\": \"Retroceder\",\n  \"ButtonJumpForward\": \"Adelantar\",\n  \"ButtonLatest\": \"Más recientes\",\n  \"ButtonLibrary\": \"Biblioteca\",\n  \"ButtonLogout\": \"Cerrar Sesión\",\n  \"ButtonLookup\": \"Averiguar\",\n  \"ButtonManageTracks\": \"Gestionar pistas\",\n  \"ButtonMapChapterTitles\": \"Asignar Títulos a Capítulos\",\n  \"ButtonMatchAllAuthors\": \"Encontrar Todos los Autores\",\n  \"ButtonMatchBooks\": \"Cotejar Libros\",\n  \"ButtonNevermind\": \"Olvidar\",\n  \"ButtonNext\": \"Siguiente\",\n  \"ButtonNextChapter\": \"Siguiente capítulo\",\n  \"ButtonNextItemInQueue\": \"El siguiente elemento en cola\",\n  \"ButtonOk\": \"Aceptar\",\n  \"ButtonOpenFeed\": \"Abrir suministro\",\n  \"ButtonOpenManager\": \"Abrir gestor\",\n  \"ButtonPause\": \"Pausar\",\n  \"ButtonPlay\": \"Reproducir\",\n  \"ButtonPlayAll\": \"Reproducir todo\",\n  \"ButtonPlaying\": \"Reproduciendo\",\n  \"ButtonPlaylists\": \"Listas de reproducción\",\n  \"ButtonPrevious\": \"Anterior\",\n  \"ButtonPreviousChapter\": \"Capítulo anterior\",\n  \"ButtonProbeAudioFile\": \"Sonda del archivo de audio\",\n  \"ButtonPurgeAllCache\": \"Purgar toda la caché\",\n  \"ButtonPurgeItemsCache\": \"Purgar caché de elementos\",\n  \"ButtonQueueAddItem\": \"Añadir a cola\",\n  \"ButtonQueueRemoveItem\": \"Quitar de cola\",\n  \"ButtonQuickEmbed\": \"Inserción rápida\",\n  \"ButtonQuickEmbedMetadata\": \"Empotrar metadatos rápidamente\",\n  \"ButtonQuickMatch\": \"Cotejo Rápido\",\n  \"ButtonReScan\": \"Re-Escanear\",\n  \"ButtonRead\": \"Leer\",\n  \"ButtonReadLess\": \"Leer menos\",\n  \"ButtonReadMore\": \"Leer más\",\n  \"ButtonRefresh\": \"Recargar\",\n  \"ButtonRemove\": \"Quitar\",\n  \"ButtonRemoveAll\": \"Quitar todo\",\n  \"ButtonRemoveAllLibraryItems\": \"Quitar todos los elementos de la biblioteca\",\n  \"ButtonRemoveFromContinueListening\": \"Quitar desde Escucha Continua\",\n  \"ButtonRemoveFromContinueReading\": \"Quitar desde Continuar Leyendo\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Quitar Series desde Series Continuas\",\n  \"ButtonReset\": \"Restablecer\",\n  \"ButtonResetToDefault\": \"Restaurar valores predeterminados\",\n  \"ButtonRestore\": \"Restaurar\",\n  \"ButtonSave\": \"Guardar\",\n  \"ButtonSaveAndClose\": \"Guardar y cerrar\",\n  \"ButtonSaveTracklist\": \"Guardar lista de pistas\",\n  \"ButtonScan\": \"Escanear\",\n  \"ButtonScanLibrary\": \"Escanear biblioteca\",\n  \"ButtonScrollLeft\": \"Desplazarse a la izquierda\",\n  \"ButtonScrollRight\": \"Desplazarse a la derecha\",\n  \"ButtonSearch\": \"Buscar\",\n  \"ButtonSelectFolderPath\": \"Seleccionar ruta de carpeta\",\n  \"ButtonSeries\": \"Series\",\n  \"ButtonSetChaptersFromTracks\": \"Establecer capítulos según las pistas\",\n  \"ButtonShare\": \"Compartir\",\n  \"ButtonShiftTimes\": \"Veces de Desplazo\",\n  \"ButtonShow\": \"Mostrar\",\n  \"ButtonStartM4BEncode\": \"Iniciar Codificación M4B\",\n  \"ButtonStartMetadataEmbed\": \"Iniciar Inserción de Metadatos\",\n  \"ButtonStats\": \"Estadísticas\",\n  \"ButtonSubmit\": \"Entregar\",\n  \"ButtonTest\": \"Prueba\",\n  \"ButtonUnlinkOpenId\": \"Desenlazar OpenID\",\n  \"ButtonUpload\": \"Subir\",\n  \"ButtonUploadBackup\": \"Subir Respaldo\",\n  \"ButtonUploadCover\": \"Subir Cubierta\",\n  \"ButtonUploadOPMLFile\": \"Subir archivo OPML\",\n  \"ButtonUserDelete\": \"Eliminar usuario {0}\",\n  \"ButtonUserEdit\": \"Editar usuario {0}\",\n  \"ButtonViewAll\": \"Ver todo\",\n  \"ButtonYes\": \"Sí\",\n  \"ErrorUploadFetchMetadataAPI\": \"Error al recuperar los metadatos\",\n  \"ErrorUploadFetchMetadataNoResults\": \"No se pudieron recuperar los metadatos; pruebe a actualizar el título y/o autor\",\n  \"ErrorUploadLacksTitle\": \"Debe tener un título\",\n  \"HeaderAccount\": \"Cuenta\",\n  \"HeaderAddCustomMetadataProvider\": \"Añadir proveedor de metadatos personalizado\",\n  \"HeaderAdvanced\": \"Avanzado\",\n  \"HeaderApiKeys\": \"Claves API\",\n  \"HeaderAppriseNotificationSettings\": \"Ajustes de notificaciones de Apprise\",\n  \"HeaderAudioTracks\": \"Pistas de Audio\",\n  \"HeaderAudiobookTools\": \"Herramientas de Gestión de Archivos de Audiolibro\",\n  \"HeaderAuthentication\": \"Autenticación\",\n  \"HeaderBackups\": \"Respaldos\",\n  \"HeaderBulkChapterModal\": \"Añadir Múltiples Capítulos\",\n  \"HeaderChangePassword\": \"Cambiar Contraseña\",\n  \"HeaderChapters\": \"Capítulos\",\n  \"HeaderChooseAFolder\": \"Escoger una Carpeta\",\n  \"HeaderCollection\": \"Colección\",\n  \"HeaderCollectionItems\": \"Elementos de colección\",\n  \"HeaderCover\": \"Cubierta\",\n  \"HeaderCurrentDownloads\": \"Descargas actuales\",\n  \"HeaderCustomMessageOnLogin\": \"Mensaje personalizado al acceder\",\n  \"HeaderCustomMetadataProviders\": \"Proveedores de metadatos personalizados\",\n  \"HeaderDetails\": \"Detalles\",\n  \"HeaderDownloadQueue\": \"Cola de descargas\",\n  \"HeaderEbookFiles\": \"Archivos de libros digitales\",\n  \"HeaderEmail\": \"Correo-e\",\n  \"HeaderEmailSettings\": \"Ajustes de correo-e\",\n  \"HeaderEpisodes\": \"Episodios\",\n  \"HeaderEreaderDevices\": \"Dispositivos Lector-e\",\n  \"HeaderEreaderSettings\": \"Ajustes del Lector-e\",\n  \"HeaderFiles\": \"Archivos\",\n  \"HeaderFindChapters\": \"Buscar capítulos\",\n  \"HeaderIgnoredFiles\": \"Archivos ignorados\",\n  \"HeaderItemFiles\": \"Archivos del elemento\",\n  \"HeaderItemMetadataUtils\": \"Utilidades de metadatos del elemento\",\n  \"HeaderLastListeningSession\": \"Última sesión de escucha\",\n  \"HeaderLatestEpisodes\": \"Episodios más recientes\",\n  \"HeaderLibraries\": \"Bibliotecas\",\n  \"HeaderLibraryFiles\": \"Archivos de biblioteca\",\n  \"HeaderLibraryStats\": \"Estadísticas de biblioteca\",\n  \"HeaderListeningSessions\": \"Sesiones Listadas\",\n  \"HeaderListeningStats\": \"Estadísticas de Tiempo Escuchado\",\n  \"HeaderLogin\": \"Inicio de Sesión\",\n  \"HeaderLogs\": \"Bitácoras\",\n  \"HeaderManageGenres\": \"Gestionar géneros\",\n  \"HeaderManageTags\": \"Gestionar etiquetas\",\n  \"HeaderMapDetails\": \"Asignar Detalles\",\n  \"HeaderMatch\": \"Coincidir\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Orden de precedencia de metadatos\",\n  \"HeaderMetadataToEmbed\": \"Metadatos para empotrar\",\n  \"HeaderNewAccount\": \"Crear Cuenta\",\n  \"HeaderNewApiKey\": \"Nueva clave API\",\n  \"HeaderNewLibrary\": \"Biblioteca nueva\",\n  \"HeaderNotificationCreate\": \"Crear Notificación\",\n  \"HeaderNotificationUpdate\": \"Notificación de Actualización\",\n  \"HeaderNotifications\": \"Notificaciones\",\n  \"HeaderOpenIDConnectAuthentication\": \"Autenticación OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Abrir escucha de sesiones\",\n  \"HeaderOpenRSSFeed\": \"Abrir suministro RSS\",\n  \"HeaderOtherFiles\": \"Otros archivos\",\n  \"HeaderPasswordAuthentication\": \"Autenticación por contraseña\",\n  \"HeaderPermissions\": \"Permisos\",\n  \"HeaderPlayerQueue\": \"Cola del reproductor\",\n  \"HeaderPlayerSettings\": \"Ajustes del reproductor\",\n  \"HeaderPlaylist\": \"Lista de reproducción\",\n  \"HeaderPlaylistItems\": \"Elementos de lista de reproducción\",\n  \"HeaderPodcastsToAdd\": \"Pódcast para añadir\",\n  \"HeaderPresets\": \"Preajustes\",\n  \"HeaderPreviewCover\": \"Previsualizar cubierta\",\n  \"HeaderRSSFeedGeneral\": \"Detalles de RSS\",\n  \"HeaderRSSFeedIsOpen\": \"El suministro RSS está abierto\",\n  \"HeaderRSSFeeds\": \"Suministros RSS\",\n  \"HeaderRemoveEpisode\": \"Quitar episodio\",\n  \"HeaderRemoveEpisodes\": \"Quitar {0} episodios\",\n  \"HeaderSavedMediaProgress\": \"Guardar Progreso de Multimedia\",\n  \"HeaderSchedule\": \"Horario\",\n  \"HeaderScheduleEpisodeDownloads\": \"Planificador de auto‐descargas de episodios\",\n  \"HeaderScheduleLibraryScans\": \"Planificar Auto‐Escaneo de Biblioteca\",\n  \"HeaderSession\": \"Sesión\",\n  \"HeaderSetBackupSchedule\": \"Establecer Planificación de Respaldo\",\n  \"HeaderSettings\": \"Ajustes\",\n  \"HeaderSettingsDisplay\": \"Interfaz\",\n  \"HeaderSettingsExperimental\": \"Características experimentales\",\n  \"HeaderSettingsGeneral\": \"Generales\",\n  \"HeaderSettingsScanner\": \"Escáner\",\n  \"HeaderSettingsSecurity\": \"Seguridad\",\n  \"HeaderSettingsWebClient\": \"Cliente web\",\n  \"HeaderSleepTimer\": \"Cronómetro de dormida\",\n  \"HeaderStatsLargestItems\": \"Elementos más grandes\",\n  \"HeaderStatsLongestItems\": \"Elementos más extensos (h)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minutos escuchando (últimos 7 días)\",\n  \"HeaderStatsRecentSessions\": \"Sesiones recientes\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autores\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Géneros\",\n  \"HeaderTableOfContents\": \"Sumario\",\n  \"HeaderTools\": \"Herramientas\",\n  \"HeaderUpdateAccount\": \"Actualizar cuenta\",\n  \"HeaderUpdateApiKey\": \"Actualizar clave API\",\n  \"HeaderUpdateAuthor\": \"Actualizar autor\",\n  \"HeaderUpdateDetails\": \"Actualizar detalles\",\n  \"HeaderUpdateLibrary\": \"Actualizar biblioteca\",\n  \"HeaderUsers\": \"Usuarios\",\n  \"HeaderYearReview\": \"Revisión del año {0}\",\n  \"HeaderYourStats\": \"Sus estadísticas\",\n  \"LabelAbridged\": \"Abreviado\",\n  \"LabelAbridgedChecked\": \"Abreviado (comprobado)\",\n  \"LabelAbridgedUnchecked\": \"Sin abreviar (sin comprobar)\",\n  \"LabelAccessibleBy\": \"Accesible por\",\n  \"LabelAccountType\": \"Tipo de cuenta\",\n  \"LabelAccountTypeAdmin\": \"Administrador\",\n  \"LabelAccountTypeGuest\": \"Invitado\",\n  \"LabelAccountTypeUser\": \"Usuario\",\n  \"LabelActivities\": \"Actividades\",\n  \"LabelActivity\": \"Actividad\",\n  \"LabelAddToCollection\": \"Añadir a colección\",\n  \"LabelAddToCollectionBatch\": \"Añadir {0} libros a colección\",\n  \"LabelAddToPlaylist\": \"Añadir a lista de reproducción\",\n  \"LabelAddToPlaylistBatch\": \"Añadir {0} elementos a lista de reproducción\",\n  \"LabelAddedAt\": \"Añadido en\",\n  \"LabelAddedDate\": \"Añadido {0}\",\n  \"LabelAdminUsersOnly\": \"Solamente usuarios administradores\",\n  \"LabelAll\": \"Todos\",\n  \"LabelAllEpisodesDownloaded\": \"Todos los episodios descargados\",\n  \"LabelAllUsers\": \"Todos los usuarios\",\n  \"LabelAllUsersExcludingGuests\": \"Todos los usuarios excepto invitados\",\n  \"LabelAllUsersIncludingGuests\": \"Todos los usuarios e invitados\",\n  \"LabelAlreadyInYourLibrary\": \"Ya dentro de tu biblioteca\",\n  \"LabelApiKeyCreated\": \"La clave de API “{0}” se ha creado correctamente.\",\n  \"LabelApiKeyCreatedDescription\": \"Asegúrate de copiar la clave de API ahora, no la volverás a ver otra vez.\",\n  \"LabelApiKeyUser\": \"Actuar en nombre del usuario\",\n  \"LabelApiKeyUserDescription\": \"Esta clave de API tendrá los mismos permisos que el usuario al que representa. En los registros se verá como si la solicitud la hubiera hecho el usuario directamente.\",\n  \"LabelApiToken\": \"Vale del API\",\n  \"LabelAppend\": \"Adjuntar\",\n  \"LabelAudioBitrate\": \"Tasa de bit del audio (p.ej., 128k)\",\n  \"LabelAudioChannels\": \"Canales de audio (1 o 2)\",\n  \"LabelAudioCodec\": \"Códec de audio\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Nombre Apellido)\",\n  \"LabelAuthorLastFirst\": \"Autor (Apellido, Nombre)\",\n  \"LabelAuthors\": \"Autores\",\n  \"LabelAutoDownloadEpisodes\": \"Auto‐Descargar episodios\",\n  \"LabelAutoFetchMetadata\": \"Recuperar metadatos automáticamente\",\n  \"LabelAutoFetchMetadataHelp\": \"Obtiene metadatos de título, autor y serie para agilizar la carga. Es posible que haya que cotejar metadatos adicionales después de la carga.\",\n  \"LabelAutoLaunch\": \"Lanzamiento automático\",\n  \"LabelAutoLaunchDescription\": \"Redirigir al proveedor de autenticación automáticamente al navegar a la página de inicio de sesión (ruta de sobreescritura manual <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Registro automático\",\n  \"LabelAutoRegisterDescription\": \"Crear usuarios automáticamente tras iniciar sesión\",\n  \"LabelBackToUser\": \"Regresar a Usuario\",\n  \"LabelBackupAudioFiles\": \"Respaldar archivos de audio\",\n  \"LabelBackupLocation\": \"Ubicación del respaldo\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Copias de respaldo automáticas\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Respaldos guardados en /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Tamaño máximo de copia de respaldo (en GB) (0 para ilimitado)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Como protección contra una configuración errónea, los respaldos fallarán si se excede el tamaño configurado.\",\n  \"LabelBackupsNumberToKeep\": \"Numero de respaldos para conservar\",\n  \"LabelBackupsNumberToKeepHelp\": \"Solamente 1 respaldo se quitará a la vez. Si tiene más respaldos guardados, debe quitarlos manualmente.\",\n  \"LabelBitrate\": \"Tasa de bits\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Libros\",\n  \"LabelButtonText\": \"Texto del botón\",\n  \"LabelByAuthor\": \"por {0}\",\n  \"LabelChangePassword\": \"Cambiar contraseña\",\n  \"LabelChannels\": \"Canales\",\n  \"LabelChapterCount\": \"{0} capítulos\",\n  \"LabelChapterTitle\": \"Título del capítulo\",\n  \"LabelChapters\": \"Capítulos\",\n  \"LabelChaptersFound\": \"capítulos encontrados\",\n  \"LabelClickForMoreInfo\": \"Pulse para más información\",\n  \"LabelClickToUseCurrentValue\": \"Pulse para utilizar el valor actual\",\n  \"LabelClosePlayer\": \"Cerrar reproductor\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Colapsar Series\",\n  \"LabelCollapseSubSeries\": \"Contraer la subserie\",\n  \"LabelCollection\": \"Colección\",\n  \"LabelCollections\": \"Colecciones\",\n  \"LabelComplete\": \"Completo\",\n  \"LabelConfirmPassword\": \"Confirmar contraseña\",\n  \"LabelContinueListening\": \"Seguir Escuchando\",\n  \"LabelContinueReading\": \"Continuar leyendo\",\n  \"LabelContinueSeries\": \"Continuar series\",\n  \"LabelCorsAllowed\": \"Orígenes CORS Permitidos\",\n  \"LabelCover\": \"Cubierta\",\n  \"LabelCoverImageURL\": \"URL de imagen de cubierta\",\n  \"LabelCoverProvider\": \"Proveedor de cubiertas\",\n  \"LabelCreatedAt\": \"Creado\",\n  \"LabelCronExpression\": \"Expresión de Cron\",\n  \"LabelCurrent\": \"Actual\",\n  \"LabelCurrently\": \"En este momento:\",\n  \"LabelCustomCronExpression\": \"Expresión de Cron personalizada:\",\n  \"LabelDatetime\": \"Hora y fecha\",\n  \"LabelDays\": \"Días\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Eliminar del sistema de archivos (desmarque para quitar de la base de datos solamente)\",\n  \"LabelDescription\": \"Descripción\",\n  \"LabelDeselectAll\": \"Deseleccionar Todos\",\n  \"LabelDetectedPattern\": \"Patrón detectado:\",\n  \"LabelDevice\": \"Dispositivo\",\n  \"LabelDeviceInfo\": \"Información del dispositivo\",\n  \"LabelDeviceIsAvailableTo\": \"El dispositivo está disponible para...\",\n  \"LabelDirectory\": \"Directorio\",\n  \"LabelDiscFromFilename\": \"Disco a partir del nombre de archivo\",\n  \"LabelDiscFromMetadata\": \"Disco a partir de metadatos\",\n  \"LabelDiscover\": \"Descubrir\",\n  \"LabelDownload\": \"Descargar\",\n  \"LabelDownloadNEpisodes\": \"Descargar {0} episodios\",\n  \"LabelDownloadable\": \"Descargable\",\n  \"LabelDuration\": \"Duración\",\n  \"LabelDurationComparisonExactMatch\": \"(coincidencia exacta)\",\n  \"LabelDurationComparisonLonger\": \"({0} más largo)\",\n  \"LabelDurationComparisonShorter\": \"({0} más corto)\",\n  \"LabelDurationFound\": \"Duración Comprobada:\",\n  \"LabelEbook\": \"Libro-e\",\n  \"LabelEbooks\": \"Libros-e\",\n  \"LabelEdit\": \"Editar\",\n  \"LabelEmail\": \"Correo electrónico\",\n  \"LabelEmailSettingsFromAddress\": \"Remitente\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Rechazar certificados no autorizados\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Desactivar la validación de certificados SSL puede exponer su conexión a riesgos de seguridad, como los ataques por intermediario. Desactive esta opción solo si conoce las implicaciones y confía en el servidor de correo al que se conecta.\",\n  \"LabelEmailSettingsSecure\": \"Seguro\",\n  \"LabelEmailSettingsSecureHelp\": \"Si está activado, se usará TLS para conectarse al servidor. Si está apagado, se usará TLS si su servidor tiene soporte para la extensión STARTTLS. En la mayoría de los casos, puede dejar esta opción activada si se está conectando al puerto 465. Apáguela en el caso de usar los puertos 587 o 25. (de nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Probar dirección\",\n  \"LabelEmbeddedCover\": \"Cubierta incrustada\",\n  \"LabelEnable\": \"Activar\",\n  \"LabelEncodingBackupLocation\": \"Se guardará una copia de respaldo de sus archivos de audio originales en:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Los capítulos no se incrustan en los audiolibros multipista.\",\n  \"LabelEncodingClearItemCache\": \"Asegúrese de purgar periódicamente la antememoria de elementos.\",\n  \"LabelEncodingFinishedM4B\": \"El M4B terminado se colocará en su carpeta de audiolibros en:\",\n  \"LabelEncodingInfoEmbedded\": \"Los metadatos se integrarán en las pistas de audio dentro de la carpeta de audiolibros.\",\n  \"LabelEncodingStartedNavigation\": \"Una vez iniciada la tarea, puedes salir de esta página.\",\n  \"LabelEncodingTimeWarning\": \"La codificación puede tardar hasta 30 minutos.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Advertencia: No actualice esta configuración a menos que esté familiarizado con las opciones de codificación de ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Si ha desactivado la supervisión de los archivos, deberá volver a escanear este audiolibro más adelante.\",\n  \"LabelEnd\": \"Fin\",\n  \"LabelEndOfChapter\": \"Fin del capítulo\",\n  \"LabelEpisode\": \"Episodio\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episodio no enlazado al feed RSS\",\n  \"LabelEpisodeNumber\": \"Episodio n.º {0}\",\n  \"LabelEpisodeTitle\": \"Título de episodio\",\n  \"LabelEpisodeType\": \"Tipo de episodio\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL de episodio del suministro RSS\",\n  \"LabelEpisodes\": \"Episodios\",\n  \"LabelEpisodic\": \"Episódico\",\n  \"LabelExample\": \"Ejemplo\",\n  \"LabelExpandSeries\": \"Ampliar serie\",\n  \"LabelExpandSubSeries\": \"Expandir la subserie\",\n  \"LabelExpired\": \"Expirado\",\n  \"LabelExpiresAt\": \"Expira El\",\n  \"LabelExpiresInSeconds\": \"Expira en (segundos)\",\n  \"LabelExpiresNever\": \"Nunca\",\n  \"LabelExplicit\": \"Explícito\",\n  \"LabelExplicitChecked\": \"Explícito (marcado)\",\n  \"LabelExplicitUnchecked\": \"No Explícito (sin marcar)\",\n  \"LabelExportOPML\": \"Exportar OPML\",\n  \"LabelFeedURL\": \"URL del suministro\",\n  \"LabelFetchingMetadata\": \"Obteniendo metadatos\",\n  \"LabelFile\": \"Archivo\",\n  \"LabelFileBirthtime\": \"Archivo creado en\",\n  \"LabelFileBornDate\": \"Creado {0}\",\n  \"LabelFileModified\": \"Archivo modificado\",\n  \"LabelFileModifiedDate\": \"Modificado {0}\",\n  \"LabelFilename\": \"Nombre del archivo\",\n  \"LabelFilterByUser\": \"Filtrar por Usuario\",\n  \"LabelFindEpisodes\": \"Buscar Episodio\",\n  \"LabelFinished\": \"Finalizado\",\n  \"LabelFinishedDate\": \"Finalizado {0}\",\n  \"LabelFolder\": \"Carpeta\",\n  \"LabelFolders\": \"Carpetas\",\n  \"LabelFontBold\": \"Negrilla\",\n  \"LabelFontBoldness\": \"Tipográfico sin Negrita\",\n  \"LabelFontFamily\": \"Familia tipográfica\",\n  \"LabelFontItalic\": \"Itálica\",\n  \"LabelFontScale\": \"Escala de letra\",\n  \"LabelFontStrikethrough\": \"Tachado\",\n  \"LabelFormat\": \"Formato\",\n  \"LabelFull\": \"Completo\",\n  \"LabelGenre\": \"Género\",\n  \"LabelGenres\": \"Géneros\",\n  \"LabelHardDeleteFile\": \"Eliminar Definitivamente\",\n  \"LabelHasEbook\": \"Tiene libro-e\",\n  \"LabelHasSupplementaryEbook\": \"Tiene un libro-e suplementario\",\n  \"LabelHideSubtitles\": \"Ocultar subtítulos\",\n  \"LabelHighestPriority\": \"Mayor prioridad\",\n  \"LabelHost\": \"Anfitrión\",\n  \"LabelHour\": \"Hora\",\n  \"LabelHours\": \"Horas\",\n  \"LabelIcon\": \"Icono\",\n  \"LabelImageURLFromTheWeb\": \"URL de la imagen\",\n  \"LabelInProgress\": \"En proceso\",\n  \"LabelIncludeInTracklist\": \"Incluir en la Tracklist\",\n  \"LabelIncomplete\": \"Incompleto\",\n  \"LabelInterval\": \"Intervalo\",\n  \"LabelIntervalCustomDailyWeekly\": \"Personalizar diario/semanal\",\n  \"LabelIntervalEvery12Hours\": \"Cada 12 horas\",\n  \"LabelIntervalEvery15Minutes\": \"Cada 15 minutos\",\n  \"LabelIntervalEvery2Hours\": \"Cada 2 horas\",\n  \"LabelIntervalEvery30Minutes\": \"Cada 30 minutos\",\n  \"LabelIntervalEvery6Hours\": \"Cada 6 horas\",\n  \"LabelIntervalEveryDay\": \"Cada día\",\n  \"LabelIntervalEveryHour\": \"Cada hora\",\n  \"LabelIntervalEveryMinute\": \"Cada minuto\",\n  \"LabelInvert\": \"Invertir\",\n  \"LabelItem\": \"Elemento\",\n  \"LabelJumpBackwardAmount\": \"Cantidad de saltos hacia atrás\",\n  \"LabelJumpForwardAmount\": \"Cantidad de saltos hacia adelante\",\n  \"LabelLanguage\": \"Idioma\",\n  \"LabelLanguageDefaultServer\": \"Idioma predeterminado del servidor\",\n  \"LabelLanguages\": \"Idiomas\",\n  \"LabelLastBookAdded\": \"Último libro añadido\",\n  \"LabelLastBookUpdated\": \"Último libro actualizado\",\n  \"LabelLastProgressDate\": \"Último progreso: {0}\",\n  \"LabelLastSeen\": \"Última Vez Visto\",\n  \"LabelLastTime\": \"Última Vez\",\n  \"LabelLastUpdate\": \"Última Actualización\",\n  \"LabelLayout\": \"Disposición\",\n  \"LabelLayoutSinglePage\": \"Página única\",\n  \"LabelLayoutSplitPage\": \"Dos Páginas\",\n  \"LabelLess\": \"Menos\",\n  \"LabelLibrariesAccessibleToUser\": \"Bibliotecas Disponibles para el Usuario\",\n  \"LabelLibrary\": \"Biblioteca\",\n  \"LabelLibraryFilterSublistEmpty\": \"Sin {0}\",\n  \"LabelLibraryItem\": \"Elemento de Biblioteca\",\n  \"LabelLibraryName\": \"Nombre de Biblioteca\",\n  \"LabelLibrarySortByProgress\": \"Progreso: Último actualizado\",\n  \"LabelLibrarySortByProgressFinished\": \"Progreso: Finalizado\",\n  \"LabelLibrarySortByProgressStarted\": \"Progreso: Iniciado\",\n  \"LabelLimit\": \"Limites\",\n  \"LabelLineSpacing\": \"Interlineado\",\n  \"LabelListenAgain\": \"Volver a escuchar\",\n  \"LabelLogLevelDebug\": \"Depurar\",\n  \"LabelLogLevelInfo\": \"Información\",\n  \"LabelLogLevelWarn\": \"Advertencia\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Buscar Nuevos Episodios a partir de esta Fecha\",\n  \"LabelLowestPriority\": \"Menor prioridad\",\n  \"LabelMatchConfidence\": \"Confidencia\",\n  \"LabelMatchExistingUsersBy\": \"Emparejar a los usuarios existentes por\",\n  \"LabelMatchExistingUsersByDescription\": \"Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Número máximo de episodios nuevos que se descargarán por comprobación\",\n  \"LabelMaxEpisodesToKeep\": \"Número máximo de episodios que se mantendrán\",\n  \"LabelMaxEpisodesToKeepHelp\": \"El valor 0 no establece un límite máximo. Después de que se descargue automáticamente un nuevo episodio, esto eliminará el episodio más antiguo si tiene más de X episodios. Esto solo eliminará 1 episodio por nueva descarga.\",\n  \"LabelMediaPlayer\": \"Reproductor multimedia\",\n  \"LabelMediaType\": \"Tipo de multimedia\",\n  \"LabelMetaTag\": \"Metaetiqueta\",\n  \"LabelMetaTags\": \"Metaetiquetas\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Las fuentes de metadatos de mayor prioridad prevalecerán sobre las de menor prioridad\",\n  \"LabelMetadataProvider\": \"Proveedor de metadatos\",\n  \"LabelMinute\": \"Minuto\",\n  \"LabelMinutes\": \"Minutos\",\n  \"LabelMissing\": \"Falta\",\n  \"LabelMissingEbook\": \"No tiene libro electrónico\",\n  \"LabelMissingSupplementaryEbook\": \"No tiene libro electrónico suplementario\",\n  \"LabelMobileRedirectURIs\": \"URIs de redirección a móviles permitidos\",\n  \"LabelMobileRedirectURIsDescription\": \"Esta es una lista en blanco de las URI de re‐direccionamiento válidos para aplicaciones móviles. El predeterminado es <code>audiobookshelf</code> , que puede retirar o sustituir con las URI adicionales para la integración de aplicaciones de terceros. Usando un asterisco (<code>*</code> ) como única entrada que permite cualquier URI.\",\n  \"LabelMore\": \"Más\",\n  \"LabelMoreInfo\": \"Más información\",\n  \"LabelName\": \"Nombre\",\n  \"LabelNarrator\": \"Narrador\",\n  \"LabelNarrators\": \"Narradores\",\n  \"LabelNew\": \"Nuevo\",\n  \"LabelNewPassword\": \"Nueva Contraseña\",\n  \"LabelNewestAuthors\": \"Autores más nuevos\",\n  \"LabelNewestEpisodes\": \"Episodios más nuevos\",\n  \"LabelNextBackupDate\": \"Fecha del siguiente respaldo\",\n  \"LabelNextChapters\": \"Los próximos capítulos serán:\",\n  \"LabelNextScheduledRun\": \"Próxima ejecución programada\",\n  \"LabelNoApiKeys\": \"Sin claves API\",\n  \"LabelNoCustomMetadataProviders\": \"Sin proveedores de metadatos personalizados\",\n  \"LabelNoEpisodesSelected\": \"Ningún Episodio Seleccionado\",\n  \"LabelNotFinished\": \"No finalizado\",\n  \"LabelNotStarted\": \"Sin iniciar\",\n  \"LabelNotes\": \"Notas\",\n  \"LabelNotificationAppriseURL\": \"URL(s) de Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Variables disponibles\",\n  \"LabelNotificationBodyTemplate\": \"Plantilla de cuerpo\",\n  \"LabelNotificationEvent\": \"Evento de notificación\",\n  \"LabelNotificationTitleTemplate\": \"Plantilla de título\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Máximo de intentos fallidos\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Las notificaciones se desactivan después de fallar este número de veces\",\n  \"LabelNotificationsMaxQueueSize\": \"Tamaño máximo de la cola de notificaciones\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Las notificaciones están limitadas a 1 por segundo. Las notificaciones serán ignoradas si llegan al numero máximo de cola para prevenir spam de eventos.\",\n  \"LabelNumberOfBooks\": \"Número de libros\",\n  \"LabelNumberOfChapters\": \"Número de capítulos:\",\n  \"LabelNumberOfEpisodes\": \"Nº de episodios\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Nombre de la notificación de OpenID que contiene permisos avanzados para acciones de usuario dentro de la aplicación que se aplicarán a roles que no sean de administrador (<b>si están configurados</b>). Si el reclamo no aparece en la respuesta, se denegará el acceso a ABS. Si falta una sola opción, se tratará como <code>falsa</code>. Asegúrese de que la notificación del proveedor de identidades coincida con la estructura esperada:\",\n  \"LabelOpenIDClaims\": \"Deje las siguientes opciones vacías para desactivar la asignación avanzada de grupos y permisos, lo que asignaría de manera automática al grupo «Usuario».\",\n  \"LabelOpenIDGroupClaimDescription\": \"Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como <code>grupos</code>. <b>Si se configura</b>, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \\\"admin\\\", \\\"user\\\" o \\\"guest\\\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.\",\n  \"LabelOpenRSSFeed\": \"Abrir suministro RSS\",\n  \"LabelOverwrite\": \"Sobrescribir\",\n  \"LabelPaginationPageXOfY\": \"Página {0} de {1}\",\n  \"LabelPassword\": \"Contraseña\",\n  \"LabelPath\": \"Ruta\",\n  \"LabelPermanent\": \"Permanente\",\n  \"LabelPermissionsAccessAllLibraries\": \"Puede acceder a todas las bibliotecas\",\n  \"LabelPermissionsAccessAllTags\": \"Puede acceder a todas las etiquetas\",\n  \"LabelPermissionsAccessExplicitContent\": \"Puede acceder a contenido explícito\",\n  \"LabelPermissionsCreateEreader\": \"Puede crear un lector\",\n  \"LabelPermissionsDelete\": \"Puede eliminar\",\n  \"LabelPermissionsDownload\": \"Puede descargar\",\n  \"LabelPermissionsUpdate\": \"Puede actualizar\",\n  \"LabelPermissionsUpload\": \"Puede cargar\",\n  \"LabelPersonalYearReview\": \"Revisión de su año ({0})\",\n  \"LabelPhotoPathURL\": \"Ruta/URL de foto\",\n  \"LabelPlayMethod\": \"Método de reproducción\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Cantidad de aumento/reducción de tasa de reproducción\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} de {1}\",\n  \"LabelPlaylists\": \"Listas de reproducción\",\n  \"LabelPodcast\": \"Pódcast\",\n  \"LabelPodcastSearchRegion\": \"Región de búsqueda de pódcast\",\n  \"LabelPodcastType\": \"Tipo de pódcast\",\n  \"LabelPodcasts\": \"Pódcast\",\n  \"LabelPort\": \"Puerto\",\n  \"LabelPrefixesToIgnore\": \"Prefijos para ignorar (no distingue entre mayúsculas y minúsculas)\",\n  \"LabelPreventIndexing\": \"Evite que los directorios de pódcast de iTunes y Google indexen su suministro\",\n  \"LabelPrimaryEbook\": \"Libro electrónico principal\",\n  \"LabelProgress\": \"Progreso\",\n  \"LabelProvider\": \"Proveedor\",\n  \"LabelProviderAuthorizationValue\": \"Valor del encabezado de autorización\",\n  \"LabelPubDate\": \"Fecha de publicación\",\n  \"LabelPublishYear\": \"Año de publicación\",\n  \"LabelPublishedDate\": \"Publicado {0}\",\n  \"LabelPublishedDecade\": \"Década de publicación\",\n  \"LabelPublishedDecades\": \"Décadas publicadas\",\n  \"LabelPublisher\": \"Editor\",\n  \"LabelPublishers\": \"Editores\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Correo-e de propietario personalizado\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nombre de propietario personalizado\",\n  \"LabelRSSFeedOpen\": \"Fuente RSS Abierta\",\n  \"LabelRSSFeedPreventIndexing\": \"Evitar indización\",\n  \"LabelRSSFeedSlug\": \"Ficha de suministro RSS\",\n  \"LabelRSSFeedURL\": \"URL de suministro RSS\",\n  \"LabelRandomly\": \"Aleatorio\",\n  \"LabelReAddSeriesToContinueListening\": \"Volver a agregar la serie para continuar escuchándola\",\n  \"LabelRead\": \"Leído\",\n  \"LabelReadAgain\": \"Volver a leer\",\n  \"LabelReadEbookWithoutProgress\": \"Leer libro electrónico sin guardar progreso\",\n  \"LabelRecentSeries\": \"Series recientes\",\n  \"LabelRecentlyAdded\": \"Añadidos recientemente\",\n  \"LabelRecommended\": \"Recomendados\",\n  \"LabelRedo\": \"Rehacer\",\n  \"LabelRegion\": \"Región\",\n  \"LabelReleaseDate\": \"Fecha de estreno\",\n  \"LabelRemoveAllMetadataAbs\": \"Eliminar todos los archivos metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Eliminar todos los archivos metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Quitar introducción y cierre de Audible de los capítulos\",\n  \"LabelRemoveCover\": \"Quitar cubierta\",\n  \"LabelRemoveMetadataFile\": \"Eliminar archivos de metadatos en carpetas de elementos de biblioteca\",\n  \"LabelRemoveMetadataFileHelp\": \"Elimine todos los archivos metadata.json y metadata.abs de sus carpetas {0}.\",\n  \"LabelRowsPerPage\": \"Filas por página\",\n  \"LabelSearchTerm\": \"Buscar término\",\n  \"LabelSearchTitle\": \"Buscar título\",\n  \"LabelSearchTitleOrASIN\": \"Buscar título o ASIN\",\n  \"LabelSeason\": \"Temporada\",\n  \"LabelSeasonNumber\": \"{0}.ª temporada\",\n  \"LabelSelectAll\": \"Seleccionar todo\",\n  \"LabelSelectAllEpisodes\": \"Seleccionar todos los episodios\",\n  \"LabelSelectEpisodesShowing\": \"Seleccionar los {0} episodios visibles\",\n  \"LabelSelectUser\": \"Seleccionar usuario\",\n  \"LabelSelectUsers\": \"Seleccionar usuarios\",\n  \"LabelSendEbookToDevice\": \"Enviar libro electrónico a...\",\n  \"LabelSequence\": \"Secuencia\",\n  \"LabelSerial\": \"En serie\",\n  \"LabelSeries\": \"Series\",\n  \"LabelSeriesName\": \"Nombre de la serie\",\n  \"LabelSeriesProgress\": \"Progreso de la serie\",\n  \"LabelServerLogLevel\": \"Nivel de registro del servidor\",\n  \"LabelServerYearReview\": \"Resumen del año del servidor ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Establecer como primario\",\n  \"LabelSetEbookAsSupplementary\": \"Establecer como suplementario\",\n  \"LabelSettingsAllowIframe\": \"Permitir incrustación en un iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Sólo Audiolibros\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios\",\n  \"LabelSettingsBookshelfViewHelp\": \"Diseño Esqueuomorfo con Estantes de Madera\",\n  \"LabelSettingsChromecastSupport\": \"Compatibilidad con Chromecast\",\n  \"LabelSettingsDateFormat\": \"Formato de Fecha\",\n  \"LabelSettingsEnableWatcher\": \"Vigilar automáticamente los cambios en bibliotecas\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Vigilar automáticamente los cambios de biblioteca\",\n  \"LabelSettingsEnableWatcherHelp\": \"Permite agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Requiere reiniciar el servidor\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Permitir scripts en epubs\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Permitir que los archivos epub ejecuten scripts. Se recomienda mantener esta opción desactivada a menos que confíe en el origen de los archivos epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Funcionalidades experimentales\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funciones en desarrollo que se beneficiarían de sus comentarios y experiencias de prueba. Haga click aquí para abrir una conversación en Github.\",\n  \"LabelSettingsFindCovers\": \"Encontrar cubiertas\",\n  \"LabelSettingsFindCoversHelp\": \"Si su audiolibro no tiene una cubierta incrustada, o esta no se encuentra en la carpeta, el escaneador tratará de encontrar una cubierta.<br>Nota: esto prolongará el tiempo de escaneo\",\n  \"LabelSettingsHideSingleBookSeries\": \"Esconder series con un solo libro\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Las series con un solo libro no aparecerán en la página de series ni la repisa para series de la página principal.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Usar la vista de librero en la página principal\",\n  \"LabelSettingsLibraryBookshelfView\": \"Usar la vista de librero en la biblioteca\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"El porcentaje completado es mayor que\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"El tiempo restante es menor a (segundos)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Marcar el archivo multimedia como terminado cuando\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Saltar libros anteriores de la serie Continuada\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"El estante de la página de inicio de Continuar Serie muestra el primer libro no iniciado de una serie que tenga por lo menos un libro finalizado y no tenga libros en progreso. Habilitar esta opción le permitirá continuar series desde el último libro que ha completado en vez del primer libro que no ha empezado.\",\n  \"LabelSettingsParseSubtitles\": \"Extraer Subtítulos\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Extraer subtítulos de los nombres de las carpetas de los audiolibros.<br>Los subtítulos deben estar separados por « - »<br>Así, «Título de libro - Un subtítulo aquí» tiene el subtítulo «Un subtítulo aquí»\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferir metadatos encontrados\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Los datos encontrados sobreescribirán los detalles del elemento cuando se use \\\"Encontrar Rápido\\\". Por defecto, \\\"Encontrar Rápido\\\" sólo completará los detalles faltantes.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Omitir libros coincidentes que ya tengan un ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Omitir libros coincidentes que ya tengan un ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorar prefijos al ordenar\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"Es decir, para el prefijo \\\"el\\\" título del libro, \\\"El título del libro\\\" se ordenaría como \\\"Título del libro, El\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Usar cubiertas cuadradas\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Preferir usar cubiertas cuadradas sobre las cubiertas estándar 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Guardar cubiertas con el elemento\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"De forma predeterminada, las portadas se almacenan en /metadata/items, activando esta opción almacenará las portadas en la carpeta de elementos de la biblioteca. Sólo se conservará un archivo llamado \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Guardar metadatos con elementos\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca\",\n  \"LabelSettingsTimeFormat\": \"Formato de Tiempo\",\n  \"LabelShare\": \"Compartir\",\n  \"LabelShareDownloadableHelp\": \"Permite a quienes posean el enlace de compartición descargar un archivo zip del elemento de la biblioteca.\",\n  \"LabelShareOpen\": \"abrir un recurso compartido\",\n  \"LabelShareURL\": \"Compartir la URL\",\n  \"LabelShowAll\": \"Mostrar todo\",\n  \"LabelShowSeconds\": \"Mostrar segundos\",\n  \"LabelShowSubtitles\": \"Mostrar subtítulos\",\n  \"LabelSize\": \"Tamaño\",\n  \"LabelSleepTimer\": \"Temporizador de dormida\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Ascendente\",\n  \"LabelSortDescending\": \"Descendente\",\n  \"LabelSortPubDate\": \"Ord. fecha pub.\",\n  \"LabelStart\": \"Iniciar\",\n  \"LabelStartTime\": \"Tiempo de Inicio\",\n  \"LabelStarted\": \"Iniciado\",\n  \"LabelStartedAt\": \"Iniciado En\",\n  \"LabelStartedDate\": \"Iniciado {0}\",\n  \"LabelStatsAudioTracks\": \"Pistas de Audio\",\n  \"LabelStatsAuthors\": \"Autores\",\n  \"LabelStatsBestDay\": \"Mejor día\",\n  \"LabelStatsDailyAverage\": \"Promedio diario\",\n  \"LabelStatsDays\": \"Días\",\n  \"LabelStatsDaysListened\": \"Días escuchando\",\n  \"LabelStatsHours\": \"Horas\",\n  \"LabelStatsInARow\": \"seguidos\",\n  \"LabelStatsItemsFinished\": \"Elementos terminados\",\n  \"LabelStatsItemsInLibrary\": \"Elementos en biblioteca\",\n  \"LabelStatsMinutes\": \"minutos\",\n  \"LabelStatsMinutesListening\": \"Minutos escuchando\",\n  \"LabelStatsOverallDays\": \"Total de días\",\n  \"LabelStatsOverallHours\": \"Total de horas\",\n  \"LabelStatsWeekListening\": \"Tiempo escuchando en la semana\",\n  \"LabelSubtitle\": \"Subtítulo\",\n  \"LabelSupportedFileTypes\": \"Tipos de archivo admitidos\",\n  \"LabelTag\": \"Etiqueta\",\n  \"LabelTags\": \"Etiquetas\",\n  \"LabelTagsAccessibleToUser\": \"Etiquetas accessibles al usuario\",\n  \"LabelTagsNotAccessibleToUser\": \"Etiquetas no accesibles al usuario\",\n  \"LabelTasks\": \"Tareas en ejecución\",\n  \"LabelTextEditorBulletedList\": \"Lista con bolos\",\n  \"LabelTextEditorLink\": \"Enlazar\",\n  \"LabelTextEditorNumberedList\": \"Lista numerada\",\n  \"LabelTextEditorUnlink\": \"Desenlazar\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Oscuro\",\n  \"LabelThemeLight\": \"Claro\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Tiempo Base\",\n  \"LabelTimeDurationXHours\": \"{0} horas\",\n  \"LabelTimeDurationXMinutes\": \"{0} minutos\",\n  \"LabelTimeDurationXSeconds\": \"{0} segundos\",\n  \"LabelTimeInMinutes\": \"Tiempo en minutos\",\n  \"LabelTimeLeft\": \"Quedan {0}\",\n  \"LabelTimeListened\": \"Tiempo Escuchando\",\n  \"LabelTimeListenedToday\": \"Tiempo Escuchando Hoy\",\n  \"LabelTimeRemaining\": \"{0} restante\",\n  \"LabelTimeToShift\": \"Tiempo para Cambiar en Segundos\",\n  \"LabelTitle\": \"Título\",\n  \"LabelToolsEmbedMetadata\": \"Incrustar Metadatos\",\n  \"LabelToolsEmbedMetadataDescription\": \"Incrusta metadatos en los archivos de audio, incluyendo la portada y capítulos.\",\n  \"LabelToolsM4bEncoder\": \"Codificador M4B\",\n  \"LabelToolsMakeM4b\": \"Hacer Archivo de Audiolibro M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Generar archivo de audiolibro .M4B con metadatos, imágenes de portada y capítulos incorporados.\",\n  \"LabelToolsSplitM4b\": \"Dividir M4B en Archivos MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Dividir M4B en Archivos MP3 e incorporar metadatos, imágenes de portada y capítulos.\",\n  \"LabelTotalDuration\": \"Duración Total\",\n  \"LabelTotalTimeListened\": \"Tiempo Total Escuchado\",\n  \"LabelTrackFromFilename\": \"Pista desde el Nombre del Archivo\",\n  \"LabelTrackFromMetadata\": \"Pista desde Metadatos\",\n  \"LabelTracks\": \"Pistas\",\n  \"LabelTracksMultiTrack\": \"Varias pistas\",\n  \"LabelTracksNone\": \"Ninguna pista\",\n  \"LabelTracksSingleTrack\": \"Una pista\",\n  \"LabelTrailer\": \"Tráiler\",\n  \"LabelType\": \"Tipo\",\n  \"LabelUnabridged\": \"No Abreviado\",\n  \"LabelUndo\": \"Deshacer\",\n  \"LabelUnknown\": \"Desconocido\",\n  \"LabelUnknownPublishDate\": \"Fecha de publicación desconocida\",\n  \"LabelUpdateCover\": \"Actualizar cubierta\",\n  \"LabelUpdateCoverHelp\": \"Permitir sobrescribir las cubiertas existentes de los libros seleccionados cuando se encuentra una coincidencia\",\n  \"LabelUpdateDetails\": \"Actualizar detalles\",\n  \"LabelUpdateDetailsHelp\": \"Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados\",\n  \"LabelUpdatedAt\": \"Actualizado En\",\n  \"LabelUploaderDragAndDrop\": \"Arrastre y suelte archivos o carpetas\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Arrastrar y soltar archivos\",\n  \"LabelUploaderDropFiles\": \"Suelte los Archivos\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Buscar título, autor y series automáticamente\",\n  \"LabelUseAdvancedOptions\": \"Usar opciones avanzadas\",\n  \"LabelUseChapterTrack\": \"Usar pista por capitulo\",\n  \"LabelUseFullTrack\": \"Usar pista completa\",\n  \"LabelUseZeroForUnlimited\": \"Utilice 0 para ilimitado\",\n  \"LabelUser\": \"Usuario\",\n  \"LabelUsername\": \"Nombre de usuario\",\n  \"LabelValue\": \"Valor\",\n  \"LabelVersion\": \"Versión\",\n  \"LabelViewBookmarks\": \"Ver Marcadores\",\n  \"LabelViewChapters\": \"Ver Capítulos\",\n  \"LabelViewPlayerSettings\": \"Ver configuración del reproductor\",\n  \"LabelViewQueue\": \"Ver Fila del Reproductor\",\n  \"LabelVolume\": \"Volumen\",\n  \"LabelWebRedirectURLsDescription\": \"Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Subcarpeta para URL de redireccionamiento\",\n  \"LabelWeekdaysToRun\": \"Correr en Días de la Semana\",\n  \"LabelXBooks\": \"{0} libros\",\n  \"LabelXItems\": \"{0} elementos\",\n  \"LabelYearReviewHide\": \"Ocultar resumen del año\",\n  \"LabelYearReviewShow\": \"Ver resumen del año\",\n  \"LabelYourAudiobookDuration\": \"Duración de tu Audiolibro\",\n  \"LabelYourBookmarks\": \"Sus marcadores\",\n  \"LabelYourPlaylists\": \"Tus Listas\",\n  \"LabelYourProgress\": \"Su progreso\",\n  \"MessageAddToPlayerQueue\": \"Agregar a fila del Reproductor\",\n  \"MessageAppriseDescription\": \"Para usar esta función deberás tener <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">la API de Apprise</a> corriendo o una API que maneje los mismos resultados. <br/>La URL de la API de Apprise debe tener la misma ruta de archivos que donde se envían las notificaciones. Por ejemplo: si su API esta en <code>http://192.168.1.1:8337</code> entonces pondría <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Cerciórese de usar el ASIN de la región correcta de Audible, no de Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Los vales de API heredados serán retirados en el futuro. Utilice las <a href=\\\"/config/api-keys\\\">claves de API</a> en su lugar.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Reinicie el servidor tras el guardado para aplicar los cambios de OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"La autenticación ha sido mejorada para seguridad. Todos los usuarios requieren reiniciar sesión.\",\n  \"MessageBackupsDescription\": \"Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en <code>/metadata/items</code> y <code>/metadata/authors</code>. Los Respaldos <strong>no</strong> incluyen ningún archivo guardado en la carpeta de tu biblioteca.\",\n  \"MessageBackupsLocationEditNote\": \"Nota: actualizar la ubicación de la copia de respaldo no moverá ni modificará los respaldos existentes\",\n  \"MessageBackupsLocationNoEditNote\": \"Nota: la ubicación de la copia de respaldo se establece a través de una variable de entorno y no se puede cambiar aquí.\",\n  \"MessageBackupsLocationPathEmpty\": \"La ruta de la copia de seguridad no puede estar vacía\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Rellenar campos activados con datos de todos los elementos. Los campos con varios valores se combinarán\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Rellenar campos de detalles de mapa con datos de este elemento\",\n  \"MessageBatchQuickMatchDescription\": \"\\\"Encontrar Rápido\\\" tratará de agregar portadas y metadatos faltantes de los elementos seleccionados. Habilite la opción de abajo para que \\\"Encontrar Rápido\\\" pueda sobrescribir portadas y/o metadatos existentes.\",\n  \"MessageBookshelfNoCollections\": \"Aún no ha hecho ninguna colección\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Las colecciones son públicas. Cualquiera que pueda acceder a la biblioteca las podrá ver.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Ningún suministro RSS está abierto\",\n  \"MessageBookshelfNoResultsForFilter\": \"El filtro «{0}: {1}» no produjo ningún resultado\",\n  \"MessageBookshelfNoResultsForQuery\": \"No hay resultados para la consulta\",\n  \"MessageBookshelfNoSeries\": \"No tiene ninguna serie\",\n  \"MessageBulkChapterPattern\": \"¿Cuántos capítulos desea añadir con este patrón de numeración?\",\n  \"MessageChapterEndIsAfter\": \"El final del capítulo es después del final de tu audiolibro\",\n  \"MessageChapterErrorFirstNotZero\": \"El primer capítulo debe iniciar en 0\",\n  \"MessageChapterErrorStartGteDuration\": \"El tiempo de inicio no es válido: debe ser inferior a la duración del audiolibro\",\n  \"MessageChapterErrorStartLtPrev\": \"El tiempo de inicio no es válido: debe ser mayor o igual que el tiempo de inicio del capítulo anterior\",\n  \"MessageChapterStartIsAfter\": \"El comienzo del capítulo es después del final de su audiolibro\",\n  \"MessageChaptersNotFound\": \"Capítulos no encontrados\",\n  \"MessageCheckingCron\": \"Revisando cron...\",\n  \"MessageConfirmCloseFeed\": \"¿Confirma que quiere cerrar este suministro?\",\n  \"MessageConfirmDeleteApiKey\": \"¿Está seguro que desea eliminar la clave API «{0}»?\",\n  \"MessageConfirmDeleteBackup\": \"¿Confirma que quiere eliminar el respaldo de {0}?\",\n  \"MessageConfirmDeleteDevice\": \"¿Confirma que quiere eliminar el lector electrónico «{0}»?\",\n  \"MessageConfirmDeleteFile\": \"Esto eliminará el archivo del sistema de archivos. ¿Quiere continuar?\",\n  \"MessageConfirmDeleteLibrary\": \"¿Confirma que quiere eliminar permanentemente la biblioteca «{0}»?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Esto eliminará el elemento de la biblioteca de la base de datos y del sistema de archivos. ¿Confirma que quiere hacerlo?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Esto eliminará {0} elementos de la biblioteca de la base de datos y del sistema de archivos. ¿Confirma que quiere hacerlo?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"¿Confirma que quiere eliminar el proveedor de metadatos personalizado «{0}»?\",\n  \"MessageConfirmDeleteNotification\": \"¿Confirma que quiere eliminar esta notificación?\",\n  \"MessageConfirmDeleteSession\": \"¿Confirma que quiere eliminar esta sesión?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"¿Confirma que quiere incrustar metadatos en {0} archivos de audio?\",\n  \"MessageConfirmForceReScan\": \"¿Confirma que quiere forzar un reescaneo?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"¿Confirma que quiere marcar todos los episodios como terminados?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"¿Confirma que quiere marcar todos los episodios como no terminados?\",\n  \"MessageConfirmMarkItemFinished\": \"¿Confirma que quiere marcar «{0}» como terminado?\",\n  \"MessageConfirmMarkItemNotFinished\": \"¿Confirma que quiere marcar «{0}» como no terminado?\",\n  \"MessageConfirmMarkSeriesFinished\": \"¿Confirma que quiere marcar todos los libros de esta serie como terminados?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"¿Confirma que quiere marcar todos los libros de esta serie como no terminados?\",\n  \"MessageConfirmNotificationTestTrigger\": \"¿Activar esta notificación con datos de prueba?\",\n  \"MessageConfirmPurgeCache\": \"La purga del caché eliminará el directorio completo en <code>/metadata/cache</code>. <br /><br />¿Confirma que desea quitar el directorio de caché?\",\n  \"MessageConfirmPurgeItemsCache\": \"Purgar el caché de elementos eliminará el directorio completo ubicado en <code>/metadata/cache/items</code>.<br />¿Lo confirma?\",\n  \"MessageConfirmQuickEmbed\": \"Atención: la incrustación rápida no realiza copias de respaldo a ninguno de sus archivos de audio. Cerciórese de haber realizado una copia de los mismos previamente. <br><br>¿Quiere continuar?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Quiere continuar?\",\n  \"MessageConfirmReScanLibraryItems\": \"¿Confirma que quiere volver a analizar {0} elementos?\",\n  \"MessageConfirmRemoveAllChapters\": \"¿Confirma que quiere quitar todos los capítulos?\",\n  \"MessageConfirmRemoveAuthor\": \"¿Confirma que quiere quitar el autor «{0}»?\",\n  \"MessageConfirmRemoveCollection\": \"¿Confirma que quiere quitar la colección «{0}»?\",\n  \"MessageConfirmRemoveEpisode\": \"¿Confirma que quiere quitar el episodio «{0}»?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Nota: Esto no borra el archivo de audio a menos que se active la opción \\\"Borrado definitivo del archivo\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"¿Confirma que quiere quitar {0} episodios?\",\n  \"MessageConfirmRemoveListeningSessions\": \"¿Confirma que quiere quitar {0} sesiones de escucha?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"¿Confirma que quiere quitar todos los archivos metadata.{0} en las carpetas de elementos de su biblioteca?\",\n  \"MessageConfirmRemoveNarrator\": \"¿Confirma que quiere quitar el narrador «{0}»?\",\n  \"MessageConfirmRemovePlaylist\": \"¿Confirma que quiere quitar la lista de reproducción «{0}»?\",\n  \"MessageConfirmRenameGenre\": \"¿Confirma que quiere cambiar el nombre del género «{0}» a «{1}» en todos los elementos?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Nota: Este género ya existe, por lo que se fusionarán.\",\n  \"MessageConfirmRenameGenreWarning\": \"¡Atención! Ya existe un género similar con distinta mayusculación, «{0}».\",\n  \"MessageConfirmRenameTag\": \"¿Confirma que quiere cambiar el nombre de la etiqueta «{0}» a «{1}» en todos los elementos?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Nota: esta etiqueta ya existe, por lo que se fusionarán.\",\n  \"MessageConfirmRenameTagWarning\": \"¡Atención! Ya existe una etiqueta similar con distinta mayusculación, «{0}».\",\n  \"MessageConfirmResetProgress\": \"¿Confirma que quiere restablecer su progreso?\",\n  \"MessageConfirmSendEbookToDevice\": \"¿Confirma que quiere enviar el libro electrónico {0} «{1}» al dispositivo «{2}»?\",\n  \"MessageConfirmUnlinkOpenId\": \"¿Confirma que quiere desenlazar este usuario de OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} días escuchados el año pasado\",\n  \"MessageDownloadingEpisode\": \"Descargando episodio\",\n  \"MessageDragFilesIntoTrackOrder\": \"Arrastre los archivos al orden correcto de las pistas\",\n  \"MessageEmbedFailed\": \"Incorporación incorrecta.\",\n  \"MessageEmbedFinished\": \"Incorporación finalizada.\",\n  \"MessageEmbedQueue\": \"En cola para incrustar metadatos ({0} en cola)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} episodio(s) en cola para descargar\",\n  \"MessageEreaderDevices\": \"Para garantizar la entrega de libros electrónicos, es posible que tenga que agregar la dirección de correo electrónico anterior como remitente válido para cada dispositivo enumerado a continuación.\",\n  \"MessageFeedURLWillBe\": \"El URL del suministro será {0}\",\n  \"MessageFetching\": \"Recuperando...\",\n  \"MessageForceReScanDescription\": \"Escaneará todos los archivos como un nuevo escaneo. Archivos de audio con etiquetas ID3, archivos OPF y archivos de texto serán escaneados como nuevos.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} escuchando</strong> en {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"No enumera sesiones en {0}\",\n  \"MessageImportantNotice\": \"¡Notificación importante!\",\n  \"MessageInsertChapterBelow\": \"Insertar capítulo debajo\",\n  \"MessageInvalidAsin\": \"ASIN no válido\",\n  \"MessageItemsSelected\": \"{0} elementos seleccionados\",\n  \"MessageItemsUpdated\": \"{0} elementos actualizados\",\n  \"MessageJoinUsOn\": \"Únase a nosotros en\",\n  \"MessageLoading\": \"Cargando...\",\n  \"MessageLoadingFolders\": \"Cargando archivos...\",\n  \"MessageLogsDescription\": \"Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"¡Fallo de M4B!\",\n  \"MessageM4BFinished\": \"¡M4B Terminado!\",\n  \"MessageMapChapterTitles\": \"Asignar los nombres de capítulos a los capítulos existentes en tu audiolibro sin ajustar sus tiempos\",\n  \"MessageMarkAllEpisodesFinished\": \"Marcar todos los episodios como terminados\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Marcar todos los episodios como no terminados\",\n  \"MessageMarkAsFinished\": \"Marcar como terminado\",\n  \"MessageMarkAsNotFinished\": \"Marcar como No Terminado\",\n  \"MessageMatchBooksDescription\": \"Se intentará hacer coincidir los libros de la biblioteca con un libro del proveedor de búsqueda seleccionado, y se rellenarán los detalles vacíos y la portada. No sobrescribe los detalles.\",\n  \"MessageNoAudioTracks\": \"Sin Pista de Audio\",\n  \"MessageNoAuthors\": \"Sin Autores\",\n  \"MessageNoBackups\": \"Sin Respaldos\",\n  \"MessageNoBookmarks\": \"Sin marcadores\",\n  \"MessageNoChapters\": \"Sin capítulos\",\n  \"MessageNoCollections\": \"Sin colecciones\",\n  \"MessageNoCoversFound\": \"Ninguna cubierta encontrada\",\n  \"MessageNoDescription\": \"Sin Descripción\",\n  \"MessageNoDevices\": \"Sin dispositivos\",\n  \"MessageNoDownloadsInProgress\": \"No hay descargas actualmente en curso\",\n  \"MessageNoDownloadsQueued\": \"Sin Lista de Descarga\",\n  \"MessageNoEpisodeMatchesFound\": \"No se encontraron episodios que coinciden\",\n  \"MessageNoEpisodes\": \"Ningún episodio\",\n  \"MessageNoFoldersAvailable\": \"Ninguna carpeta disponible\",\n  \"MessageNoGenres\": \"Ningún género\",\n  \"MessageNoIssues\": \"Ningún número\",\n  \"MessageNoItems\": \"Ningún elemento\",\n  \"MessageNoItemsFound\": \"Ningún elemento encontrado\",\n  \"MessageNoListeningSessions\": \"Ninguna sesión de escucha\",\n  \"MessageNoLogs\": \"Ningún registro\",\n  \"MessageNoMediaProgress\": \"Multimedia sin Progreso\",\n  \"MessageNoNotifications\": \"Ninguna notificación\",\n  \"MessageNoPodcastFeed\": \"Pódcast no válido: no hay suministro\",\n  \"MessageNoPodcastsFound\": \"No se encontró ningún pódcast\",\n  \"MessageNoResults\": \"Ningún resultado\",\n  \"MessageNoSearchResultsFor\": \"La búsqueda «{0}» no produjo ningún resultado\",\n  \"MessageNoSeries\": \"Ninguna serie\",\n  \"MessageNoTags\": \"Ninguna etiqueta\",\n  \"MessageNoTasksRunning\": \"Ninguna tarea en ejecución\",\n  \"MessageNoUpdatesWereNecessary\": \"No fue necesario actualizar\",\n  \"MessageNoUserPlaylists\": \"No tiene ninguna lista de reproducción\",\n  \"MessageNoUserPlaylistsHelp\": \"Las listas de reproducción son privadas. Solo quien las cree podrá verlas.\",\n  \"MessageNotYetImplemented\": \"Aún no implementado\",\n  \"MessageOpmlPreviewNote\": \"Nota: Esta es una vista previa del archivo OPML analizado. El título real del podcast se obtendrá del canal RSS.\",\n  \"MessageOr\": \"o\",\n  \"MessagePauseChapter\": \"Pausar la reproducción del capítulo\",\n  \"MessagePlayChapter\": \"Escuchar el comienzo del capítulo\",\n  \"MessagePlaylistCreateFromCollection\": \"Crear una lista de reproducción a partir de una colección\",\n  \"MessagePleaseWait\": \"Espere…\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"El pódcast no tiene un URL de suministro RSS que pueda usarse para encontrar correspondencias\",\n  \"MessagePodcastSearchField\": \"Introduzca el término de búsqueda o el URL del suministro RSS\",\n  \"MessageQuickEmbedInProgress\": \"Integración rápida en proceso\",\n  \"MessageQuickEmbedQueue\": \"En cola para inserción rápida ({0} en cola)\",\n  \"MessageQuickMatchAllEpisodes\": \"Combina rápidamente todos los episodios\",\n  \"MessageQuickMatchDescription\": \"Rellena los detalles y la cubierta de los elementos vacíos con el primer resultado coincidente de «{0}». No sobrescribe los detalles a menos que se active la opción del servidor «Preferir metadatos coincidentes».\",\n  \"MessageRemoveChapter\": \"Quitar capítulo\",\n  \"MessageRemoveEpisodes\": \"Quitar {0} episodio(s)\",\n  \"MessageRemoveFromPlayerQueue\": \"Quitar de la cola de reproducción\",\n  \"MessageRemoveUserWarning\": \"¿Confirma que quiere eliminar permanentemente el usuario «{0}»?\",\n  \"MessageReportBugsAndContribute\": \"Informe de defectos, solicite funciones y contribuya en\",\n  \"MessageResetChaptersConfirm\": \"¿Confirma que quiere deshacer los cambios y restablecer los capítulos a su estado original?\",\n  \"MessageRestoreBackupConfirm\": \"¿Confirma que quiere restaurar el respaldo creado el\",\n  \"MessageRestoreBackupWarning\": \"Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.\",\n  \"MessageScheduleLibraryScanNote\": \"Para muchos usuarios, es recomendado dejar esta característica inhabilitada y mantener habilitados los ajustes de la «Vigía automática de cambio de biblioteca»: detectará automáticamente los cambios en sus carpetas de bibliotecas. Habilitar esta características si «Vigía automática de cambio de biblioteca» no funciona en su sistema de archivo (como NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Ejecutar cada {0} a las {1}\",\n  \"MessageSearchResultsFor\": \"Resultados de la búsqueda de\",\n  \"MessageSelected\": \"{0} seleccionado(s)\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"La secuencia de la serie no puede contener espacios\",\n  \"MessageServerCouldNotBeReached\": \"No se pudo establecer la conexión con el servidor\",\n  \"MessageSetChaptersFromTracksDescription\": \"Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio\",\n  \"MessageShareExpirationWillBe\": \"La caducidad será <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Caduduca en {0}\",\n  \"MessageShareURLWillBe\": \"La URL para compartir será <strong> {0} </strong>\",\n  \"MessageStartPlaybackAtTime\": \"¿Iniciar reproducción para «{0}» en {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"El archivo de audio «{0}» no se puede grabar\",\n  \"MessageTaskCanceledByUser\": \"Tarea cancelada por el usuario\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Descargando el episodio «{0}»\",\n  \"MessageTaskEmbeddingMetadata\": \"Inserción de metadatos\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Incrustando metadatos en el audiolibro «{0}»\",\n  \"MessageTaskEncodingM4b\": \"Codificación M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Codificando el audiolibro «{0}» en un único archivo m4b\",\n  \"MessageTaskFailed\": \"Fallida\",\n  \"MessageTaskFailedToBackupAudioFile\": \"No se pudo respaldar el archivo de audio «{0}»\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Error al crear el directorio de la caché\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"No se pudieron incrustar metadatos en el archivo «{0}»\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Error al fusionar archivos de audio\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Error al mover el archivo m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Error al escribir el archivo de metadatos\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Libros coincidentes en la biblioteca «{0}»\",\n  \"MessageTaskNoFilesToScan\": \"Sin archivos para escanear\",\n  \"MessageTaskOpmlImport\": \"Importar OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Creando pódcast a partir de {0} suministros RSS\",\n  \"MessageTaskOpmlImportFeed\": \"Feed de importación OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importando el suministro RSS «{0}»\",\n  \"MessageTaskOpmlImportFeedFailed\": \"No se pudo obtener el suministro del pódcast\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Creando pódcast «{0}»\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"El pódcast ya existe en la ruta\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"No se pudo crear el pódcast\",\n  \"MessageTaskOpmlImportFinished\": \"Añadido {0} podcasts\",\n  \"MessageTaskOpmlParseFailed\": \"No se pudo analizar el archivo OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"No se encontró la etiqueta <opml> del archivo OPML no válido O no se encontró la etiqueta <outline>\",\n  \"MessageTaskOpmlParseNoneFound\": \"No se encontró ningún suministro en el archivo OPML\",\n  \"MessageTaskScanItemsAdded\": \"{0} añadido\",\n  \"MessageTaskScanItemsMissing\": \"Falta {0}\",\n  \"MessageTaskScanItemsUpdated\": \"{0} actualizado\",\n  \"MessageTaskScanNoChangesNeeded\": \"No se necesitan cambios\",\n  \"MessageTaskScanningFileChanges\": \"Escaneando cambios en archivos en «{0}»\",\n  \"MessageTaskScanningLibrary\": \"Escaneando la biblioteca «{0}»\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"El directorio de destino no se puede escribir\",\n  \"MessageThinking\": \"Pensando...\",\n  \"MessageUploaderItemFailed\": \"Error al Subir\",\n  \"MessageUploaderItemSuccess\": \"¡Éxito al Subir!\",\n  \"MessageUploading\": \"Subiendo...\",\n  \"MessageValidCronExpression\": \"Expresión de Cron válida\",\n  \"MessageWatcherIsDisabledGlobally\": \"El watcher está desactivado globalmente en la configuración del servidor\",\n  \"MessageXLibraryIsEmpty\": \"¡La biblioteca {0} está vacía!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"La duración de su audiolibro es más larga que la duración encontrada\",\n  \"MessageYourAudiobookDurationIsShorter\": \"La duración de su audiolibro es más corta que la duración encontrada\",\n  \"NoteChangeRootPassword\": \"El usuario Root es el único usuario que puede no tener una contraseña\",\n  \"NoteChapterEditorTimes\": \"Nota: El tiempo de inicio del primer capítulo debe permanecer en 0:00, y el tiempo de inicio del último capítulo no puede exceder la duración del audiolibro.\",\n  \"NoteFolderPicker\": \"Nota: Las carpetas ya asignadas no se mostrarán\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Atención: la mayoría de las aplicaciones de pódcast requieren que el URL del suministro RSS use HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Advertencia: 1 o más de sus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Las carpetas con archivos multimedia se manejarán como elementos separados en la biblioteca.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Si sube solamente archivos de audio, cada archivo se manejará como un audiolibro por separado.\",\n  \"NoteUploaderUnsupportedFiles\": \"Se ignorarán los archivos no admitidos. Al elegir o arrastrar una carpeta, los archivos que no estén dentro de una subcarpeta se ignorarán.\",\n  \"NotificationOnBackupCompletedDescription\": \"Se activa cuando se completa una copia de seguridad\",\n  \"NotificationOnBackupFailedDescription\": \"Se activa cuando falla una copia de seguridad\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Se activa cuando se descarga automáticamente un episodio de un podcast\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Se activa cuando las descargas automáticas de episodios se desactivan debido a varios intentos fallidos\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Se activa cuando la solicitud a la fuente RSS falla durante una descarga automática de episodio\",\n  \"NotificationOnTestDescription\": \"Evento para probar el sistema de notificaciones\",\n  \"PlaceholderBulkChapterInput\": \"Ingrese título de capítulo o use numeración (ej. 'Episodio 1', 'Capítulo 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Nuevo nombre de la colección\",\n  \"PlaceholderNewFolderPath\": \"Nueva ruta de carpeta\",\n  \"PlaceholderNewPlaylist\": \"Nuevo nombre de la lista de reproducción\",\n  \"PlaceholderSearch\": \"Buscar..\",\n  \"PlaceholderSearchEpisode\": \"Buscar Episodio..\",\n  \"StatsAuthorsAdded\": \"autores añadidos\",\n  \"StatsBooksAdded\": \"libros añadidos\",\n  \"StatsBooksAdditional\": \"Algunas adiciones incluyen…\",\n  \"StatsBooksFinished\": \"libros terminados\",\n  \"StatsBooksFinishedThisYear\": \"Algunos libros terminados este año…\",\n  \"StatsBooksListenedTo\": \"libros escuchados\",\n  \"StatsCollectionGrewTo\": \"Tu colección de libros creció hasta…\",\n  \"StatsSessions\": \"sesiones\",\n  \"StatsSpentListening\": \"dedicado a la escucha\",\n  \"StatsTopAuthor\": \"AUTOR DESTACADO\",\n  \"StatsTopAuthors\": \"AUTORES DESTACADOS\",\n  \"StatsTopGenre\": \"GÉNERO PRINCIPAL\",\n  \"StatsTopGenres\": \"GÉNEROS PRINCIPALES\",\n  \"StatsTopMonth\": \"DESTACADO DEL MES\",\n  \"StatsTopNarrator\": \"NARRADOR DESTACADO\",\n  \"StatsTopNarrators\": \"NARRADORES DESTACADOS\",\n  \"StatsTotalDuration\": \"Con una duración total de…\",\n  \"StatsYearInReview\": \"RESEÑA DEL AÑO\",\n  \"ToastAccountUpdateSuccess\": \"Cuenta actualizada\",\n  \"ToastAppriseUrlRequired\": \"Debes ingresar una URL de Apprise\",\n  \"ToastAsinRequired\": \"Se requiere ASIN\",\n  \"ToastAuthorImageRemoveSuccess\": \"Se eliminó la imagen del autor\",\n  \"ToastAuthorNotFound\": \"No se encontró el autor «{0}»\",\n  \"ToastAuthorRemoveSuccess\": \"Autor eliminado\",\n  \"ToastAuthorSearchNotFound\": \"No se encontró al autor\",\n  \"ToastAuthorUpdateMerged\": \"Autor combinado\",\n  \"ToastAuthorUpdateSuccess\": \"Autor actualizado\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor actualizado (Imagen no encontrada)\",\n  \"ToastBackupAppliedSuccess\": \"Copia de seguridad aplicada\",\n  \"ToastBackupCreateFailed\": \"Error al crear respaldo\",\n  \"ToastBackupCreateSuccess\": \"Respaldo creado\",\n  \"ToastBackupDeleteFailed\": \"Error al eliminar respaldo\",\n  \"ToastBackupDeleteSuccess\": \"Respaldo eliminado\",\n  \"ToastBackupInvalidMaxKeep\": \"Número no válido de copias de respaldo para conservar\",\n  \"ToastBackupInvalidMaxSize\": \"Tamaño máximo de copia de respaldo no válido\",\n  \"ToastBackupRestoreFailed\": \"Error al restaurar el respaldo\",\n  \"ToastBackupUploadFailed\": \"Error al cargar la copia de respaldo\",\n  \"ToastBackupUploadSuccess\": \"Respaldo cargado\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Detalles aplicados a los elementos\",\n  \"ToastBatchDeleteFailed\": \"Falló la eliminación por lotes\",\n  \"ToastBatchDeleteSuccess\": \"Se eliminó por lotes correctamente\",\n  \"ToastBatchQuickMatchFailed\": \"¡Error en la sincronización rápida por lotes!\",\n  \"ToastBatchQuickMatchStarted\": \"¡Se inició el lote de búsqueda rápida de {0} libros!\",\n  \"ToastBatchUpdateFailed\": \"Falló la actualización por lotes\",\n  \"ToastBatchUpdateSuccess\": \"Se actualizó por lotes correctamente\",\n  \"ToastBookmarkCreateFailed\": \"No se pudo crear el marcador\",\n  \"ToastBookmarkCreateSuccess\": \"Marcador añadido\",\n  \"ToastBookmarkRemoveSuccess\": \"Marcador eliminado\",\n  \"ToastBulkChapterInvalidCount\": \"Por favor ingrese un número válido entre 1 y 150\",\n  \"ToastCachePurgeFailed\": \"No se pudo purgar la antememoria\",\n  \"ToastCachePurgeSuccess\": \"Se purgó la antememoria correctamente\",\n  \"ToastChapterLocked\": \"El capítulo está bloqueado.\",\n  \"ToastChapterStartTimeAdjusted\": \"El capítulo inicia el tiempo ajustado en {0} segundos\",\n  \"ToastChaptersAllLocked\": \"Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.\",\n  \"ToastChaptersHaveErrors\": \"Los capítulos tienen errores\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Cantidad de desplazamiento no válida. La hora de inicio del último capítulo se extendería más allá de la duración de este audiolibro.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Cantidad de desplazamiento no válida. El primer capítulo tendría una duración cero o negativa y lo sobrescribiría el segundo capítulo. Aumente la duración inicial del segundo capítulo.\",\n  \"ToastChaptersMustHaveTitles\": \"Los capítulos deben tener título\",\n  \"ToastChaptersRemoved\": \"Capítulos eliminados\",\n  \"ToastChaptersUpdated\": \"Capítulos actualizados\",\n  \"ToastCollectionItemsAddFailed\": \"Artículo(s) añadido(s) a la colección fallido(s)\",\n  \"ToastCollectionRemoveSuccess\": \"Colección quitada\",\n  \"ToastCollectionUpdateSuccess\": \"Colección actualizada\",\n  \"ToastConnectionNotAvailable\": \"Conexión no disponible. Intenta de nuevo más tarde\",\n  \"ToastCoverSearchFailed\": \"Cobertura de búsqueda incorrecta\",\n  \"ToastCoverUpdateFailed\": \"Error al actualizar la cubierta\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Fecha y hora no válidas o incompletas\",\n  \"ToastDeleteFileFailed\": \"Falló la eliminación del archivo\",\n  \"ToastDeleteFileSuccess\": \"Archivo eliminado\",\n  \"ToastDeviceAddFailed\": \"Error al añadir dispositivo\",\n  \"ToastDeviceNameAlreadyExists\": \"Un libro electrónico ya existe con ese nombre\",\n  \"ToastDeviceTestEmailFailed\": \"Error al enviar correo de prueba\",\n  \"ToastDeviceTestEmailSuccess\": \"Correo electrónico de prueba enviado\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Configuración del correo electrónico actualizada\",\n  \"ToastEncodeCancelFailed\": \"No se pudo cancelar la codificación\",\n  \"ToastEncodeCancelSucces\": \"Codificación cancelada\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"No se pudo borrar la cola\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Se borró la cola de descargas de los episodios\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} episodio(s) actualizado(s)\",\n  \"ToastErrorCannotShare\": \"No se puede compartir de forma nativa en este dispositivo\",\n  \"ToastFailedToCreate\": \"Ha fallado al crear\",\n  \"ToastFailedToDelete\": \"Ha fallado al eliminar\",\n  \"ToastFailedToLoadData\": \"Error al cargar data\",\n  \"ToastFailedToMatch\": \"Error al emparejar\",\n  \"ToastFailedToShare\": \"Error al compartir\",\n  \"ToastFailedToUpdate\": \"Error al actualizar\",\n  \"ToastInvalidImageUrl\": \"URL de la imagen no válida\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Número máximo de episodios para descargar no válidos\",\n  \"ToastInvalidUrl\": \"URL no válida\",\n  \"ToastInvalidUrls\": \"Una o más URL son inválidas\",\n  \"ToastItemCoverUpdateSuccess\": \"Cubierta del elemento actualizada\",\n  \"ToastItemDeletedFailed\": \"Error al eliminar el elemento\",\n  \"ToastItemDeletedSuccess\": \"Elemento borrado\",\n  \"ToastItemDetailsUpdateSuccess\": \"Detalles del Elemento Actualizados\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Error al marcar como terminado\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Elemento marcado como terminado\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"No se ha podido marcar como no finalizado\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Elemento marcado como No Terminado\",\n  \"ToastItemUpdateSuccess\": \"Elemento actualizado\",\n  \"ToastLibraryCreateFailed\": \"Error al crear biblioteca\",\n  \"ToastLibraryCreateSuccess\": \"Se creó la biblioteca «{0}»\",\n  \"ToastLibraryDeleteFailed\": \"Error al eliminar biblioteca\",\n  \"ToastLibraryDeleteSuccess\": \"Biblioteca eliminada\",\n  \"ToastLibraryScanFailedToStart\": \"Error al iniciar el escaneo\",\n  \"ToastLibraryScanStarted\": \"Se inició el escaneo de la biblioteca\",\n  \"ToastLibraryUpdateSuccess\": \"Se actualizó la biblioteca «{0}»\",\n  \"ToastMatchAllAuthorsFailed\": \"No se pudo encontrar a todos los autores\",\n  \"ToastMetadataFilesRemovedError\": \"Error al eliminar metadatos de {0} archivo(s)\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Sin metadatos.{0} archivo(s) eliminado(s)\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadatos.{1} archivos eliminados\",\n  \"ToastMustHaveAtLeastOnePath\": \"Debe tener al menos una ruta\",\n  \"ToastNameEmailRequired\": \"Son obligatorios el nombre y el correo electrónico\",\n  \"ToastNameRequired\": \"Nombre obligatorio\",\n  \"ToastNewApiKeyUserError\": \"Debe seleccionar un usuario\",\n  \"ToastNewEpisodesFound\": \"{0} nuevo(s) episodio(s) encontrado(s)\",\n  \"ToastNewUserCreatedFailed\": \"No se pudo crear la cuenta: «{0}»\",\n  \"ToastNewUserCreatedSuccess\": \"Nueva cuenta creada\",\n  \"ToastNewUserLibraryError\": \"Debes seleccionar al menos una biblioteca\",\n  \"ToastNewUserPasswordError\": \"Debes tener una contraseña, solo el usuario root puede estar sin contraseña\",\n  \"ToastNewUserTagError\": \"Debes seleccionar al menos una etiqueta\",\n  \"ToastNewUserUsernameError\": \"Introduce un nombre de usuario\",\n  \"ToastNoNewEpisodesFound\": \"No se encontraron nuevos episodios\",\n  \"ToastNoRSSFeed\": \"El pódcast no tiene un suministro RSS\",\n  \"ToastNoUpdatesNecessary\": \"No es necesario actualizar\",\n  \"ToastNotificationCreateFailed\": \"Error al crear notificación\",\n  \"ToastNotificationDeleteFailed\": \"Error al borrar la notificación\",\n  \"ToastNotificationFailedMaximum\": \"El número máximo de intentos fallidos debe ser ≥ 0\",\n  \"ToastNotificationQueueMaximum\": \"La cola de notificación máxima debe ser ≥ 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Configuración de notificaciones actualizada\",\n  \"ToastNotificationTestTriggerFailed\": \"No se ha podido activar la notificación de prueba\",\n  \"ToastNotificationTestTriggerSuccess\": \"Notificación de prueba activada\",\n  \"ToastNotificationUpdateSuccess\": \"Notificación actualizada\",\n  \"ToastPlaylistCreateFailed\": \"Error al crear la lista de reproducción\",\n  \"ToastPlaylistCreateSuccess\": \"Lista de reproducción creada\",\n  \"ToastPlaylistRemoveSuccess\": \"Lista de reproducción eliminada\",\n  \"ToastPlaylistUpdateSuccess\": \"Lista de reproducción actualizada\",\n  \"ToastPodcastCreateFailed\": \"No se pudo crear el pódcast\",\n  \"ToastPodcastCreateSuccess\": \"Se creó el pódcast correctamente\",\n  \"ToastPodcastEpisodeUpdated\": \"Episodio actualizado\",\n  \"ToastPodcastGetFeedFailed\": \"No se puede obtener el podcast\",\n  \"ToastPodcastNoEpisodesInFeed\": \"No se han encontrado episodios en el feed del RSS\",\n  \"ToastPodcastNoRssFeed\": \"El pódcast no tiene suministro RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"El progreso no se sincroniza, reinicia la reproducción\",\n  \"ToastProviderCreatedFailed\": \"Error al añadir el proveedor\",\n  \"ToastProviderCreatedSuccess\": \"Nuevo proveedor añadido\",\n  \"ToastProviderNameAndUrlRequired\": \"Nombre y Url obligatorios\",\n  \"ToastProviderRemoveSuccess\": \"Proveedor eliminado\",\n  \"ToastRSSFeedCloseFailed\": \"Error al cerrar el suministro RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"Suministro RSS cerrado\",\n  \"ToastRemoveFailed\": \"Error al eliminar\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Error al eliminar el elemento de la colección\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Elemento eliminado de la colección\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Error en la eliminación de artículos de biblioteca incorrectos\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Se eliminaron artículos de biblioteca incorrectos\",\n  \"ToastRenameFailed\": \"Error al cambiar el nombre\",\n  \"ToastRescanFailed\": \"Error al volver a escanear para {0}\",\n  \"ToastRescanRemoved\": \"Se eliminó el elemento reescaneado\",\n  \"ToastRescanUpToDate\": \"Reescaneado del artículo completo, estaba actualizado\",\n  \"ToastRescanUpdated\": \"Reescaneado completado, el artículo ha sido actualizado\",\n  \"ToastScanFailed\": \"No se pudo escanear el elemento de la biblioteca\",\n  \"ToastSelectAtLeastOneUser\": \"Seleccione al menos un usuario\",\n  \"ToastSendEbookToDeviceFailed\": \"No se pudo enviar el libro electrónico al dispositivo\",\n  \"ToastSendEbookToDeviceSuccess\": \"Libro electrónico enviado al dispositivo «{0}»\",\n  \"ToastSeriesSubmitFailedSameName\": \"No se puede añadir dos series con el mismo nombre\",\n  \"ToastSeriesUpdateFailed\": \"Error al actualizar la serie\",\n  \"ToastSeriesUpdateSuccess\": \"Serie actualizada\",\n  \"ToastServerSettingsUpdateSuccess\": \"Configuración del servidor actualizada\",\n  \"ToastSessionCloseFailed\": \"Error al cerrar la sesión\",\n  \"ToastSessionDeleteFailed\": \"Error al eliminar sesión\",\n  \"ToastSessionDeleteSuccess\": \"Sesión eliminada\",\n  \"ToastSleepTimerDone\": \"Temporizador de apagado automático activado... zZzzZz\",\n  \"ToastSlugMustChange\": \"El slug contiene caracteres no válidos\",\n  \"ToastSlugRequired\": \"Slug obligatorio\",\n  \"ToastSocketConnected\": \"Socket conectado\",\n  \"ToastSocketDisconnected\": \"Socket desconectado\",\n  \"ToastSocketFailedToConnect\": \"Error al conectar al Socket\",\n  \"ToastSortingPrefixesEmptyError\": \"Debe tener por lo menos 1 prefijo para ordenar\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Prefijos de ordenar actualizaron ({0} items)\",\n  \"ToastTitleRequired\": \"Título obligatorio\",\n  \"ToastUnknownError\": \"Error desconocido\",\n  \"ToastUnlinkOpenIdFailed\": \"Error al desvincular el usuario de OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Usuario desvinculado de OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"La ruta de archivo «{0}» ya existe en el servidor\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"El elemento «{0}» usa un subdirectorio de la ruta de carga.\",\n  \"ToastUserDeleteFailed\": \"Error al eliminar el usuario\",\n  \"ToastUserDeleteSuccess\": \"Usuario eliminado\",\n  \"ToastUserPasswordChangeSuccess\": \"Contraseña modificada correctamente\",\n  \"ToastUserPasswordMismatch\": \"No coinciden las contraseñas\",\n  \"ToastUserPasswordMustChange\": \"La nueva contraseña no puede ser igual que la anterior\",\n  \"ToastUserRootRequireName\": \"Debe introducir un nombre de usuario administrativo\",\n  \"TooltipAddChapters\": \"Añadir capítulo(s)\",\n  \"TooltipAddOneSecond\": \"Añadir 1 segundo\",\n  \"TooltipAdjustChapterStart\": \"Pulse para ajustar la hora de inicio\",\n  \"TooltipLockAllChapters\": \"Bloquear todos los capítulos\",\n  \"TooltipLockChapter\": \"Bloquear capítulo (Mayús+clic para rango)\",\n  \"TooltipSubtractOneSecond\": \"Restar 1 segundo\",\n  \"TooltipUnlockAllChapters\": \"Desbloquear todos los capítulos\",\n  \"TooltipUnlockChapter\": \"Desbloquear capítulo (Mayús+clic para rango)\"\n}\n"
  },
  {
    "path": "client/strings/et.json",
    "content": "{\n  \"ButtonAdd\": \"Lisa\",\n  \"ButtonAddChapters\": \"Lisa peatükid\",\n  \"ButtonAddDevice\": \"Lisa seade\",\n  \"ButtonAddLibrary\": \"Lisa raamatukogu\",\n  \"ButtonAddPodcasts\": \"Lisa podcastid\",\n  \"ButtonAddUser\": \"Lisa kasutaja\",\n  \"ButtonAddYourFirstLibrary\": \"Lisa oma esimene raamatukogu\",\n  \"ButtonApply\": \"Rakenda\",\n  \"ButtonApplyChapters\": \"Rakenda peatükid\",\n  \"ButtonAuthors\": \"Autorid\",\n  \"ButtonBack\": \"Tagasi\",\n  \"ButtonBrowseForFolder\": \"Sirvi kausta\",\n  \"ButtonCancel\": \"Katkesta\",\n  \"ButtonCancelEncode\": \"Tühista kodeerimine\",\n  \"ButtonChangeRootPassword\": \"Muuda põhiparooli\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Kontrolli ja laadi alla uued episoodid\",\n  \"ButtonChooseAFolder\": \"Vali kaust\",\n  \"ButtonChooseFiles\": \"Vali failid\",\n  \"ButtonClearFilter\": \"Tühista filter\",\n  \"ButtonCloseFeed\": \"Sulge voog\",\n  \"ButtonCloseSession\": \"Sulge avatud sessioon\",\n  \"ButtonCollections\": \"Kollektsioonid\",\n  \"ButtonConfigureScanner\": \"Konfigureeri skanner\",\n  \"ButtonCreate\": \"Loo uus\",\n  \"ButtonCreateBackup\": \"Loo varundus\",\n  \"ButtonDelete\": \"Kustuta\",\n  \"ButtonDownloadQueue\": \"Järjekord\",\n  \"ButtonEdit\": \"Muuda\",\n  \"ButtonEditChapters\": \"Muuda peatükke\",\n  \"ButtonEditPodcast\": \"Muuda podcasti\",\n  \"ButtonEnable\": \"Aktiveeri\",\n  \"ButtonForceReScan\": \"Sunnitud uuestiskaneerimine\",\n  \"ButtonFullPath\": \"Täielik asukoht\",\n  \"ButtonHide\": \"Peida\",\n  \"ButtonHome\": \"Avaleht\",\n  \"ButtonIssues\": \"Probleemid\",\n  \"ButtonJumpBackward\": \"Hüppa tagasi\",\n  \"ButtonJumpForward\": \"Hüppa edasi\",\n  \"ButtonLatest\": \"Viimased\",\n  \"ButtonLibrary\": \"Raamatukogu\",\n  \"ButtonLogout\": \"Logi välja\",\n  \"ButtonLookup\": \"Otsi\",\n  \"ButtonManageTracks\": \"Halda lugusid\",\n  \"ButtonMapChapterTitles\": \"Kaardista peatükkide pealkirjad\",\n  \"ButtonMatchAllAuthors\": \"Sobita kõik autorid\",\n  \"ButtonMatchBooks\": \"Sobita raamatud\",\n  \"ButtonNevermind\": \"Pole tähtis\",\n  \"ButtonNext\": \"Järgmine\",\n  \"ButtonNextChapter\": \"Järgmine peatükk\",\n  \"ButtonNextItemInQueue\": \"Järgmine kirje järjekorras\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Ava voog\",\n  \"ButtonOpenManager\": \"Ava haldur\",\n  \"ButtonPause\": \"Paus\",\n  \"ButtonPlay\": \"Play\",\n  \"ButtonPlayAll\": \"Mängi kõik\",\n  \"ButtonPlaying\": \"Mängib\",\n  \"ButtonPlaylists\": \"Playlist\",\n  \"ButtonPrevious\": \"Eelmine\",\n  \"ButtonPreviousChapter\": \"Eelmine peatükk\",\n  \"ButtonPurgeAllCache\": \"Tühjenda kogu vahemälu\",\n  \"ButtonPurgeItemsCache\": \"Tühjenda esemete vahemälu\",\n  \"ButtonQueueAddItem\": \"Lisa järjekorda\",\n  \"ButtonQueueRemoveItem\": \"Eemalda järjekorrast\",\n  \"ButtonQuickMatch\": \"Kiire sobitamine\",\n  \"ButtonReScan\": \"Uuestiskaneeri\",\n  \"ButtonRead\": \"Loe\",\n  \"ButtonReadLess\": \"Loe vähem\",\n  \"ButtonReadMore\": \"Loe rohkem\",\n  \"ButtonRefresh\": \"Värskenda\",\n  \"ButtonRemove\": \"Kustuta\",\n  \"ButtonRemoveAll\": \"Eemalda kõik\",\n  \"ButtonRemoveAllLibraryItems\": \"Eemalda kõik raamatukogu esemed\",\n  \"ButtonRemoveFromContinueListening\": \"Eemalda jätkake kuulamisest\",\n  \"ButtonRemoveFromContinueReading\": \"Eemalda jätkake lugemisest\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Eemalda seeria jätkamisest\",\n  \"ButtonReset\": \"Lähtesta\",\n  \"ButtonResetToDefault\": \"Lähtesta vaikeseade\",\n  \"ButtonRestore\": \"Taasta\",\n  \"ButtonSave\": \"Salvesta\",\n  \"ButtonSaveAndClose\": \"Salvesta ja sulge\",\n  \"ButtonSaveTracklist\": \"Salvesta lugude loend\",\n  \"ButtonScan\": \"Skanneeri\",\n  \"ButtonScanLibrary\": \"Skanneeri raamatukogu\",\n  \"ButtonSearch\": \"Otsi\",\n  \"ButtonSelectFolderPath\": \"Vali kaustatee\",\n  \"ButtonSeries\": \"Sarjad\",\n  \"ButtonSetChaptersFromTracks\": \"Määra peatükid lugudest\",\n  \"ButtonShiftTimes\": \"Nihke ajad\",\n  \"ButtonShow\": \"Näita\",\n  \"ButtonStartM4BEncode\": \"Alusta M4B kodeerimist\",\n  \"ButtonStartMetadataEmbed\": \"Alusta metaandmete lisamist\",\n  \"ButtonSubmit\": \"Esita\",\n  \"ButtonUpload\": \"Lae üles\",\n  \"ButtonUploadBackup\": \"Lae üles varundus\",\n  \"ButtonUploadCover\": \"Lae üles ümbris\",\n  \"ButtonUploadOPMLFile\": \"Lae üles OPML-fail\",\n  \"ButtonUserDelete\": \"Kustuta kasutaja {0}\",\n  \"ButtonUserEdit\": \"Muuda kasutajat {0}\",\n  \"ButtonViewAll\": \"Vaata kõiki\",\n  \"ButtonYes\": \"Jah\",\n  \"ErrorUploadFetchMetadataAPI\": \"Viga metaandmete hankimisel\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Ei saanud metaandmeid hankida - proovi tiitlit ja/või autorit uuendada\",\n  \"ErrorUploadLacksTitle\": \"Peab olema pealkiri\",\n  \"HeaderAccount\": \"Konto\",\n  \"HeaderAdvanced\": \"Täpsem\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise teavitamise seaded\",\n  \"HeaderAudioTracks\": \"Helirajad\",\n  \"HeaderAudiobookTools\": \"Heliraamatu failihaldustööriistad\",\n  \"HeaderAuthentication\": \"Autentimine\",\n  \"HeaderBackups\": \"Varukoopiad\",\n  \"HeaderChangePassword\": \"Muuda parooli\",\n  \"HeaderChapters\": \"Peatükid\",\n  \"HeaderChooseAFolder\": \"Vali kaust\",\n  \"HeaderCollection\": \"Kogu\",\n  \"HeaderCollectionItems\": \"Kogu esemed\",\n  \"HeaderCover\": \"Ümbris\",\n  \"HeaderCurrentDownloads\": \"Praegused allalaadimised\",\n  \"HeaderCustomMetadataProviders\": \"Kohandatud metaandmete pakkujad\",\n  \"HeaderDetails\": \"Detailid\",\n  \"HeaderDownloadQueue\": \"Allalaadimise järjekord\",\n  \"HeaderEbookFiles\": \"E-raamatu failid\",\n  \"HeaderEmail\": \"E-post\",\n  \"HeaderEmailSettings\": \"E-posti seaded\",\n  \"HeaderEpisodes\": \"Episoodid\",\n  \"HeaderEreaderDevices\": \"E-lugerite seadmed\",\n  \"HeaderEreaderSettings\": \"E-lugeja sätted\",\n  \"HeaderFiles\": \"Failid\",\n  \"HeaderFindChapters\": \"Leia peatükid\",\n  \"HeaderIgnoredFiles\": \"Ignoreeritud failid\",\n  \"HeaderItemFiles\": \"Esemete failid\",\n  \"HeaderItemMetadataUtils\": \"Eseme metaandmete tööriistad\",\n  \"HeaderLastListeningSession\": \"Viimane kuulamissessioon\",\n  \"HeaderLatestEpisodes\": \"Viimased episoodid\",\n  \"HeaderLibraries\": \"Raamatukogud\",\n  \"HeaderLibraryFiles\": \"Raamatukogu failid\",\n  \"HeaderLibraryStats\": \"Raamatukogu statistika\",\n  \"HeaderListeningSessions\": \"Kuulamissessioonid\",\n  \"HeaderListeningStats\": \"Kuulamise statistika\",\n  \"HeaderLogin\": \"Logi sisse\",\n  \"HeaderLogs\": \"Logid\",\n  \"HeaderManageGenres\": \"Halda žanre\",\n  \"HeaderManageTags\": \"Halda silte\",\n  \"HeaderMapDetails\": \"Kaardi detailid\",\n  \"HeaderMatch\": \"Sobita\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metaandmete eelnevusjärjestus\",\n  \"HeaderMetadataToEmbed\": \"Manusta metaandmed\",\n  \"HeaderNewAccount\": \"Uus konto\",\n  \"HeaderNewLibrary\": \"Uus raamatukogu\",\n  \"HeaderNotifications\": \"Teatised\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect autentimine\",\n  \"HeaderOpenRSSFeed\": \"Ava RSS-voog\",\n  \"HeaderOtherFiles\": \"Muud failid\",\n  \"HeaderPasswordAuthentication\": \"Parooli autentimine\",\n  \"HeaderPermissions\": \"Õigused\",\n  \"HeaderPlayerQueue\": \"Mängija järjekord\",\n  \"HeaderPlaylist\": \"Playlist\",\n  \"HeaderPlaylistItems\": \"Playlisti esemed\",\n  \"HeaderPodcastsToAdd\": \"Lisatavad podcastid\",\n  \"HeaderPreviewCover\": \"Eelvaate kaas\",\n  \"HeaderRSSFeedGeneral\": \"RSS-i üksikasjad\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-voog on avatud\",\n  \"HeaderRSSFeeds\": \"RSS-vooged\",\n  \"HeaderRemoveEpisode\": \"Eemalda episood\",\n  \"HeaderRemoveEpisodes\": \"Eemalda {0} episoodi\",\n  \"HeaderSavedMediaProgress\": \"Salvestatud meedia edenemine\",\n  \"HeaderSchedule\": \"Ajakava\",\n  \"HeaderScheduleLibraryScans\": \"Ajasta automaatsed raamatukogu skaneerimised\",\n  \"HeaderSession\": \"Sessioon\",\n  \"HeaderSetBackupSchedule\": \"Määra varunduse ajakava\",\n  \"HeaderSettings\": \"Seaded\",\n  \"HeaderSettingsDisplay\": \"Kuva\",\n  \"HeaderSettingsExperimental\": \"Katsetusfunktsioonid\",\n  \"HeaderSettingsGeneral\": \"Üldised\",\n  \"HeaderSettingsScanner\": \"Skanner\",\n  \"HeaderSleepTimer\": \"Unetaimer\",\n  \"HeaderStatsLargestItems\": \"Suurimad esemed\",\n  \"HeaderStatsLongestItems\": \"Kõige pikemad esemed (tunnid)\",\n  \"HeaderStatsMinutesListeningChart\": \"Kuulamise minutid (viimased 7 päeva)\",\n  \"HeaderStatsRecentSessions\": \"Hiljutised sessioonid\",\n  \"HeaderStatsTop10Authors\": \"Top 10 autorit\",\n  \"HeaderStatsTop5Genres\": \"Top 5 žanrit\",\n  \"HeaderTableOfContents\": \"Sisukord\",\n  \"HeaderTools\": \"Tööriistad\",\n  \"HeaderUpdateAccount\": \"Uuenda kontot\",\n  \"HeaderUpdateAuthor\": \"Uuenda autorit\",\n  \"HeaderUpdateDetails\": \"Uuenda detaile\",\n  \"HeaderUpdateLibrary\": \"Uuenda raamatukogu\",\n  \"HeaderUsers\": \"Kasutajad\",\n  \"HeaderYourStats\": \"Sinu statistika\",\n  \"LabelAbridged\": \"Kärbitud\",\n  \"LabelAccountType\": \"Konto tüüp\",\n  \"LabelAccountTypeAdmin\": \"Administraator\",\n  \"LabelAccountTypeGuest\": \"Külaline\",\n  \"LabelAccountTypeUser\": \"Kasutaja\",\n  \"LabelActivity\": \"Tegevus\",\n  \"LabelAddToCollection\": \"Lisa kogusse\",\n  \"LabelAddToCollectionBatch\": \"Lisa {0} raamatut kogusse\",\n  \"LabelAddToPlaylist\": \"Lisa playlisti\",\n  \"LabelAddToPlaylistBatch\": \"Lisa {0} eset mänguloendisse\",\n  \"LabelAddedAt\": \"Lisatud\",\n  \"LabelAddedDate\": \"Lisatud {0}\",\n  \"LabelAdminUsersOnly\": \"Ainult administraatorid\",\n  \"LabelAll\": \"Kõik\",\n  \"LabelAllUsers\": \"Kõik kasutajad\",\n  \"LabelAllUsersExcludingGuests\": \"Kõik kasutajad, välja arvatud külalised\",\n  \"LabelAllUsersIncludingGuests\": \"Kõik kasutajad, kaasa arvatud külalised\",\n  \"LabelAlreadyInYourLibrary\": \"Juba teie raamatukogus\",\n  \"LabelAppend\": \"Lisa\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (eesnimi perekonnanimi)\",\n  \"LabelAuthorLastFirst\": \"Autor (perekonnanimi, eesnimi)\",\n  \"LabelAuthors\": \"Autorid\",\n  \"LabelAutoDownloadEpisodes\": \"Episoodide automaatne allalaadimine\",\n  \"LabelAutoFetchMetadata\": \"Automaatne metaandmete hankimine\",\n  \"LabelAutoFetchMetadataHelp\": \"Toob tiitli, autori ja seeria metaandmed üleslaadimise hõlbustamiseks. Lisametaandmed võivad pärast üleslaadimist vajada vastavust.\",\n  \"LabelAutoLaunch\": \"Automaatne käivitamine\",\n  \"LabelAutoLaunchDescription\": \"Suunab automaatselt autentimist pakkuvale teenusele, kui navigeeritakse sisselogimislehele (käsitsi ülekirjutuse tee <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automaatne registreerimine\",\n  \"LabelAutoRegisterDescription\": \"Loo uued kasutajad automaatselt sisselogimisel\",\n  \"LabelBackToUser\": \"Tagasi kasutajale\",\n  \"LabelBackupLocation\": \"Varukoopia asukoht\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Luba automaatsed varukoopiad\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Varukoopiad salvestatakse /metadata/backups kausta\",\n  \"LabelBackupsMaxBackupSize\": \"Maksimaalne varukoopia suurus (GB-des) (0 lõpmatu suuruse jaoks)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Kaitsena valesti seadistamise vastu ebaõnnestuvad varukoopiad, kui need ületavad seadistatud suuruse.\",\n  \"LabelBackupsNumberToKeep\": \"Varukoopiate arv, mida hoida\",\n  \"LabelBackupsNumberToKeepHelp\": \"Ühel ajal eemaldatakse ainult 1 varukoopia, seega kui teil on juba rohkem varukoopiaid kui siin määratud, peaksite need käsitsi eemaldama.\",\n  \"LabelBitrate\": \"Bittkiirus\",\n  \"LabelBooks\": \"Raamatud\",\n  \"LabelButtonText\": \"Nupu tekst\",\n  \"LabelChangePassword\": \"Muuda parooli\",\n  \"LabelChannels\": \"Kanalid\",\n  \"LabelChapterTitle\": \"Peatüki pealkiri\",\n  \"LabelChapters\": \"Peatükid\",\n  \"LabelChaptersFound\": \"peatükid leitud\",\n  \"LabelClickForMoreInfo\": \"Klõpsa lisateabe saamiseks\",\n  \"LabelClosePlayer\": \"Sulge mängija\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Ahenda seeria\",\n  \"LabelCollection\": \"Kogu\",\n  \"LabelCollections\": \"Kogud\",\n  \"LabelComplete\": \"Valmis\",\n  \"LabelConfirmPassword\": \"Kinnita parool\",\n  \"LabelContinueListening\": \"Jätka kuulamist\",\n  \"LabelContinueReading\": \"Jätka lugemist\",\n  \"LabelContinueSeries\": \"Jätka seeriat\",\n  \"LabelCover\": \"Ümbris\",\n  \"LabelCoverImageURL\": \"Ümbrise pildi URL\",\n  \"LabelCreatedAt\": \"Loodud\",\n  \"LabelCronExpression\": \"Croni valem\",\n  \"LabelCurrent\": \"Praegune\",\n  \"LabelCurrently\": \"Praegu:\",\n  \"LabelCustomCronExpression\": \"Kohandatud Croni valem:\",\n  \"LabelDatetime\": \"Kuupäev ja kellaaeg\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Kustuta failisüsteemist (ärge märkige seda ära, et eemaldada ainult andmebaasist)\",\n  \"LabelDescription\": \"Kirjeldus\",\n  \"LabelDeselectAll\": \"Tühista kõigi valimine\",\n  \"LabelDevice\": \"Seade\",\n  \"LabelDeviceInfo\": \"Seadme info\",\n  \"LabelDeviceIsAvailableTo\": \"Seade on saadaval kasutajale...\",\n  \"LabelDirectory\": \"Kataloog\",\n  \"LabelDiscFromFilename\": \"Ketas failinimest\",\n  \"LabelDiscFromMetadata\": \"Ketas metaandmetest\",\n  \"LabelDiscover\": \"Avasta\",\n  \"LabelDownload\": \"Lae alla\",\n  \"LabelDownloadNEpisodes\": \"Lae alla {0} episoodi\",\n  \"LabelDuration\": \"Kestvus\",\n  \"LabelDurationFound\": \"Leitud kestus:\",\n  \"LabelEbook\": \"E-raamat\",\n  \"LabelEbooks\": \"E-raamatud\",\n  \"LabelEdit\": \"Muuda\",\n  \"LabelEmail\": \"E-post\",\n  \"LabelEmailSettingsFromAddress\": \"Saatja aadress\",\n  \"LabelEmailSettingsSecure\": \"Turvaline\",\n  \"LabelEmailSettingsSecureHelp\": \"Kui see on tõene, kasutab ühendus serveriga ühenduse loomisel TLS-i. Kui see on väär, kasutatakse TLS-i, kui server toetab STARTTLS-i laiendust. Enamikul juhtudest seadke see väärtus tõeks, kui ühendate pordile 465. Pordi 587 või 25 korral hoidke seda väär. (nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Testi aadress\",\n  \"LabelEmbeddedCover\": \"Manustatud kaas\",\n  \"LabelEnable\": \"Luba\",\n  \"LabelEnd\": \"Lõpp\",\n  \"LabelEndOfChapter\": \"Peatükki lõpp\",\n  \"LabelEpisode\": \"Episood\",\n  \"LabelEpisodeTitle\": \"Episoodi pealkiri\",\n  \"LabelEpisodeType\": \"Episoodi tüüp\",\n  \"LabelExample\": \"Näide\",\n  \"LabelExplicit\": \"Vulgaarne\",\n  \"LabelFeedURL\": \"Voogu URL\",\n  \"LabelFetchingMetadata\": \"Metaandmete hankimine\",\n  \"LabelFile\": \"Fail\",\n  \"LabelFileBirthtime\": \"Faili sünniaeg\",\n  \"LabelFileModified\": \"Faili muudetud\",\n  \"LabelFilename\": \"Faili nimi\",\n  \"LabelFilterByUser\": \"Filtri alusel kasutaja järgi\",\n  \"LabelFindEpisodes\": \"Otsi episoodid\",\n  \"LabelFinished\": \"Lõpetatud\",\n  \"LabelFolder\": \"Kaust\",\n  \"LabelFolders\": \"Kataloogid\",\n  \"LabelFontBold\": \"Paks\",\n  \"LabelFontBoldness\": \"Fondi paksus\",\n  \"LabelFontFamily\": \"Fondi pere\",\n  \"LabelFontItalic\": \"Kaldkiri\",\n  \"LabelFontScale\": \"Fondi suurus\",\n  \"LabelFontStrikethrough\": \"Üle joonitud\",\n  \"LabelFormat\": \"Vorming\",\n  \"LabelGenre\": \"Žanr\",\n  \"LabelGenres\": \"Žanrid\",\n  \"LabelHardDeleteFile\": \"Faili lõplik kustutamine\",\n  \"LabelHasEbook\": \"E-raamat olemas\",\n  \"LabelHasSupplementaryEbook\": \"On täiendav e-raamat\",\n  \"LabelHighestPriority\": \"Kõrgeim prioriteet\",\n  \"LabelHour\": \"Tund\",\n  \"LabelIcon\": \"Ikoon\",\n  \"LabelImageURLFromTheWeb\": \"Pildi URL veebist\",\n  \"LabelInProgress\": \"Pooleli\",\n  \"LabelIncludeInTracklist\": \"Kaasa jälgimisloendis\",\n  \"LabelIncomplete\": \"Lõpetamata\",\n  \"LabelInterval\": \"Intervall\",\n  \"LabelIntervalCustomDailyWeekly\": \"Kohandatud päevane/nädalane\",\n  \"LabelIntervalEvery12Hours\": \"Iga 12 tunni tagant\",\n  \"LabelIntervalEvery15Minutes\": \"Iga 15 minuti tagant\",\n  \"LabelIntervalEvery2Hours\": \"Iga 2 tunni tagant\",\n  \"LabelIntervalEvery30Minutes\": \"Iga 30 minuti tagant\",\n  \"LabelIntervalEvery6Hours\": \"Iga 6 tunni tagant\",\n  \"LabelIntervalEveryDay\": \"Iga päev\",\n  \"LabelIntervalEveryHour\": \"Iga tunni tagant\",\n  \"LabelInvert\": \"Pööra ümber\",\n  \"LabelItem\": \"Kirje\",\n  \"LabelLanguage\": \"Keel\",\n  \"LabelLanguageDefaultServer\": \"Vaikeserveri keel\",\n  \"LabelLastBookAdded\": \"Viimati lisatud raamat\",\n  \"LabelLastBookUpdated\": \"Viimati uuendatud raamat\",\n  \"LabelLastSeen\": \"Viimati nähtud\",\n  \"LabelLastTime\": \"Viimati aeg\",\n  \"LabelLastUpdate\": \"Viimane uuendus\",\n  \"LabelLayout\": \"Paigutus\",\n  \"LabelLayoutSinglePage\": \"Üks lehekülg\",\n  \"LabelLayoutSplitPage\": \"Jagatud lehekülg\",\n  \"LabelLess\": \"Vähem\",\n  \"LabelLibrariesAccessibleToUser\": \"Kasutajale ligipääsetavad raamatukogud\",\n  \"LabelLibrary\": \"Raamatukogu\",\n  \"LabelLibraryItem\": \"Raamatukogu kirje\",\n  \"LabelLibraryName\": \"Raamatukogu nimi\",\n  \"LabelLimit\": \"Piirang\",\n  \"LabelLineSpacing\": \"Joonevahe\",\n  \"LabelListenAgain\": \"Kuula uuesti\",\n  \"LabelLogLevelDebug\": \"Silumine\",\n  \"LabelLogLevelInfo\": \"Teave\",\n  \"LabelLogLevelWarn\": \"Hoiatus\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Otsi uusi episoodid pärast seda kuupäeva\",\n  \"LabelLowestPriority\": \"Madalaim prioriteet\",\n  \"LabelMatchExistingUsersBy\": \"Sobita olemasolevad kasutajad\",\n  \"LabelMatchExistingUsersByDescription\": \"Kasutatakse olemasolevate kasutajate ühendamiseks. Ühendatud kasutajaid sobitatakse teie SSO pakkuja unikaalse ID järgi.\",\n  \"LabelMediaPlayer\": \"Meediapleier\",\n  \"LabelMediaType\": \"Meedia tüüp\",\n  \"LabelMetaTag\": \"Meta märge\",\n  \"LabelMetaTags\": \"Meta märgendid\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Kõrgema prioriteediga metaandmete allikad võtavad üle madalama prioriteediga metaandmete allikad\",\n  \"LabelMetadataProvider\": \"Metaandmete pakkuja\",\n  \"LabelMinute\": \"Minut\",\n  \"LabelMissing\": \"Puudub\",\n  \"LabelMobileRedirectURIs\": \"Lubatud mobiilile suunamise URI-d\",\n  \"LabelMobileRedirectURIsDescription\": \"See on mobiilirakenduste jaoks kehtivate suunamise URI-de lubatud nimekiri. Vaikimisi on selleks <code>audiobookshelf://oauth</code>, mida saate eemaldada või täiendada täiendavate URI-dega kolmanda osapoole rakenduste integreerimiseks. Tärni (<code>*</code>) ainukese kirjena kasutamine võimaldab mis tahes URI-d.\",\n  \"LabelMore\": \"Rohkem\",\n  \"LabelMoreInfo\": \"Rohkem infot\",\n  \"LabelName\": \"Nimi\",\n  \"LabelNarrator\": \"Jutustaja\",\n  \"LabelNarrators\": \"Jutustajad\",\n  \"LabelNew\": \"Uus\",\n  \"LabelNewPassword\": \"Uus parool\",\n  \"LabelNewestAuthors\": \"Uuemad autorid\",\n  \"LabelNewestEpisodes\": \"Uuemad episoodid\",\n  \"LabelNextBackupDate\": \"Järgmine varukoopia kuupäev\",\n  \"LabelNextScheduledRun\": \"Järgmine ajakava järgmine\",\n  \"LabelNoEpisodesSelected\": \"Episoodid pole valitud\",\n  \"LabelNotFinished\": \"Lõpetamata\",\n  \"LabelNotStarted\": \"Pole alustatud\",\n  \"LabelNotes\": \"Märkused\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL-id\",\n  \"LabelNotificationAvailableVariables\": \"Saadaolevad muutujad\",\n  \"LabelNotificationBodyTemplate\": \"Keha mall\",\n  \"LabelNotificationEvent\": \"Teavituse sündmus\",\n  \"LabelNotificationTitleTemplate\": \"Pealkirja mall\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maksimaalsed ebaõnnestunud katsed\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Teatised keelatakse, kui need ebaõnnestuvad nii palju kordi\",\n  \"LabelNotificationsMaxQueueSize\": \"Teavituste sündmuste maksimaalne järjekorra suurus\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Sündmused on piiratud 1 sekundiga. Sündmusi ignoreeritakse, kui järjekord on maksimumsuuruses. See takistab teavituste rämpsposti.\",\n  \"LabelNumberOfBooks\": \"Raamatute arv\",\n  \"LabelNumberOfEpisodes\": \"# episoode\",\n  \"LabelOpenRSSFeed\": \"Ava RSS voog\",\n  \"LabelOverwrite\": \"Kirjuta üle\",\n  \"LabelPassword\": \"Parool\",\n  \"LabelPath\": \"Asukoht\",\n  \"LabelPermissionsAccessAllLibraries\": \"Saab ligi kõikidele raamatukogudele\",\n  \"LabelPermissionsAccessAllTags\": \"Saab ligi kõikidele siltidele\",\n  \"LabelPermissionsAccessExplicitContent\": \"Saab ligi vulgaarsele sisule\",\n  \"LabelPermissionsDelete\": \"Saab kustutada\",\n  \"LabelPermissionsDownload\": \"Saab alla laadida\",\n  \"LabelPermissionsUpdate\": \"Saab uuendada\",\n  \"LabelPermissionsUpload\": \"Saab üles laadida\",\n  \"LabelPhotoPathURL\": \"Foto tee/URL\",\n  \"LabelPlayMethod\": \"Esitusmeetod\",\n  \"LabelPlaylists\": \"Mänguloendid\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcasti otsingu piirkond\",\n  \"LabelPodcastType\": \"Podcasti tüüp\",\n  \"LabelPodcasts\": \"Podcastid\",\n  \"LabelPrefixesToIgnore\": \"Eiramiseks eesliited (tõstutundetu)\",\n  \"LabelPreventIndexing\": \"Vältige oma voogu indekseerimist iTunes'i ja Google podcasti kataloogides\",\n  \"LabelPrimaryEbook\": \"Esmane e-raamat\",\n  \"LabelProgress\": \"Progress\",\n  \"LabelProvider\": \"Pakkuja\",\n  \"LabelPubDate\": \"Publitseerimise kuupäev\",\n  \"LabelPublishYear\": \"Publitseerimise aasta\",\n  \"LabelPublishedDate\": \"Publitseeritud {0}\",\n  \"LabelPublisher\": \"Kirjastaja\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Kohandatud omaniku e-post\",\n  \"LabelRSSFeedCustomOwnerName\": \"Kohandatud omaniku nimi\",\n  \"LabelRSSFeedOpen\": \"Ava RSS voog\",\n  \"LabelRSSFeedPreventIndexing\": \"Vältige indekseerimist\",\n  \"LabelRSSFeedSlug\": \"RSS voog Slug\",\n  \"LabelRSSFeedURL\": \"RSS voog URL\",\n  \"LabelRandomly\": \"Juhuslikus järjekorras\",\n  \"LabelRead\": \"Loetud läbi\",\n  \"LabelReadAgain\": \"Loe uuesti\",\n  \"LabelReadEbookWithoutProgress\": \"Lugege e-raamatut ilma edenemist säilitamata\",\n  \"LabelRecentSeries\": \"Hiljutised seeriad\",\n  \"LabelRecentlyAdded\": \"Hiljuti lisatud\",\n  \"LabelRecommended\": \"Soovitatud\",\n  \"LabelRedo\": \"Tee uuesti\",\n  \"LabelRegion\": \"Piirkond\",\n  \"LabelReleaseDate\": \"Väljalaske kuupäev\",\n  \"LabelRemoveCover\": \"Eemalda ümbris\",\n  \"LabelRowsPerPage\": \"Rida lehe kohta\",\n  \"LabelSearchTerm\": \"Otsingutermin\",\n  \"LabelSearchTitle\": \"Otsi pealkirja\",\n  \"LabelSearchTitleOrASIN\": \"Otsi pealkirja või ASIN-i\",\n  \"LabelSeason\": \"Hooaeg\",\n  \"LabelSelectAllEpisodes\": \"Vali kõik episoodid\",\n  \"LabelSelectEpisodesShowing\": \"Valige {0} näidatavat episoodi\",\n  \"LabelSelectUsers\": \"Valige kasutajad\",\n  \"LabelSendEbookToDevice\": \"Saada e-raamat seadmele...\",\n  \"LabelSequence\": \"Järjestus\",\n  \"LabelSeries\": \"Seeria\",\n  \"LabelSeriesName\": \"Seeria nimi\",\n  \"LabelSeriesProgress\": \"Seeria edenemine\",\n  \"LabelSetEbookAsPrimary\": \"Määra peamiseks\",\n  \"LabelSetEbookAsSupplementary\": \"Määra täiendavaks\",\n  \"LabelSettingsAudiobooksOnly\": \"Ainult heliraamatud\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Selle seadistuse lubamine eirab e-raamatute faile, välja arvatud juhul, kui need on heliraamatu kaustas, kus need seatakse täiendavate e-raamatutena\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorfne kujundus puidust riiulitega\",\n  \"LabelSettingsChromecastSupport\": \"Chromecasti tugi\",\n  \"LabelSettingsDateFormat\": \"Kuupäeva vorming\",\n  \"LabelSettingsEnableWatcherHelp\": \"Lubab automaatset lisamist/uuendamist, kui tuvastatakse failimuudatused. *Nõuab serveri taaskäivitamist\",\n  \"LabelSettingsExperimentalFeatures\": \"Eksperimentaalsed funktsioonid\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Arengus olevad funktsioonid, mis vajavad teie tagasisidet ja abi testimisel. Klõpsake GitHubi arutelu avamiseks.\",\n  \"LabelSettingsFindCovers\": \"Leia ümbrised\",\n  \"LabelSettingsFindCoversHelp\": \"Kui teie heliraamatul pole sisseehitatud ümbrist ega ümbrise pilti kaustas, proovib skanner leida ümbrist.<br>Märkus: see pikendab skaneerimisaega\",\n  \"LabelSettingsHideSingleBookSeries\": \"Peida üksikute raamatute seeriad\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Ühe raamatuga seeriaid peidetakse seeria lehelt ja avalehe riiulitelt.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Avaleht kasutage raamatukoguvaadet\",\n  \"LabelSettingsLibraryBookshelfView\": \"Raamatukogu kasutamiseks kasutage raamatukoguvaadet\",\n  \"LabelSettingsParseSubtitles\": \"Lugege subtiitreid\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Eraldage subtiitrid heliraamatu kaustade nimedest.<br>Subtiitrid peavad olema eraldatud  kasutades \\\" - \\\".<br>Näiteks: \\\"Raamatu pealkiri - Siin on alapealkiri\\\" alapealkiri on \\\"Siin on alapealkiri\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Eelista sobitatud metaandmeid\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Sobitatud andmed kirjutavad Kiir Sobitamise kasutamisel üle üksikasjad.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Jätke ASIN-iga sobituvad raamatud vahele\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Jätke ISBN-iga sobituvad raamatud vahele\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignoreeri eesliiteid sortimisel\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"nt. eesliidet \\\"the\\\" kasutades raamatu pealkiri \\\"The Book Title\\\" sorteeritakse \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Kasutage ruudukujulisi raamatu kaasi\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Eelistage ruudukujulisi kaasi tavaliste 1.6:1 raamatu ümbrise asemel\",\n  \"LabelSettingsStoreCoversWithItem\": \"Salvesta kaaned üksusega\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Vaikimisi salvestatakse kaaned /metadata/items kausta. Selle seadistuse lubamine salvestab kaaned teie raamatukogu üksuse kausta. Hoitakse ainult ühte faili nimega \\\"kaas\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Salvesta metaandmed üksusega\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Vaikimisi salvestatakse metaandmed /metadata/items kausta. Selle seadistuse lubamine salvestab metaandmed teie raamatukogu üksuse kaustadesse\",\n  \"LabelSettingsTimeFormat\": \"Kellaaja vorming\",\n  \"LabelShowAll\": \"Näita kõik\",\n  \"LabelSize\": \"Suurus\",\n  \"LabelSleepTimer\": \"Unetaimer\",\n  \"LabelStart\": \"Alusta\",\n  \"LabelStartTime\": \"Alustamise aeg\",\n  \"LabelStarted\": \"Alustatud\",\n  \"LabelStartedAt\": \"Alustatud\",\n  \"LabelStatsAudioTracks\": \"Audiojäljed\",\n  \"LabelStatsAuthors\": \"Autorid\",\n  \"LabelStatsBestDay\": \"Parim päev\",\n  \"LabelStatsDailyAverage\": \"Päevane keskmine\",\n  \"LabelStatsDays\": \"Päevi\",\n  \"LabelStatsDaysListened\": \"Kuulatud päevad\",\n  \"LabelStatsHours\": \"Tunnid\",\n  \"LabelStatsInARow\": \"järjest\",\n  \"LabelStatsItemsFinished\": \"Lõpetatud raamatud\",\n  \"LabelStatsItemsInLibrary\": \"Üksused raamatukogus\",\n  \"LabelStatsMinutes\": \"minuteid\",\n  \"LabelStatsMinutesListening\": \"Kuulamise minutid\",\n  \"LabelStatsOverallDays\": \"Kokku päevad\",\n  \"LabelStatsOverallHours\": \"Kokku tunnid\",\n  \"LabelStatsWeekListening\": \"Nädala keskmine\",\n  \"LabelSubtitle\": \"Alapealkiri\",\n  \"LabelSupportedFileTypes\": \"Toetatud failitüübid\",\n  \"LabelTag\": \"Silt\",\n  \"LabelTags\": \"Sildid\",\n  \"LabelTagsAccessibleToUser\": \"Kasutajale kättesaadavad sildid\",\n  \"LabelTagsNotAccessibleToUser\": \"Kasutajale mittekättesaadavad sildid\",\n  \"LabelTasks\": \"Käimasolevad ülesanded\",\n  \"LabelTextEditorBulletedList\": \"Punktloend\",\n  \"LabelTextEditorNumberedList\": \"Numberloend\",\n  \"LabelTextEditorUnlink\": \"Eemalda link\",\n  \"LabelTheme\": \"Teema\",\n  \"LabelThemeDark\": \"Pime\",\n  \"LabelThemeLight\": \"Hele\",\n  \"LabelTimeBase\": \"Aja alus\",\n  \"LabelTimeListened\": \"Kuulatud aeg\",\n  \"LabelTimeListenedToday\": \"Täna kuulatud aeg\",\n  \"LabelTimeRemaining\": \"{0} jäänud\",\n  \"LabelTimeToShift\": \"Nihutamiseks sekundites kuluv aeg\",\n  \"LabelTitle\": \"Pealkiri\",\n  \"LabelToolsEmbedMetadata\": \"Manusta metaandmed\",\n  \"LabelToolsEmbedMetadataDescription\": \"Manusta metaandmed helifailidesse, sealhulgas kaanepilt ja peatükid.\",\n  \"LabelToolsMakeM4b\": \"Loo M4B heliraamatu fail\",\n  \"LabelToolsMakeM4bDescription\": \"Loo .M4B heliraamatu fail, kuhu on manustatud metaandmed, kaanepilt ja peatükid.\",\n  \"LabelToolsSplitM4b\": \"Jaga M4B MP3-deks\",\n  \"LabelToolsSplitM4bDescription\": \"Loo MP3-d M4B-st peatükkide kaupa, kus on manustatud metaandmed, kaanepilt ja peatükid.\",\n  \"LabelTotalDuration\": \"Kogukestus\",\n  \"LabelTotalTimeListened\": \"Kogu kuulatud aeg\",\n  \"LabelTrackFromFilename\": \"Jälg nimest\",\n  \"LabelTrackFromMetadata\": \"Jälg metaandmetest\",\n  \"LabelTracks\": \"Jäljed\",\n  \"LabelTracksMultiTrack\": \"Mitmejälg\",\n  \"LabelTracksNone\": \"Ühtegi jälgimist\",\n  \"LabelTracksSingleTrack\": \"Üksikjälg\",\n  \"LabelType\": \"Tüüp\",\n  \"LabelUnabridged\": \"Täismahus\",\n  \"LabelUndo\": \"Võta tagasi\",\n  \"LabelUnknown\": \"Teadmata\",\n  \"LabelUpdateCover\": \"Uuenda kaant\",\n  \"LabelUpdateCoverHelp\": \"Luba üle kirjutamine olemasolevate kaante jaoks valitud raamatutele, kui leitakse sobivus\",\n  \"LabelUpdateDetails\": \"Uuenda üksikasju\",\n  \"LabelUpdateDetailsHelp\": \"Luba üle kirjutamine olemasolevate üksikasjade jaoks valitud raamatutele, kui leitakse sobivus\",\n  \"LabelUpdatedAt\": \"Uuendatud\",\n  \"LabelUploaderDragAndDrop\": \"Lohista ja aseta faile või kaustu\",\n  \"LabelUploaderDropFiles\": \"Aseta failid\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Hangi automaatselt pealkiri, autor ja seeria\",\n  \"LabelUseChapterTrack\": \"Kasuta peatüki jälge\",\n  \"LabelUseFullTrack\": \"Kasuta täielikku jälge\",\n  \"LabelUser\": \"Kasutaja\",\n  \"LabelUsername\": \"Kasutajanimi\",\n  \"LabelValue\": \"Väärtus\",\n  \"LabelVersion\": \"Versioon\",\n  \"LabelViewBookmarks\": \"Vaata järjehoidjaid\",\n  \"LabelViewChapters\": \"Vaata peatükke\",\n  \"LabelViewQueue\": \"Vaata esitusjärjekorda\",\n  \"LabelVolume\": \"Heli tugevus\",\n  \"LabelWeekdaysToRun\": \"Päevad nädalas käivitamiseks\",\n  \"LabelYourAudiobookDuration\": \"Teie heliraamatu kestus\",\n  \"LabelYourBookmarks\": \"Teie järjehoidjad\",\n  \"LabelYourPlaylists\": \"Teie esitusloendid\",\n  \"LabelYourProgress\": \"Teie edenemine\",\n  \"MessageAddToPlayerQueue\": \"Lisa esitusjärjekorda\",\n  \"MessageAppriseDescription\": \"Selle funktsiooni kasutamiseks peate käivitama <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> eksemplari või API, mis töötleb samu päringuid. <br />Apprise API URL peaks olema täielik URL-rada teatise saatmiseks, näiteks kui teie API eksemplar töötab aadressil <code>http://192.168.1.1:8337</code>, siis peaksite sisestama <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageBackupsDescription\": \"Varukoopiad hõlmavad kasutajaid, kasutajate edenemist, raamatukogu üksikasju, serveri seadeid ja kaustades <code>/metadata/items</code> ja <code>/metadata/authors</code> salvestatud pilte. Varukoopiad ei hõlma ühtegi teie raamatukogu kaustades olevat faili.\",\n  \"MessageBatchQuickMatchDescription\": \"Kiire sobitamine üritab lisada valitud üksustele puuduvad kaaned ja metaandmed. Luba allpool olevad valikud, et lubada Kiire sobitamine'il üle kirjutada olemasolevaid kaasi ja/või metaandmeid.\",\n  \"MessageBookshelfNoCollections\": \"Te pole veel ühtegi kollektsiooni teinud\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Kollektsioonid on avalikud. Kõik kasutajad kellel on olemas ligipääs raamatukogule saavad neid näha.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Ühtegi RSS-i voogu pole avatud\",\n  \"MessageBookshelfNoResultsForFilter\": \"Filtrile \\\"{0}: {1}\\\" pole tulemusi\",\n  \"MessageBookshelfNoSeries\": \"Teil pole ühtegi seeriat\",\n  \"MessageChapterEndIsAfter\": \"Peatüki lõpp on pärast teie heliraamatu lõppu\",\n  \"MessageChapterErrorFirstNotZero\": \"Esimene peatükk peab algama 0-st\",\n  \"MessageChapterErrorStartGteDuration\": \"Vigane algusaeg peab olema väiksem kui heliraamatu kestus\",\n  \"MessageChapterErrorStartLtPrev\": \"Vigane algusaeg peab olema suurem või võrdne eelneva peatüki algusajaga\",\n  \"MessageChapterStartIsAfter\": \"Peatüki algus on pärast teie heliraamatu lõppu\",\n  \"MessageCheckingCron\": \"Croni kontrollimine...\",\n  \"MessageConfirmCloseFeed\": \"Olete kindel, et soovite selle voo sulgeda?\",\n  \"MessageConfirmDeleteBackup\": \"Olete kindel, et soovite varukoopia kustutada {0} kohta?\",\n  \"MessageConfirmDeleteFile\": \"See kustutab faili teie failisüsteemist. Olete kindel?\",\n  \"MessageConfirmDeleteLibrary\": \"Olete kindel, et soovite raamatukogu \\\"{0}\\\" lõplikult kustutada?\",\n  \"MessageConfirmDeleteLibraryItem\": \"See kustutab raamatukogu üksuse andmebaasist ja failisüsteemist. Olete kindel?\",\n  \"MessageConfirmDeleteLibraryItems\": \"See kustutab {0} raamatukogu üksust andmebaasist ja failisüsteemist. Olete kindel?\",\n  \"MessageConfirmDeleteSession\": \"Olete kindel, et soovite selle seansi kustutada?\",\n  \"MessageConfirmForceReScan\": \"Olete kindel, et soovite jõuga uuesti skannida?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Olete kindel, et soovite kõik episoodid lõpetatuks märkida?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Olete kindel, et soovite kõik episoodid mitte lõpetatuks märkida?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Olete kindel, et soovite selle seeria kõik raamatud lõpetatuks märkida?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Olete kindel, et soovite selle seeria kõik raamatud mitte lõpetatuks märkida?\",\n  \"MessageConfirmQuickEmbed\": \"Hoiatus! Quick Embed ei tee varukoopiaid teie helifailidest. Veenduge, et teil oleks varukoopia oma helifailidest. <br><br>Kas soovite jätkata?\",\n  \"MessageConfirmReScanLibraryItems\": \"Olete kindel, et soovite uuesti skannida {0} üksust?\",\n  \"MessageConfirmRemoveAllChapters\": \"Olete kindel, et soovite eemaldada kõik peatükid?\",\n  \"MessageConfirmRemoveAuthor\": \"Olete kindel, et soovite autori \\\"{0}\\\" eemaldada?\",\n  \"MessageConfirmRemoveCollection\": \"Olete kindel, et soovite kogumi \\\"{0}\\\" eemaldada?\",\n  \"MessageConfirmRemoveEpisode\": \"Olete kindel, et soovite episoodi \\\"{0}\\\" eemaldada?\",\n  \"MessageConfirmRemoveEpisodes\": \"Olete kindel, et soovite eemaldada {0} episoodi?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Olete kindel, et soovite eemaldada {0} kuulamise sessiooni?\",\n  \"MessageConfirmRemoveNarrator\": \"Olete kindel, et soovite jutustaja \\\"{0}\\\" eemaldada?\",\n  \"MessageConfirmRemovePlaylist\": \"Olete kindel, et soovite eemaldada oma esitusloendi \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Olete kindel, et soovite žanri \\\"{0}\\\" ümber nimetada kujule \\\"{1}\\\" kõikidele üksustele?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Märkus: See žanr on juba olemas, nii et need ühendatakse.\",\n  \"MessageConfirmRenameGenreWarning\": \"Hoiatus! Sarnane žanr erineva puhvriga on juba olemas \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Olete kindel, et soovite silti \\\"{0}\\\" ümber nimetada kujule \\\"{1}\\\" kõikidele üksustele?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Märkus: See silt on juba olemas, nii et need ühendatakse.\",\n  \"MessageConfirmRenameTagWarning\": \"Hoiatus! Sarnane silt erineva puhvriga on juba olemas \\\"{0}\\\".\",\n  \"MessageConfirmSendEbookToDevice\": \"Olete kindel, et soovite saata {0} e-raamatu \\\"{1}\\\" seadmesse \\\"{2}\\\"?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} päeva kuuldud viimase aasta jooksul\",\n  \"MessageDownloadingEpisode\": \"Episoodi allalaadimine\",\n  \"MessageDragFilesIntoTrackOrder\": \"Lohistage failid õigesse järjekorda\",\n  \"MessageEmbedFinished\": \"Manustamine lõpetatud!\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Episood(i) on allalaadimiseks järjekorras\",\n  \"MessageFeedURLWillBe\": \"Toite URL saab olema {0}\",\n  \"MessageFetching\": \"Hangitakse...\",\n  \"MessageForceReScanDescription\": \"skaneerib kõik failid uuesti nagu värsket skannimist. Heli faili ID3 silte, OPF faile ja tekstifaile skaneeritakse uuesti.\",\n  \"MessageImportantNotice\": \"Oluline märkus!\",\n  \"MessageInsertChapterBelow\": \"Sisesta peatükk allapoole\",\n  \"MessageItemsSelected\": \"{0} Valitud üksust\",\n  \"MessageItemsUpdated\": \"{0} Üksust on uuendatud\",\n  \"MessageJoinUsOn\": \"Liitu meiega\",\n  \"MessageLoading\": \"Laadimine...\",\n  \"MessageLoadingFolders\": \"Kaustade laadimine...\",\n  \"MessageM4BFailed\": \"M4B ebaõnnestus!\",\n  \"MessageM4BFinished\": \"M4B lõpetatud!\",\n  \"MessageMapChapterTitles\": \"Kaarda peatükkide pealkirjad olemasolevatele heliraamatu peatükkidele, ajatempe ei muudeta\",\n  \"MessageMarkAllEpisodesFinished\": \"Märgi kõik episoodid lõpetatuks\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Märgi kõik episoodid mitte lõpetatuks\",\n  \"MessageMarkAsFinished\": \"Märgi lõpetatuks\",\n  \"MessageMarkAsNotFinished\": \"Märgi mitte lõpetatuks\",\n  \"MessageMatchBooksDescription\": \"üritab raamatuid raamatukogus sobitada otsingupakkujast leitud raamatuga ning täita tühjad üksikasjad ja kaas. Ei üle kirjuta üksikasju.\",\n  \"MessageNoAudioTracks\": \"Ühtegi helijälge pole\",\n  \"MessageNoAuthors\": \"Ühtegi autori pole\",\n  \"MessageNoBackups\": \"Ühtegi varukoopia pole\",\n  \"MessageNoBookmarks\": \"Ühtegi järjehoidjat pole\",\n  \"MessageNoChapters\": \"Ühtegi peatükki pole\",\n  \"MessageNoCollections\": \"Ühtegi kogumit pole\",\n  \"MessageNoCoversFound\": \"Ühtegi kaant pole leitud\",\n  \"MessageNoDescription\": \"Kirjeldust pole\",\n  \"MessageNoDownloadsInProgress\": \"Praegu allalaadimisi pole\",\n  \"MessageNoDownloadsQueued\": \"Pole järjekorras allalaadimisi\",\n  \"MessageNoEpisodeMatchesFound\": \"Ühtegi episoodi vastet pole leitud\",\n  \"MessageNoEpisodes\": \"Ühtegi episoodi pole\",\n  \"MessageNoFoldersAvailable\": \"Ühtegi kausta pole saadaval\",\n  \"MessageNoGenres\": \"Ühtegi žanrit pole\",\n  \"MessageNoIssues\": \"Ühtegi probleemi pole\",\n  \"MessageNoItems\": \"Ühtegi üksust pole\",\n  \"MessageNoItemsFound\": \"Ühtegi üksust pole leitud\",\n  \"MessageNoListeningSessions\": \"Ühtegi kuulamissessiooni pole\",\n  \"MessageNoLogs\": \"Ühtegi logi pole\",\n  \"MessageNoMediaProgress\": \"Ühtegi meediaprogressi pole\",\n  \"MessageNoNotifications\": \"Ühtegi teavitust pole\",\n  \"MessageNoPodcastsFound\": \"Ühtegi podcasti pole leitud\",\n  \"MessageNoResults\": \"Ühtegi tulemust pole\",\n  \"MessageNoSearchResultsFor\": \"Otsingutulemusi pole märksõna kohta: \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Ühtegi seeriat pole\",\n  \"MessageNoTags\": \"Ühtegi silti pole\",\n  \"MessageNoTasksRunning\": \"Ühtegi käimasolevat ülesannet pole\",\n  \"MessageNoUpdatesWereNecessary\": \"Ühtegi värskendust polnud vaja\",\n  \"MessageNoUserPlaylists\": \"Teil pole ühtegi esitusloendit\",\n  \"MessageNotYetImplemented\": \"Pole veel ellu viidud\",\n  \"MessageOr\": \"või\",\n  \"MessagePauseChapter\": \"Peata peatüki esitamine\",\n  \"MessagePlayChapter\": \"Kuula peatüki algust\",\n  \"MessagePlaylistCreateFromCollection\": \"Loo esitusloend kogumist\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcastil pole sobitamiseks RSS-voogu\",\n  \"MessageQuickMatchDescription\": \"täidab tühjad üksikasjad ja kaaned raamatukogus esimese otsingutulemusega rakendusest '{0}'. Ei üle kirjuta üksikasju, välja arvatud juhul, kui serveri sätetes on lubatud 'Eelista sobitatud metaandmeid'.\",\n  \"MessageRemoveChapter\": \"Eemalda peatükk\",\n  \"MessageRemoveEpisodes\": \"Eemalda {0} episood(i)\",\n  \"MessageRemoveFromPlayerQueue\": \"Eemalda esitusjärjekorrast\",\n  \"MessageRemoveUserWarning\": \"Olete kindel, et soovite kasutaja \\\"{0}\\\" lõplikult kustutada?\",\n  \"MessageReportBugsAndContribute\": \"Raporteeri vigu, palu funktsioone ja aita kaasa\",\n  \"MessageResetChaptersConfirm\": \"Olete kindel, et soovite peatükkide lähtestada ja tehtud muudatused tagasi võtta?\",\n  \"MessageRestoreBackupConfirm\": \"Olete kindel, et soovite taastada varukoopia, mis loodi\",\n  \"MessageRestoreBackupWarning\": \"Varukoopia taastamine kirjutab üle kogu /config ja /metadata/items & /metadata/authors kaustas oleva andmebaasi. <br /><br />Varukoopiad ei muuda teie raamatukogukaustades olevaid faile. Kui olete lubanud serveri sätetel salvestada kaane kunsti ja metaandmed teie raamatukogu kaustadesse, siis neid ei varundata ega kirjutata üle.<br /><br />Kõik teie serveri kasutavad kliendid värskendatakse automaatselt.\",\n  \"MessageSearchResultsFor\": \"Otsingutulemused märksõnale\",\n  \"MessageSelected\": \"{0} valitud\",\n  \"MessageServerCouldNotBeReached\": \"Serveriga ei saanud ühendust luua\",\n  \"MessageSetChaptersFromTracksDescription\": \"Määrake peatükid, kasutades iga helifaili peatükina ja peatüki pealkirjana helifaili nime\",\n  \"MessageStartPlaybackAtTime\": \"Alustage \\\"{0}\\\" esitamist kell {1}?\",\n  \"MessageThinking\": \"Mõtlen...\",\n  \"MessageUploaderItemFailed\": \"Üleslaadimine ebaõnnestus\",\n  \"MessageUploaderItemSuccess\": \"Edukalt üles laaditud!\",\n  \"MessageUploading\": \"Üles laadimine...\",\n  \"MessageValidCronExpression\": \"Kehtiv cron-väljend\",\n  \"MessageWatcherIsDisabledGlobally\": \"Vaatleja on ülemaailmselt keelatud serveri sätetes\",\n  \"MessageXLibraryIsEmpty\": \"{0} raamatukogu on tühi!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Teie heliraamatu kestus on pikem kui leitud kestus\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Teie heliraamatu kestus on lühem kui leitud kestus\",\n  \"NoteChangeRootPassword\": \"Root kasutajal võib olla ainus kasutaja, kellel võib olla tühi parool\",\n  \"NoteChapterEditorTimes\": \"Märkus: Esimese peatüki algusaeg peab jääma 0:00 ja viimase peatüki algusaeg ei tohi ületada selle heliraamatu kestust.\",\n  \"NoteFolderPicker\": \"Märkus: juba kaardistatud kaustu ei kuvata\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Hoiatus: Enamik podcasti rakendusi nõuab, et RSS-voogu URL kasutaks HTTPS-i\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Hoiatus: Üks või mitu teie episoodi ei sisalda publikatsioonikuupäeva. Mõned podcasti rakendused nõuavad seda.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Kaustu, kus on meediat, käsitletakse eraldi raamatukogu üksustena.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Kui laadite üles ainult helifaile, käsitletakse iga helifaili eraldi heliraamatuna.\",\n  \"NoteUploaderUnsupportedFiles\": \"Toetamata failid jäetakse tähelepanuta. Kausta valimisel või lohistamisel jäetakse tähelepanuta muud failid, mis pole üksuse kaustas.\",\n  \"PlaceholderNewCollection\": \"Uue kogumi nimi\",\n  \"PlaceholderNewFolderPath\": \"Uus kausta tee\",\n  \"PlaceholderNewPlaylist\": \"Uue esitusloendi nimi\",\n  \"PlaceholderSearch\": \"Otsi...\",\n  \"PlaceholderSearchEpisode\": \"Otsi episoodi...\",\n  \"ToastAccountUpdateSuccess\": \"Konto on värskendatud\",\n  \"ToastAuthorImageRemoveSuccess\": \"Autori pilt on eemaldatud\",\n  \"ToastAuthorUpdateMerged\": \"Autor liidetud\",\n  \"ToastAuthorUpdateSuccess\": \"Autor värskendatud\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor värskendatud (pilti ei leitud)\",\n  \"ToastBackupCreateFailed\": \"Varukoopia loomine ebaõnnestus\",\n  \"ToastBackupCreateSuccess\": \"Varukoopia loodud\",\n  \"ToastBackupDeleteFailed\": \"Varukoopia kustutamine ebaõnnestus\",\n  \"ToastBackupDeleteSuccess\": \"Varukoopia kustutatud\",\n  \"ToastBackupRestoreFailed\": \"Varukoopia taastamine ebaõnnestus\",\n  \"ToastBackupUploadFailed\": \"Varukoopia üles laadimine ebaõnnestus\",\n  \"ToastBackupUploadSuccess\": \"Varukoopia üles laaditud\",\n  \"ToastBatchUpdateFailed\": \"Partii värskendamine ebaõnnestus\",\n  \"ToastBatchUpdateSuccess\": \"Partii värskendamine õnnestus\",\n  \"ToastBookmarkCreateFailed\": \"Järjehoidja loomine ebaõnnestus\",\n  \"ToastBookmarkCreateSuccess\": \"Järjehoidja lisatud\",\n  \"ToastBookmarkRemoveSuccess\": \"Järjehoidja eemaldatud\",\n  \"ToastChaptersHaveErrors\": \"Peatükkidel on vigu\",\n  \"ToastChaptersMustHaveTitles\": \"Peatükkidel peab olema pealkiri\",\n  \"ToastCollectionRemoveSuccess\": \"Kogum eemaldatud\",\n  \"ToastCollectionUpdateSuccess\": \"Kogum värskendatud\",\n  \"ToastItemCoverUpdateSuccess\": \"Üksuse kaas värskendatud\",\n  \"ToastItemDetailsUpdateSuccess\": \"Üksuse üksikasjad värskendatud\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Märgistamine kui lõpetatud ebaõnnestus\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Üksus märgitud kui lõpetatud\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Märgistamine kui mitte lõpetatud ebaõnnestus\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Üksus märgitud kui mitte lõpetatud\",\n  \"ToastLibraryCreateFailed\": \"Raamatukogu loomine ebaõnnestus\",\n  \"ToastLibraryCreateSuccess\": \"Raamatukogu \\\"{0}\\\" loodud\",\n  \"ToastLibraryDeleteFailed\": \"Raamatukogu kustutamine ebaõnnestus\",\n  \"ToastLibraryDeleteSuccess\": \"Raamatukogu kustutatud\",\n  \"ToastLibraryScanFailedToStart\": \"Skanneerimine ei käivitunud\",\n  \"ToastLibraryScanStarted\": \"Raamatukogu skaneerimine alustatud\",\n  \"ToastLibraryUpdateSuccess\": \"Raamatukogu \\\"{0}\\\" värskendatud\",\n  \"ToastPlaylistCreateFailed\": \"Esitusloendi loomine ebaõnnestus\",\n  \"ToastPlaylistCreateSuccess\": \"Esitusloend loodud\",\n  \"ToastPlaylistRemoveSuccess\": \"Esitusloend eemaldatud\",\n  \"ToastPlaylistUpdateSuccess\": \"Esitusloend värskendatud\",\n  \"ToastPodcastCreateFailed\": \"Podcasti loomine ebaõnnestus\",\n  \"ToastPodcastCreateSuccess\": \"Podcast loodud edukalt\",\n  \"ToastRSSFeedCloseFailed\": \"RSS-voogu sulgemine ebaõnnestus\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-voog suletud\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Üksuse eemaldamine kogumist ebaõnnestus\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Üksus eemaldatud kogumist\",\n  \"ToastSendEbookToDeviceFailed\": \"E-raamatu saatmine seadmesse ebaõnnestus\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-raamat saadetud seadmesse \\\"{0}\\\"\",\n  \"ToastSeriesUpdateFailed\": \"Sarja värskendamine ebaõnnestus\",\n  \"ToastSeriesUpdateSuccess\": \"Sarja värskendamine õnnestus\",\n  \"ToastSessionDeleteFailed\": \"Seansi kustutamine ebaõnnestus\",\n  \"ToastSessionDeleteSuccess\": \"Sessioon kustutatud\",\n  \"ToastSocketConnected\": \"Pesa ühendatud\",\n  \"ToastSocketDisconnected\": \"Pesa ühendus katkenud\",\n  \"ToastSocketFailedToConnect\": \"Pesa ühendamine ebaõnnestus\",\n  \"ToastUserDeleteFailed\": \"Kasutaja kustutamine ebaõnnestus\",\n  \"ToastUserDeleteSuccess\": \"Kasutaja kustutatud\"\n}\n"
  },
  {
    "path": "client/strings/eu.json",
    "content": "{}\n"
  },
  {
    "path": "client/strings/fa.json",
    "content": "{\n  \"ButtonAdd\": \"افزودن\",\n  \"ButtonAuthors\": \"ناشر\",\n  \"ButtonBack\": \"بازگشت\",\n  \"ButtonCancel\": \"انصراف\",\n  \"ButtonClearFilter\": \"حذف صافی\",\n  \"ButtonCloseFeed\": \"بستن فید\",\n  \"ButtonCollections\": \"مجموعه ها\",\n  \"ButtonCreate\": \"ساختن\",\n  \"ButtonDelete\": \"حذف\",\n  \"ButtonHome\": \"خانه\",\n  \"ButtonIssues\": \"مشکلات\",\n  \"ButtonLatest\": \"جدیدترین\",\n  \"ButtonLibrary\": \"کتابخانه\",\n  \"ButtonOk\": \"تایید\",\n  \"ButtonOpenFeed\": \"باز کردن فید\",\n  \"ButtonPause\": \"توقف\",\n  \"ButtonPlay\": \"پخش\",\n  \"ButtonPlaylists\": \"لیست پخش\",\n  \"ButtonRead\": \"خواندن\",\n  \"ButtonReadLess\": \"خواندن کمتر\",\n  \"ButtonReadMore\": \"خواندن بیشتر\",\n  \"ButtonRemove\": \"حذف\",\n  \"ButtonSave\": \"ذخیره\",\n  \"ButtonSearch\": \"جستجو\",\n  \"ButtonSeries\": \"مجموعه\",\n  \"ButtonSubmit\": \"ثبت\"\n}\n"
  },
  {
    "path": "client/strings/fi.json",
    "content": "{\n  \"ButtonAdd\": \"Lisää\",\n  \"ButtonAddApiKey\": \"Lisää API avain\",\n  \"ButtonAddChapters\": \"Lisää lukuja\",\n  \"ButtonAddDevice\": \"Lisää laite\",\n  \"ButtonAddLibrary\": \"Lisää kirjasto\",\n  \"ButtonAddPodcasts\": \"Lisää podcasteja\",\n  \"ButtonAddUser\": \"Lisää käyttäjä\",\n  \"ButtonAddYourFirstLibrary\": \"Lisää ensimmäinen kirjastosi\",\n  \"ButtonApply\": \"Käytä\",\n  \"ButtonApplyChapters\": \"Käytä lukuihin\",\n  \"ButtonAuthors\": \"Tekijät\",\n  \"ButtonBack\": \"Takaisin\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Täydennä olemassa olevista\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Täydennä karttatiedot\",\n  \"ButtonBrowseForFolder\": \"Selaa (kansio)\",\n  \"ButtonCancel\": \"Peruuta\",\n  \"ButtonCancelEncode\": \"Lopeta enkoodaus\",\n  \"ButtonChangeRootPassword\": \"Vaihda root salasana\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Tarkista ja lataa uudet jaksot\",\n  \"ButtonChooseAFolder\": \"Valitse kansio\",\n  \"ButtonChooseFiles\": \"Valitse tiedostot\",\n  \"ButtonClearFilter\": \"Poista suodatus\",\n  \"ButtonClose\": \"Sulje\",\n  \"ButtonCloseFeed\": \"Sulje syöte\",\n  \"ButtonCloseSession\": \"Sulje Avoin Sessio\",\n  \"ButtonCollections\": \"Kokoelmat\",\n  \"ButtonConfigureScanner\": \"Skannerin asetukset\",\n  \"ButtonCreate\": \"Luo\",\n  \"ButtonCreateBackup\": \"Luo varmuuskopio\",\n  \"ButtonDelete\": \"Poista\",\n  \"ButtonDownloadQueue\": \"Jono\",\n  \"ButtonEdit\": \"Muokkaa\",\n  \"ButtonEditChapters\": \"Muokkaa lukuja\",\n  \"ButtonEditPodcast\": \"Muokkaa podcastia\",\n  \"ButtonEnable\": \"Aktivoi\",\n  \"ButtonFireAndFail\": \"Laukaise ja epäonnistu\",\n  \"ButtonFireOnTest\": \"Laukaise onTest tapahtuma\",\n  \"ButtonForceReScan\": \"Pakota uudelleenskannaus\",\n  \"ButtonFullPath\": \"Koko polku\",\n  \"ButtonHide\": \"Piilota\",\n  \"ButtonHome\": \"Koti\",\n  \"ButtonIssues\": \"Ongelmat\",\n  \"ButtonJumpBackward\": \"Hyppää taaksepäin\",\n  \"ButtonJumpForward\": \"Hyppää eteenpäin\",\n  \"ButtonLatest\": \"Viimeisimmät\",\n  \"ButtonLibrary\": \"Kirjasto\",\n  \"ButtonLogout\": \"Kirjaudu ulos\",\n  \"ButtonLookup\": \"Hae\",\n  \"ButtonManageTracks\": \"Hallitse raitoja\",\n  \"ButtonMapChapterTitles\": \"Kartoita lukujen nimet\",\n  \"ButtonMatchAllAuthors\": \"Täsmää kaikki Tekijät\",\n  \"ButtonMatchBooks\": \"Täsmää kirjat\",\n  \"ButtonNevermind\": \"Ei sittenkään\",\n  \"ButtonNext\": \"Seuraava\",\n  \"ButtonNextChapter\": \"Seuraava luku\",\n  \"ButtonNextItemInQueue\": \"Seuraava jonossa\",\n  \"ButtonOk\": \"Hyvä on\",\n  \"ButtonOpenFeed\": \"Avaa syöte\",\n  \"ButtonOpenManager\": \"Avaa hallinta\",\n  \"ButtonPause\": \"Pysäytä\",\n  \"ButtonPlay\": \"Toista\",\n  \"ButtonPlayAll\": \"Toista kaikki\",\n  \"ButtonPlaying\": \"Toistetaan\",\n  \"ButtonPlaylists\": \"Soittolistat\",\n  \"ButtonPrevious\": \"Edellinen\",\n  \"ButtonPreviousChapter\": \"Edellinen luku\",\n  \"ButtonProbeAudioFile\": \"Luotaa äänitiedosto\",\n  \"ButtonPurgeAllCache\": \"Tyhjennä kaikki välimuistit\",\n  \"ButtonPurgeItemsCache\": \"Tyhjennä kohteiden välimuisti\",\n  \"ButtonQueueAddItem\": \"Lisää jonoon\",\n  \"ButtonQueueRemoveItem\": \"Poista jonosta\",\n  \"ButtonQuickEmbed\": \"Pikaupota\",\n  \"ButtonQuickEmbedMetadata\": \"Upota metatiedot pikaisesti\",\n  \"ButtonQuickMatch\": \"Pikatäsmäys\",\n  \"ButtonReScan\": \"Uudelleenskannaa\",\n  \"ButtonRead\": \"Lue\",\n  \"ButtonReadLess\": \"Lue vähemmän\",\n  \"ButtonReadMore\": \"Lue enemmän\",\n  \"ButtonRefresh\": \"Päivitä\",\n  \"ButtonRemove\": \"Poista\",\n  \"ButtonRemoveAll\": \"Poista kaikki\",\n  \"ButtonRemoveAllLibraryItems\": \"Poista kaikki kirjaston kohteet\",\n  \"ButtonRemoveFromContinueListening\": \"Poista Jatka kuuntelua -osiosta\",\n  \"ButtonRemoveFromContinueReading\": \"Poista Jatka lukemista -osiosta\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Poista sarja Jatka sarjaa -osiosta\",\n  \"ButtonReset\": \"Nollaa\",\n  \"ButtonResetToDefault\": \"Palauta oletusasetukset\",\n  \"ButtonRestore\": \"Palauta\",\n  \"ButtonSave\": \"Tallenna\",\n  \"ButtonSaveAndClose\": \"Tallenna ja sulje\",\n  \"ButtonSaveTracklist\": \"Tallenna raitalista\",\n  \"ButtonScan\": \"Skannaa\",\n  \"ButtonScanLibrary\": \"Skannaa kirjasto\",\n  \"ButtonScrollLeft\": \"Vieritä vasemmalle\",\n  \"ButtonScrollRight\": \"Vieritä oikealle\",\n  \"ButtonSearch\": \"Etsi\",\n  \"ButtonSelectFolderPath\": \"Valitse kansiopolku\",\n  \"ButtonSeries\": \"Sarjat\",\n  \"ButtonSetChaptersFromTracks\": \"Aseta luvut raidoista\",\n  \"ButtonShare\": \"Jaa\",\n  \"ButtonShiftTimes\": \"Siirrä aikoja\",\n  \"ButtonShow\": \"Näytä\",\n  \"ButtonStartM4BEncode\": \"Aloita M4B enkoodaus\",\n  \"ButtonStartMetadataEmbed\": \"Aloita metadatan embed\",\n  \"ButtonStats\": \"Tilastot\",\n  \"ButtonSubmit\": \"Lähetä\",\n  \"ButtonTest\": \"Testi\",\n  \"ButtonUnlinkOpenId\": \"Poista OpenID linkitys\",\n  \"ButtonUpload\": \"Lähetä palvelimelle\",\n  \"ButtonUploadBackup\": \"Lähetä varmuuskopio\",\n  \"ButtonUploadCover\": \"Lähetä kansikuva\",\n  \"ButtonUploadOPMLFile\": \"Lähetä OPML-tiedosto\",\n  \"ButtonUserDelete\": \"Poista käyttäjä {0}\",\n  \"ButtonUserEdit\": \"Muokkaa käyttäjää {0}\",\n  \"ButtonViewAll\": \"Näytä kaikki\",\n  \"ButtonYes\": \"Kyllä\",\n  \"ErrorUploadFetchMetadataAPI\": \"Virhe haettaessa metadataa\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Metadatan haku epäonnistui, yritä päivittää Teoksen nimi ja/tai Tekijä\",\n  \"ErrorUploadLacksTitle\": \"Pitää sisältää Nimi\",\n  \"HeaderAccount\": \"Tili\",\n  \"HeaderAddCustomMetadataProvider\": \"Lisää mukautettu metadata tarjoaja\",\n  \"HeaderAdvanced\": \"Edistynyt\",\n  \"HeaderApiKeys\": \"API avaimet\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise-ilmoitusasetukset\",\n  \"HeaderAudioTracks\": \"Ääniraidat\",\n  \"HeaderAudiobookTools\": \"Äänikirjojen tiedostonhallintatyökalut\",\n  \"HeaderAuthentication\": \"Todennus\",\n  \"HeaderBackups\": \"Varmuuskopiot\",\n  \"HeaderBulkChapterModal\": \"Lisää useita kappaleita\",\n  \"HeaderChangePassword\": \"Vaihda salasana\",\n  \"HeaderChapters\": \"Luvut\",\n  \"HeaderChooseAFolder\": \"Valitse kansio\",\n  \"HeaderCollection\": \"Kokoelma\",\n  \"HeaderCollectionItems\": \"Kokoelman osat\",\n  \"HeaderCover\": \"Kansikuva\",\n  \"HeaderCurrentDownloads\": \"Tämänhetkiset lataukset\",\n  \"HeaderCustomMessageOnLogin\": \"Muokattu kirjautumisviesti\",\n  \"HeaderCustomMetadataProviders\": \"Mukautetut metadatan tarjoajat\",\n  \"HeaderDetails\": \"Yksityiskohdat\",\n  \"HeaderDownloadQueue\": \"Latausjono\",\n  \"HeaderEbookFiles\": \"S-kirjatiedostot\",\n  \"HeaderEmail\": \"Sähköposti\",\n  \"HeaderEmailSettings\": \"Sähköpostiasetukset\",\n  \"HeaderEpisodes\": \"Jaksot\",\n  \"HeaderEreaderDevices\": \"E-lukijalaitteet\",\n  \"HeaderEreaderSettings\": \"E-lukijan asetukset\",\n  \"HeaderFiles\": \"Tiedostot\",\n  \"HeaderFindChapters\": \"Etsi kappaleet\",\n  \"HeaderIgnoredFiles\": \"Ohitetut tiedostot\",\n  \"HeaderItemFiles\": \"Kohteen tiedostot\",\n  \"HeaderItemMetadataUtils\": \"Metadatan hallinta\",\n  \"HeaderLastListeningSession\": \"Edellinen kuuntelukerta\",\n  \"HeaderLatestEpisodes\": \"Viimeisimmät jaksot\",\n  \"HeaderLibraries\": \"Kirjastot\",\n  \"HeaderLibraryFiles\": \"Kirjaston tiedostot\",\n  \"HeaderLibraryStats\": \"Kirjaston tilastot\",\n  \"HeaderListeningSessions\": \"Kuuntelukerrat\",\n  \"HeaderListeningStats\": \"Kuuntelutilastot\",\n  \"HeaderLogin\": \"Kirjaudu\",\n  \"HeaderLogs\": \"Lokit\",\n  \"HeaderManageGenres\": \"Hallitse lajityyppejä\",\n  \"HeaderManageTags\": \"Hallitse tageja\",\n  \"HeaderMapDetails\": \"Karttatiedot\",\n  \"HeaderMatch\": \"Täsmää\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metadatan tärkeysjärjestys\",\n  \"HeaderMetadataToEmbed\": \"Sisällytettävä metadata\",\n  \"HeaderNewAccount\": \"Uusi tili\",\n  \"HeaderNewApiKey\": \"Uusi API avain\",\n  \"HeaderNewLibrary\": \"Uusi kirjasto\",\n  \"HeaderNotificationCreate\": \"Luo ilmoitus\",\n  \"HeaderNotificationUpdate\": \"Päivitä ilmoitus\",\n  \"HeaderNotifications\": \"Ilmoitukset\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect -todennus\",\n  \"HeaderOpenListeningSessions\": \"Avoimet kuuntelusessiot\",\n  \"HeaderOpenRSSFeed\": \"Avaa RSS-syöte\",\n  \"HeaderOtherFiles\": \"Muut tiedostot\",\n  \"HeaderPasswordAuthentication\": \"Salasanan todentaminen\",\n  \"HeaderPermissions\": \"Käyttöoikeudet\",\n  \"HeaderPlayerQueue\": \"Soittimen jono\",\n  \"HeaderPlayerSettings\": \"Soittimen asetukset\",\n  \"HeaderPlaylist\": \"Soittolista\",\n  \"HeaderPlaylistItems\": \"Soittolistan kohteet\",\n  \"HeaderPodcastsToAdd\": \"Lisättävät podcastit\",\n  \"HeaderPresets\": \"Esivalinnat\",\n  \"HeaderPreviewCover\": \"Esikatsele kansikuvaa\",\n  \"HeaderRSSFeedGeneral\": \"RSS yksityiskohdat\",\n  \"HeaderRSSFeedIsOpen\": \"RSS syöte on avoinna\",\n  \"HeaderRSSFeeds\": \"RSS syötteet\",\n  \"HeaderRemoveEpisode\": \"Poista jakso\",\n  \"HeaderRemoveEpisodes\": \"Poista {0} jaksoa\",\n  \"HeaderSavedMediaProgress\": \"Tallennettu median edistyminen\",\n  \"HeaderSchedule\": \"Ajoita\",\n  \"HeaderScheduleEpisodeDownloads\": \"Ajoita automaattiset jaksolataukset\",\n  \"HeaderScheduleLibraryScans\": \"Ajoita automaattiset kirjastoskannaukset\",\n  \"HeaderSession\": \"Istunto\",\n  \"HeaderSetBackupSchedule\": \"Aseta varmuuskopiointiaikataulu\",\n  \"HeaderSettings\": \"Asetukset\",\n  \"HeaderSettingsDisplay\": \"Näyttö\",\n  \"HeaderSettingsExperimental\": \"Kokeelliset ominaisuudet\",\n  \"HeaderSettingsGeneral\": \"Yleiset\",\n  \"HeaderSettingsScanner\": \"Skannaaja\",\n  \"HeaderSettingsSecurity\": \"Turvallisuus\",\n  \"HeaderSettingsWebClient\": \"Webasiakasohjelma\",\n  \"HeaderSleepTimer\": \"Uniajastin\",\n  \"HeaderStatsLargestItems\": \"Suurimmat kohteet\",\n  \"HeaderStatsLongestItems\": \"Pisimmät kohteet (h)\",\n  \"HeaderStatsMinutesListeningChart\": \"Kuunteluminuutit (viim. 7 pv)\",\n  \"HeaderStatsRecentSessions\": \"Viimeaikaiset istunnot\",\n  \"HeaderStatsTop10Authors\": \"Suosituimmat 10 tekijää\",\n  \"HeaderStatsTop5Genres\": \"Suosituimmat 5 lajityyppiä\",\n  \"HeaderTableOfContents\": \"Sisällysluettelo\",\n  \"HeaderTools\": \"Työkalut\",\n  \"HeaderUpdateAccount\": \"Päivitä tili\",\n  \"HeaderUpdateApiKey\": \"Päivitä API avain\",\n  \"HeaderUpdateAuthor\": \"Päivitä tekijä\",\n  \"HeaderUpdateDetails\": \"Päivitä yksityiskohdat\",\n  \"HeaderUpdateLibrary\": \"Päivitä kirjasto\",\n  \"HeaderUsers\": \"Käyttäjät\",\n  \"HeaderYearReview\": \"Vuosi {0} tarkasteltuna\",\n  \"HeaderYourStats\": \"Tilastosi\",\n  \"LabelAbridged\": \"Lyhennetty\",\n  \"LabelAbridgedChecked\": \"Lyhennetty (tarkistettu)\",\n  \"LabelAbridgedUnchecked\": \"Lyhentämätön (tarkistamaton)\",\n  \"LabelAccessibleBy\": \"Saavutettavissa\",\n  \"LabelAccountType\": \"Tilin tyyppi\",\n  \"LabelAccountTypeAdmin\": \"Järjestelmänvalvoja\",\n  \"LabelAccountTypeGuest\": \"Vieras\",\n  \"LabelAccountTypeUser\": \"Käyttäjä\",\n  \"LabelActivities\": \"Toiminnot\",\n  \"LabelActivity\": \"Toiminta\",\n  \"LabelAddToCollection\": \"Lisää kokoelmaan\",\n  \"LabelAddToCollectionBatch\": \"Lisää {0} kirjaa kokoelmaan\",\n  \"LabelAddToPlaylist\": \"Lisää soittolistaan\",\n  \"LabelAddToPlaylistBatch\": \"Lisää {0} kohdetta soittolistaan\",\n  \"LabelAddedAt\": \"Lisätty listalle\",\n  \"LabelAddedDate\": \"Lisätty {0}\",\n  \"LabelAdminUsersOnly\": \"Vain järjestelmänvalvojat\",\n  \"LabelAll\": \"Kaikki\",\n  \"LabelAllEpisodesDownloaded\": \"Kaikki jaksot ladattu\",\n  \"LabelAllUsers\": \"Kaikki käyttäjät\",\n  \"LabelAllUsersExcludingGuests\": \"Kaikki käyttäjät vieraita lukuun ottamatta\",\n  \"LabelAllUsersIncludingGuests\": \"Kaikki käyttäjät mukaan lukien vieraat\",\n  \"LabelAlreadyInYourLibrary\": \"Jo kirjastossasi\",\n  \"LabelApiKeyCreated\": \"API avain \\\"{0}\\\" luotu onnistuneesti.\",\n  \"LabelApiKeyCreatedDescription\": \"Varmista, että kopioit API avaimen. Sitä ei näytetä enää tämän jälkeen.\",\n  \"LabelApiKeyUser\": \"Toimi käyttäjän puolesta\",\n  \"LabelApiKeyUserDescription\": \"Tällä API-avaimella on samat käyttöoikeudet kuin käyttäjällä, jonka puolesta se toimii. Tämä näkyy lokeissa samalla tavalla kuin jos käyttäjä itse tekisi pyynnön.\",\n  \"LabelApiToken\": \"Sovellusliittymätunnus\",\n  \"LabelAppend\": \"Lisää loppuun\",\n  \"LabelAudioBitrate\": \"Äänen bittinopeus (esim. 128k)\",\n  \"LabelAudioChannels\": \"Äänikanavat (1 tai 2)\",\n  \"LabelAudioCodec\": \"Äänikoodekki\",\n  \"LabelAuthor\": \"Tekijä\",\n  \"LabelAuthorFirstLast\": \"Tekijä (Etunimi Sukunimi)\",\n  \"LabelAuthorLastFirst\": \"Tekijä (Sukunimi, Etunimi)\",\n  \"LabelAuthors\": \"Tekijät\",\n  \"LabelAutoDownloadEpisodes\": \"Lataa jaksot automaattisesti\",\n  \"LabelAutoFetchMetadata\": \"Etsi metadata automaattisesti\",\n  \"LabelAutoFetchMetadataHelp\": \"Hakee metatiedot kohteille, kirjailijoille ja sarjoille lähetyksen nopeuttamiseksi. Joitain metatietoja voidaan joutua täsmäämään lähetyksen jälkeen.\",\n  \"LabelAutoLaunch\": \"Automaattinen käynnistys\",\n  \"LabelAutoLaunchDescription\": \"Uudelleenohjaa automaattisesti kirjautumisen tarjoajaan kirjautumissivulle saavuttaessa. (ohitettavissa käyttämällä polkua <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automaattinen rekisteröinti\",\n  \"LabelAutoRegisterDescription\": \"Luo automaattisesti uusia käyttäjiä kirjautumisen jälkeen\",\n  \"LabelBackToUser\": \"Takaisin käyttäjään\",\n  \"LabelBackupAudioFiles\": \"Varmuuskopioi äänitiedostot\",\n  \"LabelBackupLocation\": \"Varmuuskopiointipaikka\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automaattiset varmuuskopiot\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Varmuuskopiot tallennettu kansioon /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Varmuuskopion enimmäiskoko (Gt) (0 rajaton)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Virheellisten asetusten estämiseksi varmuuskopiot epäonnistuvat, jos ne ovat asetettua kokoa suurempia.\",\n  \"LabelBackupsNumberToKeep\": \"Säilytettävien varmuuskopioiden määrä\",\n  \"LabelBackupsNumberToKeepHelp\": \"Varmuuskopiot poistetaan yksi kerrallaan, joten jos niitä on enemmän kuin yksi, ne on poistettava manuaalisesti.\",\n  \"LabelBitrate\": \"Bittinopeus\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Kirjat\",\n  \"LabelButtonText\": \"Painikkeen teksti\",\n  \"LabelByAuthor\": \"Tekijältä: {0}\",\n  \"LabelChangePassword\": \"Vaihda salasana\",\n  \"LabelChannels\": \"Kanavat\",\n  \"LabelChapterCount\": \"{0} lukua\",\n  \"LabelChapterTitle\": \"Luvun nimi\",\n  \"LabelChapters\": \"Luvut\",\n  \"LabelChaptersFound\": \"lukuja löydetty\",\n  \"LabelClickForMoreInfo\": \"Napsauta saadaksesi lisätietoja\",\n  \"LabelClickToUseCurrentValue\": \"Käytä nykyistä arvoa napsauttamalla\",\n  \"LabelClosePlayer\": \"Sulje soitin\",\n  \"LabelCodec\": \"Koodekki\",\n  \"LabelCollapseSeries\": \"Pienennä sarja\",\n  \"LabelCollapseSubSeries\": \"Tiivistä alisarjat\",\n  \"LabelCollection\": \"Kokoelma\",\n  \"LabelCollections\": \"Kokoelmat\",\n  \"LabelComplete\": \"Valmis\",\n  \"LabelConfirmPassword\": \"Vahvista salasana\",\n  \"LabelContinueListening\": \"Jatka kuuntelua\",\n  \"LabelContinueReading\": \"Jatka lukemista\",\n  \"LabelContinueSeries\": \"Jatka sarjoja\",\n  \"LabelCorsAllowed\": \"Salli CORS Origins\",\n  \"LabelCover\": \"Kansikuva\",\n  \"LabelCoverImageURL\": \"Kansikuvan URL-osoite\",\n  \"LabelCoverProvider\": \"Kansikuvan tarjoaja\",\n  \"LabelCreatedAt\": \"Luotu\",\n  \"LabelCronExpression\": \"Cron ajastin\",\n  \"LabelCurrent\": \"Nykyinen\",\n  \"LabelCurrently\": \"Nyt:\",\n  \"LabelCustomCronExpression\": \"Mukautettu Cron-ajastin:\",\n  \"LabelDatetime\": \"Päivämäärä/Aika\",\n  \"LabelDays\": \"Päivää\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Poista tiedostojärjestelmästä (poista merkintä, jos haluat poistaa vain tietokannasta)\",\n  \"LabelDescription\": \"Kuvaus\",\n  \"LabelDeselectAll\": \"Poista valinta kaikista\",\n  \"LabelDetectedPattern\": \"Tunnista malli:\",\n  \"LabelDevice\": \"Laite\",\n  \"LabelDeviceInfo\": \"Laitteen tiedot\",\n  \"LabelDeviceIsAvailableTo\": \"Laite on saatavilla...\",\n  \"LabelDirectory\": \"Kansio\",\n  \"LabelDiscFromFilename\": \"Levyn numero tiedostonimestä\",\n  \"LabelDiscFromMetadata\": \"Levyn numero metatiedoista\",\n  \"LabelDiscover\": \"Löydä\",\n  \"LabelDownload\": \"Lataa\",\n  \"LabelDownloadNEpisodes\": \"Lataa {0} jaksoa\",\n  \"LabelDownloadable\": \"Ladattavissa\",\n  \"LabelDuration\": \"Kesto\",\n  \"LabelDurationComparisonExactMatch\": \"(tarkka vastaavuus)\",\n  \"LabelDurationComparisonLonger\": \"({0} pidempi)\",\n  \"LabelDurationComparisonShorter\": \"({0} lyhyempi)\",\n  \"LabelDurationFound\": \"Kesto löydetty:\",\n  \"LabelEbook\": \"S-kirja\",\n  \"LabelEbooks\": \"S-kirjat\",\n  \"LabelEdit\": \"Muokkaa\",\n  \"LabelEmail\": \"Sähköposti\",\n  \"LabelEmailSettingsFromAddress\": \"Osoitteesta\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Hylkää luvattomat sertifikaatit\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"SSL-sertifikaatin varmentamisen käytöstä poistaminen saattaa vaarantaa yhteytesti turvallisuusriskeihin, kuten man-in-the-middle hyökkäyksiin. Poista käytöstä vain jos ymmärrät vaaran ja luotat yhdistämääsi sähköpostipalvelimeen.\",\n  \"LabelEmailSettingsSecure\": \"Turvallinen\",\n  \"LabelEmailSettingsSecureHelp\": \"Jos tosi, niin yhteys käyttää TLS:ää yhdistäessään palvelimeen. Jos epätosi, niin TSL käytetään jos palvelin tukee STARTTLS-lisäosaa. Yleensä tämä arvo on tosi jos yhdistät porttiin 465. Porteille 587 tai 25 käytä arvoa epätosi (Lähde: nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Testiosoite\",\n  \"LabelEmbeddedCover\": \"Upotettu kansikuva\",\n  \"LabelEnable\": \"Ota käyttöön\",\n  \"LabelEncodingBackupLocation\": \"Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Lukuja ei ole upotettu moniraitaisiin äänikirjoihin.\",\n  \"LabelEncodingClearItemCache\": \"Varmista, että kohteiden välimuisti tyhjennetään säännöllisesti.\",\n  \"LabelEncodingFinishedM4B\": \"Valmistunut M4B tullaan viemään äänikirjakansioosi:\",\n  \"LabelEncodingInfoEmbedded\": \"Kuvailutiedot upotetaan äänikirjakansion ääniraitoihin.\",\n  \"LabelEncodingStartedNavigation\": \"Voit poistua sivulta kun tehtävä on aloitettu.\",\n  \"LabelEncodingTimeWarning\": \"Koodaus saattaa kestää 30 minuuttiin asti.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Varoitus: Älä päivitä näitä asetuksia ellet ymmärrä ffmpeg-koodausasetuksia.\",\n  \"LabelEncodingWatcherDisabled\": \"Jos kansiotarkkailu on poistettu käytöstä, tämä äänikirja pitää skannata uudestaan myöhemmin.\",\n  \"LabelEnd\": \"Loppu\",\n  \"LabelEndOfChapter\": \"Luvun loppu\",\n  \"LabelEpisode\": \"Jakso\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Jakso ei yhdistetty RSS-syötteeseen\",\n  \"LabelEpisodeNumber\": \"Jakso #{0}\",\n  \"LabelEpisodeTitle\": \"Jakson nimi\",\n  \"LabelEpisodeType\": \"Jakson tyyppi\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Jakson URL RSS-syötteestä\",\n  \"LabelEpisodes\": \"Jaksot\",\n  \"LabelEpisodic\": \"Jaksollinen\",\n  \"LabelExample\": \"Esimerkki\",\n  \"LabelExpandSeries\": \"Laajenna sarja\",\n  \"LabelExpandSubSeries\": \"Laajenna alisarja\",\n  \"LabelExpired\": \"Vanhentunut\",\n  \"LabelExpiresAt\": \"Vanhentuu\",\n  \"LabelExpiresInSeconds\": \"Vanhentuu (sekunnissa)\",\n  \"LabelExpiresNever\": \"Ei koskaan\",\n  \"LabelExplicit\": \"Sopimaton\",\n  \"LabelExplicitChecked\": \"Yksiselitteinen (valittu)\",\n  \"LabelExplicitUnchecked\": \"Ei yksiselitteinen (ei valittu)\",\n  \"LabelExportOPML\": \"Vie OPML\",\n  \"LabelFeedURL\": \"Syötteen URL\",\n  \"LabelFetchingMetadata\": \"Noudetaan kuvailutietoja\",\n  \"LabelFile\": \"Tiedosto\",\n  \"LabelFileBirthtime\": \"Tiedoston syntymäaika\",\n  \"LabelFileBornDate\": \"Syntynyt {0}\",\n  \"LabelFileModified\": \"Muutettu tiedosto\",\n  \"LabelFileModifiedDate\": \"Muokattu {0}\",\n  \"LabelFilename\": \"Tiedostonimi\",\n  \"LabelFilterByUser\": \"Suodata käyttäjien perusteella\",\n  \"LabelFindEpisodes\": \"Etsi jaksoja\",\n  \"LabelFinished\": \"Valmis\",\n  \"LabelFinishedDate\": \"Valmis {0}\",\n  \"LabelFolder\": \"Kansio\",\n  \"LabelFolders\": \"Kansiot\",\n  \"LabelFontBold\": \"Lihavoitu\",\n  \"LabelFontBoldness\": \"Kirjasintyyppien lihavointi\",\n  \"LabelFontFamily\": \"Fonttiperhe\",\n  \"LabelFontItalic\": \"Kursiivi\",\n  \"LabelFontScale\": \"Kirjasintyyppien skaalautuminen\",\n  \"LabelFontStrikethrough\": \"Yliviivattu\",\n  \"LabelFormat\": \"Muoto\",\n  \"LabelFull\": \"Täynnä\",\n  \"LabelGenre\": \"Lajityyppi\",\n  \"LabelGenres\": \"Lajityypit\",\n  \"LabelHardDeleteFile\": \"Kova tiedostojen poisto\",\n  \"LabelHasEbook\": \"Sillä on s-kirja\",\n  \"LabelHasSupplementaryEbook\": \"Sillä on täydentävän s-kirjan\",\n  \"LabelHideSubtitles\": \"Piilota alaotsikot\",\n  \"LabelHighestPriority\": \"Korkein etusija\",\n  \"LabelHost\": \"Isäntä\",\n  \"LabelHour\": \"Tunti\",\n  \"LabelHours\": \"Tunnit\",\n  \"LabelIcon\": \"Kuvake\",\n  \"LabelImageURLFromTheWeb\": \"Kuvan verkko-osoite\",\n  \"LabelInProgress\": \"Kesken\",\n  \"LabelIncludeInTracklist\": \"Sisällytä kappalelistaan\",\n  \"LabelIncomplete\": \"Keskeneräinen\",\n  \"LabelInterval\": \"Väli\",\n  \"LabelIntervalCustomDailyWeekly\": \"Mukautettu päivittäinen/viikoittainen\",\n  \"LabelIntervalEvery12Hours\": \"12 tunnin välein\",\n  \"LabelIntervalEvery15Minutes\": \"15 minuutin välein\",\n  \"LabelIntervalEvery2Hours\": \"2 tunnin välein\",\n  \"LabelIntervalEvery30Minutes\": \"30 minuutin välein\",\n  \"LabelIntervalEvery6Hours\": \"6 tunnin välein\",\n  \"LabelIntervalEveryDay\": \"Joka päivä\",\n  \"LabelIntervalEveryHour\": \"Joka tunti\",\n  \"LabelIntervalEveryMinute\": \"Joka minuutti\",\n  \"LabelInvert\": \"Saa käänteiseksi\",\n  \"LabelItem\": \"Kohde\",\n  \"LabelJumpBackwardAmount\": \"Taaksepäin-hyppyjen määrä\",\n  \"LabelJumpForwardAmount\": \"Eteenpäin-hyppyjen määrä\",\n  \"LabelLanguage\": \"Kieli\",\n  \"LabelLanguageDefaultServer\": \"Palvelimen oletuskieli\",\n  \"LabelLanguages\": \"Kielet\",\n  \"LabelLastBookAdded\": \"Viimeisin lisätty kirja\",\n  \"LabelLastBookUpdated\": \"Viimeisin päivitetty kirja\",\n  \"LabelLastProgressDate\": \"Viimeisin edistyminen {0}\",\n  \"LabelLastSeen\": \"Nähty viimeksi\",\n  \"LabelLastTime\": \"Viimeinen kerta\",\n  \"LabelLastUpdate\": \"Viimeisin päivitys\",\n  \"LabelLayout\": \"Asettelu\",\n  \"LabelLayoutSinglePage\": \"Yksi sivu\",\n  \"LabelLayoutSplitPage\": \"Jaa sivu osiin\",\n  \"LabelLess\": \"Vähemmän\",\n  \"LabelLibrariesAccessibleToUser\": \"Käyttäjälle saatavilla olevat kirjastot\",\n  \"LabelLibrary\": \"Kirjasto\",\n  \"LabelLibraryFilterSublistEmpty\": \"Ei {0}\",\n  \"LabelLibraryItem\": \"Kirjaston kohde\",\n  \"LabelLibraryName\": \"Kirjaston nimi\",\n  \"LabelLibrarySortByProgress\": \"Edistyminen: Viimeksi päivitetty\",\n  \"LabelLibrarySortByProgressFinished\": \"Edistyminen: Valmis\",\n  \"LabelLibrarySortByProgressStarted\": \"Edistyminen: Aloitettu\",\n  \"LabelLimit\": \"Raja\",\n  \"LabelLineSpacing\": \"Riviväli\",\n  \"LabelListenAgain\": \"Kuuntele uudelleen\",\n  \"LabelLogLevelDebug\": \"Viankorjaus\",\n  \"LabelLogLevelInfo\": \"Tiedot\",\n  \"LabelLogLevelWarn\": \"Varoitus\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Etsi uusia jaksoja tämän päivämäärän jälkeen\",\n  \"LabelLowestPriority\": \"Vähiten tärkeä\",\n  \"LabelMatchConfidence\": \"Varmuus\",\n  \"LabelMatchExistingUsersBy\": \"Vastaa olemassa olevia käyttäjiä mukaan\",\n  \"LabelMatchExistingUsersByDescription\": \"Käytetään olemassa olevien käyttäjien yhdistämiseen. Kun yhteys on muodostettu, käyttäjät saavat yksilöllisen tunnuksen SSO-palveluntarjoajaltasi\",\n  \"LabelMaxEpisodesToDownload\": \"Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Enintään # ladattavia uusia jaksoja tarkistusta kohden\",\n  \"LabelMaxEpisodesToKeep\": \"Säilytettävien jaksojen enimmäismäärä\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Jos arvona on 0, enimmäisrajaa ei ole. Kun uusi jakso ladataan automaattisesti, vanhin jakso poistetaan, jos jaksoja on yli X. Tämä poistaa vain yhden jakson uutta latauskertaa kohden.\",\n  \"LabelMediaPlayer\": \"Mediasoitin\",\n  \"LabelMediaType\": \"Mediatyyppi\",\n  \"LabelMetaTag\": \"Metatunniste\",\n  \"LabelMetaTags\": \"Metatunnisteet\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Tärkeämmät kuvailutietojen lähteet ohittavat vähemmän tärkeät lähteet\",\n  \"LabelMetadataProvider\": \"Kuvailutietojen toimittaja\",\n  \"LabelMinute\": \"Minuutti\",\n  \"LabelMinutes\": \"Minuutit\",\n  \"LabelMissing\": \"Puuttuva\",\n  \"LabelMissingEbook\": \"Sillä ei ole s-kirjaa\",\n  \"LabelMissingSupplementaryEbook\": \"Ei täydentävää s-kirjaa\",\n  \"LabelMobileRedirectURIs\": \"Sallitut mobiiliuudelleenohjaus-URI:t\",\n  \"LabelMobileRedirectURIsDescription\": \"Tämä on valkoluettelo kelvollisista uudelleenohjaus-URI:ista mobiilisovelluksille. Oletusarvo on <code>äänikirjahylly://oauth</code>, jonka voit poistaa tai täydentää ylimääräisillä URI:lla kolmannen osapuolen sovellusten integrointia varten. Asteriskin (<code>*</code>) käyttäminen ainoana merkintänä sallii minkä tahansa URI:n.\",\n  \"LabelMore\": \"Lisää\",\n  \"LabelMoreInfo\": \"Lisätietoja\",\n  \"LabelName\": \"Nimi\",\n  \"LabelNarrator\": \"Lukija\",\n  \"LabelNarrators\": \"Lukijat\",\n  \"LabelNew\": \"Uusi\",\n  \"LabelNewPassword\": \"Uusi salasana\",\n  \"LabelNewestAuthors\": \"Uusimmat tekijät\",\n  \"LabelNewestEpisodes\": \"Uusimmat jaksot\",\n  \"LabelNextBackupDate\": \"Seuraava varmuuskopiointipäivämäärä\",\n  \"LabelNextChapters\": \"Seuraavat luvut:\",\n  \"LabelNextScheduledRun\": \"Seuraava ajastettu suorittaminen\",\n  \"LabelNoApiKeys\": \"Ei API-avaimia\",\n  \"LabelNoCustomMetadataProviders\": \"Ei mukautettuja kuvailutietojen toimittajia\",\n  \"LabelNoEpisodesSelected\": \"Jaksoja ei ole valittu\",\n  \"LabelNotFinished\": \"Ei valmis\",\n  \"LabelNotStarted\": \"Ei aloitettu\",\n  \"LabelNotes\": \"Muistiinpanoja\",\n  \"LabelNotificationAppriseURL\": \"Apprise osoitteet (URL)\",\n  \"LabelNotificationAvailableVariables\": \"Käytettävissä olevat muuttujat\",\n  \"LabelNotificationBodyTemplate\": \"Runkomalli\",\n  \"LabelNotificationEvent\": \"Ilmoitustapahtuma\",\n  \"LabelNotificationTitleTemplate\": \"Otsikkomalli\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Epäonnistuneiden yritysten enimmäismäärä\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Ilmoitukset poistetaan käytöstä, jos niiden lähettäminen epäonnistuu näin monta kertaa\",\n  \"LabelNotificationsMaxQueueSize\": \"Ilmoitustapahtumajonon enimmäispituus\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Tapahtumat on rajoitettu ampumaan yksi sekunnissa. Tapahtumat ohitetaan, jos jono on enimmäiskoko. Tämä estää ilmoitusten roskapostin.\",\n  \"LabelNumberOfBooks\": \"Kirjojen määrä\",\n  \"LabelNumberOfChapters\": \"Lukujen lukumäärä:\",\n  \"LabelNumberOfEpisodes\": \"# jaksoja\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"OpenID-vaatimuksen nimi, joka sisältää lisäoikeudet sovelluksen käyttäjän toimiin, joita sovelletaan muihin kuin järjestelmänvalvojan rooleihin (<b>jos määritetty</b>). Jos vaatimus puuttuu vastauksesta, pääsy ABS:iin evätään. Jos yksittäinen vaihtoehto puuttuu, sitä käsitellään <code>false</code>-arvona. Varmista, että identiteetin tarjoajan vaatimus vastaa odotettua rakennetta:\",\n  \"LabelOpenIDClaims\": \"Jätä seuraavat vaihtoehdot tyhjiksi, jos haluat poistaa edistyneen ryhmän ja lupien määrityksen käytöstä ja määrittää sitten automaattisesti käyttäjäryhmän.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Sen OpenID-vaatimuksen nimi, joka sisältää luettelon käyttäjäryhmistä. Kutsutaan yleisesti <code>ryhmiksi</code>. <b>Jos se on määritetty</b>, sovellus jakaa automaattisesti roolit käyttäjän ryhmäjäsenyyksien perusteella, jos näiden ryhmien nimet eivät erota kirjainkoosta \\\"admin\\\", \\\"user\\\" tai \\\"guest\\\" vaatimuksessa. Vaatimuksen tulee sisältää luettelo, ja jos käyttäjä kuuluu useisiin ryhmiin, sovellus määrittää korkeinta pääsytasoa vastaavan roolin. Jos mikään ryhmä ei täsmää, pääsy evätään.\",\n  \"LabelOpenRSSFeed\": \"Avaa RSS-syöte\",\n  \"LabelOverwrite\": \"Korvaa\",\n  \"LabelPaginationPageXOfY\": \"Sivu {0}/{1}\",\n  \"LabelPassword\": \"Salasana\",\n  \"LabelPath\": \"Polku\",\n  \"LabelPermanent\": \"Pysyvä\",\n  \"LabelPermissionsAccessAllLibraries\": \"Käyttöoikeudet kaikkiin kirjastoihin\",\n  \"LabelPermissionsAccessAllTags\": \"On pääsy kaikkiin tunnisteihin\",\n  \"LabelPermissionsAccessExplicitContent\": \"Saa käyttää aikuisille tarkoitettua sisältöä\",\n  \"LabelPermissionsCreateEreader\": \"Voi luoda e-lukijan\",\n  \"LabelPermissionsDelete\": \"Voi poistaa\",\n  \"LabelPermissionsDownload\": \"Voi ladata\",\n  \"LabelPermissionsUpdate\": \"Voi päivittää\",\n  \"LabelPermissionsUpload\": \"Voi lähettää\",\n  \"LabelPersonalYearReview\": \"Vuotesi katsauksessa ({0})\",\n  \"LabelPhotoPathURL\": \"Valokuvan polku/URL-osoite\",\n  \"LabelPlayMethod\": \"Toistotapa\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Toistonopeuden lisäys-/vähennysmäärä\",\n  \"LabelPlayerChapterNumberMarker\": \"{0}/{1}\",\n  \"LabelPlaylists\": \"Soittolistat\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcastien hakualue\",\n  \"LabelPodcastType\": \"Podcastien tyyppi\",\n  \"LabelPodcasts\": \"Podcastit\",\n  \"LabelPort\": \"Portti\",\n  \"LabelPrefixesToIgnore\": \"Ohitettavat etuliitteet (kirjainkoolla ei väliä)\",\n  \"LabelPreventIndexing\": \"Estä syötteesi olemasta iTunesin ja Googlen podcast-hakemistojen indeksoinnin kohteena\",\n  \"LabelPrimaryEbook\": \"Ensisijainen s-kirja\",\n  \"LabelProgress\": \"Edistyminen\",\n  \"LabelProvider\": \"Toimittaja\",\n  \"LabelProviderAuthorizationValue\": \"Valtuutusotsikon arvo\",\n  \"LabelPubDate\": \"Julkaisupäivä\",\n  \"LabelPublishYear\": \"Julkaisuvuosi\",\n  \"LabelPublishedDate\": \"Julkaistu {0}\",\n  \"LabelPublishedDecade\": \"Julkaistu vuosikymmen\",\n  \"LabelPublishedDecades\": \"Julkaistu vuosikymmenet\",\n  \"LabelPublisher\": \"Julkaisija\",\n  \"LabelPublishers\": \"Julkaisijat\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Mukautettu omistajan sähköposti\",\n  \"LabelRSSFeedCustomOwnerName\": \"Mukautettu omistajan nimi\",\n  \"LabelRSSFeedOpen\": \"RSS Syöte Avoin\",\n  \"LabelRSSFeedPreventIndexing\": \"Estä indeksointi\",\n  \"LabelRSSFeedSlug\": \"RSS-syöte Slug\",\n  \"LabelRSSFeedURL\": \"RSS-syötteen URL-osoite\",\n  \"LabelRandomly\": \"Satunnaisesti\",\n  \"LabelReAddSeriesToContinueListening\": \"Lisää sarja uudelleen jatkaaksesi kuuntelua\",\n  \"LabelRead\": \"Lue\",\n  \"LabelReadAgain\": \"Lue uudelleen\",\n  \"LabelReadEbookWithoutProgress\": \"Lue s-kirja tallentamatta edistymistietoja\",\n  \"LabelRecentSeries\": \"Viimeisimmät sarjat\",\n  \"LabelRecentlyAdded\": \"Viimeksi lisätyt\",\n  \"LabelRecommended\": \"Suositeltu\",\n  \"LabelRedo\": \"Tee uudelleen\",\n  \"LabelRegion\": \"Alue\",\n  \"LabelReleaseDate\": \"Julkaisupäivä\",\n  \"LabelRemoveAllMetadataAbs\": \"Poista kaikki metadata.abs-tiedostot\",\n  \"LabelRemoveAllMetadataJson\": \"Poista kaikki metadata.json-tiedostot\",\n  \"LabelRemoveAudibleBranding\": \"Poista Audiblen intro ja outro kappaleista\",\n  \"LabelRemoveCover\": \"Poista kansikuva\",\n  \"LabelRemoveMetadataFile\": \"Poista metatietotiedostot kirjaston kohdekansioista\",\n  \"LabelRemoveMetadataFileHelp\": \"Poista kaikki metadata.json- ja metadata.abs-tiedostot {0} kansioistasi.\",\n  \"LabelRowsPerPage\": \"Rivejä sivulla\",\n  \"LabelSearchTerm\": \"Hakusana\",\n  \"LabelSearchTitle\": \"Etsi otsikko\",\n  \"LabelSearchTitleOrASIN\": \"Etsi otsikko tai ASIN\",\n  \"LabelSeason\": \"Kausi\",\n  \"LabelSeasonNumber\": \"Kausi #{0}\",\n  \"LabelSelectAll\": \"Valitse kaikki\",\n  \"LabelSelectAllEpisodes\": \"Valitse kaikki jaksot\",\n  \"LabelSelectEpisodesShowing\": \"Valitse {0} näytettävää jaksoa\",\n  \"LabelSelectUser\": \"Valitse käyttäjä\",\n  \"LabelSelectUsers\": \"Valitse käyttäjät\",\n  \"LabelSendEbookToDevice\": \"Lähetä s-kirja kohteeseen...\",\n  \"LabelSequence\": \"Sekvenssi\",\n  \"LabelSerial\": \"Sarja\",\n  \"LabelSeries\": \"Sarja\",\n  \"LabelSeriesName\": \"Sarjan nimi\",\n  \"LabelSeriesProgress\": \"Sarjan edistyminen\",\n  \"LabelServerLogLevel\": \"Palvelimen lokitaso\",\n  \"LabelServerYearReview\": \"Palvelimen vuosi katsauksessa ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Aseta ensisijaiseksi\",\n  \"LabelSetEbookAsSupplementary\": \"Aseta täydentäväksi\",\n  \"LabelSettingsAllowIframe\": \"Salli upottaminen iframe-kehykseen\",\n  \"LabelSettingsAudiobooksOnly\": \"Vain äänikirjat\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Tämän asetuksen käyttöönotto ohittaa s-kirjatiedostot, elleivät ne ole äänikirjakansiossa, jolloin ne asetetaan täydentäviksi s-kirjoiksi\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeuomorfinen muotoilu puisilla hyllyillä\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast-tuki\",\n  \"LabelSettingsDateFormat\": \"Päivämäärän muoto\",\n  \"LabelSettingsEnableWatcher\": \"Vahdi kirjastoja automaattisesti muutoksien varalta\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Vahdi kirjastoja automaattisesti muutoksien varalta\",\n  \"LabelSettingsEnableWatcherHelp\": \"Ottaa käyttöön kohteiden automaattisen lisäämisen ja päivityksen kun tiedostomuutoksia havaitaan. *Tarvitsee palvelimen uudelleenkäynnistyksen\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Salli komentosarjamuotoinen sisältö epubissa\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Salli epub-tiedostojen suorittaa komentosarjoja. On suositeltavaa pitää tämä asetus pois käytöstä, ellet luota epub-tiedostojen lähteeseen.\",\n  \"LabelSettingsExperimentalFeatures\": \"Kokeelliset ominaisuudet\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Kehitettävissä olevat ominaisuudet, jotka voivat hyödyntää palautettasi ja auttaa testaamisessa. Napsauta avataksesi github-keskustelun.\",\n  \"LabelSettingsFindCovers\": \"Etsi kansikuvia\",\n  \"LabelSettingsFindCoversHelp\": \"Jos äänikirjassasi ei ole kansion sisällä upotettua kantta tai kansikuvaa, skanneri yrittää löytää kannen.<br>Huomaa: Tämä pidentää skannausaikaa\",\n  \"LabelSettingsHideSingleBookSeries\": \"Piilota yksittäinen kirjasarja\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Sarjat, joissa on yksi kirja, piilotetaan sarjasivulta ja kotisivujen hyllyiltä.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Kotisivu käyttää kirjahyllynäkymää\",\n  \"LabelSettingsLibraryBookshelfView\": \"Kirjasto käyttää kirjahyllynäkymää\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Valmistumisprosentti on suurempi kuin\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Jäljellä oleva aika on alle (sekuntia)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Merkitse mediakohde valmiiksi, kun\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Ohita aiemmat kirjat Jatka sarjassa\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Jatka sarja -kotisivun hyllyssä näkyy ensimmäinen kirja, jota ei ole aloitettu sarjoissa, joissa on vähintään yksi kirja valmiina eikä yhtään kirjaa kesken. Tämän asetuksen ottaminen käyttöön jatkaa sarjaa kauimpana valmistuneesta kirjasta ensimmäisen aloittamattoman kirjan sijaan.\",\n  \"LabelSettingsParseSubtitles\": \"Jäsennä alaotsikot\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Pura alaotsikot äänikirjojen kansioiden nimistä.<br>Tekstitys on erotettava toisistaan merkillä \\\"-\\\"<br>ts. \\\"Kirjan otsikko - Tekstitys täällä\\\" on alaotsikko \\\"Tekstitys täällä\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Pidä mieluummin täsmäävät metatiedot\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Täsmäävät tiedot ohittavat kohteen tiedot käytettäessä Pikatäsmäystä. Oletuksena Pikatäsmäys täyttää vain puuttuvat tiedot.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Ohita täsmäävät kirjat, joilla on jo ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Ohita täsmäävät kirjat, joilla on jo ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Jätä etuliitteet huomioimatta lajittelussa\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"eli etuliitteelle \\\"tämän\\\" kirjan nimi \\\"Tämän kirjan nimi\\\" lajitellaan muodossa \\\"Kirjan nimi, Tämän\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Käytä neliömäisiä kirjankansia\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Käytä mieluummin neliömäisiä kansia kuin tavallisia 1,6:1 kirjankansia\",\n  \"LabelSettingsStoreCoversWithItem\": \"Säilytyskannet esineen kanssa\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Oletusarvoisesti kannet tallennetaan kansioon /metadata/items, ja tämän asetuksen ottaminen käyttöön tallentaa kannet kirjaston kohdekansioon. Vain yksi tiedosto nimeltä \\\"cover\\\" säilytetään\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Tallenna metatiedot kohteen kanssa\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Oletuksena metatietotiedostot tallennetaan kansioon /metadata/items, ja tämän asetuksen ottaminen käyttöön tallentaa metatietotiedostot kirjastosi kohdekansioihin\",\n  \"LabelSettingsTimeFormat\": \"Aikamuoto\",\n  \"LabelShare\": \"Jaa\",\n  \"LabelShareDownloadableHelp\": \"Antaa käyttäjien, joilla on jakolinkki, ladata kirjastokohteen zip-tiedoston.\",\n  \"LabelShareOpen\": \"Jaa Avoin\",\n  \"LabelShareURL\": \"Jaa URL-osoite\",\n  \"LabelShowAll\": \"Näytä kaikki\",\n  \"LabelShowSeconds\": \"Näytä sekunnit\",\n  \"LabelShowSubtitles\": \"Näytä alaotsikot\",\n  \"LabelSize\": \"Koko\",\n  \"LabelSleepTimer\": \"Uniajastin\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Nouseva\",\n  \"LabelSortDescending\": \"Laskeva\",\n  \"LabelSortPubDate\": \"Järjestä julkaisupäivän mukaan\",\n  \"LabelStart\": \"Aloita\",\n  \"LabelStartTime\": \"Aloitusaika\",\n  \"LabelStarted\": \"Aloitettu\",\n  \"LabelStartedAt\": \"Aloitettu\",\n  \"LabelStartedDate\": \"Aloitettu {0}\",\n  \"LabelStatsAudioTracks\": \"Ääniraidat\",\n  \"LabelStatsAuthors\": \"Tekijät\",\n  \"LabelStatsBestDay\": \"Paras päivä\",\n  \"LabelStatsDailyAverage\": \"Päivittäinen keskiarvo\",\n  \"LabelStatsDays\": \"Päivää\",\n  \"LabelStatsDaysListened\": \"Päivää kuunneltu\",\n  \"LabelStatsHours\": \"Tunnit\",\n  \"LabelStatsInARow\": \"peräjälkeen\",\n  \"LabelStatsItemsFinished\": \"Valmiit tuotteet\",\n  \"LabelStatsItemsInLibrary\": \"Kohteet kirjastossa\",\n  \"LabelStatsMinutes\": \"minuuttia\",\n  \"LabelStatsMinutesListening\": \"Minuuttia kuunneltu\",\n  \"LabelStatsOverallDays\": \"Päivät kokonaisuudessaan\",\n  \"LabelStatsOverallHours\": \"Tunnit kokonaisuudessaan\",\n  \"LabelStatsWeekListening\": \"Viikon aikana kuunneltu\",\n  \"LabelSubtitle\": \"Alaotsikko\",\n  \"LabelSupportedFileTypes\": \"Tuetut tiedostotyypit\",\n  \"LabelTag\": \"Tägi\",\n  \"LabelTags\": \"Tägit\",\n  \"LabelTagsAccessibleToUser\": \"Tunnisteet käyttäjän käytettävissä\",\n  \"LabelTagsNotAccessibleToUser\": \"Tunnisteet ei käyttäjien käytettävissä\",\n  \"LabelTasks\": \"Tehtävät käynnissä\",\n  \"LabelTextEditorBulletedList\": \"Luettelomerkitty luettelo\",\n  \"LabelTextEditorLink\": \"Linkki\",\n  \"LabelTextEditorNumberedList\": \"Numeroitu luettelo\",\n  \"LabelTextEditorUnlink\": \"Poista linkitys\",\n  \"LabelTheme\": \"Teema\",\n  \"LabelThemeDark\": \"Tumma\",\n  \"LabelThemeLight\": \"Kirkas\",\n  \"LabelThemeSepia\": \"Seepia\",\n  \"LabelTimeBase\": \"Aika-alusta\",\n  \"LabelTimeDurationXHours\": \"{0} tuntia\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuuttia\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekuntia\",\n  \"LabelTimeInMinutes\": \"Aika minuutteina\",\n  \"LabelTimeLeft\": \"{0} jäljellä\",\n  \"LabelTimeListened\": \"Aika kuunneltu\",\n  \"LabelTimeListenedToday\": \"Kuunneltu aika tänään\",\n  \"LabelTimeRemaining\": \"{0} jäljellä\",\n  \"LabelTimeToShift\": \"Vaihtoaika sekunteina\",\n  \"LabelTitle\": \"Nimi\",\n  \"LabelToolsEmbedMetadata\": \"Upota metatiedot\",\n  \"LabelToolsEmbedMetadataDescription\": \"Upota metatiedot äänitiedostoihin, mukaan lukien kansikuva ja luvut.\",\n  \"LabelToolsM4bEncoder\": \"M4B Enkooderi\",\n  \"LabelToolsMakeM4b\": \"Tee M4B-äänikirjatiedosto\",\n  \"LabelToolsMakeM4bDescription\": \"Luo .M4B-äänikirjatiedosto, joka sisältää upotetut metatiedot, kansikuvan ja luvut.\",\n  \"LabelToolsSplitM4b\": \"Jaa M4B MP3:ksi\",\n  \"LabelToolsSplitM4bDescription\": \"Luo MP3-tiedostoja M4B:stä, jaettuna lukujen mukaan, upotetulla metatiedolla, kansikuvalla ja luvuilla.\",\n  \"LabelTotalDuration\": \"Kokonaiskesto\",\n  \"LabelTotalTimeListened\": \"Yhteensä kuunneltu aika\",\n  \"LabelTrackFromFilename\": \"Raita tiedostonimestä\",\n  \"LabelTrackFromMetadata\": \"Raita metatiedoista\",\n  \"LabelTracks\": \"Raidat\",\n  \"LabelTracksMultiTrack\": \"Moniraitainen\",\n  \"LabelTracksNone\": \"Ei raitoja\",\n  \"LabelTracksSingleTrack\": \"Yksiraitainen\",\n  \"LabelTrailer\": \"Traileri\",\n  \"LabelType\": \"Tyyppi\",\n  \"LabelUnabridged\": \"Lyhentämätön\",\n  \"LabelUndo\": \"Kumoa\",\n  \"LabelUnknown\": \"Tuntematon\",\n  \"LabelUnknownPublishDate\": \"Tuntematon julkaisupäivämäärä\",\n  \"LabelUpdateCover\": \"Päivitä kansikuva\",\n  \"LabelUpdateCoverHelp\": \"Salli valittujen kirjojen olemassa olevien kansien päällekirjoittaminen, kun osuma löytyy\",\n  \"LabelUpdateDetails\": \"Päivitä yksityiskohdat\",\n  \"LabelUpdateDetailsHelp\": \"Salli valittujen kirjojen olemassa olevien tietojen korvaaminen, kun osuma löytyy\",\n  \"LabelUpdatedAt\": \"Päivitetty\",\n  \"LabelUploaderDragAndDrop\": \"Vedä ja pudota tiedostoja tai kansioita\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Vedä ja pudota tiedostoja\",\n  \"LabelUploaderDropFiles\": \"Pudota tiedostot\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Nouda automaattisesti otsikko, tekijä ja sarja\",\n  \"LabelUseAdvancedOptions\": \"Käytä edistyneitä vaihtoehtoja\",\n  \"LabelUseChapterTrack\": \"Käytä luvunraitaa\",\n  \"LabelUseFullTrack\": \"Käytä täyttä raitaa\",\n  \"LabelUseZeroForUnlimited\": \"Käytä 0 rajatonta varten\",\n  \"LabelUser\": \"Käyttäjä\",\n  \"LabelUsername\": \"Käyttäjätunnus\",\n  \"LabelValue\": \"Arvo\",\n  \"LabelVersion\": \"Versio\",\n  \"LabelViewBookmarks\": \"Katso kirjanmerkit\",\n  \"LabelViewChapters\": \"Katso luvut\",\n  \"LabelViewPlayerSettings\": \"Katso soittimen asetukset\",\n  \"LabelViewQueue\": \"Katso soittimen jono\",\n  \"LabelVolume\": \"Äänenvoimakkuus\",\n  \"LabelWebRedirectURLsDescription\": \"Valtuuta nämä URL-osoitteet OAuth-palveluntarjoajassasi sallimaan uudelleenohjauksen takaisin verkkosovellukseen sisäänkirjautumisen jälkeen:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Alikansio URL-osoitteiden uudelleenohjaukselle\",\n  \"LabelWeekdaysToRun\": \"Ajettavat arkipäivät\",\n  \"LabelXBooks\": \"{0} kirjaa\",\n  \"LabelXItems\": \"{0} kohdetta\",\n  \"LabelYearReviewHide\": \"Piilota vuosi arvostelussa\",\n  \"LabelYearReviewShow\": \"Näytä vuosi arvostelussa\",\n  \"LabelYourAudiobookDuration\": \"Äänikirjan kesto\",\n  \"LabelYourBookmarks\": \"Kirjanmerkkisi\",\n  \"LabelYourPlaylists\": \"Soittolistasi\",\n  \"LabelYourProgress\": \"Edistymisesi\",\n  \"MessageAddToPlayerQueue\": \"Lisää soittimen jonoon\",\n  \"MessageAppriseDescription\": \"Käyttääksesi tätä toimintoa tarvitset <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> -instanssin tai rajapinnan joka käsittelee samoja pyyntöjä. <br />Apprise rajapinnan osoite tulee olla täysi URL polku ilmoituksen lähetykseen, esim. jos rajapinta on osoitteessa <code>http://192.168.1.1:8337</code>,niin arvoksi tulee antaa <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Varmista, että käytät ASIN-tunnusta oikealta Audible-alueelta, ei Amazonista.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Vanhat API-tunnukset poistetaan tulevaisuudessa. Käytä sen sijaan <a href=\\\"/config/api-keys\\\">API-avaimia</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Käynnistä palvelin uudelleen tallennuksen jälkeen ottaaksesi OIDC-muutokset käyttöön.\",\n  \"MessageAuthenticationSecurityMessage\": \"Tunnistautumisen tietoturvaa on parannettu. Kaikkien käyttäjien tulee kirjautua sisään uudelleen.\",\n  \"MessageBackupsDescription\": \"Varmuuskopiot sisältävät käyttäjät, käyttäjien edistymisen, kirjastokohteiden tiedot, palvelinasetukset ja <code>/metadata/items</code>- ja <code>/metadata/authors</code> -kansioihin tallennetut kuvat. Varmuuskopiot <strong>eivät sisällä</strong> kirjastosi kansioihin tallennettuja tiedostoja.\",\n  \"MessageBackupsLocationEditNote\": \"Huomautus: Varmuuskopion sijainnin päivittäminen ei siirrä tai muokkaa olemassa olevia varmuuskopioita\",\n  \"MessageBackupsLocationNoEditNote\": \"Huomautus: Varmuuskopion sijainti asetetaan ympäristömuuttujan kautta, eikä sitä voi muuttaa tässä.\",\n  \"MessageBackupsLocationPathEmpty\": \"Varmuuskopiointisijainnin polku ei voi olla tyhjä\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Täytä käytössä olevat kentät tiedoilla kaikista kohteista. Kentät, joilla on useita arvoja, yhdistetään\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Täytä käytössä olevat karttayksityiskohtakentät tämän kohteen tiedoilla\",\n  \"MessageBatchQuickMatchDescription\": \"Pikatäsmäys yrittää lisätä puuttuvat kannet ja metatiedot valituille kohteille. Ota käyttöön alla olevat vaihtoehdot, jotta Pikatäsmäys korvaa olemassa olevat kannet ja/tai metatiedot.\",\n  \"MessageBookshelfNoCollections\": \"Et ole vielä tehnyt kokoelmia\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Kokoelmat ovat julkisia. Kaikki käyttäjät, joilla on pääsy kirjastoon, voivat nähdä ne.\",\n  \"MessageBookshelfNoRSSFeeds\": \"RSS-syötteitä ei ole auki\",\n  \"MessageBookshelfNoResultsForFilter\": \"Ei tuloksia suodattimelle \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Ei tuloksia kyselylle\",\n  \"MessageBookshelfNoSeries\": \"Sinulla ei ole sarjoja\",\n  \"MessageBulkChapterPattern\": \"Kuinka monta lukua haluaisit lisätä tällä numerointimallilla?\",\n  \"MessageChapterEndIsAfter\": \"Luvun loppu sijaitsee äänikirjan lopun jälkeen\",\n  \"MessageChapterErrorFirstNotZero\": \"Ensimmäisen luvun tulee alkaa nollasta\",\n  \"MessageChapterErrorStartGteDuration\": \"Epäkelvollinen aloitusaika; on oltava lyhyempi kuin äänikirjan kesto\",\n  \"MessageChapterErrorStartLtPrev\": \"Epäkelvollinen aloitusaika; on oltava suurempi tai yhtä suuri kuin edellisen luvun aloitusaika\",\n  \"MessageChapterStartIsAfter\": \"Luku alkaa äänikirjan lopun jälkeen\",\n  \"MessageChaptersNotFound\": \"Kappaleita ei löydy\",\n  \"MessageCheckingCron\": \"Tarkistetaan cronia...\",\n  \"MessageConfirmCloseFeed\": \"Oletko varma, että haluat sulkea tämän syötteen?\",\n  \"MessageConfirmDeleteApiKey\": \"Haluatko varmasti poistaa API-avaimen \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Oletko varma, että haluat poistaa varmuuskopion {0}:lle?\",\n  \"MessageConfirmDeleteDevice\": \"Oletko varma, että haluat poistaa s-lukulaitteen \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Tämä poistaa tiedoston tiedostojärjestelmästäsi. Oletko varma?\",\n  \"MessageConfirmDeleteLibrary\": \"Oletko varma, että haluat poistaa kirjaston \\\"{0}\\\" pysyvästi?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Tämä poistaa kirjastokohteen tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Tämä poistaa {0} kirjastokohdetta tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Oletko varma, että haluat poistaa mukautetun metatietojen tarjoajan \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Oletko varma, että haluat poistaa tämän ilmoituksen?\",\n  \"MessageConfirmDeleteSession\": \"Oletko varma, että haluat poistaa tämän istunnon?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Oletko varma, että haluat upottaa metatiedot {0} äänitiedostoihin?\",\n  \"MessageConfirmForceReScan\": \"Oletko varma, että haluat pakottaa uudelleenskannauksen?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Oletko varma, että haluat merkitä kaikki jaksot päättyneiksi?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Oletko varma, että haluat merkitä kaikki jaksot ei-valmiiksi?\",\n  \"MessageConfirmMarkItemFinished\": \"Oletko varma, että haluat merkitä \\\"{0}\\\":n valmiiksi?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Oletko varma, että haluat merkitä \\\"{0}\\\":n ei-valmiiksi?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Oletko varma, että haluat merkitä kaikki tämän sarjan kirjat valmiiksi?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Oletko varma, että haluat merkitä kaikki tämän sarjan kirjat ei-valmiiksi?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Käynnistetäänkö tämä ilmoitus testitiedoilla?\",\n  \"MessageConfirmPurgeCache\": \"'Tyhjennä välimuisti' poistaa koko hakemiston sijainnilla <code>/metadata/cache</code>. <br /><br />Oletko varma, että haluat poistaa välimuistihakemiston?\",\n  \"MessageConfirmPurgeItemsCache\": \"'Tyhjennä kohteiden välimuisti' poistaa koko hakemiston sijainnilla <code>/metadata/cache/items</code>.<br />Oletko varma?\",\n  \"MessageConfirmQuickEmbed\": \"Varoitus! Pikaupottaminen ei varmuuskopioi äänitiedostojasi. Varmista, että sinulla on varmuuskopio äänitiedostoistasi. <br><br>Haluatko jatkaa?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Jaksojen pikatäsmääminen korvaa tiedot, jos vastaavuus löytyy. Vain täsmäämättömät jaksot päivitetään. Oletko varma?\",\n  \"MessageConfirmReScanLibraryItems\": \"Oletko varma, että haluat skannata uudelleen {0} kohdetta?\",\n  \"MessageConfirmRemoveAllChapters\": \"Oletko varma, että haluat poistaa kaikki jaksot?\",\n  \"MessageConfirmRemoveAuthor\": \"Oletko varma, että haluat poistaa tekijän \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Oletko varma, että haluat poistaa kokoelman \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Oletko varma, että haluat poistaa jakson \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Huomioi: Tämä ei poista äänitiedostoa, ellei \\\"Poista tiedosto pysyvästi\\\" -asetusta ole valittuna\",\n  \"MessageConfirmRemoveEpisodes\": \"Oletko varma, että haluat poistaa {0} jaksoa?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Oletko varma, että haluat poistaa {0} kuuntelukertaa?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Oletko varma, että haluat poistaa kaikki metadata.{0}-tiedostot kirjaston kohdekansioista?\",\n  \"MessageConfirmRemoveNarrator\": \"Oletko varma, että haluat poistaa kertojan \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Oletko varma, että haluat poistaa soittolistan \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Oletko varma, että haluat nimetä lajityypin \\\"{0}\\\" uudelleen \\\"{1}\\\":ksi kaikille kohteille?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Huomautus: Tämä lajityyppi on jo olemassa, joten ne yhdistetään.\",\n  \"MessageConfirmRenameGenreWarning\": \"Varoitus! Samanlainen lajityyppi eri kotelolla on jo olemassa \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Oletko varma, että haluat nimetä tunnisteen \\\"{0}\\\" uudelleen \\\"{1}\\\":ksi kaikille kohteille?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Huomautus: Tämä tunniste on jo olemassa, joten ne yhdistetään.\",\n  \"MessageConfirmRenameTagWarning\": \"Varoitus! Samanlainen tunniste eri kotelolla on jo olemassa \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Oletko varma, että haluat nollata edistymisesi?\",\n  \"MessageConfirmSendEbookToDevice\": \"Oletko varma, että haluat lähettää {0} s-kirjan \\\"{1}\\\" laitteeseen \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Oletko varma, että haluat poistaa tämän käyttäjän linkityksen OpenID:stä?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} kuunneltua päivää viime vuonna\",\n  \"MessageDownloadingEpisode\": \"Ladataan jaksoa\",\n  \"MessageDragFilesIntoTrackOrder\": \"Vedä tiedostot oikeaan raitojen järjestykseen\",\n  \"MessageEmbedFailed\": \"Upotus epäonnistui!\",\n  \"MessageEmbedFinished\": \"Upotus valmis!\",\n  \"MessageEmbedQueue\": \"Jonossa metatietojen upottamista varten ({0} jonossa)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} jaksoa on latausjonossa\",\n  \"MessageEreaderDevices\": \"S-kirjojen toimituksen varmistamiseksi sinun on ehkä lisättävä yllä oleva sähköpostiosoite kelvolliseksi lähettäjäksi jokaiselle alla luetellulle laitteelle.\",\n  \"MessageFeedURLWillBe\": \"Syötteen URL tulee olemaan {0}\",\n  \"MessageFetching\": \"Haetaan...\",\n  \"MessageForceReScanDescription\": \"skannaa kaikki tiedostot uudelleen kuten uusi tarkistus. Äänitiedoston ID3-tunnisteet, OPF-tiedostot ja tekstitiedostot skannataan uusina.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} kuunnellaan</strong> on {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Ei kuuntelujaksoja {0}\",\n  \"MessageImportantNotice\": \"Tärkeä huomautus!\",\n  \"MessageInsertChapterBelow\": \"Syötä luku alle\",\n  \"MessageInvalidAsin\": \"Virheellinen ASIN\",\n  \"MessageItemsSelected\": \"{0} kohdetta valittu\",\n  \"MessageItemsUpdated\": \"{0} kohdetta päivitetty\",\n  \"MessageJoinUsOn\": \"Liity meihin\",\n  \"MessageLoading\": \"Ladataan...\",\n  \"MessageLoadingFolders\": \"Ladataan kansioita...\",\n  \"MessageLogsDescription\": \"Lokitiedot tallennetaan kansioon <code>/metadata/logs</code> JSON-tiedostoina. Kaatumislokit tallennetaan kansioon <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B epäonnistui!\",\n  \"MessageM4BFinished\": \"M4B valmis!\",\n  \"MessageMapChapterTitles\": \"Kartoita lukujen otsikot olemassa oleviin äänikirjan lukuihin muuttamatta aikaleimoja\",\n  \"MessageMarkAllEpisodesFinished\": \"Merkitse kaikki jaksot päättyneiksi\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Merkitse kaikki jaksot ei-päättyneiksi\",\n  \"MessageMarkAsFinished\": \"Merkitse valmiiksi\",\n  \"MessageMarkAsNotFinished\": \"Merkitse Ei-päättyneiksi\",\n  \"MessageMatchBooksDescription\": \"yrittää yhdistää kirjaston kirjoja valitun hakupalvelun kirjaan ja täyttää tyhjät tiedot ja kansikuvan. Ei korvaa yksityiskohtia.\",\n  \"MessageNoAudioTracks\": \"Ei ääniraitoja\",\n  \"MessageNoAuthors\": \"Ei tekijöitä\",\n  \"MessageNoBackups\": \"Ei varmuuskopioita\",\n  \"MessageNoBookmarks\": \"Ei kirjanmerkkejä\",\n  \"MessageNoChapters\": \"Ei kappaleita\",\n  \"MessageNoCollections\": \"Ei kokoelmia\",\n  \"MessageNoCoversFound\": \"Kansikuvia ei löydetty\",\n  \"MessageNoDescription\": \"Ei kuvausta\",\n  \"MessageNoDevices\": \"Ei laitteita\",\n  \"MessageNoDownloadsInProgress\": \"Ei latauksia tällä hetkellä meneillään\",\n  \"MessageNoDownloadsQueued\": \"Ei latauksia jonossa\",\n  \"MessageNoEpisodeMatchesFound\": \"Jaksoosumia ei löytynyt\",\n  \"MessageNoEpisodes\": \"Ei jaksoja\",\n  \"MessageNoFoldersAvailable\": \"Ei kansioita saatavilla\",\n  \"MessageNoGenres\": \"Ei lajityyppejä\",\n  \"MessageNoIssues\": \"Ei vikoja\",\n  \"MessageNoItems\": \"Ei kohteita\",\n  \"MessageNoItemsFound\": \"Kohteita ei löytynyt\",\n  \"MessageNoListeningSessions\": \"Ei kuunteluistuntoja\",\n  \"MessageNoLogs\": \"Ei lokeja\",\n  \"MessageNoMediaProgress\": \"Ei median edistymistä\",\n  \"MessageNoNotifications\": \"Ei ilmoituksia\",\n  \"MessageNoPodcastFeed\": \"Epäkelvollinen podcast: Ei syötettä\",\n  \"MessageNoPodcastsFound\": \"Podcasteja ei löytynyt\",\n  \"MessageNoResults\": \"Ei tuloksia\",\n  \"MessageNoSearchResultsFor\": \"Ei hakutuloksia \\\"{0}\\\":lle\",\n  \"MessageNoSeries\": \"Ei sarjaa\",\n  \"MessageNoTags\": \"Ei tunnisteita\",\n  \"MessageNoTasksRunning\": \"Ei käynnissä olevia tehtäviä\",\n  \"MessageNoUpdatesWereNecessary\": \"Päivityksiä ei tarvittu\",\n  \"MessageNoUserPlaylists\": \"Sinulla ei ole soittolistoja\",\n  \"MessageNoUserPlaylistsHelp\": \"Soittolistat ovat yksityisiä. Vain ne luonut käyttäjä näkee ne.\",\n  \"MessageNotYetImplemented\": \"Ei vielä toteutettu\",\n  \"MessageOpmlPreviewNote\": \"Huomautus: Tämä on esikatselu jäsennetystä OPML-tiedostosta. Varsinainen podcastin nimi tullaan ottamaan RSS-syötteestä.\",\n  \"MessageOr\": \"tai\",\n  \"MessagePauseChapter\": \"Keskeytä luvun toisto\",\n  \"MessagePlayChapter\": \"Kuuntele luvun alku\",\n  \"MessagePlaylistCreateFromCollection\": \"Luo soittolista kokoelmasta\",\n  \"MessagePleaseWait\": \"Ole hyvä ja odota...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcastilla ei ole RSS-syötteen URL-osoitetta, jota voitaisiin käyttää täsmäämiseen\",\n  \"MessagePodcastSearchField\": \"Syötä hakutermi tai RSS-syötteen URL-osoite\",\n  \"MessageQuickEmbedInProgress\": \"Pikaupottaminen meneillään\",\n  \"MessageQuickEmbedQueue\": \"Jonotettu pikaupotusta varten ({0} jonossa)\",\n  \"MessageQuickMatchAllEpisodes\": \"Pikatäsmää kaikki jaksot\",\n  \"MessageQuickMatchDescription\": \"Täytä tyhjän tuotteen tiedot ja kansi ensimmäisellä täsmäävällä tuloksella {0}:sta. Ei korvaa tietoja, ellei 'Pidä mieluummin täsmäävät metatiedot'-palvelinasetus on otettu käyttöön.\",\n  \"MessageRemoveChapter\": \"Poista luku\",\n  \"MessageRemoveEpisodes\": \"Poista {0} jakso(a)\",\n  \"MessageRemoveFromPlayerQueue\": \"Poista soittimen jonosta\",\n  \"MessageRemoveUserWarning\": \"Oletko varma, että haluat poistaa käyttäjän \\\"{0}\\\" pysyvästi?\",\n  \"MessageReportBugsAndContribute\": \"Ilmoita virheistä, toivo ominaisuuksia ja osallistu\",\n  \"MessageResetChaptersConfirm\": \"Oletko varma, että haluat nollata luvut ja kumota tekemäsi muutokset?\",\n  \"MessageRestoreBackupConfirm\": \"Oletko varma, että haluat palauttaa varmuuskopion, joka on luotu\",\n  \"MessageRestoreBackupWarning\": \"Varmuuskopion palauttaminen korvaa koko /config:ssa sijaitsevan tietokannan, ja kansikuvat /metadata/items & /metadata/authors:ssa.<br /><br />Varmuuskopiot eivät muuta kirjastokansioissasi olevia tiedostoja. Jos olet ottanut käyttöön palvelinasetuksissa kansikuvien ja metatietojen tallentamisen kirjaston kansioihin, niitä ei varmuuskopioida tai korvata.<br /><br />Kaikki palvelintasi käyttävät asiakkaat virkistetään automaattisesti.\",\n  \"MessageScheduleLibraryScanNote\": \"Suurimmalle osaa käyttäjistä on suositeltavaa jättää tämä ominaisuus pois päältä ja \\\"Tarkkaile kirjaston muutoksia automaattisesti\\\" -asetus pidetään käytössä - se havaitsee muutokset kirjastokansioissasi automaattisesti. Ota tämä ominaisuus käyttöön, jos \\\"Tarkkaile kirjaston muutoksia automaattisesti\\\" ei toimi tiedostojärjestelmässäsi (kuten NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Suorita joka {0} klo {1}\",\n  \"MessageSearchResultsFor\": \"Hakutulokset haulle\",\n  \"MessageSelected\": \"{0} valittuna\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Sarjan sekvenssi ei voi sisältää välilyöntejä\",\n  \"MessageServerCouldNotBeReached\": \"Palvelimelle ei saatu yhteyttä\",\n  \"MessageSetChaptersFromTracksDescription\": \"Aseta luvut käyttämällä kutakin äänitiedostoa lukuna ja luvun otsikkoa äänitiedoston nimenä\",\n  \"MessageShareExpirationWillBe\": \"Umpeutuminen on <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Umpeutuu {0}:n kuluttua\",\n  \"MessageShareURLWillBe\": \"Jaa URL-osoite on <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Aloitetaanko \\\"{0}\\\":n toisto klo {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Äänitiedosto \\\"{0}\\\" ei ole kirjoitettava\",\n  \"MessageTaskCanceledByUser\": \"Tehtävä peruttu käyttäjän toimesta\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Ladataan jaksoa \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Upotetaan metatiedot\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Upotetaan metatiedot äänikirjaan \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Koodaus M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Koodataan äänikirjaa \\\"{0}\\\" yhdeksi m4b-tiedostoksi\",\n  \"MessageTaskFailed\": \"Epäonnistunut\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Äänitiedoston \\\"{0}\\\" varmuuskopiointi epäonnistui\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Välimuistihakemiston luominen epäonnistui\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Metatietojen upottaminen tiedostoon \\\"{0}\\\" epäonnistui\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Äänitiedostojen yhdistäminen epäonnistui\",\n  \"MessageTaskFailedToMoveM4bFile\": \"m4b-tiedoston siirtäminen epäonnistui\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Metatietotiedoston kirjoittaminen epäonnistui\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Vastaavat kirjat kirjastossa \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Ei skannattavia tiedostoja\",\n  \"MessageTaskOpmlImport\": \"OPML-tuonti\",\n  \"MessageTaskOpmlImportDescription\": \"Luodaan podcasteja {0} RSS-syötteistä\",\n  \"MessageTaskOpmlImportFeed\": \"OPML-tuontisyöte\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Tuodaan RSS-syötettä \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Podcast-syötteen saaminen epäonnistui\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Luodaan podcastia \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast on jo olemassa polulla\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Podcastin luominen epäonnistui\",\n  \"MessageTaskOpmlImportFinished\": \"Lisätty {0} podcastia\",\n  \"MessageTaskOpmlParseFailed\": \"OPML-tiedoston jäsentäminen epäonnistui\",\n  \"MessageTaskOpmlParseFastFail\": \"Epäkelvollinen OPML-tiedoston <opml>-tunnistetta ei löytynyt tai <outline>-tunnistetta ei löytynyt\",\n  \"MessageTaskOpmlParseNoneFound\": \"Syötteitä ei löytynyt OPML-tiedostosta\",\n  \"MessageTaskScanItemsAdded\": \"{0} lisätty\",\n  \"MessageTaskScanItemsMissing\": \"{0} puuttuu\",\n  \"MessageTaskScanItemsUpdated\": \"{0} päivitetty\",\n  \"MessageTaskScanNoChangesNeeded\": \"Muutoksia ei tarvita\",\n  \"MessageTaskScanningFileChanges\": \"Tarkastetaan tiedoston muutoksia \\\"{0}\\\":sta\",\n  \"MessageTaskScanningLibrary\": \"Tarkastetaan kirjastoa \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Kohdehakemisto ei ole kirjoitettava\",\n  \"MessageThinking\": \"Ajatellaan...\",\n  \"MessageUploaderItemFailed\": \"Lataaminen ulospäin epäonnistui\",\n  \"MessageUploaderItemSuccess\": \"Onnistuneesti ladattu! ulospäin!\",\n  \"MessageUploading\": \"Ladataan! ulospäin...\",\n  \"MessageValidCronExpression\": \"Kelvollinen cron-lauseke\",\n  \"MessageWatcherIsDisabledGlobally\": \"Kansiotarkkailu on poistettu käytöstä kaikkialla palvelimen asetuksissa\",\n  \"MessageXLibraryIsEmpty\": \"{0} Kirjasto on tyhjä!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Äänikirjasi kesto on pidempi kuin löydetty kesto\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Äänikirjasi kesto on lyhyempi kuin löydetty kesto\",\n  \"NoteChangeRootPassword\": \"Käyttäjä root on ainoa käyttäjä, jolla voi olla tyhjä salasana\",\n  \"NoteChapterEditorTimes\": \"Huomautus: Ensimmäisen luvun aloitusajan on oltava 0:00 ja viimeisen luvun aloitusaika ei saa ylittää tätä äänikirjan kestoa.\",\n  \"NoteFolderPicker\": \"Huomautus: jo kartoitettuja kansioita ei näytetä\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Varoitus: Useimmat podcast-sovellukset edellyttävät, että RSS-syötteen URL-osoite käyttää HTTPS:a\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Varoitus: yhdellä tai useammalla jaksollasi ei ole julkaisupäivämäärää. Jotkut podcast-sovellukset vaativat tämän.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mediatiedostoja sisältävät kansiot käsitellään erillisinä kirjastokohteina.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Jos ladataan luospäin vain äänitiedostoja, silloin jokainen äänitiedosto käsitellään erillisenä äänikirjana.\",\n  \"NoteUploaderUnsupportedFiles\": \"Ei-tuetut tiedostot ohitetaan. Kansiota valittaessa tai pudottaessa, muut tiedostot, jotka eivät ole kohdekansiossa, ohitetaan.\",\n  \"NotificationOnBackupCompletedDescription\": \"Laukaistu, kun varmuuskopiointi on valmis\",\n  \"NotificationOnBackupFailedDescription\": \"Laukaistu, kun varmuuskopiointi epäonnistuu\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Laukaistu, kun podcast-jakso ladataan automaattisesti\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Laukaistaan, kun automaattiset jaksolataukset poistetaan käytöstä liian monen epäonnistuneen yrityksen vuoksi\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Laukaistaan, kun RRS-syötteen pyyntö epäonnistuu automaattisessa jaksolatauksessa\",\n  \"NotificationOnTestDescription\": \"Tapahtuma ilmoitusjärjestelmän testaamista varten\",\n  \"PlaceholderBulkChapterInput\": \"Syötä luvun otsikko tai käytä numerointia (esim. 'Episodi 1', 'Luku 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Uusi kokoelman nimi\",\n  \"PlaceholderNewFolderPath\": \"Uusi kansion polku\",\n  \"PlaceholderNewPlaylist\": \"Uusi soittolistan nimi\",\n  \"PlaceholderSearch\": \"Haku...\",\n  \"PlaceholderSearchEpisode\": \"Haku jaksosta..\",\n  \"StatsAuthorsAdded\": \"tekijät lisätty\",\n  \"StatsBooksAdded\": \"kirjat lisätty\",\n  \"StatsBooksAdditional\": \"Jotkut lisäykset sisältävät…\",\n  \"StatsBooksFinished\": \"kirjat päättyneet\",\n  \"StatsBooksFinishedThisYear\": \"Jotkut kirjat päättyneet tänä vuonna…\",\n  \"StatsBooksListenedTo\": \"kuunnellut kirjat\",\n  \"StatsCollectionGrewTo\": \"Kirjakokoelmasi kasvoi asti…\",\n  \"StatsSessions\": \"istunnot\",\n  \"StatsSpentListening\": \"kuunteluun käytetty\",\n  \"StatsTopAuthor\": \"HUIPPUTEKIJÄ\",\n  \"StatsTopAuthors\": \"HUIPPUTEKIJÄT\",\n  \"StatsTopGenre\": \"HUIPPUTYYLILAJI\",\n  \"StatsTopGenres\": \"HUIPPUTYYLILAJIT\",\n  \"StatsTopMonth\": \"HUIPPUKUUKAUSI\",\n  \"StatsTopNarrator\": \"HUIPPUKERTOJA\",\n  \"StatsTopNarrators\": \"HUIPPUKERTOJAT\",\n  \"StatsTotalDuration\": \"Kokonaiskestolla…\",\n  \"StatsYearInReview\": \"VUOSI KATSAUKSESSA\",\n  \"ToastAccountUpdateSuccess\": \"Tili päivitetty\",\n  \"ToastAppriseUrlRequired\": \"Arvon tulee olla Apprise URL\",\n  \"ToastAsinRequired\": \"ASIN vaaditaan\",\n  \"ToastAuthorImageRemoveSuccess\": \"Tekijän kuva poistettu\",\n  \"ToastAuthorNotFound\": \"Tekijää \\\"{0}\\\" ei löydy\",\n  \"ToastAuthorRemoveSuccess\": \"Tekijä poistettu\",\n  \"ToastAuthorSearchNotFound\": \"Tekijää ei löydy\",\n  \"ToastAuthorUpdateMerged\": \"Tekijä yhdistetty\",\n  \"ToastAuthorUpdateSuccess\": \"Tekijä päivitetty\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Tekijä päivitetty (kuvaa ei löytynyt)\",\n  \"ToastBackupAppliedSuccess\": \"Varmuuskopiointi sovellettu\",\n  \"ToastBackupCreateFailed\": \"Varmuuskopion luominen epäonnistui\",\n  \"ToastBackupCreateSuccess\": \"Varmuuskopio luotu\",\n  \"ToastBackupDeleteFailed\": \"Varmuuskopion poistaminen epäonnistui\",\n  \"ToastBackupDeleteSuccess\": \"Varmuuskopio poistettu\",\n  \"ToastBackupInvalidMaxKeep\": \"Epäkelvollinen määrä säilytettäviä varmuuskopioita\",\n  \"ToastBackupInvalidMaxSize\": \"Epäkelvollinen varmuuskopion enimmäiskoko\",\n  \"ToastBackupRestoreFailed\": \"Varmuuskopion palauttaminen epäonnistui\",\n  \"ToastBackupUploadFailed\": \"Varmuuskopion lataaminen ulospäin epäonnistui\",\n  \"ToastBackupUploadSuccess\": \"Varmuuskopio ladattu ulospäin\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Kohteisiin sovelletut yksityiskohdat\",\n  \"ToastBatchDeleteFailed\": \"Eräpoisto epäonnistui\",\n  \"ToastBatchDeleteSuccess\": \"Eräpoisto onnistui\",\n  \"ToastBatchQuickMatchFailed\": \"Erän pikatäsmäys epäonnistui!\",\n  \"ToastBatchQuickMatchStarted\": \"{0} kirjan erän pikatäsmäys aloitettu!\",\n  \"ToastBatchUpdateFailed\": \"Eräpäivitys epäonnistui\",\n  \"ToastBatchUpdateSuccess\": \"Eräpäivitys onnistui\",\n  \"ToastBookmarkCreateFailed\": \"Kirjanmerkin luominen epäonnistui\",\n  \"ToastBookmarkCreateSuccess\": \"Kirjanmerkki lisätty\",\n  \"ToastBookmarkRemoveSuccess\": \"Kirjanmerkki poistettu\",\n  \"ToastBulkChapterInvalidCount\": \"Syötä numero 1 ja 150 välillä\",\n  \"ToastCachePurgeFailed\": \"Välimuistin tyhjentäminen epäonnistui\",\n  \"ToastCachePurgeSuccess\": \"Välimuisti tyhjennetty onnistuneesti\",\n  \"ToastChapterLocked\": \"Luku on lukittu.\",\n  \"ToastChapterStartTimeAdjusted\": \"Luvun aloitusaikaa on säädetty {0} sekunnilla\",\n  \"ToastChaptersAllLocked\": \"Kaikki luvut ovat lukittuina. Avaa lukuja vaihtaaksesi niiden aikoja.\",\n  \"ToastChaptersHaveErrors\": \"Luvuissa on virheitä\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Virheellinen siirtomäärä. Viimeisen luvun aloitusaika ylittäisi tämän äänikirjan keston.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Virheellinen siirtomäärä. Ensimmäisen luvun pituudeksi tulisi nolla tai negatiivinen arvo, ja toinen luku kirjoittaisi sen päälle. Kasvata toisen luvun aloitusaikaa.\",\n  \"ToastChaptersMustHaveTitles\": \"Lukuilla on oltava otsikot\",\n  \"ToastChaptersRemoved\": \"Luvut poistettu\",\n  \"ToastChaptersUpdated\": \"Luvut päivitetty\",\n  \"ToastCollectionItemsAddFailed\": \"Kohteen/kohteiden lisääminen kokoelmaan epäonnistui\",\n  \"ToastCollectionRemoveSuccess\": \"Kokoelma poistettu\",\n  \"ToastCollectionUpdateSuccess\": \"Kokoelma päivitetty\",\n  \"ToastConnectionNotAvailable\": \"Verkkoyhteyttä ei saatavilla. Yritä hetken päästä uudelleen\",\n  \"ToastCoverSearchFailed\": \"Kansikuvan haku epäonnistui\",\n  \"ToastCoverUpdateFailed\": \"Kansikuvan päivitys epäonnistui\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Päivämäärä ja aika ovat epäkelvolliset tai puutteelliset\",\n  \"ToastDeleteFileFailed\": \"Tiedoston poistaminen epäonnistui\",\n  \"ToastDeleteFileSuccess\": \"Tiedosto poistettu\",\n  \"ToastDeviceAddFailed\": \"Laitteen lisääminen epäonnistui\",\n  \"ToastDeviceNameAlreadyExists\": \"Tämän niminen sähköinen lukulaite on jo olemassa\",\n  \"ToastDeviceTestEmailFailed\": \"Testisähköpostin lähettäminen epäonnistui\",\n  \"ToastDeviceTestEmailSuccess\": \"Testisähköposti lähetetty\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Sähköpostiasetukset päivitetty\",\n  \"ToastEncodeCancelFailed\": \"Koodauksen peruuttaminen epäonnistui\",\n  \"ToastEncodeCancelSucces\": \"Koodaaminen peruutettu\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Jonon tyhjentäminen epäonnistui\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Jakson latausjono tyhjennetty\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} jaksoa päivitetty\",\n  \"ToastErrorCannotShare\": \"Ei voi jakaa alkuperäisesti tällä laitteella\",\n  \"ToastFailedToCreate\": \"Luonti epäonnistui\",\n  \"ToastFailedToDelete\": \"Poisto epäonnistui\",\n  \"ToastFailedToLoadData\": \"Tietojen lataaminen epäonnistui\",\n  \"ToastFailedToMatch\": \"Vastaaminen epäonnistui\",\n  \"ToastFailedToShare\": \"Jakaminen epäonnistui\",\n  \"ToastFailedToUpdate\": \"Päivittäminen epäonnistui\",\n  \"ToastInvalidImageUrl\": \"Epäkelvollinen kuvan URL-osoite\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Ladattavien jaksojen enimmäismäärä on epäkelvollinen\",\n  \"ToastInvalidUrl\": \"Epäkelvollinen URL-osoite\",\n  \"ToastInvalidUrls\": \"Yksi tai useampi URL on virheellinen\",\n  \"ToastItemCoverUpdateSuccess\": \"Kohteen kansikuva päivitetty\",\n  \"ToastItemDeletedFailed\": \"Kohteen poistaminen epäonnistui\",\n  \"ToastItemDeletedSuccess\": \"Poistettu kohde\",\n  \"ToastItemDetailsUpdateSuccess\": \"Tuotteen yksityiskohdat päivitetty\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Valmiiksi merkitseminen epäonnistui\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Kohde merkitty Päättyneeksi\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Valmiiksi merkitsemisen poisto epäonnistui\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Kohde merkitty Ei-päättyneeksi\",\n  \"ToastItemUpdateSuccess\": \"Kohde päivitetty\",\n  \"ToastLibraryCreateFailed\": \"Kirjaston luominen epäonnistui\",\n  \"ToastLibraryCreateSuccess\": \"Kirjasto \\\"{0}\\\" luotu\",\n  \"ToastLibraryDeleteFailed\": \"Kirjaston poistaminen epäonnistui\",\n  \"ToastLibraryDeleteSuccess\": \"Kirjasto poistettu\",\n  \"ToastLibraryScanFailedToStart\": \"Skannauksen käynnistäminen epäonnistui\",\n  \"ToastLibraryScanStarted\": \"Kirjaston skannaus käynnistetty\",\n  \"ToastLibraryUpdateSuccess\": \"Kirjasto \\\"{0}\\\" päivitetty\",\n  \"ToastMatchAllAuthorsFailed\": \"Kaikkia tekijöitä ei voitu vaastattaa\",\n  \"ToastMetadataFilesRemovedError\": \"Virhe poistettaessa metadata.{0}-tiedostot\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"metadata.{0}-tiedostoja ei löytynyt kirjastosta\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Ei metadata.{0}-tiedostoja poistettu\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1}-tiedostoa poistettu\",\n  \"ToastMustHaveAtLeastOnePath\": \"On oltava vähintään yksi polku\",\n  \"ToastNameEmailRequired\": \"Nimi ja sähköpostiosoite vaaditaan\",\n  \"ToastNameRequired\": \"Nimi vaaditaan\",\n  \"ToastNewApiKeyUserError\": \"Täytyy valita käyttäjä\",\n  \"ToastNewEpisodesFound\": \"{0} uutta jaksoa löydetty\",\n  \"ToastNewUserCreatedFailed\": \"Tilin \\\"{0}\\\" luominen epäonnistui\",\n  \"ToastNewUserCreatedSuccess\": \"Uusi tili luotu\",\n  \"ToastNewUserLibraryError\": \"On valittava vähintään yksi kirjasto\",\n  \"ToastNewUserPasswordError\": \"On oltava salasana; vain käyttäjällä root voi olla tyhjä salasana\",\n  \"ToastNewUserTagError\": \"On valittava vähintään yksi tunniste\",\n  \"ToastNewUserUsernameError\": \"Syötä käyttäjänimi\",\n  \"ToastNoNewEpisodesFound\": \"Uusia jaksoja ei löytynyt\",\n  \"ToastNoRSSFeed\": \"Podcastilla ei ole RSS-syötettä\",\n  \"ToastNoUpdatesNecessary\": \"Päivityksiä ei tarvita\",\n  \"ToastNotificationCreateFailed\": \"Ilmoituksen luominen epäonnistui\",\n  \"ToastNotificationDeleteFailed\": \"Ilmoituksen poistaminen epäonnistui\",\n  \"ToastNotificationFailedMaximum\": \"Epäonnistuneiden yritysten enimmäismäärän on oltava >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Ilmoitusjonon enimmäismäärä on oltava >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Ilmoitusasetukset päivitetty\",\n  \"ToastNotificationTestTriggerFailed\": \"Testiilmoituksen laukaiseminen epäonnistui\",\n  \"ToastNotificationTestTriggerSuccess\": \"Laukaistiin testiilmoitus\",\n  \"ToastNotificationUpdateSuccess\": \"Ilmoitus päivitetty\",\n  \"ToastPlaylistCreateFailed\": \"Soittolistan luominen epäonnistui\",\n  \"ToastPlaylistCreateSuccess\": \"Soittolista luotu\",\n  \"ToastPlaylistRemoveSuccess\": \"Soittolista poistettu\",\n  \"ToastPlaylistUpdateSuccess\": \"Soittolista päivitetty\",\n  \"ToastPodcastCreateFailed\": \"Podcastin luominen epäonnistui\",\n  \"ToastPodcastCreateSuccess\": \"Podcastin luominen onnistui\",\n  \"ToastPodcastEpisodeUpdated\": \"Episodi päivitetty\",\n  \"ToastPodcastGetFeedFailed\": \"Podcast-syötteen saaminen epäonnistui\",\n  \"ToastPodcastNoEpisodesInFeed\": \"RSS-syötteestä ei löytynyt jaksoja\",\n  \"ToastPodcastNoRssFeed\": \"Podcastilla ei ole RSS-syötettä\",\n  \"ToastProgressIsNotBeingSynced\": \"Edistystä ei synkronoida, aloita toisto uudelleen\",\n  \"ToastProviderCreatedFailed\": \"Palveluntarjoajan lisääminen epäonnistui\",\n  \"ToastProviderCreatedSuccess\": \"Uusi palveluntarjoaja lisätty\",\n  \"ToastProviderNameAndUrlRequired\": \"Nimi ja URL-osoite vaaditaan\",\n  \"ToastProviderRemoveSuccess\": \"Palveluntarjoaja poistettu\",\n  \"ToastRSSFeedCloseFailed\": \"RSS syötteen sulkeminen epäonnistui\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS syöte suljettu\",\n  \"ToastRemoveFailed\": \"Poistaminen epäonnistui\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Kohteen poistaminen kokoelmasta epäonnistui\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Kohde poistettu kokoelmasta\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Vikoja sisältävien kirjastokohteiden poistaminen epäonnistui\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Vikoja sisältäviä kirjastokohteita poistettu\",\n  \"ToastRenameFailed\": \"Uudelleennimeäminen epäonnistui\",\n  \"ToastRescanFailed\": \"Uudelleenskannaus {0}n kohdalla epäonnistui\",\n  \"ToastRescanRemoved\": \"Uudelleenskannauksen täydellinen kohde poistettiin\",\n  \"ToastRescanUpToDate\": \"Uudelleenskannauksen täydellinen kohde oli ajan tasalla\",\n  \"ToastRescanUpdated\": \"Uudelleenskannauksen täydellinen kohde päivitettiin\",\n  \"ToastScanFailed\": \"Kirjastokohteen skannaaminen epäonnistui\",\n  \"ToastSelectAtLeastOneUser\": \"Valitse ainakin yksi käyttäjä\",\n  \"ToastSendEbookToDeviceFailed\": \"S-kirjan lähettäminen laitteeseen epäonnistui\",\n  \"ToastSendEbookToDeviceSuccess\": \"S-kirja lähetetty laitteeseen \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Ei voi lisätä kahta samannimistä sarjaa\",\n  \"ToastSeriesUpdateFailed\": \"Sarjan päivittäminen epäonnistui\",\n  \"ToastSeriesUpdateSuccess\": \"Sarjan päivittäminen onnistui\",\n  \"ToastServerSettingsUpdateSuccess\": \"Palvelimen asetukset päivitetty\",\n  \"ToastSessionCloseFailed\": \"Istunnon sulkeminen epäonnistui\",\n  \"ToastSessionDeleteFailed\": \"Istunnon poistaminen epäonnistui\",\n  \"ToastSessionDeleteSuccess\": \"Istunto poistettu\",\n  \"ToastSleepTimerDone\": \"Uniajastin tehty... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug sisältää epäkelvollisia merkkejä\",\n  \"ToastSlugRequired\": \"Slug vaaditaan\",\n  \"ToastSocketConnected\": \"Yhteys saatu\",\n  \"ToastSocketDisconnected\": \"Yhteys katkaistu\",\n  \"ToastSocketFailedToConnect\": \"Yhteyden muodostus epäonnistui\",\n  \"ToastSortingPrefixesEmptyError\": \"On oltava vähintään yksi lajitteluetuliite\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Lajitteluetuliitteet päivitetty ({0} kohdetta)\",\n  \"ToastTitleRequired\": \"Otsikko on pakollinen\",\n  \"ToastUnknownError\": \"Tuntematon virhe\",\n  \"ToastUnlinkOpenIdFailed\": \"Käyttäjän linkityksen poistaminen OpenID:sta epäonnistui\",\n  \"ToastUnlinkOpenIdSuccess\": \"Käyttäjän linkitys poistettu OpenID:sta\",\n  \"ToastUploaderFilepathExistsError\": \"Tiedostopolku \\\"{0}\\\" on jo olemassa palvelimella\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Kohde {0} käyttää ulospäinlatauspolun alihakemistoa.\",\n  \"ToastUserDeleteFailed\": \"Käyttäjän poisto epäonnistui\",\n  \"ToastUserDeleteSuccess\": \"Käyttäjä poistettu\",\n  \"ToastUserPasswordChangeSuccess\": \"Salasana vaihdettu onnistuneesti\",\n  \"ToastUserPasswordMismatch\": \"Salasanat eivät täsmää\",\n  \"ToastUserPasswordMustChange\": \"Uusi salasana ei voi olla sama kuin vanha salasana\",\n  \"ToastUserRootRequireName\": \"Pääkäyttäjän nimi on pakollinen\",\n  \"TooltipAddChapters\": \"Lisää luku tai lukuja\",\n  \"TooltipAddOneSecond\": \"Lisää 1 sekunti\",\n  \"TooltipAdjustChapterStart\": \"Napauta säätääksesi aloitusaikaa\",\n  \"TooltipLockAllChapters\": \"Lukitse kaikki luvut\",\n  \"TooltipLockChapter\": \"Lukitse luku (Shift+napauta valitaksesi alueen)\",\n  \"TooltipSubtractOneSecond\": \"Vähennä 1 sekunti\",\n  \"TooltipUnlockAllChapters\": \"Avaa kaikki luvut\",\n  \"TooltipUnlockChapter\": \"Avaa luku (Shift+napauta valitaksesi alueen)\"\n}\n"
  },
  {
    "path": "client/strings/fr.json",
    "content": "{\n  \"ButtonAdd\": \"Ajouter\",\n  \"ButtonAddApiKey\": \"Ajouter une clé API\",\n  \"ButtonAddChapters\": \"Ajouter des chapitres\",\n  \"ButtonAddDevice\": \"Ajouter un appareil\",\n  \"ButtonAddLibrary\": \"Ajouter une bibliothèque\",\n  \"ButtonAddPodcasts\": \"Ajouter des podcasts\",\n  \"ButtonAddUser\": \"Ajouter un utilisateur\",\n  \"ButtonAddYourFirstLibrary\": \"Ajouter votre première bibliothèque\",\n  \"ButtonApply\": \"Appliquer\",\n  \"ButtonApplyChapters\": \"Appliquer aux chapitres\",\n  \"ButtonAuthors\": \"Auteurs\",\n  \"ButtonBack\": \"Retour\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Remplir à partir de l'existant\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Remplir les détails de la carte\",\n  \"ButtonBrowseForFolder\": \"Naviguer vers le répertoire\",\n  \"ButtonCancel\": \"Annuler\",\n  \"ButtonCancelEncode\": \"Annuler l’encodage\",\n  \"ButtonChangeRootPassword\": \"Modifier le mot de passe Administrateur\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Vérifier et télécharger de nouveaux épisodes\",\n  \"ButtonChooseAFolder\": \"Sélectionner un dossier\",\n  \"ButtonChooseFiles\": \"Sélectionner des fichiers\",\n  \"ButtonClearFilter\": \"Effacer le filtre\",\n  \"ButtonClose\": \"Fermer\",\n  \"ButtonCloseFeed\": \"Fermer le flux\",\n  \"ButtonCloseSession\": \"Fermer la session\",\n  \"ButtonCollections\": \"Collections\",\n  \"ButtonConfigureScanner\": \"Configurer l’analyse\",\n  \"ButtonCreate\": \"Créer\",\n  \"ButtonCreateBackup\": \"Créer une sauvegarde\",\n  \"ButtonDelete\": \"Supprimer\",\n  \"ButtonDownloadQueue\": \"File d’attente de téléchargement\",\n  \"ButtonEdit\": \"Modifier\",\n  \"ButtonEditChapters\": \"Modifier les chapitres\",\n  \"ButtonEditPodcast\": \"Modifier les podcasts\",\n  \"ButtonEnable\": \"Activer\",\n  \"ButtonFireAndFail\": \"Échec de l’action\",\n  \"ButtonFireOnTest\": \"Déclencher l’événement onTest\",\n  \"ButtonForceReScan\": \"Forcer une nouvelle analyse\",\n  \"ButtonFullPath\": \"Chemin complet\",\n  \"ButtonHide\": \"Cacher\",\n  \"ButtonHome\": \"Accueil\",\n  \"ButtonIssues\": \"Problèmes\",\n  \"ButtonJumpBackward\": \"Retour\",\n  \"ButtonJumpForward\": \"Avancer\",\n  \"ButtonLatest\": \"Dernière version\",\n  \"ButtonLibrary\": \"Bibliothèque\",\n  \"ButtonLogout\": \"Déconnexion\",\n  \"ButtonLookup\": \"Chercher\",\n  \"ButtonManageTracks\": \"Gérer les pistes\",\n  \"ButtonMapChapterTitles\": \"Correspondance des titres de chapitres\",\n  \"ButtonMatchAllAuthors\": \"Chercher tous les auteurs\",\n  \"ButtonMatchBooks\": \"Chercher les livres\",\n  \"ButtonNevermind\": \"Non merci\",\n  \"ButtonNext\": \"Suivant\",\n  \"ButtonNextChapter\": \"Chapitre suivant\",\n  \"ButtonNextItemInQueue\": \"Élément suivant dans la file d’attente\",\n  \"ButtonOk\": \"D'accord\",\n  \"ButtonOpenFeed\": \"Ouvrir le flux\",\n  \"ButtonOpenManager\": \"Ouvrir le gestionnaire\",\n  \"ButtonPause\": \"Pause\",\n  \"ButtonPlay\": \"Lire\",\n  \"ButtonPlayAll\": \"Lire tout\",\n  \"ButtonPlaying\": \"En lecture\",\n  \"ButtonPlaylists\": \"Listes de lecture\",\n  \"ButtonPrevious\": \"Précédent\",\n  \"ButtonPreviousChapter\": \"Chapitre précédent\",\n  \"ButtonProbeAudioFile\": \"Analyser le fichier audio\",\n  \"ButtonPurgeAllCache\": \"Purger tout le cache\",\n  \"ButtonPurgeItemsCache\": \"Purger le cache des éléments\",\n  \"ButtonQueueAddItem\": \"Ajouter à la liste de lecture\",\n  \"ButtonQueueRemoveItem\": \"Supprimer de la liste de lecture\",\n  \"ButtonQuickEmbed\": \"Intégration rapide\",\n  \"ButtonQuickEmbedMetadata\": \"Ajouter rapidement des métadonnées\",\n  \"ButtonQuickMatch\": \"Recherche rapide\",\n  \"ButtonReScan\": \"Nouvelle analyse\",\n  \"ButtonRead\": \"Lire\",\n  \"ButtonReadLess\": \"Lire moins\",\n  \"ButtonReadMore\": \"Lire plus\",\n  \"ButtonRefresh\": \"Rafraîchir\",\n  \"ButtonRemove\": \"Retirer\",\n  \"ButtonRemoveAll\": \"Supprimer tout\",\n  \"ButtonRemoveAllLibraryItems\": \"Supprimer tous les éléments de la bibliothèque\",\n  \"ButtonRemoveFromContinueListening\": \"Ne plus continuer à écouter\",\n  \"ButtonRemoveFromContinueReading\": \"Ne plus continuer à lire\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Ne plus continuer à écouter la série\",\n  \"ButtonReset\": \"Réinitialiser\",\n  \"ButtonResetToDefault\": \"Réinitialiser aux valeurs par défaut\",\n  \"ButtonRestore\": \"Rétablir\",\n  \"ButtonSave\": \"Sauvegarder\",\n  \"ButtonSaveAndClose\": \"Sauvegarder et fermer\",\n  \"ButtonSaveTracklist\": \"Sauvegarder la liste de lecture\",\n  \"ButtonScan\": \"Analyser\",\n  \"ButtonScanLibrary\": \"Analyser la bibliothèque\",\n  \"ButtonScrollLeft\": \"Défiler vers la gauche\",\n  \"ButtonScrollRight\": \"Défiler vers la droite\",\n  \"ButtonSearch\": \"Chercher\",\n  \"ButtonSelectFolderPath\": \"Sélectionner le chemin du dossier\",\n  \"ButtonSeries\": \"Séries\",\n  \"ButtonSetChaptersFromTracks\": \"Positionner les chapitres par rapports aux pistes\",\n  \"ButtonShare\": \"Partager\",\n  \"ButtonShiftTimes\": \"Décaler l’horodatage du livre\",\n  \"ButtonShow\": \"Afficher\",\n  \"ButtonStartM4BEncode\": \"Démarrer l’encodage M4B\",\n  \"ButtonStartMetadataEmbed\": \"Démarrer les Métadonnées intégrées\",\n  \"ButtonStats\": \"Statistiques\",\n  \"ButtonSubmit\": \"Soumettre\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Dissocier OpenID\",\n  \"ButtonUpload\": \"Téléverser\",\n  \"ButtonUploadBackup\": \"Téléverser une sauvegarde\",\n  \"ButtonUploadCover\": \"Téléverser une couverture\",\n  \"ButtonUploadOPMLFile\": \"Téléverser un fichier OPML\",\n  \"ButtonUserDelete\": \"Supprimer l’utilisateur {0}\",\n  \"ButtonUserEdit\": \"Modifier l’utilisateur {0}\",\n  \"ButtonViewAll\": \"Afficher tout\",\n  \"ButtonYes\": \"Oui\",\n  \"ErrorUploadFetchMetadataAPI\": \"Erreur lors de la récupération des métadonnées\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Impossible de récupérer les métadonnées - essayez de mettre à jour le titre et/ou l’auteur\",\n  \"ErrorUploadLacksTitle\": \"Doit avoir un titre\",\n  \"HeaderAccount\": \"Compte\",\n  \"HeaderAddCustomMetadataProvider\": \"Ajouter un fournisseur de métadonnées personnalisé\",\n  \"HeaderAdvanced\": \"Avancé\",\n  \"HeaderApiKeys\": \"Clés API\",\n  \"HeaderAppriseNotificationSettings\": \"Configuration des notifications Apprise\",\n  \"HeaderAudioTracks\": \"Pistes audio\",\n  \"HeaderAudiobookTools\": \"Outils de gestion de fichiers de livres audio\",\n  \"HeaderAuthentication\": \"Authentification\",\n  \"HeaderBackups\": \"Sauvegardes\",\n  \"HeaderBulkChapterModal\": \"Ajouter Plusieurs Chapitres\",\n  \"HeaderChangePassword\": \"Modifier le mot de passe\",\n  \"HeaderChapters\": \"Chapitres\",\n  \"HeaderChooseAFolder\": \"Sélectionner un dossier\",\n  \"HeaderCollection\": \"Collection\",\n  \"HeaderCollectionItems\": \"Entrées de la collection\",\n  \"HeaderCover\": \"Couverture\",\n  \"HeaderCurrentDownloads\": \"Téléchargements en cours\",\n  \"HeaderCustomMessageOnLogin\": \"Message personnalisé lors de la connexion\",\n  \"HeaderCustomMetadataProviders\": \"Fournisseurs de métadonnées personnalisés\",\n  \"HeaderDetails\": \"Détails\",\n  \"HeaderDownloadQueue\": \"File d’attente de téléchargements\",\n  \"HeaderEbookFiles\": \"Fichiers des livres numériques\",\n  \"HeaderEmail\": \"Courriel\",\n  \"HeaderEmailSettings\": \"Configuration de l’envoie des courriels\",\n  \"HeaderEpisodes\": \"Épisodes\",\n  \"HeaderEreaderDevices\": \"Lecteur de livres numériques\",\n  \"HeaderEreaderSettings\": \"Paramètres de la liseuse\",\n  \"HeaderFiles\": \"Fichiers\",\n  \"HeaderFindChapters\": \"Trouver les chapitres\",\n  \"HeaderIgnoredFiles\": \"Fichiers ignorés\",\n  \"HeaderItemFiles\": \"Fichiers des éléments\",\n  \"HeaderItemMetadataUtils\": \"Outils de gestion des métadonnées\",\n  \"HeaderLastListeningSession\": \"Dernière session d’écoute\",\n  \"HeaderLatestEpisodes\": \"Dernier épisodes\",\n  \"HeaderLibraries\": \"Bibliothèque\",\n  \"HeaderLibraryFiles\": \"Fichier de bibliothèque\",\n  \"HeaderLibraryStats\": \"Statistiques de bibliothèque\",\n  \"HeaderListeningSessions\": \"Sessions d’écoute\",\n  \"HeaderListeningStats\": \"Statistiques d’écoute\",\n  \"HeaderLogin\": \"Connexion\",\n  \"HeaderLogs\": \"Journaux\",\n  \"HeaderManageGenres\": \"Gérer les genres\",\n  \"HeaderManageTags\": \"Gérer les étiquettes\",\n  \"HeaderMapDetails\": \"Édition en masse\",\n  \"HeaderMatch\": \"Chercher\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Ordre de priorité des métadonnées\",\n  \"HeaderMetadataToEmbed\": \"Métadonnées à intégrer\",\n  \"HeaderNewAccount\": \"Nouveau compte\",\n  \"HeaderNewApiKey\": \"Nouvelle clé API\",\n  \"HeaderNewLibrary\": \"Nouvelle bibliothèque\",\n  \"HeaderNotificationCreate\": \"Créer une notification\",\n  \"HeaderNotificationUpdate\": \"Mise à jour de la notification\",\n  \"HeaderNotifications\": \"Notifications\",\n  \"HeaderOpenIDConnectAuthentication\": \"Authentification via OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Sessions d'écoute ouvertes\",\n  \"HeaderOpenRSSFeed\": \"Ouvrir le flux RSS\",\n  \"HeaderOtherFiles\": \"Autres fichiers\",\n  \"HeaderPasswordAuthentication\": \"Authentification par mot de passe\",\n  \"HeaderPermissions\": \"Permissions\",\n  \"HeaderPlayerQueue\": \"Liste d’écoute\",\n  \"HeaderPlayerSettings\": \"Paramètres du lecteur\",\n  \"HeaderPlaylist\": \"Liste de lecture\",\n  \"HeaderPlaylistItems\": \"Éléments de la liste de lecture\",\n  \"HeaderPodcastsToAdd\": \"Podcasts à ajouter\",\n  \"HeaderPresets\": \"Préréglages\",\n  \"HeaderPreviewCover\": \"Prévisualiser la couverture\",\n  \"HeaderRSSFeedGeneral\": \"Détails du flux RSS\",\n  \"HeaderRSSFeedIsOpen\": \"Le flux RSS est actif\",\n  \"HeaderRSSFeeds\": \"Flux RSS\",\n  \"HeaderRemoveEpisode\": \"Supprimer l’épisode\",\n  \"HeaderRemoveEpisodes\": \"Suppression de {0} épisodes\",\n  \"HeaderSavedMediaProgress\": \"Progression de la sauvegarde des médias\",\n  \"HeaderSchedule\": \"Programmation\",\n  \"HeaderScheduleEpisodeDownloads\": \"Programmer des téléchargements automatiques d'épisodes\",\n  \"HeaderScheduleLibraryScans\": \"Analyse automatique de la bibliothèque\",\n  \"HeaderSession\": \"Session\",\n  \"HeaderSetBackupSchedule\": \"Activer la sauvegarde automatique\",\n  \"HeaderSettings\": \"Paramètres\",\n  \"HeaderSettingsDisplay\": \"Affichage\",\n  \"HeaderSettingsExperimental\": \"Fonctionnalités expérimentales\",\n  \"HeaderSettingsGeneral\": \"Général\",\n  \"HeaderSettingsScanner\": \"Analyseur\",\n  \"HeaderSettingsSecurity\": \"Sécurité\",\n  \"HeaderSettingsWebClient\": \"Client Web\",\n  \"HeaderSleepTimer\": \"Minuterie\",\n  \"HeaderStatsLargestItems\": \"Éléments les plus grands\",\n  \"HeaderStatsLongestItems\": \"Éléments les plus long (hrs)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minutes d’écoute (7 derniers jours)\",\n  \"HeaderStatsRecentSessions\": \"Sessions récentes\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Auteurs\",\n  \"HeaderStatsTop5Genres\": \"Top 5 des genres\",\n  \"HeaderTableOfContents\": \"Table des matières\",\n  \"HeaderTools\": \"Outils\",\n  \"HeaderUpdateAccount\": \"Mettre à jour le compte\",\n  \"HeaderUpdateApiKey\": \"Mettre à jour la clé API\",\n  \"HeaderUpdateAuthor\": \"Mettre à jour l’auteur\",\n  \"HeaderUpdateDetails\": \"Mettre à jour les détails\",\n  \"HeaderUpdateLibrary\": \"Mettre à jour la bibliothèque\",\n  \"HeaderUsers\": \"Utilisateurs\",\n  \"HeaderYearReview\": \"Bilan de l’année {0}\",\n  \"HeaderYourStats\": \"Vos statistiques\",\n  \"LabelAbridged\": \"Version courte\",\n  \"LabelAbridgedChecked\": \"Abrégé (vérifié)\",\n  \"LabelAbridgedUnchecked\": \"Intégral (non vérifié)\",\n  \"LabelAccessibleBy\": \"Accessible par\",\n  \"LabelAccountType\": \"Type de compte\",\n  \"LabelAccountTypeAdmin\": \"Admin\",\n  \"LabelAccountTypeGuest\": \"Invité\",\n  \"LabelAccountTypeUser\": \"Utilisateur\",\n  \"LabelActivities\": \"Activités\",\n  \"LabelActivity\": \"Activité\",\n  \"LabelAddToCollection\": \"Ajouter à la collection\",\n  \"LabelAddToCollectionBatch\": \"Ajout de {0} livres à la collection\",\n  \"LabelAddToPlaylist\": \"Ajouter à la liste de lecture\",\n  \"LabelAddToPlaylistBatch\": \"{0} éléments ajoutés à la liste de lecture\",\n  \"LabelAddedAt\": \"Date d’ajout\",\n  \"LabelAddedDate\": \"Ajouté le {0}\",\n  \"LabelAdminUsersOnly\": \"Administrateurs uniquement\",\n  \"LabelAll\": \"Tout\",\n  \"LabelAllEpisodesDownloaded\": \"Tous les épisodes ont été téléchargés\",\n  \"LabelAllUsers\": \"Tous les utilisateurs\",\n  \"LabelAllUsersExcludingGuests\": \"Tous les utilisateurs à l’exception des invités\",\n  \"LabelAllUsersIncludingGuests\": \"Tous les utilisateurs, y compris les invités\",\n  \"LabelAlreadyInYourLibrary\": \"Déjà dans la bibliothèque\",\n  \"LabelApiKeyCreated\": \"La clé API « {0} » a été créée avec succès.\",\n  \"LabelApiKeyCreatedDescription\": \"Assurez-vous de copier la clé API maintenant car vous ne pourrez plus la voir.\",\n  \"LabelApiKeyUser\": \"Agir au nom de l’utilisateur\",\n  \"LabelApiKeyUserDescription\": \"Cette clé API disposera des mêmes autorisations que l’utilisateur pour lequel elle agit. Elle apparaîtra dans les journaux comme si c’était l’utilisateur qui effectuait la requête.\",\n  \"LabelApiToken\": \"Token API\",\n  \"LabelAppend\": \"Ajouter\",\n  \"LabelAudioBitrate\": \"Débit audio (par exemple 128k)\",\n  \"LabelAudioChannels\": \"Canaux audio (1 ou 2)\",\n  \"LabelAudioCodec\": \"Codec audio\",\n  \"LabelAuthor\": \"Auteur\",\n  \"LabelAuthorFirstLast\": \"Auteur (Prénom Nom)\",\n  \"LabelAuthorLastFirst\": \"Auteur (Nom, Prénom)\",\n  \"LabelAuthors\": \"Auteurs\",\n  \"LabelAutoDownloadEpisodes\": \"Téléchargement automatique des épisodes\",\n  \"LabelAutoFetchMetadata\": \"Recherche automatique de métadonnées\",\n  \"LabelAutoFetchMetadataHelp\": \"Récupère les métadonnées du titre, de l’auteur et de la série pour simplifier le téléchargement. Il se peut que des métadonnées supplémentaires doivent être ajoutées après le téléchargement.\",\n  \"LabelAutoLaunch\": \"Lancement automatique\",\n  \"LabelAutoLaunchDescription\": \"Redirection automatique vers le fournisseur d’authentification lors de la navigation vers la page de connexion (chemin de remplacement manuel <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Enregistrement automatique\",\n  \"LabelAutoRegisterDescription\": \"Créer automatiquement de nouveaux utilisateurs après la connexion\",\n  \"LabelBackToUser\": \"Retour à l’utilisateur\",\n  \"LabelBackupAudioFiles\": \"Sauvegarder les fichiers audio\",\n  \"LabelBackupLocation\": \"Emplacement de la sauvegarde\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Sauvegardes automatiques\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Sauvegardes enregistrées dans /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Taille maximale de la sauvegarde (en Go) (0 pour illimité)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.\",\n  \"LabelBackupsNumberToKeep\": \"Nombre de sauvegardes à conserver\",\n  \"LabelBackupsNumberToKeepHelp\": \"Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.\",\n  \"LabelBitrate\": \"Débit binaire\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Livres\",\n  \"LabelButtonText\": \"Texte du bouton\",\n  \"LabelByAuthor\": \"de {0}\",\n  \"LabelChangePassword\": \"Modifier le mot de passe\",\n  \"LabelChannels\": \"Canaux\",\n  \"LabelChapterCount\": \"{0} Chapitres\",\n  \"LabelChapterTitle\": \"Titre du chapitre\",\n  \"LabelChapters\": \"Chapitres\",\n  \"LabelChaptersFound\": \"chapitres trouvés\",\n  \"LabelClickForMoreInfo\": \"Cliquez ici pour plus d’informations\",\n  \"LabelClickToUseCurrentValue\": \"Cliquez pour utiliser la valeur actuelle\",\n  \"LabelClosePlayer\": \"Fermer le lecteur\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Réduire les séries\",\n  \"LabelCollapseSubSeries\": \"Replier les sous-séries\",\n  \"LabelCollection\": \"Collection\",\n  \"LabelCollections\": \"Collections\",\n  \"LabelComplete\": \"Complet\",\n  \"LabelConfirmPassword\": \"Confirmer le mot de passe\",\n  \"LabelContinueListening\": \"Continuer l'écoute\",\n  \"LabelContinueReading\": \"Continuer la lecture\",\n  \"LabelContinueSeries\": \"Continuer les séries\",\n  \"LabelCorsAllowed\": \"Origines autorisées pour les requêtes CORS\",\n  \"LabelCover\": \"Couverture\",\n  \"LabelCoverImageURL\": \"URL vers l’image de couverture\",\n  \"LabelCoverProvider\": \"Source des couvertures\",\n  \"LabelCreatedAt\": \"Créé le\",\n  \"LabelCronExpression\": \"Expression cron\",\n  \"LabelCurrent\": \"Actuel\",\n  \"LabelCurrently\": \"Actuellement :\",\n  \"LabelCustomCronExpression\": \"Expression cron personnalisée :\",\n  \"LabelDatetime\": \"Date\",\n  \"LabelDays\": \"Jours\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)\",\n  \"LabelDescription\": \"Description\",\n  \"LabelDeselectAll\": \"Tout déselectionner\",\n  \"LabelDetectedPattern\": \"Motif détecté :\",\n  \"LabelDevice\": \"Appareil\",\n  \"LabelDeviceInfo\": \"Détail de l’appareil\",\n  \"LabelDeviceIsAvailableTo\": \"L’appareil est disponible pour…\",\n  \"LabelDirectory\": \"Répertoire\",\n  \"LabelDiscFromFilename\": \"Depuis le fichier\",\n  \"LabelDiscFromMetadata\": \"Depuis les métadonnées\",\n  \"LabelDiscover\": \"Découvrir\",\n  \"LabelDownload\": \"Télécharger\",\n  \"LabelDownloadNEpisodes\": \"Télécharger {0} épisode(s)\",\n  \"LabelDownloadable\": \"Téléchargeable\",\n  \"LabelDuration\": \"Durée\",\n  \"LabelDurationComparisonExactMatch\": \"(correspondance exacte)\",\n  \"LabelDurationComparisonLonger\": \"({0} plus long)\",\n  \"LabelDurationComparisonShorter\": \"({0} plus court)\",\n  \"LabelDurationFound\": \"Durée trouvée :\",\n  \"LabelEbook\": \"Livre numérique\",\n  \"LabelEbooks\": \"Livres numériques\",\n  \"LabelEdit\": \"Modifier\",\n  \"LabelEmail\": \"Courriel\",\n  \"LabelEmailSettingsFromAddress\": \"Expéditeur\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Rejeter les certificats non autorisés\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.\",\n  \"LabelEmailSettingsSecure\": \"Sécurisé\",\n  \"LabelEmailSettingsSecureHelp\": \"Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Adresse de test\",\n  \"LabelEmbeddedCover\": \"Couverture du livre intégrée\",\n  \"LabelEnable\": \"Activer\",\n  \"LabelEncodingBackupLocation\": \"Une sauvegarde de vos fichiers audio originaux sera stockée dans :\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Les chapitres ne sont pas intégrés dans les livres audio multipistes.\",\n  \"LabelEncodingClearItemCache\": \"Assurez-vous de purger périodiquement le cache des éléments.\",\n  \"LabelEncodingFinishedM4B\": \"Le fichier M4B terminé sera placé dans votre dossier de livre audio à l'adresse suivante :\",\n  \"LabelEncodingInfoEmbedded\": \"Les métadonnées seront intégrées dans les pistes audio de votre dossier de livre audio.\",\n  \"LabelEncodingStartedNavigation\": \"Une fois la tâche démarrée, vous pouvez quitter cette page.\",\n  \"LabelEncodingTimeWarning\": \"L’encodage peut prendre jusqu’à 30 minutes.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Avertissement : ne mettez pas à jour ces paramètres à moins que vous ne soyez familier avec les options d'encodage « ffmpeg ».\",\n  \"LabelEncodingWatcherDisabled\": \"Si l'observateur est désactivé, vous devrez ensuite réanalyser ce livre audio.\",\n  \"LabelEnd\": \"Fin\",\n  \"LabelEndOfChapter\": \"Fin du chapitre\",\n  \"LabelEpisode\": \"Épisode\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Épisode non lié au flux RSS\",\n  \"LabelEpisodeNumber\": \"Épisode n°{0}\",\n  \"LabelEpisodeTitle\": \"Titre de l’épisode\",\n  \"LabelEpisodeType\": \"Type de l’épisode\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL de l’épisode à partir du flux RSS\",\n  \"LabelEpisodes\": \"Épisodes\",\n  \"LabelEpisodic\": \"Épisodique\",\n  \"LabelExample\": \"Exemple\",\n  \"LabelExpandSeries\": \"Développer la série\",\n  \"LabelExpandSubSeries\": \"Développer les sous-séries\",\n  \"LabelExpired\": \"Expiré\",\n  \"LabelExpiresAt\": \"Expire à\",\n  \"LabelExpiresInSeconds\": \"Expire dans (secondes)\",\n  \"LabelExpiresNever\": \"Jamais\",\n  \"LabelExplicit\": \"Contenu explicite\",\n  \"LabelExplicitChecked\": \"Explicite (vérifié)\",\n  \"LabelExplicitUnchecked\": \"Non explicite (non vérifié)\",\n  \"LabelExportOPML\": \"Exporter OPML\",\n  \"LabelFeedURL\": \"URL du flux\",\n  \"LabelFetchingMetadata\": \"Récupération des métadonnées\",\n  \"LabelFile\": \"Fichier\",\n  \"LabelFileBirthtime\": \"Création du fichier\",\n  \"LabelFileBornDate\": \"Créé le {0}\",\n  \"LabelFileModified\": \"Modification du fichier\",\n  \"LabelFileModifiedDate\": \"Modifié le {0}\",\n  \"LabelFilename\": \"Nom de fichier\",\n  \"LabelFilterByUser\": \"Filtrer par utilisateur\",\n  \"LabelFindEpisodes\": \"Trouver des épisodes\",\n  \"LabelFinished\": \"Terminé le\",\n  \"LabelFinishedDate\": \"Terminé {0}\",\n  \"LabelFolder\": \"Dossier\",\n  \"LabelFolders\": \"Dossiers\",\n  \"LabelFontBold\": \"Gras\",\n  \"LabelFontBoldness\": \"Graisse de la police\",\n  \"LabelFontFamily\": \"Famille de caractères\",\n  \"LabelFontItalic\": \"Italique\",\n  \"LabelFontScale\": \"Taille de la police\",\n  \"LabelFontStrikethrough\": \"Barrer\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Complet\",\n  \"LabelGenre\": \"Genre\",\n  \"LabelGenres\": \"Genres\",\n  \"LabelHardDeleteFile\": \"Suppression du fichier\",\n  \"LabelHasEbook\": \"A un livre numérique\",\n  \"LabelHasSupplementaryEbook\": \"A un livre numérique supplémentaire\",\n  \"LabelHideSubtitles\": \"Masquer les sous-titres\",\n  \"LabelHighestPriority\": \"Priorité la plus élevée\",\n  \"LabelHost\": \"Hôte\",\n  \"LabelHour\": \"Heure\",\n  \"LabelHours\": \"Heures\",\n  \"LabelIcon\": \"Icône\",\n  \"LabelImageURLFromTheWeb\": \"URL de l’image à partir du web\",\n  \"LabelInProgress\": \"En cours\",\n  \"LabelIncludeInTracklist\": \"Inclure dans la liste de lecture\",\n  \"LabelIncomplete\": \"Incomplet\",\n  \"LabelInterval\": \"Intervalle\",\n  \"LabelIntervalCustomDailyWeekly\": \"Personnaliser quotidiennement / hebdomadairement\",\n  \"LabelIntervalEvery12Hours\": \"Toutes les 12 heures\",\n  \"LabelIntervalEvery15Minutes\": \"Toutes les 15 minutes\",\n  \"LabelIntervalEvery2Hours\": \"Toutes les 2 heures\",\n  \"LabelIntervalEvery30Minutes\": \"Toutes les 30 minutes\",\n  \"LabelIntervalEvery6Hours\": \"Toutes les 6 heures\",\n  \"LabelIntervalEveryDay\": \"Tous les jours\",\n  \"LabelIntervalEveryHour\": \"Toutes les heures\",\n  \"LabelIntervalEveryMinute\": \"Toutes les minutes\",\n  \"LabelInvert\": \"Inverser\",\n  \"LabelItem\": \"Élément\",\n  \"LabelJumpBackwardAmount\": \"Dans le lecteur, reculer de\",\n  \"LabelJumpForwardAmount\": \"Dans le lecteur, avancer de\",\n  \"LabelLanguage\": \"Langue\",\n  \"LabelLanguageDefaultServer\": \"Langue par défaut\",\n  \"LabelLanguages\": \"Langues\",\n  \"LabelLastBookAdded\": \"Dernier livre ajouté\",\n  \"LabelLastBookUpdated\": \"Dernier livre mis à jour\",\n  \"LabelLastProgressDate\": \"Dernière position : {0}\",\n  \"LabelLastSeen\": \"Vu dernièrement\",\n  \"LabelLastTime\": \"Progression\",\n  \"LabelLastUpdate\": \"Dernière mise à jour\",\n  \"LabelLayout\": \"Mise en page\",\n  \"LabelLayoutSinglePage\": \"Page seule\",\n  \"LabelLayoutSplitPage\": \"Vue partagée\",\n  \"LabelLess\": \"Moins\",\n  \"LabelLibrariesAccessibleToUser\": \"Bibliothèque accessible à l’utilisateur\",\n  \"LabelLibrary\": \"Bibliothèque\",\n  \"LabelLibraryFilterSublistEmpty\": \"Aucun {0}\",\n  \"LabelLibraryItem\": \"Élément de bibliothèque\",\n  \"LabelLibraryName\": \"Nom de la bibliothèque\",\n  \"LabelLibrarySortByProgress\": \"Progression : Mise à jour\",\n  \"LabelLibrarySortByProgressFinished\": \"Progression : Terminé\",\n  \"LabelLibrarySortByProgressStarted\": \"Progression : En cours\",\n  \"LabelLimit\": \"Limite\",\n  \"LabelLineSpacing\": \"Interligne\",\n  \"LabelListenAgain\": \"Écouter à nouveau\",\n  \"LabelLogLevelDebug\": \"Débogage\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Attention\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Rechercher les nouveaux épisodes après cette date\",\n  \"LabelLowestPriority\": \"Priorité la plus basse\",\n  \"LabelMatchConfidence\": \"Confiance\",\n  \"LabelMatchExistingUsersBy\": \"Correspondance avec les utilisateurs existants\",\n  \"LabelMatchExistingUsersByDescription\": \"Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Nombre maximum d’épisodes à télécharger. 0 pour illimité.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Nombre maximum de nouveaux épisodes à télécharger par vérification\",\n  \"LabelMaxEpisodesToKeep\": \"Nombre maximum d’épisodes à conserver\",\n  \"LabelMaxEpisodesToKeepHelp\": \"La valeur 0 ne définit aucune limite maximale. Une fois qu’un nouvel épisode est téléchargé automatiquement, l’épisode le plus ancien sera supprimé si vous avez plus de X épisodes. Cela ne supprimera qu’un seul épisode par nouveau téléchargement.\",\n  \"LabelMediaPlayer\": \"Lecteur multimédia\",\n  \"LabelMediaType\": \"Type de média\",\n  \"LabelMetaTag\": \"Balise de métadonnée\",\n  \"LabelMetaTags\": \"Balises de métadonnée\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée\",\n  \"LabelMetadataProvider\": \"Fournisseur de métadonnées\",\n  \"LabelMinute\": \"Minute\",\n  \"LabelMinutes\": \"Minutes\",\n  \"LabelMissing\": \"Manquant\",\n  \"LabelMissingEbook\": \"Ne possède aucun livre numérique\",\n  \"LabelMissingSupplementaryEbook\": \"Ne possède aucun livre numérique supplémentaire\",\n  \"LabelMobileRedirectURIs\": \"URI de redirection mobile autorisés\",\n  \"LabelMobileRedirectURIsDescription\": \"Il s’agit d’une liste blanche d’URI de redirection valides pour les applications mobiles. Celui par défaut est <code>audiobookshelf://oauth</code>, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour l’intégration d’applications tierces. L’utilisation d’un astérisque (<code>*</code>) comme seule entrée autorise n’importe quel URI.\",\n  \"LabelMore\": \"Plus\",\n  \"LabelMoreInfo\": \"Plus d’infos\",\n  \"LabelName\": \"Nom\",\n  \"LabelNarrator\": \"Narrateur\",\n  \"LabelNarrators\": \"Narrateurs\",\n  \"LabelNew\": \"Nouveau\",\n  \"LabelNewPassword\": \"Nouveau mot de passe\",\n  \"LabelNewestAuthors\": \"Auteurs récents\",\n  \"LabelNewestEpisodes\": \"Épisodes récents\",\n  \"LabelNextBackupDate\": \"Date de la prochaine sauvegarde\",\n  \"LabelNextChapters\": \"Les prochains chapitres seront :\",\n  \"LabelNextScheduledRun\": \"Prochain lancement prévu\",\n  \"LabelNoApiKeys\": \"Aucune clé API\",\n  \"LabelNoCustomMetadataProviders\": \"Aucun fournisseurs de métadonnées personnalisés\",\n  \"LabelNoEpisodesSelected\": \"Aucun épisode sélectionné\",\n  \"LabelNotFinished\": \"Non terminé\",\n  \"LabelNotStarted\": \"Pas commencé\",\n  \"LabelNotes\": \"Notes\",\n  \"LabelNotificationAppriseURL\": \"URL(s) d’Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Variables disponibles\",\n  \"LabelNotificationBodyTemplate\": \"Modèle de message\",\n  \"LabelNotificationEvent\": \"Evènement de Notification\",\n  \"LabelNotificationTitleTemplate\": \"Modèle de titre\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Nombre maximal de tentatives échouées atteint\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"La notification est abandonnée une fois ce seuil atteint\",\n  \"LabelNotificationsMaxQueueSize\": \"Nombres de notifications maximum à mettre en attente\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file d’attente est à son maximum. Cela empêche un flot trop important.\",\n  \"LabelNumberOfBooks\": \"Nombre de livres\",\n  \"LabelNumberOfChapters\": \"Nombre de chapitres :\",\n  \"LabelNumberOfEpisodes\": \"Nombre d'épisodes\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Nom de la demande OpenID qui contient des autorisations avancées pour les actions de l’utilisateur dans l’application, qui s’appliqueront à des rôles autres que celui d’administrateur (<b>s’il est configuré</b>). Si la demande est absente de la réponse, l’accès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme <code>false</code>. Assurez-vous que la demande du fournisseur d’identité correspond à la structure attendue :\",\n  \"LabelOpenIDClaims\": \"Laissez les options suivantes vides pour désactiver l’attribution avancée de groupes et d’autorisations, en attribuant alors automatiquement le groupe « Utilisateur ».\",\n  \"LabelOpenIDGroupClaimDescription\": \"Nom de la demande OpenID qui contient une liste des groupes de l’utilisateur. Communément appelé <code>groups</code>. <b>Si elle est configurée</b>, l’application attribuera automatiquement des rôles en fonction de l’appartenance de l’utilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, l’application attribuera le rôle correspondant au niveau d’accès le plus élevé. Si aucun groupe ne correspond, l’accès sera refusé.\",\n  \"LabelOpenRSSFeed\": \"Ouvrir le flux RSS\",\n  \"LabelOverwrite\": \"Écraser\",\n  \"LabelPaginationPageXOfY\": \"Page {0} sur {1}\",\n  \"LabelPassword\": \"Mot de passe\",\n  \"LabelPath\": \"Chemin\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Peut accéder à toutes les bibliothèque\",\n  \"LabelPermissionsAccessAllTags\": \"Peut accéder à toutes les étiquettes\",\n  \"LabelPermissionsAccessExplicitContent\": \"Peut accéder au contenu restreint\",\n  \"LabelPermissionsCreateEreader\": \"Peut créer une liseuse\",\n  \"LabelPermissionsDelete\": \"Peut supprimer\",\n  \"LabelPermissionsDownload\": \"Peut télécharger\",\n  \"LabelPermissionsUpdate\": \"Peut mettre à jour\",\n  \"LabelPermissionsUpload\": \"Peut téléverser\",\n  \"LabelPersonalYearReview\": \"Bilan de l’année ({0})\",\n  \"LabelPhotoPathURL\": \"Chemin / URL des photos\",\n  \"LabelPlayMethod\": \"Méthode d’écoute\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Augmentation/Diminition de la vitesse de lecture\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} sur {1}\",\n  \"LabelPlaylists\": \"Listes de lecture\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Région de recherche de podcasts\",\n  \"LabelPodcastType\": \"Type de Podcast\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Préfixes à Ignorer (Insensible à la Casse)\",\n  \"LabelPreventIndexing\": \"Empêcher l’indexation de votre flux par les bases de données iTunes et Google podcast\",\n  \"LabelPrimaryEbook\": \"Premier livre numérique\",\n  \"LabelProgress\": \"Progression\",\n  \"LabelProvider\": \"Fournisseur\",\n  \"LabelProviderAuthorizationValue\": \"Valeur de l’en-tête d’autorisation\",\n  \"LabelPubDate\": \"Date de publication\",\n  \"LabelPublishYear\": \"Année de publication\",\n  \"LabelPublishedDate\": \"Publié en {0}\",\n  \"LabelPublishedDecade\": \"Décennie de publication\",\n  \"LabelPublishedDecades\": \"Décennies de publication\",\n  \"LabelPublisher\": \"Éditeur\",\n  \"LabelPublishers\": \"Éditeurs\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Courriel personnalisée du propriétaire\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nom propriétaire personnalisé\",\n  \"LabelRSSFeedOpen\": \"Flux RSS ouvert\",\n  \"LabelRSSFeedPreventIndexing\": \"Empêcher l’indexation\",\n  \"LabelRSSFeedSlug\": \"Identifiant d’URL du flux RSS\",\n  \"LabelRSSFeedURL\": \"Adresse du flux RSS\",\n  \"LabelRandomly\": \"Au hasard\",\n  \"LabelReAddSeriesToContinueListening\": \"Ajouter à nouveau la série pour continuer à l’écouter\",\n  \"LabelRead\": \"Lire\",\n  \"LabelReadAgain\": \"Lire à nouveau\",\n  \"LabelReadEbookWithoutProgress\": \"Lire le livre numérique sans sauvegarder la progression\",\n  \"LabelRecentSeries\": \"Séries récentes\",\n  \"LabelRecentlyAdded\": \"Ajouts récents\",\n  \"LabelRecommended\": \"Recommandé\",\n  \"LabelRedo\": \"Refaire\",\n  \"LabelRegion\": \"Région\",\n  \"LabelReleaseDate\": \"Date de parution\",\n  \"LabelRemoveAllMetadataAbs\": \"Supprimer tous les fichiers metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Supprimer tous les fichiers metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Supprimer l’intro et la fin Audible des chapitres\",\n  \"LabelRemoveCover\": \"Supprimer la couverture\",\n  \"LabelRemoveMetadataFile\": \"Supprimer les fichiers de métadonnées dans les dossiers des éléments de la bibliothèque\",\n  \"LabelRemoveMetadataFileHelp\": \"Supprimer tous les fichiers metadata.json et metadata.abs de vos dossiers {0}.\",\n  \"LabelRowsPerPage\": \"Lignes par page\",\n  \"LabelSearchTerm\": \"Terme de recherche\",\n  \"LabelSearchTitle\": \"Titre de recherche\",\n  \"LabelSearchTitleOrASIN\": \"Recherche du titre ou ASIN\",\n  \"LabelSeason\": \"Saison\",\n  \"LabelSeasonNumber\": \"Saison n°{0}\",\n  \"LabelSelectAll\": \"Tout sélectionner\",\n  \"LabelSelectAllEpisodes\": \"Sélectionner tous les épisodes\",\n  \"LabelSelectEpisodesShowing\": \"Sélectionner {0} épisode(s) en cours\",\n  \"LabelSelectUser\": \"Sélectionner l’utilisateur\",\n  \"LabelSelectUsers\": \"Sélectionner les utilisateurs\",\n  \"LabelSendEbookToDevice\": \"Envoyer le livre numérique à…\",\n  \"LabelSequence\": \"Séquence\",\n  \"LabelSerial\": \"N° de série\",\n  \"LabelSeries\": \"Séries\",\n  \"LabelSeriesName\": \"Nom de la série\",\n  \"LabelSeriesProgress\": \"Progression de séries\",\n  \"LabelServerLogLevel\": \"Niveau de journalisation du serveur\",\n  \"LabelServerYearReview\": \"Bilan de l’année du serveur ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Définir comme principale\",\n  \"LabelSetEbookAsSupplementary\": \"Définir comme supplémentaire\",\n  \"LabelSettingsAllowIframe\": \"Autoriser l’intégration dans une iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Livres audios seulement\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"L’activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s’ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires\",\n  \"LabelSettingsBookshelfViewHelp\": \"Interface skeumorphique avec étagères en bois\",\n  \"LabelSettingsChromecastSupport\": \"Support du Chromecast\",\n  \"LabelSettingsDateFormat\": \"Format de date\",\n  \"LabelSettingsEnableWatcher\": \"Surveiller automatiquement les bibliothèques pour détecter les modifications\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Surveiller automatiquement la bibliothèque pour détecter les modifications\",\n  \"LabelSettingsEnableWatcherHelp\": \"Active la mise à jour automatique d'éléments lorsque des modifications de fichiers sont détectées. * Nécessite le redémarrage du serveur\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Autoriser le contenu scénarisé pour les fichiers EPUB\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Autoriser les fichiers EPUB à exécuter des scripts. Il est recommandé de laisser ce paramètre désactivé, sauf si vous faites confiance à la source des fichiers EPUB.\",\n  \"LabelSettingsExperimentalFeatures\": \"Fonctionnalités expérimentales\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.\",\n  \"LabelSettingsFindCovers\": \"Chercher des couvertures de livre\",\n  \"LabelSettingsFindCoversHelp\": \"Si votre livre audio ne possède aucune couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention : cela peut augmenter le temps d’analyse\",\n  \"LabelSettingsHideSingleBookSeries\": \"Masquer les séries de livres uniques\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Utiliser la vue étagère sur la page d’accueil\",\n  \"LabelSettingsLibraryBookshelfView\": \"Utiliser la vue étagère pour la bibliothèque\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Le pourcentage d'achèvement est supérieur à\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Le temps restant est inférieur à (secondes)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Marquer l’élément multimédia comme terminé lorsque\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Sauter les livres précédents dans « Continuer la série »\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.\",\n  \"LabelSettingsParseSubtitles\": \"Analyser les sous-titres\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Extraire les sous-titres des noms de dossiers de livres audio.<br>Les sous-titres doivent être séparés par « - »<br>Par exemple, « Titre du livre - Un sous-titre » a pour sous-titre « Un sous-titre »\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Préférer les métadonnées par correspondance\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Les métadonnées mises en correspondance remplaceront les détails de l’élément lors de l’utilisation de la correspondance rapide. Par défaut, la correspondance rapide ne remplira que les détails manquants.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Ignorer la recherche par correspondance pour les livres ayant déjà un ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Ignorer la recherche par correspondance pour les livres ayant déjà un ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorer les préfixes lors du tri\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"c’est-à-dire : pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »\",\n  \"LabelSettingsSquareBookCovers\": \"Utiliser des couvertures carrées\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Enregistrer la couverture avec les éléments\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de élément. Seul un fichier nommé « cover » sera conservé\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Enregistrer les métadonnées avec l’élément\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque\",\n  \"LabelSettingsTimeFormat\": \"Format d’heure\",\n  \"LabelShare\": \"Partager\",\n  \"LabelShareDownloadableHelp\": \"Permet aux utilisateurs disposant du lien de partage de télécharger un fichier zip contenant l'élément de la bibliothèque.\",\n  \"LabelShareOpen\": \"Ouvrir le partage\",\n  \"LabelShareURL\": \"Partager l’URL\",\n  \"LabelShowAll\": \"Tout afficher\",\n  \"LabelShowSeconds\": \"Afficher les secondes\",\n  \"LabelShowSubtitles\": \"Afficher les sous-titres\",\n  \"LabelSize\": \"Taille\",\n  \"LabelSleepTimer\": \"Minuterie de mise en veille\",\n  \"LabelSlug\": \"Identifiant d’URL\",\n  \"LabelSortAscending\": \"Croissant\",\n  \"LabelSortDescending\": \"Décroissant\",\n  \"LabelSortPubDate\": \"Trier par date de publication\",\n  \"LabelStart\": \"Démarrer\",\n  \"LabelStartTime\": \"Heure de démarrage\",\n  \"LabelStarted\": \"Démarré\",\n  \"LabelStartedAt\": \"Démarré à\",\n  \"LabelStartedDate\": \"Commencé {0}\",\n  \"LabelStatsAudioTracks\": \"Pistes audio\",\n  \"LabelStatsAuthors\": \"Auteurs\",\n  \"LabelStatsBestDay\": \"Meilleur jour\",\n  \"LabelStatsDailyAverage\": \"Moyenne journalière\",\n  \"LabelStatsDays\": \"Jours\",\n  \"LabelStatsDaysListened\": \"Jours d’écoute\",\n  \"LabelStatsHours\": \"Heures\",\n  \"LabelStatsInARow\": \"d’affilée(s)\",\n  \"LabelStatsItemsFinished\": \"Éléments terminés\",\n  \"LabelStatsItemsInLibrary\": \"Éléments dans la bibliothèque\",\n  \"LabelStatsMinutes\": \"minutes\",\n  \"LabelStatsMinutesListening\": \"Minutes d’écoute\",\n  \"LabelStatsOverallDays\": \"Nombre total de jours\",\n  \"LabelStatsOverallHours\": \"Nombre total d’heures\",\n  \"LabelStatsWeekListening\": \"Écoute de la semaine\",\n  \"LabelSubtitle\": \"Sous-titre\",\n  \"LabelSupportedFileTypes\": \"Types de fichiers supportés\",\n  \"LabelTag\": \"Étiquette\",\n  \"LabelTags\": \"Étiquettes\",\n  \"LabelTagsAccessibleToUser\": \"Étiquettes accessibles à l’utilisateur\",\n  \"LabelTagsNotAccessibleToUser\": \"Étiquettes non accessibles à l’utilisateur\",\n  \"LabelTasks\": \"Tâches en cours\",\n  \"LabelTextEditorBulletedList\": \"Liste à puces\",\n  \"LabelTextEditorLink\": \"Lien\",\n  \"LabelTextEditorNumberedList\": \"Liste numérotée\",\n  \"LabelTextEditorUnlink\": \"Dissocier\",\n  \"LabelTheme\": \"Thème\",\n  \"LabelThemeDark\": \"Sombre\",\n  \"LabelThemeLight\": \"Clair\",\n  \"LabelThemeSepia\": \"Sépia\",\n  \"LabelTimeBase\": \"Base de temps\",\n  \"LabelTimeDurationXHours\": \"{0} heures\",\n  \"LabelTimeDurationXMinutes\": \"{0} minutes\",\n  \"LabelTimeDurationXSeconds\": \"{0} secondes\",\n  \"LabelTimeInMinutes\": \"Temps en minutes\",\n  \"LabelTimeLeft\": \"{0} restant\",\n  \"LabelTimeListened\": \"Temps d’écoute\",\n  \"LabelTimeListenedToday\": \"Nombres d’écoutes aujourd’hui\",\n  \"LabelTimeRemaining\": \"{0} restantes\",\n  \"LabelTimeToShift\": \"Temps de décalage en secondes\",\n  \"LabelTitle\": \"Titre\",\n  \"LabelToolsEmbedMetadata\": \"Métadonnées intégrées\",\n  \"LabelToolsEmbedMetadataDescription\": \"Intègre les métadonnées au fichier audio avec la couverture et les chapitres.\",\n  \"LabelToolsM4bEncoder\": \"Encodeur M4B\",\n  \"LabelToolsMakeM4b\": \"Créer un fichier livre audio M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Générer un fichier de livre audio .M4B avec des métadonnées intégrées, une image de couverture et des chapitres.\",\n  \"LabelToolsSplitM4b\": \"Scinde le fichier M4B en fichiers MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l’image de couverture et les chapitres.\",\n  \"LabelTotalDuration\": \"Durée totale\",\n  \"LabelTotalTimeListened\": \"Temps d’écoute total\",\n  \"LabelTrackFromFilename\": \"Piste depuis le fichier\",\n  \"LabelTrackFromMetadata\": \"Piste depuis les métadonnées\",\n  \"LabelTracks\": \"Pistes\",\n  \"LabelTracksMultiTrack\": \"Piste multiple\",\n  \"LabelTracksNone\": \"Aucune piste\",\n  \"LabelTracksSingleTrack\": \"Piste simple\",\n  \"LabelTrailer\": \"Bande-annonce\",\n  \"LabelType\": \"Type\",\n  \"LabelUnabridged\": \"Version intégrale\",\n  \"LabelUndo\": \"Annuler\",\n  \"LabelUnknown\": \"Inconnu\",\n  \"LabelUnknownPublishDate\": \"Date de publication inconnue\",\n  \"LabelUpdateCover\": \"Mettre à jour la couverture\",\n  \"LabelUpdateCoverHelp\": \"Autoriser la mise à jour de la couverture existante lorsqu’une correspondance est trouvée\",\n  \"LabelUpdateDetails\": \"Mettre à jours les détails\",\n  \"LabelUpdateDetailsHelp\": \"Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée\",\n  \"LabelUpdatedAt\": \"Mis à jour à\",\n  \"LabelUploaderDragAndDrop\": \"Glisser et déposer des fichiers ou dossiers\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Glisser & déposer des fichiers\",\n  \"LabelUploaderDropFiles\": \"Déposer des fichiers\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Récupérer automatiquement le titre, l’auteur et la série\",\n  \"LabelUseAdvancedOptions\": \"Utiliser les options avancées\",\n  \"LabelUseChapterTrack\": \"Utiliser la piste du chapitre\",\n  \"LabelUseFullTrack\": \"Utiliser la piste complète\",\n  \"LabelUseZeroForUnlimited\": \"0 pour illimité\",\n  \"LabelUser\": \"Utilisateur\",\n  \"LabelUsername\": \"Nom d’utilisateur\",\n  \"LabelValue\": \"Valeur\",\n  \"LabelVersion\": \"Version\",\n  \"LabelViewBookmarks\": \"Afficher les favoris\",\n  \"LabelViewChapters\": \"Afficher les chapitres\",\n  \"LabelViewPlayerSettings\": \"Afficher les paramètres du lecteur\",\n  \"LabelViewQueue\": \"Afficher la liste de lecture\",\n  \"LabelVolume\": \"Volume\",\n  \"LabelWebRedirectURLsDescription\": \"Autoriser ces URL dans votre fournisseur OAuth pour permettre la redirection vers l'application web après la connexion :\",\n  \"LabelWebRedirectURLsSubfolder\": \"Sous-dossier pour les URL de redirection\",\n  \"LabelWeekdaysToRun\": \"Jours de la semaine à exécuter\",\n  \"LabelXBooks\": \"{0} livres\",\n  \"LabelXItems\": \"{0} éléments\",\n  \"LabelYearReviewHide\": \"Masquer le bilan de l’année\",\n  \"LabelYearReviewShow\": \"Afficher le bilan de l’année\",\n  \"LabelYourAudiobookDuration\": \"Durée de vos livres audios\",\n  \"LabelYourBookmarks\": \"Vos favoris\",\n  \"LabelYourPlaylists\": \"Mes listes de lecture\",\n  \"LabelYourProgress\": \"Votre progression\",\n  \"MessageAddToPlayerQueue\": \"Ajouter en file d’attente\",\n  \"MessageAppriseDescription\": \"Nécessite une instance d’<a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br />L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Assurez-vous d’utiliser l’ASIN de la bonne région Audible, et non d’Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Les jetons d’API hérités seront supprimés à l’avenir. Utilisez plutôt les <a href=\\\"/config/api-keys\\\">clés API</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"L’authentification a été améliorée pour plus de sécurité. Tous les utilisateurs doivent se reconnecter.\",\n  \"MessageBackupsDescription\": \"Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>n’incluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.\",\n  \"MessageBackupsLocationEditNote\": \"Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes\",\n  \"MessageBackupsLocationNoEditNote\": \"Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.\",\n  \"MessageBackupsLocationPathEmpty\": \"L'emplacement de secours ne peut pas être vide\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Renseignez les champs activés avec les données de tous les éléments. Les champs comportant plusieurs valeurs seront fusionnés.\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Renseigner les champs de la carte active avec les informations de cet élément\",\n  \"MessageBatchQuickMatchDescription\": \"La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.\",\n  \"MessageBookshelfNoCollections\": \"Vous n’avez pas encore de collections\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Les collections sont publiques. Tous les utilisateurs ayant accès à la bibliothèque pourront les voir.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Aucun flux RSS n’est ouvert\",\n  \"MessageBookshelfNoResultsForFilter\": \"Aucun résultat pour le filtre « {0} : {1} »\",\n  \"MessageBookshelfNoResultsForQuery\": \"Aucun résultat pour la requête\",\n  \"MessageBookshelfNoSeries\": \"Vous n’avez aucune série\",\n  \"MessageBulkChapterPattern\": \"Combien de chapitres souhaitez-vous ajouter avec ce motif de numérotation ?\",\n  \"MessageChapterEndIsAfter\": \"La fin du chapitre se situe après la fin de votre livre audio\",\n  \"MessageChapterErrorFirstNotZero\": \"Le premier capitre doit débuter à 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Horodatage invalide car il doit débuter avant la fin du livre\",\n  \"MessageChapterErrorStartLtPrev\": \"Horodatage invalide car il doit débuter au moins après le précédent chapitre\",\n  \"MessageChapterStartIsAfter\": \"Le premier chapitre est situé au début de votre livre audio\",\n  \"MessageChaptersNotFound\": \"Chapitres non trouvés\",\n  \"MessageCheckingCron\": \"Vérification du cron…\",\n  \"MessageConfirmCloseFeed\": \"Êtes-vous sûr·e de vouloir fermer ce flux ?\",\n  \"MessageConfirmDeleteApiKey\": \"Êtes-vous sûr de vouloir supprimer la clé API « {0} » ?\",\n  \"MessageConfirmDeleteBackup\": \"Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?\",\n  \"MessageConfirmDeleteDevice\": \"Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?\",\n  \"MessageConfirmDeleteFile\": \"Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?\",\n  \"MessageConfirmDeleteLibrary\": \"Êtes-vous sûr·e de vouloir supprimer définitivement la bibliothèque « {0} » ?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Cette opération supprimera l’élément de la base de données et de votre système de fichiers. Êtes-vous sûr ?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Êtes-vous sûr·e de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?\",\n  \"MessageConfirmDeleteNotification\": \"Êtes-vous sûr·e de vouloir supprimer cette notification ?\",\n  \"MessageConfirmDeleteSession\": \"Êtes-vous sûr·e de vouloir supprimer cette session ?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio ?\",\n  \"MessageConfirmForceReScan\": \"Êtes-vous sûr·e de vouloir lancer une analyse forcée ?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?\",\n  \"MessageConfirmMarkItemFinished\": \"Êtes-vous sûr·e de vouloir marquer {0} comme terminé ?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Êtes-vous sûr·e de vouloir marquer {0} comme non terminé ?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Êtes-vous sûr·e de vouloir marquer tous les livres de cette série comme terminées ?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Êtes-vous sûr·e de vouloir marquer tous les livres de cette série comme non terminés ?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Déclencher cette notification avec des données de test ?\",\n  \"MessageConfirmPurgeCache\": \"La purge du cache supprimera l’intégralité du répertoire à <code>/metadata/cache</code>.<br /><br />Êtes-vous sûr·e de vouloir supprimer le répertoire de cache ?\",\n  \"MessageConfirmPurgeItemsCache\": \"Purger le cache des éléments supprimera l'ensemble du répertoire <code>/metadata/cache/items</code>.<br />Êtes-vous sûr ?\",\n  \"MessageConfirmQuickEmbed\": \"Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.<br><br>Êtes-vous sûr·e de vouloir continuer ?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Les épisodes correspondants seront écrasés si une correspondance est trouvée. Seuls les épisodes non correspondants seront mis à jour. Êtes-vous sûr·e ?\",\n  \"MessageConfirmReScanLibraryItems\": \"Êtes-vous sûr·e de vouloir réanalyser {0} éléments ?\",\n  \"MessageConfirmRemoveAllChapters\": \"Êtes-vous sûr·e de vouloir supprimer tous les chapitres ?\",\n  \"MessageConfirmRemoveAuthor\": \"Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?\",\n  \"MessageConfirmRemoveCollection\": \"Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?\",\n  \"MessageConfirmRemoveEpisode\": \"Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Remarque : cela ne supprime pas le fichier audio, sauf si vous activez « Supprimer définitivement le fichier »\",\n  \"MessageConfirmRemoveEpisodes\": \"Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers d’éléments de votre bibliothèque ?\",\n  \"MessageConfirmRemoveNarrator\": \"Êtes-vous sûr·e de vouloir supprimer le narrateur « {0} » ?\",\n  \"MessageConfirmRemovePlaylist\": \"Êtes-vous sûr·e de vouloir supprimer la liste de lecture « {0} » ?\",\n  \"MessageConfirmRenameGenre\": \"Êtes-vous sûr·e de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Information : ce genre existe déjà et sera fusionné.\",\n  \"MessageConfirmRenameGenreWarning\": \"Attention ! Un genre similaire avec une casse différente existe déjà « {0} ».\",\n  \"MessageConfirmRenameTag\": \"Êtes-vous sûr·e de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les éléments ?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Information : Cette étiquette existe déjà et sera fusionnée.\",\n  \"MessageConfirmRenameTagWarning\": \"Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».\",\n  \"MessageConfirmResetProgress\": \"Êtes-vous sûr·e de vouloir réinitialiser votre progression ?\",\n  \"MessageConfirmSendEbookToDevice\": \"Êtes-vous sûr·e de vouloir envoyer {0} livre numérique « {1} » à l'appareil « {2} » ?\",\n  \"MessageConfirmUnlinkOpenId\": \"Êtes-vous sûr·e de vouloir dissocier cet utilisateur d’OpenID ?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} jours écoutés l'an dernier\",\n  \"MessageDownloadingEpisode\": \"Téléchargement de l’épisode\",\n  \"MessageDragFilesIntoTrackOrder\": \"Faites glisser les fichiers dans l’ordre correct des pistes\",\n  \"MessageEmbedFailed\": \"Échec de l’intégration !\",\n  \"MessageEmbedFinished\": \"Intégration terminée !\",\n  \"MessageEmbedQueue\": \"En file d'attente pour l'intégration des métadonnées ({0} dans la file d'attente)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} épisode(s) mis en file pour téléchargement\",\n  \"MessageEreaderDevices\": \"Pour garantir l’envoi des livres électroniques, vous devrez peut-être ajouter le courriel ci-dessus comme expéditeur valide pour chaque appareil répertorié ci-dessous.\",\n  \"MessageFeedURLWillBe\": \"L’URL du flux sera {0}\",\n  \"MessageFetching\": \"Récupération…\",\n  \"MessageForceReScanDescription\": \"analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} À l’écoute</strong> sur {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Aucune session en cours sur {0}\",\n  \"MessageImportantNotice\": \"Information importante !\",\n  \"MessageInsertChapterBelow\": \"Insérer le chapitre ci-dessous\",\n  \"MessageInvalidAsin\": \"ASIN invalide\",\n  \"MessageItemsSelected\": \"{0} éléments sélectionnés\",\n  \"MessageItemsUpdated\": \"{0} éléments mis à jour\",\n  \"MessageJoinUsOn\": \"Rejoignez-nous sur\",\n  \"MessageLoading\": \"Chargement…\",\n  \"MessageLoadingFolders\": \"Chargement des dossiers…\",\n  \"MessageLogsDescription\": \"Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux d’incidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"Échec de la conversion en M4B !\",\n  \"MessageM4BFinished\": \"M4B terminé !\",\n  \"MessageMapChapterTitles\": \"Faire correspondre les titres de chapitres avec ceux de vos livres audio existants sans ajuster les horodatages\",\n  \"MessageMarkAllEpisodesFinished\": \"Marquer tous les épisodes terminés\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Marquer tous les épisodes non terminés\",\n  \"MessageMarkAsFinished\": \"Marquer comme terminé\",\n  \"MessageMarkAsNotFinished\": \"Marquer comme non terminé\",\n  \"MessageMatchBooksDescription\": \"tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N’écrase pas les données existantes.\",\n  \"MessageNoAudioTracks\": \"Aucune piste audio\",\n  \"MessageNoAuthors\": \"Aucun auteur\",\n  \"MessageNoBackups\": \"Aucune sauvegarde\",\n  \"MessageNoBookmarks\": \"Aucun favoris\",\n  \"MessageNoChapters\": \"Aucun chapitre\",\n  \"MessageNoCollections\": \"Aucune collection\",\n  \"MessageNoCoversFound\": \"Aucune couverture trouvée\",\n  \"MessageNoDescription\": \"Aucune description\",\n  \"MessageNoDevices\": \"Aucun appareil\",\n  \"MessageNoDownloadsInProgress\": \"Aucun téléchargement en cours\",\n  \"MessageNoDownloadsQueued\": \"Aucun téléchargement en attente\",\n  \"MessageNoEpisodeMatchesFound\": \"Aucune correspondance d’épisode trouvée\",\n  \"MessageNoEpisodes\": \"Aucun épisode\",\n  \"MessageNoFoldersAvailable\": \"Aucun dossiers disponible\",\n  \"MessageNoGenres\": \"Aucun genre\",\n  \"MessageNoIssues\": \"Aucune parution\",\n  \"MessageNoItems\": \"Aucun élément\",\n  \"MessageNoItemsFound\": \"Aucun élément trouvé\",\n  \"MessageNoListeningSessions\": \"Aucune session d’écoute en cours\",\n  \"MessageNoLogs\": \"Aucun journal\",\n  \"MessageNoMediaProgress\": \"Aucun média en cours\",\n  \"MessageNoNotifications\": \"Aucune notification\",\n  \"MessageNoPodcastFeed\": \"Podcast invalide : pas de flux\",\n  \"MessageNoPodcastsFound\": \"Aucun podcast trouvé\",\n  \"MessageNoResults\": \"Aucun résultat\",\n  \"MessageNoSearchResultsFor\": \"Aucun résultat pour la recherche « {0} »\",\n  \"MessageNoSeries\": \"Aucune série\",\n  \"MessageNoTags\": \"Aucune étiquette\",\n  \"MessageNoTasksRunning\": \"Aucune tâche en cours\",\n  \"MessageNoUpdatesWereNecessary\": \"Aucune mise à jour n’était nécessaire\",\n  \"MessageNoUserPlaylists\": \"Vous n’avez aucune liste de lecture\",\n  \"MessageNoUserPlaylistsHelp\": \"Les playlists sont privées. Seul l’utilisateur qui les crée peut les voir.\",\n  \"MessageNotYetImplemented\": \"Non implémenté\",\n  \"MessageOpmlPreviewNote\": \"Remarque : Il s’agit d’un aperçu du fichier OPML analysé. Le titre réel du podcast provient du flux RSS.\",\n  \"MessageOr\": \"ou\",\n  \"MessagePauseChapter\": \"Suspendre la lecture du chapitre\",\n  \"MessagePlayChapter\": \"Écouter depuis le début du chapitre\",\n  \"MessagePlaylistCreateFromCollection\": \"Créer une liste de lecture depuis la collection\",\n  \"MessagePleaseWait\": \"Merci de patienter…\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Le Podcast n’a pas d’URL de flux RSS à utiliser pour la correspondance\",\n  \"MessagePodcastSearchField\": \"Saisir un terme de recherche ou l'URL d'un flux RSS\",\n  \"MessageQuickEmbedInProgress\": \"Intégration rapide en cours\",\n  \"MessageQuickEmbedQueue\": \"En file d'attente pour une intégration rapide ({0} dans la file d'attente)\",\n  \"MessageQuickMatchAllEpisodes\": \"Associer rapidement tous les épisodes\",\n  \"MessageQuickMatchDescription\": \"Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.\",\n  \"MessageRemoveChapter\": \"Supprimer le chapitre\",\n  \"MessageRemoveEpisodes\": \"Suppression de {0} épisode(s)\",\n  \"MessageRemoveFromPlayerQueue\": \"Supprimer de la liste d’écoute\",\n  \"MessageRemoveUserWarning\": \"Êtes-vous sûr·e de vouloir supprimer définitivement l’utilisateur « {0} » ?\",\n  \"MessageReportBugsAndContribute\": \"Signalez des anomalies, demandez des fonctionnalités et contribuez sur\",\n  \"MessageResetChaptersConfirm\": \"Êtes-vous sûr·e de vouloir réinitialiser les chapitres et annuler les changements effectués ?\",\n  \"MessageRestoreBackupConfirm\": \"Êtes-vous sûr·e de vouloir restaurer la sauvegarde créée le\",\n  \"MessageRestoreBackupWarning\": \"Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br><br>Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br><br>Tous les clients utilisant votre serveur seront automatiquement mis à jour.\",\n  \"MessageScheduleLibraryScanNote\": \"Pour la plupart des utilisateurs, il est recommandé de laisser cette fonctionnalité désactivée et de maintenir le paramètre « Surveiller automatiquement la bibliothèque pour détecter les modifications » activé – il détectera automatiquement les modifications dans les dossiers de votre bibliothèque. Activez cette fonctionnalité si l'option « Surveiller automatiquement la bibliothèque pour détecter les modifications » ne fonctionne pas pour votre système de fichiers (comme NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Exécuté tous les {0} à {1}\",\n  \"MessageSearchResultsFor\": \"Résultats de recherche pour\",\n  \"MessageSelected\": \"{0} sélectionnés\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"La séquence de séries ne peut pas contenir d’espaces\",\n  \"MessageServerCouldNotBeReached\": \"Serveur inaccessible\",\n  \"MessageSetChaptersFromTracksDescription\": \"Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre\",\n  \"MessageShareExpirationWillBe\": \"Expire le <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Expire dans {0}\",\n  \"MessageShareURLWillBe\": \"L’adresse de partage sera <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Démarrer la lecture pour « {0} » à {1} ?\",\n  \"MessageTaskAudioFileNotWritable\": \"Le fichier audio « {0} » n’est pas accessible en écriture\",\n  \"MessageTaskCanceledByUser\": \"Tâche annulée par l’utilisateur\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Téléchargement de l'épisode « {0} »\",\n  \"MessageTaskEmbeddingMetadata\": \"Intégration de métadonnées\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Intégration de métadonnées dans le livre audio « {0} »\",\n  \"MessageTaskEncodingM4b\": \"Encodage M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Encodage du livre audio « {0} » dans un seul fichier M4B\",\n  \"MessageTaskFailed\": \"Échec\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Échec de la sauvegarde du fichier audio « {0} »\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Échec de la création du répertoire de cache\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Échec de l'intégration des métadonnées dans le fichier « {0} »\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Échec de la fusion des fichiers audio\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Échec du déplacement du fichier M4B\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Échec de l’écriture du fichier de métadonnées\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Livres correspondants dans la bibliothèque « {0} »\",\n  \"MessageTaskNoFilesToScan\": \"Aucun fichier à analyser\",\n  \"MessageTaskOpmlImport\": \"Importation OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Création de podcasts à partir de {0} flux RSS\",\n  \"MessageTaskOpmlImportFeed\": \"Flux d’importation OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importation du flux RSS « {0} »\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Échec de l’obtention du flux de podcast\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Création du podcast « {0} »\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Le podcast existe déjà à cet emplacement\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Échec de la création du podcast\",\n  \"MessageTaskOpmlImportFinished\": \"Ajout de {0} podcasts\",\n  \"MessageTaskOpmlParseFailed\": \"Échec de l'analyse du fichier OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"Balise <opml> de fichier OPML non valide introuvable OU une balise <outline> n’a pas été trouvée\",\n  \"MessageTaskOpmlParseNoneFound\": \"Aucun flux trouvé dans le fichier OPML\",\n  \"MessageTaskScanItemsAdded\": \"{0} ajouté\",\n  \"MessageTaskScanItemsMissing\": \"{0} manquant\",\n  \"MessageTaskScanItemsUpdated\": \"{0} mis à jour\",\n  \"MessageTaskScanNoChangesNeeded\": \"Aucun changement nécessaire\",\n  \"MessageTaskScanningFileChanges\": \"Analyse des modifications du fichier dans « {0} »\",\n  \"MessageTaskScanningLibrary\": \"Analyse de la bibliothèque « {0} »\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Le répertoire cible n’est pas accessible en écriture\",\n  \"MessageThinking\": \"À la recherche de…\",\n  \"MessageUploaderItemFailed\": \"Échec du téléversement\",\n  \"MessageUploaderItemSuccess\": \"Téléversement effectué !\",\n  \"MessageUploading\": \"Téléchargement…\",\n  \"MessageValidCronExpression\": \"Expression cron valide\",\n  \"MessageWatcherIsDisabledGlobally\": \"La surveillance est désactivée par un paramètre global du serveur\",\n  \"MessageXLibraryIsEmpty\": \"La bibliothèque {0} est vide !\",\n  \"MessageYourAudiobookDurationIsLonger\": \"La durée de votre livre audio est plus longue que la durée trouvée\",\n  \"MessageYourAudiobookDurationIsShorter\": \"La durée de votre livre audio est plus courte que la durée trouvée\",\n  \"NoteChangeRootPassword\": \"seul l’utilisateur « root » peut utiliser un mot de passe vide\",\n  \"NoteChapterEditorTimes\": \"Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.\",\n  \"NoteFolderPicker\": \"Information : les dossiers déjà surveillés ne sont pas affichés\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Attention : la majorité des application de podcast nécessite une adresse de flux HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.\",\n  \"NoteUploaderUnsupportedFiles\": \"Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.\",\n  \"NotificationOnBackupCompletedDescription\": \"Déclenché lorsqu’une sauvegarde est terminée\",\n  \"NotificationOnBackupFailedDescription\": \"Déclenché lorsqu'une sauvegarde échoue\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Déclenché lorsqu’un épisode de podcast est téléchargé automatiquement\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Déclenché lorsque les téléchargements automatiques d’épisodes sont désactivés en raison d’un trop grand nombre de tentatives infructueuses\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Déclenché lorsque la demande de flux RSS échoue pour un téléchargement automatique d’épisode\",\n  \"NotificationOnTestDescription\": \"Événement pour tester le système de notification\",\n  \"PlaceholderBulkChapterInput\": \"Entrez le titre du chapitre ou utilisez la numérotation (ex. 'Épisode 1', 'Chapitre 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Nom de la nouvelle collection\",\n  \"PlaceholderNewFolderPath\": \"Nouveau chemin de dossier\",\n  \"PlaceholderNewPlaylist\": \"Nouveau nom de liste de lecture\",\n  \"PlaceholderSearch\": \"Recherche...\",\n  \"PlaceholderSearchEpisode\": \"Rechercher un épisode…\",\n  \"StatsAuthorsAdded\": \"auteurs ajoutés\",\n  \"StatsBooksAdded\": \"livres ajoutés\",\n  \"StatsBooksAdditional\": \"Les ajouts comprennent…\",\n  \"StatsBooksFinished\": \"livres terminés\",\n  \"StatsBooksFinishedThisYear\": \"Quelques livres terminés cette année…\",\n  \"StatsBooksListenedTo\": \"livres écoutés\",\n  \"StatsCollectionGrewTo\": \"Votre collection de livres a atteint…\",\n  \"StatsSessions\": \"sessions\",\n  \"StatsSpentListening\": \"temps passé à écouter\",\n  \"StatsTopAuthor\": \"TOP AUTEUR\",\n  \"StatsTopAuthors\": \"TOP AUTEURS\",\n  \"StatsTopGenre\": \"TOP GENRE\",\n  \"StatsTopGenres\": \"TOP GENRES\",\n  \"StatsTopMonth\": \"TOP MOIS\",\n  \"StatsTopNarrator\": \"TOP NARRATEUR\",\n  \"StatsTopNarrators\": \"TOP NARRATEURS\",\n  \"StatsTotalDuration\": \"Pour une durée totale de…\",\n  \"StatsYearInReview\": \"BILAN DE L’ANNÉE\",\n  \"ToastAccountUpdateSuccess\": \"Compte mis à jour\",\n  \"ToastAppriseUrlRequired\": \"Vous devez entrer une URL Apprise\",\n  \"ToastAsinRequired\": \"ASIN requis\",\n  \"ToastAuthorImageRemoveSuccess\": \"Image de l’auteur supprimée\",\n  \"ToastAuthorNotFound\": \"Auteur \\\"{0}\\\" non trouvé\",\n  \"ToastAuthorRemoveSuccess\": \"Auteur supprimé\",\n  \"ToastAuthorSearchNotFound\": \"Auteur non trouvé\",\n  \"ToastAuthorUpdateMerged\": \"Auteur fusionné\",\n  \"ToastAuthorUpdateSuccess\": \"Auteur mis à jour\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Auteur mis à jour (aucune image trouvée)\",\n  \"ToastBackupAppliedSuccess\": \"Sauvegarde appliquée\",\n  \"ToastBackupCreateFailed\": \"Échec de la création de sauvegarde\",\n  \"ToastBackupCreateSuccess\": \"Sauvegarde créée\",\n  \"ToastBackupDeleteFailed\": \"Échec de la suppression de sauvegarde\",\n  \"ToastBackupDeleteSuccess\": \"Sauvegarde supprimée\",\n  \"ToastBackupInvalidMaxKeep\": \"Nombre de sauvegardes à conserver invalide\",\n  \"ToastBackupInvalidMaxSize\": \"Taille maximale de sauvegarde invalide\",\n  \"ToastBackupRestoreFailed\": \"Échec de la restauration de sauvegarde\",\n  \"ToastBackupUploadFailed\": \"Échec du téléversement de sauvegarde\",\n  \"ToastBackupUploadSuccess\": \"Sauvegarde téléversée\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Détails appliqués aux articles\",\n  \"ToastBatchDeleteFailed\": \"Échec de la suppression par lot\",\n  \"ToastBatchDeleteSuccess\": \"Suppression par lot réussie\",\n  \"ToastBatchQuickMatchFailed\": \"Échec de la correspondance rapide par lot !\",\n  \"ToastBatchQuickMatchStarted\": \"La correspondance rapide par lots de {0} livres a commencé !\",\n  \"ToastBatchUpdateFailed\": \"Échec de la mise à jour par lot\",\n  \"ToastBatchUpdateSuccess\": \"Mise à jour par lot terminée\",\n  \"ToastBookmarkCreateFailed\": \"Échec de la création de signet\",\n  \"ToastBookmarkCreateSuccess\": \"Signet ajouté\",\n  \"ToastBookmarkRemoveSuccess\": \"Signet supprimé\",\n  \"ToastBulkChapterInvalidCount\": \"Veuillez entrer un nombre valide entre 1 et 150\",\n  \"ToastCachePurgeFailed\": \"Échec de la purge du cache\",\n  \"ToastCachePurgeSuccess\": \"Cache purgé avec succès\",\n  \"ToastChapterLocked\": \"Le chapitre est verrouillé.\",\n  \"ToastChapterStartTimeAdjusted\": \"Début du chapitre ajusté de {0} secondes\",\n  \"ToastChaptersAllLocked\": \"Tous les chapitres sont verrouillés. Déverrouillez certains chapitres pour décaler leurs temps.\",\n  \"ToastChaptersHaveErrors\": \"Les chapitres contiennent des erreurs\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Durée de décalage non valide. L’heure de début du dernier chapitre pourrait dépasser la durée de ce livre audio.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Durée de décalage non valide. Le premier chapitre aurait une longueur nulle ou négative et serait écrasé par le second. Augmentez la durée de début du second chapitre.\",\n  \"ToastChaptersMustHaveTitles\": \"Les chapitre doivent avoir un titre\",\n  \"ToastChaptersRemoved\": \"Chapitres supprimés\",\n  \"ToastChaptersUpdated\": \"Chapitres mis à jour\",\n  \"ToastCollectionItemsAddFailed\": \"Échec de l’ajout de(s) élément(s) à la collection\",\n  \"ToastCollectionRemoveSuccess\": \"Collection supprimée\",\n  \"ToastCollectionUpdateSuccess\": \"Collection mise à jour\",\n  \"ToastConnectionNotAvailable\": \"Connexion indisponible. Veuillez réessayer plus tard.\",\n  \"ToastCoverSearchFailed\": \"La recherche de la couverture a échoué\",\n  \"ToastCoverUpdateFailed\": \"Échec de la mise à jour de la couverture\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"La date et l'heure sont invalides ou incomplètes\",\n  \"ToastDeleteFileFailed\": \"Échec de la suppression du fichier\",\n  \"ToastDeleteFileSuccess\": \"Fichier supprimé\",\n  \"ToastDeviceAddFailed\": \"Échec de l’ajout de l’appareil\",\n  \"ToastDeviceNameAlreadyExists\": \"Un appareil de lecture avec ce nom existe déjà\",\n  \"ToastDeviceTestEmailFailed\": \"Échec de l’envoi du courriel de test\",\n  \"ToastDeviceTestEmailSuccess\": \"Courriel de test envoyé\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Paramètres de messagerie mis à jour\",\n  \"ToastEncodeCancelFailed\": \"Échec de l’annulation de l’encodage\",\n  \"ToastEncodeCancelSucces\": \"Encodage annulé\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Échec de la suppression de la file d'attente\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"File d’attente de téléchargement des épisodes effacée\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} épisodes mis à jour\",\n  \"ToastErrorCannotShare\": \"Impossible de partager nativement sur cet appareil\",\n  \"ToastFailedToCreate\": \"Échec de la création\",\n  \"ToastFailedToDelete\": \"Échec de la suppression\",\n  \"ToastFailedToLoadData\": \"Échec du chargement des données\",\n  \"ToastFailedToMatch\": \"Échec de la correspondance\",\n  \"ToastFailedToShare\": \"Échec du partage\",\n  \"ToastFailedToUpdate\": \"Échec de la mise à jour\",\n  \"ToastInvalidImageUrl\": \"URL de l'image invalide\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Nombre maximum d’épisodes à télécharger non valide\",\n  \"ToastInvalidUrl\": \"URL invalide\",\n  \"ToastInvalidUrls\": \"Une ou plusieurs URL sont invalides\",\n  \"ToastItemCoverUpdateSuccess\": \"Couverture mise à jour\",\n  \"ToastItemDeletedFailed\": \"La suppression de l'élément à échouée\",\n  \"ToastItemDeletedSuccess\": \"Élément supprimé\",\n  \"ToastItemDetailsUpdateSuccess\": \"Détails de l’élément mis à jour\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Échec de l’annotation terminée\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Article marqué comme terminé\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Échec de l’annotation non-terminée\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Article marqué comme non-terminé\",\n  \"ToastItemUpdateSuccess\": \"Élément mis a jour\",\n  \"ToastLibraryCreateFailed\": \"Échec de la création de bibliothèque\",\n  \"ToastLibraryCreateSuccess\": \"Bibliothèque « {0} » créée\",\n  \"ToastLibraryDeleteFailed\": \"Échec de la suppression de la bibliothèque\",\n  \"ToastLibraryDeleteSuccess\": \"Bibliothèque supprimée\",\n  \"ToastLibraryScanFailedToStart\": \"Échec du démarrage de l’analyse\",\n  \"ToastLibraryScanStarted\": \"Analyse de la bibliothèque démarrée\",\n  \"ToastLibraryUpdateSuccess\": \"Bibliothèque « {0} » mise à jour\",\n  \"ToastMatchAllAuthorsFailed\": \"Tous les auteurs et autrices n’ont pas pu être classés\",\n  \"ToastMetadataFilesRemovedError\": \"Erreur lors de la suppression des fichiers « metadata.{0} »\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Aucun fichier « metadata.{0} » trouvé dans la bibliothèque\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Aucun fichier « metadata.{0} » n’a été supprimé\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} fichiers metadata.{1} supprimés\",\n  \"ToastMustHaveAtLeastOnePath\": \"Doit avoir au moins un chemin\",\n  \"ToastNameEmailRequired\": \"Le nom et le courriel sont requis\",\n  \"ToastNameRequired\": \"Le nom est requis\",\n  \"ToastNewApiKeyUserError\": \"Vous devez sélectionner un utilisateur\",\n  \"ToastNewEpisodesFound\": \"{0} nouveaux épisodes trouvés\",\n  \"ToastNewUserCreatedFailed\": \"La création du compte à échouée : « {0} »\",\n  \"ToastNewUserCreatedSuccess\": \"Nouveau compte créé\",\n  \"ToastNewUserLibraryError\": \"Au moins une bibliothèque est requise\",\n  \"ToastNewUserPasswordError\": \"Un mot de passe est requis, seul l’utilisateur root peut avoir un mot de passe vide\",\n  \"ToastNewUserTagError\": \"Au moins une étiquette est requise\",\n  \"ToastNewUserUsernameError\": \"Entrez un nom d’utilisateur\",\n  \"ToastNoNewEpisodesFound\": \"Aucun nouvel épisode trouvé\",\n  \"ToastNoRSSFeed\": \"Le podcast n'a pas de flux RSS\",\n  \"ToastNoUpdatesNecessary\": \"Aucune mise à jour nécessaire\",\n  \"ToastNotificationCreateFailed\": \"La création de la notification à échouée\",\n  \"ToastNotificationDeleteFailed\": \"La suppression de la notification à échouée\",\n  \"ToastNotificationFailedMaximum\": \"Le nombre maximum de tentatives échouées doit être >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Le nombre de notification maximum doit être >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Paramètres de notification mis à jour\",\n  \"ToastNotificationTestTriggerFailed\": \"L'envoi de la notification de test à échoué\",\n  \"ToastNotificationTestTriggerSuccess\": \"Notification de test déclenchée\",\n  \"ToastNotificationUpdateSuccess\": \"Notification mise à jour\",\n  \"ToastPlaylistCreateFailed\": \"Échec de la création de la liste de lecture\",\n  \"ToastPlaylistCreateSuccess\": \"Liste de lecture créée\",\n  \"ToastPlaylistRemoveSuccess\": \"Liste de lecture supprimée\",\n  \"ToastPlaylistUpdateSuccess\": \"Liste de lecture mise à jour\",\n  \"ToastPodcastCreateFailed\": \"Échec de la création du podcast\",\n  \"ToastPodcastCreateSuccess\": \"Podcast créé avec succès\",\n  \"ToastPodcastEpisodeUpdated\": \"Épisode mis à jour\",\n  \"ToastPodcastGetFeedFailed\": \"Échec de la récupération du flux du podcast\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Aucun épisode trouvé dans le flux RSS\",\n  \"ToastPodcastNoRssFeed\": \"Le podcast n’a pas de flux RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"La progression n’est pas synchronisée, redémarrez la lecture\",\n  \"ToastProviderCreatedFailed\": \"Échec de l’ajout du fournisseur\",\n  \"ToastProviderCreatedSuccess\": \"Nouveau fournisseur ajouté\",\n  \"ToastProviderNameAndUrlRequired\": \"Nom et URL requis\",\n  \"ToastProviderRemoveSuccess\": \"Fournisseur supprimé\",\n  \"ToastRSSFeedCloseFailed\": \"Échec de la fermeture du flux RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"Flux RSS fermé\",\n  \"ToastRemoveFailed\": \"Échec de la suppression\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Échec de la suppression d’un élément de la collection\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Élément supprimé de la collection\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Échec de la suppression des éléments de bibliothèque présentant des problèmes\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Éléments de bibliothèque supprimés avec des problèmes\",\n  \"ToastRenameFailed\": \"Échec du renommage\",\n  \"ToastRescanFailed\": \"Échec de la nouvelle analyse pour {0}\",\n  \"ToastRescanRemoved\": \"Nouvelle analyse terminée, l’élément a été supprimé\",\n  \"ToastRescanUpToDate\": \"Nouvelle analyse terminée, l’élément était déjà à jour\",\n  \"ToastRescanUpdated\": \"Nouvelle analyse terminée, l’élément a été mis à jour\",\n  \"ToastScanFailed\": \"Échec de l’analyse de l’élément de la bibliothèque\",\n  \"ToastSelectAtLeastOneUser\": \"Sélectionnez au moins un utilisateur\",\n  \"ToastSendEbookToDeviceFailed\": \"Échec de l’envoi du livre numérique à l’appareil\",\n  \"ToastSendEbookToDeviceSuccess\": \"Livre numérique envoyé à l’appareil : {0}\",\n  \"ToastSeriesSubmitFailedSameName\": \"Impossible d’ajouter deux séries ayant le même nom\",\n  \"ToastSeriesUpdateFailed\": \"Échec de la mise à jour de la série\",\n  \"ToastSeriesUpdateSuccess\": \"Mise à jour de la série réussie\",\n  \"ToastServerSettingsUpdateSuccess\": \"Mise à jour des paramètres du serveur\",\n  \"ToastSessionCloseFailed\": \"Échec de la fermeture de la session\",\n  \"ToastSessionDeleteFailed\": \"Échec de la suppression de session\",\n  \"ToastSessionDeleteSuccess\": \"Session supprimée\",\n  \"ToastSleepTimerDone\": \"Minuterie de mise en veille terminée… zZzzZz\",\n  \"ToastSlugMustChange\": \"L’identifiant d’URL contient des caractères invalides\",\n  \"ToastSlugRequired\": \"L’identifiant d’URL est requis\",\n  \"ToastSocketConnected\": \"WebSocket connecté\",\n  \"ToastSocketDisconnected\": \"WebSocket déconnecté\",\n  \"ToastSocketFailedToConnect\": \"Échec de la connexion WebSocket\",\n  \"ToastSortingPrefixesEmptyError\": \"Doit avoir au moins 1 préfixe de tri\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Mise à jour des préfixes de tri ({0} élément)\",\n  \"ToastTitleRequired\": \"Le titre est requis\",\n  \"ToastUnknownError\": \"Erreur inconnue\",\n  \"ToastUnlinkOpenIdFailed\": \"Échec de la dissociation de l’utilisateur l’OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Utilisateur dissocié de OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Le chemin de fichier « {0} » existe déjà sur le serveur\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"L’élément « {0} » utilise un sous-répertoire du chemin de téléchargement.\",\n  \"ToastUserDeleteFailed\": \"Échec de la suppression de l’utilisateur\",\n  \"ToastUserDeleteSuccess\": \"Utilisateur supprimé\",\n  \"ToastUserPasswordChangeSuccess\": \"Mot de passe modifié avec succès\",\n  \"ToastUserPasswordMismatch\": \"Les mots de passe ne correspondent pas\",\n  \"ToastUserPasswordMustChange\": \"Le nouveau mot de passe ne peut pas être identique à l’ancien\",\n  \"ToastUserRootRequireName\": \"Vous devez entrer un nom d’utilisateur root\",\n  \"TooltipAddChapters\": \"Ajouter chapitre(s)\",\n  \"TooltipAddOneSecond\": \"Ajouter 1 seconde\",\n  \"TooltipAdjustChapterStart\": \"Cliquez pour régler l'heure de début\",\n  \"TooltipLockAllChapters\": \"Verrouiller tous les chapitres\",\n  \"TooltipLockChapter\": \"Verrouiller le chapitre (Maj+clic pour plage)\",\n  \"TooltipSubtractOneSecond\": \"Soustraire 1 seconde\",\n  \"TooltipUnlockAllChapters\": \"Déverrouiller tous les chapitres\",\n  \"TooltipUnlockChapter\": \"Déverrouiller le chapitre (Maj+clic pour plage)\"\n}\n"
  },
  {
    "path": "client/strings/gu.json",
    "content": "{\n  \"ButtonAdd\": \"ઉમેરો\",\n  \"ButtonAddChapters\": \"પ્રકરણો ઉમેરો\",\n  \"ButtonAddDevice\": \"ઉપકરણ ઉમેરો\",\n  \"ButtonAddLibrary\": \"પુસ્તકાલય ઉમેરો\",\n  \"ButtonAddPodcasts\": \"પોડકાસ્ટ ઉમેરો\",\n  \"ButtonAddUser\": \"વપરાશકર્તા ઉમેરો\",\n  \"ButtonAddYourFirstLibrary\": \"તમારી પ્રથમ પુસ્તકાલય ઉમેરો\",\n  \"ButtonApply\": \"લાગુ કરો\",\n  \"ButtonApplyChapters\": \"પ્રકરણો લાગુ કરો\",\n  \"ButtonAuthors\": \"લેખકો\",\n  \"ButtonBack\": \"પાછા\",\n  \"ButtonBatchEditPopulateFromExisting\": \"હાલની માહિતીમાંથી ભરો\",\n  \"ButtonBatchEditPopulateMapDetails\": \"નકશાની વિગત ભરો\",\n  \"ButtonBrowseForFolder\": \"ફોલ્ડર માટે જુઓ\",\n  \"ButtonCancel\": \"રદ કરો\",\n  \"ButtonCancelEncode\": \"એન્કોડ રદ કરો\",\n  \"ButtonChangeRootPassword\": \"રૂટ પાસવર્ડ બદલો\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"નવા એપિસોડ્સ ચેક કરો અને ડાઉનલોડ કરો\",\n  \"ButtonChooseAFolder\": \"ફોલ્ડર પસંદ કરો\",\n  \"ButtonChooseFiles\": \"ફાઇલો પસંદ કરો\",\n  \"ButtonClearFilter\": \"ફિલ્ટર જતુ કરો\",\n  \"ButtonCloseFeed\": \"ફીડ બંધ કરો\",\n  \"ButtonCollections\": \"સંગ્રહ\",\n  \"ButtonConfigureScanner\": \"સ્કેનર સેટિંગ બદલો\",\n  \"ButtonCreate\": \"બનાવો\",\n  \"ButtonCreateBackup\": \"બેકઅપ બનાવો\",\n  \"ButtonDelete\": \"કાઢી નાખો\",\n  \"ButtonDownloadQueue\": \"કતાર ડાઉનલોડ કરો\",\n  \"ButtonEdit\": \"સંપાદિત કરો\",\n  \"ButtonEditChapters\": \"પ્રકરણો સંપાદિત કરો\",\n  \"ButtonEditPodcast\": \"પોડકાસ્ટ સંપાદિત કરો\",\n  \"ButtonEnable\": \"સક્રિય કરો\",\n  \"ButtonForceReScan\": \"બળપૂર્વક ફરીથી સ્કેન કરો\",\n  \"ButtonFullPath\": \"સંપૂર્ણ પથ\",\n  \"ButtonHide\": \"છુપાવો\",\n  \"ButtonHome\": \"ઘર\",\n  \"ButtonIssues\": \"સમસ્યાઓ\",\n  \"ButtonJumpBackward\": \"પાછળ જાવો\",\n  \"ButtonJumpForward\": \"આગળ જાવો\",\n  \"ButtonLatest\": \"નવીનતમ\",\n  \"ButtonLibrary\": \"પુસ્તકાલય\",\n  \"ButtonLogout\": \"લૉગ આઉટ\",\n  \"ButtonLookup\": \"શોધો\",\n  \"ButtonManageTracks\": \"ટ્રેક્સ મેનેજ કરો\",\n  \"ButtonMapChapterTitles\": \"પ્રકરણ શીર્ષકો મેપ કરો\",\n  \"ButtonMatchAllAuthors\": \"બધા મેળ ખાતા લેખકો શોધો\",\n  \"ButtonMatchBooks\": \"મેળ ખાતી પુસ્તકો શોધો\",\n  \"ButtonNevermind\": \"કંઈ વાંધો નહીં\",\n  \"ButtonNext\": \"આગળ જાઓ\",\n  \"ButtonNextChapter\": \"આગળનું અધ્યાય\",\n  \"ButtonNextItemInQueue\": \"કતારમાં આવતું આગળનું અધ્યાય\",\n  \"ButtonOk\": \"ઓકે\",\n  \"ButtonOpenFeed\": \"ફીડ ખોલો\",\n  \"ButtonOpenManager\": \"મેનેજર ખોલો\",\n  \"ButtonPause\": \"વિરામ\",\n  \"ButtonPlay\": \"ચલાવો\",\n  \"ButtonPlayAll\": \"બધું ચલાવો\",\n  \"ButtonPlaying\": \"ચલાવી રહ્યું છે\",\n  \"ButtonPlaylists\": \"પ્લેલિસ્ટ\",\n  \"ButtonPrevious\": \"પાછળનું\",\n  \"ButtonPreviousChapter\": \"પાછળનું અધ્યાય\",\n  \"ButtonProbeAudioFile\": \"ઑડિયો ફાઇલ તપાસો\",\n  \"ButtonPurgeAllCache\": \"બધો Cache કાઢી નાખો\",\n  \"ButtonPurgeItemsCache\": \"વસ્તુઓનો Cache કાઢી નાખો\",\n  \"ButtonQueueAddItem\": \"કતારમાં ઉમેરો\",\n  \"ButtonQueueRemoveItem\": \"કતારથી કાઢી નાખો\",\n  \"ButtonQuickEmbed\": \"ઝડપથી સમાવેશ કરો\",\n  \"ButtonQuickEmbedMetadata\": \"ઝડપથી મેટાડેટા સમાવવો\",\n  \"ButtonQuickMatch\": \"ઝડપી મેળ ખવડાવો\",\n  \"ButtonReScan\": \"ફરીથી સ્કેન કરો\",\n  \"ButtonRead\": \"વાંચો\",\n  \"ButtonReadLess\": \"ઓછું વાંચો\",\n  \"ButtonReadMore\": \"વધારે વાંચો\",\n  \"ButtonRefresh\": \"તાજું કરો\",\n  \"ButtonRemove\": \"કાઢી નાખો\",\n  \"ButtonRemoveAll\": \"બધું કાઢી નાખો\",\n  \"ButtonRemoveAllLibraryItems\": \"બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો\",\n  \"ButtonRemoveFromContinueListening\": \"સાંભળતી પુસ્તકો માંથી કાઢી નાખો\",\n  \"ButtonRemoveFromContinueReading\": \"સાંભળતી પુસ્તકો માંથી કાઢી નાખો\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"સાંભળતી સિરીઝ માંથી કાઢી નાખો\",\n  \"ButtonReset\": \"રીસેટ કરો\",\n  \"ButtonResetToDefault\": \"ડિફોલ્ટ પર રીસેટ કરો\",\n  \"ButtonRestore\": \"પુનઃસ્થાપિત કરો\",\n  \"ButtonSave\": \"સાચવો\",\n  \"ButtonSaveAndClose\": \"સાચવો અને બંધ કરો\",\n  \"ButtonSaveTracklist\": \"ટ્રેક યાદી સાચવો\",\n  \"ButtonScan\": \"સ્કેન કરો\",\n  \"ButtonScanLibrary\": \"પુસ્તકાલય સ્કેન કરો\",\n  \"ButtonScrollLeft\": \"ડાબે\",\n  \"ButtonScrollRight\": \"જમણે\",\n  \"ButtonSearch\": \"શોધો\",\n  \"ButtonSelectFolderPath\": \"ફોલ્ડર પથ પસંદ કરો\",\n  \"ButtonSeries\": \"સિરીઝ\",\n  \"ButtonSetChaptersFromTracks\": \"ટ્રેક્સથી પ્રકરણો સેટ કરો\",\n  \"ButtonShare\": \"શેર કરો\",\n  \"ButtonShiftTimes\": \"સમય શિફ્ટ કરો\",\n  \"ButtonShow\": \"બતાવો\",\n  \"ButtonStartM4BEncode\": \"M4B એન્કોડ શરૂ કરો\",\n  \"ButtonStartMetadataEmbed\": \"મેટાડેટા એમ્બેડ શરૂ કરો\",\n  \"ButtonStats\": \"આંકડા\",\n  \"ButtonSubmit\": \"સબમિટ કરો\",\n  \"ButtonTest\": \"પરખ કરો\",\n  \"ButtonUnlinkOpenId\": \"OpenID દૂર કરો\",\n  \"ButtonUpload\": \"અપલોડ કરો\",\n  \"ButtonUploadBackup\": \"બેકઅપ અપલોડ કરો\",\n  \"ButtonUploadCover\": \"કવર અપલોડ કરો\",\n  \"ButtonUploadOPMLFile\": \"OPML ફાઇલ અપલોડ કરો\",\n  \"ButtonUserDelete\": \"વપરાશકર્તા {0} કાઢી નાખો\",\n  \"ButtonUserEdit\": \"વપરાશકર્તા {0} સંપાદિત કરો\",\n  \"ButtonViewAll\": \"બધું જુઓ\",\n  \"ButtonYes\": \"હા\",\n  \"ErrorUploadFetchMetadataAPI\": \"મેટાડેટા મેળવવામાં તકલીફ આવી\",\n  \"ErrorUploadFetchMetadataNoResults\": \"મેટાડેટા મેળવી શક્યા નહીં – કૃપા કરીને શીર્ષક અને/અથવા લેખકનું નામ અપડેટ કરવાનો પ્રયત્ન કરો\",\n  \"ErrorUploadLacksTitle\": \"શીર્ષક હોવું આવશ્યક છે\",\n  \"HeaderAccount\": \"એકાઉન્ટ\",\n  \"HeaderAddCustomMetadataProvider\": \"કસ્ટમ મેટાડેટા પ્રોવાઇડર ઉમેરો\",\n  \"HeaderAdvanced\": \"અડ્વાન્સડ\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise સૂચના સેટિંગ્સ\",\n  \"HeaderAudioTracks\": \"ઓડિયો ટ્રેક્સ\",\n  \"HeaderAudiobookTools\": \"ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ\",\n  \"HeaderAuthentication\": \"પ્રમાણીકરણ\",\n  \"HeaderBackups\": \"બેકઅપ્સ\",\n  \"HeaderChangePassword\": \"પાસવર્ડ બદલો\",\n  \"HeaderChapters\": \"પ્રકરણો\",\n  \"HeaderChooseAFolder\": \"ફોલ્ડર પસંદ કરો\",\n  \"HeaderCollection\": \"સંગ્રહ\",\n  \"HeaderCollectionItems\": \"સંગ્રહ વસ્તુઓ\",\n  \"HeaderCover\": \"આવરણ\",\n  \"HeaderCurrentDownloads\": \"વર્તમાન ડાઉનલોડ્સ\",\n  \"HeaderCustomMetadataProviders\": \"કસ્ટમ મેટાડેટા પ્રોવાઇડર્સ\",\n  \"HeaderDetails\": \"વિગતો\",\n  \"HeaderDownloadQueue\": \"ડાઉનલોડ કતાર\",\n  \"HeaderEbookFiles\": \"ઇબુક ફાઇલો\",\n  \"HeaderEmail\": \"ઈમેલ\",\n  \"HeaderEmailSettings\": \"ઈમેલ સેટિંગ્સ\",\n  \"HeaderEpisodes\": \"એપિસોડ્સ\",\n  \"HeaderEreaderDevices\": \"ઇરીડર ઉપકરણો\",\n  \"HeaderEreaderSettings\": \"ઇરીડર સેટિંગ્સ\",\n  \"HeaderFiles\": \"ફાઇલો\",\n  \"HeaderFindChapters\": \"પ્રકરણો શોધો\",\n  \"HeaderIgnoredFiles\": \"અવગણેલી ફાઇલો\",\n  \"HeaderItemFiles\": \"વાસ્તુ ની ફાઈલો\",\n  \"HeaderItemMetadataUtils\": \"વસ્તુ મેટાડેટા સાધનો\",\n  \"HeaderLastListeningSession\": \"છેલ્લી સાંભળતી સેશન\",\n  \"HeaderLatestEpisodes\": \"નવીનતમ એપિસોડ્સ\",\n  \"HeaderLibraries\": \"પુસ્તકાલયો\",\n  \"HeaderLibraryFiles\": \"પુસ્તકાલય ફાઇલો\",\n  \"HeaderLibraryStats\": \"પુસ્તકાલય આંકડા\",\n  \"HeaderListeningSessions\": \"સાંભળતી સેશન્સ\",\n  \"HeaderListeningStats\": \"સાંભળતી આંકડા\",\n  \"HeaderLogin\": \"લોગિન\",\n  \"HeaderLogs\": \"લોગ્સ\",\n  \"HeaderManageGenres\": \"જાતિઓ મેનેજ કરો\",\n  \"HeaderManageTags\": \"ટેગ્સ મેનેજ કરો\",\n  \"HeaderMapDetails\": \"વિગતો મેપ કરો\",\n  \"HeaderMatch\": \"મેળ ખાતી શોધો\",\n  \"HeaderMetadataOrderOfPrecedence\": \"મેટાડેટા પ્રાધાન્યતાનો ક્રમ\",\n  \"HeaderMetadataToEmbed\": \"એમ્બેડ કરવા માટે મેટાડેટા\",\n  \"HeaderNewAccount\": \"નવું એકાઉન્ટ\",\n  \"HeaderNewLibrary\": \"નવી પુસ્તકાલય\",\n  \"HeaderNotificationCreate\": \"સૂચના બનાવો\",\n  \"HeaderNotifications\": \"સૂચનાઓ\",\n  \"HeaderOpenRSSFeed\": \"RSS ફીડ ખોલો\",\n  \"HeaderOtherFiles\": \"અન્ય ફાઇલો\",\n  \"HeaderPermissions\": \"પરવાનગીઓ\",\n  \"HeaderPlayerQueue\": \"પ્લેયર કતાર\",\n  \"HeaderPlaylist\": \"પ્લેલિસ્ટ\",\n  \"HeaderPlaylistItems\": \"પ્લેલિસ્ટ ની વસ્તુઓ\",\n  \"HeaderPodcastsToAdd\": \"ઉમેરવા માટે પોડકાસ્ટ્સ\",\n  \"HeaderPreviewCover\": \"પૂર્વાવલોકન કવર\",\n  \"HeaderRSSFeedGeneral\": \"સામાન્ય RSS ફીડ\",\n  \"HeaderRSSFeedIsOpen\": \"RSS ફીડ ખોલેલી છે\",\n  \"HeaderRSSFeeds\": \"RSS ફીડ્સ\",\n  \"HeaderRemoveEpisode\": \"એપિસોડ કાઢી નાખો\",\n  \"HeaderRemoveEpisodes\": \"{0} એપિસોડ્સ કાઢી નાખો\",\n  \"HeaderSavedMediaProgress\": \"સાચવેલ મીડિયા પ્રગતિ\",\n  \"HeaderSchedule\": \"સમયપત્રક\",\n  \"HeaderScheduleLibraryScans\": \"પુસ્તકાલય સ્કેન સમયપત્રક\",\n  \"HeaderSession\": \"સેશન\",\n  \"HeaderSetBackupSchedule\": \"બેકઅપ સમયપત્રક સેટ કરો\",\n  \"HeaderSettings\": \"સેટિંગ્સ\",\n  \"HeaderSettingsDisplay\": \"ડિસ્પ્લે સેટિંગ્સ\",\n  \"HeaderSettingsExperimental\": \"પ્રયોગશીલ સેટિંગ્સ\",\n  \"HeaderSettingsGeneral\": \"સામાન્ય સેટિંગ્સ\",\n  \"HeaderSettingsScanner\": \"સ્કેનર સેટિંગ્સ\",\n  \"HeaderSleepTimer\": \"સ્લીપ ટાઈમર\",\n  \"HeaderStatsLargestItems\": \"સૌથી મોટી વસ્તુઓ\",\n  \"HeaderStatsLongestItems\": \"સૌથી લાંબી વસ્તુઓ (કલાક)\",\n  \"HeaderStatsMinutesListeningChart\": \"સાંભળવાની મિનિટ (છેલ્લા ૭ દિવસ)\",\n  \"HeaderStatsRecentSessions\": \"છેલ્લી સાંભળતી સેશન્સ\",\n  \"LabelBackupsMaxBackupSize\": \"Maximum backup size (in GB)\",\n  \"LabelFontFamily\": \"ફોન્ટ કુટુંબ\",\n  \"LabelPodcastSearchRegion\": \"પોડકાસ્ટ શોધ પ્રદેશ\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.\",\n  \"MessageBookshelfNoResultsForFilter\": \"No Results for filter \\\"{0}: {1}\\\"\",\n  \"ToastSendEbookToDeviceFailed\": \"Failed to Send Ebook to device\"\n}\n"
  },
  {
    "path": "client/strings/he.json",
    "content": "{\n  \"ButtonAdd\": \"הוסף\",\n  \"ButtonAddApiKey\": \"הוסף מפתח ממשק תכנות (API)\",\n  \"ButtonAddChapters\": \"הוסף פרקים\",\n  \"ButtonAddDevice\": \"הוסף התקן\",\n  \"ButtonAddLibrary\": \"הוסף ספרייה\",\n  \"ButtonAddPodcasts\": \"הוסף פודקאסטים\",\n  \"ButtonAddUser\": \"הוסף משתמש\",\n  \"ButtonAddYourFirstLibrary\": \"הוסף את הספרייה הראשונה שלך\",\n  \"ButtonApply\": \"החל\",\n  \"ButtonApplyChapters\": \"החל פרקים\",\n  \"ButtonAuthors\": \"סופרים\",\n  \"ButtonBack\": \"חזור\",\n  \"ButtonBatchEditPopulateFromExisting\": \"מלא משדות קיימים\",\n  \"ButtonBatchEditPopulateMapDetails\": \"מלא פרטי מפה\",\n  \"ButtonBrowseForFolder\": \"עיין בתיקייה\",\n  \"ButtonCancel\": \"ביטול\",\n  \"ButtonCancelEncode\": \"בטל קידוד\",\n  \"ButtonChangeRootPassword\": \"שנה סיסמת root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"בדוק והורד פרקים חדשים\",\n  \"ButtonChooseAFolder\": \"בחר תיקייה\",\n  \"ButtonChooseFiles\": \"בחר קבצים\",\n  \"ButtonClearFilter\": \"נקה סינון\",\n  \"ButtonClose\": \"סגור\",\n  \"ButtonCloseFeed\": \"סגור ערוץ\",\n  \"ButtonCloseSession\": \"סגור סשן פתוח\",\n  \"ButtonCollections\": \"אוספים\",\n  \"ButtonConfigureScanner\": \"הגדר סורק\",\n  \"ButtonCreate\": \"צור\",\n  \"ButtonCreateBackup\": \"צור גיבוי\",\n  \"ButtonDelete\": \"מחק\",\n  \"ButtonDownloadQueue\": \"תור הורדה\",\n  \"ButtonEdit\": \"ערוך\",\n  \"ButtonEditChapters\": \"ערוך פרקים\",\n  \"ButtonEditPodcast\": \"ערוך פודקאסט\",\n  \"ButtonEnable\": \"אפשר\",\n  \"ButtonFireAndFail\": \"שלח בכישלון\",\n  \"ButtonFireOnTest\": \"שלח באירוע בדיקה\",\n  \"ButtonForceReScan\": \"סרוק מחדש בכוח\",\n  \"ButtonFullPath\": \"נתיב מלא\",\n  \"ButtonHide\": \"הסתר\",\n  \"ButtonHome\": \"בית\",\n  \"ButtonIssues\": \"תקלות\",\n  \"ButtonJumpBackward\": \"דלג אחורה\",\n  \"ButtonJumpForward\": \"דלג קדימה\",\n  \"ButtonLatest\": \"אחרון\",\n  \"ButtonLibrary\": \"ספרייה\",\n  \"ButtonLogout\": \"התנתק\",\n  \"ButtonLookup\": \"חפש\",\n  \"ButtonManageTracks\": \"נהל רצועות\",\n  \"ButtonMapChapterTitles\": \"מפה כותרות פרקים\",\n  \"ButtonMatchAllAuthors\": \"התאם את כל היוצרים\",\n  \"ButtonMatchBooks\": \"התאם ספרים\",\n  \"ButtonNevermind\": \"לא משנה\",\n  \"ButtonNext\": \"הבא\",\n  \"ButtonNextChapter\": \"פרק הבא\",\n  \"ButtonNextItemInQueue\": \"פריט הבא בתור\",\n  \"ButtonOk\": \"אישור\",\n  \"ButtonOpenFeed\": \"פתח פיד\",\n  \"ButtonOpenManager\": \"פתח מנהל\",\n  \"ButtonPause\": \"השהה\",\n  \"ButtonPlay\": \"נגן\",\n  \"ButtonPlayAll\": \"נגן הכל\",\n  \"ButtonPlaying\": \"מנגן\",\n  \"ButtonPlaylists\": \"רשימות השמעה\",\n  \"ButtonPrevious\": \"קודם\",\n  \"ButtonPreviousChapter\": \"פרק קודם\",\n  \"ButtonProbeAudioFile\": \"בדוק קובץ אודיו\",\n  \"ButtonPurgeAllCache\": \"נקה את כל המטמון\",\n  \"ButtonPurgeItemsCache\": \"נקה את מטמון הפריטים\",\n  \"ButtonQueueAddItem\": \"הוסף לתור\",\n  \"ButtonQueueRemoveItem\": \"הסר מהתור\",\n  \"ButtonQuickEmbed\": \"הטמעה מהירה\",\n  \"ButtonQuickEmbedMetadata\": \"הטמעת מטא נתונים מהירה\",\n  \"ButtonQuickMatch\": \"התאמה מהירה\",\n  \"ButtonReScan\": \"סרוק מחדש\",\n  \"ButtonRead\": \"קרא\",\n  \"ButtonReadLess\": \"קרא פחות\",\n  \"ButtonReadMore\": \"קרא עוד\",\n  \"ButtonRefresh\": \"רענן\",\n  \"ButtonRemove\": \"הסר\",\n  \"ButtonRemoveAll\": \"הסר הכל\",\n  \"ButtonRemoveAllLibraryItems\": \"הסר את כל פריטי הספרייה\",\n  \"ButtonRemoveFromContinueListening\": \"הסר מ״המשך האזנה״\",\n  \"ButtonRemoveFromContinueReading\": \"הסר מ- המשך קריאה\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"הסר סדרה מ- המשך סדרה\",\n  \"ButtonReset\": \"איפוס\",\n  \"ButtonResetToDefault\": \"איפוס לברירת המחדל\",\n  \"ButtonRestore\": \"שחזר\",\n  \"ButtonSave\": \"שמור\",\n  \"ButtonSaveAndClose\": \"שמור וסגור\",\n  \"ButtonSaveTracklist\": \"שמור רשימת רצועות\",\n  \"ButtonScan\": \"סרוק\",\n  \"ButtonScanLibrary\": \"סרוק ספרייה\",\n  \"ButtonScrollLeft\": \"גלול שמאלה\",\n  \"ButtonScrollRight\": \"גלול ימינה\",\n  \"ButtonSearch\": \"חיפוש\",\n  \"ButtonSelectFolderPath\": \"בחר נתיב לתיקייה\",\n  \"ButtonSeries\": \"סדרה\",\n  \"ButtonSetChaptersFromTracks\": \"קבע פרקים לפי הרצועות\",\n  \"ButtonShare\": \"שיתוף\",\n  \"ButtonShiftTimes\": \"הזז זמנים\",\n  \"ButtonShow\": \"הצג\",\n  \"ButtonStartM4BEncode\": \"התחל קידוד M4B\",\n  \"ButtonStartMetadataEmbed\": \"התחל הטמעת מטא-נתונים\",\n  \"ButtonStats\": \"סטטיסטיקות\",\n  \"ButtonSubmit\": \"שליחה\",\n  \"ButtonTest\": \"בדיקה\",\n  \"ButtonUnlinkOpenId\": \"נתק OpenID\",\n  \"ButtonUpload\": \"העלה\",\n  \"ButtonUploadBackup\": \"העלה גיבוי\",\n  \"ButtonUploadCover\": \"העלה כריכה\",\n  \"ButtonUploadOPMLFile\": \"העלה קובץ OPML\",\n  \"ButtonUserDelete\": \"מחק משתמש {0}\",\n  \"ButtonUserEdit\": \"ערוך משתמש {0}\",\n  \"ButtonViewAll\": \"הצג הכול\",\n  \"ButtonYes\": \"כן\",\n  \"ErrorUploadFetchMetadataAPI\": \"שגיאה בשליפת מטא-נתונים\",\n  \"ErrorUploadFetchMetadataNoResults\": \"לא ניתן לשלוף מטא-נתונים - נסה לעדכן כותרת ו/או יוצר\",\n  \"ErrorUploadLacksTitle\": \"חובה לתת כותרת\",\n  \"HeaderAccount\": \"חשבון\",\n  \"HeaderAddCustomMetadataProvider\": \"הוסף ספק מטא-נתונים מותאם אישית\",\n  \"HeaderAdvanced\": \"מתקדם\",\n  \"HeaderApiKeys\": \"מפתחות API\",\n  \"HeaderAppriseNotificationSettings\": \"הגדרות התראות של Apprise\",\n  \"HeaderAudioTracks\": \"רצועות קול\",\n  \"HeaderAudiobookTools\": \"כלים לניהול קבצי ספרים קוליים\",\n  \"HeaderAuthentication\": \"אימות\",\n  \"HeaderBackups\": \"גיבויים\",\n  \"HeaderBulkChapterModal\": \"הוסף מספר פרקים\",\n  \"HeaderChangePassword\": \"שנה סיסמה\",\n  \"HeaderChapters\": \"פרקים\",\n  \"HeaderChooseAFolder\": \"בחר תיקייה\",\n  \"HeaderCollection\": \"אוסף\",\n  \"HeaderCollectionItems\": \"פרטי אוסף\",\n  \"HeaderCover\": \"כריכה\",\n  \"HeaderCurrentDownloads\": \"הורדות נוכחיות\",\n  \"HeaderCustomMessageOnLogin\": \"הודעה מותאמת אישית בהתחברות\",\n  \"HeaderCustomMetadataProviders\": \"ספקי מטא-נתונים מותאמים אישית\",\n  \"HeaderDetails\": \"פרטים\",\n  \"HeaderDownloadQueue\": \"תור הורדה\",\n  \"HeaderEbookFiles\": \"קבצי Ebook\",\n  \"HeaderEmail\": \"אימייל\",\n  \"HeaderEmailSettings\": \"הגדרות אימייל\",\n  \"HeaderEpisodes\": \"פרקים\",\n  \"HeaderEreaderDevices\": \"התקני קריאה דיגיטליים\",\n  \"HeaderEreaderSettings\": \"הגדרות קורא אלקטרוני\",\n  \"HeaderFiles\": \"קבצים\",\n  \"HeaderFindChapters\": \"מצא פרקים\",\n  \"HeaderIgnoredFiles\": \"קבצים שנתעלמו\",\n  \"HeaderItemFiles\": \"קבצי פריט\",\n  \"HeaderItemMetadataUtils\": \"כלי מטא-נתונים\",\n  \"HeaderLastListeningSession\": \"הפעלת האזנה אחרונה\",\n  \"HeaderLatestEpisodes\": \"פרקים אחרונים\",\n  \"HeaderLibraries\": \"ספריות\",\n  \"HeaderLibraryFiles\": \"קבצי ספרייה\",\n  \"HeaderLibraryStats\": \"סטטיסטיקות ספרייה\",\n  \"HeaderListeningSessions\": \"הפעלות האזנה\",\n  \"HeaderListeningStats\": \"סטטיסטיקות האזנה\",\n  \"HeaderLogin\": \"התחברות\",\n  \"HeaderLogs\": \"לוגים\",\n  \"HeaderManageGenres\": \"נהל ז'אנרים\",\n  \"HeaderManageTags\": \"נהל תגיות\",\n  \"HeaderMapDetails\": \"מפה פרטים\",\n  \"HeaderMatch\": \"התאם\",\n  \"HeaderMetadataOrderOfPrecedence\": \"סדר העדפת מטא-נתונים\",\n  \"HeaderMetadataToEmbed\": \"מטא-נתונים להטמעה\",\n  \"HeaderNewAccount\": \"חשבון חדש\",\n  \"HeaderNewApiKey\": \"מפתח API חדש\",\n  \"HeaderNewLibrary\": \"ספרייה חדשה\",\n  \"HeaderNotificationCreate\": \"צור התראה\",\n  \"HeaderNotificationUpdate\": \"עדכון התראה\",\n  \"HeaderNotifications\": \"התראות\",\n  \"HeaderOpenIDConnectAuthentication\": \"אימות OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"פתח הפעלות האזנה\",\n  \"HeaderOpenRSSFeed\": \"פתח ערוץ RSS\",\n  \"HeaderOtherFiles\": \"קבצים אחרים\",\n  \"HeaderPasswordAuthentication\": \"אימות סיסמה\",\n  \"HeaderPermissions\": \"הרשאות\",\n  \"HeaderPlayerQueue\": \"תור ניגון\",\n  \"HeaderPlayerSettings\": \"הגדרות נגן\",\n  \"HeaderPlaylist\": \"רשימת השמעה\",\n  \"HeaderPlaylistItems\": \"פריטי רשימת השמעה\",\n  \"HeaderPodcastsToAdd\": \"פודקאסטים להוספה\",\n  \"HeaderPresets\": \"קביעות מוגדרות מראש\",\n  \"HeaderPreviewCover\": \"תצוגה מקדימה של כריכה\",\n  \"HeaderRSSFeedGeneral\": \"פרטי RSS\",\n  \"HeaderRSSFeedIsOpen\": \"ערוץ RSS פתוח\",\n  \"HeaderRSSFeeds\": \"ערוצי RSS\",\n  \"HeaderRemoveEpisode\": \"הסר פרק\",\n  \"HeaderRemoveEpisodes\": \"הסר {0} פרקים\",\n  \"HeaderSavedMediaProgress\": \"התקדמות מדיה שמורה\",\n  \"HeaderSchedule\": \"תיזמון\",\n  \"HeaderScheduleEpisodeDownloads\": \"תזמן הורדת פרקים אוטומטית\",\n  \"HeaderScheduleLibraryScans\": \"קבע סריקות ספרייה אוטומטיות\",\n  \"HeaderSession\": \"הפעלה\",\n  \"HeaderSetBackupSchedule\": \"קבע לוח זמנים לגיבוי\",\n  \"HeaderSettings\": \"הגדרות\",\n  \"HeaderSettingsDisplay\": \"תצוגה\",\n  \"HeaderSettingsExperimental\": \"תכונות ניסיוניות\",\n  \"HeaderSettingsGeneral\": \"כללי\",\n  \"HeaderSettingsScanner\": \"סורק\",\n  \"HeaderSettingsSecurity\": \"אבטחה\",\n  \"HeaderSettingsWebClient\": \"מערך\",\n  \"HeaderSleepTimer\": \"טיימר שינה\",\n  \"HeaderStatsLargestItems\": \"הפריטים הגדולים ביותר\",\n  \"HeaderStatsLongestItems\": \"הפריטים הארוכים ביותר (בשעות)\",\n  \"HeaderStatsMinutesListeningChart\": \"דקות האזנה (7 ימים אחרונים)\",\n  \"HeaderStatsRecentSessions\": \"האזנות אחרונות\",\n  \"HeaderStatsTop10Authors\": \"10 היוצרים המובילים\",\n  \"HeaderStatsTop5Genres\": \"הז'אנרים המובילים 5\",\n  \"HeaderTableOfContents\": \"תוכן עניינים\",\n  \"HeaderTools\": \"כלים\",\n  \"HeaderUpdateAccount\": \"עדכן חשבון\",\n  \"HeaderUpdateApiKey\": \"עדכן מפתח API\",\n  \"HeaderUpdateAuthor\": \"עדכן יוצר\",\n  \"HeaderUpdateDetails\": \"עדכן פרטים\",\n  \"HeaderUpdateLibrary\": \"עדכן ספרייה\",\n  \"HeaderUsers\": \"משתמשים\",\n  \"HeaderYearReview\": \"שנת {0} בסקירה\",\n  \"HeaderYourStats\": \"הסטטיסטיקות שלך\",\n  \"LabelAbridged\": \"מקוצר\",\n  \"LabelAbridgedChecked\": \"מקוצר (מסומן)\",\n  \"LabelAbridgedUnchecked\": \"בלתי מקוצר (לא מסומן)\",\n  \"LabelAccessibleBy\": \"נגיש על ידי\",\n  \"LabelAccountType\": \"סוג חשבון\",\n  \"LabelAccountTypeAdmin\": \"מנהל\",\n  \"LabelAccountTypeGuest\": \"אורח\",\n  \"LabelAccountTypeUser\": \"משתמש\",\n  \"LabelActivities\": \"פעילויות\",\n  \"LabelActivity\": \"פעילות\",\n  \"LabelAddToCollection\": \"הוסף לאוסף\",\n  \"LabelAddToCollectionBatch\": \"הוסף {0} ספרים לאוסף\",\n  \"LabelAddToPlaylist\": \"הוסף לרשימת השמעה\",\n  \"LabelAddToPlaylistBatch\": \"הוסף {0} פריטים לרשימת השמעה\",\n  \"LabelAddedAt\": \"נוסף ב-\",\n  \"LabelAddedDate\": \"נוסף ב-{0}\",\n  \"LabelAdminUsersOnly\": \"רק מנהלים\",\n  \"LabelAll\": \"הכל\",\n  \"LabelAllEpisodesDownloaded\": \"כל הפרקים הורדו\",\n  \"LabelAllUsers\": \"כל המשתמשים\",\n  \"LabelAllUsersExcludingGuests\": \"כל המשתמשים, ללא אורחים\",\n  \"LabelAllUsersIncludingGuests\": \"כל המשתמשים כולל אורחים\",\n  \"LabelAlreadyInYourLibrary\": \"כבר קיים בספרייה שלך\",\n  \"LabelApiKeyCreated\": \"מפתח API ״{0}״ נוצר בהצלחה.\",\n  \"LabelApiKeyCreatedDescription\": \"אנא העתק את מפתח ה־API כעת, לא ניתן יהיה להציגו שוב.\",\n  \"LabelApiKeyUser\": \"פעל בשם המשתמש\",\n  \"LabelApiKeyUserDescription\": \"למפתח ה־API יהיו הרשאות זהות למשתמש שעל שמו הוא פועל. ביומני הרישום (logs), הפעולות יופיעו כאילו בוצעו על ידי המשתמש עצמו.\",\n  \"LabelApiToken\": \"טוקן API\",\n  \"LabelAppend\": \"הוסף לסוף\",\n  \"LabelAudioBitrate\": \"קצב סיביות (לדוגמא 128k)\",\n  \"LabelAudioChannels\": \"ערוצי קול (1 או 2)\",\n  \"LabelAudioCodec\": \"קידוד קול\",\n  \"LabelAuthor\": \"סופר\",\n  \"LabelAuthorFirstLast\": \"סופר (שם, משפחה)\",\n  \"LabelAuthorLastFirst\": \"סופר (משפחה, שם)\",\n  \"LabelAuthors\": \"סופרים\",\n  \"LabelAutoDownloadEpisodes\": \"הורד פרקים באופן אוטומטי\",\n  \"LabelAutoFetchMetadata\": \"חפש והורד מטא-נתונים באופן אוטומטי\",\n  \"LabelAutoFetchMetadataHelp\": \"מחפש ומוריד מטא-נתונים לשדות כותרת, יוצר וסדרה כדי לשפר את תהליך ההעלאה. ייתכן שיהיה צורך להתאים מטא-נתונים נוסף לאחר ההעלאה.\",\n  \"LabelAutoLaunch\": \"הפעלה אוטומטית\",\n  \"LabelAutoLaunchDescription\": \"הפניה אוטומטית לספק האימות כאשר מגיעים לדף ההתחברות (ניתן להפעיל ידנית בכתובת <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"הרשמה אוטומטית\",\n  \"LabelAutoRegisterDescription\": \"יצירת משתמשים חדשים אוטומטית לאחר התחברות\",\n  \"LabelBackToUser\": \"חזרה למשתמש\",\n  \"LabelBackupAudioFiles\": \"גיבוי קבצי שמע\",\n  \"LabelBackupLocation\": \"מיקום גיבוי\",\n  \"LabelBackupsEnableAutomaticBackups\": \"גיבויים אוטומטיים\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"גיבויים שמורים ב /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"גודל הגיבוי המרבי (בג'יגה-בייט) (0 - ללא הגבלה)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"כהגנה על עצמך מפני תצורה שגויה, הגיבויים ייכשלו אם הם יעברו את הגודל שהוגדר.\",\n  \"LabelBackupsNumberToKeep\": \"מספר הגיבויים לשמירה\",\n  \"LabelBackupsNumberToKeepHelp\": \"רק גיבוי אחד יוסר בכל פעם, לכן אם יש לך כבר יותר מגיבוי אחד יש להסיר אותם באופן ידני.\",\n  \"LabelBitrate\": \"קצב סיביות\",\n  \"LabelBonus\": \"בונוס\",\n  \"LabelBooks\": \"ספרים\",\n  \"LabelButtonText\": \"טקסט לחצן\",\n  \"LabelByAuthor\": \"על ידי {0}\",\n  \"LabelChangePassword\": \"שינוי סיסמה\",\n  \"LabelChannels\": \"ערוצים\",\n  \"LabelChapterCount\": \"{0} פרקים\",\n  \"LabelChapterTitle\": \"כותרת הפרק\",\n  \"LabelChapters\": \"פרקים\",\n  \"LabelChaptersFound\": \"פרקים שנמצאו\",\n  \"LabelClickForMoreInfo\": \"לחץ למידע נוסף\",\n  \"LabelClickToUseCurrentValue\": \"לחץ לשימוש בערך הנוכחי\",\n  \"LabelClosePlayer\": \"סגור נגן\",\n  \"LabelCodec\": \"Coded\",\n  \"LabelCollapseSeries\": \"הסתר סדרה\",\n  \"LabelCollapseSubSeries\": \"הסתר תת סדרה\",\n  \"LabelCollection\": \"אוסף\",\n  \"LabelCollections\": \"אוספים\",\n  \"LabelComplete\": \"הושלם\",\n  \"LabelConfirmPassword\": \"אישור סיסמה\",\n  \"LabelContinueListening\": \"המשך האזנה\",\n  \"LabelContinueReading\": \"המשך קריאה\",\n  \"LabelContinueSeries\": \"המשך סדרה\",\n  \"LabelCorsAllowed\": \"מקורות CORS מורשים\",\n  \"LabelCover\": \"כריכה\",\n  \"LabelCoverImageURL\": \"כתובת התמונה ברשת\",\n  \"LabelCoverProvider\": \"ספק כריכה\",\n  \"LabelCreatedAt\": \"נוצר בתאריך\",\n  \"LabelCronExpression\": \"ביטוי cron\",\n  \"LabelCurrent\": \"נוכחי\",\n  \"LabelCurrently\": \"כעת:\",\n  \"LabelCustomCronExpression\": \"ביטוי cron מותאם אישית:\",\n  \"LabelDatetime\": \"Datetime\",\n  \"LabelDays\": \"ימים\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"מחיקה מהמערכת הקבצים (הסר סימון למחיקה רק ממסד הנתונים)\",\n  \"LabelDescription\": \"תיאור\",\n  \"LabelDeselectAll\": \"הסר בחירת כל הפריטים\",\n  \"LabelDetectedPattern\": \"תבנית שזוהתה:\",\n  \"LabelDevice\": \"התקן\",\n  \"LabelDeviceInfo\": \"מידע על התקן\",\n  \"LabelDeviceIsAvailableTo\": \"התקן זמין ל...\",\n  \"LabelDirectory\": \"תיקייה\",\n  \"LabelDiscFromFilename\": \"דיסק משם הקובץ\",\n  \"LabelDiscFromMetadata\": \"דיסק מהמטא-נתונים\",\n  \"LabelDiscover\": \"גלה\",\n  \"LabelDownload\": \"הורדה\",\n  \"LabelDownloadNEpisodes\": \"הורד {0} פרקים\",\n  \"LabelDownloadable\": \"ניתן להורדה\",\n  \"LabelDuration\": \"משך\",\n  \"LabelDurationComparisonExactMatch\": \"(התאמה מדוייקת)\",\n  \"LabelDurationComparisonLonger\": \"({0} ארוך יותר)\",\n  \"LabelDurationComparisonShorter\": \"({0} קצר יותר)\",\n  \"LabelDurationFound\": \"משך נמצא:\",\n  \"LabelEbook\": \"ספר אלקטרוני\",\n  \"LabelEbooks\": \"ספרים אלקטרוניים\",\n  \"LabelEdit\": \"עריכה\",\n  \"LabelEmail\": \"דואר אלקטרוני\",\n  \"LabelEmailSettingsFromAddress\": \"מאת\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"דחה תעודות לא מאושרות\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"השבתת אימות תעודת SSL עלולה לחשוף את החיבור שלך לסיכוני אבטחה, כגון התקפות \\\"אדם באמצע\\\". השבת אפשרות זו רק אם אתה מבין את ההשלכות ובוטח בשרת הדואר שאליו אתה מתחבר.\",\n  \"LabelEmailSettingsSecure\": \"מאובטח\",\n  \"LabelEmailSettingsSecureHelp\": \"אם מופעל, החיבור ישתמש ב-TLS בעת ההתחברות לשרת. אם לא, אז TLS יהיה בשימוש אם השרת תומך בהרחבת STARTTLS. ברוב המקרים מומלץ להפעיל את הגדרה זו אם אתה מתחבר לפורט 465. לפורט 587 או 25, השאר כבוי. (from nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"כתובת לבדיקה\",\n  \"LabelEmbeddedCover\": \"כריכה מוטמעת\",\n  \"LabelEnable\": \"אפשר\",\n  \"LabelEncodingBackupLocation\": \"גיבוי של קבצי אודיו מקוריים יישמר ב:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"פרקים אינם מוטבעים בספרי אודיו מרובי רצועות.\",\n  \"LabelEncodingClearItemCache\": \"הקפד לנקות מטמון פריטים מעת לעת.\",\n  \"LabelEncodingFinishedM4B\": \"קובץ M4B סופי יישמר בתיקייה ה-audiobook ב:\",\n  \"LabelEncodingInfoEmbedded\": \"מטה דאטה יוטמע ברצועות השמע בתוך תיקיית ה-audiobook.\",\n  \"LabelEncodingStartedNavigation\": \"לאחר שהמשימה תתחיל אפשר לנווט לדף אחר.\",\n  \"LabelEncodingTimeWarning\": \"קידוד יכול להימשך עד 30 דקות.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"אזהרה: אל תעדכן את ההגדרות האלה אלא אם כן אתה מכיר את אפשרויות קידוד ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"אם ה-watcher כבוי, יש לסרוק את הספר מחדש לאחר מכן.\",\n  \"LabelEnd\": \"סוף\",\n  \"LabelEndOfChapter\": \"סוף הפרק\",\n  \"LabelEpisode\": \"פרק\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"פרק לא מקושר לערוץ RSS\",\n  \"LabelEpisodeNumber\": \"פרק #{0}\",\n  \"LabelEpisodeTitle\": \"כותרת הפרק\",\n  \"LabelEpisodeType\": \"סוג הפרק\",\n  \"LabelEpisodeUrlFromRssFeed\": \"קישור פרק מערוץ RSS\",\n  \"LabelEpisodes\": \"פרקים\",\n  \"LabelEpisodic\": \"ארעי\",\n  \"LabelExample\": \"דוגמה\",\n  \"LabelExpandSeries\": \"הרחב סדרה\",\n  \"LabelExpandSubSeries\": \"הרחב תת סדרה\",\n  \"LabelExpired\": \"פג תוקף\",\n  \"LabelExpiresAt\": \"יפוג בתאריך\",\n  \"LabelExpiresInSeconds\": \"יפוג בעוד (שניות)\",\n  \"LabelExpiresNever\": \"ללא הגבלת זמן\",\n  \"LabelExplicit\": \"מפורש\",\n  \"LabelExplicitChecked\": \"בוטה (מסומן)\",\n  \"LabelExplicitUnchecked\": \"לא בוטה (לא מסומן)\",\n  \"LabelExportOPML\": \"ייצוא OPML\",\n  \"LabelFeedURL\": \"כתובת ערוץ\",\n  \"LabelFetchingMetadata\": \"מושך מטא-נתונים\",\n  \"LabelFile\": \"קובץ\",\n  \"LabelFileBirthtime\": \"זמן יצירת הקובץ\",\n  \"LabelFileBornDate\": \"נוצר {0}\",\n  \"LabelFileModified\": \"קובץ נערך\",\n  \"LabelFileModifiedDate\": \"שונה {0}\",\n  \"LabelFilename\": \"שם קובץ\",\n  \"LabelFilterByUser\": \"סינון לפי משתמש\",\n  \"LabelFindEpisodes\": \"מצא פרקים\",\n  \"LabelFinished\": \"הושלם\",\n  \"LabelFinishedDate\": \"הושלם {0}\",\n  \"LabelFolder\": \"תיקייה\",\n  \"LabelFolders\": \"תיקיות\",\n  \"LabelFontBold\": \"מודגש\",\n  \"LabelFontBoldness\": \"עובי פונט\",\n  \"LabelFontFamily\": \"משפחת הפונטים\",\n  \"LabelFontItalic\": \"נטוי\",\n  \"LabelFontScale\": \"גודל פונט\",\n  \"LabelFontStrikethrough\": \"קו חוצה\",\n  \"LabelFormat\": \"תבנית\",\n  \"LabelFull\": \"מלא\",\n  \"LabelGenre\": \"סגנון\",\n  \"LabelGenres\": \"סגנונות\",\n  \"LabelHardDeleteFile\": \"מחיקה חזקה של הקובץ\",\n  \"LabelHasEbook\": \"קיים ספר אלקטרוני\",\n  \"LabelHasSupplementaryEbook\": \"קיים ספר אלקטרוני משלים\",\n  \"LabelHideSubtitles\": \"הסתר תת כותרות\",\n  \"LabelHighestPriority\": \"העדיפות הגבוהה ביותר\",\n  \"LabelHost\": \"מארח\",\n  \"LabelHour\": \"שעה\",\n  \"LabelHours\": \"שעות\",\n  \"LabelIcon\": \"סמל\",\n  \"LabelImageURLFromTheWeb\": \"כתובת התמונה מהרשת\",\n  \"LabelInProgress\": \"בתהליך\",\n  \"LabelIncludeInTracklist\": \"כלול ברשימת השמעה\",\n  \"LabelIncomplete\": \"לא הושלם\",\n  \"LabelInterval\": \"מרווח\",\n  \"LabelIntervalCustomDailyWeekly\": \"מותאם אישית יומי/שבועי\",\n  \"LabelIntervalEvery12Hours\": \"כל 12 שעות\",\n  \"LabelIntervalEvery15Minutes\": \"כל 15 דקות\",\n  \"LabelIntervalEvery2Hours\": \"כל שעתיים\",\n  \"LabelIntervalEvery30Minutes\": \"כל 30 דקות\",\n  \"LabelIntervalEvery6Hours\": \"כל 6 שעות\",\n  \"LabelIntervalEveryDay\": \"כל יום\",\n  \"LabelIntervalEveryHour\": \"כל שעה\",\n  \"LabelIntervalEveryMinute\": \"כל דקה\",\n  \"LabelInvert\": \"הפוך\",\n  \"LabelItem\": \"פריט\",\n  \"LabelJumpBackwardAmount\": \"כמות הרצה לאחור\",\n  \"LabelJumpForwardAmount\": \"כמות הרצה קדימה\",\n  \"LabelLanguage\": \"שפה\",\n  \"LabelLanguageDefaultServer\": \"שפת ברירת המחדל של השרת\",\n  \"LabelLanguages\": \"שפות\",\n  \"LabelLastBookAdded\": \"הספר האחרון שנוסף\",\n  \"LabelLastBookUpdated\": \"הספר האחרון שעודכן\",\n  \"LabelLastProgressDate\": \"התקדמות אחרונה: {0}\",\n  \"LabelLastSeen\": \"נראה לאחרונה\",\n  \"LabelLastTime\": \"הזמן האחרון\",\n  \"LabelLastUpdate\": \"עדכון אחרון\",\n  \"LabelLayout\": \"Layout\",\n  \"LabelLayoutSinglePage\": \"עמוד יחיד\",\n  \"LabelLayoutSplitPage\": \"פיצול הדף\",\n  \"LabelLess\": \"פחות\",\n  \"LabelLibrariesAccessibleToUser\": \"ספריות נגישות למשתמש\",\n  \"LabelLibrary\": \"ספרייה\",\n  \"LabelLibraryFilterSublistEmpty\": \"לא {0}\",\n  \"LabelLibraryItem\": \"פריט ספרייה\",\n  \"LabelLibraryName\": \"שם הספרייה\",\n  \"LabelLibrarySortByProgress\": \"התקדמות: עודכן לאחרונה\",\n  \"LabelLibrarySortByProgressFinished\": \"התקדמות: הושלם\",\n  \"LabelLibrarySortByProgressStarted\": \"התקדמות: הותחל\",\n  \"LabelLimit\": \"מגבלה\",\n  \"LabelLineSpacing\": \"מרווח שורה\",\n  \"LabelListenAgain\": \"האזן שוב\",\n  \"LabelLogLevelDebug\": \"דיבוג\",\n  \"LabelLogLevelInfo\": \"מידע\",\n  \"LabelLogLevelWarn\": \"אזהרה\",\n  \"LabelLookForNewEpisodesAfterDate\": \"חפש פרקים חדשים לאחר תאריך זה\",\n  \"LabelLowestPriority\": \"העדיפות הנמוכה ביותר\",\n  \"LabelMatchConfidence\": \"רמת ודאות\",\n  \"LabelMatchExistingUsersBy\": \"התאם משתמשים קיימים לפי\",\n  \"LabelMatchExistingUsersByDescription\": \"משמש לחיבור משתמשים קיימים. לאחר החיבור, המשתמשים יותאמו לפי זיהוי ייחודי מספק ה-SSO שלך\",\n  \"LabelMaxEpisodesToDownload\": \"מספר פרקים מקסימלי להורדה. 0 - ללא הגבלה.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"מספר פרקים חדשים מקסימלי להורדה בכל בדיקה\",\n  \"LabelMaxEpisodesToKeep\": \"מספר פרקים מקסימלי לשמור\",\n  \"LabelMaxEpisodesToKeepHelp\": \"ערך של 0 קובע ללא מגבלה. לאחר הורדה אוטומטית של פרק חדש יימחק את הפרק הישן ביותר אם יש לך יותר מ-X פרקים. פעולה זו תמחק רק פרק אחד לכל הורדה חדשה.\",\n  \"LabelMediaPlayer\": \"נגן מדיה\",\n  \"LabelMediaType\": \"סוג מדיה\",\n  \"LabelMetaTag\": \"תג מטא\",\n  \"LabelMetaTags\": \"תגי מטא\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"מקורות המטא-נתונים עם עדיפות גבוהה יחליפו מקורות עם עדיפות נמוכה יותר\",\n  \"LabelMetadataProvider\": \"ספק מטא-נתונים\",\n  \"LabelMinute\": \"דקה\",\n  \"LabelMinutes\": \"דקות\",\n  \"LabelMissing\": \"חסר\",\n  \"LabelMissingEbook\": \"אין ספר אלקטרוני\",\n  \"LabelMissingSupplementaryEbook\": \"אין ספר אלקטרוני נלווה\",\n  \"LabelMobileRedirectURIs\": \"כתובות משדר ניידות מורשות\",\n  \"LabelMobileRedirectURIsDescription\": \"זהו רשימה לבניה של כתובות ה-URI הנתמכות להפניות עבור אפליקציות ניידות. הברירת מחדל היא <code>audiobookshelf://oauth</code>, שניתן להסיר או להוסיף לה כתובות נוספות לאינטגרציה עם אפליקציות צד שלישי. שימוש בכוכבית (<code>*</code>) כקלט בודד מאפשר כל URI.\",\n  \"LabelMore\": \"עוד\",\n  \"LabelMoreInfo\": \"מידע נוסף\",\n  \"LabelName\": \"שם\",\n  \"LabelNarrator\": \"מספר\",\n  \"LabelNarrators\": \"מספרים\",\n  \"LabelNew\": \"חדש\",\n  \"LabelNewPassword\": \"סיסמה חדשה\",\n  \"LabelNewestAuthors\": \"הסופרים האחרונים\",\n  \"LabelNewestEpisodes\": \"הפרקים החדשים ביותר\",\n  \"LabelNextBackupDate\": \"תאריך הגיבוי הבא\",\n  \"LabelNextChapters\": \"הפרקים הבא יהיו:\",\n  \"LabelNextScheduledRun\": \"הרצה מתוזמנת הבאה\",\n  \"LabelNoApiKeys\": \"אין מפתחות API\",\n  \"LabelNoCustomMetadataProviders\": \"אין ספקי מטא-נתונים מותאמים אישית\",\n  \"LabelNoEpisodesSelected\": \"לא נבחרו פרקים\",\n  \"LabelNotFinished\": \"לא הושלם\",\n  \"LabelNotStarted\": \"לא התחיל\",\n  \"LabelNotes\": \"הערות\",\n  \"LabelNotificationAppriseURL\": \"כתובות Apprise\",\n  \"LabelNotificationAvailableVariables\": \"משתנים זמינים\",\n  \"LabelNotificationBodyTemplate\": \"תבנית גוף\",\n  \"LabelNotificationEvent\": \"אירוע התראה\",\n  \"LabelNotificationTitleTemplate\": \"תבנית כותרת\",\n  \"LabelNotificationsMaxFailedAttempts\": \"מספר הניסיונות הנכשלים המרבי\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"ההתראות מושבתות לאחר שהן נכשלות לשלוח מספר פעמים זה\",\n  \"LabelNotificationsMaxQueueSize\": \"גודל התור המרבי לאירועי התראה\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"האירועים מוגבלים לשליחה אחת לשנייה. האירועים יתעלמו אם התור מלא. הגדרה זו נועדה למנוע ספאם התראות.\",\n  \"LabelNumberOfBooks\": \"מספר הספרים\",\n  \"LabelNumberOfChapters\": \"מספר הפרקים:\",\n  \"LabelNumberOfEpisodes\": \"# פרקים\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"שם OpenID claim המכילה הרשאות מתקדמות לפעולות משתמש בתוך האפליקציה, אשר יחולו על תפקידים שאינם מנהלי מערכת (<b>אם הוגדרה</b>). אם התביעה חסרה בתגובה, הגישה ל-ABS תידחה. אם אפשרות אחת חסרה, היא תטופל כ-<code>false</code> יש לוודא שטענת ספק הזהויות תואמת את המבנה הצפוי:\",\n  \"LabelOpenIDClaims\": \"השאר את האפשרויות הבאות ריקות כדי להשבית הקצאת קבוצות והרשאות מתקדמת, ולאחר מכן להקצות אוטומטית את קבוצת 'משתמש'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"שם ה־OpenID claim המכיל את רשימת הקבוצות של המשתמש. בדרך כלל נקרא <code>groups</code>. <b>אם הוגדרה</b>, האפליקציה תקצה תפקידים באופן אוטומטי על סמך השיוך לקבוצות, בתנאי ששמות הקבוצות ב־claim הם 'admin', 'user' או 'guest' (ללא רגישות לרישיות - Case-insensitive). ה־claim צריך להכיל רשימה; אם המשתמש משויך למספר קבוצות, האפליקציה תקצה את התפקיד בעל רמת הגישה הגבוהה ביותר. במידה ולא נמצאה קבוצה תואמת, הגישה תיחסם.\",\n  \"LabelOpenRSSFeed\": \"פתח ערוץ RSS\",\n  \"LabelOverwrite\": \"לשכפל\",\n  \"LabelPaginationPageXOfY\": \"עמוד {0} מתוך {1}\",\n  \"LabelPassword\": \"סיסמה\",\n  \"LabelPath\": \"נתיב\",\n  \"LabelPermanent\": \"קבוע\",\n  \"LabelPermissionsAccessAllLibraries\": \"ניתן לגשת לכל הספריות\",\n  \"LabelPermissionsAccessAllTags\": \"ניתן לגשת לכל התגיות\",\n  \"LabelPermissionsAccessExplicitContent\": \"ניתן לגשת לתוכן בוטה\",\n  \"LabelPermissionsCreateEreader\": \"ניתן ליצור קורא ספרים דיגיטלי\",\n  \"LabelPermissionsDelete\": \"מותר למחוק\",\n  \"LabelPermissionsDownload\": \"מותר להוריד\",\n  \"LabelPermissionsUpdate\": \"מותר לעדכן\",\n  \"LabelPermissionsUpload\": \"מותר להעלות\",\n  \"LabelPersonalYearReview\": \"השנה שלך בסקירה ({0})\",\n  \"LabelPhotoPathURL\": \"נתיב/URL לתמונה\",\n  \"LabelPlayMethod\": \"שיטת הפעלה\",\n  \"LabelPlaybackRateIncrementDecrement\": \"שיעור הגדלה/הפחתה של מהירות ההשמעה\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} מתוך {1}\",\n  \"LabelPlaylists\": \"רשימות השמעה\",\n  \"LabelPodcast\": \"פודקאסט\",\n  \"LabelPodcastSearchRegion\": \"אזור חיפוש פודקאסט\",\n  \"LabelPodcastType\": \"סוג פודקאסט\",\n  \"LabelPodcasts\": \"פודקאסטים\",\n  \"LabelPort\": \"פורט\",\n  \"LabelPrefixesToIgnore\": \"קידומות להתעלמות (מתעלם מאותיות גדולות/קטנות)\",\n  \"LabelPreventIndexing\": \"מנע רישום של הערוץ שלך על ידי ספריות אייטונס וגוגל פודקאסט\",\n  \"LabelPrimaryEbook\": \"ספר אלקטרוני ראשי\",\n  \"LabelProgress\": \"התקדמות\",\n  \"LabelProvider\": \"ספק\",\n  \"LabelProviderAuthorizationValue\": \"ערך כותרת האימות (Authorization Header)\",\n  \"LabelPubDate\": \"תאריך פרסום\",\n  \"LabelPublishYear\": \"שנת הפרסום\",\n  \"LabelPublishedDate\": \"פורסם {0}\",\n  \"LabelPublishedDecade\": \"עשור פרסום\",\n  \"LabelPublishedDecades\": \"עשורי פרסום\",\n  \"LabelPublisher\": \"מוציא לאור\",\n  \"LabelPublishers\": \"מוצאים לאור\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"אימייל בעלים מותאם אישית\",\n  \"LabelRSSFeedCustomOwnerName\": \"שם בעלים מותאם אישית\",\n  \"LabelRSSFeedOpen\": \"ערוץ RSS פתוח\",\n  \"LabelRSSFeedPreventIndexing\": \"מנע רישום\",\n  \"LabelRSSFeedSlug\": \"Slug של ערוץ ה-RSS\",\n  \"LabelRSSFeedURL\": \"כתובת ערוץ ה-RSS\",\n  \"LabelRandomly\": \"באופן אקראי\",\n  \"LabelReAddSeriesToContinueListening\": \"הוסף סדרה בחזרה אל ״המשך האזנה״\",\n  \"LabelRead\": \"קריאה\",\n  \"LabelReadAgain\": \"קרא שוב\",\n  \"LabelReadEbookWithoutProgress\": \"קרא/י ספר אלקטרוני ללא שמירת התקדמות\",\n  \"LabelRecentSeries\": \"סדרות אחרונות\",\n  \"LabelRecentlyAdded\": \"נוסף לאחרונה\",\n  \"LabelRecommended\": \"מומלץ\",\n  \"LabelRedo\": \"עשה שוב\",\n  \"LabelRegion\": \"אזור\",\n  \"LabelReleaseDate\": \"תאריך הוצאה לאור\",\n  \"LabelRemoveAllMetadataAbs\": \"הסר את כל קבצי metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"הסר את כל קבצי metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"הסר פתיח וסיום של Audible מהפרקים\",\n  \"LabelRemoveCover\": \"הסר כריכה\",\n  \"LabelRemoveMetadataFile\": \"הסר קבצי מטא־נתונים מתיקיות הפריטים בספרייה\",\n  \"LabelRemoveMetadataFileHelp\": \"הסר את כל קבצי metadata.json ו־metadata.abs מתיקיות {0}.\",\n  \"LabelRowsPerPage\": \"שורות לעמוד\",\n  \"LabelSearchTerm\": \"מונח חיפוש\",\n  \"LabelSearchTitle\": \"כותרת חיפוש\",\n  \"LabelSearchTitleOrASIN\": \"כותרת חיפוש או ASIN\",\n  \"LabelSeason\": \"עונה\",\n  \"LabelSeasonNumber\": \"עונה #{0}\",\n  \"LabelSelectAll\": \"בחר הכל\",\n  \"LabelSelectAllEpisodes\": \"בחר את כל הפרקים\",\n  \"LabelSelectEpisodesShowing\": \"בחר {0} פרקים המוצגים\",\n  \"LabelSelectUser\": \"בחר משתמש\",\n  \"LabelSelectUsers\": \"בחר משתמשים\",\n  \"LabelSendEbookToDevice\": \"שלח ספר אלקטרוני ל...\",\n  \"LabelSequence\": \"רצף\",\n  \"LabelSerial\": \"מספר סידורי\",\n  \"LabelSeries\": \"סדרה\",\n  \"LabelSeriesName\": \"שם הסדרה\",\n  \"LabelSeriesProgress\": \"התקדמות בסדרה\",\n  \"LabelServerLogLevel\": \"רמת פירוט יומני הרישום\",\n  \"LabelServerYearReview\": \"השנה בסקירה של השרת ({0})\",\n  \"LabelSetEbookAsPrimary\": \"קבע כראשי\",\n  \"LabelSetEbookAsSupplementary\": \"קבע כמשלים\",\n  \"LabelSettingsAllowIframe\": \"אפשר הטמעה בתוך iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"רק ספרי קול\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"הפעלת ההגדרה הזו תתעלם מקבצי ספרים אלקטרוניים אלא אם כן הם נמצאים בתיקיית ספרי קול, שבמקרה זה יקבעו כספרים אלקטרוניים נלווים\",\n  \"LabelSettingsBookshelfViewHelp\": \"עיצוב סקאומורפי עם מדפי עץ\",\n  \"LabelSettingsChromecastSupport\": \"תמיכה ב-Chromecast\",\n  \"LabelSettingsDateFormat\": \"פורמט תאריך\",\n  \"LabelSettingsEnableWatcher\": \"הפעל מעקב שינויים בספריות\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"הפעל מעקב שינויים בספרייה\",\n  \"LabelSettingsEnableWatcherHelp\": \"מאפשר הוספת/עדכון אוטומטי של פריטים כאשר שינויי קבצים זוהים. *דורש איתחול שרת\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"אפשור תוכן הכולל סקריפטים ב־ePubs\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"אפשר לקובצי EPUB להריץ סקריפטים. מומלץ להשאיר את ההגדרה כבויה, אלא אם כן מקור קובצי ה־ePub מהימן.\",\n  \"LabelSettingsExperimentalFeatures\": \"תכונות ניסיוניות\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"תכונות בפיתוח שדורשות משובך ובדיקה. לחץ לפתיחת דיון ב-GitHub.\",\n  \"LabelSettingsFindCovers\": \"מצא כריכות\",\n  \"LabelSettingsFindCoversHelp\": \"אם לספר הקולי שלך אין כריכה מוטמעת או תמונת כריכה בתיקייה, הסורק ינסה למצוא תמונת כריכה.<br>שים לב: זה יאריך את זמן הסריקה\",\n  \"LabelSettingsHideSingleBookSeries\": \"הסתר סדרות עם ספר אחד\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"סדרות הכוללות ספר אחד יוסתרו מדף הסדרות ומדף הבית.\",\n  \"LabelSettingsHomePageBookshelfView\": \"השתמש בתצוגת מדף בדף הבית\",\n  \"LabelSettingsLibraryBookshelfView\": \"השתמש בתצוגת מדף בספרייה\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"סמן פריט מדיה כהושלם כאשר\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"דלג על ספרים קודמים ב״המשך סדרה״\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"מדף המשך סדרות מציג את הספר הראשון שלא הושמע בסדרה שיש בה לפחות ספר אחד שהושלם ואין ספרים שכבר באמצע שמיעה. הפעלת הגדרה זו תמשיך סדרות מהספר שהושלם הכי מתקדם בסדרה במקום מהספר הראשון שלא הושמע.\",\n  \"LabelSettingsParseSubtitles\": \"פענח כתוביות\",\n  \"LabelSettingsParseSubtitlesHelp\": \"העתק כותרת משנה משם תיקיית הספר.<br>כותרת המשנה חייבת להיות מופרדת עם התו ״-״<br>לדוגמא, כותרת המשנה לספר ״שם הספר - כותרת משנה״, היא ״כותרת משנה״\",\n  \"LabelSettingsPreferMatchedMetadata\": \"העדף מטה-נתונים מותאמים\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"נתונים מותאמים יועדפו על פני פרטים שכבר מוטמעים בפריט כאשר התאמה מהירה בשימוש. כברירת מחדל, התאמה מהירה תמלא פרטים חסרים בלבד.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"דלג על ספרים שכבר יש להם ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"דלג על ספרים שכבר יש להם ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"התעלם מקידומות במיון\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"לדוגמא, לקידומת ״ה״ שם הספר, שם הספר ימוין בתור ״שם הספר״, ״ה״\",\n  \"LabelSettingsSquareBookCovers\": \"השתמש בכריכות מרובעות לספרים\",\n  \"LabelSettingsSquareBookCoversHelp\": \"השתמש בכריכות מרובעות על פני בכריכות סטנדרטיות ביחס 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"אחסן תמונת כריכה עם הפריט\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"כברירת מחדל, צילומי כריכות נשמרים בתיקיית /metadata/items, לאחר הפעלת הגדרה זו צילומי כריכות יישמרו בתיקיית הספר, רק קובץ אחד בשם ״cover״ יישמר\",\n  \"LabelSettingsStoreMetadataWithItem\": \"אחסן מטה-נתונים עם הפריט\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"כברירת מחדל, קבצי מטה-נתונים מאוחסנים ב- /metadata/items, הפעלת ההגדרה תאחסן קבצי מטה-נתונים בתיקיית פריט שלך בספרייה\",\n  \"LabelSettingsTimeFormat\": \"פורמט זמן\",\n  \"LabelShare\": \"שתף\",\n  \"LabelShareDownloadableHelp\": \"אפשר למי שיש ברשותו קישור שיתוף להוריד קובץ ZIP של פריט הספרייה.\",\n  \"LabelShareURL\": \"שתף קישור\",\n  \"LabelShowAll\": \"הצג הכל\",\n  \"LabelShowSeconds\": \"הצג שניות\",\n  \"LabelShowSubtitles\": \"הצג כתוביות\",\n  \"LabelSize\": \"גודל\",\n  \"LabelSleepTimer\": \"טיימר שינה\",\n  \"LabelSortAscending\": \"סדר עולה\",\n  \"LabelSortDescending\": \"סדר יורד\",\n  \"LabelSortPubDate\": \"מיין לפי תאריך פרסום\",\n  \"LabelStart\": \"התחל\",\n  \"LabelStartTime\": \"זמן התחלה\",\n  \"LabelStarted\": \"התחיל\",\n  \"LabelStartedAt\": \"התחיל ב\",\n  \"LabelStartedDate\": \"הותחל {0}\",\n  \"LabelStatsAudioTracks\": \"רצועות שמע\",\n  \"LabelStatsAuthors\": \"מחברים\",\n  \"LabelStatsBestDay\": \"היום הטוב ביותר\",\n  \"LabelStatsDailyAverage\": \"ממוצע יומי\",\n  \"LabelStatsDays\": \"ימים\",\n  \"LabelStatsDaysListened\": \"מספר ימים בהם נשמע ספר\",\n  \"LabelStatsHours\": \"שעות\",\n  \"LabelStatsInARow\": \"ברצף\",\n  \"LabelStatsItemsFinished\": \"פריטים שסיימת\",\n  \"LabelStatsItemsInLibrary\": \"פריטים בספרייה\",\n  \"LabelStatsMinutes\": \"דקות\",\n  \"LabelStatsMinutesListening\": \"דקות האזנה\",\n  \"LabelStatsOverallDays\": \"ימים כולל\",\n  \"LabelStatsOverallHours\": \"שעות כולל\",\n  \"LabelStatsWeekListening\": \"האזנה שבועית\",\n  \"LabelSubtitle\": \"כותרת משנה\",\n  \"LabelSupportedFileTypes\": \"סוגי קבצים נתמכים\",\n  \"LabelTag\": \"תג\",\n  \"LabelTags\": \"תגיות\",\n  \"LabelTagsAccessibleToUser\": \"תגיות נגישות למשתמש\",\n  \"LabelTagsNotAccessibleToUser\": \"תגיות לא נגישות למשתמש\",\n  \"LabelTasks\": \"משימות פעילות\",\n  \"LabelTextEditorBulletedList\": \"רשימת נקודות\",\n  \"LabelTextEditorLink\": \"קישור\",\n  \"LabelTextEditorNumberedList\": \"רשימה ממוספרת\",\n  \"LabelTextEditorUnlink\": \"ביטול קישור\",\n  \"LabelTheme\": \"ערכת נושא\",\n  \"LabelThemeDark\": \"כהה\",\n  \"LabelThemeLight\": \"בהיר\",\n  \"LabelThemeSepia\": \"ספיה\",\n  \"LabelTimeBase\": \"בסיס זמן\",\n  \"LabelTimeDurationXHours\": \"{0} שעות\",\n  \"LabelTimeDurationXMinutes\": \"{0} דקות\",\n  \"LabelTimeDurationXSeconds\": \"{0} שניות\",\n  \"LabelTimeInMinutes\": \"זמן בשניות\",\n  \"LabelTimeLeft\": \"נותרו {0}\",\n  \"LabelTimeListened\": \"זמן האזנה\",\n  \"LabelTimeListenedToday\": \"זמן האזנה היום\",\n  \"LabelTimeRemaining\": \"{0} נותרו\",\n  \"LabelTimeToShift\": \"זמן להיסט בשניות\",\n  \"LabelTitle\": \"כותרת\",\n  \"LabelToolsEmbedMetadata\": \"הטמעת מטה-נתונים\",\n  \"LabelToolsEmbedMetadataDescription\": \"הטמעת מטה-נתונים לקבצי שמע כולל תמונות כריכה ופרקים.\",\n  \"LabelToolsM4bEncoder\": \"מקודד M4B\",\n  \"LabelToolsMakeM4b\": \"יצירת קובץ אודיו M4B\",\n  \"LabelToolsMakeM4bDescription\": \"יצירת קובץ אודיו .M4B עם מטה-נתונים מוטמעים, תמונת שער ופרקים.\",\n  \"LabelToolsSplitM4b\": \"פיצול M4B ל-MP3\",\n  \"LabelToolsSplitM4bDescription\": \"יצירת קבצי MP3 מ-M4B מפוצל לפי פרקים עם מטה-נתונים מוטמעים, תמונת שער ופרקים.\",\n  \"LabelTotalDuration\": \"משך כולל\",\n  \"LabelTotalTimeListened\": \"סך הזמן שהקשבת\",\n  \"LabelTrackFromFilename\": \"רצועות משמות קבצים\",\n  \"LabelTrackFromMetadata\": \"רצועות ממטה-נתונים\",\n  \"LabelTracks\": \"רצועות\",\n  \"LabelTracksMultiTrack\": \"רב-ערוצי\",\n  \"LabelTracksNone\": \"אין ערוצים\",\n  \"LabelTracksSingleTrack\": \"רצועה יחידה\",\n  \"LabelTrailer\": \"קדימון\",\n  \"LabelType\": \"סוג\",\n  \"LabelUnabridged\": \"לא מקוצר\",\n  \"LabelUndo\": \"בטל\",\n  \"LabelUnknown\": \"לא ידוע\",\n  \"LabelUnknownPublishDate\": \"תאריך הוצאה לאור לא ידוע\",\n  \"LabelUpdateCover\": \"עדכן כריכה\",\n  \"LabelUpdateCoverHelp\": \"אפשר החלפה של כריכות קיימות עבור הספרים הנבחרים כאשר נמצאה התאמה\",\n  \"LabelUpdateDetails\": \"עדכון פרטים\",\n  \"LabelUpdateDetailsHelp\": \"אפשר החלפה של פרטים קיימים עבור הספרים הנבחרים כאשר נמצאה התאמה\",\n  \"LabelUpdatedAt\": \"עודכן ב-\",\n  \"LabelUploaderDragAndDrop\": \"גרור ושחרר קבצים או תיקיות\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"גרור ושחרר קבצים\",\n  \"LabelUploaderDropFiles\": \"שחרר קבצים\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"משיכת כותרת, סופר וסדרה באופן אוטומטי\",\n  \"LabelUseAdvancedOptions\": \"השתמש באפשרויות מתקדמות\",\n  \"LabelUseChapterTrack\": \"השתמש ברצועות הפרקים\",\n  \"LabelUseFullTrack\": \"השתמש ברצועה המלאה\",\n  \"LabelUseZeroForUnlimited\": \"השתמש ב־0 מתוך אין־סוף\",\n  \"LabelUser\": \"משתמש\",\n  \"LabelUsername\": \"שם משתמש\",\n  \"LabelValue\": \"ערך\",\n  \"LabelVersion\": \"גרסה\",\n  \"LabelViewBookmarks\": \"הצג סימניות\",\n  \"LabelViewChapters\": \"הצג פרקים\",\n  \"LabelViewPlayerSettings\": \"הצג הגדרות נגן\",\n  \"LabelViewQueue\": \"הצג תור נגן\",\n  \"LabelVolume\": \"עוצמת קול\",\n  \"LabelWebRedirectURLsDescription\": \"יש לאשר את הכתובות הבאות אצל ספק ה־OAuth כדי לאפשר הפניה חזרה לאפליקציית הדפדפן לאחר ההתחברות:\",\n  \"LabelWebRedirectURLsSubfolder\": \"תיקיית משנה לכתובות הפניה\",\n  \"LabelWeekdaysToRun\": \"ימי השבוע להרצה\",\n  \"LabelXBooks\": \"{0} ספרים\",\n  \"LabelXItems\": \"{0} פריטים\",\n  \"LabelYearReviewHide\": \"הסתר סקירת שנה\",\n  \"LabelYearReviewShow\": \"הצג סקירת שנה\",\n  \"LabelYourAudiobookDuration\": \"משך הספר הקולי שלך\",\n  \"LabelYourBookmarks\": \"הסימניות שלך\",\n  \"LabelYourPlaylists\": \"הפלייליסטים שלך\",\n  \"LabelYourProgress\": \"ההתקדמות שלך\",\n  \"MessageAddToPlayerQueue\": \"הוסף לתור הנגן\",\n  \"MessageAppriseDescription\": \"כדי להשתמש בתכונה זו יש לך להריץ מופע של <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">ממשק התכנית האפליקציה</a> או API שיטפל בבקשות אלו. <br /> כתובת URL של ממשק ה-Apprise API צריכה להיות הנתיב המלא לשליחת ההתראה, לדוגמה, אם המופע של ה-API שלך מוצע ב-<code>http://192.168.1.1:8337</code> אז עליך לשים <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"יש לוודא שימוש ב־ASIN מאזור ה־Audible הנכון, ולא מ־Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"אסימוני API ישנים יוסרו בעתיד. יש להשתמש ב <a href=\\\"/config/api-keys\\\">מפתחות API</a> במקום.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"יש להפעיל מחדש את השרת לאחר השמירה כדי להחיל את שינויי ה־OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"האימות שופר מטעמי אבטחה. כל המשתמשים נדרשים להתחבר מחדש.\",\n  \"MessageBackupsDescription\": \"גיבויים כוללים משתמשים, התקדמות משתמש, פרטי פריטי ספרייה, הגדרות שרת ותמונות השמורות ב-<code>/metadata/items</code> & <code>/metadata/authors</code>. גיבויים <strong>לא</strong> כוללים קבצים שמורים בתיקיות הספרייה שלך.\",\n  \"MessageBackupsLocationEditNote\": \"הערה: שינוי מיקום הגיבוי לא יגרום להעברה או לשינוי של גיבויים קיימים\",\n  \"MessageBackupsLocationNoEditNote\": \"הערה: מיקום הגיבוי מוגדר באמצעות משתנה סביבה ולא ניתן לשנותו כאן.\",\n  \"MessageBackupsLocationPathEmpty\": \"נתיב מיקום הגיבוי אינו יכול להיות ריק\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"מלא את השדות הפעילים בנתונים מכל הפריטים. שדות בעלי ערכים מרובים ימוזגו\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"מלא את שדות פרטי המיפוי הפעילים בנתונים מפריט זה\",\n  \"MessageBatchQuickMatchDescription\": \"התאמה מהירה תנסה להוסיף כריכות ומטה-נתונים חסרים עבור הפריטים הנבחרים. הפעל את האפשרויות למטה כדי לאפשר להתאמה מהירה להחליף כריכות קיימות ו/או מטה-נתונים.\",\n  \"MessageBookshelfNoCollections\": \"עדיין לא יצרת אוספים\",\n  \"MessageBookshelfNoCollectionsHelp\": \"האוספים ציבוריים. כל המשתמשים בעלי גישה לספרייה יכולים לראות אותם.\",\n  \"MessageBookshelfNoRSSFeeds\": \"אין ערוצי RSS פתוחים\",\n  \"MessageBookshelfNoResultsForFilter\": \"אין תוצאות עבור סינון \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"אין תוצאות עבור השאילתה\",\n  \"MessageBookshelfNoSeries\": \"אין לך סדרות\",\n  \"MessageBulkChapterPattern\": \"כמה פרקים להוסיף לפי תבנית מספור זו?\",\n  \"MessageChapterEndIsAfter\": \"זמן סיום הפרק אחרי סיום הספר הקולי שלך\",\n  \"MessageChapterErrorFirstNotZero\": \"הפרק הראשון חייב להתחיל ב-0\",\n  \"MessageChapterErrorStartGteDuration\": \"זמן התחלה לא תקין, חייב להיות פחות ממשך הספר הקולי\",\n  \"MessageChapterErrorStartLtPrev\": \"זמן התחלה לא תקין, חייב להיות גדול או שווה לזמן ההתחלה של הפרק הקודם\",\n  \"MessageChapterStartIsAfter\": \"התחלת הפרק אחרי סיום הספר הקולי שלך\",\n  \"MessageChaptersNotFound\": \"לא נמצאו פרקים\",\n  \"MessageCheckingCron\": \"בודק את תזמון העבודה...\",\n  \"MessageConfirmCloseFeed\": \"האם אתה בטוח שאתה רוצה לסגור את הערוץ הזה?\",\n  \"MessageConfirmDeleteApiKey\": \"האם למחוק את מפתח ה־API \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"האם אתה בטוח שברצונך למחוק גיבוי עבור {0}?\",\n  \"MessageConfirmDeleteDevice\": \"האם למחוק את הקורא האלקטרוני \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"הקובץ ימחק לצמיתות מהמערכת שלך. האם אתה בטוח?\",\n  \"MessageConfirmDeleteLibrary\": \"האם אתה בטוח שברצונך למחוק לצמיתות את הספרייה \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"פריט הספרייה יימחק לצמיתות ממסד הנתונים ומהמערכת שלך. האם אתה בטוח?\",\n  \"MessageConfirmDeleteLibraryItems\": \"פריטי הספרייה {0} יימחקו ממסד הנתונים ומהמערכת שלך. האם אתה בטוח?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"האם למחוק את ספק המטא־נתונים המותאם \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"האם למחוק התראה זו?\",\n  \"MessageConfirmDeleteSession\": \"האם אתה בטוח שאתה רוצה למחוק את ההפעלה הזו?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"האם להטמיע מטא־נתונים ב־{0} קובצי שמע?\",\n  \"MessageConfirmForceReScan\": \"האם אתה בטוח שאתה רוצה להכריח סריקה מחדש?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"האם אתה בטוח שברצונך לסמן את כל הפרקים כהסתיימו?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"האם אתה בטוח שברצונך לסמן את כל הפרקים כלא הסתיימו?\",\n  \"MessageConfirmMarkItemFinished\": \"האם לסמן את \\\"{0}\\\" כהושלם?\",\n  \"MessageConfirmMarkItemNotFinished\": \"האם לסמן את \\\"{0}\\\" כלא הושלם?\",\n  \"MessageConfirmMarkSeriesFinished\": \"האם אתה בטוח שברצונך לסמן את כל הספרים בסדרה זו כהסתיימו?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"האם אתה בטוח שברצונך לסמן את כל הספרים בסדרה זו כלא הסתיימו?\",\n  \"MessageConfirmNotificationTestTrigger\": \"האם להפעיל התראה זו עם נתוני בדיקה?\",\n  \"MessageConfirmPurgeCache\": \"ניקוי המטמון ימחק את כל התיקייה ב־<code>/metadata/cache</code>.<br /><br />האם למחוק את תיקיית המטמון?\",\n  \"MessageConfirmPurgeItemsCache\": \"ניקוי מטמון הפריטים ימחק את כל התיקייה ב־<code>metadata/cache/items/</code>.<br />האם למחוק?\",\n  \"MessageConfirmQuickEmbed\": \"אזהרה! הטמעה מהירה לא תגבה גיבוי של קבצי האודיו שלך. וודא שיש לך גיבוי של קבצי האודיו שלך. <br><br>האם ברצונך להמשיך?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"התאמה מהירה תדרוס פרטים עבור פרקים תואמים. רק פרקים ללא התאמה יעודכנו. האם להמשיך?\",\n  \"MessageConfirmReScanLibraryItems\": \"האם אתה בטוח שברצונך לסרוק מחדש {0} פריטים?\",\n  \"MessageConfirmRemoveAllChapters\": \"האם אתה בטוח שברצונך להסיר את כל הפרקים?\",\n  \"MessageConfirmRemoveAuthor\": \"האם אתה בטוח שברצונך להסיר את המחבר \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"האם אתה בטוח שברצונך להסיר אוסף \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"האם אתה בטוח שברצונך להסיר פרק \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodes\": \"האם אתה בטוח שברצונך להסיר {0} פרקים?\",\n  \"MessageConfirmRemoveListeningSessions\": \"האם אתה בטוח שברצונך להסיר {0} הפעלות האזנה?\",\n  \"MessageConfirmRemoveNarrator\": \"האם אתה בטוח שברצונך להסיר מקריא \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"האם אתה בטוח שברצונך להסיר את רשימת ההשמעה שלך \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"האם אתה בטוח שברצונך לשנות את שם הז'אנר \\\"{0}\\\" ל \\\"{1}\\\" עבור כל הפריטים?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"הערה: ז'אנר זה כבר קיים ולכן הם יתמזגו.\",\n  \"MessageConfirmRenameGenreWarning\": \"אזהרה! יש ז'אנר דומה עם רישום שונה שכבר קיים \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"האם אתה בטוח שברצונך לשנות את שם התג \\\"{0}\\\" ל \\\"{1}\\\" עבור כל הפריטים?\",\n  \"MessageConfirmRenameTagMergeNote\": \"הערה: התג זה כבר קיים ולכן הם יתמזגו.\",\n  \"MessageConfirmRenameTagWarning\": \"אזהרה! יש תג דומה עם רישום שונה שכבר קיים \\\"{0}\\\".\",\n  \"MessageConfirmSendEbookToDevice\": \"האם אתה בטוח שברצונך לשלוח {0} את הספר האלקטרוני \\\"{1}\\\" למכשיר \\\"{2}\\\"?\",\n  \"MessageDownloadingEpisode\": \"מוריד פרק\",\n  \"MessageDragFilesIntoTrackOrder\": \"גרור קבצים לסדר ההשמעה נכון\",\n  \"MessageEmbedFinished\": \"ההטמעה הושלמה!\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} פרק/ים בתור להורדה\",\n  \"MessageFeedURLWillBe\": \"כתובת ה- URL של הערוץ תהיה {0}\",\n  \"MessageFetching\": \"מושך...\",\n  \"MessageForceReScanDescription\": \"תבוצע סריקה מחדש כמו סריקה חדש מאפס, תגי ID3 של קבצי קול, קבצי OPF, וקבצי טקסט ייסרקו כחדשים.\",\n  \"MessageImportantNotice\": \"הודעה חשובה!\",\n  \"MessageInsertChapterBelow\": \"הוסף פרק מתחת\",\n  \"MessageItemsSelected\": \"{0} פריטים נבחרו\",\n  \"MessageItemsUpdated\": \"{0} פריטים עודכנו\",\n  \"MessageJoinUsOn\": \"הצטרף אלינו ב-\",\n  \"MessageLoading\": \"טוען...\",\n  \"MessageLoadingFolders\": \"טוען תיקיות...\",\n  \"MessageM4BFailed\": \"M4B נכשל!\",\n  \"MessageM4BFinished\": \"M4B הושלם!\",\n  \"MessageMapChapterTitles\": \"מפה שמות פרקים לפרקי הספר השמורים שלך ללא שינוי תגי זמן\",\n  \"MessageMarkAllEpisodesFinished\": \"סמן את כל הפרקים כהסתיימו\",\n  \"MessageMarkAllEpisodesNotFinished\": \"סמן את כל הפרקים כלא הסתיימו\",\n  \"MessageMarkAsFinished\": \"סמן כהושלם\",\n  \"MessageMarkAsNotFinished\": \"סמן כלא הסתיים\",\n  \"MessageMatchBooksDescription\": \"ינסה להתאים ספרים בספריית הספרים שלך עם ספר מספק החיפוש הנבחר וימלא פרטים ריקים ותמונות כריכה. לא יחליף פרטים קיימים.\",\n  \"MessageNoAudioTracks\": \"אין רצועות שמע\",\n  \"MessageNoAuthors\": \"אין סופרים\",\n  \"MessageNoBackups\": \"אין גיבויים\",\n  \"MessageNoBookmarks\": \"אין סימניות\",\n  \"MessageNoChapters\": \"אין פרקים\",\n  \"MessageNoCollections\": \"אין אוספים\",\n  \"MessageNoCoversFound\": \"לא נמצאו כריכות\",\n  \"MessageNoDescription\": \"אין תיאור\",\n  \"MessageNoDownloadsInProgress\": \"אין הורדות פעילות כרגע\",\n  \"MessageNoDownloadsQueued\": \"אין הורדות בתור\",\n  \"MessageNoEpisodeMatchesFound\": \"לא נמצאו התאמות לפרק\",\n  \"MessageNoEpisodes\": \"אין פרקים\",\n  \"MessageNoFoldersAvailable\": \"אין תיקיות זמינות\",\n  \"MessageNoGenres\": \"אין ז'אנרים\",\n  \"MessageNoIssues\": \"אין תקלות\",\n  \"MessageNoItems\": \"אין פריטים\",\n  \"MessageNoItemsFound\": \"לא נמצאו פריטים\",\n  \"MessageNoListeningSessions\": \"אין הפעלות האזנה\",\n  \"MessageNoLogs\": \"אין לוגים\",\n  \"MessageNoMediaProgress\": \"אין התקדמות במדיה\",\n  \"MessageNoNotifications\": \"אין התראות\",\n  \"MessageNoPodcastsFound\": \"לא נמצאו פודקאסטים\",\n  \"MessageNoResults\": \"אין תוצאות\",\n  \"MessageNoSearchResultsFor\": \"אין תוצאות חיפוש עבור \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"אין סדרות\",\n  \"MessageNoTags\": \"אין תגיות\",\n  \"MessageNoTasksRunning\": \"אין משימות פעילות\",\n  \"MessageNoUpdatesWereNecessary\": \"לא נדרש עדכון\",\n  \"MessageNoUserPlaylists\": \"אין לך רשימות השמעה\",\n  \"MessageNotYetImplemented\": \"עדיין לא מיושם\",\n  \"MessageOr\": \"או\",\n  \"MessagePauseChapter\": \"השהה השמעת הפרק\",\n  \"MessagePlayChapter\": \"הקשב לתחילת הפרק\",\n  \"MessagePlaylistCreateFromCollection\": \"צור רשימת השמעה מאוסף\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"לפודקאסט אין כתובת URL של ערוץ RSS להתאמה\",\n  \"MessagePodcastSearchField\": \"הזן מונח חיפוש או כתובת URL של ערוץ RSS\",\n  \"MessageQuickMatchDescription\": \"ממלא פרטים ריקים וכריכות עם התוצאה הראשונה מ '{0}'. לא ימחק פרטים אלא אם הגדרת השרת 'העדף מטה-נתונים מותאמים' מופעלת.\",\n  \"MessageRemoveChapter\": \"הסר פרק\",\n  \"MessageRemoveEpisodes\": \"הסר {0} פרקים\",\n  \"MessageRemoveFromPlayerQueue\": \"הסר מתור ההשמעה של הנגן\",\n  \"MessageRemoveUserWarning\": \"האם אתה בטוח שברצונך למחוק לצמיתות את המשתמש \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"דווח על באגים, בקש תכונות חדשות, ותרום ב-\",\n  \"MessageResetChaptersConfirm\": \"האם אתה בטוח שברצונך לאפס את הפרקים ולבטל את השינויים שביצעת?\",\n  \"MessageRestoreBackupConfirm\": \"האם אתה בטוח שברצונך לשחזר את הגיבוי שנוצר ב\",\n  \"MessageRestoreBackupWarning\": \"שחזור גיבוי ימחק את כל מסד הנתונים הנוכחי השוכן ב /config ואת תמונות הכריכה ב- /metadata/items & /metadata/authors.<br /><br />גיבויים אינם משנים קבצים בתיקיות הספרייה שלך. אם הגדרות השרת לאחסן תמונות כריכה ומטא-נתונים בתיקיות הספרייה שלך מופעלות אז אלה לא יגובו או ימחקו.<br /><br />כל האפליקציות המשתמשות בשרת שלך יתעדכנו באופן אוטומטי.\",\n  \"MessageSearchResultsFor\": \"תוצאות חיפוש עבור\",\n  \"MessageSelected\": \"{0} נבחרו\",\n  \"MessageServerCouldNotBeReached\": \"לא ניתן להגיע אל השרת\",\n  \"MessageSetChaptersFromTracksDescription\": \"קבע פרקים באמצעות כל קובץ שמע כפרק וכותרת פרק כשם הקובץ שמע\",\n  \"MessageStartPlaybackAtTime\": \"להתחיל השמעה עבור \\\"{0}\\\" ב-{1}?\",\n  \"MessageThinking\": \"חושב...\",\n  \"MessageUploaderItemFailed\": \"העלאת הפריט נכשלה\",\n  \"MessageUploaderItemSuccess\": \"העלאה הצליחה!\",\n  \"MessageUploading\": \"מעלה...\",\n  \"MessageValidCronExpression\": \"ביטוי Cron חוקי\",\n  \"MessageWatcherIsDisabledGlobally\": \"עוקב מנוטרל באופן גלובלי בהגדרות השרת\",\n  \"MessageXLibraryIsEmpty\": \"ספריית {0} ריקה!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"הזמן של הספר הקולי שלך ארוך יותר מהזמן שנמצא\",\n  \"MessageYourAudiobookDurationIsShorter\": \"הזמן של הספר הקולי שלך קצר יותר מהזמן שנמצא\",\n  \"NoteChangeRootPassword\": \"המשתמש root הוא המשתמש היחיד שיכולה להיות לו סיסמה ריקה\",\n  \"NoteChapterEditorTimes\": \"הערה: זמן ההתחלה של הפרק הראשון חייב להישאר 0:00 וזמן ההתחלה של הפרק האחרון לא יכול לחרוג מהזמן של ספר השמע.\",\n  \"NoteFolderPicker\": \"הערה: תיקיות שכבר מופו לא יוצגו\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"אזהרה: רוב אפליקציות הפודקאסטים ידרשו שכתובת האתר של ערוץ ה-RSS תשתמש ב-HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"אזהרה: פרק אחד או יותר לא מכילים תאריך פרסום. חלק מיישומי הפודקאסט דורשים זאת.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"תיקיות עם קבצי מדיה יעובדו כפריטי ספריה נפרדים.\",\n  \"NoteUploaderOnlyAudioFiles\": \"אם מועלים רק קבצי שמע, כל קובץ שמע יעובד כספר שמע נפרד.\",\n  \"NoteUploaderUnsupportedFiles\": \"מתעלם מקבצים לא נתמכים. בעת בחירת תיקייה או גרירה לדף, מתעלם מקבצים אחרים שאינם בתיקיית פריט.\",\n  \"PlaceholderNewCollection\": \"שם אוסף חדש\",\n  \"PlaceholderNewFolderPath\": \"נתיב תיקייה חדשה\",\n  \"PlaceholderNewPlaylist\": \"שם רשימת השמעה חדשה\",\n  \"PlaceholderSearch\": \"חיפוש..\",\n  \"PlaceholderSearchEpisode\": \"חיפוש פרק..\",\n  \"ToastAccountUpdateSuccess\": \"חשבון עודכן בהצלחה\",\n  \"ToastAuthorImageRemoveSuccess\": \"תמונת המחבר הוסרה בהצלחה\",\n  \"ToastAuthorUpdateMerged\": \"המחבר מוזג\",\n  \"ToastAuthorUpdateSuccess\": \"המחבר עודכן בהצלחה\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"המחבר עודכן (תמונה לא נמצאה)\",\n  \"ToastBackupCreateFailed\": \"יצירת גיבוי נכשלה\",\n  \"ToastBackupCreateSuccess\": \"גיבוי נוצר בהצלחה\",\n  \"ToastBackupDeleteFailed\": \"מחיקת הגיבוי נכשלה\",\n  \"ToastBackupDeleteSuccess\": \"הגיבוי נמחק בהצלחה\",\n  \"ToastBackupRestoreFailed\": \"שחזור הגיבוי נכשל\",\n  \"ToastBackupUploadFailed\": \"העלאת הגיבוי נכשלה\",\n  \"ToastBackupUploadSuccess\": \"הגיבוי הועלה בהצלחה\",\n  \"ToastBatchUpdateFailed\": \"עדכון קבוצתי נכשל\",\n  \"ToastBatchUpdateSuccess\": \"עדכון קבוצתי הצליח\",\n  \"ToastBookmarkCreateFailed\": \"יצירת סימניה נכשלה\",\n  \"ToastBookmarkCreateSuccess\": \"הסימניה נוספה בהצלחה\",\n  \"ToastBookmarkRemoveSuccess\": \"הסימניה הוסרה בהצלחה\",\n  \"ToastChaptersHaveErrors\": \"פרקים מכילים שגיאות\",\n  \"ToastChaptersMustHaveTitles\": \"פרקים חייבים לכלול כותרות\",\n  \"ToastCollectionRemoveSuccess\": \"האוסף הוסר בהצלחה\",\n  \"ToastCollectionUpdateSuccess\": \"האוסף עודכן בהצלחה\",\n  \"ToastItemCoverUpdateSuccess\": \"כריכת הפריט עודכנה בהצלחה\",\n  \"ToastItemDetailsUpdateSuccess\": \"פרטי הפריט עודכנו בהצלחה\",\n  \"ToastItemMarkedAsFinishedFailed\": \"סימון כפריט שהושלם נכשל\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"הפריט סומן כהושלם בהצלחה\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"סימון כפריט שלא הושלם נכשל\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"הפריט סומן כלא הושלם בהצלחה\",\n  \"ToastLibraryCreateFailed\": \"יצירת הספרייה נכשלה\",\n  \"ToastLibraryCreateSuccess\": \"הספרייה \\\"{0}\\\" נוצרה בהצלחה\",\n  \"ToastLibraryDeleteFailed\": \"מחיקת הספרייה נכשלה\",\n  \"ToastLibraryDeleteSuccess\": \"הספרייה נמחקה בהצלחה\",\n  \"ToastLibraryScanFailedToStart\": \"הפעלת הסריקה נכשלה\",\n  \"ToastLibraryScanStarted\": \"הסריקה של הספרייה החלה\",\n  \"ToastLibraryUpdateSuccess\": \"הספרייה \\\"{0}\\\" עודכנה בהצלחה\",\n  \"ToastPlaylistCreateFailed\": \"יצירת רשימת השמעה נכשלה\",\n  \"ToastPlaylistCreateSuccess\": \"רשימת השמעה נוצרה בהצלחה\",\n  \"ToastPlaylistRemoveSuccess\": \"רשימת השמעה הוסרה בהצלחה\",\n  \"ToastPlaylistUpdateSuccess\": \"רשימת השמעה עודכנה בהצלחה\",\n  \"ToastPodcastCreateFailed\": \"יצירת הפודקאסט נכשלה\",\n  \"ToastPodcastCreateSuccess\": \"הפודקאסט נוצר בהצלחה\",\n  \"ToastRSSFeedCloseFailed\": \"סגירת ערוץ ה-RSS נכשלה\",\n  \"ToastRSSFeedCloseSuccess\": \"ערוץ ה-RSS נסגר בהצלחה\",\n  \"ToastRemoveItemFromCollectionFailed\": \"הסרת הפריט מהאוסף נכשלה\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"הפריט הוסר מהאוסף בהצלחה\",\n  \"ToastSendEbookToDeviceFailed\": \"שליחת הספר אל המכשיר נכשלה\",\n  \"ToastSendEbookToDeviceSuccess\": \"הספר נשלח אל המכשיר \\\"{0}\\\"\",\n  \"ToastSeriesUpdateFailed\": \"עדכון הסדרה נכשל\",\n  \"ToastSeriesUpdateSuccess\": \"הסדרה עודכנה בהצלחה\",\n  \"ToastServerSettingsUpdateSuccess\": \"הגדרות שרת עודכנו בהצלחה\",\n  \"ToastSessionDeleteFailed\": \"מחיקת הפעולה נכשלה\",\n  \"ToastSessionDeleteSuccess\": \"הפעולה נמחקה בהצלחה\",\n  \"ToastSocketConnected\": \"קצה תקשורת חובר\",\n  \"ToastSocketDisconnected\": \"קצה תקשורת נותק\",\n  \"ToastSocketFailedToConnect\": \"התחברות קצה התקשורת נכשלה\",\n  \"ToastUserDeleteFailed\": \"מחיקת המשתמש נכשלה\",\n  \"ToastUserDeleteSuccess\": \"המשתמש נמחק בהצלחה\"\n}\n"
  },
  {
    "path": "client/strings/hi.json",
    "content": "{\n  \"ButtonAdd\": \"जोड़ें\",\n  \"ButtonAddApiKey\": \"एपीआई कुंजी जोड़ें\",\n  \"ButtonAddChapters\": \"अध्याय जोड़ें\",\n  \"ButtonAddDevice\": \"उपकरण जोड़ें\",\n  \"ButtonAddLibrary\": \"संग्रह जोड़ें\",\n  \"ButtonAddPodcasts\": \"पॉडकास्ट जोड़ें\",\n  \"ButtonAddUser\": \"उपयोगकर्ता जोड़ें\",\n  \"ButtonAddYourFirstLibrary\": \"अपनी पहली पुस्तकालय जोड़ें\",\n  \"ButtonApply\": \"लागू करें\",\n  \"ButtonApplyChapters\": \"अध्यायों में परिवर्तन लागू करें\",\n  \"ButtonAuthors\": \"लेखक\",\n  \"ButtonBack\": \"पीछे\",\n  \"ButtonBatchEditPopulateFromExisting\": \"मौजूदा से आबाद करें\",\n  \"ButtonBatchEditPopulateMapDetails\": \"मानचित्र विवरण भरें\",\n  \"ButtonBrowseForFolder\": \"फ़ोल्डर खोजें\",\n  \"ButtonCancel\": \"रद्द करें\",\n  \"ButtonCancelEncode\": \"एनकोड रद्द करें\",\n  \"ButtonChangeRootPassword\": \"रूट का पासवर्ड बदलें\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"नए एपिसोड खोजें और डाउनलोड करें\",\n  \"ButtonChooseAFolder\": \"एक फ़ोल्डर चुनें\",\n  \"ButtonChooseFiles\": \"फ़ाइलें चुनें\",\n  \"ButtonClearFilter\": \"लागू फ़िल्टर साफ़ करें\",\n  \"ButtonClose\": \"बंद करें\",\n  \"ButtonCloseFeed\": \"फ़ीड बंद करें\",\n  \"ButtonCloseSession\": \"वर्तमान सत्र बंद करें\",\n  \"ButtonCollections\": \"संग्रह\",\n  \"ButtonConfigureScanner\": \"स्कैनर सेटिंग्स बदलें\",\n  \"ButtonCreate\": \"बनाएं\",\n  \"ButtonCreateBackup\": \"बैकअप लें\",\n  \"ButtonDelete\": \"हटाएं\",\n  \"ButtonDownloadQueue\": \"कतार डाउनलोड करें\",\n  \"ButtonEdit\": \"संपादित करें\",\n  \"ButtonEditChapters\": \"अध्याय संपादित करें\",\n  \"ButtonEditPodcast\": \"पॉडकास्ट संपादित करें\",\n  \"ButtonEnable\": \"सक्षम करें\",\n  \"ButtonForceReScan\": \"बलपूर्वक पुन: स्कैन करें\",\n  \"ButtonFullPath\": \"पूर्ण पथ\",\n  \"ButtonHide\": \"छुपाएं\",\n  \"ButtonHome\": \"घर\",\n  \"ButtonIssues\": \"समस्याएं\",\n  \"ButtonLatest\": \"नवीनतम\",\n  \"ButtonLibrary\": \"पुस्तकालय\",\n  \"ButtonLogout\": \"लॉग आउट\",\n  \"ButtonLookup\": \"तलाश करें\",\n  \"ButtonManageTracks\": \"ट्रैक्स मैनेज करें\",\n  \"ButtonMapChapterTitles\": \"अध्यायों का मिलान करें\",\n  \"ButtonMatchAllAuthors\": \"सभी लेखकों को तलाश करें\",\n  \"ButtonMatchBooks\": \"संबंधित पुस्तकों का मिलान करें\",\n  \"ButtonNevermind\": \"कोई बात नहीं\",\n  \"ButtonOk\": \"ठीक है\",\n  \"ButtonOpenFeed\": \"फ़ीड खोलें\",\n  \"ButtonOpenManager\": \"मैनेजर खोलें\",\n  \"ButtonPlay\": \"चलाएँ\",\n  \"ButtonPlaying\": \"चल रही है\",\n  \"ButtonPlaylists\": \"प्लेलिस्ट्स\",\n  \"ButtonPurgeAllCache\": \"सभी Cache मिटाएं\",\n  \"ButtonPurgeItemsCache\": \"आइटम Cache मिटाएं\",\n  \"ButtonQueueAddItem\": \"क़तार में जोड़ें\",\n  \"ButtonQueueRemoveItem\": \"कतार से हटाएं\",\n  \"ButtonQuickMatch\": \"जल्दी से समानता की तलाश करें\",\n  \"ButtonReScan\": \"पुन: स्कैन करें\",\n  \"ButtonRead\": \"पढ़ लिया\",\n  \"ButtonRemove\": \"हटाएं\",\n  \"ButtonRemoveAll\": \"सभी हटाएं\",\n  \"ButtonRemoveAllLibraryItems\": \"पुस्तकालय की सभी आइटम हटाएं\",\n  \"ButtonRemoveFromContinueListening\": \"सुनना जारी रखें से हटाएं\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"इस सीरीज को कंटिन्यू सीरीज से हटा दें\",\n  \"ButtonReset\": \"रीसेट करें\",\n  \"ButtonRestore\": \"पुनर्स्थापित करें\",\n  \"ButtonSave\": \"सहेजें\",\n  \"ButtonSaveAndClose\": \"सहेजें और बंद करें\",\n  \"ButtonSaveTracklist\": \"ट्रैक सूची सहेजें\",\n  \"ButtonScan\": \"स्कैन करें\",\n  \"ButtonScanLibrary\": \"पुस्तकालय स्कैन करें\",\n  \"ButtonSearch\": \"खोजें\",\n  \"ButtonSelectFolderPath\": \"फ़ोल्डर का पथ चुनें\",\n  \"ButtonSeries\": \"सीरीज\",\n  \"ButtonSetChaptersFromTracks\": \"ट्रैक्स से अध्याय बनाएं\",\n  \"ButtonShiftTimes\": \"समय खिसकाए\",\n  \"ButtonShow\": \"दिखाएं\",\n  \"ButtonStartM4BEncode\": \"M4B एन्कोडिंग शुरू करें\",\n  \"ButtonStartMetadataEmbed\": \"मेटाडेटा एम्बेडिंग शुरू करें\",\n  \"ButtonSubmit\": \"जमा करें\",\n  \"ButtonUpload\": \"अपलोड करें\",\n  \"ButtonUploadBackup\": \"बैकअप अपलोड करें\",\n  \"ButtonUploadCover\": \"कवर अपलोड करें\",\n  \"ButtonUploadOPMLFile\": \"OPML फ़ाइल अपलोड करें\",\n  \"ButtonUserDelete\": \"उपयोगकर्ता {0} को हटाएं\",\n  \"ButtonUserEdit\": \"उपयोगकर्ता {0} को संपादित करें\",\n  \"ButtonViewAll\": \"सभी को देखें\",\n  \"ButtonYes\": \"हाँ\",\n  \"HeaderAccount\": \"खाता\",\n  \"HeaderAdvanced\": \"विकसित\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise अधिसूचना सेटिंग्स\",\n  \"LabelBackupsMaxBackupSize\": \"Maximum backup size (in GB)\",\n  \"LabelFontFamily\": \"फुहारा परिवार\",\n  \"LabelPodcastSearchRegion\": \"पॉडकास्ट खोज क्षेत्र\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.\",\n  \"MessageBookshelfNoResultsForFilter\": \"No Results for filter \\\"{0}: {1}\\\"\",\n  \"NoteChangeRootPassword\": \"रूट user is the only user that can have an empty password\",\n  \"ToastSendEbookToDeviceFailed\": \"Failed to Send Ebook to device\"\n}\n"
  },
  {
    "path": "client/strings/hr.json",
    "content": "{\n  \"ButtonAdd\": \"Dodaj\",\n  \"ButtonAddApiKey\": \"Dodaj API ključ\",\n  \"ButtonAddChapters\": \"Dodaj poglavlja\",\n  \"ButtonAddDevice\": \"Dodaj uređaj\",\n  \"ButtonAddLibrary\": \"Dodaj knjižnicu\",\n  \"ButtonAddPodcasts\": \"Dodaj podcaste\",\n  \"ButtonAddUser\": \"Dodaj korisnika\",\n  \"ButtonAddYourFirstLibrary\": \"Dodaj svoju prvu knjižnicu\",\n  \"ButtonApply\": \"Primijeni\",\n  \"ButtonApplyChapters\": \"Primijeni poglavlja\",\n  \"ButtonAuthors\": \"Autori\",\n  \"ButtonBack\": \"Natrag\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Popuni iz postojećih\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Popuni detalje karte\",\n  \"ButtonBrowseForFolder\": \"Pronađi mapu\",\n  \"ButtonCancel\": \"Odustani\",\n  \"ButtonCancelEncode\": \"Otkaži kodiranje\",\n  \"ButtonChangeRootPassword\": \"Promijeni zaporku root korisnika\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Provjeri i preuzmi nove nastavke\",\n  \"ButtonChooseAFolder\": \"Odaberi mapu\",\n  \"ButtonChooseFiles\": \"Odaberi datoteke\",\n  \"ButtonClearFilter\": \"Poništi filter\",\n  \"ButtonClose\": \"Zatvori\",\n  \"ButtonCloseFeed\": \"Zatvori izvor\",\n  \"ButtonCloseSession\": \"Zatvori otvorenu sesiju\",\n  \"ButtonCollections\": \"Zbirke\",\n  \"ButtonConfigureScanner\": \"Postavi skener\",\n  \"ButtonCreate\": \"Izradi\",\n  \"ButtonCreateBackup\": \"Izradi sigurnosnu kopiju\",\n  \"ButtonDelete\": \"Izbriši\",\n  \"ButtonDownloadQueue\": \"Red\",\n  \"ButtonEdit\": \"Uredi\",\n  \"ButtonEditChapters\": \"Uredi poglavlja\",\n  \"ButtonEditPodcast\": \"Uredi podcast\",\n  \"ButtonEnable\": \"Omogući\",\n  \"ButtonFireAndFail\": \"Okini i vrati status neuspješno\",\n  \"ButtonFireOnTest\": \"Okini onTest događaj\",\n  \"ButtonForceReScan\": \"Prisilno ponovno skeniranje\",\n  \"ButtonFullPath\": \"Cijela putanja\",\n  \"ButtonHide\": \"Sakrij\",\n  \"ButtonHome\": \"Početna stranica\",\n  \"ButtonIssues\": \"Problemi\",\n  \"ButtonJumpBackward\": \"Skok unatrag\",\n  \"ButtonJumpForward\": \"Skok unaprijed\",\n  \"ButtonLatest\": \"Najnovije\",\n  \"ButtonLibrary\": \"Knjižnica\",\n  \"ButtonLogout\": \"Odjavi se\",\n  \"ButtonLookup\": \"Potraži\",\n  \"ButtonManageTracks\": \"Upravljanje zvučnim zapisima\",\n  \"ButtonMapChapterTitles\": \"Mapiraj naslove poglavlja\",\n  \"ButtonMatchAllAuthors\": \"Prepoznaj sve autore\",\n  \"ButtonMatchBooks\": \"Prepoznaj knjige\",\n  \"ButtonNevermind\": \"Nije bitno\",\n  \"ButtonNext\": \"Sljedeće\",\n  \"ButtonNextChapter\": \"Sljedeće poglavlje\",\n  \"ButtonNextItemInQueue\": \"Sljedeća stavka u redu\",\n  \"ButtonOk\": \"U redu\",\n  \"ButtonOpenFeed\": \"Otvori izvor\",\n  \"ButtonOpenManager\": \"Otvori Upravitelja\",\n  \"ButtonPause\": \"Pauziraj\",\n  \"ButtonPlay\": \"Reproduciraj\",\n  \"ButtonPlayAll\": \"Reproduciraj sve\",\n  \"ButtonPlaying\": \"Izvodi se\",\n  \"ButtonPlaylists\": \"Popisi za izvođenje\",\n  \"ButtonPrevious\": \"Prethodno\",\n  \"ButtonPreviousChapter\": \"Prethodno poglavlje\",\n  \"ButtonProbeAudioFile\": \"Ispitaj zvučnu datoteku\",\n  \"ButtonPurgeAllCache\": \"Isprazni cijelu predmemoriju\",\n  \"ButtonPurgeItemsCache\": \"Isprazni predmemoriju stavki\",\n  \"ButtonQueueAddItem\": \"Dodaj u red\",\n  \"ButtonQueueRemoveItem\": \"Ukloni iz reda\",\n  \"ButtonQuickEmbed\": \"Brzo ugrađivanje\",\n  \"ButtonQuickEmbedMetadata\": \"Brzo ugrađivanje meta-podataka\",\n  \"ButtonQuickMatch\": \"Brzo prepoznavanje\",\n  \"ButtonReScan\": \"Ponovno skeniraj\",\n  \"ButtonRead\": \"Pročitaj\",\n  \"ButtonReadLess\": \"Pročitaj manje\",\n  \"ButtonReadMore\": \"Pročitaj više\",\n  \"ButtonRefresh\": \"Osvježi\",\n  \"ButtonRemove\": \"Ukloni\",\n  \"ButtonRemoveAll\": \"Ukloni sve\",\n  \"ButtonRemoveAllLibraryItems\": \"Ukloni sve stavke iz knjižnice\",\n  \"ButtonRemoveFromContinueListening\": \"Ukloni iz Nastavi slušati\",\n  \"ButtonRemoveFromContinueReading\": \"Ukloni iz Nastavi čitati\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Ukloni serijal iz popisa „Nastavi serijal”\",\n  \"ButtonReset\": \"Poništi\",\n  \"ButtonResetToDefault\": \"Vrati na početne postavke\",\n  \"ButtonRestore\": \"Vraćanje\",\n  \"ButtonSave\": \"Spremi\",\n  \"ButtonSaveAndClose\": \"Spremi i zatvori\",\n  \"ButtonSaveTracklist\": \"Spremi popis zvučnih zapisa\",\n  \"ButtonScan\": \"Skeniraj\",\n  \"ButtonScanLibrary\": \"Skeniraj knjižnicu\",\n  \"ButtonScrollLeft\": \"Pomicanje lijevo\",\n  \"ButtonScrollRight\": \"Pomicanje desno\",\n  \"ButtonSearch\": \"Traži\",\n  \"ButtonSelectFolderPath\": \"Odaberi putanju mape\",\n  \"ButtonSeries\": \"Serijali\",\n  \"ButtonSetChaptersFromTracks\": \"Postavi poglavlja iz zvučnih zapisa\",\n  \"ButtonShare\": \"Podijeli\",\n  \"ButtonShiftTimes\": \"Pomakni vremena\",\n  \"ButtonShow\": \"Prikaži\",\n  \"ButtonStartM4BEncode\": \"Pokreni M4B kodiranje\",\n  \"ButtonStartMetadataEmbed\": \"Pokreni ugradnju meta-podataka\",\n  \"ButtonStats\": \"Statistika\",\n  \"ButtonSubmit\": \"Pošalji\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Prekini vezu s OpenID-jem\",\n  \"ButtonUpload\": \"Prenesi\",\n  \"ButtonUploadBackup\": \"Prenesi sigurnosnu kopiju\",\n  \"ButtonUploadCover\": \"Prenesi naslovnicu\",\n  \"ButtonUploadOPMLFile\": \"Prenesi OPML datoteku\",\n  \"ButtonUserDelete\": \"Izbriši korisnika {0}\",\n  \"ButtonUserEdit\": \"Uredi korisnika {0}\",\n  \"ButtonViewAll\": \"Prikaži sve\",\n  \"ButtonYes\": \"Da\",\n  \"ErrorUploadFetchMetadataAPI\": \"Pogreška pri dohvaćanju meta-podataka\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Nije bilo moguće dohvatiti meta-podatake – pokušajte aktualizirati naslov i/ili autora\",\n  \"ErrorUploadLacksTitle\": \"Mora imati naslov\",\n  \"HeaderAccount\": \"Korisnički račun\",\n  \"HeaderAddCustomMetadataProvider\": \"Dodaj prilagođenog pružatelja meta-podataka\",\n  \"HeaderAdvanced\": \"Napredno\",\n  \"HeaderApiKeys\": \"API ključevi\",\n  \"HeaderAppriseNotificationSettings\": \"Postavke obavijesti Apprise\",\n  \"HeaderAudioTracks\": \"Zvučni zapisi\",\n  \"HeaderAudiobookTools\": \"Alati za upravljanje datotekama zvučnih knjiga\",\n  \"HeaderAuthentication\": \"Provjera autentičnosti\",\n  \"HeaderBackups\": \"Sigurnosne kopije\",\n  \"HeaderBulkChapterModal\": \"Dodaj više poglavlja\",\n  \"HeaderChangePassword\": \"Promjena zaporke\",\n  \"HeaderChapters\": \"Poglavlja\",\n  \"HeaderChooseAFolder\": \"Odaberi mapu\",\n  \"HeaderCollection\": \"Zbirka\",\n  \"HeaderCollectionItems\": \"Stavke u zbirci\",\n  \"HeaderCover\": \"Naslovnica\",\n  \"HeaderCurrentDownloads\": \"Trenutačna preuzimanja\",\n  \"HeaderCustomMessageOnLogin\": \"Prilagođena poruka prilikom prijave\",\n  \"HeaderCustomMetadataProviders\": \"Prilagođeni pružatelji meta-podataka\",\n  \"HeaderDetails\": \"Pojedinosti\",\n  \"HeaderDownloadQueue\": \"Red preuzimanja\",\n  \"HeaderEbookFiles\": \"Datoteke e-knjiga\",\n  \"HeaderEmail\": \"E-pošta\",\n  \"HeaderEmailSettings\": \"Postavke e-pošte\",\n  \"HeaderEpisodes\": \"Nastavci\",\n  \"HeaderEreaderDevices\": \"E-čitači\",\n  \"HeaderEreaderSettings\": \"Postavke e-čitača\",\n  \"HeaderFiles\": \"Datoteke\",\n  \"HeaderFindChapters\": \"Pronađi poglavlja\",\n  \"HeaderIgnoredFiles\": \"Zanemarene datoteke\",\n  \"HeaderItemFiles\": \"Datoteke stavke\",\n  \"HeaderItemMetadataUtils\": \"Alati za meta-podatke\",\n  \"HeaderLastListeningSession\": \"Posljednja sesija slušanja\",\n  \"HeaderLatestEpisodes\": \"Najnoviji nastavci\",\n  \"HeaderLibraries\": \"Knjižnice\",\n  \"HeaderLibraryFiles\": \"Datoteke knjižnice\",\n  \"HeaderLibraryStats\": \"Statistika knjižnice\",\n  \"HeaderListeningSessions\": \"Sesije slušanja\",\n  \"HeaderListeningStats\": \"Statistika slušanja\",\n  \"HeaderLogin\": \"Prijava\",\n  \"HeaderLogs\": \"Zapisnici\",\n  \"HeaderManageGenres\": \"Upravljanje žanrovima\",\n  \"HeaderManageTags\": \"Upravljanje oznakama\",\n  \"HeaderMapDetails\": \"Mapiranje pojedinosti\",\n  \"HeaderMatch\": \"Prepoznavanje\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Redoslijed prihvaćanja meta-podataka\",\n  \"HeaderMetadataToEmbed\": \"Meta-podatci za ugradnju\",\n  \"HeaderNewAccount\": \"Novi korisnički račun\",\n  \"HeaderNewApiKey\": \"Novi API ključ\",\n  \"HeaderNewLibrary\": \"Nova knjižnica\",\n  \"HeaderNotificationCreate\": \"Izradi obavijest\",\n  \"HeaderNotificationUpdate\": \"Ažuriraj obavijest\",\n  \"HeaderNotifications\": \"Obavijesti\",\n  \"HeaderOpenIDConnectAuthentication\": \"Prijava na OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Otvorene sesije slušanja\",\n  \"HeaderOpenRSSFeed\": \"Otvori RSS izvor\",\n  \"HeaderOtherFiles\": \"Druge datoteke\",\n  \"HeaderPasswordAuthentication\": \"Provjera autentičnosti zaporkom\",\n  \"HeaderPermissions\": \"Dozvole\",\n  \"HeaderPlayerQueue\": \"Redoslijed izvođenja\",\n  \"HeaderPlayerSettings\": \"Postavke reproduktora\",\n  \"HeaderPlaylist\": \"Popis za izvođenje\",\n  \"HeaderPlaylistItems\": \"Stavke popisa za izvođenje\",\n  \"HeaderPodcastsToAdd\": \"Podcasti za dodavanje\",\n  \"HeaderPresets\": \"Predlošci postavki\",\n  \"HeaderPreviewCover\": \"Pretpregled naslovnice\",\n  \"HeaderRSSFeedGeneral\": \"RSS pojedinosti\",\n  \"HeaderRSSFeedIsOpen\": \"RSS izvor je otvoren\",\n  \"HeaderRSSFeeds\": \"RSS izvori\",\n  \"HeaderRemoveEpisode\": \"Ukloni nastavak\",\n  \"HeaderRemoveEpisodes\": \"Ukloni {0} nastavaka\",\n  \"HeaderSavedMediaProgress\": \"Spremljen napredak medija\",\n  \"HeaderSchedule\": \"Zakazivanje\",\n  \"HeaderScheduleEpisodeDownloads\": \"Zakazivanje automatskog preuzimanja nastavaka\",\n  \"HeaderScheduleLibraryScans\": \"Zakaži automatsko skeniranje knjižnice\",\n  \"HeaderSession\": \"Sesija\",\n  \"HeaderSetBackupSchedule\": \"Zakazivanje sigurnosne pohrane\",\n  \"HeaderSettings\": \"Postavke\",\n  \"HeaderSettingsDisplay\": \"Prikaz\",\n  \"HeaderSettingsExperimental\": \"Eksperimentalne značajke\",\n  \"HeaderSettingsGeneral\": \"Općenito\",\n  \"HeaderSettingsScanner\": \"Skener\",\n  \"HeaderSettingsSecurity\": \"Sigurnost\",\n  \"HeaderSettingsWebClient\": \"Web klijent\",\n  \"HeaderSleepTimer\": \"Timer za spavanje\",\n  \"HeaderStatsLargestItems\": \"Najveće stavke\",\n  \"HeaderStatsLongestItems\": \"Najduže stavke (sati)\",\n  \"HeaderStatsMinutesListeningChart\": \"Odslušanih minuta (posljednjih 7 dana)\",\n  \"HeaderStatsRecentSessions\": \"Nedavne sesije\",\n  \"HeaderStatsTop10Authors\": \"Top 10 autora\",\n  \"HeaderStatsTop5Genres\": \"Top 5 žanrova\",\n  \"HeaderTableOfContents\": \"Sadržaj\",\n  \"HeaderTools\": \"Alati\",\n  \"HeaderUpdateAccount\": \"Ažuriraj korisnički račun\",\n  \"HeaderUpdateApiKey\": \"Ažuriraj API ključ\",\n  \"HeaderUpdateAuthor\": \"Ažuriraj autora\",\n  \"HeaderUpdateDetails\": \"Ažuriraj pojedinosti\",\n  \"HeaderUpdateLibrary\": \"Ažuriraj knjižnicu\",\n  \"HeaderUsers\": \"Korisnici\",\n  \"HeaderYearReview\": \"Pregled godine {0}\",\n  \"HeaderYourStats\": \"Vaša statistika\",\n  \"LabelAbridged\": \"Skraćeno\",\n  \"LabelAbridgedChecked\": \"Skraćeno (označeno)\",\n  \"LabelAbridgedUnchecked\": \"Neskraćeno (neoznačeno)\",\n  \"LabelAccessibleBy\": \"Dostupno\",\n  \"LabelAccountType\": \"Vrsta korisničkog računa\",\n  \"LabelAccountTypeAdmin\": \"Administrator\",\n  \"LabelAccountTypeGuest\": \"Gost\",\n  \"LabelAccountTypeUser\": \"Korisnik\",\n  \"LabelActivities\": \"Aktivnosti\",\n  \"LabelActivity\": \"Aktivnost\",\n  \"LabelAddToCollection\": \"Dodaj u zbirku\",\n  \"LabelAddToCollectionBatch\": \"Dodaj {0} knjiga u zbirku\",\n  \"LabelAddToPlaylist\": \"Dodaj na popis za izvođenje\",\n  \"LabelAddToPlaylistBatch\": \"Dodaj {0} stavki u popis za izvođenje\",\n  \"LabelAddedAt\": \"Dodano\",\n  \"LabelAddedDate\": \"Dodano {0}\",\n  \"LabelAdminUsersOnly\": \"Samo korisnici administratori\",\n  \"LabelAll\": \"Sve\",\n  \"LabelAllEpisodesDownloaded\": \"Svi preuzeti nastavci\",\n  \"LabelAllUsers\": \"Svi korisnici\",\n  \"LabelAllUsersExcludingGuests\": \"Svi korisnici osim gostiju\",\n  \"LabelAllUsersIncludingGuests\": \"Svi korisnici uključujući i goste\",\n  \"LabelAlreadyInYourLibrary\": \"Već u vašoj knjižnici\",\n  \"LabelApiKeyCreated\": \"API ključ \\\"{0}\\\" uspješno izrađen.\",\n  \"LabelApiKeyCreatedDescription\": \"Ne zaboravite odmah kopirati API ključ jer ga više nećete moći vidjeti.\",\n  \"LabelApiKeyUser\": \"Izvršavaj u ime korisnika\",\n  \"LabelApiKeyUserDescription\": \"Ovaj API ključ imat će iste dozvole kao i korisnik u čije ime djeluje. U zapisnicima će biti zabilježeno da je korisnik slao zahtjeve.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Pridodaj\",\n  \"LabelAudioBitrate\": \"Kvaliteta zvučnog zapisa (npr. 128k)\",\n  \"LabelAudioChannels\": \"Broj zvučnih kanala (1 ili 2)\",\n  \"LabelAudioCodec\": \"Zvučni kodek\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Ime Prezime)\",\n  \"LabelAuthorLastFirst\": \"Autor (Prezime, Ime)\",\n  \"LabelAuthors\": \"Autori\",\n  \"LabelAutoDownloadEpisodes\": \"Automatski preuzmi nastavke\",\n  \"LabelAutoFetchMetadata\": \"Automatski dohvati meta-podatke\",\n  \"LabelAutoFetchMetadataHelp\": \"Dohvaća meta-podatke o naslovu, autoru i serijalu kako bi pojednostavnio prijenos. Dodatni meta-podatci će se možda morati usporediti nakon prijenosa.\",\n  \"LabelAutoLaunch\": \"Automatsko pokretanje\",\n  \"LabelAutoLaunchDescription\": \"Automatski preusmjeri na pružatelja autentifikacijskih usluga prilikom otvaranja stranice za prijavu (putanja za ručno zaobilaženje opcije <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatska registracija\",\n  \"LabelAutoRegisterDescription\": \"Automatski izradi nove korisnike nakon prijave\",\n  \"LabelBackToUser\": \"Povratak na korisnika\",\n  \"LabelBackupAudioFiles\": \"Sigurnosno kopiranje zvučnih datoteka\",\n  \"LabelBackupLocation\": \"Lokacija sigurnosnih kopija\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatske sigurnosne kopije\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Sigurnosne kopije spremaju se u /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maksimalna veličina sigurnosne kopije (u GB) (0 za neograničeno)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"U svrhu sprečavanja izrade krive konfiguracije, sigurnosne kopije neće se izraditi ako su veće od zadane veličine.\",\n  \"LabelBackupsNumberToKeep\": \"Broj sigurnosnih kopija za čuvanje\",\n  \"LabelBackupsNumberToKeepHelp\": \"Moguće je izbrisati samo jednu po jednu sigurnosnu kopiju, ako ih već imate više trebat ćete ih ručno ukloniti.\",\n  \"LabelBitrate\": \"Protok\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Knjige\",\n  \"LabelButtonText\": \"Tekst gumba\",\n  \"LabelByAuthor\": \"autor: {0}\",\n  \"LabelChangePassword\": \"Promijeni zaporku\",\n  \"LabelChannels\": \"Kanali\",\n  \"LabelChapterCount\": \"{0} Poglavlje/a\",\n  \"LabelChapterTitle\": \"Naslov poglavlja\",\n  \"LabelChapters\": \"Poglavlja\",\n  \"LabelChaptersFound\": \"poglavlja pronađeno\",\n  \"LabelClickForMoreInfo\": \"Kliknite za više informacija\",\n  \"LabelClickToUseCurrentValue\": \"Kliknite za trenutnu vrijednost\",\n  \"LabelClosePlayer\": \"Zatvori reproduktor\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Sažmi serijal\",\n  \"LabelCollapseSubSeries\": \"Sažmi podserijale\",\n  \"LabelCollection\": \"Zbirka\",\n  \"LabelCollections\": \"Zbirke\",\n  \"LabelComplete\": \"Potpuno\",\n  \"LabelConfirmPassword\": \"Potvrda zaporke\",\n  \"LabelContinueListening\": \"Nastavi slušati\",\n  \"LabelContinueReading\": \"Nastavi čitati\",\n  \"LabelContinueSeries\": \"Nastavi serijal\",\n  \"LabelCorsAllowed\": \"Dozvoljena CORS ishodišta\",\n  \"LabelCover\": \"Naslovnica\",\n  \"LabelCoverImageURL\": \"URL naslovnice\",\n  \"LabelCoverProvider\": \"Pružatelj naslovnica\",\n  \"LabelCreatedAt\": \"Izrađen\",\n  \"LabelCronExpression\": \"Cron izraz\",\n  \"LabelCurrent\": \"Trenutan\",\n  \"LabelCurrently\": \"Trenutno:\",\n  \"LabelCustomCronExpression\": \"Prilagođeni cron izraz:\",\n  \"LabelDatetime\": \"Datum i vrijeme\",\n  \"LabelDays\": \"Dani\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Izbriši datoteke (uklonite kvačicu ako stavku želite izbrisati samo iz baze podataka)\",\n  \"LabelDescription\": \"Opis\",\n  \"LabelDeselectAll\": \"Odznači sve\",\n  \"LabelDetectedPattern\": \"Prepoznat obrazac:\",\n  \"LabelDevice\": \"Uređaj\",\n  \"LabelDeviceInfo\": \"O uređaju\",\n  \"LabelDeviceIsAvailableTo\": \"Uređaj je dostupan za...\",\n  \"LabelDirectory\": \"Direktorij\",\n  \"LabelDiscFromFilename\": \"Disk iz imena datoteke\",\n  \"LabelDiscFromMetadata\": \"Disk iz metapodataka\",\n  \"LabelDiscover\": \"Otkrij\",\n  \"LabelDownload\": \"Preuzmi\",\n  \"LabelDownloadNEpisodes\": \"Preuzmi {0} nastavak/a\",\n  \"LabelDownloadable\": \"Moguće preuzimanje\",\n  \"LabelDuration\": \"Trajanje\",\n  \"LabelDurationComparisonExactMatch\": \"(točno podudaranje)\",\n  \"LabelDurationComparisonLonger\": \"({0} duže)\",\n  \"LabelDurationComparisonShorter\": \"({0} kraće)\",\n  \"LabelDurationFound\": \"Pronađeno trajanje:\",\n  \"LabelEbook\": \"E-knjiga\",\n  \"LabelEbooks\": \"E-knjige\",\n  \"LabelEdit\": \"Uredi\",\n  \"LabelEmail\": \"E-pošta\",\n  \"LabelEmailSettingsFromAddress\": \"Adresa pošiljatelja\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Odbij neovjerene certifikate\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Onemogućavanjem ovjere SSL certifikata izlažete vezu sigurnosnim rizicima, poput MITM napada. Ovu opciju isključite samo ukoliko razumijete što ona znači i vjerujete poslužitelju e-pošte s kojim se povezujete.\",\n  \"LabelEmailSettingsSecure\": \"Sigurno\",\n  \"LabelEmailSettingsSecureHelp\": \"Ako je uključeno, prilikom spajanja na poslužitelj upotrebljavat će se TLS. Ako je isključeno, TLS se upotrebljava samo ako poslužitelj podržava STARTTLS proširenje. U većini slučajeva, ovu ćete vrijednost uključiti ako se spajate na priključak 465. Za priključke 587 ili 25 ostavite je isključenom. (Izvor: nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Probna adresa\",\n  \"LabelEmbeddedCover\": \"Ugrađena naslovnica\",\n  \"LabelEnable\": \"Omogući\",\n  \"LabelEncodingBackupLocation\": \"Sigurnosna kopija vaših izvornih zvučnih datoteka čuvat će se u mapi:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Poglavlja se ne ugrađuju u zvučne knjige koje se sastoje od više zvučnih zapisa.\",\n  \"LabelEncodingClearItemCache\": \"Svakako redovito praznite predmemoriju stavki.\",\n  \"LabelEncodingFinishedM4B\": \"Gotove M4B datoteke spremit će se u vašu mapu sa zvučnim knjigama:\",\n  \"LabelEncodingInfoEmbedded\": \"Meta-podatci će se ugraditi u zvučne zapise u vašoj mapi sa zvučnim knjigama.\",\n  \"LabelEncodingStartedNavigation\": \"Nakon pokretanja zadatka možete napustiti ovu stranicu.\",\n  \"LabelEncodingTimeWarning\": \"Kodiranje može potrajati do 30 minuta.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Pažnja: Ne mijenjajte ove postavke ako niste temeljito upoznati s opcijama kodiranja u ffmpegu.\",\n  \"LabelEncodingWatcherDisabled\": \"Ako vam je onemogućeno praćenje mape, ovu ćete zvučnu knjigu poslije morati ponovno skenirati.\",\n  \"LabelEnd\": \"Kraj\",\n  \"LabelEndOfChapter\": \"Kraj poglavlja\",\n  \"LabelEpisode\": \"Nastavak\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Nastavak nije povezan s RSS izvorom\",\n  \"LabelEpisodeNumber\": \"{0}. nastavak\",\n  \"LabelEpisodeTitle\": \"Naslov nastavka\",\n  \"LabelEpisodeType\": \"Vrsta nastavka\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL nastavka iz RSS izvora\",\n  \"LabelEpisodes\": \"Nastavci\",\n  \"LabelEpisodic\": \"U nastavcima\",\n  \"LabelExample\": \"Primjer\",\n  \"LabelExpandSeries\": \"Proširi serijal\",\n  \"LabelExpandSubSeries\": \"Proširi podserijal\",\n  \"LabelExpired\": \"Istekao\",\n  \"LabelExpiresAt\": \"Istječe\",\n  \"LabelExpiresInSeconds\": \"Istječe za (sekundi)\",\n  \"LabelExpiresNever\": \"Nikada\",\n  \"LabelExplicit\": \"Eksplicitno\",\n  \"LabelExplicitChecked\": \"Eksplicitni sadržaj (označeno)\",\n  \"LabelExplicitUnchecked\": \"Nije eksplicitni sadržaj (odznačeno)\",\n  \"LabelExportOPML\": \"Izvoz OPML-a\",\n  \"LabelFeedURL\": \"URL izvora\",\n  \"LabelFetchingMetadata\": \"Dohvaćanje meta-podataka\",\n  \"LabelFile\": \"Datoteka\",\n  \"LabelFileBirthtime\": \"Datoteka stvorena\",\n  \"LabelFileBornDate\": \"Stvoreno {0}\",\n  \"LabelFileModified\": \"Datoteka izmijenjena\",\n  \"LabelFileModifiedDate\": \"Izmijenjeno {0}\",\n  \"LabelFilename\": \"Ime datoteke\",\n  \"LabelFilterByUser\": \"Filtriraj po korisniku\",\n  \"LabelFindEpisodes\": \"Pronađi nastavke\",\n  \"LabelFinished\": \"Završeno\",\n  \"LabelFinishedDate\": \"Završeno {0}\",\n  \"LabelFolder\": \"Mapa\",\n  \"LabelFolders\": \"Mape\",\n  \"LabelFontBold\": \"Podebljano\",\n  \"LabelFontBoldness\": \"Debljina slova\",\n  \"LabelFontFamily\": \"Skup pisma\",\n  \"LabelFontItalic\": \"Kurziv\",\n  \"LabelFontScale\": \"Veličina slova\",\n  \"LabelFontStrikethrough\": \"Precrtano\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Cijeli\",\n  \"LabelGenre\": \"Žanr\",\n  \"LabelGenres\": \"Žanrovi\",\n  \"LabelHardDeleteFile\": \"Izbriši datoteku zauvijek\",\n  \"LabelHasEbook\": \"Ima e-knjigu\",\n  \"LabelHasSupplementaryEbook\": \"Ima dopunsku e-knjigu\",\n  \"LabelHideSubtitles\": \"Sakrij podnaslove\",\n  \"LabelHighestPriority\": \"Najviši prioritet\",\n  \"LabelHost\": \"Poslužitelj\",\n  \"LabelHour\": \"Sat\",\n  \"LabelHours\": \"Sati\",\n  \"LabelIcon\": \"Ikona\",\n  \"LabelImageURLFromTheWeb\": \"URL slike s weba\",\n  \"LabelInProgress\": \"U tijeku\",\n  \"LabelIncludeInTracklist\": \"Uključi u popis zvučnih zapisa\",\n  \"LabelIncomplete\": \"Nepotpuno\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Prilagođeno dnevno/tjedno\",\n  \"LabelIntervalEvery12Hours\": \"Svakih 12 sati\",\n  \"LabelIntervalEvery15Minutes\": \"Svakih 15 minuta\",\n  \"LabelIntervalEvery2Hours\": \"Svaka 2 sata\",\n  \"LabelIntervalEvery30Minutes\": \"Svakih 30 minuta\",\n  \"LabelIntervalEvery6Hours\": \"Svakih 6 sati\",\n  \"LabelIntervalEveryDay\": \"Svaki dan\",\n  \"LabelIntervalEveryHour\": \"Svaki sat\",\n  \"LabelIntervalEveryMinute\": \"Svaku minutu\",\n  \"LabelInvert\": \"Obrni\",\n  \"LabelItem\": \"Stavka\",\n  \"LabelJumpBackwardAmount\": \"Dužina skoka unatrag\",\n  \"LabelJumpForwardAmount\": \"Dužina skoka unaprijed\",\n  \"LabelLanguage\": \"Jezik\",\n  \"LabelLanguageDefaultServer\": \"Zadani jezik poslužitelja\",\n  \"LabelLanguages\": \"Jezici\",\n  \"LabelLastBookAdded\": \"Zadnja dodana knjiga\",\n  \"LabelLastBookUpdated\": \"Zadnja ažurirana knjiga\",\n  \"LabelLastProgressDate\": \"Zadnji napredak: {0}\",\n  \"LabelLastSeen\": \"Zadnji puta viđen\",\n  \"LabelLastTime\": \"Zadnje doslušano vrijeme\",\n  \"LabelLastUpdate\": \"Zadnje ažuriranje\",\n  \"LabelLayout\": \"Prikaz\",\n  \"LabelLayoutSinglePage\": \"Jedna stranica\",\n  \"LabelLayoutSplitPage\": \"Podijeli stranicu\",\n  \"LabelLess\": \"Manje\",\n  \"LabelLibrariesAccessibleToUser\": \"Knjižnice dostupne korisniku\",\n  \"LabelLibrary\": \"Knjižnica\",\n  \"LabelLibraryFilterSublistEmpty\": \"Br {0}\",\n  \"LabelLibraryItem\": \"Stavka knjižnice\",\n  \"LabelLibraryName\": \"Ime knjižnice\",\n  \"LabelLibrarySortByProgress\": \"Napredak: Zadnje ažuriranje\",\n  \"LabelLibrarySortByProgressFinished\": \"Napredak: Završeno\",\n  \"LabelLibrarySortByProgressStarted\": \"Napredak: Započeto\",\n  \"LabelLimit\": \"Ograničenje\",\n  \"LabelLineSpacing\": \"Razmak između redaka\",\n  \"LabelListenAgain\": \"Ponovno poslušaj\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Upozorenje\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Traži nove nastavke nakon ovog datuma\",\n  \"LabelLowestPriority\": \"Najniži prioritet\",\n  \"LabelMatchConfidence\": \"Pouzdanost\",\n  \"LabelMatchExistingUsersBy\": \"Prepoznaj postojeće korisnike pomoću\",\n  \"LabelMatchExistingUsersByDescription\": \"Rabi se za povezivanje postojećih korisnika. Nakon što se spoje, korisnike se prepoznaje temeljem jedinstvene oznake vašeg pružatelja SSO usluga\",\n  \"LabelMaxEpisodesToDownload\": \"Najveći broj nastavaka za preuzimanje. 0 za neograničeno.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Najveći broj novih nastavaka za preuzimanje po provjeri\",\n  \"LabelMaxEpisodesToKeep\": \"Najveći # nastavaka za čuvanje\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Ako je vrijednost 0, nema ograničenja broja. Nakon automatskog preuzimanja novog nastavka ova funkcija briše najstariji nastavak ako ih ima više od zadanog broja. Ovo briše samo jedan nastavak po novom preuzetom nastavku.\",\n  \"LabelMediaPlayer\": \"Reproduktor medijskih sadržaja\",\n  \"LabelMediaType\": \"Vrsta medija\",\n  \"LabelMetaTag\": \"Meta oznaka\",\n  \"LabelMetaTags\": \"Meta oznake\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Izvori meta-podataka višeg prioriteta nadjačat će izvore nižeg prioriteta\",\n  \"LabelMetadataProvider\": \"Pružatelj meta-podataka\",\n  \"LabelMinute\": \"Minuta\",\n  \"LabelMinutes\": \"Minute\",\n  \"LabelMissing\": \"Nedostaje\",\n  \"LabelMissingEbook\": \"Nema e-knjigu\",\n  \"LabelMissingSupplementaryEbook\": \"Nema dopunsku e-knjigu\",\n  \"LabelMobileRedirectURIs\": \"Dopušteni URI-ji za preusmjeravanje mobilne aplikacije\",\n  \"LabelMobileRedirectURIsDescription\": \"Ovo je popis dopuštenih važećih URI-ja za preusmjeravanje mobilne aplikacije. Zadana vrijednost je <code>audiobookshelf://oauth</code>, nju možete ukloniti ili dopuniti dodatnim URI-jima za integraciju aplikacija trećih strana. Upisom zvjezdice (<code>*</code>) kao jedinim unosom možete dozvoliti bilo koji URI.\",\n  \"LabelMore\": \"Više\",\n  \"LabelMoreInfo\": \"Više informacija\",\n  \"LabelName\": \"Ime\",\n  \"LabelNarrator\": \"Pripovjedač\",\n  \"LabelNarrators\": \"Pripovjedači\",\n  \"LabelNew\": \"Novo\",\n  \"LabelNewPassword\": \"Nova zaporka\",\n  \"LabelNewestAuthors\": \"Najnoviji autori\",\n  \"LabelNewestEpisodes\": \"Najnoviji nastavci\",\n  \"LabelNextBackupDate\": \"Sljedeća izrada sigurnosne kopije\",\n  \"LabelNextChapters\": \"Sljedeća poglavlja bit će:\",\n  \"LabelNextScheduledRun\": \"Sljedeće zakazano izvođenje\",\n  \"LabelNoApiKeys\": \"Nema API ključeva\",\n  \"LabelNoCustomMetadataProviders\": \"Nema prilagođenih pružatelja meta-podataka\",\n  \"LabelNoEpisodesSelected\": \"Nema odabranih nastavaka\",\n  \"LabelNotFinished\": \"Nezavršeno\",\n  \"LabelNotStarted\": \"Nije započeto\",\n  \"LabelNotes\": \"Bilješke\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(ovi)\",\n  \"LabelNotificationAvailableVariables\": \"Dostupne varijable\",\n  \"LabelNotificationBodyTemplate\": \"Predložak sadržaja\",\n  \"LabelNotificationEvent\": \"Događaj za obavijest\",\n  \"LabelNotificationTitleTemplate\": \"Predložak naslova\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maksimalan broj neuspjelih pokušaja\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Obavijesti će biti isključene ako slanje ne uspije nakon ovoliko pokušaja\",\n  \"LabelNotificationsMaxQueueSize\": \"Najveći broj događaja za obavijest u redu čekanja\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Događaji se mogu okinuti samo jednom u sekundi. Događaji će se zanemariti ako je red čekanja pun. Ovo sprečava prekomjerno slanje obavijesti.\",\n  \"LabelNumberOfBooks\": \"Broj knjiga\",\n  \"LabelNumberOfChapters\": \"Broj poglavljâ:\",\n  \"LabelNumberOfEpisodes\": \"broj nastavaka\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Ime OpenID zahtjeva koji sadrži napredna dopuštenja za korisničke radnje u aplikaciji koje će se primijeniti na ne-administratorske uloge (<b>ako su konfigurirane</b>). Ako zahtjev nedostaje u odgovoru, pristup ABS-u će se odbiti. Ako jedna opcija nedostaje, smatrat će se da je <code>false</code>. Pripazite da zahtjev pružatelja identiteta uvijek odgovara očekivanoj strukturi:\",\n  \"LabelOpenIDClaims\": \"Sljedeće opcije ostavite praznima ako želite onemogućiti napredno dodjeljivanje grupa i dozvola, odnosno ako želite automatski dodijeliti grupu 'korisnik'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Ime OpenID zahtjeva koji sadrži popis grupa korisnika. Često se zove <code>groups</code>. <b>Ako se konfigurira</b>, aplikacija će automatski dodijeliti uloge temeljem korisničkih članstava u grupama, pod uvjetom da se iste zovu 'admin', 'user' ili 'guest' u zahtjevu (ne razlikuju se velika i mala slova). Zahtjev treba sadržavati popis i ako je korisnik član više grupa, aplikacija će dodijeliti ulogu koja odgovara najvišoj razini pristupa. Ukoliko se niti jedna grupa ne podudara, pristup će se odbiti.\",\n  \"LabelOpenRSSFeed\": \"Otvori RSS Feed\",\n  \"LabelOverwrite\": \"Prepiši\",\n  \"LabelPaginationPageXOfY\": \"Stranica {0} od {1}\",\n  \"LabelPassword\": \"Zaporka\",\n  \"LabelPath\": \"Putanja\",\n  \"LabelPermanent\": \"Trajno\",\n  \"LabelPermissionsAccessAllLibraries\": \"Ima pristup svim knjižnicama\",\n  \"LabelPermissionsAccessAllTags\": \"Ima pristup svim oznakama\",\n  \"LabelPermissionsAccessExplicitContent\": \"Ima pristup eksplicitnom sadržaju\",\n  \"LabelPermissionsCreateEreader\": \"Može stvoriti e-čitač\",\n  \"LabelPermissionsDelete\": \"Smije brisati\",\n  \"LabelPermissionsDownload\": \"Smije preuzimati\",\n  \"LabelPermissionsUpdate\": \"Smije ažurirati\",\n  \"LabelPermissionsUpload\": \"Smije prenositi\",\n  \"LabelPersonalYearReview\": \"Vaš godišnji pregled ({0})\",\n  \"LabelPhotoPathURL\": \"Putanja ili URL fotografije\",\n  \"LabelPlayMethod\": \"Način reprodukcije\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Korak povećanja/smanjenja brzine reprodukcije\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} od {1}\",\n  \"LabelPlaylists\": \"Popisi za izvođenje\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Zemljopisno područje kod pretraživanja podcasta\",\n  \"LabelPodcastType\": \"Vrsta podcasta\",\n  \"LabelPodcasts\": \"Podcasti\",\n  \"LabelPort\": \"Priključak\",\n  \"LabelPrefixesToIgnore\": \"Prefiksi koji se zanemaruju (mala i velika slova nisu bitna)\",\n  \"LabelPreventIndexing\": \"Spriječite da iTunes i Google indeksiraju vaš feed za svoje popise podcasta\",\n  \"LabelPrimaryEbook\": \"Primarna e-knjiga\",\n  \"LabelProgress\": \"Napredak\",\n  \"LabelProvider\": \"Dobavljač\",\n  \"LabelProviderAuthorizationValue\": \"Vrijednost autorizacijskog zaglavlja\",\n  \"LabelPubDate\": \"Datum izdavanja\",\n  \"LabelPublishYear\": \"Godina objavljivanja\",\n  \"LabelPublishedDate\": \"Objavljeno {0}\",\n  \"LabelPublishedDecade\": \"Desetljeće izdanja\",\n  \"LabelPublishedDecades\": \"Desetljeća izdanja\",\n  \"LabelPublisher\": \"Izdavač\",\n  \"LabelPublishers\": \"Izdavači\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Prilagođena adresa e-pošte vlasnika\",\n  \"LabelRSSFeedCustomOwnerName\": \"Prilagođeno ime vlasnika\",\n  \"LabelRSSFeedOpen\": \"RSS izvor otvoren\",\n  \"LabelRSSFeedPreventIndexing\": \"Onemogući indeksiranje\",\n  \"LabelRSSFeedSlug\": \"Slug RSS izvora\",\n  \"LabelRSSFeedURL\": \"URL RSS izvora\",\n  \"LabelRandomly\": \"Nasumično\",\n  \"LabelReAddSeriesToContinueListening\": \"Ponovo dodaj serijal u „Nastavi slušati”\",\n  \"LabelRead\": \"Čitaj\",\n  \"LabelReadAgain\": \"Ponovno čitaj\",\n  \"LabelReadEbookWithoutProgress\": \"Čitaj e-knjige bez praćenja napretka\",\n  \"LabelRecentSeries\": \"Nedavni serijali\",\n  \"LabelRecentlyAdded\": \"Nedavno dodano\",\n  \"LabelRecommended\": \"Preporučeno\",\n  \"LabelRedo\": \"Ponovi\",\n  \"LabelRegion\": \"Regija\",\n  \"LabelReleaseDate\": \"Datum izlaska\",\n  \"LabelRemoveAllMetadataAbs\": \"Ukloni sve datoteke metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Ukloni sve datoteke metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Ukloni Audibleove najave i odjave iz poglavlja\",\n  \"LabelRemoveCover\": \"Ukloni naslovnicu\",\n  \"LabelRemoveMetadataFile\": \"Ukloni datoteke s meta-podatcima iz mapa knjižničkih stavki\",\n  \"LabelRemoveMetadataFileHelp\": \"Uklanjanje svih datoteka metadata.json i metadata.abs u vaših {0} mapa.\",\n  \"LabelRowsPerPage\": \"Redaka po stranici\",\n  \"LabelSearchTerm\": \"Traži pojam\",\n  \"LabelSearchTitle\": \"Traži naslov\",\n  \"LabelSearchTitleOrASIN\": \"Traži naslov ili ASIN\",\n  \"LabelSeason\": \"Sezona\",\n  \"LabelSeasonNumber\": \"{0}. sezona\",\n  \"LabelSelectAll\": \"Označi sve\",\n  \"LabelSelectAllEpisodes\": \"Označi sve nastavke\",\n  \"LabelSelectEpisodesShowing\": \"Prikazujem {0} odabranih nastavaka\",\n  \"LabelSelectUser\": \"Odaberite korisnika\",\n  \"LabelSelectUsers\": \"Označi korisnike\",\n  \"LabelSendEbookToDevice\": \"Pošalji e-knjigu na…\",\n  \"LabelSequence\": \"Slijed\",\n  \"LabelSerial\": \"Serijal\",\n  \"LabelSeries\": \"Serijal\",\n  \"LabelSeriesName\": \"Ime serijala\",\n  \"LabelSeriesProgress\": \"Napredak u serijalu\",\n  \"LabelServerLogLevel\": \"Razina zapisa poslužitelja\",\n  \"LabelServerYearReview\": \"Godišnji pregled poslužitelja ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Postavi kao primarno\",\n  \"LabelSetEbookAsSupplementary\": \"Postavi kao dopunsko\",\n  \"LabelSettingsAllowIframe\": \"Omogući ugrađivanje u iframeu\",\n  \"LabelSettingsAudiobooksOnly\": \"Samo zvučne knjige\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorfni dizajn sa drvenim policama\",\n  \"LabelSettingsChromecastSupport\": \"Podrška za Chromecast\",\n  \"LabelSettingsDateFormat\": \"Format datuma\",\n  \"LabelSettingsEnableWatcher\": \"Automatski prati promjene u knjižnicama\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Automatski prati promjene u knjižnici\",\n  \"LabelSettingsEnableWatcherHelp\": \"Omogućuje automatsko dodavanje/ažuriranje stavki kada se uoče izmjene datoteka. *Potrebno je ponovno pokretanje poslužitelja\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Omogući skripte u epub datotekama\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Omogućuje epub datotekama izvođenje skripti. Preporučamo isključiti ovu mogućnost ukoliko nemate povjerenja u izvore epub datoteka.\",\n  \"LabelSettingsExperimentalFeatures\": \"Eksperimentalne funkcije\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funkcije u razvoju za koje trebamo vaše povratne informacije i pomoć u testiranju. Kliknite za otvaranje rasprave na githubu.\",\n  \"LabelSettingsFindCovers\": \"Pronađi naslovnice\",\n  \"LabelSettingsFindCoversHelp\": \"Ako vaša zvučna knjiga nema ugrađenu naslovnicu ili sliku naslovnice u mapi, skener će pokušati pronaći naslovnicu.<br>Napomena: ovo će produžiti trajanje skeniranja\",\n  \"LabelSettingsHideSingleBookSeries\": \"Sakrij serijale sa samo jednom knjigom\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serijali koji se sastoje od samo jedne knjige neće se prikazivati na stranici serijala i na policama početne stranice.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Prikaži početnu stranicu kao policu s knjigama\",\n  \"LabelSettingsLibraryBookshelfView\": \"Prikaži knjižnicu kao policu s knjigama\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Postotak završenosti je veći od\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Preostalo vrijeme je manje od (sekunde)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Označi medij kao završen kada\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Preskoči ranije knjige u funkciji Nastavi serijal\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu završenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje završene knjige umjesto od prve nezapočete knjige.\",\n  \"LabelSettingsParseSubtitles\": \"Raščlani podnaslove\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Raščlani podnaslove iz imena mape zvučne knjige.<br>Podnaslov mora biti odvojen sa \\\" - \\\"<br>npr. \\\"Naslov knjige - Ovo je podnaslov\\\" imat će podnaslov \\\"Ovo je podnaslov\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Daj prednost meta-podatcima prepoznatih stavki\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Podatci prepoznatog naslova će nadjačati postojeće informacije pri korištenju funkcije „Brzo prepoznavanje”. Zadana funkcionalnost je da „Brzo prepoznavanje” samo doda nedostajuće podatke.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Preskoči prepoznavanje knjiga koje već imaju ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Preskoči prepoznavanje knjiga koje već imaju ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Zanemari prefikse kod sortiranja\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"npr. za prefiks \\\"the\\\" naslov knjige \\\"The Book Title\\\" sortirat će se \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Koristi pravokutne naslovnice knjiga\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Koristi pravokutne naslovnice umjesto uobičajenih naslovnica omjera 1,6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Spremi naslovnice sa stavkom\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Naslovnice se obično spremaju u /metadata/items. Uključivanjem ove opcije naslovnice će se spremati u mapu stavki vaše knjižnice. Čuva se samo jedna datoteka s imenom \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Spremi metapodatke uz stavku\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Meta-podatci se obično spremaju u /metadata/items; ako uključite ovu postavku meta-podatci će se čuvati u mapama knjižničkih stavki\",\n  \"LabelSettingsTimeFormat\": \"Format vremena\",\n  \"LabelShare\": \"Podijeli\",\n  \"LabelShareDownloadableHelp\": \"Korisnicima s poveznicom za dijeljenje omogućuje preuzimanje stavke.\",\n  \"LabelShareOpen\": \"Dijeljenje otvoreno\",\n  \"LabelShareURL\": \"URL za dijeljenje\",\n  \"LabelShowAll\": \"Prikaži sve\",\n  \"LabelShowSeconds\": \"Prikaži sekunde\",\n  \"LabelShowSubtitles\": \"Prikaži podnaslove\",\n  \"LabelSize\": \"Veličina\",\n  \"LabelSleepTimer\": \"Timer za spavanje\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Uzlazno\",\n  \"LabelSortDescending\": \"Silazno\",\n  \"LabelSortPubDate\": \"Sortiranje po datumu objave\",\n  \"LabelStart\": \"Početak\",\n  \"LabelStartTime\": \"Vrijeme početka\",\n  \"LabelStarted\": \"Započeto\",\n  \"LabelStartedAt\": \"Započeto\",\n  \"LabelStartedDate\": \"Započeto {0}\",\n  \"LabelStatsAudioTracks\": \"Zvučni zapisi\",\n  \"LabelStatsAuthors\": \"Autori\",\n  \"LabelStatsBestDay\": \"Najbolji dan\",\n  \"LabelStatsDailyAverage\": \"Dnevni prosjek\",\n  \"LabelStatsDays\": \"Dani\",\n  \"LabelStatsDaysListened\": \"Dana slušano\",\n  \"LabelStatsHours\": \"Sati\",\n  \"LabelStatsInARow\": \"uzastopno\",\n  \"LabelStatsItemsFinished\": \"Završene stavke\",\n  \"LabelStatsItemsInLibrary\": \"Stavke u knjižnici\",\n  \"LabelStatsMinutes\": \"minute\",\n  \"LabelStatsMinutesListening\": \"Minuta odslušano\",\n  \"LabelStatsOverallDays\": \"Ukupno dana\",\n  \"LabelStatsOverallHours\": \"Ukupno sati\",\n  \"LabelStatsWeekListening\": \"Tjedno slušanje\",\n  \"LabelSubtitle\": \"Podnaslov\",\n  \"LabelSupportedFileTypes\": \"Podržane vrste datoteka\",\n  \"LabelTag\": \"Oznaka\",\n  \"LabelTags\": \"Oznake\",\n  \"LabelTagsAccessibleToUser\": \"Oznake dostupne korisniku\",\n  \"LabelTagsNotAccessibleToUser\": \"Oznake nedostupne korisniku\",\n  \"LabelTasks\": \"Zadatci koji se izvode\",\n  \"LabelTextEditorBulletedList\": \"Popis s grafičkim oznakama\",\n  \"LabelTextEditorLink\": \"Poveznica\",\n  \"LabelTextEditorNumberedList\": \"Numerirani popis\",\n  \"LabelTextEditorUnlink\": \"Prekini vezu\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Tamna\",\n  \"LabelThemeLight\": \"Svijetla\",\n  \"LabelThemeSepia\": \"Sepija\",\n  \"LabelTimeBase\": \"Baza vremena\",\n  \"LabelTimeDurationXHours\": \"{0} sati\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuta\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekundi\",\n  \"LabelTimeInMinutes\": \"Vrijeme u minutama\",\n  \"LabelTimeLeft\": \"preostalo {0}\",\n  \"LabelTimeListened\": \"Vremena odslušano\",\n  \"LabelTimeListenedToday\": \"Vremena odslušano danas\",\n  \"LabelTimeRemaining\": \"preostalo {0}\",\n  \"LabelTimeToShift\": \"Vrijeme za pomjeriti u sekundama\",\n  \"LabelTitle\": \"Naslov\",\n  \"LabelToolsEmbedMetadata\": \"Ugradi meta-podatke\",\n  \"LabelToolsEmbedMetadataDescription\": \"Ugradi meta-podatke u zvučne datoteke zajedno s naslovnicom i poglavljima.\",\n  \"LabelToolsM4bEncoder\": \"M4B kodiranje\",\n  \"LabelToolsMakeM4b\": \"Stvori M4B datoteku audioknjige\",\n  \"LabelToolsMakeM4bDescription\": \"Izrađuje zvučnu knjigu u .M4B formatu s ugrađenim meta-podatcima, naslovnicom i poglavljima.\",\n  \"LabelToolsSplitM4b\": \"Podijeli M4B datoteke u MP3 datoteke\",\n  \"LabelToolsSplitM4bDescription\": \"Stvara MP3 datoteke dijeljenjem M4B datoteke po poglavljima, s ugrađenim meta-podatcima, slikom naslovnice i poglavljima.\",\n  \"LabelTotalDuration\": \"Ukupno trajanje\",\n  \"LabelTotalTimeListened\": \"Sveukupno vrijeme slušanja\",\n  \"LabelTrackFromFilename\": \"Naslov iz imena datoteke\",\n  \"LabelTrackFromMetadata\": \"Naslov iz meta-podataka\",\n  \"LabelTracks\": \"Zvučni zapisi\",\n  \"LabelTracksMultiTrack\": \"Više zvučnih zapisa\",\n  \"LabelTracksNone\": \"Nema zapisa\",\n  \"LabelTracksSingleTrack\": \"Jedan zvučni zapis\",\n  \"LabelTrailer\": \"Najava\",\n  \"LabelType\": \"Vrsta\",\n  \"LabelUnabridged\": \"Neskraćeno\",\n  \"LabelUndo\": \"Vrati\",\n  \"LabelUnknown\": \"Nepoznato\",\n  \"LabelUnknownPublishDate\": \"Nepoznat datum objavljivanja\",\n  \"LabelUpdateCover\": \"Ažuriraj naslovnicu\",\n  \"LabelUpdateCoverHelp\": \"Dozvoli prepisivanje postojećih naslovnica za odabrane knjige kada se prepoznaju\",\n  \"LabelUpdateDetails\": \"Ažuriraj pojedinosti\",\n  \"LabelUpdateDetailsHelp\": \"Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju\",\n  \"LabelUpdatedAt\": \"Ažurirano\",\n  \"LabelUploaderDragAndDrop\": \"Povuci i ispusti datoteke ili mape\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Povuci i ispusti datoteke\",\n  \"LabelUploaderDropFiles\": \"Ispusti datoteke\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automatski dohvati naslov, autora i serijal\",\n  \"LabelUseAdvancedOptions\": \"Koristi napredne opcije\",\n  \"LabelUseChapterTrack\": \"Koristi zvučni zapis poglavlja\",\n  \"LabelUseFullTrack\": \"Koristi cijeli zvučni zapis\",\n  \"LabelUseZeroForUnlimited\": \"0 za neograničeno\",\n  \"LabelUser\": \"Korisnik\",\n  \"LabelUsername\": \"Korisničko ime\",\n  \"LabelValue\": \"Vrijednost\",\n  \"LabelVersion\": \"Verzija\",\n  \"LabelViewBookmarks\": \"Pogledaj knjižne oznake\",\n  \"LabelViewChapters\": \"Pogledaj poglavlja\",\n  \"LabelViewPlayerSettings\": \"Pogledaj postavke reproduktora\",\n  \"LabelViewQueue\": \"Pogledaj redoslijed izvođenja reproduktora\",\n  \"LabelVolume\": \"Glasnoća\",\n  \"LabelWebRedirectURLsDescription\": \"Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Podmapa za URL-ove preusmjeravanja\",\n  \"LabelWeekdaysToRun\": \"Dani u tjednu za pokretanje\",\n  \"LabelXBooks\": \"{0} knjiga\",\n  \"LabelXItems\": \"{0} stavki\",\n  \"LabelYearReviewHide\": \"Ne prikazuj Godišnji pregled\",\n  \"LabelYearReviewShow\": \"Pogledaj Godišnji pregled\",\n  \"LabelYourAudiobookDuration\": \"Trajanje vaših zvučnih knjiga\",\n  \"LabelYourBookmarks\": \"Vaše knjižne oznake\",\n  \"LabelYourPlaylists\": \"Vaši popisi za izvođenje\",\n  \"LabelYourProgress\": \"Vaš napredak\",\n  \"MessageAddToPlayerQueue\": \"Dodaj u redoslijed izvođenja\",\n  \"MessageAppriseDescription\": \"Za korištnje ove funkcije, treba vam instanca <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API-ja</a> ili API koji može rukovati istom vrstom zahtjeva.<br />URL Apprise API-ja mora biti potpuna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi <code>http://192.168.1.1:8337</code> morate upisati <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Starije API tokene ćemo ukloniti. Umjesto njih, koristite se <a href=\\\"/config/api-keys\\\">API ključevima</a> .\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.\",\n  \"MessageAuthenticationSecurityMessage\": \"Provjera autentičnosti poboljšana je radi sigurnosti. Svi se korisnici moraju ponovno prijaviti.\",\n  \"MessageBackupsDescription\": \"Sigurnosne kopije sadrže korisnike, napredak korisnika, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u <code>/metadata/items</code> i <code>/metadata/authors</code>. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.\",\n  \"MessageBackupsLocationEditNote\": \"Napomena: Uređivanje lokacije za sigurnosne kopije ne premješta ili mijenja postojeće sigurnosne kopije\",\n  \"MessageBackupsLocationNoEditNote\": \"Napomena: Lokacija za sigurnosne kopije zadana je kroz varijablu okoline i ovdje se ne može izmijeniti.\",\n  \"MessageBackupsLocationPathEmpty\": \"Putanja do lokacije za sigurnosne kopije ne može ostati prazna\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Nadopunjuje omogućena polja podatcima iz svih stavki. Polja s višestrukim podatcima će se spojiti\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Popuni omogućena polja mapiranih pojedinosti s podatcima iz ove stavke\",\n  \"MessageBatchQuickMatchDescription\": \"Brzo prepoznavanje za odabrane će stavke pokušati dodati naslovnice i meta-podatke koji nedostaju. Uključite donje opcije ako želite da Brzo prepoznavanje prepiše postojeće naslovnice i/ili meta-podatke.\",\n  \"MessageBookshelfNoCollections\": \"Niste izradili niti jednu zbirku\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Zbirke su javne. Svi korisnici s pristupom knjižnici mogu ih vidjeti.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Nema otvorenih RSS izvora\",\n  \"MessageBookshelfNoResultsForFilter\": \"Nema rezultata za filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Vaš upit nema rezultata\",\n  \"MessageBookshelfNoSeries\": \"Nemate niti jedan serijal\",\n  \"MessageBulkChapterPattern\": \"Koliko poglavlja želite dodati s ovim obrascem numeracije?\",\n  \"MessageChapterEndIsAfter\": \"Kraj poglavlja je nakon kraja zvučne knjige\",\n  \"MessageChapterErrorFirstNotZero\": \"Prvo poglavlje mora započeti u 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Netočno vrijeme početka, mora biti manje od trajanja zvučne knjige\",\n  \"MessageChapterErrorStartLtPrev\": \"Netočno vrijeme početka, mora biti veće ili jednako vremenu početka prethodnog poglavlja\",\n  \"MessageChapterStartIsAfter\": \"Početak poglavlja je nakon kraja zvučne knjige\",\n  \"MessageChaptersNotFound\": \"Poglavlja nisu pronađena\",\n  \"MessageCheckingCron\": \"Provjeravanje cron izraza...\",\n  \"MessageConfirmCloseFeed\": \"Sigurno želite zatvoriti ovaj izvor?\",\n  \"MessageConfirmDeleteApiKey\": \"Sigurno želite izbrisati API ključ \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Sigurno želite izbrisati sigurnosnu kopiju za {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Sigurno želite izbrisati e-čitač \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?\",\n  \"MessageConfirmDeleteLibrary\": \"Sigurno želite trajno izbrisati knjižnicu \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Ovo će izbrisati knjižničku stavku iz baze podataka i s datotečnog sustava. Jeste li sigurni?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Ovo će izbrisati {0} knjižničkih stavki iz baze podataka i datotečnog sustava. Jeste li sigurni?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Sigurno želite izbrisati prilagođenog pružatelja meta-podataka \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Sigurno želite izbrisati ovu obavijest?\",\n  \"MessageConfirmDeleteSession\": \"Sigurno želite izbrisati ovu sesiju?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Sigurno želite ugraditi meta-podatke u {0} zvučnih datoteka?\",\n  \"MessageConfirmForceReScan\": \"Sigurno želite ponovno pokrenuti skeniranje?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Sigurno želite označiti sve nastavke kao završene?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Sigurno želite označiti sve nastavke kao nezavršene?\",\n  \"MessageConfirmMarkItemFinished\": \"Sigurno želite označiti stavku \\\"{0}\\\" kao završenu?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Sigurno želite označiti stavku \\\"{0}\\\" kao nezavršenu?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Sigurno želite označiti sve knjige u ovom serijalu kao završene?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Sigurno želite označiti sve knjige u ovom serijalu kao nezavršene?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Želite li okinuti ovu obavijest s probnim podatcima?\",\n  \"MessageConfirmPurgeCache\": \"Brisanje predmemorije izbrisat će cijelu mapu <code>/metadata/cache</code>. <br /><br />Sigurno želite izbrisati mapu predmemorije?\",\n  \"MessageConfirmPurgeItemsCache\": \"Brisanje predmemorije stavki izbrisat će cijelu mapu <code>/metadata/cache/items</code>.<br />Jeste li sigurni?\",\n  \"MessageConfirmQuickEmbed\": \"Pažnja! Funkcija brzog ugrađivanja ne stvara sigurnosne kopije vaših zvučnih datoteka. Provjerite imate li sigurnosnu kopiju. <br><br>Želite li nastaviti?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Brzo prepoznavanje nastavaka prepisat će pojedinosti ukoliko se pronađe podudaranje. Neprepoznati nastavci će se ažurirati. Jeste li sigurni?\",\n  \"MessageConfirmReScanLibraryItems\": \"Sigurno želite ponovno skenirati {0} stavki?\",\n  \"MessageConfirmRemoveAllChapters\": \"Sigurno želite ukloniti sva poglavlja?\",\n  \"MessageConfirmRemoveAuthor\": \"Sigurno želite ukloniti autora \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Sigurno želite obrisati kolekciju \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Sigurno želite ukloniti nastavak \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Napomena: Ova funkcija neće izbrisati zvučnu datoteku ukoliko ne uključite opciju \\\"Izbriši datoteku zauvijek\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Sigurno želite ukloniti {0} nastavaka?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Sigurno želite ukloniti {0} sesija slušanja?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Sigurno želite ukloniti sve datoteke metadata.{0} u mapama vaših knjižničkih stavki?\",\n  \"MessageConfirmRemoveNarrator\": \"Sigurno želite ukloniti pripovjedača \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Sigurno želite ukloniti vaš popis za izvođenje \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Sigurno želite preimenovati žanr \\\"{0}\\\" u \\\"{1}\\\" za sve stavke?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Napomena: Ovaj žanr već postoji, stoga će biti pripojen.\",\n  \"MessageConfirmRenameGenreWarning\": \"Pažnja! Sličan žanr s drugačijim velikim i malim slovima već postoji \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Sigurno želite preimenovati oznaku \\\"{0}\\\" u \\\"{1}\\\" za sve stavke?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Napomena: Ova oznaka već postoji, stoga će biti pripojena.\",\n  \"MessageConfirmRenameTagWarning\": \"Pažnja! Slična oznaka s drugačijim velikim i malim slovima već postoji \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Sigurno želite resetirati napredak?\",\n  \"MessageConfirmSendEbookToDevice\": \"Sigurno želite poslati {0} e-knjigu \\\"{1}\\\" na uređaj \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Sigurno želite odspojiti ovog korisnika s OpenID-ja?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dana slušanja u posljednjih godinu dana\",\n  \"MessageDownloadingEpisode\": \"Preuzimam nastavak\",\n  \"MessageDragFilesIntoTrackOrder\": \"Povlači datoteke u ispravan redoslijed\",\n  \"MessageEmbedFailed\": \"Ugrađivanje nije uspjelo!\",\n  \"MessageEmbedFinished\": \"Ugrađivanje je završeno!\",\n  \"MessageEmbedQueue\": \"Ugrađivanje meta-podataka dodano u red obrade ({0} u redu)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} nastavak(a) u redu za preuzimanje\",\n  \"MessageEreaderDevices\": \"Da biste osigurali isporuku e-knjiga, možda ćete morati gornju adresu e-pošte dodati kao dopuštenog pošiljatelja za svaki od donjih uređaja.\",\n  \"MessageFeedURLWillBe\": \"URL izvora bit će {0}\",\n  \"MessageFetching\": \"Dohvaćanje...\",\n  \"MessageForceReScanDescription\": \"će ponovno skenirati sve datoteke kao nove datoteke. ID3 tagovi zvučnih datoteka, OPF datoteke i tekstualne datoteke skenirat će se kao da su nove.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} sluša</strong> na {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Nema sesija slušanja na {0}\",\n  \"MessageImportantNotice\": \"Važna obavijest!\",\n  \"MessageInsertChapterBelow\": \"Unesi poglavlje ispod\",\n  \"MessageInvalidAsin\": \"Nevažeći ASIN\",\n  \"MessageItemsSelected\": \"{0} odabranih stavki\",\n  \"MessageItemsUpdated\": \"{0} stavki ažurirano\",\n  \"MessageJoinUsOn\": \"Pridruži nam se na\",\n  \"MessageLoading\": \"Učitavanje...\",\n  \"MessageLoadingFolders\": \"Učitavanje mape...\",\n  \"MessageLogsDescription\": \"Zapisnici se čuvaju u <code>/metadata/logs</code> u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B datoteka neuspjela!\",\n  \"MessageM4BFinished\": \"M4B datoteka je završena!\",\n  \"MessageMapChapterTitles\": \"Mapiraj imana poglavlja s vašim postojećim poglavljima zvučne knjige bez podešavanja vremenskih identifikatora\",\n  \"MessageMarkAllEpisodesFinished\": \"Označi sve nastavke kao završene\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Označi sve nastavke kao nezavršene\",\n  \"MessageMarkAsFinished\": \"Označi kao završeno\",\n  \"MessageMarkAsNotFinished\": \"Označi kao nezavršeno\",\n  \"MessageMatchBooksDescription\": \"će pokušati prepoznati knjige iz knjižnice u katalogu odabranog pružatelja podatka te nadopuniti podatke koji nedostaju i naslovnice. Ne prepisuje postojeće podatke.\",\n  \"MessageNoAudioTracks\": \"Nema zvučnih zapisa\",\n  \"MessageNoAuthors\": \"Nema autora\",\n  \"MessageNoBackups\": \"Nema sigurnosnih kopija\",\n  \"MessageNoBookmarks\": \"Nema knjižnih oznaka\",\n  \"MessageNoChapters\": \"Nema poglavlja\",\n  \"MessageNoCollections\": \"Nema zbirki\",\n  \"MessageNoCoversFound\": \"Nije pronađena nijedna naslovnica\",\n  \"MessageNoDescription\": \"Nema opisa\",\n  \"MessageNoDevices\": \"Nema uređaja\",\n  \"MessageNoDownloadsInProgress\": \"Trenutačno nema preuzimanja u tijeku\",\n  \"MessageNoDownloadsQueued\": \"Nema preuzimanja u redu\",\n  \"MessageNoEpisodeMatchesFound\": \"Nije pronađen ni jedan odgovarajući nastavak\",\n  \"MessageNoEpisodes\": \"Nema nastavaka\",\n  \"MessageNoFoldersAvailable\": \"Nema dostupnih mapa\",\n  \"MessageNoGenres\": \"Nema žanrova\",\n  \"MessageNoIssues\": \"Nema problema\",\n  \"MessageNoItems\": \"Nema stavki\",\n  \"MessageNoItemsFound\": \"Nema pronađenih stavki\",\n  \"MessageNoListeningSessions\": \"Nema sesija slušanja\",\n  \"MessageNoLogs\": \"Nema zapisnika\",\n  \"MessageNoMediaProgress\": \"Nema podataka o započetim medijima\",\n  \"MessageNoNotifications\": \"Nema obavijesti\",\n  \"MessageNoPodcastFeed\": \"Neispravan podcast: Nema izvora\",\n  \"MessageNoPodcastsFound\": \"Nije pronađen niti jedan podcast\",\n  \"MessageNoResults\": \"Nema rezultata\",\n  \"MessageNoSearchResultsFor\": \"Nema rezultata pretrage za \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Nema serijala\",\n  \"MessageNoTags\": \"Nema oznaka\",\n  \"MessageNoTasksRunning\": \"Nema zadataka koji se izvode\",\n  \"MessageNoUpdatesWereNecessary\": \"Ažuriranje nije bilo potrebno\",\n  \"MessageNoUserPlaylists\": \"Nemate popisa za izvođenje\",\n  \"MessageNoUserPlaylistsHelp\": \"Popisi za izvođenje su privatni. Može ih vidjeti samo korisnik koji ih je izradio.\",\n  \"MessageNotYetImplemented\": \"Još nije implementirano\",\n  \"MessageOpmlPreviewNote\": \"Napomena: Ovo je pretpregled raščlanjene OPML datoteke. Stvarni naslov podcasta preuzet će se iz RSS izvora.\",\n  \"MessageOr\": \"ili\",\n  \"MessagePauseChapter\": \"Pauziraj reprodukciju poglavlja\",\n  \"MessagePlayChapter\": \"Slušaj početak poglavlja\",\n  \"MessagePlaylistCreateFromCollection\": \"Stvori popis za izvođenje od zbirke\",\n  \"MessagePleaseWait\": \"Pričekajte...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast nema adresu RSS izvora za prepoznavanje\",\n  \"MessagePodcastSearchField\": \"Upišite izraz za pretraživanje ili URL RSS izvora\",\n  \"MessageQuickEmbedInProgress\": \"Brzo ugrađivanje u tijeku\",\n  \"MessageQuickEmbedQueue\": \"Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)\",\n  \"MessageQuickMatchAllEpisodes\": \"Brzo prepoznavanje svih nastavaka\",\n  \"MessageQuickMatchDescription\": \"Popuni pojedinosti i naslovnice koji nedostaju prvim pronađenim rezultatom za '{0}'. Ne prepisuje podatke osim ako je postavka poslužitelja „Daj prednost meta-podatcima prepoznatih stavki” uključena.\",\n  \"MessageRemoveChapter\": \"Ukloni poglavlje\",\n  \"MessageRemoveEpisodes\": \"Ukloni {0} nastavaka\",\n  \"MessageRemoveFromPlayerQueue\": \"Ukloni iz redoslijeda izvođenja\",\n  \"MessageRemoveUserWarning\": \"Sigurno želite trajno izbrisati korisnika \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Prijavite pogreške, zatražite funkcije i doprinesite na\",\n  \"MessageResetChaptersConfirm\": \"Sigurno želite vratiti poglavlja na prethodno stanje i poništiti učinjene promjene?\",\n  \"MessageRestoreBackupConfirm\": \"Sigurno želite vratiti sigurnosnu kopiju izrađenu\",\n  \"MessageRestoreBackupWarning\": \"Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.\",\n  \"MessageScheduleLibraryScanNote\": \"Za većinu korisnika se preporučuje ostaviti ovu funkciju deaktiviranom kao i fukciju „Automatski prati promjene u knjižnici” koja automatski otkriva promjene u mapama vaše knjižnice. Fukcija „Automatski prati promjene u knjižnici” ne radi na svakom datotečnom sustavu (kao što je NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Pokreni svaki {0} u {1}\",\n  \"MessageSearchResultsFor\": \"Rezultati pretrage za\",\n  \"MessageSelected\": \"{0} odabrano\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Slijed serijala ne može sadržavati praznine\",\n  \"MessageServerCouldNotBeReached\": \"Nije bilo moguće pristupiti poslužitelju\",\n  \"MessageSetChaptersFromTracksDescription\": \"Postavi poglavlja koristeći svaku zvučnu datoteku kao poglavlje, a naslov poglavlja kao ime datoteke\",\n  \"MessageShareExpirationWillBe\": \"Vrijeme isteka će biti <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Istječe za {0}\",\n  \"MessageShareURLWillBe\": \"URL za dijeljenje bit će <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Pokreni reprodukciju za \\\"{0}\\\" na {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"U zvučnu datoteku \\\"{0}\\\" nije moguće pisati\",\n  \"MessageTaskCanceledByUser\": \"Korisnik je otkazao izvršavanje zadatka\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Preuzimanje nastavka \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Ugrađivanje meta-podataka\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Ugrađivanje meta-podataka u zvučnu knjigu \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Kodiranje M4B datoteke\",\n  \"MessageTaskEncodingM4bDescription\": \"Kodiranje zvučne knjige \\\"{0}\\\" u jedinstvenu m4b datoteku\",\n  \"MessageTaskFailed\": \"Nije uspjelo\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Izrada sigurnosne kopije zvučne datoteke \\\"{0}\\\" nije uspjela\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Izrada mape predmemorije nije uspjela\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Ugradnja meta-podataka u datoteku \\\"{0}\\\" nije uspjela\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Spajanje zvučnih datoteka nije uspjelo\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Premještanje m4b datoteke nije uspjelo\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Pisanje datoteke s meta-podatcima nije uspjelo\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Prepoznavanje knjiga u knjižnici \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Nema datoteka za skeniranje\",\n  \"MessageTaskOpmlImport\": \"Uvoz OPML-a\",\n  \"MessageTaskOpmlImportDescription\": \"Stvaram podcaste od {0} RSS izvora\",\n  \"MessageTaskOpmlImportFeed\": \"Uvoz OPML izvora\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Uvoz RSS izvora \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Izvor podcasta nije dohvaćen\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Stvaranje podcasta \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast već postoji u putanji\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Stvaranje podcasta nije uspjelo\",\n  \"MessageTaskOpmlImportFinished\": \"Broj dodanih podcasta: {0}\",\n  \"MessageTaskOpmlParseFailed\": \"Raščlanjivanje OPML datoteke nije uspjelo\",\n  \"MessageTaskOpmlParseFastFail\": \"Neispravna OPML datoteka, oznaka <opml> nije pronađena ILI oznaka <outline> nije pronađena\",\n  \"MessageTaskOpmlParseNoneFound\": \"U OPML datoteci nisu pronađeni izvori\",\n  \"MessageTaskScanItemsAdded\": \"{0} dodan(o)\",\n  \"MessageTaskScanItemsMissing\": \"{0} nedostaje\",\n  \"MessageTaskScanItemsUpdated\": \"{0} ažurirano\",\n  \"MessageTaskScanNoChangesNeeded\": \"Nisu potrebne izmjene\",\n  \"MessageTaskScanningFileChanges\": \"Skeniranje izmijenjenih datoteka u \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Skeniranje knjižnice \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"U odredišnu mapu nije moguće pisati\",\n  \"MessageThinking\": \"Razmišljanje...\",\n  \"MessageUploaderItemFailed\": \"Prijenos nije uspio\",\n  \"MessageUploaderItemSuccess\": \"Uspješno preneseno!\",\n  \"MessageUploading\": \"Prijenos...\",\n  \"MessageValidCronExpression\": \"Ispravan cron izraz\",\n  \"MessageWatcherIsDisabledGlobally\": \"Praćenje datotečnog sustava globalno je isključen u postavkama poslužitelja\",\n  \"MessageXLibraryIsEmpty\": \"{0} Knjižnica je prazna!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Vaše trajanje zvučne knjige duže je od pronađenog trajanja\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Vaše trajanje zvučne knjige kraće je od pronađenog trajanja\",\n  \"NoteChangeRootPassword\": \"Samo root korisnik može imati praznu zaporku\",\n  \"NoteChapterEditorTimes\": \"Napomena: Vrijeme početka prvog poglavlja mora ostati 0:00, a vrijeme početka zadnjeg poglavlja ne može premašiti ukupno trajanje ove zvučne knjige.\",\n  \"NoteFolderPicker\": \"Napomena: mape koje su već mapirane neće se prikazati\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Pripazite: Većina aplikacija za podcaste iziskuje URL RSS izvora koji se koristi HTTPS protokolom\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Upozorenje: jedan ili više vaših nastavaka nemaju datum objavljivanja. To je obavezno kod nekih aplikacija za podcaste.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mape s medijskim datotekama smatrat će se zasebnim stavkama knjižnice.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Ako prenosite samo zvučne datoteke svaka će se zvučna datoteka uvesti kao zasebna zvučna knjiga.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nepodržane vrste datoteka se zanemaruju. Kada birate datoteke ili ispuštate mapu, sve datoteke koje nisu u mapi stavke će se zanemariti.\",\n  \"NotificationOnBackupCompletedDescription\": \"Pokreće se po završetku sigurnosnog kopiranja\",\n  \"NotificationOnBackupFailedDescription\": \"Pokreće se kada sigurnosno kopiranje ne uspije\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Pokreće se kada se nastavak podcasta automatski preuzme\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Pokreće se kada su automatska preuzimanja nastavaka onemogućena zbog previše neuspjelih pokušaja\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Pokreće se u slučaju pogreške pri pokušaju automatskog preuzimanja nastavka s RSS izvora\",\n  \"NotificationOnTestDescription\": \"Događaj za testiranje sustava obavijesti\",\n  \"PlaceholderBulkChapterInput\": \"Upišite naslov poglavlja ili ga numerirajte (npr. '1. nastavak', 'Poglavlje 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Ime nove zbirke\",\n  \"PlaceholderNewFolderPath\": \"Nova putanja mape\",\n  \"PlaceholderNewPlaylist\": \"Ime novog popisa za izvođenje\",\n  \"PlaceholderSearch\": \"Traži...\",\n  \"PlaceholderSearchEpisode\": \"Traži nastavak...\",\n  \"StatsAuthorsAdded\": \"autora dodano\",\n  \"StatsBooksAdded\": \"knjiga dodano\",\n  \"StatsBooksAdditional\": \"Novi naslovi uključuju…\",\n  \"StatsBooksFinished\": \"završene knjige\",\n  \"StatsBooksFinishedThisYear\": \"Neke završene knjige ove godine…\",\n  \"StatsBooksListenedTo\": \"knjiga slušano\",\n  \"StatsCollectionGrewTo\": \"Vaša je zbirka knjiga narasla na…\",\n  \"StatsSessions\": \"sesija\",\n  \"StatsSpentListening\": \"provedeno u slušanju\",\n  \"StatsTopAuthor\": \"NAJPOPULARNIJI AUTOR\",\n  \"StatsTopAuthors\": \"NAJPOPULARNIJI AUTORI\",\n  \"StatsTopGenre\": \"NAJPOPULARNIJI ŽANR\",\n  \"StatsTopGenres\": \"NAJPOPULARNIJI ŽANROVI\",\n  \"StatsTopMonth\": \"NAJJAČI MJESEC\",\n  \"StatsTopNarrator\": \"NAJPOPULARNIJI PRIPOVJEDAČ\",\n  \"StatsTopNarrators\": \"NAJPOPULARNIJI PRIPOVJEDAČI\",\n  \"StatsTotalDuration\": \"S ukupnim trajanjem od…\",\n  \"StatsYearInReview\": \"PREGLED GODINE\",\n  \"ToastAccountUpdateSuccess\": \"Račun ažuriran\",\n  \"ToastAppriseUrlRequired\": \"Obavezno upisati Apprise URL\",\n  \"ToastAsinRequired\": \"ASIN je obvezan\",\n  \"ToastAuthorImageRemoveSuccess\": \"Slika autora uklonjena\",\n  \"ToastAuthorNotFound\": \"Autor \\\"{0}\\\" nije pronađen\",\n  \"ToastAuthorRemoveSuccess\": \"Autor uklonjen\",\n  \"ToastAuthorSearchNotFound\": \"Autor nije pronađen\",\n  \"ToastAuthorUpdateMerged\": \"Autor pripojen\",\n  \"ToastAuthorUpdateSuccess\": \"Autor ažuriran\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor ažuriran (slika nije pronađena)\",\n  \"ToastBackupAppliedSuccess\": \"Sigurnosna kopija vraćena\",\n  \"ToastBackupCreateFailed\": \"Izrada sigurnosne kopije nije uspjela\",\n  \"ToastBackupCreateSuccess\": \"Izrađena sigurnosna kopija\",\n  \"ToastBackupDeleteFailed\": \"Brisanje sigurnosne kopije nije uspjelo\",\n  \"ToastBackupDeleteSuccess\": \"Sigurnosna kopija izbrisana\",\n  \"ToastBackupInvalidMaxKeep\": \"Neispravan broj sigurnosnih kopija za čuvanje\",\n  \"ToastBackupInvalidMaxSize\": \"Neispravna najveća veličina sigurnosne kopije\",\n  \"ToastBackupRestoreFailed\": \"Vraćanje sigurnosne kopije nije uspjelo\",\n  \"ToastBackupUploadFailed\": \"Prijenos sigurnosne kopije nije uspio\",\n  \"ToastBackupUploadSuccess\": \"Sigurnosna kopija je prenesea\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Pojedinosti primijenjene stavkama\",\n  \"ToastBatchDeleteFailed\": \"Grupno brisanje nije uspjelo\",\n  \"ToastBatchDeleteSuccess\": \"Grupno brisanje je uspjelo\",\n  \"ToastBatchQuickMatchFailed\": \"Grupno brzo prepoznavanje nije uspjelo!\",\n  \"ToastBatchQuickMatchStarted\": \"Započelo je brzo prepoznavanje {0} knjiga!\",\n  \"ToastBatchUpdateFailed\": \"Skupno ažuriranje nije uspjelo\",\n  \"ToastBatchUpdateSuccess\": \"Skupno ažuriranje uspješno dovršeno\",\n  \"ToastBookmarkCreateFailed\": \"Izrada knjižne oznake nije uspjela\",\n  \"ToastBookmarkCreateSuccess\": \"Knjižna oznaka dodana\",\n  \"ToastBookmarkRemoveSuccess\": \"Knjižna oznaka uklonjena\",\n  \"ToastBulkChapterInvalidCount\": \"Upišite broj od 1 i 150\",\n  \"ToastCachePurgeFailed\": \"Čišćenje predmemorije nije uspjelo\",\n  \"ToastCachePurgeSuccess\": \"Predmemorija uspješno očišćena\",\n  \"ToastChapterLocked\": \"Poglavlje je zaključano.\",\n  \"ToastChapterStartTimeAdjusted\": \"Vrijeme početka poglavlja prilagođeno za {0} sekundi\",\n  \"ToastChaptersAllLocked\": \"Sva su poglavlja zaključana. Otključajte neka poglavlja za pomicanje njihovog vremena.\",\n  \"ToastChaptersHaveErrors\": \"Poglavlja imaju pogreške\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Neispravna vrijednost pomaka. Početak zadnjeg poglavlja bio bi nakon duljine trajanja ove zvučne knjige.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Neispravna vrijednost pomaka. Trajanje prvog poglavlja bilo bi nula ili negativno i drugo poglavlje bi ga prepisalo. Povećajte vrijeme početka drugog poglavlja.\",\n  \"ToastChaptersMustHaveTitles\": \"Poglavlja moraju imati naslove\",\n  \"ToastChaptersRemoved\": \"Poglavlja uklonjena\",\n  \"ToastChaptersUpdated\": \"Poglavlja su ažurirana\",\n  \"ToastCollectionItemsAddFailed\": \"Neuspjelo dodavanje stavki u zbirku\",\n  \"ToastCollectionRemoveSuccess\": \"Zbirka izbrisana\",\n  \"ToastCollectionUpdateSuccess\": \"Zbirka ažurirana\",\n  \"ToastConnectionNotAvailable\": \"Veza nije dostupna. Pokušaj ponovo kasnije\",\n  \"ToastCoverSearchFailed\": \"Pretraga naslovnice neuspjela\",\n  \"ToastCoverUpdateFailed\": \"Ažuriranje naslovnice nije uspjelo\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Datum i vrijeme su neispravni ili nepotpuni\",\n  \"ToastDeleteFileFailed\": \"Brisanje datoteke nije uspjelo\",\n  \"ToastDeleteFileSuccess\": \"Datoteka izbrisana\",\n  \"ToastDeviceAddFailed\": \"Dodavanje uređaja nije uspjelo\",\n  \"ToastDeviceNameAlreadyExists\": \"E-čitač s tim imenom već postoji\",\n  \"ToastDeviceTestEmailFailed\": \"Slanje probne poruke e-pošte nije uspjelo\",\n  \"ToastDeviceTestEmailSuccess\": \"Probna poruka e-pošte poslana\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Postavke e-pošte ažurirane\",\n  \"ToastEncodeCancelFailed\": \"Prekidanje kodiranja nije uspjelo\",\n  \"ToastEncodeCancelSucces\": \"Kodiranje otkazano\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Brisanje redoslijeda izvođenja nije uspjelo\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Redoslijed preuzimanja nastavaka očišćen\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} nastavak/a ažurirano\",\n  \"ToastErrorCannotShare\": \"Dijeljenje na ovaj uređaj nije moguće\",\n  \"ToastFailedToCreate\": \"Izrada nije uspjela\",\n  \"ToastFailedToDelete\": \"Brisanje nije uspjelo\",\n  \"ToastFailedToLoadData\": \"Učitavanje podataka nije uspjelo\",\n  \"ToastFailedToMatch\": \"Nije prepoznato\",\n  \"ToastFailedToShare\": \"Dijeljenje nije uspjelo\",\n  \"ToastFailedToUpdate\": \"Ažuriranje nije uspjelo\",\n  \"ToastInvalidImageUrl\": \"Neispravan URL slike\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Neispravan unos maksimalnog broja nastavaka\",\n  \"ToastInvalidUrl\": \"Neispravan URL\",\n  \"ToastInvalidUrls\": \"Jedan ili više URL-ova nisu ispravni\",\n  \"ToastItemCoverUpdateSuccess\": \"Naslovnica stavke ažurirana\",\n  \"ToastItemDeletedFailed\": \"Brisanje stavke nije uspjelo\",\n  \"ToastItemDeletedSuccess\": \"Stavka je izbrisana\",\n  \"ToastItemDetailsUpdateSuccess\": \"Pojedinosti stavke su ažurirane\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Označavanje kao „Završeno” nije uspjelo\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Stavka je označena kao završena\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Označavanje kao „Nezavršeno” nije uspjelo\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Stavka je označena kao nezavršena\",\n  \"ToastItemUpdateSuccess\": \"Stavka ažurirana\",\n  \"ToastLibraryCreateFailed\": \"Stvaranje knjižnice nije uspjelo\",\n  \"ToastLibraryCreateSuccess\": \"Knjižnica \\\"{0}\\\" stvorena\",\n  \"ToastLibraryDeleteFailed\": \"Brisanje knjižnice nije uspjelo\",\n  \"ToastLibraryDeleteSuccess\": \"Knjižnica izbrisana\",\n  \"ToastLibraryScanFailedToStart\": \"Skeniranje nije uspjelo\",\n  \"ToastLibraryScanStarted\": \"Skeniranje knjižnice započelo\",\n  \"ToastLibraryUpdateSuccess\": \"Knjižnica \\\"{0}\\\" ažurirana\",\n  \"ToastMatchAllAuthorsFailed\": \"Prepoznavanje svih autora nije uspjelo\",\n  \"ToastMetadataFilesRemovedError\": \"Pogreška kod uklanjanja datoteka metadata.{0}\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"U knjižnici nisu pronađene datoteke metadata.{0}\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Nijedna datoteka metadata.{0} nije uklonjenja\",\n  \"ToastMetadataFilesRemovedSuccess\": \"Broj uklonjenih datoteka metadata.{1}: {0}\",\n  \"ToastMustHaveAtLeastOnePath\": \"Mora imati barem jednu putanju\",\n  \"ToastNameEmailRequired\": \"Ime i adresa e-pošte su obavezni\",\n  \"ToastNameRequired\": \"Ime je obavezno\",\n  \"ToastNewApiKeyUserError\": \"Morate odabrati korisnika\",\n  \"ToastNewEpisodesFound\": \"pronađeno {0} novih nastavaka\",\n  \"ToastNewUserCreatedFailed\": \"Stvaranje računa nije uspjelo: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Novi račun izrađen\",\n  \"ToastNewUserLibraryError\": \"Treba odabrati barem jednu knjižnicu\",\n  \"ToastNewUserPasswordError\": \"Mora imati zaporku, samo korisnik root može imati praznu zaporku\",\n  \"ToastNewUserTagError\": \"Potrebno je odabrati najmanje jednu oznaku\",\n  \"ToastNewUserUsernameError\": \"Upišite korisničko ime\",\n  \"ToastNoNewEpisodesFound\": \"Nisu pronađeni novi nastavci\",\n  \"ToastNoRSSFeed\": \"Podcast nema RSS izvor\",\n  \"ToastNoUpdatesNecessary\": \"Ažuriranja nisu potrebna\",\n  \"ToastNotificationCreateFailed\": \"Stvaranje obavijesti nije uspjelo\",\n  \"ToastNotificationDeleteFailed\": \"Brisanje obavijesti nije uspjelo\",\n  \"ToastNotificationFailedMaximum\": \"Najveći broj neuspjelih pokušaja mora biti >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Najveći broj obavijesti u redu mora biti >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Postavke obavijesti ažurirane\",\n  \"ToastNotificationTestTriggerFailed\": \"Okidanje probne obavijesti nije uspjelo\",\n  \"ToastNotificationTestTriggerSuccess\": \"Okinuta je probna obavijest\",\n  \"ToastNotificationUpdateSuccess\": \"Obavijest ažurirana\",\n  \"ToastPlaylistCreateFailed\": \"Izrada popisa za izvođenje nije uspjela\",\n  \"ToastPlaylistCreateSuccess\": \"Popis za izvođenje izrađen\",\n  \"ToastPlaylistRemoveSuccess\": \"Popis za izvođenje uklonjen\",\n  \"ToastPlaylistUpdateSuccess\": \"Popis za izvođenje ažuriran\",\n  \"ToastPodcastCreateFailed\": \"Stvaranje podcasta nije uspjelo\",\n  \"ToastPodcastCreateSuccess\": \"Podcast uspješno izrađen\",\n  \"ToastPodcastEpisodeUpdated\": \"Nastavak ažuriran\",\n  \"ToastPodcastGetFeedFailed\": \"Dohvaćanje izvora podcasta nije uspjelo\",\n  \"ToastPodcastNoEpisodesInFeed\": \"U RSS izvoru nisu pronađeni nastavci\",\n  \"ToastPodcastNoRssFeed\": \"Podcast nema RSS izvor\",\n  \"ToastProgressIsNotBeingSynced\": \"Napredak se ne sinkronizira, ponovno pokrenite reprodukciju\",\n  \"ToastProviderCreatedFailed\": \"Dodavanje pružatelja nije uspjelo\",\n  \"ToastProviderCreatedSuccess\": \"Novi pružatelj dodan\",\n  \"ToastProviderNameAndUrlRequired\": \"Ime i URL su obavezni\",\n  \"ToastProviderRemoveSuccess\": \"Pružatelj uklonjen\",\n  \"ToastRSSFeedCloseFailed\": \"Zatvaranje RSS izvora nije uspjelo\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS izvor zatvoren\",\n  \"ToastRemoveFailed\": \"Uklanjanje nije uspjelo\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Uklanjanje stavke iz zbirke nije uspjelo\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Stavka uklonjena iz zbirke\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Uklanjanje knjižničkih stavki s problemima nije uspjelo\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Uspješno uklonjene knjižničke stavke s problemima\",\n  \"ToastRenameFailed\": \"Preimenovanje nije uspjelo\",\n  \"ToastRescanFailed\": \"Ponovno skeniranje nije uspjelo za {0}\",\n  \"ToastRescanRemoved\": \"Ponovno skeniranje dovršene stavke je uklonjeno\",\n  \"ToastRescanUpToDate\": \"Ponovno skeniranje dovršene stavke bilo je ažurno\",\n  \"ToastRescanUpdated\": \"Ponovno skeniranje dovršene stavke je ažurirano\",\n  \"ToastScanFailed\": \"Skeniranje knjižničke stavke nije uspjelo\",\n  \"ToastSelectAtLeastOneUser\": \"Odaberite najmanje jednog korisnika\",\n  \"ToastSendEbookToDeviceFailed\": \"Slanje e-knjige na uređaj nije uspjelo\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-knjiga poslana uređaju \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Nije moguće dodati dva istoimena serijala\",\n  \"ToastSeriesUpdateFailed\": \"Ažuriranje serijala nije uspjelo\",\n  \"ToastSeriesUpdateSuccess\": \"Serijal uspješno ažuriran\",\n  \"ToastServerSettingsUpdateSuccess\": \"Postavke poslužitelja ažurirane\",\n  \"ToastSessionCloseFailed\": \"Zatvaranje sesije nije uspjelo\",\n  \"ToastSessionDeleteFailed\": \"Brisanje sesije nije uspjelo\",\n  \"ToastSessionDeleteSuccess\": \"Sesija izbrisana\",\n  \"ToastSleepTimerDone\": \"Timer za spavanje gotov... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug sadrži nedozvoljene znakove\",\n  \"ToastSlugRequired\": \"Slug je obavezan\",\n  \"ToastSocketConnected\": \"Socket priključen\",\n  \"ToastSocketDisconnected\": \"Veza sa socketom je prekinuta\",\n  \"ToastSocketFailedToConnect\": \"Priključivanje na socket nije uspjelo\",\n  \"ToastSortingPrefixesEmptyError\": \"Mora imati najmanje jedan prefiks za sortiranje\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Prefiksi za sortiranje ažurirani ({0} stavki)\",\n  \"ToastTitleRequired\": \"Naslov je obavezan\",\n  \"ToastUnknownError\": \"Nepoznata pogreška\",\n  \"ToastUnlinkOpenIdFailed\": \"Uklanjanje OpenID veze korisnika nije uspjelo\",\n  \"ToastUnlinkOpenIdSuccess\": \"Korisnik odspojen od OpenID-ja\",\n  \"ToastUploaderFilepathExistsError\": \"Datotečna putanja \\\"{0}\\\" već postoji na poslužitelju\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Stavka \\\"{0}\\\" koristi podmapu putanje za prijenos.\",\n  \"ToastUserDeleteFailed\": \"Brisanje korisnika nije uspjelo\",\n  \"ToastUserDeleteSuccess\": \"Korisnik izbrisan\",\n  \"ToastUserPasswordChangeSuccess\": \"Zaporka je uspješno promijenjena\",\n  \"ToastUserPasswordMismatch\": \"Zaporke se ne podudaraju\",\n  \"ToastUserPasswordMustChange\": \"Nova zaporka ne smije biti jednaka staroj\",\n  \"ToastUserRootRequireName\": \"Obavezan je unos korisničkog imena root korisnika\",\n  \"TooltipAddChapters\": \"Dodavanje poglavlja\",\n  \"TooltipAddOneSecond\": \"Dodaj 1 sekundu\",\n  \"TooltipAdjustChapterStart\": \"Kliknite za uređivanje početnog vremena\",\n  \"TooltipLockAllChapters\": \"Zaključaj sva poglavlja\",\n  \"TooltipLockChapter\": \"Zaključaj poglavlje (Shift + klik za raspon)\",\n  \"TooltipSubtractOneSecond\": \"Oduzmi 1 sekundu\",\n  \"TooltipUnlockAllChapters\": \"Otključaj sva poglavlja\",\n  \"TooltipUnlockChapter\": \"Otključaj poglavlje (Shift+klik za raspon)\"\n}\n"
  },
  {
    "path": "client/strings/hu.json",
    "content": "{\n  \"ButtonAdd\": \"Hozzáadás\",\n  \"ButtonAddApiKey\": \"API kulcs hozzáadása\",\n  \"ButtonAddChapters\": \"Fejezetek hozzáadása\",\n  \"ButtonAddDevice\": \"Eszköz hozzáadása\",\n  \"ButtonAddLibrary\": \"Könyvtár hozzáadása\",\n  \"ButtonAddPodcasts\": \"Podcastok hozzáadása\",\n  \"ButtonAddUser\": \"Felhasználó hozzáadása\",\n  \"ButtonAddYourFirstLibrary\": \"Az első könyvtár hozzáadása\",\n  \"ButtonApply\": \"Alkalmaz\",\n  \"ButtonApplyChapters\": \"Fejezetek alkalmazása\",\n  \"ButtonAuthors\": \"Szerzők\",\n  \"ButtonBack\": \"Vissza\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Létezőből feltöltés\",\n  \"ButtonBatchEditPopulateMapDetails\": \"A térkép részleteinek feltöltése\",\n  \"ButtonBrowseForFolder\": \"Mappa keresése\",\n  \"ButtonCancel\": \"Mégse\",\n  \"ButtonCancelEncode\": \"Kódolás megszakítása\",\n  \"ButtonChangeRootPassword\": \"Gyökérjelszó megváltoztatása\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Új epizódok ellenőrzése és letöltése\",\n  \"ButtonChooseAFolder\": \"Válassz egy mappát\",\n  \"ButtonChooseFiles\": \"Fájlok kiválasztása\",\n  \"ButtonClearFilter\": \"Szűrő törlése\",\n  \"ButtonClose\": \"Bezár\",\n  \"ButtonCloseFeed\": \"Hírcsatorna bezárása\",\n  \"ButtonCloseSession\": \"Nyitott munkamenet bezárása\",\n  \"ButtonCollections\": \"Gyűjtemények\",\n  \"ButtonConfigureScanner\": \"Szkenner konfigurálása\",\n  \"ButtonCreate\": \"Létrehozás\",\n  \"ButtonCreateBackup\": \"Biztonsági másolat készítése\",\n  \"ButtonDelete\": \"Törlés\",\n  \"ButtonDownloadQueue\": \"Sor\",\n  \"ButtonEdit\": \"Szerkesztés\",\n  \"ButtonEditChapters\": \"Fejezetek szerkesztése\",\n  \"ButtonEditPodcast\": \"Podcast szerkesztése\",\n  \"ButtonEnable\": \"Engedélyezés\",\n  \"ButtonFireAndFail\": \"Küldés és összeomlás\",\n  \"ButtonFireOnTest\": \"onTest esemény küldése\",\n  \"ButtonForceReScan\": \"Újraszkennelés kényszerítése\",\n  \"ButtonFullPath\": \"Teljes útvonal\",\n  \"ButtonHide\": \"Elrejtés\",\n  \"ButtonHome\": \"Kezdőlap\",\n  \"ButtonIssues\": \"Problémák\",\n  \"ButtonJumpBackward\": \"Ugrás vissza\",\n  \"ButtonJumpForward\": \"Ugrás előre\",\n  \"ButtonLatest\": \"Legújabb\",\n  \"ButtonLibrary\": \"Könyvtár\",\n  \"ButtonLogout\": \"Kijelentkezés\",\n  \"ButtonLookup\": \"Keresés\",\n  \"ButtonManageTracks\": \"Sávok kezelése\",\n  \"ButtonMapChapterTitles\": \"Fejezetcímek hozzárendelése\",\n  \"ButtonMatchAllAuthors\": \"Minden szerző egyeztetése\",\n  \"ButtonMatchBooks\": \"Könyvek egyeztetése\",\n  \"ButtonNevermind\": \"Mindegy\",\n  \"ButtonNext\": \"Következő\",\n  \"ButtonNextChapter\": \"Következő fejezet\",\n  \"ButtonNextItemInQueue\": \"Következő elem a sorban\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Hírcsatorna megnyitása\",\n  \"ButtonOpenManager\": \"Kezelő megnyitása\",\n  \"ButtonPause\": \"Szünet\",\n  \"ButtonPlay\": \"Lejátszás\",\n  \"ButtonPlayAll\": \"Összes lejátszása\",\n  \"ButtonPlaying\": \"Lejátszás folyamatban\",\n  \"ButtonPlaylists\": \"Lejátszási listák\",\n  \"ButtonPrevious\": \"Előző\",\n  \"ButtonPreviousChapter\": \"Előző fejezet\",\n  \"ButtonProbeAudioFile\": \"Hangfájl vizsgálata\",\n  \"ButtonPurgeAllCache\": \"Összes gyorsítótár törlése\",\n  \"ButtonPurgeItemsCache\": \"Elemek gyorsítótárának törlése\",\n  \"ButtonQueueAddItem\": \"Hozzáadás a sorhoz\",\n  \"ButtonQueueRemoveItem\": \"Eltávolítás a sorból\",\n  \"ButtonQuickEmbed\": \"Gyors beágyazás\",\n  \"ButtonQuickEmbedMetadata\": \"Metaadat gyors beágyazása\",\n  \"ButtonQuickMatch\": \"Gyors egyeztetés\",\n  \"ButtonReScan\": \"Újraszkennelés\",\n  \"ButtonRead\": \"Olvasás\",\n  \"ButtonReadLess\": \"Kevesebb mutatása\",\n  \"ButtonReadMore\": \"Mutass többet\",\n  \"ButtonRefresh\": \"Frissítés\",\n  \"ButtonRemove\": \"Eltávolítás\",\n  \"ButtonRemoveAll\": \"Összes eltávolítása\",\n  \"ButtonRemoveAllLibraryItems\": \"Összes könyvtárelem eltávolítása\",\n  \"ButtonRemoveFromContinueListening\": \"Eltávolítás a Folytatás hallgatásából\",\n  \"ButtonRemoveFromContinueReading\": \"Eltávolítás a Folytatás olvasásából\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Sorozat eltávolítása a Folytatás sorozatokból\",\n  \"ButtonReset\": \"Visszaállítás\",\n  \"ButtonResetToDefault\": \"Alapértelmezésre állítás\",\n  \"ButtonRestore\": \"Visszaállítás\",\n  \"ButtonSave\": \"Mentés\",\n  \"ButtonSaveAndClose\": \"Mentés és bezárás\",\n  \"ButtonSaveTracklist\": \"Sávlista mentése\",\n  \"ButtonScan\": \"Szkennelés\",\n  \"ButtonScanLibrary\": \"Könyvtár szkennelése\",\n  \"ButtonScrollLeft\": \"Balra görgetés\",\n  \"ButtonScrollRight\": \"Jobbra görgetés\",\n  \"ButtonSearch\": \"Keresés\",\n  \"ButtonSelectFolderPath\": \"Mappa útvonalának kiválasztása\",\n  \"ButtonSeries\": \"Sorozatok\",\n  \"ButtonSetChaptersFromTracks\": \"Fejezetek beállítása sávokból\",\n  \"ButtonShare\": \"Megosztás\",\n  \"ButtonShiftTimes\": \"Idők eltolása\",\n  \"ButtonShow\": \"Megjelenítés\",\n  \"ButtonStartM4BEncode\": \"M4B kódolás indítása\",\n  \"ButtonStartMetadataEmbed\": \"Metaadatok beágyazásának indítása\",\n  \"ButtonStats\": \"Statisztikák\",\n  \"ButtonSubmit\": \"Küldés\",\n  \"ButtonTest\": \"Teszt\",\n  \"ButtonUnlinkOpenId\": \"OpenID szétkapcsolása\",\n  \"ButtonUpload\": \"Feltöltés\",\n  \"ButtonUploadBackup\": \"Biztonsági másolat feltöltése\",\n  \"ButtonUploadCover\": \"Borító feltöltése\",\n  \"ButtonUploadOPMLFile\": \"OPML fájl feltöltése\",\n  \"ButtonUserDelete\": \"Felhasználó törlése {0}\",\n  \"ButtonUserEdit\": \"Felhasználó szerkesztése {0}\",\n  \"ButtonViewAll\": \"Összes megtekintése\",\n  \"ButtonYes\": \"Igen\",\n  \"ErrorUploadFetchMetadataAPI\": \"Hiba a metaadatok lekérésekor\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Nem sikerült a metaadatok lekérése - próbálja meg frissíteni a címet és/vagy a szerzőt\",\n  \"ErrorUploadLacksTitle\": \"Cím szükséges\",\n  \"HeaderAccount\": \"Fiók\",\n  \"HeaderAddCustomMetadataProvider\": \"Egyedi metaadat szolgáltató hozzáadása\",\n  \"HeaderAdvanced\": \"Haladó\",\n  \"HeaderApiKeys\": \"API kulcsok\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise értesítési beállítások\",\n  \"HeaderAudioTracks\": \"Audiósávok\",\n  \"HeaderAudiobookTools\": \"Hangoskönyv fájlkezelő eszközök\",\n  \"HeaderAuthentication\": \"Hitelesítés\",\n  \"HeaderBackups\": \"Biztonsági másolatok\",\n  \"HeaderBulkChapterModal\": \"Több fejezet hozzáadása\",\n  \"HeaderChangePassword\": \"Jelszó megváltoztatása\",\n  \"HeaderChapters\": \"Fejezetek\",\n  \"HeaderChooseAFolder\": \"Válasszon egy mappát\",\n  \"HeaderCollection\": \"Gyűjtemény\",\n  \"HeaderCollectionItems\": \"Gyűjtemény elemek\",\n  \"HeaderCover\": \"Borító\",\n  \"HeaderCurrentDownloads\": \"Jelenlegi letöltések\",\n  \"HeaderCustomMessageOnLogin\": \"Egyedi üzenet bejelentkezéskor\",\n  \"HeaderCustomMetadataProviders\": \"Egyéni metaadat-szolgáltatók\",\n  \"HeaderDetails\": \"Részletek\",\n  \"HeaderDownloadQueue\": \"Letöltési sor\",\n  \"HeaderEbookFiles\": \"E-könyv fájlok\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"E-mail beállítások\",\n  \"HeaderEpisodes\": \"Epizódok\",\n  \"HeaderEreaderDevices\": \"E-olvasó eszközök\",\n  \"HeaderEreaderSettings\": \"E-olvasó beállítások\",\n  \"HeaderFiles\": \"Fájlok\",\n  \"HeaderFindChapters\": \"Fejezetek keresése\",\n  \"HeaderIgnoredFiles\": \"Figyelmen kívül hagyott fájlok\",\n  \"HeaderItemFiles\": \"Elemfájlok\",\n  \"HeaderItemMetadataUtils\": \"Metaadatok eszközei\",\n  \"HeaderLastListeningSession\": \"Utolsó hallgatási munkamenet\",\n  \"HeaderLatestEpisodes\": \"Legújabb epizódok\",\n  \"HeaderLibraries\": \"Könyvtárak\",\n  \"HeaderLibraryFiles\": \"Könyvtárfájlok\",\n  \"HeaderLibraryStats\": \"Könyvtár statisztikák\",\n  \"HeaderListeningSessions\": \"Hallgatási munkamenetek\",\n  \"HeaderListeningStats\": \"Hallgatási statisztikák\",\n  \"HeaderLogin\": \"Bejelentkezés\",\n  \"HeaderLogs\": \"Naplók\",\n  \"HeaderManageGenres\": \"Műfajok kezelése\",\n  \"HeaderManageTags\": \"Címkék kezelése\",\n  \"HeaderMapDetails\": \"Részletek hozzárendelése\",\n  \"HeaderMatch\": \"Egyeztetés\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metaadatok előnyben részesítési sorrendje\",\n  \"HeaderMetadataToEmbed\": \"Beágyazandó metaadatok\",\n  \"HeaderNewAccount\": \"Új fiók\",\n  \"HeaderNewApiKey\": \"Új API kulcs\",\n  \"HeaderNewLibrary\": \"Új könyvtár\",\n  \"HeaderNotificationCreate\": \"Értesítés készítése\",\n  \"HeaderNotificationUpdate\": \"Értesítés frissítése\",\n  \"HeaderNotifications\": \"Értesítések\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect hitelesítés\",\n  \"HeaderOpenListeningSessions\": \"Hallgatási menetek megnyitása\",\n  \"HeaderOpenRSSFeed\": \"RSS hírcsatorna megnyitása\",\n  \"HeaderOtherFiles\": \"Egyéb fájlok\",\n  \"HeaderPasswordAuthentication\": \"Jelszó hitelesítés\",\n  \"HeaderPermissions\": \"Engedélyek\",\n  \"HeaderPlayerQueue\": \"Lejátszó sor\",\n  \"HeaderPlayerSettings\": \"Lejátszó beállításai\",\n  \"HeaderPlaylist\": \"Lejátszási lista\",\n  \"HeaderPlaylistItems\": \"Lejátszási lista elemek\",\n  \"HeaderPodcastsToAdd\": \"Hozzáadandó podcastok\",\n  \"HeaderPresets\": \"Alapbeállítások\",\n  \"HeaderPreviewCover\": \"Borító előnézete\",\n  \"HeaderRSSFeedGeneral\": \"RSS részletek\",\n  \"HeaderRSSFeedIsOpen\": \"RSS hírcsatorna nyitva van\",\n  \"HeaderRSSFeeds\": \"RSS hírcsatornák\",\n  \"HeaderRemoveEpisode\": \"Epizód eltávolítása\",\n  \"HeaderRemoveEpisodes\": \"{0} epizód eltávolítása\",\n  \"HeaderSavedMediaProgress\": \"Mentett médialejátszási állapot\",\n  \"HeaderSchedule\": \"Ütemezés\",\n  \"HeaderScheduleEpisodeDownloads\": \"Automatikus epizódletöltés ütemezése\",\n  \"HeaderScheduleLibraryScans\": \"Könyvtárak automatikus szkennelésének ütemezése\",\n  \"HeaderSession\": \"Munkamenet\",\n  \"HeaderSetBackupSchedule\": \"Biztonsági másolatok ütemezésének beállítása\",\n  \"HeaderSettings\": \"Beállítások\",\n  \"HeaderSettingsDisplay\": \"Kijelző\",\n  \"HeaderSettingsExperimental\": \"Kísérleti funkciók\",\n  \"HeaderSettingsGeneral\": \"Általános\",\n  \"HeaderSettingsScanner\": \"Szkenner\",\n  \"HeaderSettingsSecurity\": \"Biztonság\",\n  \"HeaderSettingsWebClient\": \"Webkliens\",\n  \"HeaderSleepTimer\": \"Alvásidőzítő\",\n  \"HeaderStatsLargestItems\": \"Legnagyobb elemek\",\n  \"HeaderStatsLongestItems\": \"Leghosszabb elemek (órában)\",\n  \"HeaderStatsMinutesListeningChart\": \"Hallgatási grafikon percben (az elmúlt 7 napból)\",\n  \"HeaderStatsRecentSessions\": \"Legutóbbi munkamenetek\",\n  \"HeaderStatsTop10Authors\": \"Top 10 szerző\",\n  \"HeaderStatsTop5Genres\": \"Top 5 műfaj\",\n  \"HeaderTableOfContents\": \"Tartalomjegyzék\",\n  \"HeaderTools\": \"Eszközök\",\n  \"HeaderUpdateAccount\": \"Fiók frissítése\",\n  \"HeaderUpdateApiKey\": \"API kulcs frissítése\",\n  \"HeaderUpdateAuthor\": \"Szerző frissítése\",\n  \"HeaderUpdateDetails\": \"Részletek frissítése\",\n  \"HeaderUpdateLibrary\": \"Könyvtár frissítése\",\n  \"HeaderUsers\": \"Felhasználók\",\n  \"HeaderYearReview\": \"Visszatekintés {0} -ra/re\",\n  \"HeaderYourStats\": \"Saját statisztikák\",\n  \"LabelAbridged\": \"Tömörített\",\n  \"LabelAbridgedChecked\": \"Rövidített (ellenőrizve)\",\n  \"LabelAbridgedUnchecked\": \"Teljes (nem ellenőrzött)\",\n  \"LabelAccessibleBy\": \"Hozzáférhető\",\n  \"LabelAccountType\": \"Fióktípus\",\n  \"LabelAccountTypeAdmin\": \"Adminisztrátor\",\n  \"LabelAccountTypeGuest\": \"Vendég\",\n  \"LabelAccountTypeUser\": \"Felhasználó\",\n  \"LabelActivities\": \"Tevékenységek\",\n  \"LabelActivity\": \"Tevékenység\",\n  \"LabelAddToCollection\": \"Hozzáadás a gyűjteményhez\",\n  \"LabelAddToCollectionBatch\": \"{0} könyv hozzáadása a gyűjteményhez\",\n  \"LabelAddToPlaylist\": \"Hozzáadás a lejátszási listához\",\n  \"LabelAddToPlaylistBatch\": \"{0} elem hozzáadása a lejátszási listához\",\n  \"LabelAddedAt\": \"Hozzáadás ideje\",\n  \"LabelAddedDate\": \"{0} Hozzáadva\",\n  \"LabelAdminUsersOnly\": \"Csak admin felhasználók\",\n  \"LabelAll\": \"Összes\",\n  \"LabelAllEpisodesDownloaded\": \"Minden epizód letöltve\",\n  \"LabelAllUsers\": \"Minden felhasználó\",\n  \"LabelAllUsersExcludingGuests\": \"Minden felhasználó, vendégek kivételével\",\n  \"LabelAllUsersIncludingGuests\": \"Minden felhasználó, beleértve a vendégeket is\",\n  \"LabelAlreadyInYourLibrary\": \"Már a könyvtárában van\",\n  \"LabelApiKeyCreated\": \"\\\"{0}\\\" API kulcs sikeresen létrehozva.\",\n  \"LabelApiKeyCreatedDescription\": \"Feltétlenül másolja le az API kulcsot, mert később már nem fogja látni.\",\n  \"LabelApiKeyUser\": \"Felhasználó nevében eljárva\",\n  \"LabelApiKeyUserDescription\": \"Ez az API-kulcs ugyanazokkal a jogosultságokkal rendelkezik, mint az a felhasználó, akinek a nevében működik. A naplófájlokban ez úgy jelenik meg, mintha a felhasználó maga küldte volna a kérést.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Hozzáfűzés\",\n  \"LabelAudioBitrate\": \"Audió bitráta (pl.128k)\",\n  \"LabelAudioChannels\": \"Audió csatorna (1 vagy 2)\",\n  \"LabelAudioCodec\": \"Audio Codec\",\n  \"LabelAuthor\": \"Szerző\",\n  \"LabelAuthorFirstLast\": \"Szerző (Keresztnév Vezetéknév)\",\n  \"LabelAuthorLastFirst\": \"Szerző (Vezetéknév, Keresztnév)\",\n  \"LabelAuthors\": \"Szerző\",\n  \"LabelAutoDownloadEpisodes\": \"Epizódok automatikus letöltése\",\n  \"LabelAutoFetchMetadata\": \"Metaadatok automatikus lekérése\",\n  \"LabelAutoFetchMetadataHelp\": \"Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.\",\n  \"LabelAutoLaunch\": \"Automatikus indítás\",\n  \"LabelAutoLaunchDescription\": \"Automatikus átirányítás az hitelesítő szolgáltatóhoz a bejelentkezési oldalra navigáláskor (kézi felülbírálás útvonala <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatikus regisztráció\",\n  \"LabelAutoRegisterDescription\": \"Új felhasználók automatikus létrehozása bejelentkezés után\",\n  \"LabelBackToUser\": \"Vissza a felhasználóhoz\",\n  \"LabelBackupAudioFiles\": \"Audiófájlok biztonsági mentése\",\n  \"LabelBackupLocation\": \"Biztonsági másolat helye\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatikus biztonsági másolatok\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Biztonsági másolatok mentése a /metadata/backups mappába\",\n  \"LabelBackupsMaxBackupSize\": \"Maximális biztonsági másolat méret (GB-ban) (0-tól végtelenig)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"A rossz konfiguráció elleni védelem érdekében a biztonsági másolatok meghiúsulnak, ha meghaladják a beállított méretet.\",\n  \"LabelBackupsNumberToKeep\": \"Megtartandó biztonsági másolatok száma\",\n  \"LabelBackupsNumberToKeepHelp\": \"Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.\",\n  \"LabelBitrate\": \"Bitráta\",\n  \"LabelBonus\": \"Bónusz\",\n  \"LabelBooks\": \"Könyvek\",\n  \"LabelButtonText\": \"Gomb szövege\",\n  \"LabelByAuthor\": \"{0} által\",\n  \"LabelChangePassword\": \"Jelszó megváltoztatása\",\n  \"LabelChannels\": \"Csatornák\",\n  \"LabelChapterCount\": \"{0} Fejezet\",\n  \"LabelChapterTitle\": \"Fejezet címe\",\n  \"LabelChapters\": \"Fejezetek\",\n  \"LabelChaptersFound\": \"fejezet található\",\n  \"LabelClickForMoreInfo\": \"További információkért kattintson\",\n  \"LabelClickToUseCurrentValue\": \"Kattintson az aktuális érték használatához\",\n  \"LabelClosePlayer\": \"Lejátszó bezárása\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Sorozatok összecsukása\",\n  \"LabelCollapseSubSeries\": \"Alszéria összecsukása\",\n  \"LabelCollection\": \"Gyűjtemény\",\n  \"LabelCollections\": \"Gyűjtemények\",\n  \"LabelComplete\": \"Kész\",\n  \"LabelConfirmPassword\": \"Jelszó megerősítése\",\n  \"LabelContinueListening\": \"Hallgatás folytatása\",\n  \"LabelContinueReading\": \"Olvasás folytatása\",\n  \"LabelContinueSeries\": \"Sorozat folytatása\",\n  \"LabelCorsAllowed\": \"Megengedett CORS Originek\",\n  \"LabelCover\": \"Borító\",\n  \"LabelCoverImageURL\": \"Borítókép URL\",\n  \"LabelCoverProvider\": \"Borító Szolgáltató\",\n  \"LabelCreatedAt\": \"Létrehozás ideje\",\n  \"LabelCronExpression\": \"Cron kifejezés\",\n  \"LabelCurrent\": \"Jelenlegi\",\n  \"LabelCurrently\": \"Jelenleg:\",\n  \"LabelCustomCronExpression\": \"Egyéni Cron kifejezés:\",\n  \"LabelDatetime\": \"Dátumidő\",\n  \"LabelDays\": \"Napok\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Törlés a fájlrendszerről (ne jelölje be, ha csak az adatbázisból szeretné eltávolítani)\",\n  \"LabelDescription\": \"Leírás\",\n  \"LabelDeselectAll\": \"Minden kijelölés megszüntetése\",\n  \"LabelDetectedPattern\": \"Észlelt minta:\",\n  \"LabelDevice\": \"Eszköz\",\n  \"LabelDeviceInfo\": \"Eszköz információ\",\n  \"LabelDeviceIsAvailableTo\": \"Eszköz elérhető a következő számára...\",\n  \"LabelDirectory\": \"Könyvtár\",\n  \"LabelDiscFromFilename\": \"Lemez a fájlnévből\",\n  \"LabelDiscFromMetadata\": \"Lemez a metaadatokból\",\n  \"LabelDiscover\": \"Felfedezés\",\n  \"LabelDownload\": \"Letöltés\",\n  \"LabelDownloadNEpisodes\": \"{0} epizód letöltése\",\n  \"LabelDownloadable\": \"Letölthető\",\n  \"LabelDuration\": \"Időtartam\",\n  \"LabelDurationComparisonExactMatch\": \"(pontos egyezés)\",\n  \"LabelDurationComparisonLonger\": \"({0}-val hosszabb)\",\n  \"LabelDurationComparisonShorter\": \"({0}-val rövidebb)\",\n  \"LabelDurationFound\": \"Megtalált időtartam:\",\n  \"LabelEbook\": \"E-könyv\",\n  \"LabelEbooks\": \"E-könyvek\",\n  \"LabelEdit\": \"Szerkesztés\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Feladó címe\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Nem hitelesített tanúsítványok elutasítása\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Az SSL tanúsítványok érvényesítésének letiltása biztonsági kockázatoknak, például man-in-the-middle támadásoknak teheti ki a kapcsolatot. Csak akkor tiltsd le ezt az opciót, ha tisztában vagy a következményekkel, és megbízol az e-mail szerverben, amelyhez csatlakozol.\",\n  \"LabelEmailSettingsSecure\": \"Biztonságos\",\n  \"LabelEmailSettingsSecureHelp\": \"Ha igaz, a kapcsolat TLS-t használ a szerverhez való csatlakozáskor. Ha hamis, akkor TLS-t használ, ha a szerver támogatja a STARTTLS kiterjesztést. A legtöbb esetben állítsa ezt az értéket igazra, ha a 465-ös portra csatlakozik. A 587-es vagy 25-ös port esetében tartsa hamis értéken. (a nodemailer.com/smtp/#authentication oldalról)\",\n  \"LabelEmailSettingsTestAddress\": \"Teszt cím\",\n  \"LabelEmbeddedCover\": \"Beágyazott borító\",\n  \"LabelEnable\": \"Engedélyezés\",\n  \"LabelEncodingBackupLocation\": \"Az eredeti hangfájlok biztonsági másolata a következő helyen lesz tárolva:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.\",\n  \"LabelEncodingClearItemCache\": \"Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.\",\n  \"LabelEncodingFinishedM4B\": \"A kész M4B a hangoskönyv mappádba kerül:\",\n  \"LabelEncodingInfoEmbedded\": \"A metaadatok beépülnek a hangsávokba a hangoskönyv mappáján belül.\",\n  \"LabelEncodingStartedNavigation\": \"Ha a feladat elindult, el lehet navigálni erről az oldalról.\",\n  \"LabelEncodingTimeWarning\": \"A kódolás akár 30 percet is igénybe vehet.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.\",\n  \"LabelEncodingWatcherDisabled\": \"Ha a figyelőt letiltotta, akkor ezt a hangoskönyvet utólag újra be kell olvasnia.\",\n  \"LabelEnd\": \"Vége\",\n  \"LabelEndOfChapter\": \"Fejezet vége\",\n  \"LabelEpisode\": \"Epizód\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Epizód nem kapcsolódik RSS hírcsatonához\",\n  \"LabelEpisodeNumber\": \"Epizód #{0}\",\n  \"LabelEpisodeTitle\": \"Epizód címe\",\n  \"LabelEpisodeType\": \"Epizód típusa\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Epizód URL-címe az RSS hírcsatornából\",\n  \"LabelEpisodes\": \"Epizódok\",\n  \"LabelEpisodic\": \"Epizódikus\",\n  \"LabelExample\": \"Példa\",\n  \"LabelExpandSeries\": \"Sorozat kinyitása\",\n  \"LabelExpandSubSeries\": \"Alsorozat kinyitása\",\n  \"LabelExpired\": \"Lejárt\",\n  \"LabelExpiresAt\": \"Lejár\",\n  \"LabelExpiresInSeconds\": \"Lejár (másodpercben)\",\n  \"LabelExpiresNever\": \"Soha\",\n  \"LabelExplicit\": \"Szókimondó\",\n  \"LabelExplicitChecked\": \"Explicit (ellenőrizve)\",\n  \"LabelExplicitUnchecked\": \"Nem explicit (nem ellenőrzött)\",\n  \"LabelExportOPML\": \"OPML exportálása\",\n  \"LabelFeedURL\": \"Hírcsatorna URL\",\n  \"LabelFetchingMetadata\": \"Metaadatok lekérése\",\n  \"LabelFile\": \"Fájl\",\n  \"LabelFileBirthtime\": \"Fájl létrehozásának ideje\",\n  \"LabelFileBornDate\": \"Született {0}\",\n  \"LabelFileModified\": \"Fájl módosításának ideje\",\n  \"LabelFileModifiedDate\": \"Módosítva {0}\",\n  \"LabelFilename\": \"Fájlnév\",\n  \"LabelFilterByUser\": \"Szűrés felhasználó szerint\",\n  \"LabelFindEpisodes\": \"Epizódok keresése\",\n  \"LabelFinished\": \"Befejezett\",\n  \"LabelFinishedDate\": \"Befejezve {0}\",\n  \"LabelFolder\": \"Mappa\",\n  \"LabelFolders\": \"Mappák\",\n  \"LabelFontBold\": \"Félkövér\",\n  \"LabelFontBoldness\": \"Betű vastagság\",\n  \"LabelFontFamily\": \"Betűcsalád\",\n  \"LabelFontItalic\": \"Dőlt\",\n  \"LabelFontScale\": \"Betűméret skála\",\n  \"LabelFontStrikethrough\": \"Áthúzott\",\n  \"LabelFormat\": \"Formátum\",\n  \"LabelFull\": \"Teljes\",\n  \"LabelGenre\": \"Műfaj\",\n  \"LabelGenres\": \"Műfajok\",\n  \"LabelHardDeleteFile\": \"Fájl végleges törlése\",\n  \"LabelHasEbook\": \"Van e-könyve\",\n  \"LabelHasSupplementaryEbook\": \"Van kiegészítő e-könyve\",\n  \"LabelHideSubtitles\": \"Alcím elrejtése\",\n  \"LabelHighestPriority\": \"Legmagasabb prioritás\",\n  \"LabelHost\": \"Kiszolgáló\",\n  \"LabelHour\": \"Óra\",\n  \"LabelHours\": \"Órák\",\n  \"LabelIcon\": \"Ikon\",\n  \"LabelImageURLFromTheWeb\": \"Kép URL a weben\",\n  \"LabelInProgress\": \"Folyamatban\",\n  \"LabelIncludeInTracklist\": \"Beleértve a sávlistába\",\n  \"LabelIncomplete\": \"Befejezetlen\",\n  \"LabelInterval\": \"Intervallum\",\n  \"LabelIntervalCustomDailyWeekly\": \"Egyéni napi/heti\",\n  \"LabelIntervalEvery12Hours\": \"Minden 12 órában\",\n  \"LabelIntervalEvery15Minutes\": \"Minden 15 percben\",\n  \"LabelIntervalEvery2Hours\": \"Minden 2 órában\",\n  \"LabelIntervalEvery30Minutes\": \"Minden 30 percben\",\n  \"LabelIntervalEvery6Hours\": \"Minden 6 órában\",\n  \"LabelIntervalEveryDay\": \"Minden nap\",\n  \"LabelIntervalEveryHour\": \"Minden órában\",\n  \"LabelIntervalEveryMinute\": \"Minden percben\",\n  \"LabelInvert\": \"Inverz\",\n  \"LabelItem\": \"Elem\",\n  \"LabelJumpBackwardAmount\": \"Visszafelé ugrás mennyisége\",\n  \"LabelJumpForwardAmount\": \"Előre ugrás mennyisége\",\n  \"LabelLanguage\": \"Nyelv\",\n  \"LabelLanguageDefaultServer\": \"Szerver alapértelmezett nyelve\",\n  \"LabelLanguages\": \"Nyelvek\",\n  \"LabelLastBookAdded\": \"Utolsó hozzáadott könyv\",\n  \"LabelLastBookUpdated\": \"Utolsó frissített könyv\",\n  \"LabelLastProgressDate\": \"Legutóbbi haladás: {0}\",\n  \"LabelLastSeen\": \"Utolsó látogatás\",\n  \"LabelLastTime\": \"Utolsó alkalom\",\n  \"LabelLastUpdate\": \"Utolsó frissítés\",\n  \"LabelLayout\": \"Elrendezés\",\n  \"LabelLayoutSinglePage\": \"Egyoldalas\",\n  \"LabelLayoutSplitPage\": \"Kétoldalas\",\n  \"LabelLess\": \"Kevesebb\",\n  \"LabelLibrariesAccessibleToUser\": \"A felhasználó számára elérhető könyvtárak\",\n  \"LabelLibrary\": \"Könyvtár\",\n  \"LabelLibraryFilterSublistEmpty\": \"Nem {0}\",\n  \"LabelLibraryItem\": \"Könyvtári elem\",\n  \"LabelLibraryName\": \"Könyvtár neve\",\n  \"LabelLibrarySortByProgress\": \"Folyamat: Legutóbbi frissítés\",\n  \"LabelLibrarySortByProgressFinished\": \"Folyamat: Befejezve\",\n  \"LabelLibrarySortByProgressStarted\": \"Folyamat: Elindult\",\n  \"LabelLimit\": \"Korlát\",\n  \"LabelLineSpacing\": \"Sorköz\",\n  \"LabelListenAgain\": \"Újrahallgatás\",\n  \"LabelLogLevelDebug\": \"Hibakeresés\",\n  \"LabelLogLevelInfo\": \"Információ\",\n  \"LabelLogLevelWarn\": \"Figyelmeztetés\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Új epizódok keresése ezen a dátum után\",\n  \"LabelLowestPriority\": \"Legalacsonyabb prioritás\",\n  \"LabelMatchConfidence\": \"Bizalom\",\n  \"LabelMatchExistingUsersBy\": \"Meglévő felhasználók egyeztetése\",\n  \"LabelMatchExistingUsersByDescription\": \"Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától\",\n  \"LabelMaxEpisodesToDownload\": \"Letölthető epizódok maximális száma. Használja a 0-t a korlátlan letöltéshez.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Ellenőrzésenként letölthető új epizódok maximális száma\",\n  \"LabelMaxEpisodesToKeep\": \"Maximálisan megtartható epizódok száma\",\n  \"LabelMaxEpisodesToKeepHelp\": \"A 0 érték nem állít be maximális korlátot. Az új epizód automatikus letöltése után ez a beállítás törli a legrégebbi epizódot, ha X epizódnál több van. Új letöltésenként csak 1 epizódot töröl.\",\n  \"LabelMediaPlayer\": \"Médialejátszó\",\n  \"LabelMediaType\": \"Média típus\",\n  \"LabelMetaTag\": \"Meta címke\",\n  \"LabelMetaTags\": \"Meta címkék\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat\",\n  \"LabelMetadataProvider\": \"Metaadat-szolgáltató\",\n  \"LabelMinute\": \"Perc\",\n  \"LabelMinutes\": \"Perc\",\n  \"LabelMissing\": \"Hiányzik\",\n  \"LabelMissingEbook\": \"Nincs e-könyve\",\n  \"LabelMissingSupplementaryEbook\": \"Nincs kiegészítő e-könyve\",\n  \"LabelMobileRedirectURIs\": \"Engedélyezett mobil átirányítási URI-k\",\n  \"LabelMobileRedirectURIsDescription\": \"Ez egy fehérlista az érvényes mobilalkalmazás-átirányítási URI-k számára. Az alapértelmezett <code>audiobookshelf://oauth</code>, amely eltávolítható vagy kiegészíthető további URI-kkal harmadik féltől származó alkalmazásintegráció érdekében. Ha az egyetlen bejegyzés egy csillag (<code>*</code>), akkor bármely URI engedélyezett.\",\n  \"LabelMore\": \"Több\",\n  \"LabelMoreInfo\": \"További információ\",\n  \"LabelName\": \"Név\",\n  \"LabelNarrator\": \"Előadó\",\n  \"LabelNarrators\": \"Előadók\",\n  \"LabelNew\": \"Új\",\n  \"LabelNewPassword\": \"Új jelszó\",\n  \"LabelNewestAuthors\": \"A legújabb szerzők\",\n  \"LabelNewestEpisodes\": \"Legújabb epizódok\",\n  \"LabelNextBackupDate\": \"Következő biztonsági másolat dátuma\",\n  \"LabelNextChapters\": \"A következő fejezetek:\",\n  \"LabelNextScheduledRun\": \"Következő ütemezett futtatás\",\n  \"LabelNoApiKeys\": \"Nincs API kulcs\",\n  \"LabelNoCustomMetadataProviders\": \"Nincsenek egyedi metaadat szolgáltatók\",\n  \"LabelNoEpisodesSelected\": \"Nincsenek kiválasztott epizódok\",\n  \"LabelNotFinished\": \"Nem befejezett\",\n  \"LabelNotStarted\": \"Nem indult el\",\n  \"LabelNotes\": \"Megjegyzések\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(ek)\",\n  \"LabelNotificationAvailableVariables\": \"Elérhető változók\",\n  \"LabelNotificationBodyTemplate\": \"Törzs sablon\",\n  \"LabelNotificationEvent\": \"Értesítési esemény\",\n  \"LabelNotificationTitleTemplate\": \"Cím sablon\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maximális sikertelen próbálkozások\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Az értesítések akkor kerülnek letiltásra, ha ennyiszer nem sikerül elküldeni őket\",\n  \"LabelNotificationsMaxQueueSize\": \"Maximális értesítési események sorának mérete\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Az események korlátozva vannak, hogy másodpercenként 1-szer történjenek. Ha a sor maximális méretű, akkor az események figyelmen kívül lesznek hagyva. Ez megakadályozza az értesítések spamelését.\",\n  \"LabelNumberOfBooks\": \"Könyvek száma\",\n  \"LabelNumberOfChapters\": \"Fejezetek száma:\",\n  \"LabelNumberOfEpisodes\": \"Epizódok száma\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:\",\n  \"LabelOpenIDClaims\": \"Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a ‘Felhasználó’ csoport kerül hozzárendelésre.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában <code>groups</code> néven hivatkoznak rá. <b>Ha konfigurálva van</b>, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül ‘admin’, ‘user’ vagy ‘guest’ néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.\",\n  \"LabelOpenRSSFeed\": \"RSS hírcsatorna megnyitása\",\n  \"LabelOverwrite\": \"Felülírás\",\n  \"LabelPaginationPageXOfY\": \"{0} oldal {1}-ból/ből\",\n  \"LabelPassword\": \"Jelszó\",\n  \"LabelPath\": \"Útvonal\",\n  \"LabelPermanent\": \"Végleges\",\n  \"LabelPermissionsAccessAllLibraries\": \"Hozzáférhet az összes könyvtárhoz\",\n  \"LabelPermissionsAccessAllTags\": \"Hozzáférhet az összes címkéhez\",\n  \"LabelPermissionsAccessExplicitContent\": \"Hozzáférhet explicit tartalomhoz\",\n  \"LabelPermissionsCreateEreader\": \"Létrehozhat Ereader-t\",\n  \"LabelPermissionsDelete\": \"Törölhet\",\n  \"LabelPermissionsDownload\": \"Letölthet\",\n  \"LabelPermissionsUpdate\": \"Frissíthet\",\n  \"LabelPermissionsUpload\": \"Feltölthet\",\n  \"LabelPersonalYearReview\": \"Az éved összefoglalása ({0})\",\n  \"LabelPhotoPathURL\": \"Fénykép útvonal/URL\",\n  \"LabelPlayMethod\": \"Lejátszási módszer\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Lejátszási sebesség növelés/csökkentés értéke\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} a {1} -ből\",\n  \"LabelPlaylists\": \"Lejátszási listák\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast keresési régió\",\n  \"LabelPodcastType\": \"Podcast típus\",\n  \"LabelPodcasts\": \"Podcastok\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Figyelmen kívül hagyandó előtagok (nem érzékeny a kis- és nagybetűkre)\",\n  \"LabelPreventIndexing\": \"Megakadályozza a hírcsatornájának indexelését az iTunes és a Google podcast könyvtárakban\",\n  \"LabelPrimaryEbook\": \"Elsődleges e-könyv\",\n  \"LabelProgress\": \"Haladás\",\n  \"LabelProvider\": \"Szolgáltató\",\n  \"LabelProviderAuthorizationValue\": \"Authorization fejléc értéke\",\n  \"LabelPubDate\": \"Kiadás dátuma\",\n  \"LabelPublishYear\": \"Kiadás éve\",\n  \"LabelPublishedDate\": \"Kiadva {0}\",\n  \"LabelPublishedDecade\": \"Közzétett évtized\",\n  \"LabelPublishedDecades\": \"Közzétett évtized\",\n  \"LabelPublisher\": \"Kiadó\",\n  \"LabelPublishers\": \"Kiadók\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Egyéni tulajdonos e-mail\",\n  \"LabelRSSFeedCustomOwnerName\": \"Egyéni tulajdonos neve\",\n  \"LabelRSSFeedOpen\": \"RSS-hírcsatorna nyitva\",\n  \"LabelRSSFeedPreventIndexing\": \"Indexelés megakadályozása\",\n  \"LabelRSSFeedSlug\": \"RSS hírcsatorna slug\",\n  \"LabelRSSFeedURL\": \"RSS hírcsatorna URL\",\n  \"LabelRandomly\": \"Véletlenszerűen\",\n  \"LabelReAddSeriesToContinueListening\": \"Sorozat újbóli hozzáadása a folytatáshoz\",\n  \"LabelRead\": \"Olvasás\",\n  \"LabelReadAgain\": \"Újraolvasás\",\n  \"LabelReadEbookWithoutProgress\": \"E-könyv olvasása haladás nélkül\",\n  \"LabelRecentSeries\": \"Legutóbbi sorozatok\",\n  \"LabelRecentlyAdded\": \"Nemrég hozzáadva\",\n  \"LabelRecommended\": \"Ajánlott\",\n  \"LabelRedo\": \"Újra\",\n  \"LabelRegion\": \"Régió\",\n  \"LabelReleaseDate\": \"Megjelenés dátuma\",\n  \"LabelRemoveAllMetadataAbs\": \"Az összes metadata.abs fájl eltávolítása\",\n  \"LabelRemoveAllMetadataJson\": \"Az összes metadata.json fájl eltávolítása\",\n  \"LabelRemoveAudibleBranding\": \"Audible intro és outro eltávolítása a fejezetekből\",\n  \"LabelRemoveCover\": \"Borító eltávolítása\",\n  \"LabelRemoveMetadataFile\": \"Metaadatfájlok eltávolítása a könyvtár elemek mappáiból\",\n  \"LabelRemoveMetadataFileHelp\": \"A metadata.json és metadata.abs fájlokat eltávolítása a {0} mappáidból.\",\n  \"LabelRowsPerPage\": \"Sorok száma oldalanként\",\n  \"LabelSearchTerm\": \"Keresési kifejezés\",\n  \"LabelSearchTitle\": \"Cím keresése\",\n  \"LabelSearchTitleOrASIN\": \"Cím vagy ASIN keresése\",\n  \"LabelSeason\": \"Évad\",\n  \"LabelSeasonNumber\": \"Évad #{0}\",\n  \"LabelSelectAll\": \"Minden kiválasztása\",\n  \"LabelSelectAllEpisodes\": \"Összes epizód kiválasztása\",\n  \"LabelSelectEpisodesShowing\": \"Kiválasztás {0} megjelenített epizód\",\n  \"LabelSelectUser\": \"Felhasználó kiválasztása\",\n  \"LabelSelectUsers\": \"Felhasználók kiválasztása\",\n  \"LabelSendEbookToDevice\": \"E-könyv küldése...\",\n  \"LabelSequence\": \"Sorozat\",\n  \"LabelSerial\": \"Sorozat\",\n  \"LabelSeries\": \"Sorozat\",\n  \"LabelSeriesName\": \"Sorozat neve\",\n  \"LabelSeriesProgress\": \"Sorozat haladása\",\n  \"LabelServerLogLevel\": \"Kiszolgáló naplózási szint\",\n  \"LabelServerYearReview\": \"Szerver éves visszatekintése ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Beállítás elsődlegesként\",\n  \"LabelSetEbookAsSupplementary\": \"Beállítás kiegészítőként\",\n  \"LabelSettingsAllowIframe\": \"A beágyazás engedélyezése egy iframe-be\",\n  \"LabelSettingsAudiobooksOnly\": \"Csak hangoskönyvek\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeuomorfikus dizájn fa polcokkal\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast támogatás\",\n  \"LabelSettingsDateFormat\": \"Dátumformátum\",\n  \"LabelSettingsEnableWatcher\": \"Változások automatikus figyelése a könyvtárakban\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Változások automatikus figyelése a könyvtárban\",\n  \"LabelSettingsEnableWatcherHelp\": \"Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Szkriptelt tartalmak engedélyezése epub-okban\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Megengedi, hogy az epub fájlok szkripteket hajtsanak végre. Ezt a beállítást kikapcsolva ajánlott tartani, kivéve, ha megbízik az epub fájlok forrásában.\",\n  \"LabelSettingsExperimentalFeatures\": \"Kísérleti funkciók\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.\",\n  \"LabelSettingsFindCovers\": \"Borítók keresése\",\n  \"LabelSettingsFindCoversHelp\": \"Ha a hangoskönyvnek nincs beágyazott borítója vagy borítóképe a mappában, a szkenner megpróbálja megtalálni a borítót.<br>Megjegyzés: Ez meghosszabbítja a szkennelési időt\",\n  \"LabelSettingsHideSingleBookSeries\": \"Egykönyves sorozatok elrejtése\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Kezdőlap használja a könyvespolc nézetet\",\n  \"LabelSettingsLibraryBookshelfView\": \"Könyvtár használja a könyvespolc nézetet\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Százalékos befejezettség nagyobb mint\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"A hátralévő idő kevesebb, mint (másodperc)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"A médiaelem befejezettnek jelölése, ha\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Megelőző könyvek kihagyása a Sorozat folytatásában\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"A Sorozat folytatása kezdőlap polcán az első nem megkezdett könyv látható egy olyan sorozatban, amelynek legalább egy könyve befejeződött, és nincs folyamatban lévő rész. Ha engedélyezi ezt a beállítást, akkor a sorozatot a legvégső befejezett könyvtől folytatja az első el nem kezdett könyv helyett.\",\n  \"LabelSettingsParseSubtitles\": \"Feliratok elemzése\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \\\" - \\\" jellel<br>például: \\\"Könyv címe - Egy felirat itt\\\" esetén a felirat \\\"Egy felirat itt\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferált egyeztetett metaadatok\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Az egyeztetett adatok felülírják az elem részleteit a Gyors egyeztetés használatakor. Alapértelmezés szerint a Gyors egyeztetés csak a hiányzó részleteket tölti ki.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Már ASIN-nel rendelkező könyvek egyeztetésének kihagyása\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Már ISBN-nel rendelkező könyvek egyeztetésének kihagyása\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Előtagok figyelmen kívül hagyása rendezéskor\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"például az \\\"a\\\" előtag esetén a \\\"A könyv címe\\\" könyv címe \\\"Könyv címe, A\\\" szerint rendeződik\",\n  \"LabelSettingsSquareBookCovers\": \"Négyzet alakú könyvborítók használata\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Négyzet alakú borítók használata az 1,6:1 arányú standard könyvborítók helyett\",\n  \"LabelSettingsStoreCoversWithItem\": \"Borítók tárolása az elemmel\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Alapértelmezés szerint a borítók a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a borítókat a könyvtári elem mappájában tárolja. Csak egy \\\"cover\\\" nevű fájl lesz megtartva\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Metaadatok tárolása az elemmel\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja\",\n  \"LabelSettingsTimeFormat\": \"Időformátum\",\n  \"LabelShare\": \"Megosztás\",\n  \"LabelShareDownloadableHelp\": \"Lehetővé teszi a megosztási linket birtokló felhasználók számára, hogy letöltsék a könyvtári elem zip-fájlját.\",\n  \"LabelShareOpen\": \"Megosztás megnyitása\",\n  \"LabelShareURL\": \"URL megosztása\",\n  \"LabelShowAll\": \"Mindent mutat\",\n  \"LabelShowSeconds\": \"Másodperc megjelenítése\",\n  \"LabelShowSubtitles\": \"Felirat megjelenítése\",\n  \"LabelSize\": \"Méret\",\n  \"LabelSleepTimer\": \"Alvásidőzítő\",\n  \"LabelSlug\": \"Rövid cím\",\n  \"LabelSortAscending\": \"Emelkedő\",\n  \"LabelSortDescending\": \"Csökkenő\",\n  \"LabelSortPubDate\": \"Rendezés megjelenés dátuma szerint\",\n  \"LabelStart\": \"Kezdés\",\n  \"LabelStartTime\": \"Kezdési idő\",\n  \"LabelStarted\": \"Elkezdődött\",\n  \"LabelStartedAt\": \"Kezdés ideje\",\n  \"LabelStartedDate\": \"Elindítva {0}\",\n  \"LabelStatsAudioTracks\": \"Audiósáv\",\n  \"LabelStatsAuthors\": \"Szerző\",\n  \"LabelStatsBestDay\": \"Legjobb nap\",\n  \"LabelStatsDailyAverage\": \"Napi átlag\",\n  \"LabelStatsDays\": \"Nap\",\n  \"LabelStatsDaysListened\": \"Hallgatással töltött nap\",\n  \"LabelStatsHours\": \"Órák\",\n  \"LabelStatsInARow\": \"egymás után\",\n  \"LabelStatsItemsFinished\": \"Befejezett elem\",\n  \"LabelStatsItemsInLibrary\": \"Elem a könyvtárban\",\n  \"LabelStatsMinutes\": \"perc\",\n  \"LabelStatsMinutesListening\": \"Hallgatási perc\",\n  \"LabelStatsOverallDays\": \"Összes nap\",\n  \"LabelStatsOverallHours\": \"Összes óra\",\n  \"LabelStatsWeekListening\": \"Heti hallgatás\",\n  \"LabelSubtitle\": \"Felirat\",\n  \"LabelSupportedFileTypes\": \"Támogatott fájltípusok\",\n  \"LabelTag\": \"Címke\",\n  \"LabelTags\": \"Címkék\",\n  \"LabelTagsAccessibleToUser\": \"A felhasználó számára elérhető címkék\",\n  \"LabelTagsNotAccessibleToUser\": \"A felhasználó számára nem elérhető címkék\",\n  \"LabelTasks\": \"Futó feladatok\",\n  \"LabelTextEditorBulletedList\": \"Pontozott lista\",\n  \"LabelTextEditorLink\": \"Hivatkozás\",\n  \"LabelTextEditorNumberedList\": \"Számozott lista\",\n  \"LabelTextEditorUnlink\": \"Link eltávolítása\",\n  \"LabelTheme\": \"Téma\",\n  \"LabelThemeDark\": \"Sötét\",\n  \"LabelThemeLight\": \"Világos\",\n  \"LabelThemeSepia\": \"Szépia\",\n  \"LabelTimeBase\": \"Időalap\",\n  \"LabelTimeDurationXHours\": \"{0} óra\",\n  \"LabelTimeDurationXMinutes\": \"{0} perc\",\n  \"LabelTimeDurationXSeconds\": \"{0} másodperc\",\n  \"LabelTimeInMinutes\": \"Idő percben\",\n  \"LabelTimeLeft\": \"{0} maradt hátra\",\n  \"LabelTimeListened\": \"Hallgatott idő\",\n  \"LabelTimeListenedToday\": \"Ma hallgatott idő\",\n  \"LabelTimeRemaining\": \"{0} maradt\",\n  \"LabelTimeToShift\": \"Eltolás ideje másodpercben\",\n  \"LabelTitle\": \"Cím\",\n  \"LabelToolsEmbedMetadata\": \"Metaadatok beágyazása\",\n  \"LabelToolsEmbedMetadataDescription\": \"Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.\",\n  \"LabelToolsM4bEncoder\": \"M4B kódoló\",\n  \"LabelToolsMakeM4b\": \"M4B Hangoskönyv fájl készítése\",\n  \"LabelToolsMakeM4bDescription\": \".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.\",\n  \"LabelToolsSplitM4b\": \"M4B felosztása MP3-ra\",\n  \"LabelToolsSplitM4bDescription\": \"MP3 fájlok létrehozása egy M4B-ből, fejezetenként felosztva, beágyazott metaadatokkal, borítóképpel és fejezetekkel.\",\n  \"LabelTotalDuration\": \"Teljes időtartam\",\n  \"LabelTotalTimeListened\": \"Teljes hallgatási idő\",\n  \"LabelTrackFromFilename\": \"Sáv a fájlnévből\",\n  \"LabelTrackFromMetadata\": \"Sáv a metaadatokból\",\n  \"LabelTracks\": \"Sávok\",\n  \"LabelTracksMultiTrack\": \"Többsávos\",\n  \"LabelTracksNone\": \"Nincsenek sávok\",\n  \"LabelTracksSingleTrack\": \"Egysávos\",\n  \"LabelTrailer\": \"Előzetes\",\n  \"LabelType\": \"Típus\",\n  \"LabelUnabridged\": \"Nem tömörített\",\n  \"LabelUndo\": \"Visszavonás\",\n  \"LabelUnknown\": \"Ismeretlen\",\n  \"LabelUnknownPublishDate\": \"Ismeretlen megjelenési dátum\",\n  \"LabelUpdateCover\": \"Borító frissítése\",\n  \"LabelUpdateCoverHelp\": \"Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál\",\n  \"LabelUpdateDetails\": \"Részletek frissítése\",\n  \"LabelUpdateDetailsHelp\": \"Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál\",\n  \"LabelUpdatedAt\": \"Frissítve\",\n  \"LabelUploaderDragAndDrop\": \"Fájlok vagy mappák húzása és elengedése\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Fájlok húzása és elengedése\",\n  \"LabelUploaderDropFiles\": \"Fájlok elengedése\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Cím, szerző és sorozat automatikus lekérése\",\n  \"LabelUseAdvancedOptions\": \"Haladó beállítások használata\",\n  \"LabelUseChapterTrack\": \"Fejezetsáv használata\",\n  \"LabelUseFullTrack\": \"Teljes sáv használata\",\n  \"LabelUseZeroForUnlimited\": \"Használja a 0-t a korlátlan értékhez\",\n  \"LabelUser\": \"Felhasználó\",\n  \"LabelUsername\": \"Felhasználónév\",\n  \"LabelValue\": \"Érték\",\n  \"LabelVersion\": \"Verzió\",\n  \"LabelViewBookmarks\": \"Könyvjelzők megtekintése\",\n  \"LabelViewChapters\": \"Fejezetek megtekintése\",\n  \"LabelViewPlayerSettings\": \"A lejátszó beállításainak megtekintése\",\n  \"LabelViewQueue\": \"Lejátszó sor megtekintése\",\n  \"LabelVolume\": \"Hangerő\",\n  \"LabelWebRedirectURLsDescription\": \"Engedélyezze ezeket az URL-címeket az OAuth-szolgáltatóban, hogy a bejelentkezés után vissza lehessen irányítani a webes alkalmazáshoz:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Almappa átirányító URL-ek számára\",\n  \"LabelWeekdaysToRun\": \"Futás napjai\",\n  \"LabelXBooks\": \"{0} könyv\",\n  \"LabelXItems\": \"{0} elem\",\n  \"LabelYearReviewHide\": \"Visszatekintés az évre elrejtése\",\n  \"LabelYearReviewShow\": \"Visszatekintés az évre megtekintése\",\n  \"LabelYourAudiobookDuration\": \"Hangoskönyv időtartama\",\n  \"LabelYourBookmarks\": \"Könyvjelzőid\",\n  \"LabelYourPlaylists\": \"Lejátszási listáid\",\n  \"LabelYourProgress\": \"Haladásod\",\n  \"MessageAddToPlayerQueue\": \"Hozzáadás a lejátszó sorhoz\",\n  \"MessageAppriseDescription\": \"Ennek a funkció használatához futtatnia kell egy <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.\",\n  \"MessageAsinCheck\": \"Győződjön meg róla, hogy az ASIN-t a megfelelő Audible régióból használja, nem az Amazonból.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"A régi API-tokenek a jövőben eltávolításra kerülnek. Helyette használja az <a href=\\\"/config/api-keys\\\">API-kulcsokat</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"A mentés után indítsa újra a szervert az OIDC módosítások alkalmazásához.\",\n  \"MessageAuthenticationSecurityMessage\": \"A biztonság érdekében a hitelesítés folyamatát továbbfejlesztettük. Minden felhasználónak újra be kell jelentkeznie.\",\n  \"MessageBackupsDescription\": \"A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.\",\n  \"MessageBackupsLocationEditNote\": \"Megjegyzés: A biztonsági mentés helyének frissítése nem mozgatja vagy módosítja a meglévő biztonsági mentéseket\",\n  \"MessageBackupsLocationNoEditNote\": \"Megjegyzés: A biztonsági mentés helye egy környezeti változóval van beállítva, és itt nem módosítható.\",\n  \"MessageBackupsLocationPathEmpty\": \"A biztonsági mentés helyének elérési útvonala nem lehet üres\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Az engedélyezett mezők feltöltése az összes elem adatával. A több értéket tartalmazó mezők összevonásra kerülnek\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"A térkép engedélyezett adatmezőinek feltöltése ezen elem adataival\",\n  \"MessageBatchQuickMatchDescription\": \"A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.\",\n  \"MessageBookshelfNoCollections\": \"Még nem készített gyűjteményeket\",\n  \"MessageBookshelfNoCollectionsHelp\": \"A gyűjtemények nyilvánosak. Minden, a könyvtárhoz hozzáféréssel rendelkező felhasználó láthatja őket.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Nincsenek nyitott RSS hírcsatornák\",\n  \"MessageBookshelfNoResultsForFilter\": \"Nincs eredmény a \\\"{0}: {1}\\\" szűrőre\",\n  \"MessageBookshelfNoResultsForQuery\": \"Nincs eredmény a lekérdezéshez\",\n  \"MessageBookshelfNoSeries\": \"Nincsenek sorozatai\",\n  \"MessageBulkChapterPattern\": \"Hány fejezetet szeretne hozzáadni ezzel a számozási mintával?\",\n  \"MessageChapterEndIsAfter\": \"A fejezet vége a hangoskönyv végét követi\",\n  \"MessageChapterErrorFirstNotZero\": \"Az első fejezetnek 0:00-kor kell kezdődnie\",\n  \"MessageChapterErrorStartGteDuration\": \"Érvénytelen kezdési idő, kevesebbnek kell lennie, mint a hangoskönyv időtartama\",\n  \"MessageChapterErrorStartLtPrev\": \"Érvénytelen kezdési idő, nagyobbnak kell lennie, mint az előző fejezet kezdési ideje\",\n  \"MessageChapterStartIsAfter\": \"A fejezet kezdete a hangoskönyv végét követi\",\n  \"MessageChaptersNotFound\": \"Fejezetek nem találhatók\",\n  \"MessageCheckingCron\": \"Cron ellenőrzése...\",\n  \"MessageConfirmCloseFeed\": \"Biztosan be szeretné zárni ezt a hírcsatornát?\",\n  \"MessageConfirmDeleteApiKey\": \"Biztosan törölni szeretné az \\\"{0}\\\" API kulcsot?\",\n  \"MessageConfirmDeleteBackup\": \"Biztosan törölni szeretné a(z) {0} biztonsági másolatot?\",\n  \"MessageConfirmDeleteDevice\": \"Biztos, hogy törölni szeretné a „{0}” e-olvasó eszközt?\",\n  \"MessageConfirmDeleteFile\": \"Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?\",\n  \"MessageConfirmDeleteLibrary\": \"Biztosan véglegesen törölni szeretné a(z) \\\"{0}\\\" könyvtárat?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Biztos, hogy törölni szeretné a „{0}” egyéni metaadat-szolgáltatót?\",\n  \"MessageConfirmDeleteNotification\": \"Biztos, hogy törölni szeretné ezt az értesítést?\",\n  \"MessageConfirmDeleteSession\": \"Biztosan törölni szeretné ezt a munkamenetet?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?\",\n  \"MessageConfirmForceReScan\": \"Biztosan kényszeríteni szeretné az újraszkennelést?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Biztosan meg szeretné jelölni az összes epizódot befejezettnek?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?\",\n  \"MessageConfirmMarkItemFinished\": \"Biztos, hogy a „{0}”-t befejezettnek akarja jelölni?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Biztos, hogy a „{0}”-t befejezetlennek akarja jelölni?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Ez az értesítés indítható tesztadatokkal?\",\n  \"MessageConfirmPurgeCache\": \"A gyorsítótár kiürítése törli a teljes könyvtárat a <code>/metadata/cache</code> helyről. <br /><br />Biztosan eltávolítja a gyorsítótár könyvtárát?\",\n  \"MessageConfirmPurgeItemsCache\": \"Az elemek gyorsítótárának kiürítése törli a teljes könyvtárat a <code>/metadata/cache/items</code> helyről.<br />Biztos benne?\",\n  \"MessageConfirmQuickEmbed\": \"Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Az epizódok gyors megfeleltetése felülírja a részleteket, ha egyezést talál. Csak a nem egyező epizódok frissülnek. Biztos benne?\",\n  \"MessageConfirmReScanLibraryItems\": \"Biztosan újra szeretné szkennelni a(z) {0} elemet?\",\n  \"MessageConfirmRemoveAllChapters\": \"Biztosan eltávolítja az összes fejezetet?\",\n  \"MessageConfirmRemoveAuthor\": \"Biztosan eltávolítja a(z) \\\"{0}\\\" szerzőt?\",\n  \"MessageConfirmRemoveCollection\": \"Biztosan eltávolítja a(z) \\\"{0}\\\" gyűjteményt?\",\n  \"MessageConfirmRemoveEpisode\": \"Biztosan eltávolítja a(z) \\\"{0}\\\" epizódot?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \\\"Hangfájl végleges törlése\\\" be van kapcsolva\",\n  \"MessageConfirmRemoveEpisodes\": \"Biztosan eltávolítja a(z) {0} epizódot?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?\",\n  \"MessageConfirmRemoveNarrator\": \"Biztosan eltávolítja a(z) \\\"{0}\\\" előadót?\",\n  \"MessageConfirmRemovePlaylist\": \"Biztosan eltávolítja a(z) \\\"{0}\\\" lejátszási listáját?\",\n  \"MessageConfirmRenameGenre\": \"Biztosan át szeretné nevezni a(z) \\\"{0}\\\" műfajt \\\"{1}\\\"-re az összes elemnél?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Megjegyzés: Ez a műfaj már létezik, így össze lesznek vonva.\",\n  \"MessageConfirmRenameGenreWarning\": \"Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező műfaj már létezik \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Biztosan át szeretné nevezni a(z) \\\"{0}\\\" címkét \\\"{1}\\\"-re az összes elemnél?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.\",\n  \"MessageConfirmRenameTagWarning\": \"Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Biztos, hogy vissza akarja állítani a haladási folyamatát?\",\n  \"MessageConfirmSendEbookToDevice\": \"Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \\\"{1}\\\" eszközre?\",\n  \"MessageConfirmUnlinkOpenId\": \"Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} napot hallgatott az elmúlt évben\",\n  \"MessageDownloadingEpisode\": \"Epizód letöltése\",\n  \"MessageDragFilesIntoTrackOrder\": \"Húzza a fájlokat a helyes sávrendbe\",\n  \"MessageEmbedFailed\": \"A beágyazás sikertelen!\",\n  \"MessageEmbedFinished\": \"Beágyazás befejeződött!\",\n  \"MessageEmbedQueue\": \"Metaadatok beágyazására várakozik ({0} a sorban)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} epizód letöltésre vár\",\n  \"MessageEreaderDevices\": \"Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.\",\n  \"MessageFeedURLWillBe\": \"A hírcsatorna URL-je {0} lesz\",\n  \"MessageFetching\": \"Lekérdezés...\",\n  \"MessageForceReScanDescription\": \"minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} hallgatja</strong> ezen {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Nincs hallgatás folyamatban ezen: {0}\",\n  \"MessageImportantNotice\": \"Fontos közlemény!\",\n  \"MessageInsertChapterBelow\": \"Fejezet beszúrása alulra\",\n  \"MessageInvalidAsin\": \"Érvénytelen ASIN\",\n  \"MessageItemsSelected\": \"{0} kiválasztott elem\",\n  \"MessageItemsUpdated\": \"{0} frissített elem\",\n  \"MessageJoinUsOn\": \"Csatlakozzon hozzánk a\",\n  \"MessageLoading\": \"Betöltés...\",\n  \"MessageLoadingFolders\": \"Mappák betöltése...\",\n  \"MessageLogsDescription\": \"A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.\",\n  \"MessageM4BFailed\": \"M4B sikertelen!\",\n  \"MessageM4BFinished\": \"M4B befejeződött!\",\n  \"MessageMapChapterTitles\": \"Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná\",\n  \"MessageMarkAllEpisodesFinished\": \"Az összes epizód megjelölése befejezettnek\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Az összes epizód megjelölése nem befejezettnek\",\n  \"MessageMarkAsFinished\": \"Megjelölés befejezettnek\",\n  \"MessageMarkAsNotFinished\": \"Megjelölés nem befejezettnek\",\n  \"MessageMatchBooksDescription\": \"megpróbálja egyeztetni a könyvtár könyveit egy kiválasztott keresési szolgáltató könyvével, és kitölti az üres részleteket és a borítót. Nem írja felül a részleteket.\",\n  \"MessageNoAudioTracks\": \"Nincsenek audiósávok\",\n  \"MessageNoAuthors\": \"Nincsenek szerzők\",\n  \"MessageNoBackups\": \"Nincsenek biztonsági másolatok\",\n  \"MessageNoBookmarks\": \"Nincsenek könyvjelzők\",\n  \"MessageNoChapters\": \"Nincsenek fejezetek\",\n  \"MessageNoCollections\": \"Nincsenek gyűjtemények\",\n  \"MessageNoCoversFound\": \"Nem találhatóak borítók\",\n  \"MessageNoDescription\": \"Nincs leírás\",\n  \"MessageNoDevices\": \"Nincs eszköz\",\n  \"MessageNoDownloadsInProgress\": \"Jelenleg nincsenek folyamatban lévő letöltések\",\n  \"MessageNoDownloadsQueued\": \"Nincsenek várakozó letöltések\",\n  \"MessageNoEpisodeMatchesFound\": \"Nincs találat az epizódokra\",\n  \"MessageNoEpisodes\": \"Nincsenek epizódok\",\n  \"MessageNoFoldersAvailable\": \"Nincsenek elérhető mappák\",\n  \"MessageNoGenres\": \"Nincsenek műfajok\",\n  \"MessageNoIssues\": \"Nincsenek problémák\",\n  \"MessageNoItems\": \"Nincsenek elemek\",\n  \"MessageNoItemsFound\": \"Nincs találat\",\n  \"MessageNoListeningSessions\": \"Nincsenek hallgatási munkamenetek\",\n  \"MessageNoLogs\": \"Nincsenek naplók\",\n  \"MessageNoMediaProgress\": \"Nincs előrehaladás a médialejátszásban\",\n  \"MessageNoNotifications\": \"Nincsenek értesítések\",\n  \"MessageNoPodcastFeed\": \"Érvénytelen podcast: Nincs forrás\",\n  \"MessageNoPodcastsFound\": \"Nem találhatóak podcastok\",\n  \"MessageNoResults\": \"Nincsenek eredmények\",\n  \"MessageNoSearchResultsFor\": \"Nincs keresési eredmény erre: \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Nincsenek sorozatok\",\n  \"MessageNoTags\": \"Nincsenek címkék\",\n  \"MessageNoTasksRunning\": \"Nincsenek futó feladatok\",\n  \"MessageNoUpdatesWereNecessary\": \"Nem volt szükség frissítésekre\",\n  \"MessageNoUserPlaylists\": \"Nincsenek felhasználói lejátszási listák\",\n  \"MessageNoUserPlaylistsHelp\": \"A lejátszási listák személyesek. Csak az a felhasználó láthatja őket, aki létrehozta őket.\",\n  \"MessageNotYetImplemented\": \"Még nem implementált\",\n  \"MessageOpmlPreviewNote\": \"Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.\",\n  \"MessageOr\": \"vagy\",\n  \"MessagePauseChapter\": \"Fejezet lejátszásának szüneteltetése\",\n  \"MessagePlayChapter\": \"Fejezet elejének meghallgatása\",\n  \"MessagePlaylistCreateFromCollection\": \"Lejátszási lista létrehozása gyűjteményből\",\n  \"MessagePleaseWait\": \"Kérem várjon...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez\",\n  \"MessagePodcastSearchField\": \"Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét\",\n  \"MessageQuickEmbedInProgress\": \"Gyors beágyazás folyamatban\",\n  \"MessageQuickEmbedQueue\": \"Gyors beágyazásra várakozik ({0} a sorban)\",\n  \"MessageQuickMatchAllEpisodes\": \"Minden epizód gyors egyeztetése\",\n  \"MessageQuickMatchDescription\": \"Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.\",\n  \"MessageRemoveChapter\": \"Fejezet eltávolítása\",\n  \"MessageRemoveEpisodes\": \"Epizód(ok) eltávolítása: {0}\",\n  \"MessageRemoveFromPlayerQueue\": \"Eltávolítás a lejátszási sorból\",\n  \"MessageRemoveUserWarning\": \"Biztosan véglegesen törölni szeretné a(z) \\\"{0}\\\" felhasználót?\",\n  \"MessageReportBugsAndContribute\": \"Hibák jelentése, funkciók kérése és hozzájárulás itt\",\n  \"MessageResetChaptersConfirm\": \"Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?\",\n  \"MessageRestoreBackupConfirm\": \"Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:\",\n  \"MessageRestoreBackupWarning\": \"A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.\",\n  \"MessageScheduleLibraryScanNote\": \"A legtöbb felhasználó számára ajánlott ezt a funkciót kikapcsolva hagyni, és engedélyezni a mappafigyelő beállítást. A mappafigyelő automatikusan észleli a könyvtári mappák változásait. Kapcsolja be ezt a funkciót, ha az „Automatikus könyvtárfigyelés” nem működik a fájlrendszerén (például NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Futás minden {1} óra {0}-kor\",\n  \"MessageSearchResultsFor\": \"Keresési eredmények\",\n  \"MessageSelected\": \"{0} kiválasztva\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Sorozat sorrend nem tartalmazhat szóközt\",\n  \"MessageServerCouldNotBeReached\": \"A szervert nem lehet elérni\",\n  \"MessageSetChaptersFromTracksDescription\": \"Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként\",\n  \"MessageShareExpirationWillBe\": \"A lejárat: <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"{0} múlva jár le\",\n  \"MessageShareURLWillBe\": \"A megosztási URL <strong>{0}</strong> lesz\",\n  \"MessageStartPlaybackAtTime\": \"\\\"{0}\\\" lejátszásának kezdése {1} -tól?\",\n  \"MessageTaskAudioFileNotWritable\": \"A/Az „{0}” hangfájl nem írható\",\n  \"MessageTaskCanceledByUser\": \"Felhasználó törölte a feladatot\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"„{0}” epizód letöltése\",\n  \"MessageTaskEmbeddingMetadata\": \"Metaadatok beágyazása\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Metaadatok beágyazása a „{0}” hangoskönyvbe\",\n  \"MessageTaskEncodingM4b\": \"Kódolás M4B-ban\",\n  \"MessageTaskEncodingM4bDescription\": \"„{0}” hangoskönyv kódolása egyetlen m4b fájlba\",\n  \"MessageTaskFailed\": \"Sikertelen\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Nem sikerült a „{0}” hangfájl mentése\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Nem sikerült létrehozni a gyorsítótár könyvtárat\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Nem sikerült beágyazni a metaadatokat a „{0}” fájlba\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"A hangfájlok egyesítése nem sikerült\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Nem sikerült m4b fájlt áthelyezni\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Metaadatfájl írása sikertelen\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Könyvek egyeztetése a \\\"{0}\\\" könyvtárban\",\n  \"MessageTaskNoFilesToScan\": \"Nincs beolvasandó fájl\",\n  \"MessageTaskOpmlImport\": \"OPML import\",\n  \"MessageTaskOpmlImportDescription\": \"Podcastok létrehozása {0} RSS hírcsatornából\",\n  \"MessageTaskOpmlImportFeed\": \"OPML import hírcsatorna\",\n  \"MessageTaskOpmlImportFeedDescription\": \"RSS feed „{0}” importálása\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Nem sikerült letölteni a podcast feedet\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"„{0}” podcast létrehozása\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast már létezik az elérési útvonalon\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Nem sikerült podcastot létrehozni\",\n  \"MessageTaskOpmlImportFinished\": \"{0} podcast hozzáadva\",\n  \"MessageTaskOpmlParseFailed\": \"Az OPML fájl elemzése nem sikerült\",\n  \"MessageTaskOpmlParseFastFail\": \"Érvénytelen OPML fájl: <opml> tag nem található VAGY nem találtak <outline> taget\",\n  \"MessageTaskOpmlParseNoneFound\": \"Nem található feed az OPML fájlban\",\n  \"MessageTaskScanItemsAdded\": \"{0} hozzáadva\",\n  \"MessageTaskScanItemsMissing\": \"{0} hiányzik\",\n  \"MessageTaskScanItemsUpdated\": \"{0} frissítve\",\n  \"MessageTaskScanNoChangesNeeded\": \"Nincs szükség változtatásra\",\n  \"MessageTaskScanningFileChanges\": \"Fájlváltozások keresése a „{0}” fájlban\",\n  \"MessageTaskScanningLibrary\": \"„{0}” könyvtár beolvasása\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"A célkönyvtár nem írható\",\n  \"MessageThinking\": \"Gondolkodom...\",\n  \"MessageUploaderItemFailed\": \"A feltöltés sikertelen\",\n  \"MessageUploaderItemSuccess\": \"Sikeresen feltöltve!\",\n  \"MessageUploading\": \"Feltöltés...\",\n  \"MessageValidCronExpression\": \"Érvényes cron kifejezés\",\n  \"MessageWatcherIsDisabledGlobally\": \"A megfigyelő globálisan le van tiltva a szerver beállításokban\",\n  \"MessageXLibraryIsEmpty\": \"{0} könyvtár üres!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Az Ön hangoskönyvének hossza hosszabb, mint a talált időtartam\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Az Ön hangoskönyvének hossza rövidebb, mint a talált időtartam\",\n  \"NoteChangeRootPassword\": \"A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava\",\n  \"NoteChapterEditorTimes\": \"Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.\",\n  \"NoteFolderPicker\": \"Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS hírcsatorna URL-jában HTTPS-t használjon\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.\",\n  \"NoteUploaderUnsupportedFiles\": \"A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.\",\n  \"NotificationOnBackupCompletedDescription\": \"A biztonsági mentés befejezésekor aktiválódik\",\n  \"NotificationOnBackupFailedDescription\": \"A biztonsági mentés sikertelensége esetén aktiválódik\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Egy podcast epizód automatikus letöltésekor aktiválódik\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Akkor lép működésbe, ha az automatikus epizódletöltés a túl sok sikertelen próbálkozás miatt letiltásra kerül\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Akkor aktiválódik, ha az RSS feed kérés sikertelen az automatikus epizódletöltésnél\",\n  \"NotificationOnTestDescription\": \"Esemény az értesítési rendszer teszteléséhez\",\n  \"PlaceholderBulkChapterInput\": \"Írja be a fejezet címét vagy használjon számozást (pl. „1. epizód”, „10. fejezet”, „1.”)\",\n  \"PlaceholderNewCollection\": \"Új gyűjtemény neve\",\n  \"PlaceholderNewFolderPath\": \"Új mappa útvonala\",\n  \"PlaceholderNewPlaylist\": \"Új lejátszási lista neve\",\n  \"PlaceholderSearch\": \"Keresés..\",\n  \"PlaceholderSearchEpisode\": \"Epizód keresése..\",\n  \"StatsAuthorsAdded\": \"szerző hozzáadva\",\n  \"StatsBooksAdded\": \"könyv hozzáadva\",\n  \"StatsBooksAdditional\": \"Néhány kiegészítés…\",\n  \"StatsBooksFinished\": \"könyv befejezve\",\n  \"StatsBooksFinishedThisYear\": \"Néhány idén befejezett könyv…\",\n  \"StatsBooksListenedTo\": \"hallgatott könyv\",\n  \"StatsCollectionGrewTo\": \"Könyvgyűjtemény nőtt…\",\n  \"StatsSessions\": \"munkamenet\",\n  \"StatsSpentListening\": \"hallgatással töltött idő\",\n  \"StatsTopAuthor\": \"TOP SZERZŐ\",\n  \"StatsTopAuthors\": \"TOP SZERZŐ\",\n  \"StatsTopGenre\": \"TOP MŰFAJ\",\n  \"StatsTopGenres\": \"TOP MŰFAJ\",\n  \"StatsTopMonth\": \"TOP HÓNAP\",\n  \"StatsTopNarrator\": \"TOP ELŐADÓ\",\n  \"StatsTopNarrators\": \"TOP ELŐADÓ\",\n  \"StatsTotalDuration\": \"A teljes időtartam…\",\n  \"StatsYearInReview\": \"ÉVVISSZATEKINTÉS\",\n  \"ToastAccountUpdateSuccess\": \"Fiók frissítve\",\n  \"ToastAppriseUrlRequired\": \"Meg kell adnia egy Apprise URL-címet\",\n  \"ToastAsinRequired\": \"ASIN kötelező\",\n  \"ToastAuthorImageRemoveSuccess\": \"Szerző képe eltávolítva\",\n  \"ToastAuthorNotFound\": \"A szerző „{0}” nem található\",\n  \"ToastAuthorRemoveSuccess\": \"Szerző eltávolítva\",\n  \"ToastAuthorSearchNotFound\": \"Szerző nem található\",\n  \"ToastAuthorUpdateMerged\": \"Szerző összevonva\",\n  \"ToastAuthorUpdateSuccess\": \"Szerző frissítve\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Szerző frissítve (nem található kép)\",\n  \"ToastBackupAppliedSuccess\": \"Biztonsági mentés alkalmazva\",\n  \"ToastBackupCreateFailed\": \"A biztonsági mentés létrehozása sikertelen\",\n  \"ToastBackupCreateSuccess\": \"Biztonsági mentés létrehozva\",\n  \"ToastBackupDeleteFailed\": \"A biztonsági mentés törlése sikertelen\",\n  \"ToastBackupDeleteSuccess\": \"Biztonsági mentés törölve\",\n  \"ToastBackupInvalidMaxKeep\": \"A megőrzendő biztonsági másolatok száma érvénytelen\",\n  \"ToastBackupInvalidMaxSize\": \"Érvénytelen maximális mentésméret\",\n  \"ToastBackupRestoreFailed\": \"A biztonsági mentés visszaállítása sikertelen\",\n  \"ToastBackupUploadFailed\": \"A biztonsági mentés feltöltése sikertelen\",\n  \"ToastBackupUploadSuccess\": \"Biztonsági mentés feltöltve\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Tételekre alkalmazott részletek\",\n  \"ToastBatchDeleteFailed\": \"A tömeges törlés nem sikerült\",\n  \"ToastBatchDeleteSuccess\": \"Sikeres tömeges törlés\",\n  \"ToastBatchQuickMatchFailed\": \"Tömeges Gyors Egyeztetés sikertelen!\",\n  \"ToastBatchQuickMatchStarted\": \"{0} könyv Tömeges Gyors Egyeztetése elkezdődött!\",\n  \"ToastBatchUpdateFailed\": \"Kötegelt frissítés sikertelen\",\n  \"ToastBatchUpdateSuccess\": \"Kötegelt frissítés sikeres\",\n  \"ToastBookmarkCreateFailed\": \"Könyvjelző létrehozása sikertelen\",\n  \"ToastBookmarkCreateSuccess\": \"Könyvjelző hozzáadva\",\n  \"ToastBookmarkRemoveSuccess\": \"Könyvjelző eltávolítva\",\n  \"ToastBulkChapterInvalidCount\": \"Írjon be egy számot 1 és 150 között\",\n  \"ToastCachePurgeFailed\": \"A gyorsítótár törlése sikertelen\",\n  \"ToastCachePurgeSuccess\": \"A gyorsítótár sikeresen törölve\",\n  \"ToastChapterLocked\": \"A fejezet zárolt.\",\n  \"ToastChapterStartTimeAdjusted\": \"A fejezet kezdési ideje {0} másodperccel módosítva\",\n  \"ToastChaptersAllLocked\": \"Minden fejezet zárolt. Nyisson meg néhány fejezetet, hogy módosítsa azok idejét.\",\n  \"ToastChaptersHaveErrors\": \"A fejezetek hibákat tartalmaznak\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Érvénytelen eltolási érték. Az utolsó fejezet kezdési időpontja túlnyúlna a hangoskönyv időtartamán.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Érvénytelen eltolási érték. Az első fejezet hossza nulla vagy negatív lenne, és a második fejezet felülírná. Növelje a második fejezet kezdő időtartamát.\",\n  \"ToastChaptersMustHaveTitles\": \"A fejezeteknek címekkel kell rendelkezniük\",\n  \"ToastChaptersRemoved\": \"Fejezetek eltávolítva\",\n  \"ToastChaptersUpdated\": \"Fejezetek frissítve\",\n  \"ToastCollectionItemsAddFailed\": \"A tétel(ek) hozzáadása gyűjteményhez sikertelen\",\n  \"ToastCollectionRemoveSuccess\": \"Gyűjtemény eltávolítva\",\n  \"ToastCollectionUpdateSuccess\": \"Gyűjtemény frissítve\",\n  \"ToastConnectionNotAvailable\": \"A kapcsolat nem elérhető. Kérem, próbálkozzon később\",\n  \"ToastCoverSearchFailed\": \"A borítók keresése sikertelen\",\n  \"ToastCoverUpdateFailed\": \"A borító frissítése nem sikerült\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"A dátum és az időpont érvénytelen vagy hiányos\",\n  \"ToastDeleteFileFailed\": \"Nem sikerült törölni a fájlt\",\n  \"ToastDeleteFileSuccess\": \"Fájl törölve\",\n  \"ToastDeviceAddFailed\": \"Nem sikerült eszközt hozzáadni\",\n  \"ToastDeviceNameAlreadyExists\": \"Ilyen nevű olvasóeszköz már létezik\",\n  \"ToastDeviceTestEmailFailed\": \"Teszt email küldése sikertelen\",\n  \"ToastDeviceTestEmailSuccess\": \"Teszt email elküldve\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Email beállítások frissítve\",\n  \"ToastEncodeCancelFailed\": \"A kódolás törlése sikertelen volt\",\n  \"ToastEncodeCancelSucces\": \"Kódolás törölve\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Nem sikerült törölni a várólistát\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Epizód letöltési várólista törölve\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} epizód frissítve\",\n  \"ToastErrorCannotShare\": \"Ezen az eszközön nem lehet natívan megosztani\",\n  \"ToastFailedToCreate\": \"Sikertelen létrehozás\",\n  \"ToastFailedToDelete\": \"Sikertelen törlés\",\n  \"ToastFailedToLoadData\": \"Sikertelen adatbetöltés\",\n  \"ToastFailedToMatch\": \"Nem sikerült egyezőséget találni\",\n  \"ToastFailedToShare\": \"Nem sikerült megosztani\",\n  \"ToastFailedToUpdate\": \"Nem sikerült frissíteni\",\n  \"ToastInvalidImageUrl\": \"Érvénytelen a kép URL címe\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"A letölthető epizódok száma érvénytelen\",\n  \"ToastInvalidUrl\": \"Érvénytelen URL\",\n  \"ToastInvalidUrls\": \"Egy vagy több URL érvénytelen\",\n  \"ToastItemCoverUpdateSuccess\": \"Elem borítója frissítve\",\n  \"ToastItemDeletedFailed\": \"Nem sikerült törölni az elemet\",\n  \"ToastItemDeletedSuccess\": \"Elem törölve\",\n  \"ToastItemDetailsUpdateSuccess\": \"Elem részletei frissítve\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Megjelölés Befejezettként sikertelen\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Elem megjelölve Befejezettként\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Az elem befejezetlennek jelölése sikertelen\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Elem megjelölve Nem Befejezettként\",\n  \"ToastItemUpdateSuccess\": \"Elem frissítve\",\n  \"ToastLibraryCreateFailed\": \"Könyvtár létrehozása sikertelen\",\n  \"ToastLibraryCreateSuccess\": \"\\\"{0}\\\" könyvtár létrehozva\",\n  \"ToastLibraryDeleteFailed\": \"Könyvtár törlése sikertelen\",\n  \"ToastLibraryDeleteSuccess\": \"Könyvtár törölve\",\n  \"ToastLibraryScanFailedToStart\": \"A beolvasás elindítása sikertelen\",\n  \"ToastLibraryScanStarted\": \"Könyvtár beolvasása elindítva\",\n  \"ToastLibraryUpdateSuccess\": \"\\\"{0}\\\" könyvtár frissítve\",\n  \"ToastMatchAllAuthorsFailed\": \"Nem sikerült az összes szerzőt azonosítani\",\n  \"ToastMetadataFilesRemovedError\": \"Hiba a metaadatok eltávolításakor.{0} fájl\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Nincsenek metaadatok.{0} fájl a könyvtárban\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Nincsenek metaadatok.{0} fájl eltávolítva\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metaadat.{1} fájl eltávolítva\",\n  \"ToastMustHaveAtLeastOnePath\": \"Legalább egy elérési útvonalnak kell lennie\",\n  \"ToastNameEmailRequired\": \"Név és e-mail cím megadása kötelező\",\n  \"ToastNameRequired\": \"A név megadása kötelező\",\n  \"ToastNewApiKeyUserError\": \"Ki kell választani egy felhasználót\",\n  \"ToastNewEpisodesFound\": \"{0} új epizód\",\n  \"ToastNewUserCreatedFailed\": \"Nem sikerült a fiókot létrehozni: „{0}”\",\n  \"ToastNewUserCreatedSuccess\": \"Új fiók létrehozva\",\n  \"ToastNewUserLibraryError\": \"Legalább egy könyvtárat ki kell választani\",\n  \"ToastNewUserPasswordError\": \"Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava\",\n  \"ToastNewUserTagError\": \"Legalább egy címkét ki kell választania\",\n  \"ToastNewUserUsernameError\": \"Adjon meg egy felhasználónevet\",\n  \"ToastNoNewEpisodesFound\": \"Nincs új epizód\",\n  \"ToastNoRSSFeed\": \"A podcastnak nincs RSS hírcsatornája\",\n  \"ToastNoUpdatesNecessary\": \"Nincs szükség frissítésre\",\n  \"ToastNotificationCreateFailed\": \"Értesítés létrehozása sikertelen\",\n  \"ToastNotificationDeleteFailed\": \"Értesítés törlése sikertelen\",\n  \"ToastNotificationFailedMaximum\": \"A sikertelen kísérletek maximális száma >= 0 kell, hogy legyen\",\n  \"ToastNotificationQueueMaximum\": \"Az értesítési sor maximális száma >= 0 kell, hogy legyen\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Értesítési beállítások frissítve\",\n  \"ToastNotificationTestTriggerFailed\": \"Nem sikerült a tesztértesítést elindítani\",\n  \"ToastNotificationTestTriggerSuccess\": \"Kiváltott tesztértesítés\",\n  \"ToastNotificationUpdateSuccess\": \"Értesítés frissítve\",\n  \"ToastPlaylistCreateFailed\": \"Lejátszási lista létrehozása sikertelen\",\n  \"ToastPlaylistCreateSuccess\": \"Lejátszási lista létrehozva\",\n  \"ToastPlaylistRemoveSuccess\": \"Lejátszási lista eltávolítva\",\n  \"ToastPlaylistUpdateSuccess\": \"Lejátszási lista frissítve\",\n  \"ToastPodcastCreateFailed\": \"Podcast létrehozása sikertelen\",\n  \"ToastPodcastCreateSuccess\": \"A podcast sikeresen létrehozva\",\n  \"ToastPodcastEpisodeUpdated\": \"Epizód frissítve\",\n  \"ToastPodcastGetFeedFailed\": \"Nem sikerült podcast feedet kapni\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Nincsenek epizódok az RSS hírcsatornában\",\n  \"ToastPodcastNoRssFeed\": \"A podcastnak nincs RSS-hírcsatornája\",\n  \"ToastProgressIsNotBeingSynced\": \"Az előrehaladás nem szinkronizálódik, a lejátszás újraindul\",\n  \"ToastProviderCreatedFailed\": \"Hiba a szolgáltató hozzáadásakor\",\n  \"ToastProviderCreatedSuccess\": \"Új szolgáltató hozzáadva\",\n  \"ToastProviderNameAndUrlRequired\": \"Név és Url kötelező\",\n  \"ToastProviderRemoveSuccess\": \"Szolgáltató eltávolítva\",\n  \"ToastRSSFeedCloseFailed\": \"Az RSS hírcsatorna bezárása sikertelen\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS hírfolyam leállítva\",\n  \"ToastRemoveFailed\": \"Sikertelen eltávolítás\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Tétel eltávolítása a gyűjteményből sikertelen\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Tétel eltávolítva a gyűjteményből\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Nem sikerült eltávolítani a hibás könyvtárelemeket\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Hibás könyvtárelemek eltávolítva\",\n  \"ToastRenameFailed\": \"Sikertelen átnevezés\",\n  \"ToastRescanFailed\": \"Sikertelen újrakeresés a következőnél: {0}\",\n  \"ToastRescanRemoved\": \"A teljes újrabeolvasás befejezve, elem eltávolítva\",\n  \"ToastRescanUpToDate\": \"A teljes újrabeolvasás befejezve, elem naprakész volt\",\n  \"ToastRescanUpdated\": \"A teljes újrabeolvasás befejezve, elem frissítve\",\n  \"ToastScanFailed\": \"Nem sikerült beolvasni a könyvtárelemet\",\n  \"ToastSelectAtLeastOneUser\": \"Válasszon legalább egy felhasználót\",\n  \"ToastSendEbookToDeviceFailed\": \"E-könyv küldése az eszközre sikertelen\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-könyv elküldve az eszközre \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Nem lehet két azonos nevű sorozatot hozzáadni\",\n  \"ToastSeriesUpdateFailed\": \"Sorozat frissítése sikertelen\",\n  \"ToastSeriesUpdateSuccess\": \"Sorozat frissítése sikeres\",\n  \"ToastServerSettingsUpdateSuccess\": \"Szerver beállítások frissítve\",\n  \"ToastSessionCloseFailed\": \"A munkamenet bezárása sikertelen\",\n  \"ToastSessionDeleteFailed\": \"Munkamenet törlése sikertelen\",\n  \"ToastSessionDeleteSuccess\": \"Munkamenet törölve\",\n  \"ToastSleepTimerDone\": \"Alvásidőzítő kész... zZzzZZz\",\n  \"ToastSlugMustChange\": \"A Slug érvénytelen karaktereket tartalmaz\",\n  \"ToastSlugRequired\": \"Slug szükséges\",\n  \"ToastSocketConnected\": \"Socket csatlakoztatva\",\n  \"ToastSocketDisconnected\": \"Socket lecsatlakoztatva\",\n  \"ToastSocketFailedToConnect\": \"A Socket csatlakoztatása sikertelen\",\n  \"ToastSortingPrefixesEmptyError\": \"Legalább 1 rendezési előtaggal kell rendelkeznie\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Rendezési előtagok frissítése ({0} elem)\",\n  \"ToastTitleRequired\": \"A cím kötelező\",\n  \"ToastUnknownError\": \"Ismeretlen hiba\",\n  \"ToastUnlinkOpenIdFailed\": \"Nem sikerült leválasztani a felhasználót az OpenID-ről\",\n  \"ToastUnlinkOpenIdSuccess\": \"Felhasználó leválasztva az OpenID-ről\",\n  \"ToastUploaderFilepathExistsError\": \"A \\\"{0}\\\" fájl elérési útja már létezik a szerveren\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"A „{0}” elem a feltöltési útvonal egy alkönyvtárát használja.\",\n  \"ToastUserDeleteFailed\": \"Felhasználó törlése sikertelen\",\n  \"ToastUserDeleteSuccess\": \"Felhasználó törölve\",\n  \"ToastUserPasswordChangeSuccess\": \"Jelszó sikeresen megváltoztatva\",\n  \"ToastUserPasswordMismatch\": \"A jelszavak nem egyeznek\",\n  \"ToastUserPasswordMustChange\": \"Az új jelszó nem egyezik a régi jelszóval\",\n  \"ToastUserRootRequireName\": \"Egy root felhasználónevet kell megadnia\",\n  \"TooltipAddChapters\": \"Fejezet(ek) hozzáadása\",\n  \"TooltipAddOneSecond\": \"1 másodperc hozzáadása\",\n  \"TooltipAdjustChapterStart\": \"Kattintson a kezdési idő beállításához\",\n  \"TooltipLockAllChapters\": \"Az összes fejezet zárolása\",\n  \"TooltipLockChapter\": \"Fejezet zárolása (Shift+kattintás a tartományhoz)\",\n  \"TooltipSubtractOneSecond\": \"1 másodperc levonása\",\n  \"TooltipUnlockAllChapters\": \"Az összes fejezet feloldása\",\n  \"TooltipUnlockChapter\": \"Fejezet feloldása (Shift+kattintás a tartományhoz)\"\n}\n"
  },
  {
    "path": "client/strings/is.json",
    "content": "{}\n"
  },
  {
    "path": "client/strings/it.json",
    "content": "{\n  \"ButtonAdd\": \"Aggiungi\",\n  \"ButtonAddApiKey\": \"Aggiungi la chiave API\",\n  \"ButtonAddChapters\": \"Aggiungi Capitoli\",\n  \"ButtonAddDevice\": \"Aggiungi Dispositivo\",\n  \"ButtonAddLibrary\": \"Aggiungi Libreria\",\n  \"ButtonAddPodcasts\": \"Aggiungi Podcast\",\n  \"ButtonAddUser\": \"Aggiungi Utente\",\n  \"ButtonAddYourFirstLibrary\": \"Aggiungi la tua prima libreria\",\n  \"ButtonApply\": \"Applica\",\n  \"ButtonApplyChapters\": \"Applica Capitoli\",\n  \"ButtonAuthors\": \"Autori\",\n  \"ButtonBack\": \"Indietro\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Popola da esistente\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Inserisci i dettagli della mappa\",\n  \"ButtonBrowseForFolder\": \"Sfoglia per Cartella\",\n  \"ButtonCancel\": \"Annulla\",\n  \"ButtonCancelEncode\": \"Ferma la codifica\",\n  \"ButtonChangeRootPassword\": \"Cambia la Password di root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Controlla & scarica i nuovi episodi\",\n  \"ButtonChooseAFolder\": \"Seleziona la Cartella\",\n  \"ButtonChooseFiles\": \"Seleziona i File\",\n  \"ButtonClearFilter\": \"Elimina filtri\",\n  \"ButtonClose\": \"Chiudi\",\n  \"ButtonCloseFeed\": \"Chiudi flusso\",\n  \"ButtonCloseSession\": \"Chiudi la sessione aperta\",\n  \"ButtonCollections\": \"Raccolte\",\n  \"ButtonConfigureScanner\": \"Configura Scanner\",\n  \"ButtonCreate\": \"Crea\",\n  \"ButtonCreateBackup\": \"Crea un Backup\",\n  \"ButtonDelete\": \"Elimina\",\n  \"ButtonDownloadQueue\": \"Coda\",\n  \"ButtonEdit\": \"Modifica\",\n  \"ButtonEditChapters\": \"Modifica Capitoli\",\n  \"ButtonEditPodcast\": \"Modifica Podcast\",\n  \"ButtonEnable\": \"Abilita\",\n  \"ButtonFireAndFail\": \"Fire and Fail\",\n  \"ButtonFireOnTest\": \"Fire onTest event\",\n  \"ButtonForceReScan\": \"Forza Re-Scan\",\n  \"ButtonFullPath\": \"Percorso Completo\",\n  \"ButtonHide\": \"Nascondi\",\n  \"ButtonHome\": \"Pagina principale\",\n  \"ButtonIssues\": \"Errori\",\n  \"ButtonJumpBackward\": \"Salta indietro\",\n  \"ButtonJumpForward\": \"Salta Avanti\",\n  \"ButtonLatest\": \"Ultimi\",\n  \"ButtonLibrary\": \"Biblioteca\",\n  \"ButtonLogout\": \"Disconnetti\",\n  \"ButtonLookup\": \"Consulta\",\n  \"ButtonManageTracks\": \"Gestisci le Tracce\",\n  \"ButtonMapChapterTitles\": \"Titoli dei Capitoli\",\n  \"ButtonMatchAllAuthors\": \"Aggiungi metadata agli Autori\",\n  \"ButtonMatchBooks\": \"Aggiungi metadata della Libreria\",\n  \"ButtonNevermind\": \"Ingora\",\n  \"ButtonNext\": \"Prossimo\",\n  \"ButtonNextChapter\": \"Prossimo Capitolo\",\n  \"ButtonNextItemInQueue\": \"Elemento successivo in coda\",\n  \"ButtonOk\": \"D'accordo\",\n  \"ButtonOpenFeed\": \"Apri il flusso\",\n  \"ButtonOpenManager\": \"Apri Manager\",\n  \"ButtonPause\": \"Pausa\",\n  \"ButtonPlay\": \"Riproduci\",\n  \"ButtonPlayAll\": \"Riproduci tutto\",\n  \"ButtonPlaying\": \"In riproduzione\",\n  \"ButtonPlaylists\": \"Playlist\",\n  \"ButtonPrevious\": \"Precendente\",\n  \"ButtonPreviousChapter\": \"Capitolo Precendente\",\n  \"ButtonProbeAudioFile\": \"Analizza il file audio\",\n  \"ButtonPurgeAllCache\": \"Elimina tutta la Cache\",\n  \"ButtonPurgeItemsCache\": \"Elimina la Cache selezionata\",\n  \"ButtonQueueAddItem\": \"Aggiungi alla Coda\",\n  \"ButtonQueueRemoveItem\": \"Rimuovi dalla Coda\",\n  \"ButtonQuickEmbed\": \"Incorporazione Rapida\",\n  \"ButtonQuickEmbedMetadata\": \"Incorporamento rapido Metadati\",\n  \"ButtonQuickMatch\": \"Controlla Metadata Auto\",\n  \"ButtonReScan\": \"Ri-scansiona\",\n  \"ButtonRead\": \"Leggi\",\n  \"ButtonReadLess\": \"Riduci\",\n  \"ButtonReadMore\": \"Espandi\",\n  \"ButtonRefresh\": \"Aggiorna\",\n  \"ButtonRemove\": \"Rimuovi\",\n  \"ButtonRemoveAll\": \"Rimuovi Tutto\",\n  \"ButtonRemoveAllLibraryItems\": \"Rimuovi tutto il contenuto della libreria\",\n  \"ButtonRemoveFromContinueListening\": \"Rimuovi per proseguire l'ascolto\",\n  \"ButtonRemoveFromContinueReading\": \"Rimuovi per proseguire la lettura\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Rimuovi la Serie per Continuarla\",\n  \"ButtonReset\": \"Ripristina\",\n  \"ButtonResetToDefault\": \"Ripristino di default\",\n  \"ButtonRestore\": \"Ripristina\",\n  \"ButtonSave\": \"Salva\",\n  \"ButtonSaveAndClose\": \"Salva & Chiudi\",\n  \"ButtonSaveTracklist\": \"Salva Tracklist\",\n  \"ButtonScan\": \"Scansiona\",\n  \"ButtonScanLibrary\": \"Scansiona Libreria\",\n  \"ButtonScrollLeft\": \"Scorri verso sinistra\",\n  \"ButtonScrollRight\": \"Scorri verso destra\",\n  \"ButtonSearch\": \"Cerca\",\n  \"ButtonSelectFolderPath\": \"Seleziona percorso cartella\",\n  \"ButtonSeries\": \"Serie\",\n  \"ButtonSetChaptersFromTracks\": \"Impostare i capitoli dalle tracce\",\n  \"ButtonShare\": \"Condividi\",\n  \"ButtonShiftTimes\": \"Ricerca veloce\",\n  \"ButtonShow\": \"Mostra\",\n  \"ButtonStartM4BEncode\": \"Inizia la codifica del M4B\",\n  \"ButtonStartMetadataEmbed\": \"Inizia i metadati incorporati\",\n  \"ButtonStats\": \"Statistische\",\n  \"ButtonSubmit\": \"Invia\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Disattiva OpenID\",\n  \"ButtonUpload\": \"Carica\",\n  \"ButtonUploadBackup\": \"Carica il backup\",\n  \"ButtonUploadCover\": \"Carica una copertina\",\n  \"ButtonUploadOPMLFile\": \"Carica file OPML\",\n  \"ButtonUserDelete\": \"Elimina l'utente {0}\",\n  \"ButtonUserEdit\": \"Modifica l'utente {0}\",\n  \"ButtonViewAll\": \"Mostra tutto\",\n  \"ButtonYes\": \"Sì\",\n  \"ErrorUploadFetchMetadataAPI\": \"Errore durante il recupero metadati\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Impossibile recuperare i metadati: prova a modificate il titolo e/o l'autore\",\n  \"ErrorUploadLacksTitle\": \"Deve avere un titolo\",\n  \"HeaderAccount\": \"Account\",\n  \"HeaderAddCustomMetadataProvider\": \"Aggiungi fornitori di metadati personalizzati\",\n  \"HeaderAdvanced\": \"Avanzate\",\n  \"HeaderApiKeys\": \"Le chiavi API\",\n  \"HeaderAppriseNotificationSettings\": \"Apprendi le impostazioni di Notifica\",\n  \"HeaderAudioTracks\": \"Tracce audio\",\n  \"HeaderAudiobookTools\": \"Strumenti di gestione file audiolibri\",\n  \"HeaderAuthentication\": \"Authenticazione\",\n  \"HeaderBackups\": \"Backup\",\n  \"HeaderBulkChapterModal\": \"Aggiungere più capitoli\",\n  \"HeaderChangePassword\": \"Cambia la password\",\n  \"HeaderChapters\": \"Capitoli\",\n  \"HeaderChooseAFolder\": \"Seleziona la cartella\",\n  \"HeaderCollection\": \"Raccolta\",\n  \"HeaderCollectionItems\": \"Elementi della raccolta\",\n  \"HeaderCover\": \"Copertina\",\n  \"HeaderCurrentDownloads\": \"Scaricamenti correnti\",\n  \"HeaderCustomMessageOnLogin\": \"Messaggio personalizzato all'accesso\",\n  \"HeaderCustomMetadataProviders\": \"Metadata Providers Personalizzato\",\n  \"HeaderDetails\": \"Dettagli\",\n  \"HeaderDownloadQueue\": \"Download coda\",\n  \"HeaderEbookFiles\": \"File dei libri\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"Impostazioni e-mail\",\n  \"HeaderEpisodes\": \"Episodi\",\n  \"HeaderEreaderDevices\": \"Dispositivo Ereader\",\n  \"HeaderEreaderSettings\": \"Impostazioni lettore\",\n  \"HeaderFiles\": \"File\",\n  \"HeaderFindChapters\": \"Trova Capitoli\",\n  \"HeaderIgnoredFiles\": \"File Ignorati\",\n  \"HeaderItemFiles\": \"Files\",\n  \"HeaderItemMetadataUtils\": \"Utilità Metadata oggetti\",\n  \"HeaderLastListeningSession\": \"Ultima sessione di Ascolto\",\n  \"HeaderLatestEpisodes\": \"Ultimi Episodi\",\n  \"HeaderLibraries\": \"Biblioteche\",\n  \"HeaderLibraryFiles\": \"File della Libreria\",\n  \"HeaderLibraryStats\": \"Statistiche Libreria\",\n  \"HeaderListeningSessions\": \"Sessioni di Ascolto\",\n  \"HeaderListeningStats\": \"Statistiche di Ascolto\",\n  \"HeaderLogin\": \"Accesso\",\n  \"HeaderLogs\": \"Registri\",\n  \"HeaderManageGenres\": \"Gestisci Generi\",\n  \"HeaderManageTags\": \"Gestisci Tags\",\n  \"HeaderMapDetails\": \"Mappa Dettagli\",\n  \"HeaderMatch\": \"Trova Corrispondenza\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Priorità ordine Metadata\",\n  \"HeaderMetadataToEmbed\": \"Metadata da incorporare\",\n  \"HeaderNewAccount\": \"Nuovo Account\",\n  \"HeaderNewApiKey\": \"Nuova chiave API\",\n  \"HeaderNewLibrary\": \"Nuova Libreria\",\n  \"HeaderNotificationCreate\": \"Crea una notifica\",\n  \"HeaderNotificationUpdate\": \"Aggiornamento della notifica\",\n  \"HeaderNotifications\": \"Notifiche\",\n  \"HeaderOpenIDConnectAuthentication\": \"Autenticazione OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Apri sessioni di ascolto\",\n  \"HeaderOpenRSSFeed\": \"Apri il flusso RSS\",\n  \"HeaderOtherFiles\": \"Altri File\",\n  \"HeaderPasswordAuthentication\": \"Autenticazione della password\",\n  \"HeaderPermissions\": \"Permessi\",\n  \"HeaderPlayerQueue\": \"Coda Riproduzione\",\n  \"HeaderPlayerSettings\": \"Impostazioni Player\",\n  \"HeaderPlaylist\": \"Playlist\",\n  \"HeaderPlaylistItems\": \"Elementi della playlist\",\n  \"HeaderPodcastsToAdd\": \"Podcasts da Aggiungere\",\n  \"HeaderPresets\": \"Presets\",\n  \"HeaderPreviewCover\": \"Anteprima Cover\",\n  \"HeaderRSSFeedGeneral\": \"Dettagli RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS Feed è aperto\",\n  \"HeaderRSSFeeds\": \"Flussi RSS\",\n  \"HeaderRemoveEpisode\": \"Rimuovi Episodi\",\n  \"HeaderRemoveEpisodes\": \"Rimuovi {0} Episodi\",\n  \"HeaderSavedMediaProgress\": \"Progressi salvati\",\n  \"HeaderSchedule\": \"Schedula\",\n  \"HeaderScheduleEpisodeDownloads\": \"Imposta il download automatico degli episodi\",\n  \"HeaderScheduleLibraryScans\": \"Schedula la scansione della libreria\",\n  \"HeaderSession\": \"Sessione\",\n  \"HeaderSetBackupSchedule\": \"Imposta programmazione Backup\",\n  \"HeaderSettings\": \"Impostazioni\",\n  \"HeaderSettingsDisplay\": \"Visualizzazione\",\n  \"HeaderSettingsExperimental\": \"Opzioni Sperimentali\",\n  \"HeaderSettingsGeneral\": \"Generale\",\n  \"HeaderSettingsScanner\": \"Scanner\",\n  \"HeaderSettingsSecurity\": \"Sicurezza\",\n  \"HeaderSettingsWebClient\": \"Web Client\",\n  \"HeaderSleepTimer\": \"Sveglia\",\n  \"HeaderStatsLargestItems\": \"File pesanti\",\n  \"HeaderStatsLongestItems\": \"libri più lunghi (ore)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minuti ascoltati (Ultimi 7 Giorni)\",\n  \"HeaderStatsRecentSessions\": \"Sessioni Recenti\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autori\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Generi\",\n  \"HeaderTableOfContents\": \"Indice\",\n  \"HeaderTools\": \"Strumenti\",\n  \"HeaderUpdateAccount\": \"Aggiorna Account\",\n  \"HeaderUpdateApiKey\": \"Aggiornamento chiave API\",\n  \"HeaderUpdateAuthor\": \"Aggiorna Autore\",\n  \"HeaderUpdateDetails\": \"Aggiorna Dettagli\",\n  \"HeaderUpdateLibrary\": \"Aggiorna Libreria\",\n  \"HeaderUsers\": \"Utenti\",\n  \"HeaderYearReview\": \"Anno {0} in Sintesi\",\n  \"HeaderYourStats\": \"Statistiche personali\",\n  \"LabelAbridged\": \"Abbreviato\",\n  \"LabelAbridgedChecked\": \"Ridotto (selezionato)\",\n  \"LabelAbridgedUnchecked\": \"Integrale (non selezionato)\",\n  \"LabelAccessibleBy\": \"Accessibile da\",\n  \"LabelAccountType\": \"Tipo di Account\",\n  \"LabelAccountTypeAdmin\": \"Amministratore\",\n  \"LabelAccountTypeGuest\": \"Ospite\",\n  \"LabelAccountTypeUser\": \"Utente\",\n  \"LabelActivities\": \"Attività\",\n  \"LabelActivity\": \"Attività\",\n  \"LabelAddToCollection\": \"Aggiungi alla Raccolta\",\n  \"LabelAddToCollectionBatch\": \"Aggiungi {0} Libri alla Raccolta\",\n  \"LabelAddToPlaylist\": \"Aggiungi alla playlist\",\n  \"LabelAddToPlaylistBatch\": \"Aggiungi {0} file alla Playlist\",\n  \"LabelAddedAt\": \"Aggiunto il\",\n  \"LabelAddedDate\": \"Aggiunti {0}\",\n  \"LabelAdminUsersOnly\": \"Solo utenti Amministratori\",\n  \"LabelAll\": \"Tutti\",\n  \"LabelAllEpisodesDownloaded\": \"Tutti gli Episodi Scaricati\",\n  \"LabelAllUsers\": \"Tutti gli Utenti\",\n  \"LabelAllUsersExcludingGuests\": \"Tutti gli Utenti Esclusi gli ospiti\",\n  \"LabelAllUsersIncludingGuests\": \"Tutti gli Utenti Inclusi gli ospiti\",\n  \"LabelAlreadyInYourLibrary\": \"Già esistente nella libreria\",\n  \"LabelApiKeyCreated\": \"API Key \\\"{0}\\\" creato con successo.\",\n  \"LabelApiKeyCreatedDescription\": \"Assicurarsi di copiare la chiave API ora poiché non si potrà rivederla.\",\n  \"LabelApiKeyUser\": \"Agisce per conto dell'utente\",\n  \"LabelApiKeyUserDescription\": \"Questa chiave API avrà le stesse autorizzazioni dell'utente per conto del quale agisce. Apparirà nei registri come se l'utente stesse facendo la richiesta.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Appese\",\n  \"LabelAudioBitrate\": \"Audio Bitrate (es. 128k)\",\n  \"LabelAudioChannels\": \"Canali Audio (1 o 2)\",\n  \"LabelAudioCodec\": \"Codec Audio\",\n  \"LabelAuthor\": \"Autore\",\n  \"LabelAuthorFirstLast\": \"Autore (Per Nome)\",\n  \"LabelAuthorLastFirst\": \"Autori (Per Cognome)\",\n  \"LabelAuthors\": \"Autori\",\n  \"LabelAutoDownloadEpisodes\": \"Auto Download Episodi\",\n  \"LabelAutoFetchMetadata\": \"Auto controllo Metadata\",\n  \"LabelAutoFetchMetadataHelp\": \"Recupera i metadati per titolo, autore e serie per semplificare il caricamento. Potrebbe essere necessario abbinare metadati aggiuntivi dopo il caricamento.\",\n  \"LabelAutoLaunch\": \"Avvio Automatico\",\n  \"LabelAutoLaunchDescription\": \"Reindirizzamento automatico al provider di autenticazione quando si accede alla pagina di accesso (percorso di sostituzione manuale <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Auto Registrazione\",\n  \"LabelAutoRegisterDescription\": \"Crea automaticamente nuovi utenti dopo aver effettuato l'accesso\",\n  \"LabelBackToUser\": \"Torna a Utenti\",\n  \"LabelBackupAudioFiles\": \"Backup file Audio\",\n  \"LabelBackupLocation\": \"Percorso del Backup\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Backup Automatico\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"I Backup saranno salvati in /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Dimensione massima backup (in GB) (0 Illimitato)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Come protezione contro gli errori di config, i backup falliranno se superano la dimensione configurata.\",\n  \"LabelBackupsNumberToKeep\": \"Numero di backup da mantenere\",\n  \"LabelBackupsNumberToKeepHelp\": \"Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.\",\n  \"LabelBitrate\": \"Velocità di trasmissione\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Libri\",\n  \"LabelButtonText\": \"Buttone Testo\",\n  \"LabelByAuthor\": \"di {0}\",\n  \"LabelChangePassword\": \"Cambia Password\",\n  \"LabelChannels\": \"Canali\",\n  \"LabelChapterCount\": \"{0} Capitoli\",\n  \"LabelChapterTitle\": \"Titoli dei Capitoli\",\n  \"LabelChapters\": \"Capitoli\",\n  \"LabelChaptersFound\": \"Capitoli Trovati\",\n  \"LabelClickForMoreInfo\": \"Click per altre Info\",\n  \"LabelClickToUseCurrentValue\": \"Clicca per usare il valore corrente\",\n  \"LabelClosePlayer\": \"Chiudi player\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Comprimi Serie\",\n  \"LabelCollapseSubSeries\": \"Comprimi subserie\",\n  \"LabelCollection\": \"Raccolta\",\n  \"LabelCollections\": \"Raccolte\",\n  \"LabelComplete\": \"Completo\",\n  \"LabelConfirmPassword\": \"Conferma Password\",\n  \"LabelContinueListening\": \"Continua l'ascolto\",\n  \"LabelContinueReading\": \"Continua la lettura\",\n  \"LabelContinueSeries\": \"Continua serie\",\n  \"LabelCorsAllowed\": \"CORS consentiti Origine\",\n  \"LabelCover\": \"Copertina\",\n  \"LabelCoverImageURL\": \"Indirizzo della cover URL\",\n  \"LabelCoverProvider\": \"Cover Sorgente\",\n  \"LabelCreatedAt\": \"Creato A\",\n  \"LabelCronExpression\": \"Espressione Cron\",\n  \"LabelCurrent\": \"Attuale\",\n  \"LabelCurrently\": \"Attualmente:\",\n  \"LabelCustomCronExpression\": \"Espressione Cron personalizzata:\",\n  \"LabelDatetime\": \"Data & Ora\",\n  \"LabelDays\": \"Giorni\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Elimina dal file system (togli la spunta per eliminarla solo dal DB)\",\n  \"LabelDescription\": \"Descrizione\",\n  \"LabelDeselectAll\": \"Deseleziona Tutto\",\n  \"LabelDetectedPattern\": \"Trovato pattern:\",\n  \"LabelDevice\": \"Dispositivo\",\n  \"LabelDeviceInfo\": \"Info dispositivo\",\n  \"LabelDeviceIsAvailableTo\": \"Il dispositivo e disponibile su…\",\n  \"LabelDirectory\": \"Elenco\",\n  \"LabelDiscFromFilename\": \"Disco dal nome file\",\n  \"LabelDiscFromMetadata\": \"Disco dai metadati\",\n  \"LabelDiscover\": \"Scopri\",\n  \"LabelDownload\": \"Scarica\",\n  \"LabelDownloadNEpisodes\": \"Scarica {0} episodi\",\n  \"LabelDownloadable\": \"Scaricabile\",\n  \"LabelDuration\": \"Durata\",\n  \"LabelDurationComparisonExactMatch\": \"(corrispondenza esatta)\",\n  \"LabelDurationComparisonLonger\": \"({0} lungo)\",\n  \"LabelDurationComparisonShorter\": \"({0} corto)\",\n  \"LabelDurationFound\": \"Durata trovata:\",\n  \"LabelEbook\": \"Libro digitale\",\n  \"LabelEbooks\": \"Libri digitali\",\n  \"LabelEdit\": \"Modifica\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Indirizzo del mittente\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Rifiuta i certificati non autorizzati\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"La disattivazione della convalida del certificato SSL può esporre la tua connessione a rischi per la sicurezza, come attacchi man-in-the-middle. Disattiva questa opzione solo se ne comprendi le implicazioni e ti fidi del server di posta a cui ti stai connettendo.\",\n  \"LabelEmailSettingsSecure\": \"Sicuro\",\n  \"LabelEmailSettingsSecureHelp\": \"Se vero, la connessione utilizzerà TLS durante la connessione al server. Se false, viene utilizzato TLS se il server supporta l'estensione STARTTLS. Nella maggior parte dei casi impostare questo valore su true se ci si connette alla porta 465. Per la porta 587 o 25 mantenerlo false. (da nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Indirizzo di test\",\n  \"LabelEmbeddedCover\": \"Copertina integrata\",\n  \"LabelEnable\": \"Abilita\",\n  \"LabelEncodingBackupLocation\": \"Un backup dei file audio verrà archiviato in:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Negli audiolibri multitraccia i capitoli non sono incorporati.\",\n  \"LabelEncodingClearItemCache\": \"Assicurati di svuotare periodicamente la cache degli oggetti.\",\n  \"LabelEncodingFinishedM4B\": \"L'M4B completato verrà inserito nella cartella:\",\n  \"LabelEncodingInfoEmbedded\": \"I metadati verranno incorporati nelle tracce audio all'interno della cartella dell'audiolibro.\",\n  \"LabelEncodingStartedNavigation\": \"Una volta avviata l'attività, è possibile uscire da questa pagina.\",\n  \"LabelEncodingTimeWarning\": \"La codifica può richiedere fino a 30 minuti.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Attenzione: non aggiornare queste impostazioni se non hai familiarità con le opzioni di codifica ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Se hai disabilitato l'opzione Watcher, dovrai eseguire nuovamente la scansione dell'audiolibro in seguito.\",\n  \"LabelEnd\": \"Fine\",\n  \"LabelEndOfChapter\": \"Fine Capitolo\",\n  \"LabelEpisode\": \"Episodio\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episode non linkati nel RSS feed\",\n  \"LabelEpisodeNumber\": \"Episodio #{0}\",\n  \"LabelEpisodeTitle\": \"Titolo Episodio\",\n  \"LabelEpisodeType\": \"Tipo Episodio\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL dell'episodio dal RSS feed\",\n  \"LabelEpisodes\": \"Episodi\",\n  \"LabelEpisodic\": \"Episodico\",\n  \"LabelExample\": \"Esempio\",\n  \"LabelExpandSeries\": \"Espandi Serie\",\n  \"LabelExpandSubSeries\": \"Espandi Sub Serie\",\n  \"LabelExpired\": \"Scadenza\",\n  \"LabelExpiresAt\": \"Scade a\",\n  \"LabelExpiresInSeconds\": \"Scade in (secondi)\",\n  \"LabelExpiresNever\": \"Mai\",\n  \"LabelExplicit\": \"Esplicito\",\n  \"LabelExplicitChecked\": \"Esplicito (selezionato)\",\n  \"LabelExplicitUnchecked\": \"Non Esplicito (selezionato)\",\n  \"LabelExportOPML\": \"Esposta OPML\",\n  \"LabelFeedURL\": \"URL del flusso\",\n  \"LabelFetchingMetadata\": \"Recupero dei metadati\",\n  \"LabelFile\": \"File\",\n  \"LabelFileBirthtime\": \"Data di creazione\",\n  \"LabelFileBornDate\": \"Creato {0}\",\n  \"LabelFileModified\": \"Ultima modifica\",\n  \"LabelFileModifiedDate\": \"Modificato {0}\",\n  \"LabelFilename\": \"Nome del file\",\n  \"LabelFilterByUser\": \"Filtro per Utente\",\n  \"LabelFindEpisodes\": \"Trova Episodi\",\n  \"LabelFinished\": \"Finita\",\n  \"LabelFinishedDate\": \"Finito {0}\",\n  \"LabelFolder\": \"Cartella\",\n  \"LabelFolders\": \"Cartelle\",\n  \"LabelFontBold\": \"Grassetto\",\n  \"LabelFontBoldness\": \"Grassetto\",\n  \"LabelFontFamily\": \"Famiglia caratteri\",\n  \"LabelFontItalic\": \"Corsivo\",\n  \"LabelFontScale\": \"Dimensione font\",\n  \"LabelFontStrikethrough\": \"Barrato\",\n  \"LabelFormat\": \"Formato\",\n  \"LabelFull\": \"Pieno\",\n  \"LabelGenre\": \"Genere\",\n  \"LabelGenres\": \"Generi\",\n  \"LabelHardDeleteFile\": \"Elimina Definitivamente\",\n  \"LabelHasEbook\": \"Ha un libro\",\n  \"LabelHasSupplementaryEbook\": \"Ha un libro supplementale\",\n  \"LabelHideSubtitles\": \"Nascondi Sottotitoli\",\n  \"LabelHighestPriority\": \"Priorità Massima\",\n  \"LabelHost\": \"Host\",\n  \"LabelHour\": \"Ora\",\n  \"LabelHours\": \"Ore\",\n  \"LabelIcon\": \"Icona\",\n  \"LabelImageURLFromTheWeb\": \"Immagine URL da internet\",\n  \"LabelInProgress\": \"In corso\",\n  \"LabelIncludeInTracklist\": \"Includi nella Tracklist\",\n  \"LabelIncomplete\": \"Incompleta\",\n  \"LabelInterval\": \"Intervallo\",\n  \"LabelIntervalCustomDailyWeekly\": \"Personalizza giorni/settimane\",\n  \"LabelIntervalEvery12Hours\": \"Ogni 12 Ore\",\n  \"LabelIntervalEvery15Minutes\": \"Ogni 15 Minuti\",\n  \"LabelIntervalEvery2Hours\": \"Ogni 2 Ore\",\n  \"LabelIntervalEvery30Minutes\": \"Ogni 30 Minuti\",\n  \"LabelIntervalEvery6Hours\": \"Ogni 6 ore\",\n  \"LabelIntervalEveryDay\": \"Ogni Giorno\",\n  \"LabelIntervalEveryHour\": \"Ogni ora\",\n  \"LabelIntervalEveryMinute\": \"Ogni minuto\",\n  \"LabelInvert\": \"Inverti\",\n  \"LabelItem\": \"Oggetti\",\n  \"LabelJumpBackwardAmount\": \"secondi di avvolgimento\",\n  \"LabelJumpForwardAmount\": \"Secondi di Avvolgimento\",\n  \"LabelLanguage\": \"Lingua\",\n  \"LabelLanguageDefaultServer\": \"Lingua di Default\",\n  \"LabelLanguages\": \"Lingua\",\n  \"LabelLastBookAdded\": \"Ultimo Libro Aggiunto\",\n  \"LabelLastBookUpdated\": \"Ultimo Libro Aggiornato\",\n  \"LabelLastProgressDate\": \"Ultimi progressi: Si'\",\n  \"LabelLastSeen\": \"Ultimi Visti\",\n  \"LabelLastTime\": \"Ultima Volta\",\n  \"LabelLastUpdate\": \"Ultimo Aggiornamento\",\n  \"LabelLayout\": \"Disposizione\",\n  \"LabelLayoutSinglePage\": \"Pagina singola\",\n  \"LabelLayoutSplitPage\": \"Pagina divisa\",\n  \"LabelLess\": \"Meno\",\n  \"LabelLibrariesAccessibleToUser\": \"Biblioteche accessibili all'utente\",\n  \"LabelLibrary\": \"Biblioteca\",\n  \"LabelLibraryFilterSublistEmpty\": \"Nessuno {0}\",\n  \"LabelLibraryItem\": \"Elementi della biblioteca\",\n  \"LabelLibraryName\": \"Nome della biblioteca\",\n  \"LabelLibrarySortByProgress\": \"Progressi: Ultimi aggiornamenti\",\n  \"LabelLibrarySortByProgressFinished\": \"Progressi: Completati\",\n  \"LabelLibrarySortByProgressStarted\": \"Progressi: Iniziati\",\n  \"LabelLimit\": \"Limiti\",\n  \"LabelLineSpacing\": \"Interlinea\",\n  \"LabelListenAgain\": \"Ascolta ancora\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Allarme\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Cerca nuovi episodi dopo questa data\",\n  \"LabelLowestPriority\": \"Priorità Minima\",\n  \"LabelMatchConfidence\": \"Fiducia\",\n  \"LabelMatchExistingUsersBy\": \"Abbina gli utenti esistenti per\",\n  \"LabelMatchExistingUsersByDescription\": \"Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Max # di episodi da scaricare. Usa 0 per illimitati.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Massimo # di nuovi episodi da scaricare per il controllo\",\n  \"LabelMaxEpisodesToKeep\": \"Massimo # di episodi da tenere\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Il valore 0 non imposta alcun limite massimo. Dopo che un nuovo episodio è stato scaricato automaticamente, questo eliminerà l'episodio più vecchio se hai più di X episodi. Questo eliminerà solo 1 episodio per ogni nuovo download.\",\n  \"LabelMediaPlayer\": \"Media Player\",\n  \"LabelMediaType\": \"Tipo Media\",\n  \"LabelMetaTag\": \"Meta Tag\",\n  \"LabelMetaTags\": \"Meta Tags\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Le origini di metadati con priorità più alta sovrascriveranno le origini di metadati con priorità inferiore\",\n  \"LabelMetadataProvider\": \"Metadata Provider\",\n  \"LabelMinute\": \"Minuto\",\n  \"LabelMinutes\": \"Minuti\",\n  \"LabelMissing\": \"Mancante\",\n  \"LabelMissingEbook\": \"Non ha libri digitali\",\n  \"LabelMissingSupplementaryEbook\": \"Non ha un libro digitale supplementare\",\n  \"LabelMobileRedirectURIs\": \"URI di reindirizzamento mobile consentiti\",\n  \"LabelMobileRedirectURIsDescription\": \"Questa è una lista bianca di URI di reindirizzamento validi per le app mobili. Quello predefinito è <code>audiobookshelf://oauth</code>, che puoi rimuovere o integrare con URI aggiuntivi per l'integrazione di app di terze parti. Utilizzando un asterisco (<code>*</code>) poiché l'unica voce consente qualsiasi URI.\",\n  \"LabelMore\": \"Molto\",\n  \"LabelMoreInfo\": \"Più info\",\n  \"LabelName\": \"Nome\",\n  \"LabelNarrator\": \"Narratore\",\n  \"LabelNarrators\": \"Narratori\",\n  \"LabelNew\": \"Nuovo\",\n  \"LabelNewPassword\": \"Nuova Password\",\n  \"LabelNewestAuthors\": \"Nuovi autori\",\n  \"LabelNewestEpisodes\": \"Nuovi episodi\",\n  \"LabelNextBackupDate\": \"Data Prossimo Backup\",\n  \"LabelNextChapters\": \"I prossimi capitoli saranno:\",\n  \"LabelNextScheduledRun\": \"Data prossima esecuzione schedulata\",\n  \"LabelNoApiKeys\": \"Nessuna chiave API\",\n  \"LabelNoCustomMetadataProviders\": \"Nessun provider di metadati personalizzato\",\n  \"LabelNoEpisodesSelected\": \"Nessun Episodio Selezionato\",\n  \"LabelNotFinished\": \"Da completare\",\n  \"LabelNotStarted\": \"Non iniziato\",\n  \"LabelNotes\": \"Note\",\n  \"LabelNotificationAppriseURL\": \"Apprendi URL(s)\",\n  \"LabelNotificationAvailableVariables\": \"Variabili Selezionabili\",\n  \"LabelNotificationBodyTemplate\": \"Template del corpo messaggio\",\n  \"LabelNotificationEvent\": \"Notifiche Eventi\",\n  \"LabelNotificationTitleTemplate\": \"Template del titolo\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Numero massimo di tentativi falliti\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Le notifiche vengono disabilitate se falliscono molte volte\",\n  \"LabelNotificationsMaxQueueSize\": \"Coda Massima di notifiche eventi\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Le notifiche sono limitate per 1 al secondo, per evitare lo spamming le notifiche verrano ignorare se superano la coda.\",\n  \"LabelNumberOfBooks\": \"Numero di libri\",\n  \"LabelNumberOfChapters\": \"Numero di capitoli:\",\n  \"LabelNumberOfEpisodes\": \"Numero di episodi\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (<b>se configurato</b>). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come<code>falsa</code>. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:\",\n  \"LabelOpenIDClaims\": \"Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \\\"Utente\\\".\",\n  \"LabelOpenIDGroupClaimDescription\": \"Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come <code>gruppo</code>. <b>se configurato</b>, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \\\"admin\\\", \\\"utente\\\" o \\\"ospite\\\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.\",\n  \"LabelOpenRSSFeed\": \"Apri RSS Feed\",\n  \"LabelOverwrite\": \"Sovrascrivi\",\n  \"LabelPaginationPageXOfY\": \"Pagina {0} di {1}\",\n  \"LabelPassword\": \"Password\",\n  \"LabelPath\": \"Percorso\",\n  \"LabelPermanent\": \"Permanente\",\n  \"LabelPermissionsAccessAllLibraries\": \"Può accedere a tutte le librerie\",\n  \"LabelPermissionsAccessAllTags\": \"Può accedere a tutti i tag\",\n  \"LabelPermissionsAccessExplicitContent\": \"Può accedere a contenuti espliciti\",\n  \"LabelPermissionsCreateEreader\": \"Può creare un e-reader\",\n  \"LabelPermissionsDelete\": \"Può Cancellare\",\n  \"LabelPermissionsDownload\": \"Può Scaricare\",\n  \"LabelPermissionsUpdate\": \"Può Aggiornare\",\n  \"LabelPermissionsUpload\": \"Può caricare\",\n  \"LabelPersonalYearReview\": \"Il tuo anno in rassegna ({0})\",\n  \"LabelPhotoPathURL\": \"foto Path/URL\",\n  \"LabelPlayMethod\": \"Metodo di riproduzione\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Valore incremento/decremento velocità di riproduzione\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} di {1}\",\n  \"LabelPlaylists\": \"Playlist\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Area di ricerca podcast\",\n  \"LabelPodcastType\": \"Tipo di Podcast\",\n  \"LabelPodcasts\": \"Podcast\",\n  \"LabelPort\": \"Porta\",\n  \"LabelPrefixesToIgnore\": \"Suffissi da ignorare (specificando maiuscole e minuscole)\",\n  \"LabelPreventIndexing\": \"Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google\",\n  \"LabelPrimaryEbook\": \"Libro principale\",\n  \"LabelProgress\": \"Cominciati\",\n  \"LabelProvider\": \"Fornitore\",\n  \"LabelProviderAuthorizationValue\": \"Authorization Header Value\",\n  \"LabelPubDate\": \"Data di pubblicazione\",\n  \"LabelPublishYear\": \"Anno di pubblicazione\",\n  \"LabelPublishedDate\": \"Pubblicati {0}\",\n  \"LabelPublishedDecade\": \"Decennio di pubblicazione\",\n  \"LabelPublishedDecades\": \"Decenni di pubblicazione\",\n  \"LabelPublisher\": \"Editore\",\n  \"LabelPublishers\": \"Editori\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"E-mail del proprietario personalizzato\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nome del proprietario personalizzato\",\n  \"LabelRSSFeedOpen\": \"Feed RSS aperto\",\n  \"LabelRSSFeedPreventIndexing\": \"Impedisci l'indicizzazione\",\n  \"LabelRSSFeedSlug\": \"Parole chiave del flusso RSS\",\n  \"LabelRSSFeedURL\": \"URL del flusso RSS\",\n  \"LabelRandomly\": \"Casualmente\",\n  \"LabelReAddSeriesToContinueListening\": \"Aggiungi di nuovo la serie per continuare ad ascoltare\",\n  \"LabelRead\": \"Leggi\",\n  \"LabelReadAgain\": \"Leggi ancora\",\n  \"LabelReadEbookWithoutProgress\": \"Leggi il libro senza mantenere i progressi\",\n  \"LabelRecentSeries\": \"Serie recenti\",\n  \"LabelRecentlyAdded\": \"Aggiunti recentemente\",\n  \"LabelRecommended\": \"Raccomandati\",\n  \"LabelRedo\": \"Rifai\",\n  \"LabelRegion\": \"Regione\",\n  \"LabelReleaseDate\": \"Data Release\",\n  \"LabelRemoveAllMetadataAbs\": \"Remuovi tutti i metadata.abs files\",\n  \"LabelRemoveAllMetadataJson\": \"Rimuovi tutti i metadata.json files\",\n  \"LabelRemoveAudibleBranding\": \"Rimuovi l'intro e il termine Audible dai capitoli\",\n  \"LabelRemoveCover\": \"Rimuovi cover\",\n  \"LabelRemoveMetadataFile\": \"Rimuovi i file metadata nella cartella della libreria\",\n  \"LabelRemoveMetadataFileHelp\": \"Rimuovi tutti i file metadata.json e i file metadata.abs nelle tue {0} cartelle.\",\n  \"LabelRowsPerPage\": \"Righe per pagina\",\n  \"LabelSearchTerm\": \"Ricerca\",\n  \"LabelSearchTitle\": \"Cerca Titolo\",\n  \"LabelSearchTitleOrASIN\": \"Cerca titolo o ASIN\",\n  \"LabelSeason\": \"Stagione\",\n  \"LabelSeasonNumber\": \"Stagione #{0}\",\n  \"LabelSelectAll\": \"Seleziona tutto\",\n  \"LabelSelectAllEpisodes\": \"Seleziona tutti gli Episodi\",\n  \"LabelSelectEpisodesShowing\": \"Selezionati {0} episodi da visualizzare\",\n  \"LabelSelectUser\": \"Seleziona l'utente\",\n  \"LabelSelectUsers\": \"Selezione Utenti\",\n  \"LabelSendEbookToDevice\": \"Invia il libro a...\",\n  \"LabelSequence\": \"Sequenza\",\n  \"LabelSerial\": \"Seriale\",\n  \"LabelSeries\": \"Serie\",\n  \"LabelSeriesName\": \"Nome Serie\",\n  \"LabelSeriesProgress\": \"Cominciato\",\n  \"LabelServerLogLevel\": \"Server Log Level\",\n  \"LabelServerYearReview\": \"Anno del server in sintesi({0})\",\n  \"LabelSetEbookAsPrimary\": \"Imposta come primario\",\n  \"LabelSetEbookAsSupplementary\": \"Imposta come suplementare\",\n  \"LabelSettingsAllowIframe\": \"Consenti l'incorporamento in un iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Solo Audiolibri\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"L'abilitazione di questa impostazione ignorerà i file di libro digitale a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come libri digitali supplementari\",\n  \"LabelSettingsBookshelfViewHelp\": \"Design con scaffali in legno\",\n  \"LabelSettingsChromecastSupport\": \"Supporto a Chromecast\",\n  \"LabelSettingsDateFormat\": \"Formato Data\",\n  \"LabelSettingsEnableWatcher\": \"Controlla automaticamente le modifiche alle librerie\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Controlla automaticamente le modifiche alle librerie\",\n  \"LabelSettingsEnableWatcherHelp\": \"Abilita l'aggiunta/aggiornamento automatico degli elementi quando vengono rilevate modifiche ai file. *Richiede il riavvio del Server\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Consenti contenuti con script negli epub\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Consenti ai file epub di eseguire script. Si consiglia di mantenere questa impostazione disabilitata a meno che non si ritenga attendibile l'origine dei file epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Opzioni Sperimentali\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.\",\n  \"LabelSettingsFindCovers\": \"Trova covers\",\n  \"LabelSettingsFindCoversHelp\": \"Se il tuo audiolibro non ha una copertina incorporata o un'immagine di copertina all'interno della cartella, questa funzione tenterà di trovare una copertina.<br>Nota: aumenta il tempo di scansione\",\n  \"LabelSettingsHideSingleBookSeries\": \"Nascondi una singola serie di libri\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Home page con sfondo legno\",\n  \"LabelSettingsLibraryBookshelfView\": \"Libreria con sfondo legno\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"La percentuale di completamento è maggiore di\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Il tempo rimanente è inferiore a (secondi)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Contrassegna l'elemento multimediale come terminato quando\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Salta i libri precedenti nella serie Continua\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Lo scaffale della home page Continua serie mostra il primo libro non iniziato della serie che ha almeno un libro finito e nessun libro in corso. Abilitando questa impostazione le serie continueranno dal libro completato più lontano invece che dal primo libro non iniziato.\",\n  \"LabelSettingsParseSubtitles\": \"Analizza sottotitoli\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Estrai i sottotitoli dai nomi delle cartelle degli audiolibri. <br> I sottotitoli devono essere separati da \\\" - \\\"<br> Per esempio \\\"Il signore degli anelli - Le due Torri \\\" avrà il sottotitolo \\\"Le due Torri\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferisci i metadata trovati\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"I dati trovati in internet sovrascriveranno i dettagli del libro quando si utilizza quick Match. Per impostazione predefinita, Quick Match riempirà solo i dettagli mancanti.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Salta la ricerca dati in internet se è già presente un codice ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Salta la ricerca dati in internet se è già presente un codice ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignora i prefissi nei titoli durante l'aggiunta\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"Per prefisso si intende ad esempio \\\"il\\\" come nel libro \\\"Il signore degli anelli\\\" che verrebbe ordinato come \\\"signore degli anelli, il\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Utilizza le copertine quadrate\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Preferisci usare copertine quadrate rispetto a copertine di libri standard 1,6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Archivia le copertine con il file\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Archivia i metadata con il file\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria\",\n  \"LabelSettingsTimeFormat\": \"Formato Ora\",\n  \"LabelShare\": \"Condividi\",\n  \"LabelShareDownloadableHelp\": \"Consente agli utenti dotati del link di condivisione di scaricare un file zip dell'elemento della libreria.\",\n  \"LabelShareOpen\": \"Apri Condivisioni\",\n  \"LabelShareURL\": \"Condividi URL\",\n  \"LabelShowAll\": \"Mostra tutto\",\n  \"LabelShowSeconds\": \"Mostra i secondi\",\n  \"LabelShowSubtitles\": \"Mostra Sottotitoli\",\n  \"LabelSize\": \"Dimensione\",\n  \"LabelSleepTimer\": \"Temporizzatore\",\n  \"LabelSlug\": \"Lento\",\n  \"LabelSortAscending\": \"Crescente\",\n  \"LabelSortDescending\": \"Discendente\",\n  \"LabelSortPubDate\": \"Ordina per data di pubblicazione\",\n  \"LabelStart\": \"Inizo\",\n  \"LabelStartTime\": \"Tempo di inizio\",\n  \"LabelStarted\": \"Iniziato\",\n  \"LabelStartedAt\": \"Iniziato al\",\n  \"LabelStartedDate\": \"Iniziati {0}\",\n  \"LabelStatsAudioTracks\": \"Tracce Audio\",\n  \"LabelStatsAuthors\": \"Autori\",\n  \"LabelStatsBestDay\": \"Giorno migliore\",\n  \"LabelStatsDailyAverage\": \"Media giornaliera\",\n  \"LabelStatsDays\": \"Giorni\",\n  \"LabelStatsDaysListened\": \"Giorni di ascolto\",\n  \"LabelStatsHours\": \"Ore\",\n  \"LabelStatsInARow\": \"di fila\",\n  \"LabelStatsItemsFinished\": \"Libri completati\",\n  \"LabelStatsItemsInLibrary\": \"Libri in Libreria\",\n  \"LabelStatsMinutes\": \"minuti\",\n  \"LabelStatsMinutesListening\": \"Minuti di ascolto\",\n  \"LabelStatsOverallDays\": \"Giorni Complessivi\",\n  \"LabelStatsOverallHours\": \"Ore Complessive\",\n  \"LabelStatsWeekListening\": \"Ascolto settimanale\",\n  \"LabelSubtitle\": \"Sottotitoli\",\n  \"LabelSupportedFileTypes\": \"Tipi di file Supportati\",\n  \"LabelTag\": \"Etichetta\",\n  \"LabelTags\": \"Etichette\",\n  \"LabelTagsAccessibleToUser\": \"Tags permessi agli Utenti\",\n  \"LabelTagsNotAccessibleToUser\": \"Tags non accessibile agli Utenti\",\n  \"LabelTasks\": \"Processi in esecuzione\",\n  \"LabelTextEditorBulletedList\": \"Elenco puntato\",\n  \"LabelTextEditorLink\": \"Collegamento\",\n  \"LabelTextEditorNumberedList\": \"Elenco Numerato\",\n  \"LabelTextEditorUnlink\": \"Scollega\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Scuro\",\n  \"LabelThemeLight\": \"Chiaro\",\n  \"LabelThemeSepia\": \"Seppia\",\n  \"LabelTimeBase\": \"Tempo base\",\n  \"LabelTimeDurationXHours\": \"{0} Ore\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuti\",\n  \"LabelTimeDurationXSeconds\": \"{0} secondi\",\n  \"LabelTimeInMinutes\": \"Tempo in minuti\",\n  \"LabelTimeLeft\": \"{0} sinistra\",\n  \"LabelTimeListened\": \"Tempo di Ascolto\",\n  \"LabelTimeListenedToday\": \"Tempo di Ascolto Oggi\",\n  \"LabelTimeRemaining\": \"{0} rimanente\",\n  \"LabelTimeToShift\": \"Tempo di shift in secondi\",\n  \"LabelTitle\": \"Titolo\",\n  \"LabelToolsEmbedMetadata\": \"Incorpora Metadata\",\n  \"LabelToolsEmbedMetadataDescription\": \"Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.\",\n  \"LabelToolsM4bEncoder\": \"M4B Encoder\",\n  \"LabelToolsMakeM4b\": \"Crea un file M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.\",\n  \"LabelToolsSplitM4b\": \"Converti M4B in MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Crea MP3 da un M4B diviso per capitoli con metadati incorporati, immagine di copertina e capitoli.\",\n  \"LabelTotalDuration\": \"Durata Totale\",\n  \"LabelTotalTimeListened\": \"Tempo totale di Ascolto\",\n  \"LabelTrackFromFilename\": \"Traccia da nome file\",\n  \"LabelTrackFromMetadata\": \"Traccia da Metadata\",\n  \"LabelTracks\": \"Traccia\",\n  \"LabelTracksMultiTrack\": \"Multi-traccia\",\n  \"LabelTracksNone\": \"Nessuna traccia\",\n  \"LabelTracksSingleTrack\": \"Traccia-singola\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Tipo\",\n  \"LabelUnabridged\": \"Integrale\",\n  \"LabelUndo\": \"Annulla\",\n  \"LabelUnknown\": \"Sconosciuto\",\n  \"LabelUnknownPublishDate\": \"Data di pubblicazione sconosciuta\",\n  \"LabelUpdateCover\": \"Aggiornamento Cover\",\n  \"LabelUpdateCoverHelp\": \"Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza\",\n  \"LabelUpdateDetails\": \"Dettagli Aggiornamento\",\n  \"LabelUpdateDetailsHelp\": \"Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza\",\n  \"LabelUpdatedAt\": \"Aggiornato alle\",\n  \"LabelUploaderDragAndDrop\": \"Drag & drop file o Cartelle\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Drag & drop file\",\n  \"LabelUploaderDropFiles\": \"Elimina file\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Recupera automaticamente titolo, autore e serie\",\n  \"LabelUseAdvancedOptions\": \"Usa le opzioni avanzate\",\n  \"LabelUseChapterTrack\": \"Usa il Capitolo della Traccia\",\n  \"LabelUseFullTrack\": \"Usa la traccia totale\",\n  \"LabelUseZeroForUnlimited\": \"Usa 0 per illimitato\",\n  \"LabelUser\": \"Utente\",\n  \"LabelUsername\": \"Nome utente\",\n  \"LabelValue\": \"Valore\",\n  \"LabelVersion\": \"Versione\",\n  \"LabelViewBookmarks\": \"Visualizza i Segnalibri\",\n  \"LabelViewChapters\": \"Visualizza i Capitoli\",\n  \"LabelViewPlayerSettings\": \"Mostra Impostazioni player\",\n  \"LabelViewQueue\": \"Visualizza coda\",\n  \"LabelVolume\": \"Volume\",\n  \"LabelWebRedirectURLsDescription\": \"Autorizza questi URL nel tuo provider OAuth per consentire il reindirizzamento all'app Web dopo l'accesso:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Sottocartella per URL di reindirizzamento\",\n  \"LabelWeekdaysToRun\": \"Giorni feriali da eseguire\",\n  \"LabelXBooks\": \"{0} libri\",\n  \"LabelXItems\": \"{0} oggetti\",\n  \"LabelYearReviewHide\": \"Nascondi Anno in rassegna\",\n  \"LabelYearReviewShow\": \"Mostra Anno in rassegna\",\n  \"LabelYourAudiobookDuration\": \"La durata dell'audiolibro\",\n  \"LabelYourBookmarks\": \"I tuoi preferiti\",\n  \"LabelYourPlaylists\": \"le tue Playlist\",\n  \"LabelYourProgress\": \"Completato al\",\n  \"MessageAddToPlayerQueue\": \"Aggiungi alla coda di riproduzione\",\n  \"MessageAppriseDescription\": \"Per utilizzare questa funzione è necessario disporre di un'istanza di <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> in esecuzione o un'API che gestirà quelle stesse richieste. <br />L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .<code>http://192.168.1.1:8337</code> Allora dovrai mettere <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Assicurati di utilizzare l'ASIN della regione Audible corretta, non di Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"I token API legacy verranno rimossi in futuro. Utilizzare piuttosto le <a href=\\\"/config/api-keys\\\">chiavi API</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Riavvia il tuo server dopo aver salvato per applicare le modifiche OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"L'autenticazione è stata migliorata per incrementare la sicurezza. Tutti gli utenti sono tenuti a rieffettuare il login.\",\n  \"MessageBackupsDescription\": \"I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in <code>/metadata/items</code> & <code>/metadata/authors</code>. I backup non includono i file archiviati nelle cartelle della libreria.\",\n  \"MessageBackupsLocationEditNote\": \"Nota: l'aggiornamento della posizione di backup non sposterà o modificherà i backup esistenti\",\n  \"MessageBackupsLocationNoEditNote\": \"Nota: la posizione del backup viene impostata tramite una variabile di ambiente e non può essere modificata qui.\",\n  \"MessageBackupsLocationPathEmpty\": \"Il percorso del backup non può essere vuoto\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Popola i campi abilitati con i dati di tutti gli elementi. I campi con più valori verranno uniti\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Compila i campi dei dettagli della mappa abilitati con i dati di questo elemento\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.\",\n  \"MessageBookshelfNoCollections\": \"Non hai ancora creato nessuna raccolta\",\n  \"MessageBookshelfNoCollectionsHelp\": \"le collezioni sono pubbliche. Tutti gli utenti con accesso alla biblioteca possono vederle.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Nessun RSS feeds aperto\",\n  \"MessageBookshelfNoResultsForFilter\": \"Nessun risultato per il filtro \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Nessun risultato per la query\",\n  \"MessageBookshelfNoSeries\": \"Non c'è nessuna Serie\",\n  \"MessageBulkChapterPattern\": \"Quanti capitoli vuoi aggiungere con questo motivo di numerazione?\",\n  \"MessageChapterEndIsAfter\": \"La fine del capitolo è dopo la fine del tuo audiolibro\",\n  \"MessageChapterErrorFirstNotZero\": \"Il primo capitolo deve iniziare da 0\",\n  \"MessageChapterErrorStartGteDuration\": \"L'ora di inizio non valida deve essere inferiore alla durata dell'audiolibro\",\n  \"MessageChapterErrorStartLtPrev\": \"L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente\",\n  \"MessageChapterStartIsAfter\": \"L'inizio del capitolo è dopo la fine del tuo audiolibro\",\n  \"MessageChaptersNotFound\": \"Capitoli non trovati\",\n  \"MessageCheckingCron\": \"Controllo cron...\",\n  \"MessageConfirmCloseFeed\": \"Sei sicuro di voler chiudere questo feed?\",\n  \"MessageConfirmDeleteApiKey\": \"Sei sicuro di voler eliminare la chiave API \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Sei sicuro di voler eliminare il backup {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Sei sicuro/sicura di voler eliminare il lettore di libri {0}?\",\n  \"MessageConfirmDeleteFile\": \"Questo eliminerà il file dal tuo file system. Sei sicuro?\",\n  \"MessageConfirmDeleteLibrary\": \"Sei sicuro di voler eliminare definitivamente la libreria \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"l'elemento della libreria dal database e dal file system. Sei sicuro?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Ciò eliminerà {0} elementi della libreria dal database e dal file system. Sei sicuro?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Sei sicuro/sicura di voler eliminare il fornitore di metadati personalizzato {0}?\",\n  \"MessageConfirmDeleteNotification\": \"Sei sicuro/sicura di voler eliminare questa notifica?\",\n  \"MessageConfirmDeleteSession\": \"Sei sicuro di voler eliminare questa sessione?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Sei sicuro di voler incorporare i metadati nei file audio {0}?\",\n  \"MessageConfirmForceReScan\": \"Sei sicuro di voler forzare una nuova scansione?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Sei sicuro di voler contrassegnare tutti gli episodi come finiti?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Sei sicuro di voler contrassegnare tutti gli episodi come non completati?\",\n  \"MessageConfirmMarkItemFinished\": \"Sei sicuro/sicura di voler segnare {0} come finito?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Vuoi davvero segnare \\\"{0}\\\" come non finito?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Attivare questa notifica con dati di prova?\",\n  \"MessageConfirmPurgeCache\": \"L'eliminazione della cache eliminerà l'intera directory dei <code>/metadata/cache</code>. <br /><br />Sei sicuro di voler rimuovere la directory della cache?\",\n  \"MessageConfirmPurgeItemsCache\": \"L'eliminazione della cache degli elementi eliminerà l'intera directory <code>/metadata/cache/oggetti</code>.<br />Sei sicuro?\",\n  \"MessageConfirmQuickEmbed\": \"Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio. <br><br>Vuoi Continuare?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Gli episodi di corrispondenza rapida sovrascriveranno i dettagli se viene trovata una corrispondenza. Saranno aggiornati solo gli episodi non corrispondenti. Sei sicuro?\",\n  \"MessageConfirmReScanLibraryItems\": \"Sei sicuro di voler ripetere la scansione? {0} oggetti?\",\n  \"MessageConfirmRemoveAllChapters\": \"Sei sicuro di voler rimuovere tutti i capitoli?\",\n  \"MessageConfirmRemoveAuthor\": \"Sei sicuro di voler rimuovere l'autore? \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Sei sicuro di voler rimuovere la Raccolta \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Sei sicuro di voler rimuovere l'episodio \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Nota: Questo non cancella il file audio a meno che non toggling \\\"Hard delete file\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Sei sicuro di voler rimuovere {0} episodi?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Sei sicuro di voler rimuovere {0} sessioni di Ascolto?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Vuoi davvero rimuovere tutti i metadati.{0} file nelle cartelle degli elementi della tua libreria?\",\n  \"MessageConfirmRemoveNarrator\": \"Sei sicuro di voler rimuovere il narratore \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Sei sicuro di voler rimuovere la tua playlist \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Sei sicuro di voler rinominare il genere \\\"{0}\\\" in \\\"{1}\\\" per tutti gli oggetti?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Note: Questo genere esiste già quindi verra unito.\",\n  \"MessageConfirmRenameGenreWarning\": \"Avvertimento! Esiste già un genere simile con un nome simile \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Sei sicuro/sicura di voler rinominare l'etichetta \\\"{0}\\\" in \\\"{1}\\\" per tutti gli oggetti?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Nota: Questa etichetta esiste già e verrà unito nella vecchia.\",\n  \"MessageConfirmRenameTagWarning\": \"Avvertimento! Esiste già un tag simile con un nome simile \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Vuoi davvero azzerare i tuoi progressi?\",\n  \"MessageConfirmSendEbookToDevice\": \"Sei sicuro/sicura di voler inviare {0} libro «{1}» al dispositivo «{2}»?\",\n  \"MessageConfirmUnlinkOpenId\": \"Vuoi davvero scollegare questo utente da OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} giorni ascoltati nell'ultimo anno\",\n  \"MessageDownloadingEpisode\": \"Scaricamento dell’episodio in corso\",\n  \"MessageDragFilesIntoTrackOrder\": \"Trascina i file nell'ordine di traccia corretto\",\n  \"MessageEmbedFailed\": \"Incorporamento non riuscito!\",\n  \"MessageEmbedFinished\": \"Incorporamento finito!\",\n  \"MessageEmbedQueue\": \"In coda per l'incorporamento dei metadati ({0} in coda)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} episodio(i) in coda per lo scaricamento\",\n  \"MessageEreaderDevices\": \"Per garantire la consegna dei libri digitali, potrebbe essere necessario aggiungere l'indirizzo e-mail sopra indicato come mittente valido per ciascun dispositivo elencato di seguito.\",\n  \"MessageFeedURLWillBe\": \"l’URL del flusso sarà {0}\",\n  \"MessageFetching\": \"Recupero info…\",\n  \"MessageForceReScanDescription\": \"eseguirà nuovamente la scansione di tutti i file come una nuova scansione. I tag ID3 dei file audio, i file OPF e i file di testo verranno scansionati come nuovi.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} in ascolto</strong> su {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Nessuna sessione di ascolto su {0}\",\n  \"MessageImportantNotice\": \"Avviso Importante!\",\n  \"MessageInsertChapterBelow\": \"Inserisci capitolo sotto\",\n  \"MessageInvalidAsin\": \"ASIN non Valido\",\n  \"MessageItemsSelected\": \"{0} oggetti selezionati\",\n  \"MessageItemsUpdated\": \"{0} oggetti aggiornati\",\n  \"MessageJoinUsOn\": \"Unisciti a noi su\",\n  \"MessageLoading\": \"Caricamento…\",\n  \"MessageLoadingFolders\": \"Caricamento Cartelle...\",\n  \"MessageLogsDescription\": \"I log vengono archiviati nel percorso <code>/metadata/logs</code> as JSON files. I registri degli arresti anomali vengono archiviati nel percorso <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B Fallito!\",\n  \"MessageM4BFinished\": \"M4B Finito!\",\n  \"MessageMapChapterTitles\": \"Associa i titoli dei capitoli ai capitoli dell'audiolibro esistente senza modificare i timestamp\",\n  \"MessageMarkAllEpisodesFinished\": \"Segna tutti gli episodi come finiti\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Segna tutti gli episodi come non finiti\",\n  \"MessageMarkAsFinished\": \"Segna come finito\",\n  \"MessageMarkAsNotFinished\": \"Segna come da completare\",\n  \"MessageMatchBooksDescription\": \"tenterà di abbinare i libri nella biblioteca con un libro del provider di ricerca selezionato e inserirà i dettagli vuoti e la copertina. Non sovrascrive i dettagli.\",\n  \"MessageNoAudioTracks\": \"Nessuna Traccia Audio\",\n  \"MessageNoAuthors\": \"Nessun autore\",\n  \"MessageNoBackups\": \"Nessun Backup\",\n  \"MessageNoBookmarks\": \"Nessun preferito\",\n  \"MessageNoChapters\": \"Nessun capitolo\",\n  \"MessageNoCollections\": \"Nessuna Collezione\",\n  \"MessageNoCoversFound\": \"Nessuna Cover Trovata\",\n  \"MessageNoDescription\": \"Nessuna descrizione\",\n  \"MessageNoDevices\": \"nessun dispositivo\",\n  \"MessageNoDownloadsInProgress\": \"Nessun download attualmente in corso\",\n  \"MessageNoDownloadsQueued\": \"Nessuna coda di download\",\n  \"MessageNoEpisodeMatchesFound\": \"Nessun episodio corrispondente trovato\",\n  \"MessageNoEpisodes\": \"Nessun Episodio\",\n  \"MessageNoFoldersAvailable\": \"Nessuna Cartella disponibile\",\n  \"MessageNoGenres\": \"Nessun Genere\",\n  \"MessageNoIssues\": \"Nessun Errore\",\n  \"MessageNoItems\": \"Nessun oggetto\",\n  \"MessageNoItemsFound\": \"Nessun oggetto trovato\",\n  \"MessageNoListeningSessions\": \"Nessuna sessione di ascolto\",\n  \"MessageNoLogs\": \"Nessun Log\",\n  \"MessageNoMediaProgress\": \"Nessun progresso multimediale\",\n  \"MessageNoNotifications\": \"Nessuna notifica\",\n  \"MessageNoPodcastFeed\": \"Podcast non valido: nessun feed\",\n  \"MessageNoPodcastsFound\": \"Nessun podcast trovato\",\n  \"MessageNoResults\": \"Nessun Risultato\",\n  \"MessageNoSearchResultsFor\": \"Nessun risultato per \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Nessuna Serie\",\n  \"MessageNoTags\": \"Nessun Tags\",\n  \"MessageNoTasksRunning\": \"Nessun processo in esecuzione\",\n  \"MessageNoUpdatesWereNecessary\": \"Nessun aggiornamento necessario\",\n  \"MessageNoUserPlaylists\": \"non hai nessuna Playlist\",\n  \"MessageNoUserPlaylistsHelp\": \"Le playlist sono private. Solo l'utente che le crea può vederle.\",\n  \"MessageNotYetImplemented\": \"Non Ancora Implementato\",\n  \"MessageOpmlPreviewNote\": \"Nota: questa è un'anteprima del file OPML analizzato. Il titolo effettivo del podcast verrà preso dal feed RSS.\",\n  \"MessageOr\": \"o\",\n  \"MessagePauseChapter\": \"Metti in Pausa Capitolo\",\n  \"MessagePlayChapter\": \"Ascolta dall'inizio del capitolo\",\n  \"MessagePlaylistCreateFromCollection\": \"Crea playlist da una Raccolta\",\n  \"MessagePleaseWait\": \"Attendi...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast non ha l'URL del feed RSS da utilizzare per il match\",\n  \"MessagePodcastSearchField\": \"Inserisci il termine di ricerca o l'URL del feed RSS\",\n  \"MessageQuickEmbedInProgress\": \"Incorporamento rapido in corso\",\n  \"MessageQuickEmbedQueue\": \"In coda per incorporamento rapido ({0} in coda)\",\n  \"MessageQuickMatchAllEpisodes\": \"Associamento veloce di Tutti gli episodi\",\n  \"MessageQuickMatchDescription\": \"Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \\\"Preferisci metadati corrispondenti\\\".\",\n  \"MessageRemoveChapter\": \"Rimuovi Capitolo\",\n  \"MessageRemoveEpisodes\": \"rimuovi {0} episodio(i)\",\n  \"MessageRemoveFromPlayerQueue\": \"Rimuovi dalla coda di riproduzione\",\n  \"MessageRemoveUserWarning\": \"Sei sicuro di voler eliminare definitivamente l'utente \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Segnala errori, richiedi funzionalità e contribuisci\",\n  \"MessageResetChaptersConfirm\": \"Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?\",\n  \"MessageRestoreBackupConfirm\": \"Sei sicuro di voler ripristinare il backup creato su\",\n  \"MessageRestoreBackupWarning\": \"Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.\",\n  \"MessageScheduleLibraryScanNote\": \"Per la maggior parte degli utenti, si consiglia di lasciare questa funzione disabilitata e mantenere abilitata l'impostazione “Controlla automaticamente le modifiche nella libreria”: in questo modo verranno rilevate automaticamente le modifiche nelle cartelle della libreria. Abilita questa funzione se “Controlla automaticamente le modifiche nella libreria” non funziona con il tuo file system (come NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Esegui ogni {0} alle {1}\",\n  \"MessageSearchResultsFor\": \"cerca risultati per\",\n  \"MessageSelected\": \"{0} selezionati\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"La sequenza della serie non può contenere spazi\",\n  \"MessageServerCouldNotBeReached\": \"Impossibile raggiungere il server\",\n  \"MessageSetChaptersFromTracksDescription\": \"Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio\",\n  \"MessageShareExpirationWillBe\": \"Scadrà tra <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Scade in {0}\",\n  \"MessageShareURLWillBe\": \"L'indirizzo sarà: <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Avvia la riproduzione per \\\"{0}\\\" a {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Il file audio «{0}» non è scrivibile\",\n  \"MessageTaskCanceledByUser\": \"Attività annullata dall'utente\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Scaricamento dell'episodio «{0}»\",\n  \"MessageTaskEmbeddingMetadata\": \"Metadati integrati\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Integrazione dei metadati nell'audiolibro «{0}»\",\n  \"MessageTaskEncodingM4b\": \"Codifica M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Codifica dell'audiolibro «{0}» in un singolo file m4b\",\n  \"MessageTaskFailed\": \"Fallimento\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Non riuscita a eseguire il backup del file audio «{0}»\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Non riuscita a creare la cartella della cache\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Non ha inserito i metadati nel file «{0}»\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Non è riuscito a fondere i file audio\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Non è riuscito a spostare il file m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Non è riuscito a scrivere file di metadati\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Libri di corrispondenza in biblioteca «{0}»\",\n  \"MessageTaskNoFilesToScan\": \"Nessun file per la scansione\",\n  \"MessageTaskOpmlImport\": \"Importazione OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Creazione di podcast da {0} flusso RSS\",\n  \"MessageTaskOpmlImportFeed\": \"Flusso di importazione OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importazione del flusso RSS «{0}»\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Impossibile ottenere il flusso del podcast\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Creazione di podcast «{0}»\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Il podcast esiste già nel percorso\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Errore durante la creazione del podcast\",\n  \"MessageTaskOpmlImportFinished\": \"{0} podcast aggiunti\",\n  \"MessageTaskOpmlParseFailed\": \"Impossibile analizzare il file OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"File OPML non valido. Tag <opml> non trovato OPPURE non è stato trovato un tag <outline>\",\n  \"MessageTaskOpmlParseNoneFound\": \"Nessun feed trovato nel file OPML\",\n  \"MessageTaskScanItemsAdded\": \"{0} aggiunti\",\n  \"MessageTaskScanItemsMissing\": \"{0} mancanti\",\n  \"MessageTaskScanItemsUpdated\": \"{0} aggiornati\",\n  \"MessageTaskScanNoChangesNeeded\": \"Nessuna modifica necessaria\",\n  \"MessageTaskScanningFileChanges\": \"Cambiamenti di file di scansione in «{0}»\",\n  \"MessageTaskScanningLibrary\": \"Scansione della biblioteca «{0}»\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"La cartella di destinazione non è scrivibile\",\n  \"MessageThinking\": \"Elaborazione...\",\n  \"MessageUploaderItemFailed\": \"Caricamento Fallito\",\n  \"MessageUploaderItemSuccess\": \"Caricato con successo!\",\n  \"MessageUploading\": \"Caricamento...\",\n  \"MessageValidCronExpression\": \"Espressione Cron Valida\",\n  \"MessageWatcherIsDisabledGlobally\": \"Controllo file automatico è disabilitato a livello globale nelle impostazioni del server\",\n  \"MessageXLibraryIsEmpty\": \"{0} libreria vuota!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"La durata dell'audiolibro è più lunga della durata trovata\",\n  \"MessageYourAudiobookDurationIsShorter\": \"La durata dell'audiolibro è inferiore alla durata trovata\",\n  \"NoteChangeRootPassword\": \"L'utente root è l'unico utente che può avere una password vuota\",\n  \"NoteChapterEditorTimes\": \"Nota: l'ora di inizio del primo capitolo deve rimanere alle 0:00 e l'ora di inizio dell'ultimo capitolo non può superare la durata di questo audiolibro.\",\n  \"NoteFolderPicker\": \"Nota: le cartelle già mappate non verranno visualizzate\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Avviso: la maggior parte delle app di podcast richiede che l'URL del feed RSS utilizzi HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Avviso: 1 o più delle tue puntate non hanno una data di pubblicazione. Alcune app di podcast lo richiedono.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Le cartelle con file multimediali verranno gestite come elementi della libreria separati.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Se carichi solo file audio, ogni file audio verrà gestito come un audiolibro separato.\",\n  \"NoteUploaderUnsupportedFiles\": \"I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.\",\n  \"NotificationOnBackupCompletedDescription\": \"Attivato al completamento di un backup\",\n  \"NotificationOnBackupFailedDescription\": \"Attivato quando un backup fallisce\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Attivato quando un episodio di podcast viene scaricato automaticamente\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Attivato quando i download automatici degli episodi vengono disabilitati a causa di troppi tentativi falliti\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Attivato quando la richiesta del feed RSS per il download automatico di un episodio fallisce\",\n  \"NotificationOnTestDescription\": \"test il sistema di notifica\",\n  \"PlaceholderBulkChapterInput\": \"Inserire il titolo del capitolo o utilizzate la numerazione (es. 'Episodio 1', 'Capitolo 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Nome Nuova Raccolta\",\n  \"PlaceholderNewFolderPath\": \"Nuovo Percorso Cartella\",\n  \"PlaceholderNewPlaylist\": \"Nome nuova playlist\",\n  \"PlaceholderSearch\": \"Cerca..\",\n  \"PlaceholderSearchEpisode\": \"Cerca Episodio..\",\n  \"StatsAuthorsAdded\": \"autori aggiunti\",\n  \"StatsBooksAdded\": \"Libri aggiunti\",\n  \"StatsBooksAdditional\": \"Alcune aggiunte includono…\",\n  \"StatsBooksFinished\": \"Libri Finiti\",\n  \"StatsBooksFinishedThisYear\": \"Alcuni libri terminati quest'anno…\",\n  \"StatsBooksListenedTo\": \"Libri ascoltati\",\n  \"StatsCollectionGrewTo\": \"La tua collezione è aumentata di…\",\n  \"StatsSessions\": \"Sessioni\",\n  \"StatsSpentListening\": \"Tempo di Ascolto\",\n  \"StatsTopAuthor\": \"MIGLIOR AUTORE\",\n  \"StatsTopAuthors\": \"AUTORI MIGLIORI\",\n  \"StatsTopGenre\": \"MIGLIOR GENERE\",\n  \"StatsTopGenres\": \"GENERI MIGLIORI\",\n  \"StatsTopMonth\": \"MIGLIOR MESE\",\n  \"StatsTopNarrator\": \"MIGLIOR NARRATORE\",\n  \"StatsTopNarrators\": \"NARRATORI MIGLIORI\",\n  \"StatsTotalDuration\": \"Per una durata totale di…\",\n  \"StatsYearInReview\": \"ANNO IN RASSEGNA\",\n  \"ToastAccountUpdateSuccess\": \"Account Aggiornato\",\n  \"ToastAppriseUrlRequired\": \"È necessario immettere un indirizzo Apprise\",\n  \"ToastAsinRequired\": \"L'ASIN è obbligatorio\",\n  \"ToastAuthorImageRemoveSuccess\": \"Immagine Autore Rimossa\",\n  \"ToastAuthorNotFound\": \"Autore\\\"{0}\\\" non trovato\",\n  \"ToastAuthorRemoveSuccess\": \"Autore rimosso\",\n  \"ToastAuthorSearchNotFound\": \"Autore non trovato\",\n  \"ToastAuthorUpdateMerged\": \"Autore unito\",\n  \"ToastAuthorUpdateSuccess\": \"Autore aggiornato\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autore aggiornato (nessuna immagine trovata)\",\n  \"ToastBackupAppliedSuccess\": \"Backup applicato\",\n  \"ToastBackupCreateFailed\": \"creazione backup fallita\",\n  \"ToastBackupCreateSuccess\": \"Backup creato\",\n  \"ToastBackupDeleteFailed\": \"Eliminazione backup fallita\",\n  \"ToastBackupDeleteSuccess\": \"backup Eliminato\",\n  \"ToastBackupInvalidMaxKeep\": \"Numero non valido di backup da conservare\",\n  \"ToastBackupInvalidMaxSize\": \"Dimensione massima del backup non valida\",\n  \"ToastBackupRestoreFailed\": \"Ripristino fallito\",\n  \"ToastBackupUploadFailed\": \"Caricamento backup fallito\",\n  \"ToastBackupUploadSuccess\": \"Backup caricato\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Dettagli applicati agli articoli\",\n  \"ToastBatchDeleteFailed\": \"Eliminazione batch non riuscita\",\n  \"ToastBatchDeleteSuccess\": \"Eliminazione batch riuscita\",\n  \"ToastBatchQuickMatchFailed\": \"Batch Quick Match non riuscito!\",\n  \"ToastBatchQuickMatchStarted\": \"Avviata la ricerca rapida in batch di {0} libri!\",\n  \"ToastBatchUpdateFailed\": \"Batch di aggiornamento fallito\",\n  \"ToastBatchUpdateSuccess\": \"Batch di aggiornamento finito\",\n  \"ToastBookmarkCreateFailed\": \"Creazione segnalibro fallita\",\n  \"ToastBookmarkCreateSuccess\": \"Segnalibro creato\",\n  \"ToastBookmarkRemoveSuccess\": \"Segnalibro Rimosso\",\n  \"ToastBulkChapterInvalidCount\": \"Inserire un numero tra 1 e 150\",\n  \"ToastCachePurgeFailed\": \"Impossibile eliminare la cache\",\n  \"ToastCachePurgeSuccess\": \"Cache eliminata correttamente\",\n  \"ToastChapterLocked\": \"Il capitolo è bloccato.\",\n  \"ToastChapterStartTimeAdjusted\": \"Tempo di inizio del capitolo modificato di {0} secondi\",\n  \"ToastChaptersAllLocked\": \"Tutti i capitoli sono bloccati. Sblocca alcuni capitoli per modificarne i tempi.\",\n  \"ToastChaptersHaveErrors\": \"I capitoli contengono errori\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Quantità di spostamento non valida. L'orario di inizio dell'ultimo capitolo si estenderebbe oltre la durata di questo audiolibro.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Quantità di spostamento non valida. Il primo capitolo avrebbe una lunghezza pari a zero o negativa e verrebbe sovrascritto dal secondo capitolo. Aumentare la durata iniziale del secondo capitolo.\",\n  \"ToastChaptersMustHaveTitles\": \"I capitoli devono avere titoli\",\n  \"ToastChaptersRemoved\": \"Capitoli rimossi\",\n  \"ToastChaptersUpdated\": \"Capitoli aggiornati\",\n  \"ToastCollectionItemsAddFailed\": \"l'aggiunta dell'elemento(i) alla raccolta non è riuscito\",\n  \"ToastCollectionRemoveSuccess\": \"Collezione rimossa\",\n  \"ToastCollectionUpdateSuccess\": \"Raccolta aggiornata\",\n  \"ToastConnectionNotAvailable\": \"Connessione non disponibile. Provare più tardi\",\n  \"ToastCoverSearchFailed\": \"Ricerca Cover fallita\",\n  \"ToastCoverUpdateFailed\": \"Aggiornamento cover fallito\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Data e ora non sono valide o incomplete\",\n  \"ToastDeleteFileFailed\": \"Impossibile eliminare il file\",\n  \"ToastDeleteFileSuccess\": \"File eliminato\",\n  \"ToastDeviceAddFailed\": \"Aggiunta dispositivo fallita\",\n  \"ToastDeviceNameAlreadyExists\": \"Esiste già un dispositivo e-reader con quel nome\",\n  \"ToastDeviceTestEmailFailed\": \"Impossibile inviare l'e-mail di prova\",\n  \"ToastDeviceTestEmailSuccess\": \"Test invio mail completato\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Impostazioni e-mail aggiornate\",\n  \"ToastEncodeCancelFailed\": \"Impossibile annullare la codifica\",\n  \"ToastEncodeCancelSucces\": \"Codifica annullata\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Impossibile cancellare la coda\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Coda di download degli episodi cancellata\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} episodi aggiornati\",\n  \"ToastErrorCannotShare\": \"Impossibile condividere in modo nativo su questo dispositivo\",\n  \"ToastFailedToCreate\": \"Non creato\",\n  \"ToastFailedToDelete\": \"Non eliminata\",\n  \"ToastFailedToLoadData\": \"Impossibile caricare i dati\",\n  \"ToastFailedToMatch\": \"Impossibile abbinare\",\n  \"ToastFailedToShare\": \"Impossibile condividere\",\n  \"ToastFailedToUpdate\": \"Non aggiornato\",\n  \"ToastInvalidImageUrl\": \"URL dell'immagine non valido\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Numero massimo di episodi non valido da scaricare\",\n  \"ToastInvalidUrl\": \"URL non valido\",\n  \"ToastInvalidUrls\": \"Uno o più URL sono invalidi\",\n  \"ToastItemCoverUpdateSuccess\": \"Cover aggiornata\",\n  \"ToastItemDeletedFailed\": \"Impossibile eliminare l'elemento\",\n  \"ToastItemDeletedSuccess\": \"Elemento eliminato\",\n  \"ToastItemDetailsUpdateSuccess\": \"Dettagli file Aggiornata\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Errore nel segnare il file come finito\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"File segnato come finito\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Errore nel segnare il file come non completo\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"File segnato come non completo\",\n  \"ToastItemUpdateSuccess\": \"Articolo aggiornato\",\n  \"ToastLibraryCreateFailed\": \"Errore creazione libreria\",\n  \"ToastLibraryCreateSuccess\": \"Libreria \\\"{0}\\\" creata\",\n  \"ToastLibraryDeleteFailed\": \"Errore cancellazione libreria\",\n  \"ToastLibraryDeleteSuccess\": \"Libreria Cancellata\",\n  \"ToastLibraryScanFailedToStart\": \"Errore inizio scansione\",\n  \"ToastLibraryScanStarted\": \"Scansione Libreria iniziata\",\n  \"ToastLibraryUpdateSuccess\": \"Libreria \\\"{0}\\\" aggiornata\",\n  \"ToastMatchAllAuthorsFailed\": \"Tutti gli autori non sono potuti essere classificati\",\n  \"ToastMetadataFilesRemovedError\": \"Errore durante la rimozione dei metadati. {0} file\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Nessun metadato. {0} file trovati nella libreria\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Nessun metadato. {0} file rimossi\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadati.{1} file rimossi\",\n  \"ToastMustHaveAtLeastOnePath\": \"Deve avere almeno un percorso\",\n  \"ToastNameEmailRequired\": \"Nome ed email sono obbligatori\",\n  \"ToastNameRequired\": \"Il nome è obbligatorio\",\n  \"ToastNewApiKeyUserError\": \"Deve selezionare un utente\",\n  \"ToastNewEpisodesFound\": \"{0} nuovi episodi trovati\",\n  \"ToastNewUserCreatedFailed\": \"Impossibile creare l'account: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Nuovo account creato\",\n  \"ToastNewUserLibraryError\": \"È necessario selezionare almeno una libreria\",\n  \"ToastNewUserPasswordError\": \"Deve avere una password, solo l'utente root può avere una password vuota\",\n  \"ToastNewUserTagError\": \"Devi selezionare almeno un tag\",\n  \"ToastNewUserUsernameError\": \"Inserisci un nome utente\",\n  \"ToastNoNewEpisodesFound\": \"Nessun nuovo episodio trovato\",\n  \"ToastNoRSSFeed\": \"Il podcast non ha un feed RSS\",\n  \"ToastNoUpdatesNecessary\": \"Nessun aggiornamento necessario\",\n  \"ToastNotificationCreateFailed\": \"Impossibile creare la notifica\",\n  \"ToastNotificationDeleteFailed\": \"Impossibile eliminare la notifica\",\n  \"ToastNotificationFailedMaximum\": \"Il numero massimo di tentativi falliti deve essere >= 0\",\n  \"ToastNotificationQueueMaximum\": \"La coda di notifica massima deve essere >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Impostazioni di notifica aggiornate\",\n  \"ToastNotificationTestTriggerFailed\": \"Impossibile attivare la notifica del test\",\n  \"ToastNotificationTestTriggerSuccess\": \"Notifica di test attivata\",\n  \"ToastNotificationUpdateSuccess\": \"Notifica aggiornata\",\n  \"ToastPlaylistCreateFailed\": \"Errore creazione playlist\",\n  \"ToastPlaylistCreateSuccess\": \"Playlist creata\",\n  \"ToastPlaylistRemoveSuccess\": \"Playlist rimossa\",\n  \"ToastPlaylistUpdateSuccess\": \"Playlist Aggiornata\",\n  \"ToastPodcastCreateFailed\": \"Errore creazione podcast\",\n  \"ToastPodcastCreateSuccess\": \"Podcast creato correttamente\",\n  \"ToastPodcastEpisodeUpdated\": \"Episodio aggiornato\",\n  \"ToastPodcastGetFeedFailed\": \"Impossibile ottenere il feed del podcast\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Nessun episodio trovato nel feed RSS\",\n  \"ToastPodcastNoRssFeed\": \"Il podcast non ha un feed RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"L'avanzamento non è sincronizzato, riavviare la riproduzione\",\n  \"ToastProviderCreatedFailed\": \"Impossibile aggiungere il provider\",\n  \"ToastProviderCreatedSuccess\": \"Aggiunto nuovo provider\",\n  \"ToastProviderNameAndUrlRequired\": \"Nome e URL richiesti\",\n  \"ToastProviderRemoveSuccess\": \"Provider rimosso\",\n  \"ToastRSSFeedCloseFailed\": \"Errore chiusura flusso RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"Flusso RSS chiuso\",\n  \"ToastRemoveFailed\": \"Impossibile rimuovere\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Errore rimozione file dalla Raccolta\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Oggetto rimosso dalla Raccolta\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Impossibile rimuovere gli elementi della libreria con problemi\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Rimossi gli elementi della libreria con problemi\",\n  \"ToastRenameFailed\": \"Impossibile rinominare\",\n  \"ToastRescanFailed\": \"Nuova scansione non riuscita per {0}\",\n  \"ToastRescanRemoved\": \"L'articolo completo di Re-Scan è stato rimosso\",\n  \"ToastRescanUpToDate\": \"La nuova scansione dell'articolo completo è stata aggiornata\",\n  \"ToastRescanUpdated\": \"L'articolo completo di Re-Scan è stato aggiornato\",\n  \"ToastScanFailed\": \"Impossibile eseguire la scansione dell'elemento della libreria\",\n  \"ToastSelectAtLeastOneUser\": \"Seleziona almeno un utente\",\n  \"ToastSendEbookToDeviceFailed\": \"Impossibile inviare il libro al dispositivo\",\n  \"ToastSendEbookToDeviceSuccess\": \"Libro inviato al dispositivo «{0}»\",\n  \"ToastSeriesSubmitFailedSameName\": \"Non è possibile aggiungere due serie con lo stesso nome\",\n  \"ToastSeriesUpdateFailed\": \"Aggiornamento Serie Fallito\",\n  \"ToastSeriesUpdateSuccess\": \"Serie Aggiornate\",\n  \"ToastServerSettingsUpdateSuccess\": \"Impostazioni del server aggiornate\",\n  \"ToastSessionCloseFailed\": \"Disconnessione Fallita\",\n  \"ToastSessionDeleteFailed\": \"Errore eliminazione sessione\",\n  \"ToastSessionDeleteSuccess\": \"Sessione cancellata\",\n  \"ToastSleepTimerDone\": \"Timer di spegnimento eseguito... zZzzZz\",\n  \"ToastSlugMustChange\": \"Lo slug contiene caratteri non validi\",\n  \"ToastSlugRequired\": \"È richiesto lo slug\",\n  \"ToastSocketConnected\": \"Socket connesso\",\n  \"ToastSocketDisconnected\": \"Socket disconnesso\",\n  \"ToastSocketFailedToConnect\": \"Socket non riesce a connettersi\",\n  \"ToastSortingPrefixesEmptyError\": \"Deve avere almeno 1 prefisso di ordinamento\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Prefissi di ordinamento aggiornati ({0} items)\",\n  \"ToastTitleRequired\": \"Il titolo è obbligatorio\",\n  \"ToastUnknownError\": \"Errore sconosciuto\",\n  \"ToastUnlinkOpenIdFailed\": \"Impossibile scollegare l'utente da OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Utente scollegato da OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Il percorso file \\\"{0}\\\" esiste già sul server\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"L'elemento \\\"{0}\\\" utilizza una sottodirectory del percorso di caricamento.\",\n  \"ToastUserDeleteFailed\": \"Errore eliminazione utente\",\n  \"ToastUserDeleteSuccess\": \"Utente eliminato\",\n  \"ToastUserPasswordChangeSuccess\": \"Password modificata con successo\",\n  \"ToastUserPasswordMismatch\": \"Le password non corrispondono\",\n  \"ToastUserPasswordMustChange\": \"La nuova password non può corrispondere alla vecchia password\",\n  \"ToastUserRootRequireName\": \"È necessario immettere un nome utente root\",\n  \"TooltipAddChapters\": \"Aggiungere capitolo/i\",\n  \"TooltipAddOneSecond\": \"Aggiungere 1 secondo\",\n  \"TooltipAdjustChapterStart\": \"Clicca per modificare il tempo di inizio\",\n  \"TooltipLockAllChapters\": \"Bloccare tutti i capitoli\",\n  \"TooltipLockChapter\": \"Bloccare capitolo (Shift+click per intervallo)\",\n  \"TooltipSubtractOneSecond\": \"Sottrarre 1 secondo\",\n  \"TooltipUnlockAllChapters\": \"Sbloccare tutti i capitoli\",\n  \"TooltipUnlockChapter\": \"Sbloccare capitolo (Shift+click per intervallo)\"\n}\n"
  },
  {
    "path": "client/strings/ja.json",
    "content": "{\n  \"ButtonAdd\": \"追加\",\n  \"ButtonAddApiKey\": \"APIキーの追加\",\n  \"ButtonAddChapters\": \"チャプターの追加\",\n  \"ButtonAddDevice\": \"端末の追加\",\n  \"ButtonAddLibrary\": \"ライブラリーの追加\",\n  \"ButtonAddPodcasts\": \"ポッドキャストの追加\",\n  \"ButtonAddUser\": \"ユーザーの追加\",\n  \"ButtonAddYourFirstLibrary\": \"最初のライブラリーを追加\",\n  \"ButtonApply\": \"確定\",\n  \"ButtonApplyChapters\": \"チャプターを確定する\",\n  \"ButtonAuthors\": \"作者\",\n  \"ButtonBack\": \"戻る\",\n  \"ButtonBatchEditPopulateFromExisting\": \"既存のものから取り込む\",\n  \"ButtonBatchEditPopulateMapDetails\": \"チャプター情報を読み込む\",\n  \"ButtonBrowseForFolder\": \"フォルダーを選択する\",\n  \"ButtonCancel\": \"キャンセル\",\n  \"ButtonCancelEncode\": \"エンコードを取り消す\",\n  \"ButtonChangeRootPassword\": \"Rootのパスワードを変更する\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"新しいエピソードを確認してダウンロード\",\n  \"ButtonChooseAFolder\": \"フォルダーを選ぶ\",\n  \"ButtonChooseFiles\": \"ファイルを選ぶ\",\n  \"ButtonClearFilter\": \"絞り込みを解除\",\n  \"ButtonClose\": \"閉じる\",\n  \"ButtonCloseFeed\": \"フィードを閉じる\",\n  \"ButtonCloseSession\": \"開いているセッションを閉じる\",\n  \"ButtonCollections\": \"コレクション\",\n  \"ButtonConfigureScanner\": \"スキャナーの設定\",\n  \"ButtonCreate\": \"作成\",\n  \"ButtonCreateBackup\": \"バックアップを作成する\",\n  \"ButtonDelete\": \"削除\",\n  \"ButtonDownloadQueue\": \"次に再生\",\n  \"ButtonEdit\": \"編集\",\n  \"ButtonEditChapters\": \"チャプターの編集\",\n  \"ButtonEditPodcast\": \"ポッドキャストの編集\",\n  \"ButtonEnable\": \"オンにする\",\n  \"ButtonFireAndFail\": \"エラーを無視して実行\",\n  \"ButtonFireOnTest\": \"テストを実行\",\n  \"ButtonForceReScan\": \"強制的に再スキャンする\",\n  \"ButtonFullPath\": \"絶対パス\",\n  \"ButtonHide\": \"非表示\",\n  \"ButtonHome\": \"ホーム\",\n  \"ButtonIssues\": \"問題\",\n  \"ButtonJumpBackward\": \"巻き戻し\",\n  \"ButtonJumpForward\": \"早送り\",\n  \"ButtonLatest\": \"最新\",\n  \"ButtonLibrary\": \"ライブラリー\",\n  \"ButtonLogout\": \"ログアウト\",\n  \"ButtonLookup\": \"参照\",\n  \"ButtonManageTracks\": \"トラックの管理\",\n  \"ButtonMapChapterTitles\": \"チャプターのタイトルを割り当て\",\n  \"ButtonMatchAllAuthors\": \"すべての作者と紐付け\",\n  \"ButtonMatchBooks\": \"本と紐付け\",\n  \"ButtonNevermind\": \"中止\",\n  \"ButtonNext\": \"次\",\n  \"ButtonNextChapter\": \"次のチャプター\",\n  \"ButtonNextItemInQueue\": \"キューの中の次のアイテム\",\n  \"ButtonOk\": \"はい\",\n  \"ButtonOpenFeed\": \"フィードを開く\",\n  \"ButtonOpenManager\": \"管理画面を開く\",\n  \"ButtonPause\": \"一時停止\",\n  \"ButtonPlay\": \"再生\",\n  \"ButtonPlayAll\": \"全て再生\",\n  \"ButtonPlaying\": \"プレイ中\",\n  \"ButtonPlaylists\": \"プレイリスト\",\n  \"ButtonPrevious\": \"先\",\n  \"ButtonPreviousChapter\": \"前のチャプター\",\n  \"ButtonProbeAudioFile\": \"オーディオファイルを解析\",\n  \"ButtonPurgeAllCache\": \"全てのキャッシュを削除\",\n  \"ButtonPurgeItemsCache\": \"項目のキャッシュを削除\",\n  \"ButtonQueueAddItem\": \"次に再生する\",\n  \"ButtonQueueRemoveItem\": \"次に再生から削除\",\n  \"ButtonQuickEmbed\": \"クイック埋め込み\",\n  \"ButtonQuickEmbedMetadata\": \"メタデータの埋め込み\",\n  \"ButtonQuickMatch\": \"クイックマッチ\",\n  \"ButtonReScan\": \"再スキャン\",\n  \"ButtonRead\": \"読む\",\n  \"ButtonReadLess\": \"閉じる\",\n  \"ButtonReadMore\": \"もっと見る\",\n  \"ButtonRefresh\": \"再読み込み\",\n  \"ButtonRemove\": \"削除\",\n  \"ButtonRemoveAll\": \"全て削除\",\n  \"ButtonRemoveAllLibraryItems\": \"ライブラリーの項目を全て削除\",\n  \"ButtonRemoveFromContinueListening\": \"「続きを聴く」から削除\",\n  \"ButtonRemoveFromContinueReading\": \"「続きを読む」から削除\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"「シリーズを続く」からシリーズを削除\",\n  \"ButtonReset\": \"元に戻す\",\n  \"ButtonResetToDefault\": \"デフォルトに戻す\",\n  \"ButtonRestore\": \"復元\",\n  \"ButtonSave\": \"保存\",\n  \"ButtonSaveAndClose\": \"保存して閉じる\",\n  \"ButtonSaveTracklist\": \"トラックリストを保存\",\n  \"ButtonScan\": \"スキャン\",\n  \"ButtonScanLibrary\": \"ライブラリーをスキャン\",\n  \"ButtonScrollLeft\": \"左にスクロール\",\n  \"ButtonScrollRight\": \"右にスクロール\",\n  \"ButtonSearch\": \"検索\",\n  \"ButtonSelectFolderPath\": \"保存先フォルダを選択\",\n  \"ButtonSeries\": \"シリーズ\",\n  \"ButtonSetChaptersFromTracks\": \"トラックからチャプターを設定する\",\n  \"ButtonShare\": \"共有\",\n  \"ButtonShiftTimes\": \"再生時間の移動\",\n  \"ButtonShow\": \"表示\",\n  \"ButtonStartM4BEncode\": \"M4Bエンコード開始\",\n  \"ButtonStartMetadataEmbed\": \"メタデータ埋め込み開始\",\n  \"ButtonStats\": \"統計\",\n  \"ButtonSubmit\": \"送信\",\n  \"ButtonTest\": \"テスト\",\n  \"ButtonUnlinkOpenId\": \"OpenID 連携解除\",\n  \"ButtonUpload\": \"アップロード\",\n  \"ButtonUploadBackup\": \"バックアップのアップロード\",\n  \"ButtonUploadCover\": \"カバー画像をアップロード\",\n  \"ButtonUploadOPMLFile\": \"OPMLファイルをアップロード\",\n  \"ButtonUserDelete\": \"ユーザーを削除 {0}\",\n  \"ButtonUserEdit\": \"ユーザを編集 {0}\",\n  \"ButtonViewAll\": \"すべて表示\",\n  \"ButtonYes\": \"はい\",\n  \"ErrorUploadFetchMetadataAPI\": \"メタデータの取得中にエラーが発生しました\",\n  \"ErrorUploadFetchMetadataNoResults\": \"メタデータ取得に失敗しました。タイトルや著者名を更新してください\",\n  \"ErrorUploadLacksTitle\": \"タイトルは必須です\",\n  \"HeaderAccount\": \"アカウント\",\n  \"HeaderAddCustomMetadataProvider\": \"カスタムメタデータプロバイダーを追加\",\n  \"HeaderAdvanced\": \"上級者向け\",\n  \"HeaderApiKeys\": \"APIキー\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise 通知設定\",\n  \"HeaderAudioTracks\": \"オーディオトラック\",\n  \"HeaderAudiobookTools\": \"オーディオブックのファイル管理ツール\",\n  \"HeaderAuthentication\": \"認証\",\n  \"HeaderBackups\": \"バックアップ\",\n  \"HeaderBulkChapterModal\": \"チャプターをまとめて追加\",\n  \"HeaderChangePassword\": \"パスワードを変更\",\n  \"HeaderChapters\": \"チャプター\",\n  \"HeaderChooseAFolder\": \"フォルダを選択\",\n  \"HeaderCollection\": \"コレクション\",\n  \"HeaderCollectionItems\": \"コレクションの項目\",\n  \"HeaderCover\": \"カバー\",\n  \"HeaderCurrentDownloads\": \"現在のダウンロード\",\n  \"HeaderCustomMessageOnLogin\": \"ログイン時のカスタムメッセージ\",\n  \"HeaderCustomMetadataProviders\": \"カスタムメタデータプロバイダー\",\n  \"HeaderDetails\": \"詳細\",\n  \"HeaderDownloadQueue\": \"ダウンロード待ち\",\n  \"HeaderEbookFiles\": \"電子書籍ファイル\",\n  \"HeaderEmail\": \"メール\",\n  \"HeaderEmailSettings\": \"メール設定\",\n  \"HeaderEpisodes\": \"エピソード\",\n  \"HeaderEreaderDevices\": \"電子書籍リーダー端末\",\n  \"HeaderEreaderSettings\": \"電子書籍リーダーの設定\",\n  \"HeaderFiles\": \"ファイル\",\n  \"HeaderFindChapters\": \"チャプターを検索\",\n  \"HeaderIgnoredFiles\": \"無視されたファイル\",\n  \"HeaderItemFiles\": \"アイテムファイル\",\n  \"HeaderItemMetadataUtils\": \"アイテムメタデータユーティリティ\",\n  \"HeaderLastListeningSession\": \"直近の再生セッション\",\n  \"HeaderLatestEpisodes\": \"最新のエピソード\",\n  \"HeaderLibraries\": \"ライブラリー\",\n  \"HeaderLibraryFiles\": \"ライブラリファイル\",\n  \"HeaderLibraryStats\": \"ライブラリ統計\",\n  \"HeaderListeningSessions\": \"再生セッション\",\n  \"HeaderListeningStats\": \"再生統計\",\n  \"HeaderLogin\": \"ログイン\",\n  \"HeaderLogs\": \"ログ\",\n  \"HeaderManageGenres\": \"ジャンルを管理\",\n  \"HeaderManageTags\": \"タグを管理\",\n  \"HeaderMapDetails\": \"マップの詳細\",\n  \"HeaderMatch\": \"マッチ\",\n  \"HeaderMetadataOrderOfPrecedence\": \"メタデータの優先順\",\n  \"HeaderMetadataToEmbed\": \"埋め込むメタデータ\",\n  \"HeaderNewAccount\": \"新規アカウント\",\n  \"HeaderNewApiKey\": \"新規APIキー\",\n  \"HeaderNewLibrary\": \"新規ライブラリー\",\n  \"HeaderNotificationCreate\": \"通知を作成\",\n  \"HeaderNotificationUpdate\": \"通知を更新\",\n  \"HeaderNotifications\": \"通知\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect 認証\",\n  \"HeaderOpenRSSFeed\": \"RSS Feedを開く\",\n  \"HeaderPlayerSettings\": \"プレーヤーの設定\",\n  \"HeaderPlaylist\": \"プレイリスト\",\n  \"HeaderPlaylistItems\": \"プレイリストアイテム\",\n  \"HeaderRSSFeedGeneral\": \"RSS 詳細\",\n  \"HeaderRSSFeedIsOpen\": \"RSSフィードが開いています\",\n  \"HeaderSettings\": \"設定\",\n  \"HeaderSettingsGeneral\": \"一般\",\n  \"HeaderSettingsScanner\": \"スキャナー\",\n  \"HeaderSleepTimer\": \"スリープタイマー\",\n  \"HeaderStatsMinutesListeningChart\": \"過去7日間の視聴時間(分)\",\n  \"HeaderStatsRecentSessions\": \"最近の再生履歴\",\n  \"HeaderTableOfContents\": \"目次\",\n  \"HeaderYourStats\": \"再生統計\",\n  \"LabelAddToPlaylist\": \"プレイリストの追加\",\n  \"LabelAddedAt\": \"追加日時\",\n  \"LabelAddedDate\": \"追加日時 ­­{0}\",\n  \"LabelAll\": \"すべて\",\n  \"LabelAuthor\": \"著者\",\n  \"LabelAuthorFirstLast\": \"著者(名 氏)\",\n  \"LabelAuthorLastFirst\": \"著者(氏 名)\",\n  \"LabelAuthors\": \"著者\",\n  \"LabelAutoDownloadEpisodes\": \"エピソードの自動ダウンロード\",\n  \"LabelBooks\": \"ほん\",\n  \"LabelByAuthor\": \"著 {0}\",\n  \"LabelChapters\": \"チャプター\",\n  \"LabelClosePlayer\": \"プレイヤーを閉じる\",\n  \"LabelCollapseSeries\": \"シリーズを折りたたむ\",\n  \"LabelComplete\": \"完了\",\n  \"LabelContinueListening\": \"続きから聞く\",\n  \"LabelContinueReading\": \"続きを読む\",\n  \"LabelContinueSeries\": \"シリーズを続く\",\n  \"LabelDescription\": \"説明\",\n  \"LabelDiscover\": \"おすすめ\",\n  \"LabelDownload\": \"ダウンロード\",\n  \"LabelDuration\": \"長さ\",\n  \"LabelEbook\": \"Eブック\",\n  \"LabelEbooks\": \"Eブック\",\n  \"LabelEnable\": \"有効\",\n  \"LabelEnd\": \"終了\",\n  \"LabelEndOfChapter\": \"チャプターの最後\",\n  \"LabelEpisode\": \"エピソード\",\n  \"LabelEpisodes\": \"エピソード\",\n  \"LabelEpisodic\": \"エピソード\",\n  \"LabelExplicit\": \"露骨な表現\",\n  \"LabelFeedURL\": \"Feed URL\",\n  \"LabelFile\": \"ファイル\",\n  \"LabelFileBirthtime\": \"ファイル作成日時\",\n  \"LabelFileModified\": \"ファイル更新日時\",\n  \"LabelFilename\": \"ファイル名\",\n  \"LabelFinished\": \"完了\",\n  \"LabelFolder\": \"フォルダ\",\n  \"LabelFontBoldness\": \"フォントの太さ\",\n  \"LabelFontFamily\": \"フォントファミリー\",\n  \"LabelFontScale\": \"フォントサイズ\",\n  \"LabelGenre\": \"ジャンル\",\n  \"LabelGenres\": \"ジャンル\",\n  \"LabelHasEbook\": \"eBookあり\",\n  \"LabelHasSupplementaryEbook\": \"付属eBookあり\",\n  \"LabelHost\": \"ホスト\",\n  \"LabelInProgress\": \"進行中\",\n  \"LabelIncomplete\": \"未完了\",\n  \"LabelLanguage\": \"言語\",\n  \"LabelLanguages\": \"言語\",\n  \"LabelLayout\": \"レイアウト\",\n  \"LabelLayoutSinglePage\": \"単ページ\",\n  \"LabelLineSpacing\": \"行間\",\n  \"LabelListenAgain\": \"再度視聴\",\n  \"LabelMediaType\": \"メディアの種類\",\n  \"LabelMoreInfo\": \"追加情報\",\n  \"LabelName\": \"名\",\n  \"LabelNarrator\": \"ナレーター\",\n  \"LabelNarrators\": \"ナレーター\",\n  \"LabelNew\": \"新しい\",\n  \"LabelNewPassword\": \"新しいのパスワード\",\n  \"LabelNewestAuthors\": \"最新の著者\",\n  \"LabelNewestEpisodes\": \"最新エピソード\",\n  \"LabelPassword\": \"パスワード\",\n  \"LabelPath\": \"パス\",\n  \"LabelPlaylists\": \"プレイリスト\",\n  \"LabelPodcast\": \"ポッドキャスト\",\n  \"LabelPodcasts\": \"ポッドキャスト\",\n  \"LabelPreventIndexing\": \"フィードがiTunesおよびGoogleのポッドキャストディレクトリにインデックス登録されるのを防ぎます\",\n  \"LabelPublishYear\": \"公開年\",\n  \"LabelSettingsFindCovers\": \"表紙を探す\",\n  \"LabelSettingsFindCoversHelp\": \"もしオーディオブックに表紙が埋め込まれていない、もしくは表紙画像がフォルダー内に見つからなければ、スキャナーは表紙を探そうとします。<br>注記: これによってスキャン時間が長くなります\",\n  \"LabelSettingsParseSubtitles\": \"サブタイトルを抽出する\",\n  \"LabelSettingsParseSubtitlesHelp\": \"オーディオブックのフォルダー名からサブタイトルを抽出します。<br>サブタイトルは \\\"-\\\" で区切ってください<br>例: \\\"本のタイトル - ここにサブタイトル\\\" という名前だと \\\"ここにサブタイトル\\\" というサブタイトルになります\",\n  \"LabelSettingsPreferMatchedMetadata\": \"一致したメタデータを優先する\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"クイックマッチを使用する時、一致したデータは書籍の詳細を上書きします。デフォルトでは、埋まっていない項目のみ入力されます。\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"並び替えでプレフィックスを無視する\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"例: プレフィックス \\\"the\\\" の付いた本のタイトル \\\"The Book Title\\\" は \\\"Book Title, The\\\" として並び替えられます\",\n  \"LabelSettingsStoreCoversWithItem\": \"表紙を項目と一緒に保存する\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"デフォルトでは表紙は /metadata/items に保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます。\\\"cover\\\" という名前のファイル一つのみが保持されます\",\n  \"LabelSettingsStoreMetadataWithItem\": \"メタデータを項目と一緒に保存する\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"デフォルトではメタデータは/metadata/itemsに保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます\"\n}\n"
  },
  {
    "path": "client/strings/ko.json",
    "content": "{\n  \"ButtonAdd\": \"추가\",\n  \"ButtonAddApiKey\": \"API Key 추가\",\n  \"ButtonAddChapters\": \"챕터 추가\",\n  \"ButtonAddDevice\": \"기기 추가\",\n  \"ButtonAddLibrary\": \"라이브러리 추가\",\n  \"ButtonAddPodcasts\": \"팟케스트 추가\",\n  \"ButtonAddUser\": \"사용자 추가\",\n  \"ButtonAddYourFirstLibrary\": \"첫 라이브러리 추가\",\n  \"ButtonApply\": \"적용\",\n  \"ButtonApplyChapters\": \"챕터 적용\",\n  \"ButtonAuthors\": \"작가\",\n  \"ButtonBack\": \"뒤로\",\n  \"ButtonBatchEditPopulateFromExisting\": \"기존에서 채우기\",\n  \"ButtonBatchEditPopulateMapDetails\": \"지도 세부 정보 채우기\",\n  \"ButtonBrowseForFolder\": \"폴더 찾아보기\",\n  \"ButtonCancel\": \"취소\",\n  \"ButtonCancelEncode\": \"인코딩 취소\",\n  \"ButtonChangeRootPassword\": \"루트 비밀번호 변경\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"새로운 에피소드 확인 및 다운로드\",\n  \"ButtonChooseAFolder\": \"폴더를 선택\",\n  \"ButtonChooseFiles\": \"파일 선택\",\n  \"ButtonClearFilter\": \"필터 지우기\",\n  \"ButtonClose\": \"닫기\",\n  \"ButtonCloseFeed\": \"피드 닫기\",\n  \"ButtonCloseSession\": \"열린 세션 닫기\",\n  \"ButtonCollections\": \"컬렉션\",\n  \"ButtonConfigureScanner\": \"스캐너 구성\",\n  \"ButtonCreate\": \"만들기\",\n  \"ButtonCreateBackup\": \"백업 만들기\",\n  \"ButtonDelete\": \"삭제\",\n  \"ButtonDownloadQueue\": \"대기줄\",\n  \"ButtonEdit\": \"편집\",\n  \"ButtonEditChapters\": \"챕터 편집\",\n  \"ButtonEditPodcast\": \"팟캐스트 편집\",\n  \"ButtonEnable\": \"활성화\",\n  \"ButtonFireAndFail\": \"Fire and Fail\",\n  \"ButtonFireOnTest\": \"Fire onTest 이벤트\",\n  \"ButtonForceReScan\": \"강제 재스캔\",\n  \"ButtonFullPath\": \"전체 경로\",\n  \"ButtonHide\": \"숨기기\",\n  \"ButtonHome\": \"홈\",\n  \"ButtonIssues\": \"이슈\",\n  \"ButtonJumpBackward\": \"뒤로 점프\",\n  \"ButtonJumpForward\": \"앞으로 점프\",\n  \"ButtonLatest\": \"최신\",\n  \"ButtonLibrary\": \"라이브러리\",\n  \"ButtonLogout\": \"로그아웃\",\n  \"ButtonLookup\": \"Lookup\",\n  \"ButtonManageTracks\": \"트랙 관리\",\n  \"ButtonMapChapterTitles\": \"지도 장 제목\",\n  \"ButtonMatchAllAuthors\": \"모든 저자와 일치\",\n  \"ButtonMatchBooks\": \"일치한 책\",\n  \"ButtonNevermind\": \"괜찮아요\",\n  \"ButtonNext\": \"다음\",\n  \"ButtonNextChapter\": \"다음 장\",\n  \"ButtonNextItemInQueue\": \"대기열의 다음 항목\",\n  \"ButtonOk\": \"확인\",\n  \"ButtonOpenFeed\": \"피드 열기\",\n  \"ButtonOpenManager\": \"매니저 열기\",\n  \"ButtonPause\": \"일시정지\",\n  \"ButtonPlay\": \"재생\",\n  \"ButtonPlayAll\": \"모두 재생\",\n  \"ButtonPlaying\": \"재생중\",\n  \"ButtonPlaylists\": \"재생목록\",\n  \"ButtonPrevious\": \"이전\",\n  \"ButtonPreviousChapter\": \"이전 챕터\",\n  \"ButtonProbeAudioFile\": \"Probe 오디오 파일\",\n  \"ButtonPurgeAllCache\": \"모든 캐시 삭제\",\n  \"ButtonPurgeItemsCache\": \"항목 캐시 삭제\",\n  \"ButtonQueueAddItem\": \"대기열에 추가\",\n  \"ButtonQueueRemoveItem\": \"대기열에서 제거\",\n  \"ButtonQuickEmbed\": \"빠른 임베드\",\n  \"ButtonQuickEmbedMetadata\": \"빠른 임베드 메타데이터\",\n  \"ButtonQuickMatch\": \"퀵 매치\",\n  \"ButtonReScan\": \"재스캔\",\n  \"ButtonRead\": \"읽기\",\n  \"ButtonReadLess\": \"덜 읽기\",\n  \"ButtonReadMore\": \"더 읽기\",\n  \"ButtonRefresh\": \"새로고침\",\n  \"ButtonRemove\": \"제거\",\n  \"ButtonRemoveAll\": \"모두 제거\",\n  \"ButtonRemoveAllLibraryItems\": \"모든 라이브러리 항목 제거\",\n  \"ButtonRemoveFromContinueListening\": \"계속 듣기에서 제거\",\n  \"ButtonRemoveFromContinueReading\": \"계속 읽기에서 제거\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"계속 시리즈에서 시리즈 제거\",\n  \"ButtonReset\": \"리셋\",\n  \"ButtonResetToDefault\": \"기본값으로 재설정\",\n  \"ButtonRestore\": \"복구\",\n  \"ButtonSave\": \"저장\",\n  \"ButtonSaveAndClose\": \"저장 후 닫기\",\n  \"ButtonSaveTracklist\": \"트랙리스트 저장\",\n  \"ButtonScan\": \"스캔\",\n  \"ButtonScanLibrary\": \"라이브러리 스캔\",\n  \"ButtonScrollLeft\": \"왼쪽 스크롤\",\n  \"ButtonScrollRight\": \"오른쪽 스크롤\",\n  \"ButtonSearch\": \"검색\",\n  \"ButtonSelectFolderPath\": \"폴더 경로 선택\",\n  \"ButtonSeries\": \"시리즈\",\n  \"ButtonSetChaptersFromTracks\": \"트랙에서 챕터 설정\",\n  \"ButtonShare\": \"공유\",\n  \"ButtonShiftTimes\": \"시간 이동\",\n  \"ButtonShow\": \"보기\",\n  \"ButtonStartM4BEncode\": \"M4B 인코딩 시작\",\n  \"ButtonStartMetadataEmbed\": \"메타데이터 삽입 시작\",\n  \"ButtonStats\": \"통계\",\n  \"ButtonSubmit\": \"제출\",\n  \"ButtonTest\": \"테스트\",\n  \"ButtonUnlinkOpenId\": \"OpenID 연결 해제\",\n  \"ButtonUpload\": \"업로드\",\n  \"ButtonUploadBackup\": \"백업 업로드\",\n  \"ButtonUploadCover\": \"커버 업로드\",\n  \"ButtonUploadOPMLFile\": \"OPML 파일 업로드\",\n  \"ButtonUserDelete\": \"사용자 {0} 삭제\",\n  \"ButtonUserEdit\": \"사용자 {0} 편집\",\n  \"ButtonViewAll\": \"모두 보기\",\n  \"ButtonYes\": \"예\",\n  \"ErrorUploadFetchMetadataAPI\": \"메타데이터를 가져오는 중 오류가 발생했습니다.\",\n  \"ErrorUploadFetchMetadataNoResults\": \"메타데이터를 가져올 수 없습니다. 제목 및/또는 작성자를 업데이트해 보세요.\",\n  \"ErrorUploadLacksTitle\": \"제목이 있어야 합니다\",\n  \"HeaderAccount\": \"계정\",\n  \"HeaderAddCustomMetadataProvider\": \"사용자 정의 메타데이터 공급자 추가\",\n  \"HeaderAdvanced\": \"고급\",\n  \"HeaderApiKeys\": \"API 키\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise 알림 설정\",\n  \"HeaderAudioTracks\": \"오디오 트랙\",\n  \"HeaderAudiobookTools\": \"오디오북 파일 관리 도구\",\n  \"HeaderAuthentication\": \"인증\",\n  \"HeaderBackups\": \"백업\",\n  \"HeaderBulkChapterModal\": \"여러 챕터 추가\",\n  \"HeaderChangePassword\": \"비밀번호 변경\",\n  \"HeaderChapters\": \"챕터\",\n  \"HeaderChooseAFolder\": \"폴더 선택\",\n  \"HeaderCollection\": \"컬렉션\",\n  \"HeaderCollectionItems\": \"컬렉션 항목\",\n  \"HeaderCover\": \"커버\",\n  \"HeaderCurrentDownloads\": \"현재 다운로드\",\n  \"HeaderCustomMessageOnLogin\": \"로그인시 사용자 정의 메시지\",\n  \"HeaderCustomMetadataProviders\": \"사용자 정의 메타데이터 공급자\",\n  \"HeaderDetails\": \"세부사항\",\n  \"HeaderDownloadQueue\": \"다운로드 대기열\",\n  \"HeaderEbookFiles\": \"전자책 파일\",\n  \"HeaderEmail\": \"이메일\",\n  \"HeaderEmailSettings\": \"이메일 설정\",\n  \"HeaderEpisodes\": \"에피소드\",\n  \"HeaderEreaderDevices\": \"전자책 리더기\",\n  \"HeaderEreaderSettings\": \"전자책 리더 설정\",\n  \"HeaderFiles\": \"파일\",\n  \"HeaderFindChapters\": \"챕터 찾기\",\n  \"HeaderIgnoredFiles\": \"무시된 파일\",\n  \"HeaderItemFiles\": \"항목 파일\",\n  \"HeaderItemMetadataUtils\": \"항목 메타데이터 유틸리티\",\n  \"HeaderLastListeningSession\": \"마지막 청취 세션\",\n  \"HeaderLatestEpisodes\": \"최신 에피소드\",\n  \"HeaderLibraries\": \"라이브러리\",\n  \"HeaderLibraryFiles\": \"라이브러리 파일\",\n  \"HeaderLibraryStats\": \"라이브러리 통계\",\n  \"HeaderListeningSessions\": \"청취 세션\",\n  \"HeaderListeningStats\": \"청취 통계\",\n  \"HeaderLogin\": \"로그인\",\n  \"HeaderLogs\": \"로그\",\n  \"HeaderManageGenres\": \"장르 관리\",\n  \"HeaderManageTags\": \"태그 관리\",\n  \"HeaderMapDetails\": \"지도 세부 정보\",\n  \"HeaderMatch\": \"일치\",\n  \"HeaderMetadataOrderOfPrecedence\": \"메타데이터 우선 순위\",\n  \"HeaderMetadataToEmbed\": \"포함할 메타데이터\",\n  \"HeaderNewAccount\": \"새 계정\",\n  \"HeaderNewApiKey\": \"새로운 API 키\",\n  \"HeaderNewLibrary\": \"새 라이브러리\",\n  \"HeaderNotificationCreate\": \"알림 생성\",\n  \"HeaderNotificationUpdate\": \"업데이트 알림\",\n  \"HeaderNotifications\": \"알림\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect 인증\",\n  \"HeaderOpenListeningSessions\": \"청취 세션 열기\",\n  \"HeaderOpenRSSFeed\": \"RSS 피드 열기\",\n  \"HeaderOtherFiles\": \"기타 파일\",\n  \"HeaderPasswordAuthentication\": \"비밀번호 인증\",\n  \"HeaderPermissions\": \"권한\",\n  \"HeaderPlayerQueue\": \"플레이어 대기열\",\n  \"HeaderPlayerSettings\": \"플레이어 설정\",\n  \"HeaderPlaylist\": \"재생목록\",\n  \"HeaderPlaylistItems\": \"재생 목록 항목\",\n  \"HeaderPodcastsToAdd\": \"추가할 팟캐스트\",\n  \"HeaderPresets\": \"사전 설정\",\n  \"HeaderPreviewCover\": \"커버 미리보기\",\n  \"HeaderRSSFeedGeneral\": \"RSS 자세히\",\n  \"HeaderRSSFeedIsOpen\": \"RSS 피드가 열려 있습니다\",\n  \"HeaderRSSFeeds\": \"RSS 피드\",\n  \"HeaderRemoveEpisode\": \"에피소드 제거\",\n  \"HeaderRemoveEpisodes\": \"{0}개 에피소드 제거\",\n  \"HeaderSavedMediaProgress\": \"저장된 미디어 진행 상황\",\n  \"HeaderSchedule\": \"일정\",\n  \"HeaderScheduleEpisodeDownloads\": \"에피소드 자동 다운로드 일정 설정\",\n  \"HeaderScheduleLibraryScans\": \"라이브러리 자동 스캔 일정\",\n  \"HeaderSession\": \"세션\",\n  \"HeaderSetBackupSchedule\": \"백업 일정 설정\",\n  \"HeaderSettings\": \"설정\",\n  \"HeaderSettingsDisplay\": \"디스플레이\",\n  \"HeaderSettingsExperimental\": \"실험적 특징\",\n  \"HeaderSettingsGeneral\": \"일반\",\n  \"HeaderSettingsScanner\": \"스캐너\",\n  \"HeaderSettingsSecurity\": \"보안\",\n  \"HeaderSettingsWebClient\": \"웹 클라이언트\",\n  \"HeaderSleepTimer\": \"슬립 타이머\",\n  \"HeaderStatsLargestItems\": \"가장 큰 항목\",\n  \"HeaderStatsLongestItems\": \"가장 긴 항목(시간)\",\n  \"HeaderStatsMinutesListeningChart\": \"청취 시간(지난 7일)\",\n  \"HeaderStatsRecentSessions\": \"최근 세션\",\n  \"HeaderStatsTop10Authors\": \"상위 10명의 작가\",\n  \"HeaderStatsTop5Genres\": \"상위 5개 장르\",\n  \"HeaderTableOfContents\": \"목차\",\n  \"HeaderTools\": \"도구\",\n  \"HeaderUpdateAccount\": \"계정 업데이트\",\n  \"HeaderUpdateApiKey\": \"API 키 업데이트\",\n  \"HeaderUpdateAuthor\": \"작가 업데이트\",\n  \"HeaderUpdateDetails\": \"업데이트 세부 정보\",\n  \"HeaderUpdateLibrary\": \"라이브러리 업데이트y\",\n  \"HeaderUsers\": \"사용자\",\n  \"HeaderYearReview\": \"{0}년안의 리뷰\",\n  \"HeaderYourStats\": \"내 통계\",\n  \"LabelAbridged\": \"요약\",\n  \"LabelAbridgedChecked\": \"요약(확인됨)\",\n  \"LabelAbridgedUnchecked\": \"요약되지 않음(검토되지 않음)\",\n  \"LabelAccessibleBy\": \"접근 가능한\",\n  \"LabelAccountType\": \"계정 유형\",\n  \"LabelAccountTypeAdmin\": \"관리자\",\n  \"LabelAccountTypeGuest\": \"손님\",\n  \"LabelAccountTypeUser\": \"사용자\",\n  \"LabelActivities\": \"활동\",\n  \"LabelActivity\": \"활동적인\",\n  \"LabelAddToCollection\": \"컬렉션에 추가\",\n  \"LabelAddToCollectionBatch\": \"컬렉션에 {0}권의 책 추가\",\n  \"LabelAddToPlaylist\": \"재생목록에 추가\",\n  \"LabelAddToPlaylistBatch\": \"재생 목록에 {0}개 항목 추가\",\n  \"LabelAddedAt\": \"추가됨\",\n  \"LabelAddedDate\": \"{0} 추가됨\",\n  \"LabelAdminUsersOnly\": \"관리자 사용자만\",\n  \"LabelAll\": \"모두\",\n  \"LabelAllEpisodesDownloaded\": \"모든 에피소드 다운로드됨\",\n  \"LabelAllUsers\": \"모든 사용자\",\n  \"LabelAllUsersExcludingGuests\": \"게스트를 제외한 모든 사용자\",\n  \"LabelAllUsersIncludingGuests\": \"게스트를 포함한 모든 사용자\",\n  \"LabelAlreadyInYourLibrary\": \"이미 내 라이브러리에 있습니다\",\n  \"LabelApiKeyCreated\": \"API 키 \\\"{0}\\\"이(가) 성공적으로 생성되었습니다.\",\n  \"LabelApiKeyCreatedDescription\": \"API 키를 지금 복사해 두세요. 다시 볼 수 없게 됩니다.\",\n  \"LabelApiKeyUser\": \"사용자를 대신하여 행동\",\n  \"LabelApiKeyUserDescription\": \"이 API 키는 해당 API 키를 대신하는 사용자와 동일한 권한을 갖습니다. 이는 사용자가 요청을 하는 것과 동일하게 로그에 기록됩니다.\",\n  \"LabelApiToken\": \"API 토큰\",\n  \"LabelAppend\": \"첨부\",\n  \"LabelAudioBitrate\": \"오디오 비트레이트(예: 128k)\",\n  \"LabelAudioChannels\": \"오디오 채널(1 또는 2)\",\n  \"LabelAudioCodec\": \"오디오 코덱\",\n  \"LabelAuthor\": \"작가\",\n  \"LabelAuthorFirstLast\": \"작가 (이름 성)\",\n  \"LabelAuthorLastFirst\": \"작가 (성, 이름)\",\n  \"LabelAuthors\": \"작가\",\n  \"LabelAutoDownloadEpisodes\": \"에피소드 자동 다운로드\",\n  \"LabelAutoFetchMetadata\": \"메타데이터 자동 가져오기\",\n  \"LabelAutoFetchMetadataHelp\": \"업로드를 간소화하기 위해 제목, 저자, 시리즈에 대한 메타데이터를 가져옵니다. 업로드 후 추가 메타데이터를 일치시켜야 할 수도 있습니다.\",\n  \"LabelAutoLaunch\": \"자동 실행\",\n  \"LabelAutoLaunchDescription\": \"로그인 페이지로 이동할 때 자동으로 인증 공급자로 리디렉션합니다(수동 재정의 경로 <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"자동 등록\",\n  \"LabelAutoRegisterDescription\": \"로그인 후 자동으로 새 사용자를 생성합니다.\",\n  \"LabelBackToUser\": \"사용자에게 돌아가기\",\n  \"LabelBackupAudioFiles\": \"오디오 파일 백업\",\n  \"LabelBackupLocation\": \"백업 위치\",\n  \"LabelBackupsEnableAutomaticBackups\": \"자동 백업\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"/metadata/backups에 저장된 백업\",\n  \"LabelBackupsMaxBackupSize\": \"최대 백업 크기(GB) (무제한의 경우 0)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"잘못된 구성에 대한 보호 조치로, 구성된 크기를 초과하면 백업이 실패합니다.\",\n  \"LabelBackupsNumberToKeep\": \"보관할 백업 수\",\n  \"LabelBackupsNumberToKeepHelp\": \"한 번에 1개의 백업만 제거되므로 이미 이보다 많은 백업이 있는 경우 수동으로 제거해야 합니다.\",\n  \"LabelBitrate\": \"비트레이트\",\n  \"LabelBonus\": \"보너스\",\n  \"LabelBooks\": \"전자책\",\n  \"LabelButtonText\": \"버튼 텍스트\",\n  \"LabelByAuthor\": \"by {0}\",\n  \"LabelChangePassword\": \"비밀번호 변경\",\n  \"LabelChannels\": \"채널\",\n  \"LabelChapterCount\": \"{0} 챕터\",\n  \"LabelChapterTitle\": \"챕터 제목\",\n  \"LabelChapters\": \"챕터\",\n  \"LabelChaptersFound\": \"챕터 발견됨\",\n  \"LabelClickForMoreInfo\": \"자세한 내용을 보려면 클릭\",\n  \"LabelClickToUseCurrentValue\": \"현재 값을 사용하려면 클릭\",\n  \"LabelClosePlayer\": \"플레이어 닫기\",\n  \"LabelCodec\": \"코덱\",\n  \"LabelCollapseSeries\": \"시리즈 펴기\",\n  \"LabelCollapseSubSeries\": \"하위 시리즈 축소\",\n  \"LabelCollection\": \"컬렉션\",\n  \"LabelCollections\": \"컬렉션\",\n  \"LabelComplete\": \"완료\",\n  \"LabelConfirmPassword\": \"비밀번호 확인\",\n  \"LabelContinueListening\": \"계속 듣기\",\n  \"LabelContinueReading\": \"계속 읽기\",\n  \"LabelContinueSeries\": \"시리즈 계속하기\",\n  \"LabelCorsAllowed\": \"허용된 CORS Origins\",\n  \"LabelCover\": \"커버\",\n  \"LabelCoverImageURL\": \"커버 이미지 URL\",\n  \"LabelCoverProvider\": \"커버 제공자\",\n  \"LabelCreatedAt\": \"생성일\",\n  \"LabelCronExpression\": \"Cron Expression\",\n  \"LabelCurrent\": \"현재\",\n  \"LabelCurrently\": \"현재:\",\n  \"LabelCustomCronExpression\": \"사용자 정의 Cron 표현식:\",\n  \"LabelDatetime\": \"일시\",\n  \"LabelDays\": \"일\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"파일 시스템에서 삭제(데이터베이스에서만 제거하려면 선택 취소)\",\n  \"LabelDescription\": \"설명\",\n  \"LabelDeselectAll\": \"모두 선택 해제\",\n  \"LabelDetectedPattern\": \"감지된 패턴:\",\n  \"LabelDevice\": \"장치\",\n  \"LabelDeviceInfo\": \"장치 정보\",\n  \"LabelDeviceIsAvailableTo\": \"사용할 수 있는 장치...\",\n  \"LabelDirectory\": \"디렉토리\",\n  \"LabelDiscFromFilename\": \"파일 이름의 디스크\",\n  \"LabelDiscFromMetadata\": \"메타데이터의 디스크\",\n  \"LabelDiscover\": \"발견\",\n  \"LabelDownload\": \"다운로드\",\n  \"LabelDownloadNEpisodes\": \"{0}개 에피소드 다운로드\",\n  \"LabelDownloadable\": \"다운로드 가능\",\n  \"LabelDuration\": \"기간\",\n  \"LabelDurationComparisonExactMatch\": \"(정확히 일치)\",\n  \"LabelDurationComparisonLonger\": \"({0} 더 길음)\",\n  \"LabelDurationComparisonShorter\": \"({0} 더 짧음)\",\n  \"LabelDurationFound\": \"찾은 기간:\",\n  \"LabelEbook\": \"전자책\",\n  \"LabelEbooks\": \"전자책\",\n  \"LabelEdit\": \"편집\",\n  \"LabelEmail\": \"이메일\",\n  \"LabelEmailSettingsFromAddress\": \"보낸 사람 주소\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"승인되지 않은 인증서 거부\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"SSL 인증서 유효성 검사를 비활성화하면 중간자 공격과 같은 보안 위험에 노출될 수 있습니다. 이 옵션의 의미를 이해하고 연결하려는 메일 서버를 신뢰할 수 있는 경우에만 비활성화하세요.\",\n  \"LabelEmailSettingsSecure\": \"보안\",\n  \"LabelEmailSettingsSecureHelp\": \"true이면 서버에 연결할 때 TLS를 사용합니다. false이면 서버가 STARTTLS 확장을 지원하는 경우 TLS가 사용됩니다. 대부분의 경우 포트 465에 연결하는 경우 이 값을 true로 설정하고, 포트 587 또는 25를 사용하는 경우 false로 설정합니다. (nodemailer.com/smtp/#authentication에서 발췌)\",\n  \"LabelEmailSettingsTestAddress\": \"테스트 주소\",\n  \"LabelEmbeddedCover\": \"내장형 커버\",\n  \"LabelEnable\": \"활성화\",\n  \"LabelEncodingBackupLocation\": \"원본 오디오 파일의 백업이 저장되는 위치 :\",\n  \"LabelEncodingChaptersNotEmbedded\": \"다중 트랙 오디오북에는 챕터가 포함되지 않습니다..\",\n  \"LabelEncodingClearItemCache\": \"주기적으로 항목 캐시를 삭제하세요.\",\n  \"LabelEncodingFinishedM4B\": \"완성된 M4B는 오디오북 저장 폴더 위치:\",\n  \"LabelEncodingInfoEmbedded\": \"메타데이터는 오디오북 폴더 내의 오디오 트랙에 포함됩니다.\",\n  \"LabelEncodingStartedNavigation\": \"작업이 시작되면 이 페이지에서 벗어날 수 있습니다.\",\n  \"LabelEncodingTimeWarning\": \"인코딩은 최대 30분이 걸릴 수 있습니다.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"경고: ffmpeg 인코딩 옵션에 익숙하지 않은 경우 이러한 설정을 업데이트하지 마세요.\",\n  \"LabelEncodingWatcherDisabled\": \"시청 기능을 비활성화한 경우 나중에 오디오북을 다시 스캔해야 합니다.\",\n  \"LabelEnd\": \"마지막\",\n  \"LabelEndOfChapter\": \"챕터의 마지작\",\n  \"LabelEpisode\": \"에피소드\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"에피소드가 RSS 피드에 연결되지 않았습니다.\",\n  \"LabelEpisodeNumber\": \"에피소드 #{0}\",\n  \"LabelEpisodeTitle\": \"에피소드 제목\",\n  \"LabelEpisodeType\": \"에피소드 유형\",\n  \"LabelEpisodeUrlFromRssFeed\": \"RSS 피드의 에피소드 URL\",\n  \"LabelEpisodes\": \"에피소드\",\n  \"LabelEpisodic\": \"에피소드식\",\n  \"LabelExample\": \"예제\",\n  \"LabelExpandSeries\": \"시리즈 확장\",\n  \"LabelExpandSubSeries\": \"하위 시리즈 확장\",\n  \"LabelExpired\": \"만료됨\",\n  \"LabelExpiresAt\": \"만료일\",\n  \"LabelExpiresInSeconds\": \"(초) 후에 만료됩니다\",\n  \"LabelExpiresNever\": \"Never\",\n  \"LabelExplicit\": \"명백한\",\n  \"LabelExplicitChecked\": \"명시적(확인됨)\",\n  \"LabelExplicitUnchecked\": \"명시적이지 않음(체크되지 않음)\",\n  \"LabelExportOPML\": \"OPML 내보내기\",\n  \"LabelFeedURL\": \"피드 URL\",\n  \"LabelFetchingMetadata\": \"메타데이터 가져오는중\",\n  \"LabelFile\": \"파일\",\n  \"LabelFileBirthtime\": \"파일 생년월일\",\n  \"LabelFileBornDate\": \"생성된 {0}\",\n  \"LabelFileModified\": \"파일 수정됨\",\n  \"LabelFileModifiedDate\": \"수정된 {0}\",\n  \"LabelFilename\": \"파일 이름\",\n  \"LabelFilterByUser\": \"사용자별 필터링\",\n  \"LabelFindEpisodes\": \"에피소드 찾기\",\n  \"LabelFinished\": \"완료됨\",\n  \"LabelFinishedDate\": \"완료 {0}\",\n  \"LabelFolder\": \"폴더\",\n  \"LabelFolders\": \"폴더\",\n  \"LabelFontBold\": \"진하게\",\n  \"LabelFontBoldness\": \"글꼴 굵기\",\n  \"LabelFontFamily\": \"글꼴 모양\",\n  \"LabelFontItalic\": \"이탤릭체\",\n  \"LabelFontScale\": \"글꼴 크기\",\n  \"LabelFontStrikethrough\": \"취소선\",\n  \"LabelFormat\": \"형식\",\n  \"LabelFull\": \"전체\",\n  \"LabelGenre\": \"장르\",\n  \"LabelGenres\": \"장르\",\n  \"LabelHardDeleteFile\": \"파일 하드 삭제\",\n  \"LabelHasEbook\": \"보유한 전자책\",\n  \"LabelHasSupplementaryEbook\": \"보유한 보충 전자책\",\n  \"LabelHideSubtitles\": \"자막 숨기기\",\n  \"LabelHighestPriority\": \"가장 높은 우선순위\",\n  \"LabelHost\": \"호스트\",\n  \"LabelHour\": \"시간\",\n  \"LabelHours\": \"시간\",\n  \"LabelIcon\": \"아이콘\",\n  \"LabelImageURLFromTheWeb\": \"웹의 이미지 URL\",\n  \"LabelInProgress\": \"진행 중\",\n  \"LabelIncludeInTracklist\": \"트랙리스트에 포함\",\n  \"LabelIncomplete\": \"불완전한\",\n  \"LabelInterval\": \"간격\",\n  \"LabelIntervalCustomDailyWeekly\": \"사용자 정의 일일/주간\",\n  \"LabelIntervalEvery12Hours\": \"12시간마다\",\n  \"LabelIntervalEvery15Minutes\": \"15분마다\",\n  \"LabelIntervalEvery2Hours\": \"2시간마다\",\n  \"LabelIntervalEvery30Minutes\": \"30분마다\",\n  \"LabelIntervalEvery6Hours\": \"6시간마다\",\n  \"LabelIntervalEveryDay\": \"매일\",\n  \"LabelIntervalEveryHour\": \"매 시간마다\",\n  \"LabelIntervalEveryMinute\": \"매 분마다\",\n  \"LabelInvert\": \"반전\",\n  \"LabelItem\": \"항목\",\n  \"LabelJumpBackwardAmount\": \"뒤로 점프\",\n  \"LabelJumpForwardAmount\": \"앞으로 점프\",\n  \"LabelLanguage\": \"언어\",\n  \"LabelLanguageDefaultServer\": \"기본 서버 언어\",\n  \"LabelLanguages\": \"언어\",\n  \"LabelLastBookAdded\": \"마지막으로 추가된 책\",\n  \"LabelLastBookUpdated\": \"마지막 책 업데이트됨\",\n  \"LabelLastProgressDate\": \"마지막 진행 상황: {0}\",\n  \"LabelLastSeen\": \"마지막으로 본\",\n  \"LabelLastTime\": \"지난번\",\n  \"LabelLastUpdate\": \"마지막 업데이트\",\n  \"LabelLayout\": \"레이아웃\",\n  \"LabelLayoutSinglePage\": \"단일 페이지\",\n  \"LabelLayoutSplitPage\": \"분할 페이지\",\n  \"LabelLess\": \"Less\",\n  \"LabelLibrariesAccessibleToUser\": \"사용자가 접근 가능한 라이브러리\",\n  \"LabelLibrary\": \"라이브러리\",\n  \"LabelLibraryFilterSublistEmpty\": \"{0} 없음\",\n  \"LabelLibraryItem\": \"라이브러리 항목\",\n  \"LabelLibraryName\": \"라이브러리 이름\",\n  \"LabelLibrarySortByProgress\": \"진행상황: 마지막 업데이트\",\n  \"LabelLibrarySortByProgressFinished\": \"진행 상황: 완료\",\n  \"LabelLibrarySortByProgressStarted\": \"진행 상황: 시작됨\",\n  \"LabelLimit\": \"한계\",\n  \"LabelLineSpacing\": \"줄 간격\",\n  \"LabelListenAgain\": \"다시 듣기\",\n  \"LabelLogLevelDebug\": \"디버그\",\n  \"LabelLogLevelInfo\": \"정보\",\n  \"LabelLogLevelWarn\": \"경고\",\n  \"LabelLookForNewEpisodesAfterDate\": \"이 날짜 이후의 새로운 에피소드를 찾아보세요\",\n  \"LabelLowestPriority\": \"가장 낮은 우선순위\",\n  \"LabelMatchConfidence\": \"신뢰\",\n  \"LabelMatchExistingUsersBy\": \"기존 사용자와 일치시키기\",\n  \"LabelMatchExistingUsersByDescription\": \"기존 사용자 연결에 사용됩니다. 연결되면 사용자는 SSO 제공업체의 고유 ID와 매칭됩니다.\",\n  \"LabelMaxEpisodesToDownload\": \"다운로드 가능한 최대 # 에피소드 수. 무제한을 원하시면 0을 사용하세요.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"체크당 다운로드 가능한 최대 신규 # 에피소드 수\",\n  \"LabelMaxEpisodesToKeep\": \"보관할 최대 # 에피소드 수\",\n  \"LabelMaxEpisodesToKeepHelp\": \"값이 0이면 최대 제한이 없습니다. 새 에피소드가 자동으로 다운로드된 후, 에피소드가 X개 이상인 경우 가장 오래된 에피소드부터 삭제됩니다. 새로 다운로드할 때마다 에피소드 1개만 삭제됩니다.\",\n  \"LabelMediaPlayer\": \"미디어 플레이어\",\n  \"LabelMediaType\": \"미디어 유형\",\n  \"LabelMetaTag\": \"메타 태그\",\n  \"LabelMetaTags\": \"메타 태그\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"우선순위가 높은 메타데이터 소스는 우선순위가 낮은 메타데이터 소스를 재정의합니다.\",\n  \"LabelMetadataProvider\": \"메타데이터 공급자\",\n  \"LabelMinute\": \"분\",\n  \"LabelMinutes\": \"분\",\n  \"LabelMissing\": \"누락\",\n  \"LabelMissingEbook\": \"전자책이 없습니다\",\n  \"LabelMissingSupplementaryEbook\": \"보충 전자책이 없습니다\",\n  \"LabelMobileRedirectURIs\": \"허용된 모바일 리디렉션 URI\",\n  \"LabelMobileRedirectURIsDescription\": \"모바일 앱에 유효한 리디렉션 URI 허용 목록입니다. 기본 허용 목록은 <code>audiobookshelf://oauth</code>이며, 타사 앱 통합을 위해 추가 URI로 보완하거나 삭제할 수 있습니다. 별표(<code>*</code>)만 사용하면 모든 URI가 허용됩니다.\",\n  \"LabelMore\": \"더보기\",\n  \"LabelMoreInfo\": \"정보 더 보기\",\n  \"LabelName\": \"이름\",\n  \"LabelNarrator\": \"내레이터\",\n  \"LabelNarrators\": \"내레이터\",\n  \"LabelNew\": \"신규\",\n  \"LabelNewPassword\": \"새 비밀번호\",\n  \"LabelNewestAuthors\": \"최신 작가\",\n  \"LabelNewestEpisodes\": \"최신 에피소드\",\n  \"LabelNextBackupDate\": \"다음 백업 날짜\",\n  \"LabelNextChapters\": \"다음장은 다음과 같음:\",\n  \"LabelNextScheduledRun\": \"다음 예정된 실행\",\n  \"LabelNoApiKeys\": \"API 키 없음\",\n  \"LabelNoCustomMetadataProviders\": \"사용자 정의 메타데이터 공급자 없음\",\n  \"LabelNoEpisodesSelected\": \"선택된 에피소드가 없습니다\",\n  \"LabelNotFinished\": \"완료되지 않음\",\n  \"LabelNotStarted\": \"시작되지 않음\",\n  \"LabelNotes\": \"노트\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(s)\",\n  \"LabelNotificationAvailableVariables\": \"사용 가능한 변수\",\n  \"LabelNotificationBodyTemplate\": \"본문 템플릿\",\n  \"LabelNotificationEvent\": \"알림 이벤트\",\n  \"LabelNotificationTitleTemplate\": \"제목 템플릿\",\n  \"LabelNotificationsMaxFailedAttempts\": \"최대 실패 시도 횟수\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"이 횟수만큼 알림을 보내지 못하면 알림이 비활성화됩니다.\",\n  \"LabelNotificationsMaxQueueSize\": \"알림 이벤트에 대한 최대 대기열 크기\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"이벤트는 초당 1회로 제한됩니다. 대기열 크기가 최대치에 도달하면 이벤트가 무시됩니다. 이를 통해 알림 스팸을 방지할 수 있습니다.\",\n  \"LabelNumberOfBooks\": \"전자책의 수\",\n  \"LabelNumberOfChapters\": \"챕터의 수:\",\n  \"LabelNumberOfEpisodes\": \"# 의 에피소드\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"애플리케이션 내 사용자 작업에 대한 고급 권한을 포함하는 OpenID 클레임의 이름입니다. 이 권한은 관리자가 아닌 역할(<b>구성된 경우</b>)에 적용됩니다. 응답에서 클레임이 누락되면 ABS에 대한 액세스가 거부됩니다. 단일 옵션이 누락되면 <code>false</code>로 처리됩니다. ID 공급자의 클레임이 예상 구조와 일치하는지 확인하세요.\",\n  \"LabelOpenIDClaims\": \"고급 그룹 및 권한 할당을 비활성화하고 자동으로 '사용자' 그룹을 할당하려면 다음 옵션을 비워 두세요.\",\n  \"LabelOpenIDGroupClaimDescription\": \"사용자 그룹 목록이 포함된 OpenID 클레임의 이름입니다. 일반적으로 <code>그룹</code>이라고 합니다. <b>구성된 경우</b>, 애플리케이션은 사용자의 그룹 멤버십을 기반으로 역할을 자동으로 할당합니다. 단, 클레임에서 그룹 이름이 대소문자를 구분하지 않고 'admin', 'user' 또는 'guest'로 지정되어야 합니다. 클레임에는 목록이 포함되어야 하며, 사용자가 여러 그룹에 속하는 경우 애플리케이션은 가장 높은 수준의 액세스 권한에 해당하는 역할을 할당합니다. 일치하는 그룹이 없으면 액세스가 거부됩니다.\",\n  \"LabelOpenRSSFeed\": \"RSS 피드 열기\",\n  \"LabelOverwrite\": \"덮어쓰기\",\n  \"LabelPaginationPageXOfY\": \"{1} 중 {0}페이지\",\n  \"LabelPassword\": \"비밀번호\",\n  \"LabelPath\": \"경로\",\n  \"LabelPermanent\": \"영구적인\",\n  \"LabelPermissionsAccessAllLibraries\": \"모든 라이브러리에 접속 가능\",\n  \"LabelPermissionsAccessAllTags\": \"모든 태그에 액세스 가능\",\n  \"LabelPermissionsAccessExplicitContent\": \"명시적 콘텐츠에 액세스할 수 있음\",\n  \"LabelPermissionsCreateEreader\": \"전자책 리더기를 만들기 가능\",\n  \"LabelPermissionsDelete\": \"삭제 가능\",\n  \"LabelPermissionsDownload\": \"다운로드 가능\",\n  \"LabelPermissionsUpdate\": \"엡데이트 가능\",\n  \"LabelPermissionsUpload\": \"업로드 가능\",\n  \"LabelPersonalYearReview\": \"올해의 리뷰 ({0})\",\n  \"LabelPhotoPathURL\": \"사진 경로/URL\",\n  \"LabelPlayMethod\": \"플레이 방법\",\n  \"LabelPlaybackRateIncrementDecrement\": \"재생 속도 증가/감소량\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} of {1}\",\n  \"LabelPlaylists\": \"재생목록\",\n  \"LabelPodcast\": \"팟케스트\",\n  \"LabelPodcastSearchRegion\": \"팟캐스트 검색 영역\",\n  \"LabelPodcastType\": \"팟캐스트 유형\",\n  \"LabelPodcasts\": \"팟케스트\",\n  \"LabelPort\": \"포트\",\n  \"LabelPrefixesToIgnore\": \"무시할 접두사(대소문자 구분 없음)\",\n  \"LabelPreventIndexing\": \"iTunes 및 Google Podcast 디렉토리에서 피드가 인덱싱되는 것을 방지합니다.\",\n  \"LabelPrimaryEbook\": \"기본 전자책\",\n  \"LabelProgress\": \"진행\",\n  \"LabelProvider\": \"공급자\",\n  \"LabelProviderAuthorizationValue\": \"권한 헤더 값\",\n  \"LabelPubDate\": \"출판 날짜\",\n  \"LabelPublishYear\": \"출판 연도\",\n  \"LabelPublishedDate\": \"출판됨 {0}\",\n  \"LabelPublishedDecade\": \"출판된지 10년\",\n  \"LabelPublishedDecades\": \"출판된지 10년이상\",\n  \"LabelPublisher\": \"출판사\",\n  \"LabelPublishers\": \"출판사\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"사용자 정의 이메일\",\n  \"LabelRSSFeedCustomOwnerName\": \"사용자 정의 이름\",\n  \"LabelRSSFeedOpen\": \"RSS 피드 열기\",\n  \"LabelRSSFeedPreventIndexing\": \"인덱싱 방지\",\n  \"LabelRSSFeedSlug\": \"RSS 피드 슬러그\",\n  \"LabelRSSFeedURL\": \"RSS 피드 URL\",\n  \"LabelRandomly\": \"무작위로\",\n  \"LabelReAddSeriesToContinueListening\": \"계속 듣기 위해 시리즈를 다시 추가하세요\",\n  \"LabelRead\": \"읽기\",\n  \"LabelReadAgain\": \"다시 읽기\",\n  \"LabelReadEbookWithoutProgress\": \"진행 상황을 유지하지 않고 전자책을 읽으세요\",\n  \"LabelRecentSeries\": \"최근 시리즈\",\n  \"LabelRecentlyAdded\": \"최근 추가됨\",\n  \"LabelRecommended\": \"추천\",\n  \"LabelRedo\": \"되돌리기\",\n  \"LabelRegion\": \"지역\",\n  \"LabelReleaseDate\": \"출시일\",\n  \"LabelRemoveAllMetadataAbs\": \"모든 metadata.abs 파일을 제거합니다.\",\n  \"LabelRemoveAllMetadataJson\": \"모든 metadata.json 파일을 제거합니다.\",\n  \"LabelRemoveAudibleBranding\": \"챕터에서 Audible 인트로와 아웃트로 제거\",\n  \"LabelRemoveCover\": \"커버 제거\",\n  \"LabelRemoveMetadataFile\": \"라이브러리 항목 폴더에서 메타데이터 파일 제거\",\n  \"LabelRemoveMetadataFileHelp\": \"{0} 폴더에서 모든 metadata.json 및 metadata.abs 파일을 제거합니다.\",\n  \"LabelRowsPerPage\": \"페이지당 행 수\",\n  \"LabelSearchTerm\": \"검색어\",\n  \"LabelSearchTitle\": \"제목 검색\",\n  \"LabelSearchTitleOrASIN\": \"제목 또는 ASIN 검색\",\n  \"LabelSeason\": \"시즌\",\n  \"LabelSeasonNumber\": \"시즌 #{0}\",\n  \"LabelSelectAll\": \"모두 선택\",\n  \"LabelSelectAllEpisodes\": \"모든 에피소드 선택\",\n  \"LabelSelectEpisodesShowing\": \"{0}개의 에피소드를 선택\",\n  \"LabelSelectUser\": \"사용자 선택\",\n  \"LabelSelectUsers\": \"사용자 선택\",\n  \"LabelSendEbookToDevice\": \"...에 전자책을 보내기\",\n  \"LabelSequence\": \"순서\",\n  \"LabelSerial\": \"일련번호\",\n  \"LabelSeries\": \"시리즈\",\n  \"LabelSeriesName\": \"시리즈 이름\",\n  \"LabelSeriesProgress\": \"시리즈 진행 상황\",\n  \"LabelServerLogLevel\": \"서버 로그 수준\",\n  \"LabelServerYearReview\": \"서버 연간 리뷰 ({0})\",\n  \"LabelSetEbookAsPrimary\": \"기본으로 설정\",\n  \"LabelSetEbookAsSupplementary\": \"보충으로 설정\",\n  \"LabelSettingsAllowIframe\": \"iframe에 포함 허용\",\n  \"LabelSettingsAudiobooksOnly\": \"오디오북만\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"이 설정을 활성화하면 오디오북 폴더 내부에 있는 경우를 제외하고 전자책 파일이 무시되며, 오디오북 폴더 내부에 있는 경우 해당 파일은 보충 전자책으로 설정됩니다.\",\n  \"LabelSettingsBookshelfViewHelp\": \"나무 선반을 활용한 스큐어모픽 디자인\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast 지원\",\n  \"LabelSettingsDateFormat\": \"날짜 형식\",\n  \"LabelSettingsEnableWatcher\": \"라이브러리의 변경 사항을 자동으로 감시\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"라이브러리의 변경 사항을 자동으로 감시\",\n  \"LabelSettingsEnableWatcherHelp\": \"파일 변경 사항이 감지되면 항목을 자동으로 추가/업데이트합니다. *서버 재시작이 필요합니다.\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"epub에서 스크립트 콘텐츠 허용\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"epub 파일에서 스크립트를 실행하도록 허용합니다. epub 파일 출처를 신뢰하지 않는 한 이 설정을 비활성화하는 것이 좋습니다.\",\n  \"LabelSettingsExperimentalFeatures\": \"실험적 특징\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"여러분의 피드백과 테스트에 도움이 될 만한 개발 중인 기능입니다. 클릭하여 Github 토론을 시작하세요.\",\n  \"LabelSettingsFindCovers\": \"커버 찾기\",\n  \"LabelSettingsFindCoversHelp\": \"오디오북에 내장된 표지나 폴더 내에 표지 이미지가 없는 경우 스캐너는 표지를 찾으려고 시도합니다.<br>참고: 이렇게 하면 스캔 시간이 길어집니다.\",\n  \"LabelSettingsHideSingleBookSeries\": \"단일 책 시리즈 숨기기\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"단일 책으로 구성된 시리즈는 시리즈 페이지와 홈페이지 선반에서 숨겨집니다.\",\n  \"LabelSettingsHomePageBookshelfView\": \"홈페이지 책꽂이 보기 사용\",\n  \"LabelSettingsLibraryBookshelfView\": \"라이브러리에 bookshelf view 사용\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"완료율이 다음보다 큼\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"남은 시간은 (초) 미만입니다.\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"미디어 항목을 완료된 것으로 표시\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Continue Series의 이전 책 건너뛰기\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"시리즈 계속하기 홈페이지 선반에는 시리즈 중 아직 시작하지 않은 첫 번째 책이 표시됩니다. 시리즈 중 최소 한 권은 완료되었고 진행 중인 책은 없습니다. 이 설정을 활성화하면 시작하지 않은 첫 번째 책 대신 가장 늦게 완료된 책부터 시리즈가 이어집니다.\",\n  \"LabelSettingsParseSubtitles\": \"자막 파싱\",\n  \"LabelSettingsParseSubtitlesHelp\": \"오디오북 폴더 이름에서 자막을 추출합니다.<br>자막은 \\\" - \\\"로 구분해야 합니다.<br>즉, \\\"책 제목 - 여기에 자막이 있습니다\\\"에는 \\\"여기에 자막이 있습니다\\\"라는 자막이 있습니다.\",\n  \"LabelSettingsPreferMatchedMetadata\": \"일치하는 메타데이터를 선호\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"빠른 매칭을 사용하면 매칭된 데이터가 항목 세부 정보보다 우선합니다. 기본적으로 빠른 매칭은 누락된 세부 정보만 채웁니다.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"이미 ASIN이 있는 일치하는 책을 건너뜁니다.\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"이미 ISBN이 있는 일치하는 책을 건너뜁니다.\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"정렬 시 접두사 무시\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"즉, 접두사 \\\"the\\\"의 경우 책 제목 \\\"The Book Title\\\"은 \\\"Book Title, The\\\"로 정렬됩니다.\",\n  \"LabelSettingsSquareBookCovers\": \"정사각형 책 표지를 사용\",\n  \"LabelSettingsSquareBookCoversHelp\": \"표준 1.6:1 책 표지보다 정사각형 표지를 사용하는 것을 선호합니다.\",\n  \"LabelSettingsStoreCoversWithItem\": \"항목에 있는 커버로 저장\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"기본적으로 표지는 /metadata/items에 저장됩니다. 이 설정을 활성화하면 표지가 라이브러리 항목 폴더에 저장됩니다. \\\"cover\\\"라는 이름의 파일 하나만 저장됩니다.\",\n  \"LabelSettingsStoreMetadataWithItem\": \"항목과 함께 메타데이터 저장\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"기본적으로 메타데이터 파일은 /metadata/items에 저장되며 이 설정을 활성화하면 라이브러리 항목 폴더에 메타데이터 파일이 저장됩니다.\",\n  \"LabelSettingsTimeFormat\": \"시간 형식\",\n  \"LabelShare\": \"공유\",\n  \"LabelShareDownloadableHelp\": \"공유 링크가 있는 사용자는 라이브러리 항목의 zip 파일을 다운로드할 수 있습니다.\",\n  \"LabelShareOpen\": \"공유 열기\",\n  \"LabelShareURL\": \"URL 공유\",\n  \"LabelShowAll\": \"모두 표시\",\n  \"LabelShowSeconds\": \"초 표시\",\n  \"LabelShowSubtitles\": \"자막 표시\",\n  \"LabelSize\": \"크기\",\n  \"LabelSleepTimer\": \"슬립 타이머\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"오름차순\",\n  \"LabelSortDescending\": \"내림차순\",\n  \"LabelSortPubDate\": \"게시 날짜 정렬\",\n  \"LabelStart\": \"시작\",\n  \"LabelStartTime\": \"시작 시간\",\n  \"LabelStarted\": \"시작됨\",\n  \"LabelStartedAt\": \"시작일\",\n  \"LabelStartedDate\": \"{0} 시작됨\",\n  \"LabelStatsAudioTracks\": \"오디오 트랙\",\n  \"LabelStatsAuthors\": \"작가\",\n  \"LabelStatsBestDay\": \"최고의 날\",\n  \"LabelStatsDailyAverage\": \"일일 평균\",\n  \"LabelStatsDays\": \"일\",\n  \"LabelStatsDaysListened\": \"청취 일수\",\n  \"LabelStatsHours\": \"시간\",\n  \"LabelStatsInARow\": \"in a row\",\n  \"LabelStatsItemsFinished\": \"완료된 항목\",\n  \"LabelStatsItemsInLibrary\": \"라이브러리의 항목\",\n  \"LabelStatsMinutes\": \"분\",\n  \"LabelStatsMinutesListening\": \"청취 분수\",\n  \"LabelStatsOverallDays\": \"전체 일수\",\n  \"LabelStatsOverallHours\": \"전체 시간\",\n  \"LabelStatsWeekListening\": \"주간 청취\",\n  \"LabelSubtitle\": \"자막\",\n  \"LabelSupportedFileTypes\": \"지원되는 파일 유형\",\n  \"LabelTag\": \"태그\",\n  \"LabelTags\": \"태그\",\n  \"LabelTagsAccessibleToUser\": \"사용자가 접근 가능한 태그\",\n  \"LabelTagsNotAccessibleToUser\": \"사용자가 액세스할 수 없는 태그\",\n  \"LabelTasks\": \"실행 중인 작업\",\n  \"LabelTextEditorBulletedList\": \"글머리 기호 목록\",\n  \"LabelTextEditorLink\": \"링크\",\n  \"LabelTextEditorNumberedList\": \"번호가 매겨진 목록\",\n  \"LabelTextEditorUnlink\": \"언링크\",\n  \"LabelTheme\": \"테마\",\n  \"LabelThemeDark\": \"Dark\",\n  \"LabelThemeLight\": \"Light\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"시간 기준\",\n  \"LabelTimeDurationXHours\": \"{0}시간\",\n  \"LabelTimeDurationXMinutes\": \"{0}분\",\n  \"LabelTimeDurationXSeconds\": \"{0}초\",\n  \"LabelTimeInMinutes\": \"시간(분)\",\n  \"LabelTimeLeft\": \"{0}개 남음\",\n  \"LabelTimeListened\": \"들은 시간\",\n  \"LabelTimeListenedToday\": \"오늘 청취 시간\",\n  \"LabelTimeRemaining\": \"{0} 남음\",\n  \"LabelTimeToShift\": \"이동 시간(초)\",\n  \"LabelTitle\": \"제목\",\n  \"LabelToolsEmbedMetadata\": \"메타데이터 삽입\",\n  \"LabelToolsEmbedMetadataDescription\": \"오디오 파일에 커버 이미지와 챕터를 포함한 메타데이터를 포함합니다.\",\n  \"LabelToolsM4bEncoder\": \"M4B 인코더\",\n  \"LabelToolsMakeM4b\": \"M4B 오디오북 파일 만들기\",\n  \"LabelToolsMakeM4bDescription\": \"내장된 메타데이터, 표지 이미지, 챕터가 포함된 .M4B 오디오북 파일을 생성합니다.\",\n  \"LabelToolsSplitM4b\": \"M4B를 MP3로 분할\",\n  \"LabelToolsSplitM4bDescription\": \"챕터별로 분할된 M4B에서 내장된 메타데이터, 표지 이미지, 챕터를 사용하여 MP3를 만듭니다.\",\n  \"LabelTotalDuration\": \"총 기간\",\n  \"LabelTotalTimeListened\": \"총 청취 시간\",\n  \"LabelTrackFromFilename\": \"파일 이름에서 추적\",\n  \"LabelTrackFromMetadata\": \"메타데이터에서 추적\",\n  \"LabelTracks\": \"트랙\",\n  \"LabelTracksMultiTrack\": \"멀티트랙\",\n  \"LabelTracksNone\": \"트랙 없음\",\n  \"LabelTracksSingleTrack\": \"싱글트랙\",\n  \"LabelTrailer\": \"트레일러\",\n  \"LabelType\": \"유형\",\n  \"LabelUnabridged\": \"완본\",\n  \"LabelUndo\": \"실행 취소\",\n  \"LabelUnknown\": \"미상\",\n  \"LabelUnknownPublishDate\": \"알 수 없는 게시 날짜\",\n  \"LabelUpdateCover\": \"커버 업데이트\",\n  \"LabelUpdateCoverHelp\": \"일치하는 항목이 발견되면 선택한 책의 기존 표지를 덮어쓸 수 있습니다.\",\n  \"LabelUpdateDetails\": \"세부 정보 업데이트\",\n  \"LabelUpdateDetailsHelp\": \"일치하는 항목이 발견되면 선택한 책에 대한 기존 세부 정보를 덮어쓸 수 있습니다.\",\n  \"LabelUpdatedAt\": \"업데이트됨\",\n  \"LabelUploaderDragAndDrop\": \"파일 또는 폴더 드래그 앤 드롭\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"드래그 앤 드롭 파일\",\n  \"LabelUploaderDropFiles\": \"파일 드롭\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"제목, 저자 및 시리즈를 자동으로 가져옵니다.\",\n  \"LabelUseAdvancedOptions\": \"고급 옵션 사용\",\n  \"LabelUseChapterTrack\": \"챕터 트랙 사용\",\n  \"LabelUseFullTrack\": \"전체 트랙 사용\",\n  \"LabelUseZeroForUnlimited\": \"무제한을 원하시면 0을 사용\",\n  \"LabelUser\": \"사용자\",\n  \"LabelUsername\": \"사용자 이름\",\n  \"LabelValue\": \"값\",\n  \"LabelVersion\": \"버전\",\n  \"LabelViewBookmarks\": \"북마크 보기\",\n  \"LabelViewChapters\": \"챕터 보기\",\n  \"LabelViewPlayerSettings\": \"플레이어 설정 보기\",\n  \"LabelViewQueue\": \"플레이어 대기열 보기\",\n  \"LabelVolume\": \"볼륨\",\n  \"LabelWebRedirectURLsDescription\": \"로그인 후 웹 앱으로 다시 리디렉션할 수 있도록 OAuth 공급자에서 다음 URL을 승인하세요.\",\n  \"LabelWebRedirectURLsSubfolder\": \"리디렉션 URL의 하위 폴더\",\n  \"LabelWeekdaysToRun\": \"주중에 실행\",\n  \"LabelXBooks\": \"{0}권의 책\",\n  \"LabelXItems\": \"{0}개 항목\",\n  \"LabelYearReviewHide\": \"연간 리뷰 숨기기\",\n  \"LabelYearReviewShow\": \"올해의 리뷰 보기\",\n  \"LabelYourAudiobookDuration\": \"오디오북 길이\",\n  \"LabelYourBookmarks\": \"내 북마크\",\n  \"LabelYourPlaylists\": \"내 재생목록\",\n  \"LabelYourProgress\": \"내 진행 상황\",\n  \"MessageAddToPlayerQueue\": \"플레이어 대기열에 추가\",\n  \"MessageAppriseDescription\": \"이 기능을 사용하려면 <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> 인스턴스를 실행 중이거나 동일한 요청을 처리할 API가 필요합니다. <br />Apprise API URL은 알림을 보낼 전체 URL 경로여야 합니다. 예를 들어, API 인스턴스가 <code>http://192.168.1.1:8337</code>에서 제공되는 경우 <code>http://192.168.1.1:8337/notify</code>를 입력합니다.\",\n  \"MessageAsinCheck\": \"Amazon이 아닌 올바른 Audible 지역의 ASIN을 사용하고 있는지 확인하세요.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"기존 API 토큰은 향후 제거될 예정입니다. 대신 <a href=\\\"/config/api-keys\\\">API 키</a>를 사용하세요.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"OIDC 변경 사항을 적용하려면 저장한 후 서버를 다시 시작하세요.\",\n  \"MessageAuthenticationSecurityMessage\": \"보안을 위해 인증 방식이 개선되었습니다. 모든 사용자는 다시 로그인해야 합니다.\",\n  \"MessageBackupsDescription\": \"백업에는 사용자, 사용자 진행 상황, 라이브러리 항목 세부 정보, 서버 설정, 그리고 <code>/metadata/items</code> 및 <code>/metadata/authors</code>에 저장된 이미지가 포함됩니다. 라이브러리 폴더에 저장된 파일은 백업에 <strong>포함되지 않습니다</strong>.\",\n  \"MessageBackupsLocationEditNote\": \"참고: 백업 위치를 업데이트해도 기존 백업은 이동되거나 수정되지 않습니다.\",\n  \"MessageBackupsLocationNoEditNote\": \"참고: 백업 위치는 환경 변수를 통해 설정되며 여기서는 변경할 수 없습니다.\",\n  \"MessageBackupsLocationPathEmpty\": \"백업 위치 경로는 비워둘 수 없습니다.\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"활성화된 필드에 모든 항목의 데이터를 채웁니다. 여러 값이 있는 필드는 병합됩니다.\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"이 항목의 데이터로 활성화된 지도 세부 정보 필드를 채웁니다.\",\n  \"MessageBatchQuickMatchDescription\": \"빠른 매칭은 선택한 항목에 대해 누락된 표지와 메타데이터를 추가하려고 시도합니다. 빠른 매칭이 기존 표지 및/또는 메타데이터를 덮어쓰도록 허용하려면 아래 옵션을 활성화하세요.\",\n  \"MessageBookshelfNoCollections\": \"아직 컬렉션을 만들지 않았습니다.\",\n  \"MessageBookshelfNoCollectionsHelp\": \"소장 자료는 공개되어 있습니다. 라이브러리에 접속할 수 있는 모든 이용자가 열람할 수 있습니다.\",\n  \"MessageBookshelfNoRSSFeeds\": \"RSS 피드가 열려 있지 않습니다\",\n  \"MessageBookshelfNoResultsForFilter\": \"필터 \\\"{0}: {1}\\\"에 대한 결과가 없습니다.\",\n  \"MessageBookshelfNoResultsForQuery\": \"쿼리에 대한 결과가 없습니다\",\n  \"MessageBookshelfNoSeries\": \"시리즈가 없습니다\",\n  \"MessageBulkChapterPattern\": \"이 번호 매기기 패턴을 사용하여 몇 개의 챕터를 추가하시겠습니까?\",\n  \"MessageChapterEndIsAfter\": \"오디오북이 끝난 후 챕터가 끝납니다.\",\n  \"MessageChapterErrorFirstNotZero\": \"첫 번째 챕터는 0에서 시작해야 합니다.\",\n  \"MessageChapterErrorStartGteDuration\": \"잘못된 시작 시간은 오디오북 길이보다 짧아야 합니다.\",\n  \"MessageChapterErrorStartLtPrev\": \"잘못된 시작 시간은 이전 챕터 시작 시간보다 크거나 같아야 합니다.\",\n  \"MessageChapterStartIsAfter\": \"챕터 시작은 오디오북이 끝난 후입니다.\",\n  \"MessageChaptersNotFound\": \"챕터를 찾을 수 없습니다\",\n  \"MessageCheckingCron\": \"cron을 확인 중입니다...\",\n  \"MessageConfirmCloseFeed\": \"이 피드를 닫으시겠습니까?\",\n  \"MessageConfirmDeleteApiKey\": \"API 키 \\\"{0}\\\"을 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteBackup\": \"{0}에 대한 백업을 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteDevice\": \"e-reader 장치 \\\"{0}\\\"을(를) 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteFile\": \"이렇게 하면 파일 시스템에서 파일이 삭제됩니다. 정말 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteLibrary\": \"라이브러리 \\\"{0}\\\"을 영구적으로 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteLibraryItem\": \"이렇게 하면 라이브러리 항목이 데이터베이스와 파일 시스템에서 삭제됩니다. 정말 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteLibraryItems\": \"이렇게 하면 데이터베이스와 파일 시스템에서 {0}개의 라이브러리 항목이 삭제됩니다. 정말 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"사용자 지정 메타데이터 공급자 \\\"{0}\\\"을(를) 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteNotification\": \"이 알림을 삭제하시겠습니까?\",\n  \"MessageConfirmDeleteSession\": \"이 세션을 삭제하시겠습니까?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"{0} 오디오 파일에 메타데이터를 포함하시겠습니까?\",\n  \"MessageConfirmForceReScan\": \"강제로 다시 스캔하시겠습니까?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"모든 에피소드를 완료로 표시하시겠습니까?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"모든 에피소드를 완료되지 않은 것으로 표시하시겠습니까?\",\n  \"MessageConfirmMarkItemFinished\": \"\\\"{0}\\\"을(를) 완료로 표시하시겠습니까?\",\n  \"MessageConfirmMarkItemNotFinished\": \"\\\"{0}\\\"을(를) 완료되지 않은 것으로 표시하시겠습니까?\",\n  \"MessageConfirmMarkSeriesFinished\": \"이 시리즈의 모든 책을 완료로 표시하시겠습니까?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"이 시리즈의 모든 책을 미완료로 표시하시겠습니까?\",\n  \"MessageConfirmNotificationTestTrigger\": \"테스트 데이터로 이 알림을 트리거할까요?\",\n  \"MessageConfirmPurgeCache\": \"캐시를 제거하면 <code>/metadata/cache</code>에 있는 전체 디렉토리가 삭제됩니다. <br /><br />캐시 디렉토리를 제거하시겠습니까?\",\n  \"MessageConfirmPurgeItemsCache\": \"항목 캐시를 삭제하면 <code>/metadata/cache/items</code>에 있는 전체 디렉터리가 삭제됩니다.<br />정말입니까?\",\n  \"MessageConfirmQuickEmbed\": \"경고! 빠른 삽입 기능은 오디오 파일을 백업하지 않습니다. 오디오 파일을 백업해 두세요. <br><br>계속하시겠습니까?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"빠른 매칭 에피소드는 일치하는 에피소드가 발견되면 세부 정보를 덮어씁니다. 매칭되지 않은 에피소드만 업데이트됩니다. 계속하시겠습니까?\",\n  \"MessageConfirmReScanLibraryItems\": \"{0}개 항목을 다시 스캔하시겠습니까?\",\n  \"MessageConfirmRemoveAllChapters\": \"모든 챕터를 제거하시겠습니까?\",\n  \"MessageConfirmRemoveAuthor\": \"작가 \\\"{0}\\\"을(를) 삭제하시겠습니까?\",\n  \"MessageConfirmRemoveCollection\": \"컬렉션 \\\"{0}\\\"을 제거하시겠습니까?\",\n  \"MessageConfirmRemoveEpisode\": \"에피소드 \\\"{0}\\\"을(를) 삭제하시겠습니까?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"참고: \\\"Hard delete file\\\"를 전환하지 않는 한 오디오 파일은 삭제되지 않습니다.\",\n  \"MessageConfirmRemoveEpisodes\": \"{0}개의 에피소드를 삭제하시겠습니까?\",\n  \"MessageConfirmRemoveListeningSessions\": \"{0}개의 청취 세션을 제거하시겠습니까?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"라이브러리 항목 폴더에서 모든 metadata.{0} 파일을 제거하시겠습니까?\",\n  \"MessageConfirmRemoveNarrator\": \"내레이터 \\\"{0}\\\"을(를) 제거하시겠습니까?\",\n  \"MessageConfirmRemovePlaylist\": \"재생목록 \\\"{0}\\\"을(를) 제거하시겠습니까?\",\n  \"MessageConfirmRenameGenre\": \"모든 항목의 장르 이름을 \\\"{0}\\\"에서 \\\"{1}\\\"로 바꾸시겠습니까?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"참고: 이 장르는 이미 존재하므로 병합됩니다.\",\n  \"MessageConfirmRenameGenreWarning\": \"경고! 대소문자가 다른 유사한 장르 \\\"{0}\\\"이 이미 존재합니다.\",\n  \"MessageConfirmRenameTag\": \"모든 항목에 대해 태그 \\\"{0}\\\"의 이름을 \\\"{1}\\\"로 바꾸시겠습니까?\",\n  \"MessageConfirmRenameTagMergeNote\": \"참고: 이 태그는 이미 존재하므로 병합됩니다.\",\n  \"MessageConfirmRenameTagWarning\": \"경고! 대소문자가 다른 유사한 태그 \\\"{0}\\\"이 이미 존재합니다.\",\n  \"MessageConfirmResetProgress\": \"진행 상황을 재설정하시겠습니까?\",\n  \"MessageConfirmSendEbookToDevice\": \"{0}개의 전자책 \\\"{1}\\\"을 장치 \\\"{2}\\\"로 보내시겠습니까?\",\n  \"MessageConfirmUnlinkOpenId\": \"이 사용자를 OpenID에서 연결 해제하시겠습니까?\",\n  \"MessageDaysListenedInTheLastYear\": \"지난 1년 동안 {0}일 동안 청취됨\",\n  \"MessageDownloadingEpisode\": \"에피소드 다운로드 중\",\n  \"MessageDragFilesIntoTrackOrder\": \"파일을 올바른 트랙 순서로 끌어다 놓으세요\",\n  \"MessageEmbedFailed\": \"Embed 실패했습니다!\",\n  \"MessageEmbedFinished\": \"Embed 완료!\",\n  \"MessageEmbedQueue\": \"메타데이터 임베드 대기 중(대기열에 {0} 있음)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0}개의 에피소드가 다운로드 대기 중입니다.\",\n  \"MessageEreaderDevices\": \"전자책을 안전하게 전달하려면 아래 나열된 각 기기에 대해 위의 이메일 주소를 유효한 발신자로 추가해야 할 수도 있습니다.\",\n  \"MessageFeedURLWillBe\": \"피드 URL은 {0}입니다.\",\n  \"MessageFetching\": \"가져오는 중...\",\n  \"MessageForceReScanDescription\": \"모든 파일을 새로 스캔하는 것처럼 다시 스캔합니다. 오디오 파일 ID3 태그, OPF 파일, 텍스트 파일은 새 파일처럼 스캔됩니다.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} {1}에서 청취 중</strong>\",\n  \"MessageHeatmapNoListeningSessions\": \"{0}에 대한 청취 세션이 없습니다.\",\n  \"MessageImportantNotice\": \"중요 공지!\",\n  \"MessageInsertChapterBelow\": \"아래에 챕터를 삽입하세요\",\n  \"MessageInvalidAsin\": \"잘못된 ASIN\",\n  \"MessageItemsSelected\": \"{0}개 항목이 선택됨\",\n  \"MessageItemsUpdated\": \"{0}개 항목이 업데이트됨.\",\n  \"MessageJoinUsOn\": \"가입하기\",\n  \"MessageLoading\": \"로딩중...\",\n  \"MessageLoadingFolders\": \"폴더를 로딩 중...\",\n  \"MessageLogsDescription\": \"로그는 <code>/metadata/logs</code>에 JSON 파일로 저장됩니다. 충돌 로그는 <code>/metadata/logs/crash_logs.txt</code>에 저장됩니다.\",\n  \"MessageM4BFailed\": \"M4B 실패!\",\n  \"MessageM4BFinished\": \"M4B 완료!\",\n  \"MessageMapChapterTitles\": \"타임스탬프를 조정하지 않고 기존 오디오북 장에 챕터 제목을 매핑합니다.\",\n  \"MessageMarkAllEpisodesFinished\": \"모든 에피소드를 완료로 표시\",\n  \"MessageMarkAllEpisodesNotFinished\": \"모든 에피소드를 완료되지 않은 것으로 표시\",\n  \"MessageMarkAsFinished\": \"완료로 표시\",\n  \"MessageMarkAsNotFinished\": \"완료되지 않음으로 표시\",\n  \"MessageMatchBooksDescription\": \"라이브러리에 있는 책과 선택한 검색 제공업체의 책을 비교하고, 빈 세부 정보와 표지 그림을 채웁니다. 세부 정보를 덮어쓰지 않습니다.\",\n  \"MessageNoAudioTracks\": \"오디오 트랙 없음\",\n  \"MessageNoAuthors\": \"작가 없음\",\n  \"MessageNoBackups\": \"백업 없음\",\n  \"MessageNoBookmarks\": \"북마크 없음\",\n  \"MessageNoChapters\": \"챕터 없음\",\n  \"MessageNoCollections\": \"컬렉션 없음\",\n  \"MessageNoCoversFound\": \"커버를 찾을 수 없음\",\n  \"MessageNoDescription\": \"설명 없음\",\n  \"MessageNoDevices\": \"장치가 없음\",\n  \"MessageNoDownloadsInProgress\": \"현재 진행 중인 다운로드가 없습니다.\",\n  \"MessageNoDownloadsQueued\": \"대기 중인 다운로드가 없습니다\",\n  \"MessageNoEpisodeMatchesFound\": \"일치하는 에피소드가 없습니다.\",\n  \"MessageNoEpisodes\": \"에피소드 없음\",\n  \"MessageNoFoldersAvailable\": \"사용 가능한 폴더가 없습니다\",\n  \"MessageNoGenres\": \"장르 없음\",\n  \"MessageNoIssues\": \"이슈 없음\",\n  \"MessageNoItems\": \"항목 없음\",\n  \"MessageNoItemsFound\": \"항목을 찾을 수 없음\",\n  \"MessageNoListeningSessions\": \"청취 세션 없음\",\n  \"MessageNoLogs\": \"로그 없음\",\n  \"MessageNoMediaProgress\": \"진행중인 미디어 없음\",\n  \"MessageNoNotifications\": \"알림 없음\",\n  \"MessageNoPodcastFeed\": \"잘못된 팟캐스트: 피드 없음\",\n  \"MessageNoPodcastsFound\": \"팟캐스트를 찾을 수 없음\",\n  \"MessageNoResults\": \"결과 없음\",\n  \"MessageNoSearchResultsFor\": \"\\\"{0}\\\"에 대한 검색 결과가 없습니다.\",\n  \"MessageNoSeries\": \"시리즈 없음\",\n  \"MessageNoTags\": \"태그 없음\",\n  \"MessageNoTasksRunning\": \"실행 중인 작업이 없음\",\n  \"MessageNoUpdatesWereNecessary\": \"업데이트가 필요하지 않음\",\n  \"MessageNoUserPlaylists\": \"재생목록이 없음\",\n  \"MessageNoUserPlaylistsHelp\": \"재생목록은 비공개입니다. 재생목록을 만든 사용자만 볼 수 있습니다.\",\n  \"MessageNotYetImplemented\": \"아직 구현되지 않음\",\n  \"MessageOpmlPreviewNote\": \"참고: 이는 파싱된 OPML 파일의 미리보기입니다. 실제 팟캐스트 제목은 RSS 피드에서 가져옵니다.\",\n  \"MessageOr\": \"또는\",\n  \"MessagePauseChapter\": \"챕터 재생 일시 정지\",\n  \"MessagePlayChapter\": \"챕터의 시작 부분을 들어보세요\",\n  \"MessagePlaylistCreateFromCollection\": \"컬렉션에서 재생 목록 만들기\",\n  \"MessagePleaseWait\": \"기다리세요...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"팟캐스트에 매칭에 사용할 RSS 피드 URL이 없습니다.\",\n  \"MessagePodcastSearchField\": \"검색어 또는 RSS 피드 URL을 입력하세요\",\n  \"MessageQuickEmbedInProgress\": \"빠른 임베드 진행 중\",\n  \"MessageQuickEmbedQueue\": \"빠른 임베드 대기 중(대기 중인 {0}개)\",\n  \"MessageQuickMatchAllEpisodes\": \"모든 에피소드 퀵 매치\",\n  \"MessageQuickMatchDescription\": \"빈 항목 세부 정보를 채우고 '{0}'의 첫 번째 일치 결과로 덮습니다. '일치하는 메타데이터 선호' 서버 설정이 활성화되어 있지 않으면 세부 정보를 덮어쓰지 않습니다.\",\n  \"MessageRemoveChapter\": \"챕터 제거\",\n  \"MessageRemoveEpisodes\": \"{0}개의 에피소드를 제거합니다.\",\n  \"MessageRemoveFromPlayerQueue\": \"플레이어 대기열에서 제거\",\n  \"MessageRemoveUserWarning\": \"사용자 \\\"{0}\\\"을 영구적으로 삭제하시겠습니까?\",\n  \"MessageReportBugsAndContribute\": \"버그 보고, 기능 요청 및 기여\",\n  \"MessageResetChaptersConfirm\": \"챕터를 재설정하고 변경 사항을 취소하시겠습니까?\",\n  \"MessageRestoreBackupConfirm\": \"생성된 백업을 복원하시겠습니까?\",\n  \"MessageRestoreBackupWarning\": \"백업을 복원하면 /config에 있는 전체 데이터베이스와 /metadata/items 및 /metadata/authors에 있는 표지 이미지가 덮어씌워집니다.<br /><br />백업은 라이브러리 폴더의 어떤 파일도 수정하지 않습니다. 라이브러리 폴더에 표지 그림과 메타데이터를 저장하도록 서버 설정을 활성화한 경우, 해당 내용은 백업되거나 덮어씌워지지 않습니다.<br /><br />서버를 사용하는 모든 클라이언트는 자동으로 새로 고쳐집니다.\",\n  \"MessageScheduleLibraryScanNote\": \"대부분의 사용자는 이 기능을 비활성화하고 라이브러리 변경 사항 자동 감시 설정을 활성화하는 것이 좋습니다. 라이브러리 폴더의 변경 사항을 자동으로 감지합니다. 파일 시스템(예: NFS)에서 라이브러리 변경 사항 자동 감시 기능이 작동하지 않는 경우 이 기능을 활성화하세요.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"{1}에서 {0}마다 실행\",\n  \"MessageSearchResultsFor\": \"검색 결과\",\n  \"MessageSelected\": \"{0}개 선택됨\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"시리즈 시퀀스에는 공백을 포함할 수 없습니다.\",\n  \"MessageServerCouldNotBeReached\": \"서버에 접속할 수 없습니다\",\n  \"MessageSetChaptersFromTracksDescription\": \"각 오디오 파일을 장으로 사용하고 챕터 제목을 오디오 파일 이름으로 사용하여 챕터를 설정합니다.\",\n  \"MessageShareExpirationWillBe\": \"만료일은 <strong>{0}</strong>입니다.\",\n  \"MessageShareExpiresIn\": \"{0}에 만료됨\",\n  \"MessageShareURLWillBe\": \"공유 URL은 <strong>{0}</strong>입니다.\",\n  \"MessageStartPlaybackAtTime\": \"{1}에서 \\\"{0}\\\"의 재생을 시작하시겠습니까?\",\n  \"MessageTaskAudioFileNotWritable\": \"오디오 파일 \\\"{0}\\\"은 쓸 수 없습니다.\",\n  \"MessageTaskCanceledByUser\": \"사용자가 작업을 취소했습니다.\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"에피소드 \\\"{0}\\\" 다운로드 중\",\n  \"MessageTaskEmbeddingMetadata\": \"메타데이터 임베딩\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"오디오북 \\\"{0}\\\"에 메타데이터 삽입\",\n  \"MessageTaskEncodingM4b\": \"M4B 인코딩\",\n  \"MessageTaskEncodingM4bDescription\": \"오디오북 \\\"{0}\\\"을 단일 m4b 파일로 인코딩\",\n  \"MessageTaskFailed\": \"실패함\",\n  \"MessageTaskFailedToBackupAudioFile\": \"오디오 파일 \\\"{0}\\\"을 백업하지 못했습니다.\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"캐시 디렉토리를 생성하지 못했습니다.\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"파일 \\\"{0}\\\"에 메타데이터를 삽입하는 데 실패했습니다.\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"오디오 파일을 병합하지 못했습니다.\",\n  \"MessageTaskFailedToMoveM4bFile\": \"m4b 파일을 이동하지 못했습니다.\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"메타데이터 파일을 쓰지 못했습니다.\",\n  \"MessageTaskMatchingBooksInLibrary\": \"라이브러리 \\\"{0}\\\"에서 일치하는 책\",\n  \"MessageTaskNoFilesToScan\": \"검사할 파일이 없음\",\n  \"MessageTaskOpmlImport\": \"OPML 가져오기\",\n  \"MessageTaskOpmlImportDescription\": \"{0} RSS 피드에서 팟캐스트 만들기\",\n  \"MessageTaskOpmlImportFeed\": \"OPML 가져오기 피드\",\n  \"MessageTaskOpmlImportFeedDescription\": \"RSS 피드 \\\"{0}\\\"을(를) 가져오는 중\",\n  \"MessageTaskOpmlImportFeedFailed\": \"팟캐스트 피드를 가져오지 못했습니다.\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"팟캐스트 \\\"{0}\\\" 생성 중\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"팟캐스트가 이미 경로에 존재합니다.\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"팟캐스트를 만들지 못했습니다\",\n  \"MessageTaskOpmlImportFinished\": \"{0}개의 팟캐스트가 추가되었습니다.\",\n  \"MessageTaskOpmlParseFailed\": \"OPML 파일을 구문 분석하지 못했습니다.\",\n  \"MessageTaskOpmlParseFastFail\": \"잘못된 OPML 파일 <opml> 태그를 찾을 수 없거나 <outline> 태그를 찾을 수 없습니다.\",\n  \"MessageTaskOpmlParseNoneFound\": \"OPML 파일에서 피드를 찾을 수 없습니다.\",\n  \"MessageTaskScanItemsAdded\": \"{0} 추가됨\",\n  \"MessageTaskScanItemsMissing\": \"{0} 누락됨\",\n  \"MessageTaskScanItemsUpdated\": \"{0} 업데이트됨\",\n  \"MessageTaskScanNoChangesNeeded\": \"변경이 필요하지 않습니다\",\n  \"MessageTaskScanningFileChanges\": \"\\\"{0}\\\"의 파일 변경 사항 스캔 중\",\n  \"MessageTaskScanningLibrary\": \"\\\"{0}\\\" 라이브러리 스캔 중\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"대상 디렉토리에 쓸 수 없습니다\",\n  \"MessageThinking\": \"생각중...\",\n  \"MessageUploaderItemFailed\": \"업로드에 실패했습니다\",\n  \"MessageUploaderItemSuccess\": \"성공적으로 업로드되었습니다!\",\n  \"MessageUploading\": \"업로드 중...\",\n  \"MessageValidCronExpression\": \"유효한 cron 표현식\",\n  \"MessageWatcherIsDisabledGlobally\": \"서버 설정에서 Watcher가 전역적으로 비활성화되었습니다.\",\n  \"MessageXLibraryIsEmpty\": \"{0} 라이브러리가 비어 있습니다!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"오디오북 길이가 검색된 길이보다 깁니다.\",\n  \"MessageYourAudiobookDurationIsShorter\": \"오디오북 길이가 검색된 길이보다 짧습니다.\",\n  \"NoteChangeRootPassword\": \"루트 사용자는 빈 비밀번호를 가질 수 있는 유일한 사용자입니다.\",\n  \"NoteChapterEditorTimes\": \"참고: 첫 번째 장의 시작 시간은 0:00으로 유지되어야 하며 마지막 장의 시작 시간은 이 오디오북 길이를 초과할 수 없습니다.\",\n  \"NoteFolderPicker\": \"참고: 이미 매핑된 폴더는 표시되지 않습니다.\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"경고: 대부분의 팟캐스트 앱에서는 RSS 피드 URL이 HTTPS를 사용해야 합니다.\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"경고: 1개 이상의 에피소드에 게시 날짜가 없습니다. 일부 팟캐스트 앱에서는 게시 날짜가 필요합니다.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"미디어 파일이 있는 폴더는 별도의 라이브러리 항목으로 처리됩니다.\",\n  \"NoteUploaderOnlyAudioFiles\": \"오디오 파일만 업로드하는 경우 각 오디오 파일은 별도의 오디오북으로 처리됩니다.\",\n  \"NoteUploaderUnsupportedFiles\": \"지원되지 않는 파일은 무시됩니다. 폴더를 선택하거나 삭제할 때 항목 폴더에 없는 다른 파일은 무시됩니다.\",\n  \"NotificationOnBackupCompletedDescription\": \"백업이 완료되면 트리거됩니다.\",\n  \"NotificationOnBackupFailedDescription\": \"백업이 실패하면 트리거됨\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"팟캐스트 에피소드가 자동으로 다운로드될 때 트리거됩니다.\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"실패한 시도가 너무 많아 자동 에피소드 다운로드가 비활성화되면 트리거됩니다.\",\n  \"NotificationOnRSSFeedFailedDescription\": \"자동 에피소드 다운로드에 대한 RSS 피드 요청이 실패하면 트리거됩니다.\",\n  \"NotificationOnTestDescription\": \"알림 시스템 테스트를 위한 이벤트\",\n  \"PlaceholderBulkChapterInput\": \"챕터 제목을 입력하거나 번호를 사용하세요(예: '에피소드 1', '챕터 10', '1.')\",\n  \"PlaceholderNewCollection\": \"새로운 컬렉션 이름\",\n  \"PlaceholderNewFolderPath\": \"새 폴더 경로\",\n  \"PlaceholderNewPlaylist\": \"새로운 재생 목록 이름\",\n  \"PlaceholderSearch\": \"검색..\",\n  \"PlaceholderSearchEpisode\": \"에피소드 검색..\",\n  \"StatsAuthorsAdded\": \"작가가 추가되었습니다\",\n  \"StatsBooksAdded\": \"추가된 책\",\n  \"StatsBooksAdditional\": \"추가된 내용은 포함…\",\n  \"StatsBooksFinished\": \"책 완성\",\n  \"StatsBooksFinishedThisYear\": \"올해 읽은 책이 몇 권 있어요…\",\n  \"StatsBooksListenedTo\": \"듣는 책\",\n  \"StatsCollectionGrewTo\": \"나의 책 컬렉션이 늘어난…\",\n  \"StatsSessions\": \"세션\",\n  \"StatsSpentListening\": \"보낸 청취시간\",\n  \"StatsTopAuthor\": \"인기 작가\",\n  \"StatsTopAuthors\": \"인기 작가들\",\n  \"StatsTopGenre\": \"인기 장르\",\n  \"StatsTopGenres\": \"인기 장르\",\n  \"StatsTopMonth\": \"월간 인기\",\n  \"StatsTopNarrator\": \"인기 내레이터\",\n  \"StatsTopNarrators\": \"인기 내레이터들\",\n  \"StatsTotalDuration\": \"총 기간은…\",\n  \"StatsYearInReview\": \"올해 리뷰\",\n  \"ToastAccountUpdateSuccess\": \"계정이 업데이트되었습니다\",\n  \"ToastAppriseUrlRequired\": \"Apprise URL을 입력해야 합니다.\",\n  \"ToastAsinRequired\": \"ASIN이 필요합니다\",\n  \"ToastAuthorImageRemoveSuccess\": \"작가 이미지가 삭제되었습니다.\",\n  \"ToastAuthorNotFound\": \"작가 \\\"{0}\\\"을(를) 찾을 수 없습니다.\",\n  \"ToastAuthorRemoveSuccess\": \"작가 삭제됨\",\n  \"ToastAuthorSearchNotFound\": \"작가를 찾을 수 없습니다\",\n  \"ToastAuthorUpdateMerged\": \"작가 병합됨\",\n  \"ToastAuthorUpdateSuccess\": \"작가 업데이트됨\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"작가 업데이트됨(이미지 없음)\",\n  \"ToastBackupAppliedSuccess\": \"백업이 적용되었음\",\n  \"ToastBackupCreateFailed\": \"백업을 생성하지 못했습니다\",\n  \"ToastBackupCreateSuccess\": \"백업이 생성되었습니다\",\n  \"ToastBackupDeleteFailed\": \"백업을 삭제하지 못했습니다.\",\n  \"ToastBackupDeleteSuccess\": \"백업이 삭제되었습니다\",\n  \"ToastBackupInvalidMaxKeep\": \"보관할 백업 수가 올바르지 않습니다.\",\n  \"ToastBackupInvalidMaxSize\": \"잘못된 최대 백업 크기\",\n  \"ToastBackupRestoreFailed\": \"백업을 복원하지 못했습니다\",\n  \"ToastBackupUploadFailed\": \"백업 업로드에 실패했습니다\",\n  \"ToastBackupUploadSuccess\": \"백업 업로드됨\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"항목에 적용된 세부 정보\",\n  \"ToastBatchDeleteFailed\": \"일괄 삭제에 실패했습니다\",\n  \"ToastBatchDeleteSuccess\": \"일괄 삭제 성공\",\n  \"ToastBatchQuickMatchFailed\": \"일괄 퀵 매치에 실패했습니다!\",\n  \"ToastBatchQuickMatchStarted\": \"{0}권의 책에 대한 일괄 퀵 매치가 시작되었습니다!\",\n  \"ToastBatchUpdateFailed\": \"일괄 업데이트에 실패했습니다\",\n  \"ToastBatchUpdateSuccess\": \"일괄 업데이트 성공\",\n  \"ToastBookmarkCreateFailed\": \"북마크를 생성하지 못했습니다.\",\n  \"ToastBookmarkCreateSuccess\": \"북마크 추가됨\",\n  \"ToastBookmarkRemoveSuccess\": \"북마크가 삭제되었습니다\",\n  \"ToastBulkChapterInvalidCount\": \"1~150 사이의 숫자를 입력하세요\",\n  \"ToastCachePurgeFailed\": \"캐시를 정리하지 못했습니다.\",\n  \"ToastCachePurgeSuccess\": \"캐시가 성공적으로 삭제되었습니다.\",\n  \"ToastChapterLocked\": \"챕터가 잠겼습니다.\",\n  \"ToastChapterStartTimeAdjusted\": \"챕터 시작 시간이 {0}초로 조정되었습니다.\",\n  \"ToastChaptersAllLocked\": \"모든 챕터는 잠겨 있습니다. 일부 챕터를 잠금 해제하여 시간을 이동하세요.\",\n  \"ToastChaptersHaveErrors\": \"챕터에 오류가 있습니다\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"잘못된 이동량입니다. 마지막 장의 시작 시간이 이 오디오북의 길이를 초과합니다.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"잘못된 이동량입니다. 첫 번째 챕터의 길이는 0 또는 음수이며 두 번째 챕터로 덮어쓰여집니다. 두 번째 챕터의 시작 길이를 늘리세요.\",\n  \"ToastChaptersMustHaveTitles\": \"챕터에는 제목이 있어야 합니다.\",\n  \"ToastChaptersRemoved\": \"챕터가 삭제되었습니다\",\n  \"ToastChaptersUpdated\": \"챕터 업데이트됨\",\n  \"ToastCollectionItemsAddFailed\": \"컬렉션에 항목을 추가하는 데 실패했습니다.\",\n  \"ToastCollectionRemoveSuccess\": \"컬렉션이 제거되었습니다\",\n  \"ToastCollectionUpdateSuccess\": \"컬렉션이 업데이트되었습니다\",\n  \"ToastConnectionNotAvailable\": \"연결할 수 없습니다. 나중에 다시 시도해 주세요.\",\n  \"ToastCoverSearchFailed\": \"커버 검색에 실패했습니다\",\n  \"ToastCoverUpdateFailed\": \"커버 업데이트에 실패했습니다\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"날짜와 시간이 잘못되었거나 불완전합니다.\",\n  \"ToastDeleteFileFailed\": \"파일을 삭제하지 못했습니다\",\n  \"ToastDeleteFileSuccess\": \"파일이 삭제되었습니다\",\n  \"ToastDeviceAddFailed\": \"장치를 추가하지 못했습니다\",\n  \"ToastDeviceNameAlreadyExists\": \"해당 이름의 전자책 단말기가 이미 존재합니다.\",\n  \"ToastDeviceTestEmailFailed\": \"테스트 이메일을 보내지 못했습니다.\",\n  \"ToastDeviceTestEmailSuccess\": \"테스트 이메일이 전송되었습니다.\",\n  \"ToastEmailSettingsUpdateSuccess\": \"이메일 설정이 업데이트되었습니다.\",\n  \"ToastEncodeCancelFailed\": \"인코딩 취소에 실패했습니다\",\n  \"ToastEncodeCancelSucces\": \"인코딩 취소됨\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"대기열을 지우는 데 실패했습니다.\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"에피소드 다운로드 대기열이 지워졌습니다.\",\n  \"ToastEpisodeUpdateSuccess\": \"{0}개 에피소드 업데이트됨\",\n  \"ToastErrorCannotShare\": \"이 기기에서는 기본적으로 공유할 수 없습니다.\",\n  \"ToastFailedToCreate\": \"생성에 실패했습니다\",\n  \"ToastFailedToDelete\": \"삭제에 실패했습니다\",\n  \"ToastFailedToLoadData\": \"데이터를 로드하는 데 실패했습니다\",\n  \"ToastFailedToMatch\": \"매칭에 실패했습니다\",\n  \"ToastFailedToShare\": \"공유에 실패했습니다\",\n  \"ToastFailedToUpdate\": \"업데이트에 실패했습니다\",\n  \"ToastInvalidImageUrl\": \"잘못된 이미지 URL\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"다운로드할 수 있는 최대 에피소드 수가 올바르지 않습니다.\",\n  \"ToastInvalidUrl\": \"잘못된 URL\",\n  \"ToastInvalidUrls\": \"하나 이상의 URL이 잘못되었습니다.\",\n  \"ToastItemCoverUpdateSuccess\": \"아이템 커버 업데이트됨\",\n  \"ToastItemDeletedFailed\": \"항목을 삭제하지 못했습니다.\",\n  \"ToastItemDeletedSuccess\": \"삭제된 항목\",\n  \"ToastItemDetailsUpdateSuccess\": \"항목 상세정보 업데이트됨\",\n  \"ToastItemMarkedAsFinishedFailed\": \"완료로 표시하지 못했습니다.\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"완료로 표시된 항목\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"완료되지 않음으로 표시하는 데 실패했습니다.\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"완료되지 않음으로 표시된 항목\",\n  \"ToastItemUpdateSuccess\": \"항목이 업데이트되었습니다\",\n  \"ToastLibraryCreateFailed\": \"라이브러리를 생성하지 못했습니다.\",\n  \"ToastLibraryCreateSuccess\": \"라이브러리 \\\"{0}\\\"이 생성되었습니다.\",\n  \"ToastLibraryDeleteFailed\": \"라이브러리 삭제에 실패했습니다\",\n  \"ToastLibraryDeleteSuccess\": \"라이브러리 삭제됨\",\n  \"ToastLibraryScanFailedToStart\": \"스캔을 시작하지 못했습니다\",\n  \"ToastLibraryScanStarted\": \"라이브러리 스캔이 시작되었습니다\",\n  \"ToastLibraryUpdateSuccess\": \"라이브러리 \\\"{0}\\\"이(가) 업데이트되었습니다.\",\n  \"ToastMatchAllAuthorsFailed\": \"모든 작가를 일치시키지 못했습니다.\",\n  \"ToastMetadataFilesRemovedError\": \"메타데이터를 제거하는 중 오류가 발생했습니다.{0} 파일\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"메타데이터가 없습니다.{0} 라이브러리에서 파일을 찾았습니다.\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"메타데이터가 없습니다.{0} 파일이 제거되었습니다.\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} 메타데이터.{1} 파일 제거됨\",\n  \"ToastMustHaveAtLeastOnePath\": \"최소한 하나의 경로가 있어야 합니다\",\n  \"ToastNameEmailRequired\": \"이름과 이메일이 필요합니다\",\n  \"ToastNameRequired\": \"이름이 필요합니다\",\n  \"ToastNewApiKeyUserError\": \"사용자를 선택해야 합니다\",\n  \"ToastNewEpisodesFound\": \"{0}개의 새로운 에피소드가 발견되었습니다.\",\n  \"ToastNewUserCreatedFailed\": \"계정 생성에 실패했습니다: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"새로운 계정이 생성되었습니다\",\n  \"ToastNewUserLibraryError\": \"최소한 하나의 라이브러리를 선택해야 합니다.\",\n  \"ToastNewUserPasswordError\": \"비밀번호가 있어야 합니다. 루트 사용자만 빈 비밀번호를 가질 수 있습니다.\",\n  \"ToastNewUserTagError\": \"최소한 하나의 태그를 선택해야 합니다.\",\n  \"ToastNewUserUsernameError\": \"사용자 이름을 입력하세요\",\n  \"ToastNoNewEpisodesFound\": \"새로운 에피소드가 없습니다\",\n  \"ToastNoRSSFeed\": \"팟캐스트에 RSS 피드가 없습니다\",\n  \"ToastNoUpdatesNecessary\": \"업데이트가 필요하지 않습니다\",\n  \"ToastNotificationCreateFailed\": \"알림을 생성하지 못했습니다.\",\n  \"ToastNotificationDeleteFailed\": \"알림 삭제에 실패했습니다\",\n  \"ToastNotificationFailedMaximum\": \"실패한 최대 시도 횟수는 0 이상이어야 합니다.\",\n  \"ToastNotificationQueueMaximum\": \"최대 알림 대기열은 0 이상이어야 합니다.\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"알림 설정이 업데이트되었습니다.\",\n  \"ToastNotificationTestTriggerFailed\": \"테스트 알림을 트리거하지 못했습니다.\",\n  \"ToastNotificationTestTriggerSuccess\": \"테스트 알림이 트리거되었습니다.\",\n  \"ToastNotificationUpdateSuccess\": \"알림이 업데이트되었습니다\",\n  \"ToastPlaylistCreateFailed\": \"플레이리스트를 생성하지 못했습니다\",\n  \"ToastPlaylistCreateSuccess\": \"플레이리스트가 생성되었습니다\",\n  \"ToastPlaylistRemoveSuccess\": \"재생목록이 삭제되었습니다\",\n  \"ToastPlaylistUpdateSuccess\": \"플레이리스트가 업데이트되었습니다\",\n  \"ToastPodcastCreateFailed\": \"팟캐스트를 만들지 못했습니다\",\n  \"ToastPodcastCreateSuccess\": \"팟캐스트가 성공적으로 생성되었습니다.\",\n  \"ToastPodcastEpisodeUpdated\": \"에피소드 업데이트됨\",\n  \"ToastPodcastGetFeedFailed\": \"팟캐스트 피드를 가져오지 못했습니다.\",\n  \"ToastPodcastNoEpisodesInFeed\": \"RSS 피드에서 에피소드를 찾을 수 없습니다.\",\n  \"ToastPodcastNoRssFeed\": \"팟캐스트에 RSS 피드가 없습니다\",\n  \"ToastProgressIsNotBeingSynced\": \"진행 상황이 동기화되지 않습니다. 재생을 다시 시작하세요.\",\n  \"ToastProviderCreatedFailed\": \"공급자를 추가하지 못했습니다\",\n  \"ToastProviderCreatedSuccess\": \"새로운 공급자가 추가되었습니다\",\n  \"ToastProviderNameAndUrlRequired\": \"이름과 URL이 필요합니다\",\n  \"ToastProviderRemoveSuccess\": \"제공자 제거됨\",\n  \"ToastRSSFeedCloseFailed\": \"RSS 피드를 닫지 못했습니다.\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS 피드가 닫혔습니다\",\n  \"ToastRemoveFailed\": \"제거에 실패했습니다\",\n  \"ToastRemoveItemFromCollectionFailed\": \"컬렉션에서 항목을 제거하지 못했습니다.\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"컬렉션에서 항목이 제거되었습니다.\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"이슈가 있는 라이브러리 항목을 제거하지 못했습니다.\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"이슈가 있는 라이브러리 항목을 제거했습니다.\",\n  \"ToastRenameFailed\": \"이름 바꾸기에 실패했습니다\",\n  \"ToastRescanFailed\": \"{0}에 대한 재스캔에 실패했습니다.\",\n  \"ToastRescanRemoved\": \"재스캔 완료 항목이 제거되었습니다.\",\n  \"ToastRescanUpToDate\": \"재스캔 완료 항목이 최신 상태였습니다.\",\n  \"ToastRescanUpdated\": \"재스캔 완료 항목이 업데이트되었습니다.\",\n  \"ToastScanFailed\": \"라이브러리 항목 스캔에 실패했습니다.\",\n  \"ToastSelectAtLeastOneUser\": \"최소한 한 명의 사용자를 선택하세요\",\n  \"ToastSendEbookToDeviceFailed\": \"기기로 전자책을 보내지 못했습니다.\",\n  \"ToastSendEbookToDeviceSuccess\": \"전자책이 기기 \\\"{0}\\\"로 전송되었습니다.\",\n  \"ToastSeriesSubmitFailedSameName\": \"같은 이름의 두 시리즈를 추가할 수 없습니다.\",\n  \"ToastSeriesUpdateFailed\": \"시리즈 업데이트에 실패했습니다\",\n  \"ToastSeriesUpdateSuccess\": \"시리즈 업데이트 성공\",\n  \"ToastServerSettingsUpdateSuccess\": \"서버 설정이 업데이트되었습니다.\",\n  \"ToastSessionCloseFailed\": \"세션을 닫지 못했습니다\",\n  \"ToastSessionDeleteFailed\": \"세션 삭제에 실패했습니다\",\n  \"ToastSessionDeleteSuccess\": \"세션이 삭제되었습니다\",\n  \"ToastSleepTimerDone\": \"슬립 타이머 완료... zZzzZz\",\n  \"ToastSlugMustChange\": \"슬러그에 잘못된 문자가 포함되어 있습니다.\",\n  \"ToastSlugRequired\": \"슬러그가 필요합니다\",\n  \"ToastSocketConnected\": \"소켓 연결됨\",\n  \"ToastSocketDisconnected\": \"소켓이 연결해제\",\n  \"ToastSocketFailedToConnect\": \"소켓 연결에 실패했습니다\",\n  \"ToastSortingPrefixesEmptyError\": \"최소 1개의 정렬 접두사가 있어야 합니다.\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"정렬 접두사가 업데이트되었습니다({0}개 항목)\",\n  \"ToastTitleRequired\": \"제목은 필수입니다\",\n  \"ToastUnknownError\": \"알 수 없는 오류\",\n  \"ToastUnlinkOpenIdFailed\": \"OpenID에서 사용자 연결을 해제하지 못했습니다.\",\n  \"ToastUnlinkOpenIdSuccess\": \"OpenID에서 사용자 연결이 해제되었습니다.\",\n  \"ToastUploaderFilepathExistsError\": \"파일 경로 \\\"{0}\\\"이(가) 서버에 이미 있습니다.\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"항목 \\\"{0}\\\"이(가) 업로드 경로의 하위 디렉토리를 사용하고 있습니다.\",\n  \"ToastUserDeleteFailed\": \"사용자 삭제에 실패했습니다\",\n  \"ToastUserDeleteSuccess\": \"사용자가 삭제되었습니다\",\n  \"ToastUserPasswordChangeSuccess\": \"비밀번호가 성공적으로 변경되었습니다\",\n  \"ToastUserPasswordMismatch\": \"비밀번호가 일치하지 않습니다\",\n  \"ToastUserPasswordMustChange\": \"새 비밀번호는 이전 비밀번호와 일치할 수 없습니다.\",\n  \"ToastUserRootRequireName\": \"루트 사용자 이름을 입력해야 합니다\",\n  \"TooltipAddChapters\": \"챕터 추가\",\n  \"TooltipAddOneSecond\": \"1초 추가\",\n  \"TooltipAdjustChapterStart\": \"시작 시간을 조정하려면 클릭하세요\",\n  \"TooltipLockAllChapters\": \"모든 챕터 잠금\",\n  \"TooltipLockChapter\": \"장 잠금(범위를 위해 Shift+클릭)\",\n  \"TooltipSubtractOneSecond\": \"1초 빼기\",\n  \"TooltipUnlockAllChapters\": \"모든 챕터 잠금 해제\",\n  \"TooltipUnlockChapter\": \"챕터 잠금 해제(범위를 보려면 Shift+클릭)\"\n}\n"
  },
  {
    "path": "client/strings/lt.json",
    "content": "{\n  \"ButtonAdd\": \"Pridėti\",\n  \"ButtonAddApiKey\": \"Pridėti API raktą\",\n  \"ButtonAddChapters\": \"Pridėti skyrius\",\n  \"ButtonAddDevice\": \"Pridėti įrenginį\",\n  \"ButtonAddLibrary\": \"Pridėti Biblioteką\",\n  \"ButtonAddPodcasts\": \"Pridėti tinklalaides\",\n  \"ButtonAddUser\": \"Pridėti Vartotoją\",\n  \"ButtonAddYourFirstLibrary\": \"Pridėkite savo pirmąją biblioteką\",\n  \"ButtonApply\": \"Taikyti\",\n  \"ButtonApplyChapters\": \"Taikyti skyrius\",\n  \"ButtonAuthors\": \"Autoriai\",\n  \"ButtonBack\": \"Atgal\",\n  \"ButtonBrowseForFolder\": \"Naršyti aplanko\",\n  \"ButtonCancel\": \"Atšaukti\",\n  \"ButtonCancelEncode\": \"Atšaukti kodavimą\",\n  \"ButtonChangeRootPassword\": \"Keisti root slaptažodį\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Patikrinti ir parsiųsti naujus epizodus\",\n  \"ButtonChooseAFolder\": \"Pasirinkite aplanką\",\n  \"ButtonChooseFiles\": \"Pasirinkite failus\",\n  \"ButtonClearFilter\": \"Valyti filtrą\",\n  \"ButtonClose\": \"Uždaryti\",\n  \"ButtonCloseFeed\": \"Uždaryti srautą\",\n  \"ButtonCloseSession\": \"Uždaryti Atidarytą sesiją\",\n  \"ButtonCollections\": \"Kolekcijos\",\n  \"ButtonConfigureScanner\": \"Konfigūruoti skenerį\",\n  \"ButtonCreate\": \"Kurti\",\n  \"ButtonCreateBackup\": \"Kurti atsarginę kopiją\",\n  \"ButtonDelete\": \"Ištrinti\",\n  \"ButtonDownloadQueue\": \"Parsisiuntimų eilė\",\n  \"ButtonEdit\": \"Redaguoti\",\n  \"ButtonEditChapters\": \"Redaguoti skyrius\",\n  \"ButtonEditPodcast\": \"Redaguoti tinklalaidę\",\n  \"ButtonEnable\": \"Įjungti\",\n  \"ButtonForceReScan\": \"Priverstinai nuskaityti iš naujo\",\n  \"ButtonFullPath\": \"Visas kelias\",\n  \"ButtonHide\": \"Slėpti\",\n  \"ButtonHome\": \"Pradžia\",\n  \"ButtonIssues\": \"Problemos\",\n  \"ButtonJumpBackward\": \"Peršokti atgal\",\n  \"ButtonJumpForward\": \"Peršokti į priekį\",\n  \"ButtonLatest\": \"Naujausias\",\n  \"ButtonLibrary\": \"Biblioteka\",\n  \"ButtonLogout\": \"Atsijungti\",\n  \"ButtonLookup\": \"Ieškoti\",\n  \"ButtonManageTracks\": \"Tvarkyti takelius\",\n  \"ButtonMapChapterTitles\": \"Suderinti skyrių pavadinimus\",\n  \"ButtonMatchAllAuthors\": \"Pritaikyti visus autorius\",\n  \"ButtonMatchBooks\": \"Pritaikyti knygas\",\n  \"ButtonNevermind\": \"Nesvarbu\",\n  \"ButtonNext\": \"Kitas\",\n  \"ButtonNextChapter\": \"Kitas Skyrius\",\n  \"ButtonNextItemInQueue\": \"Kitas eilėje\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Atidaryti srautą\",\n  \"ButtonOpenManager\": \"Atidaryti tvarkyklę\",\n  \"ButtonPause\": \"Pauzė\",\n  \"ButtonPlay\": \"Groti\",\n  \"ButtonPlayAll\": \"Groti Visus\",\n  \"ButtonPlaying\": \"Grojama\",\n  \"ButtonPlaylists\": \"Grojaraščiai\",\n  \"ButtonPrevious\": \"Praeitas\",\n  \"ButtonPreviousChapter\": \"Praeitas Skyrius\",\n  \"ButtonPurgeAllCache\": \"Valyti visą saugyklą\",\n  \"ButtonPurgeItemsCache\": \"Valyti elementų saugyklą\",\n  \"ButtonQueueAddItem\": \"Pridėti į eilę\",\n  \"ButtonQueueRemoveItem\": \"Pašalinti iš eilės\",\n  \"ButtonQuickMatch\": \"Greitas pritaikymas\",\n  \"ButtonReScan\": \"Iš naujo nuskaityti\",\n  \"ButtonRead\": \"Skaityti\",\n  \"ButtonReadLess\": \"Mažiau\",\n  \"ButtonReadMore\": \"Daugiau\",\n  \"ButtonRefresh\": \"Atnaujinti\",\n  \"ButtonRemove\": \"Pašalinti\",\n  \"ButtonRemoveAll\": \"Pašalinti viską\",\n  \"ButtonRemoveAllLibraryItems\": \"Pašalinti visus bibliotekos elementus\",\n  \"ButtonRemoveFromContinueListening\": \"Pašalinti iš Tęsti Klausimą\",\n  \"ButtonRemoveFromContinueReading\": \"Pašalinti iš Tęsti Skaitymą\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Pašalinti seriją iš Tęsti Seriją\",\n  \"ButtonReset\": \"Atstatyti\",\n  \"ButtonRestore\": \"Atkurti\",\n  \"ButtonSave\": \"Išsaugoti\",\n  \"ButtonSaveAndClose\": \"Išsaugoti ir uždaryti\",\n  \"ButtonSaveTracklist\": \"Išsaugoti takelių sąrašą\",\n  \"ButtonScan\": \"Nuskaityti\",\n  \"ButtonScanLibrary\": \"Nuskaityti biblioteką\",\n  \"ButtonSearch\": \"Ieškoti\",\n  \"ButtonSelectFolderPath\": \"Pasirinkti aplanko kelią\",\n  \"ButtonSeries\": \"Serijos\",\n  \"ButtonSetChaptersFromTracks\": \"Nustatyti skyrius iš takelių\",\n  \"ButtonShare\": \"Dalintis\",\n  \"ButtonShiftTimes\": \"Perstumti laikus\",\n  \"ButtonShow\": \"Rodyti\",\n  \"ButtonStartM4BEncode\": \"Pradėti M4B kodavimą\",\n  \"ButtonStartMetadataEmbed\": \"Pradėti metaduomenų įterpimą\",\n  \"ButtonStats\": \"Statistika\",\n  \"ButtonSubmit\": \"Pateikti\",\n  \"ButtonTest\": \"Testuoti\",\n  \"ButtonUnlinkOpenId\": \"Atsieti OpenID\",\n  \"ButtonUpload\": \"Įkelti\",\n  \"ButtonUploadBackup\": \"Įkelti atsarginę kopiją\",\n  \"ButtonUploadCover\": \"Įkelti viršelį\",\n  \"ButtonUploadOPMLFile\": \"Įkelti OPML failą\",\n  \"ButtonUserDelete\": \"Ištrinti naudotoją {0}\",\n  \"ButtonUserEdit\": \"Redaguoti naudotoją {0}\",\n  \"ButtonViewAll\": \"Peržiūrėti visus\",\n  \"ButtonYes\": \"Taip\",\n  \"ErrorUploadFetchMetadataAPI\": \"Klaida gaunant metaduomenis\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių\",\n  \"ErrorUploadLacksTitle\": \"Pavadinimas yra privalomas\",\n  \"HeaderAccount\": \"Paskyra\",\n  \"HeaderAdvanced\": \"Papildomi\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise pranešimo nustatymai\",\n  \"HeaderAudioTracks\": \"Garso takeliai\",\n  \"HeaderAudiobookTools\": \"Audioknygų failų valdymo įrankiai\",\n  \"HeaderAuthentication\": \"Autentifikacija\",\n  \"HeaderBackups\": \"Atsarginės kopijos\",\n  \"HeaderChangePassword\": \"Pakeisti slaptažodį\",\n  \"HeaderChapters\": \"Skyriai\",\n  \"HeaderChooseAFolder\": \"Pasirinkti aplanką\",\n  \"HeaderCollection\": \"Kolekcija\",\n  \"HeaderCollectionItems\": \"Kolekcijos elementai\",\n  \"HeaderCover\": \"Viršelis\",\n  \"HeaderCurrentDownloads\": \"Dabartiniai parsisiuntimai\",\n  \"HeaderCustomMessageOnLogin\": \"Pritaikyta prisijungimo žinutė\",\n  \"HeaderDetails\": \"Detalės\",\n  \"HeaderDownloadQueue\": \"Parsisiuntimo eilė\",\n  \"HeaderEbookFiles\": \"Eknygos failai\",\n  \"HeaderEmail\": \"El. paštas\",\n  \"HeaderEmailSettings\": \"El. pašto nustatymai\",\n  \"HeaderEpisodes\": \"Epizodai\",\n  \"HeaderEreaderDevices\": \"Elektroniniai skaitytuvai\",\n  \"HeaderEreaderSettings\": \"Elektroninių skaitytuvų nustatymai\",\n  \"HeaderFiles\": \"Failai\",\n  \"HeaderFindChapters\": \"Rasti skyrius\",\n  \"HeaderIgnoredFiles\": \"Ignoruojami failai\",\n  \"HeaderItemFiles\": \"Elemento failai\",\n  \"HeaderItemMetadataUtils\": \"Elemento metaduomenų įrankiai\",\n  \"HeaderLastListeningSession\": \"Paskutinė klausymosi sesija\",\n  \"HeaderLatestEpisodes\": \"Naujausi epizodai\",\n  \"HeaderLibraries\": \"Bibliotekos\",\n  \"HeaderLibraryFiles\": \"Bibliotekos failai\",\n  \"HeaderLibraryStats\": \"Bibliotekos statistika\",\n  \"HeaderListeningSessions\": \"Klausymosi sesijos\",\n  \"HeaderListeningStats\": \"Klausymosi statistika\",\n  \"HeaderLogin\": \"Prisijungti\",\n  \"HeaderLogs\": \"Žurnalai\",\n  \"HeaderManageGenres\": \"Tvarkyti žanrus\",\n  \"HeaderManageTags\": \"Tvarkyti žymas\",\n  \"HeaderMapDetails\": \"Susieti detales\",\n  \"HeaderMatch\": \"Atitaikyti\",\n  \"HeaderMetadataToEmbed\": \"Metaduomenys įterpimui\",\n  \"HeaderNewAccount\": \"Nauja paskyra\",\n  \"HeaderNewLibrary\": \"Nauja biblioteka\",\n  \"HeaderNotifications\": \"Pranešimai\",\n  \"HeaderOpenRSSFeed\": \"Atidaryti RSS srautą\",\n  \"HeaderOtherFiles\": \"Kiti failai\",\n  \"HeaderPermissions\": \"Leidimai\",\n  \"HeaderPlayerQueue\": \"Grotuvo eilė\",\n  \"HeaderPlaylist\": \"Grojaraštis\",\n  \"HeaderPlaylistItems\": \"Grojaraščio elementai\",\n  \"HeaderPodcastsToAdd\": \"Pridėti tinklalaides\",\n  \"HeaderPreviewCover\": \"Peržiūrėti viršelį\",\n  \"HeaderRSSFeedGeneral\": \"RSS informacija\",\n  \"HeaderRSSFeedIsOpen\": \"RSS srautas yra atidarytas\",\n  \"HeaderRemoveEpisode\": \"Pašalinti epizodą\",\n  \"HeaderRemoveEpisodes\": \"Pašalinti {0} epizodus\",\n  \"HeaderSavedMediaProgress\": \"Išsaugota medijos pažanga\",\n  \"HeaderSchedule\": \"Tvarkaraštis\",\n  \"HeaderScheduleLibraryScans\": \"Nustatyti bibliotekų nuskaitymo tvarkaraštį\",\n  \"HeaderSession\": \"Sesija\",\n  \"HeaderSetBackupSchedule\": \"Nustatyti atsarginių kopijų tvarkaraštį\",\n  \"HeaderSettings\": \"Nustatymai\",\n  \"HeaderSettingsDisplay\": \"Rodymas\",\n  \"HeaderSettingsExperimental\": \"Eksperimentinės funkcijos\",\n  \"HeaderSettingsGeneral\": \"Bendra\",\n  \"HeaderSettingsScanner\": \"Skaitytuvas\",\n  \"HeaderSleepTimer\": \"Miego laikmatis\",\n  \"HeaderStatsLargestItems\": \"Didžiausi elementai\",\n  \"HeaderStatsLongestItems\": \"Ilgiausi elementai (val.)\",\n  \"HeaderStatsMinutesListeningChart\": \"Klausymo minutės (paskutinės 7 dienos)\",\n  \"HeaderStatsRecentSessions\": \"Naujausios sesijos\",\n  \"HeaderStatsTop10Authors\": \"Top 10 autorių\",\n  \"HeaderStatsTop5Genres\": \"Top 5 žanrai\",\n  \"HeaderTableOfContents\": \"Turinys\",\n  \"HeaderTools\": \"Įrankiai\",\n  \"HeaderUpdateAccount\": \"Atnaujinti paskyrą\",\n  \"HeaderUpdateAuthor\": \"Atnaujinti autorių\",\n  \"HeaderUpdateDetails\": \"Atnaujinti informaciją\",\n  \"HeaderUpdateLibrary\": \"Atnaujinti biblioteką\",\n  \"HeaderUsers\": \"Naudotojai\",\n  \"HeaderYourStats\": \"Jūsų statistika\",\n  \"LabelAbridged\": \"Santrauka\",\n  \"LabelAccountType\": \"Paskyros tipas\",\n  \"LabelAccountTypeAdmin\": \"Administratorius\",\n  \"LabelAccountTypeGuest\": \"Svečias\",\n  \"LabelAccountTypeUser\": \"Naudotojas\",\n  \"LabelActivity\": \"Veikla\",\n  \"LabelAddToCollection\": \"Pridėti į kolekciją\",\n  \"LabelAddToCollectionBatch\": \"Pridėti {0} knygas į kolekciją\",\n  \"LabelAddToPlaylist\": \"Pridėti į grojaraštį\",\n  \"LabelAddToPlaylistBatch\": \"Pridėti {0} elementus į grojaraštį\",\n  \"LabelAddedAt\": \"Pridėta {0}\",\n  \"LabelAll\": \"Visi\",\n  \"LabelAllUsers\": \"Visi naudotojai\",\n  \"LabelAlreadyInYourLibrary\": \"Jau yra jūsų bibliotekoje\",\n  \"LabelAppend\": \"Pridėti\",\n  \"LabelAuthor\": \"Autorius\",\n  \"LabelAuthorFirstLast\": \"Autorius (Vardas Pavardė)\",\n  \"LabelAuthorLastFirst\": \"Autorius (Pavardė, Vardas)\",\n  \"LabelAuthors\": \"Autoriai\",\n  \"LabelAutoDownloadEpisodes\": \"Automatiškai atsisiųsti epizodus\",\n  \"LabelBackToUser\": \"Grįžti į naudotoją\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Įjungti automatinį atsarginių kopijų kūrimą\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Atsarginės kopijos bus išsaugotos /metadata/backups aplanke\",\n  \"LabelBackupsMaxBackupSize\": \"Maksimalus atsarginių kopijų dydis (GB) (0 - neribotai)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Jei konfigūruotas dydis viršijamas, atsarginės kopijos nebus sukurtos, kad būtų išvengta klaidingų konfigūracijų.\",\n  \"LabelBackupsNumberToKeep\": \"Laikytinų atsarginių kopijų skaičius\",\n  \"LabelBackupsNumberToKeepHelp\": \"Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.\",\n  \"LabelBitrate\": \"Bitų sparta\",\n  \"LabelBooks\": \"Knygos\",\n  \"LabelChangePassword\": \"Pakeisti slaptažodį\",\n  \"LabelChannels\": \"Kanalai\",\n  \"LabelChapterTitle\": \"Skyriaus pavadinimas\",\n  \"LabelChapters\": \"Skyriai\",\n  \"LabelChaptersFound\": \"rasti skyriai\",\n  \"LabelClosePlayer\": \"Uždaryti grotuvą\",\n  \"LabelCodec\": \"Kodekas\",\n  \"LabelCollapseSeries\": \"Suskleisti seriją\",\n  \"LabelCollections\": \"Kolekcijos\",\n  \"LabelComplete\": \"Baigta\",\n  \"LabelConfirmPassword\": \"Patvirtinkite slaptažodį\",\n  \"LabelContinueListening\": \"Tęsti klausymąsi\",\n  \"LabelContinueReading\": \"Tęsti skaitymą\",\n  \"LabelContinueSeries\": \"Tęsti seriją\",\n  \"LabelCover\": \"Viršelis\",\n  \"LabelCoverImageURL\": \"Viršelio paveikslėlio URL\",\n  \"LabelCreatedAt\": \"Sukurta\",\n  \"LabelCronExpression\": \"Cron išraiška\",\n  \"LabelCurrent\": \"Dabartinė\",\n  \"LabelCurrently\": \"Šiuo metu:\",\n  \"LabelCustomCronExpression\": \"Nestandartinė Cron išraiška:\",\n  \"LabelDatetime\": \"Data ir laikas\",\n  \"LabelDescription\": \"Aprašymas\",\n  \"LabelDeselectAll\": \"Išvalyti pasirinktus\",\n  \"LabelDevice\": \"Įrenginys\",\n  \"LabelDeviceInfo\": \"Įrenginio informacija\",\n  \"LabelDirectory\": \"Katalogas\",\n  \"LabelDiscFromFilename\": \"Diskas pagal failo pavadinimą\",\n  \"LabelDiscFromMetadata\": \"Diskas pagal metaduomenis\",\n  \"LabelDownload\": \"Atsisiųsti\",\n  \"LabelDownloadNEpisodes\": \"Atsisiųsti {0} epizodų\",\n  \"LabelDuration\": \"Trukmė\",\n  \"LabelDurationFound\": \"Rasta trukmė:\",\n  \"LabelEbook\": \"Elektroninė knyga\",\n  \"LabelEbooks\": \"El. knygos\",\n  \"LabelEdit\": \"Redaguoti\",\n  \"LabelEmail\": \"El. paštas\",\n  \"LabelEmailSettingsFromAddress\": \"Siuntėjo adresas\",\n  \"LabelEmailSettingsSecure\": \"Apsaugota\",\n  \"LabelEmailSettingsSecureHelp\": \"Jei ši reikšmė yra \\\"true\\\", ryšys naudos TLS protokolą. Jei \\\"false\\\", TLS bus naudojamas tik tada, jei serveris palaiko STARTTLS plėtinį. Daugumos atveju, jei jungiamasi prie 465 prievado, šią reikšmę turėtumėte nustatyti kaip \\\"true\\\". Jei jungiamasi prie 587 arba 25 prievado, turi būti nustatyta \\\"false\\\". (iš nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Testinis adresas\",\n  \"LabelEmbeddedCover\": \"Įterptas viršelis\",\n  \"LabelEnable\": \"Įjungti\",\n  \"LabelEnd\": \"Pabaiga\",\n  \"LabelEndOfChapter\": \"Skyriaus pabaiga\",\n  \"LabelEpisode\": \"Epizodas\",\n  \"LabelEpisodeTitle\": \"Epizodo pavadinimas\",\n  \"LabelEpisodeType\": \"Epizodo tipas\",\n  \"LabelExample\": \"Pavyzdys\",\n  \"LabelExplicit\": \"Suaugusiems\",\n  \"LabelFeedURL\": \"Srauto URL\",\n  \"LabelFile\": \"Failas\",\n  \"LabelFileBirthtime\": \"Failo kūrimo laikas\",\n  \"LabelFileModified\": \"Failo keitimo laikas\",\n  \"LabelFilename\": \"Failo pavadinimas\",\n  \"LabelFilterByUser\": \"Filtruoti pagal naudotoją\",\n  \"LabelFindEpisodes\": \"Rasti epizodus\",\n  \"LabelFinished\": \"Baigta\",\n  \"LabelFolder\": \"Aplankas\",\n  \"LabelFolders\": \"Aplankai\",\n  \"LabelFontFamily\": \"Famiglia di font\",\n  \"LabelFontScale\": \"Šrifto mastelis\",\n  \"LabelFormat\": \"Formatas\",\n  \"LabelGenre\": \"Žanras\",\n  \"LabelGenres\": \"Žanrai\",\n  \"LabelHardDeleteFile\": \"Galutinai ištrinti failą\",\n  \"LabelHasEbook\": \"Turi el. knygą\",\n  \"LabelHasSupplementaryEbook\": \"Turi papildomą el. knygą\",\n  \"LabelHost\": \"Serveris\",\n  \"LabelHour\": \"Valanda\",\n  \"LabelIcon\": \"Piktograma\",\n  \"LabelInProgress\": \"Vyksta\",\n  \"LabelIncludeInTracklist\": \"Įtraukti į takelių sąrašą\",\n  \"LabelIncomplete\": \"Nebaigta\",\n  \"LabelInterval\": \"Intervalas\",\n  \"LabelIntervalCustomDailyWeekly\": \"Pasirinktinis kasdieninės/savaitinės periodiškumas\",\n  \"LabelIntervalEvery12Hours\": \"Kas 12 valandų\",\n  \"LabelIntervalEvery15Minutes\": \"Kas 15 minučių\",\n  \"LabelIntervalEvery2Hours\": \"Kas 2 valandas\",\n  \"LabelIntervalEvery30Minutes\": \"Kas 30 minučių\",\n  \"LabelIntervalEvery6Hours\": \"Kas 6 valandas\",\n  \"LabelIntervalEveryDay\": \"Kasdien\",\n  \"LabelIntervalEveryHour\": \"Kiekvieną valandą\",\n  \"LabelInvert\": \"Apversti\",\n  \"LabelItem\": \"Elementas\",\n  \"LabelLanguage\": \"Kalba\",\n  \"LabelLanguageDefaultServer\": \"Numatytoji serverio kalba\",\n  \"LabelLastBookAdded\": \"Paskutinė pridėta knyga\",\n  \"LabelLastBookUpdated\": \"Paskutinė atnaujinta knyga\",\n  \"LabelLastSeen\": \"Paskutinį kartą matyta\",\n  \"LabelLastTime\": \"Paskutinį kartą\",\n  \"LabelLastUpdate\": \"Paskutinė atnaujinimo data\",\n  \"LabelLayout\": \"Išdėstymas\",\n  \"LabelLayoutSinglePage\": \"Vieno puslapio\",\n  \"LabelLayoutSplitPage\": \"Padalinto puslapio\",\n  \"LabelLess\": \"Mažiau\",\n  \"LabelLibrariesAccessibleToUser\": \"Naudotojui pasiekiamos bibliotekos\",\n  \"LabelLibrary\": \"Biblioteka\",\n  \"LabelLibraryItem\": \"Bibliotekos elementas\",\n  \"LabelLibraryName\": \"Bibliotekos pavadinimas\",\n  \"LabelLimit\": \"Limitas\",\n  \"LabelLineSpacing\": \"Tarpas tarp eilučių\",\n  \"LabelListenAgain\": \"Klausytis iš naujo\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Ieškoti naujų epizodų po šios datos\",\n  \"LabelMediaPlayer\": \"Grotuvas\",\n  \"LabelMediaType\": \"Medijos tipas\",\n  \"LabelMetaTag\": \"Meta žymė\",\n  \"LabelMetaTags\": \"Meta žymos\",\n  \"LabelMetadataProvider\": \"Metaduomenų tiekėjas\",\n  \"LabelMinute\": \"Minutė\",\n  \"LabelMissing\": \"Trūksta\",\n  \"LabelMore\": \"Daugiau\",\n  \"LabelMoreInfo\": \"Daugiau informacijos\",\n  \"LabelName\": \"Pavadinimas\",\n  \"LabelNarrator\": \"Skaitytojas\",\n  \"LabelNarrators\": \"Skaitytojai\",\n  \"LabelNew\": \"Nauja\",\n  \"LabelNewPassword\": \"Naujas slaptažodis\",\n  \"LabelNewestAuthors\": \"Naujausi autoriai\",\n  \"LabelNewestEpisodes\": \"Naujausi epizodai\",\n  \"LabelNextBackupDate\": \"Kitos atsarginės kopijos data\",\n  \"LabelNextScheduledRun\": \"Kito planuoto vykdymo data\",\n  \"LabelNoEpisodesSelected\": \"Nepasirinkti jokie epizodai\",\n  \"LabelNotFinished\": \"Nebaigta\",\n  \"LabelNotStarted\": \"Nepasileista\",\n  \"LabelNotes\": \"Užrašai\",\n  \"LabelNotificationAppriseURL\": \"Pranešimo (Apprise) URL\",\n  \"LabelNotificationAvailableVariables\": \"Galimi kintamieji\",\n  \"LabelNotificationBodyTemplate\": \"Turinio šablonas\",\n  \"LabelNotificationEvent\": \"Pranešimo įvykis\",\n  \"LabelNotificationTitleTemplate\": \"Pavadinimo šablonas\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maksimalus nesėkmingų bandymų skaičius\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Pranešimai bus išjungti, jei nepavyks jų išsiųsti nurodytą kartų\",\n  \"LabelNotificationsMaxQueueSize\": \"Maksimalus pranešimų eilių dydis\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Įvykiai yra apriboti vienu įvykiu per sekundę. Įvykiai bus ignoruojami, jei eilė yra maksimalaus dydžio. Tai apsaugo nuo pranešimų šlamšto.\",\n  \"LabelNumberOfBooks\": \"Knygų skaičius\",\n  \"LabelNumberOfEpisodes\": \"Epizodų skaičius\",\n  \"LabelOpenRSSFeed\": \"Atidaryti RSS srautą\",\n  \"LabelOverwrite\": \"Perrašyti\",\n  \"LabelPassword\": \"Slaptažodis\",\n  \"LabelPath\": \"Kelias\",\n  \"LabelPermissionsAccessAllLibraries\": \"Gali pasiekti visas bibliotekas\",\n  \"LabelPermissionsAccessAllTags\": \"Gali pasiekti visas žymes\",\n  \"LabelPermissionsAccessExplicitContent\": \"Gali pasiekti turinį suaugusiems\",\n  \"LabelPermissionsDelete\": \"Gali trinti\",\n  \"LabelPermissionsDownload\": \"Gali atsisiųsti\",\n  \"LabelPermissionsUpdate\": \"Gali atnaujinti\",\n  \"LabelPermissionsUpload\": \"Gali įkelti\",\n  \"LabelPhotoPathURL\": \"Nuotraukos kelias/URL\",\n  \"LabelPlayMethod\": \"Grojimo metodas\",\n  \"LabelPlaylists\": \"Grojaraščiai\",\n  \"LabelPodcast\": \"Tinklalaidė\",\n  \"LabelPodcastSearchRegion\": \"Podcast paieškos regionas\",\n  \"LabelPodcastType\": \"Tinklalaidės tipas\",\n  \"LabelPodcasts\": \"Tinklalaidės\",\n  \"LabelPort\": \"Prievadas\",\n  \"LabelPrefixesToIgnore\": \"Ignoruojami priešdėliai (didžiosios/mažosios nesvarbu)\",\n  \"LabelPreventIndexing\": \"Neleisti indeksuoti jūsų srauto „iTunes“ ir Google podcast kataloguose\",\n  \"LabelPrimaryEbook\": \"Pagrindinė e-knyga\",\n  \"LabelProgress\": \"Progresas\",\n  \"LabelProvider\": \"Tiekėjas\",\n  \"LabelPubDate\": \"Publikavimo data\",\n  \"LabelPublishYear\": \"Leidimo metai\",\n  \"LabelPublisher\": \"Leidėjas\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Pasirinktinis savininko el. paštas\",\n  \"LabelRSSFeedCustomOwnerName\": \"Pasirinktinis savininko vardas\",\n  \"LabelRSSFeedOpen\": \"Atidarytas RSS srautas\",\n  \"LabelRSSFeedPreventIndexing\": \"Neleisti indeksuoti\",\n  \"LabelRSSFeedSlug\": \"RSS srauto identifikatorius\",\n  \"LabelRSSFeedURL\": \"RSS srauto URL\",\n  \"LabelRead\": \"Skaityta\",\n  \"LabelReadAgain\": \"Skaityti dar kartą\",\n  \"LabelReadEbookWithoutProgress\": \"Skaityti e-knygą be pažangos saugojimo\",\n  \"LabelRecentSeries\": \"Naujausios serijos\",\n  \"LabelRecentlyAdded\": \"Neseniai pridėta\",\n  \"LabelRecommended\": \"Rekomenduojama\",\n  \"LabelRegion\": \"Regionas\",\n  \"LabelReleaseDate\": \"Išleidimo data\",\n  \"LabelRemoveCover\": \"Pašalinti viršelį\",\n  \"LabelSearchTerm\": \"Paieškos žodis\",\n  \"LabelSearchTitle\": \"Ieškoti pavadinimo\",\n  \"LabelSearchTitleOrASIN\": \"Ieškoti pavadinimo arba ASIN\",\n  \"LabelSeason\": \"Sezonas\",\n  \"LabelSelectAllEpisodes\": \"Pažymėti visus epizodus\",\n  \"LabelSelectEpisodesShowing\": \"Pažymėti {0} rodomus epizodus\",\n  \"LabelSendEbookToDevice\": \"Siųsti e-knygą į...\",\n  \"LabelSequence\": \"Seka\",\n  \"LabelSeries\": \"Serija\",\n  \"LabelSeriesName\": \"Serijos pavadinimas\",\n  \"LabelSeriesProgress\": \"Serijos progresas\",\n  \"LabelSetEbookAsPrimary\": \"Nustatyti kaip pagrindinę\",\n  \"LabelSetEbookAsSupplementary\": \"Nustatyti kaip papildomą\",\n  \"LabelSettingsAudiobooksOnly\": \"Tik garso knygos\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Įjungus šią parinktį, e-knygų failai bus ignoruojami, nebent jie būtų audioknygų aplankuose, kurie tada būtų rodomi kaip papildomos e-knygos\",\n  \"LabelSettingsBookshelfViewHelp\": \"Knygų lentynos dizainas su medinėmis lentynomis\",\n  \"LabelSettingsChromecastSupport\": \"„Chromecast“ palaikymas\",\n  \"LabelSettingsDateFormat\": \"Datos formatas\",\n  \"LabelSettingsExperimentalFeatures\": \"Eksperimentiniai funkcionalumai\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.\",\n  \"LabelSettingsFindCovers\": \"Rasti viršelius\",\n  \"LabelSettingsFindCoversHelp\": \"Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę\",\n  \"LabelSettingsHideSingleBookSeries\": \"Slėpti serijas, turinčias tik vieną knygą\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Naudoti pagrindinio puslapio knygų lentynų vaizdą\",\n  \"LabelSettingsLibraryBookshelfView\": \"Naudoti bibliotekos knygų lentynų vaizdą\",\n  \"LabelSettingsParseSubtitles\": \"Analizuoti subtitrus\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Išskleisti subtitrus iš audioknygos aplanko pavadinimų.<br>Subtitrai turi būti atskirti brūkšniu \\\"-\\\"<br>pavyzdžiui, \\\"Knygos pavadinimas - Čia yra subtitrai\\\" turi subtitrą \\\"Čia yra subtitrai\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Pirmenybė atitaikytiems metaduomenis\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Atitaikyti duomenys pakeis elementų informaciją naudojant Greitą atitikimą. Pagal nutylėjimą Greitas atitaikymas užpildys tik trūkstamas detales.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Praleisti knygas, kurios jau turi ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Praleisti knygas, kurios jau turi ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignoruoti priešdėlius rūšiuojant\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"pvz., su priešdėliu \\\"the\\\" knygos pavadinimas \\\"The Book Title\\\" bus rūšiuojamas kaip \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Naudoti kvadratinius knygos viršelius\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Naudoti kvadratinius viršelius vietoj standartinių 1.6:1 knygų viršelių\",\n  \"LabelSettingsStoreCoversWithItem\": \"Saugoti viršelius su elementu\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas failas su \\\"cover\\\" pavadinimu.\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Saugoti metaduomenis su elementu\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke\",\n  \"LabelSettingsTimeFormat\": \"Laiko formatas\",\n  \"LabelShowAll\": \"Rodyti viską\",\n  \"LabelSize\": \"Dydis\",\n  \"LabelSleepTimer\": \"Miego laikmatis\",\n  \"LabelStart\": \"Pradėti\",\n  \"LabelStartTime\": \"Pradžios laikas\",\n  \"LabelStarted\": \"Pradėta\",\n  \"LabelStartedAt\": \"Pradėta\",\n  \"LabelStatsAudioTracks\": \"Garsiniai takeliai\",\n  \"LabelStatsAuthors\": \"Autoriai\",\n  \"LabelStatsBestDay\": \"Geriausia diena\",\n  \"LabelStatsDailyAverage\": \"Vidutiniškai per dieną\",\n  \"LabelStatsDays\": \"Dienos\",\n  \"LabelStatsDaysListened\": \"Klausyta dienų\",\n  \"LabelStatsHours\": \"Valandos\",\n  \"LabelStatsInARow\": \"iš eilės\",\n  \"LabelStatsItemsFinished\": \"Baigti elementai\",\n  \"LabelStatsItemsInLibrary\": \"Elementai bibliotekoje\",\n  \"LabelStatsMinutes\": \"minutės\",\n  \"LabelStatsMinutesListening\": \"Klausyta minučių\",\n  \"LabelStatsOverallDays\": \"Iš viso dienų\",\n  \"LabelStatsOverallHours\": \"Iš viso valandų\",\n  \"LabelStatsWeekListening\": \"Savaitės klausymas\",\n  \"LabelSubtitle\": \"Subtitrai\",\n  \"LabelSupportedFileTypes\": \"Palaikomi failų tipai\",\n  \"LabelTag\": \"Žyma\",\n  \"LabelTags\": \"Žymos\",\n  \"LabelTagsAccessibleToUser\": \"Žymos, pasiekiamos vartotojui\",\n  \"LabelTagsNotAccessibleToUser\": \"Žymos, nepasiekiamos vartotojui\",\n  \"LabelTasks\": \"Vykdomos užduotys\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Tamsi\",\n  \"LabelThemeLight\": \"Šviesi\",\n  \"LabelTimeBase\": \"Laiko pagrindas\",\n  \"LabelTimeListened\": \"Klausytas laikas\",\n  \"LabelTimeListenedToday\": \"Klausytas laikas šiandien\",\n  \"LabelTimeRemaining\": \"{0} likę\",\n  \"LabelTimeToShift\": \"Laiko perkėlimas sekundėmis\",\n  \"LabelTitle\": \"Pavadinimas\",\n  \"LabelToolsEmbedMetadata\": \"Įterpti metaduomenis\",\n  \"LabelToolsEmbedMetadataDescription\": \"Įterpti metaduomenis į garso failus, įskaitant viršelio paveikslu ir skyrius.\",\n  \"LabelToolsMakeM4b\": \"Sukurti M4B garso knygų failą\",\n  \"LabelToolsMakeM4bDescription\": \"Sukurti .M4B garso knygų failą su įterptais metaduomenimis, viršelio paveikslu ir skyriais.\",\n  \"LabelToolsSplitM4b\": \"Skaidyti M4B į MP3 failus\",\n  \"LabelToolsSplitM4bDescription\": \"Sukurti MP3 failus iš M4B su skyrių skaldymu ir įterptais metaduomenimis, viršelio paveikslu ir skyriais.\",\n  \"LabelTotalDuration\": \"Viso trukmė\",\n  \"LabelTotalTimeListened\": \"Iš viso klausyta laiko\",\n  \"LabelTrackFromFilename\": \"Takelis iš failo pavadinimo\",\n  \"LabelTrackFromMetadata\": \"Takelis iš metaduomenų\",\n  \"LabelTracks\": \"Takeliai\",\n  \"LabelTracksMultiTrack\": \"Keli takeliai\",\n  \"LabelTracksSingleTrack\": \"Vienas takelis\",\n  \"LabelType\": \"Tipas\",\n  \"LabelUnabridged\": \"Neprikurptas\",\n  \"LabelUnknown\": \"Nežinoma\",\n  \"LabelUpdateCover\": \"Atnaujinti viršelį\",\n  \"LabelUpdateCoverHelp\": \"Leisti perrašyti esamus viršelius pasirinktoms knygoms, kai yra rasta atitikmenų\",\n  \"LabelUpdateDetails\": \"Atnaujinti duomenis\",\n  \"LabelUpdateDetailsHelp\": \"Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų\",\n  \"LabelUpdatedAt\": \"Atnaujinta\",\n  \"LabelUploaderDragAndDrop\": \"Tempkite ir paleiskite failus ar aplankus\",\n  \"LabelUploaderDropFiles\": \"Nutempti failus\",\n  \"LabelUseChapterTrack\": \"Naudoti skyrių takelį\",\n  \"LabelUseFullTrack\": \"Naudoti visą takelį\",\n  \"LabelUser\": \"Vartotojas\",\n  \"LabelUsername\": \"Vartotojo vardas\",\n  \"LabelValue\": \"Reikšmė\",\n  \"LabelVersion\": \"Versija\",\n  \"LabelViewBookmarks\": \"Peržiūrėti skirtukus\",\n  \"LabelViewChapters\": \"Peržiūrėti skyrius\",\n  \"LabelViewQueue\": \"Peržiūrėti grotuvo eilę\",\n  \"LabelVolume\": \"Garsumas\",\n  \"LabelWeekdaysToRun\": \"Dienos, kuriomis vykdyti\",\n  \"LabelYourAudiobookDuration\": \"Jūsų garso knygos trukmė\",\n  \"LabelYourBookmarks\": \"Jūsų skirtukai\",\n  \"LabelYourPlaylists\": \"Jūsų grojaraščiai\",\n  \"LabelYourProgress\": \"Jūsų pažanga\",\n  \"MessageAddToPlayerQueue\": \"Pridėti į grotuvo eilę\",\n  \"MessageAppriseDescription\": \"Norint naudoti šią funkciją, reikės turėti <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> veikiantį arba API, kuris tvarkys tas pačias užklausas.<br />Apprise API URL turėtų būti visi kelio takai iki pranešimo siuntimo, pvz., jei jūsų API pasiekiamas adresu <code>http://192.168.1.1:8337</code>, tada įveskite <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageBackupsDescription\": \"Atsarginės kopijos apima vartotojus, vartotojų pažangą, bibliotekos elemento informaciją, serverio nustatymus ir vaizdus, saugomus <code>/metadata/items</code> ir <code>/metadata/authors</code>. Atsarginės kopijos <strong>neįtraukia</strong> jokių failų, saugomų jūsų bibliotekos aplankuose.\",\n  \"MessageBatchQuickMatchDescription\": \"Greitas atitikmens rasti bandys pridėti trūkstamus viršelius ir metaduomenis pasirinktiems elementams. Įjunkite žemiau esančias parinktis, kad leistumėte Greitajam atitikmeniui perrašyti esamus viršelius ir/ar metaduomenis.\",\n  \"MessageBookshelfNoCollections\": \"Dar nepridėjote jokių kolekcijų\",\n  \"MessageBookshelfNoRSSFeeds\": \"Nėra atvertų RSS srautų\",\n  \"MessageBookshelfNoResultsForFilter\": \"Rezultatų pagal filtrą \\\"{0}: {1}\\\" nėra\",\n  \"MessageBookshelfNoSeries\": \"Neturite jokių serijų\",\n  \"MessageChapterEndIsAfter\": \"Skyriaus pabaiga yra po jūsų garso knygos pabaigos\",\n  \"MessageChapterErrorFirstNotZero\": \"Pirmasis skyrius turi prasidėti nuo 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Netinkamas pradžios laikas. Turi būti mažesnis nei garso knygos trukmė\",\n  \"MessageChapterErrorStartLtPrev\": \"Netinkamas pradžios laikas. Turi būti didesnis arba lygus ankstesnio skyriaus pradžios laikui\",\n  \"MessageChapterStartIsAfter\": \"Skyriaus pradžia yra po jūsų garso knygos pabaigos\",\n  \"MessageCheckingCron\": \"Tikrinamas cron...\",\n  \"MessageConfirmDeleteBackup\": \"Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?\",\n  \"MessageConfirmDeleteFile\": \"Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?\",\n  \"MessageConfirmDeleteLibrary\": \"Ar tikrai norite visam laikui ištrinti biblioteką \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteSession\": \"Ar tikrai norite ištrinti šią sesiją?\",\n  \"MessageConfirmForceReScan\": \"Ar tikrai norite priversti perskenavimą?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Ar tikrai norite pažymėti visus epizodus kaip užbaigtus?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Ar tikrai norite pažymėti visus epizodus kaip nebaigtus?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Ar tikrai norite pažymėti visas knygas šioje serijoje kaip užbaigtas?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Ar tikrai norite pažymėti visas knygas šioje serijoje kaip nebaigtas?\",\n  \"MessageConfirmRemoveAllChapters\": \"Ar tikrai norite pašalinti visus skyrius?\",\n  \"MessageConfirmRemoveCollection\": \"Ar tikrai norite pašalinti kolekciją \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Ar tikrai norite pašalinti epizodą \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodes\": \"Ar tikrai norite pašalinti {0} epizodus?\",\n  \"MessageConfirmRemoveNarrator\": \"Ar tikrai norite pašalinti skaitytoją \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Ar tikrai norite pašalinti savo grojaraštį \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Ar tikrai norite pervadinti žanrą \\\"{0}\\\" į \\\"{1}\\\" visiems elementams?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Pastaba: šis žanras jau yra, todėl jie bus sujungti.\",\n  \"MessageConfirmRenameGenreWarning\": \"Įspėjimas! Panašus žanras jau yra \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Ar tikrai norite pervadinti žymą \\\"{0}\\\" į \\\"{1}\\\" visiems elementams?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Pastaba: ši žyma jau egzistuoja, todėl jos bus sujungtos.\",\n  \"MessageConfirmRenameTagWarning\": \"Įspėjimas! Panaši žyma jau egzistuoja \\\"{0}\\\".\",\n  \"MessageConfirmSendEbookToDevice\": \"Ar tikrai norite nusiųsti {0} el. knygą \\\"{1}\\\" į įrenginį \\\"{2}\\\"?\",\n  \"MessageDownloadingEpisode\": \"Epizodas atsisiunčiamas\",\n  \"MessageDragFilesIntoTrackOrder\": \"Surikiuokite takelius vilkdami failus\",\n  \"MessageEmbedFinished\": \"Įterpimas baigtas!\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} epizodai laukia atsisiuntimo\",\n  \"MessageFeedURLWillBe\": \"Srauto URL bus {0}\",\n  \"MessageFetching\": \"Surenkama...\",\n  \"MessageForceReScanDescription\": \"skenuos visus failus lyg iš naujo. Garsinių failų ID3 žymos, OPF failai ir tekstiniai failai bus nuskenuoti kaip nauji.\",\n  \"MessageImportantNotice\": \"Svarbus pranešimas!\",\n  \"MessageInsertChapterBelow\": \"Įterpti skyrių žemiau\",\n  \"MessageItemsSelected\": \"Pasirinkti {0} elementai (-ų)\",\n  \"MessageItemsUpdated\": \"Atnaujinti {0} elementai (-ų)\",\n  \"MessageJoinUsOn\": \"Prisijunkite prie mūsų\",\n  \"MessageLoading\": \"Kraunama...\",\n  \"MessageLoadingFolders\": \"Kraunami aplankai...\",\n  \"MessageM4BFailed\": \"M4B Nepavyko!\",\n  \"MessageM4BFinished\": \"M4B Baigta!\",\n  \"MessageMapChapterTitles\": \"Susieti skyriaus pavadinimus su jūsų esamais garso knygos skyriais, neredaguojant laiko žymų\",\n  \"MessageMarkAllEpisodesFinished\": \"Pažymėti visus epizodus kaip užbaigtus\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Pažymėti visus epizodus kaip nebaigtus\",\n  \"MessageMarkAsFinished\": \"Pažymėti kaip užbaigtą\",\n  \"MessageMarkAsNotFinished\": \"Pažymėti kaip nebaigtą\",\n  \"MessageMatchBooksDescription\": \"bandys suderinti bibliotekos knygas su knyga iš pasirinkto paieškos tiekėjo ir užpildys tuščius duomenis ir viršelius. Neperrašo detalių.\",\n  \"MessageNoAudioTracks\": \"Nėra garso takelių\",\n  \"MessageNoAuthors\": \"Nėra autorių\",\n  \"MessageNoBackups\": \"Nėra atsarginių kopijų\",\n  \"MessageNoBookmarks\": \"Nėra žymų\",\n  \"MessageNoChapters\": \"Nėra skyrių\",\n  \"MessageNoCollections\": \"Nėra kolekcijų\",\n  \"MessageNoCoversFound\": \"Nerasta viršelių\",\n  \"MessageNoDescription\": \"Nėra aprašymo\",\n  \"MessageNoDownloadsInProgress\": \"Nėra vykstančių atsisiuntimų\",\n  \"MessageNoDownloadsQueued\": \"Nėra eilėje esančių atsisiuntimų\",\n  \"MessageNoEpisodeMatchesFound\": \"Nerasta epizodo atitikmenų\",\n  \"MessageNoEpisodes\": \"Nėra epizodų\",\n  \"MessageNoFoldersAvailable\": \"Nėra prieinamų aplankų\",\n  \"MessageNoGenres\": \"Nėra žanrų\",\n  \"MessageNoIssues\": \"Nėra problemų\",\n  \"MessageNoItems\": \"Nėra elementų\",\n  \"MessageNoItemsFound\": \"Elementų nerasta\",\n  \"MessageNoListeningSessions\": \"Klausymo sesijų nėra\",\n  \"MessageNoLogs\": \"Žurnalo įrašų nėra\",\n  \"MessageNoMediaProgress\": \"Nėra medijos pažangos\",\n  \"MessageNoNotifications\": \"Nėra pranešimų\",\n  \"MessageNoPodcastsFound\": \"Tinklalaidžių nerasta\",\n  \"MessageNoResults\": \"Rezultatų nėra\",\n  \"MessageNoSearchResultsFor\": \"Paieškos rezultatų nėra „{0}“\",\n  \"MessageNoSeries\": \"Serijų nėra\",\n  \"MessageNoTags\": \"Žymų nėra\",\n  \"MessageNoTasksRunning\": \"Nėra vykstančių užduočių\",\n  \"MessageNoUpdatesWereNecessary\": \"Nereikalingi jokie atnaujinimai\",\n  \"MessageNoUserPlaylists\": \"Neturite grojaraščių\",\n  \"MessageNotYetImplemented\": \"Dar neįgyvendinta\",\n  \"MessageOr\": \"arba\",\n  \"MessagePauseChapter\": \"Pristabdyti skyriaus grojimą\",\n  \"MessagePlayChapter\": \"Paklausyti skyriaus pradžios\",\n  \"MessagePlaylistCreateFromCollection\": \"Sukurti grojaraštį iš kolekcijos\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Tinklalidė neturi RSS srauto URL kuriuo būtų galima sulyginti\",\n  \"MessageQuickMatchDescription\": \"Užpildykite tuščius elementų duomenis ir viršelius su pirmuoju atitikimo rezultatu iš „{0}“. Neneperrašo detalių, nebent įgalintas serverio nustatymas „Pirmenybė atitaikytiems metaduomenis“.\",\n  \"MessageRemoveChapter\": \"Pašalinti skyrių\",\n  \"MessageRemoveEpisodes\": \"Pašalinti {0} epizodų (-ą)\",\n  \"MessageRemoveFromPlayerQueue\": \"Pašalinti iš grojaraščio\",\n  \"MessageRemoveUserWarning\": \"Ar tikrai norite visam laikui ištrinti naudotoją „{0}“?\",\n  \"MessageReportBugsAndContribute\": \"Praneškite apie klaidas, prašykite naujovių ir prisidėkite\",\n  \"MessageResetChaptersConfirm\": \"Ar tikrai norite atkurti skyrius ir atšaukti pakeitimus, kuriuos atlikote?\",\n  \"MessageRestoreBackupConfirm\": \"Ar tikrai norite atkurti atsarginę kopiją, sukurtą\",\n  \"MessageRestoreBackupWarning\": \"Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.\",\n  \"MessageSearchResultsFor\": \"Paieškos rezultatai „{0}“\",\n  \"MessageServerCouldNotBeReached\": \"Nepavyko pasiekti serverio\",\n  \"MessageSetChaptersFromTracksDescription\": \"Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą\",\n  \"MessageStartPlaybackAtTime\": \"Paleisti klausymą „{0}“ nuo {1}?\",\n  \"MessageThinking\": \"Mąstau...\",\n  \"MessageUploaderItemFailed\": \"Įkelti nepavyko\",\n  \"MessageUploaderItemSuccess\": \"Sėkmingai įkelta!\",\n  \"MessageUploading\": \"Įkeliama...\",\n  \"MessageValidCronExpression\": \"Galiojanti cron išraiška\",\n  \"MessageWatcherIsDisabledGlobally\": \"Serverio nustatymuose stebėtojas išjungtas visuotinai\",\n  \"MessageXLibraryIsEmpty\": \"{0} biblioteka tuščia!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Jūsų garso knygos trukmė yra ilgesnė nei rasta trukmė\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Jūsų garso knygos trukmė yra trumpesnė nei rasta trukmė\",\n  \"NoteChangeRootPassword\": \"Tik root vartotojas gali turėti tuščią slaptažodį\",\n  \"NoteChapterEditorTimes\": \"Pastaba: Pirmasis skyriaus pradžios laikas turi likti 0:00, o paskutinio skyriaus pradžios laikas negali viršyti šios garso knygos trukmės.\",\n  \"NoteFolderPicker\": \"Pastaba: jau susieti aplankai nebus rodomi\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Įspėjimas: Dauguma tinklalaidžių programų reikalauja, kad RSS kanalo URL būtų naudojamas su HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Įspėjimas: Vienas ar daugiau jūsų epizodų neturi publikavimo datos. Kai kurios tinklalaidžių programos to reikalauja.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Aplankai su medijos failais bus tvarkomi kaip atskiri bibliotekos elementai.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Jei įkeliami tik garso failai, kiekvienas garso failas bus tvarkomas kaip atskira garso knyga.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nepalaikomi failai yra ignoruojami. Pasirinkus ar atidarant aplanką, kiti failai, nesantys elementų aplankuose, yra ignoruojami.\",\n  \"PlaceholderNewCollection\": \"Naujas kolekcijos pavadinimas\",\n  \"PlaceholderNewFolderPath\": \"Naujas aplanko kelias\",\n  \"PlaceholderNewPlaylist\": \"Naujas grojaraščio pavadinimas\",\n  \"PlaceholderSearch\": \"Ieškoti..\",\n  \"PlaceholderSearchEpisode\": \"Ieškoti epizodo..\",\n  \"ToastAccountUpdateSuccess\": \"Paskyra atnaujinta\",\n  \"ToastAuthorImageRemoveSuccess\": \"Autoriaus paveiksliukas pašalintas\",\n  \"ToastAuthorUpdateMerged\": \"Autorius sujungtas\",\n  \"ToastAuthorUpdateSuccess\": \"Autorius atnaujintas\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autorius atnaujintas (paveiksliukas nerastas)\",\n  \"ToastBackupCreateFailed\": \"Atsarginės kopijos sukurti nepavyko\",\n  \"ToastBackupCreateSuccess\": \"Atsarginė kopija sukurta\",\n  \"ToastBackupDeleteFailed\": \"Atsarginės kopijos ištrinti nepavyko\",\n  \"ToastBackupDeleteSuccess\": \"Atsarginė kopija ištrinta\",\n  \"ToastBackupRestoreFailed\": \"Atsarginės kopijos atkurti nepavyko\",\n  \"ToastBackupUploadFailed\": \"Atsarginės kopijos įkelti nepavyko\",\n  \"ToastBackupUploadSuccess\": \"Atsarginė kopija įkelta\",\n  \"ToastBatchUpdateFailed\": \"Masinis atnaujinimas nepavyko\",\n  \"ToastBatchUpdateSuccess\": \"Masinis atnaujinimas sėkmingas\",\n  \"ToastBookmarkCreateFailed\": \"Žymos sukurti nepavyko\",\n  \"ToastBookmarkCreateSuccess\": \"Žyma pridėta\",\n  \"ToastBookmarkRemoveSuccess\": \"Žyma pašalinta\",\n  \"ToastChaptersHaveErrors\": \"Skyriai turi klaidų\",\n  \"ToastChaptersMustHaveTitles\": \"Skyriai turi turėti pavadinimus\",\n  \"ToastChaptersRemoved\": \"Skyriai pašalinti\",\n  \"ToastCollectionItemsAddFailed\": \"Nepavyko pridėti į kolekciją\",\n  \"ToastCollectionRemoveSuccess\": \"Kolekcija pašalinta\",\n  \"ToastCollectionUpdateSuccess\": \"Kolekcija atnaujinta\",\n  \"ToastCoverUpdateFailed\": \"Viršelio atnaujinimas nepavyko\",\n  \"ToastDeviceTestEmailSuccess\": \"Bandomasis el. laiškas išsiųstas\",\n  \"ToastItemCoverUpdateSuccess\": \"Elemento viršelis atnaujintas\",\n  \"ToastItemDeletedFailed\": \"Nepavyko ištrinti\",\n  \"ToastItemDeletedSuccess\": \"Ištrinta\",\n  \"ToastItemDetailsUpdateSuccess\": \"Elemento detalės atnaujintos\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Pažymėti kaip Baigta nepavyko\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Elementas pažymėtas kaip Baigta\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Pažymėti kaip Nebaigta nepavyko\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Elementas pažymėtas kaip Nebaigta\",\n  \"ToastLibraryCreateFailed\": \"Bibliotekos sukurti nepavyko\",\n  \"ToastLibraryCreateSuccess\": \"Biblioteka \\\"{0}\\\" sukurta\",\n  \"ToastLibraryDeleteFailed\": \"Bibliotekos ištrinti nepavyko\",\n  \"ToastLibraryDeleteSuccess\": \"Biblioteka ištrinta\",\n  \"ToastLibraryScanFailedToStart\": \"Nepavyko pradėti bibliotekos skenavimo\",\n  \"ToastLibraryScanStarted\": \"Bibliotekos skenavimas pradėtas\",\n  \"ToastLibraryUpdateSuccess\": \"Biblioteka \\\"{0}\\\" atnaujinta\",\n  \"ToastPlaylistCreateFailed\": \"Grojaraščio sukurti nepavyko\",\n  \"ToastPlaylistCreateSuccess\": \"Grojaraštis sukurtas\",\n  \"ToastPlaylistRemoveSuccess\": \"Grojaraštis pašalintas\",\n  \"ToastPlaylistUpdateSuccess\": \"Grojaraštis atnaujintas\",\n  \"ToastPodcastCreateFailed\": \"Tinklalaidės sukurti nepavyko\",\n  \"ToastPodcastCreateSuccess\": \"Tinklalaidė sėkmingai sukurta\",\n  \"ToastRSSFeedCloseFailed\": \"RSS srauto uždaryti nepavyko\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS srautas uždarytas\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Elemento pašalinti iš kolekcijos nepavyko\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Elementas pašalintas iš kolekcijos\",\n  \"ToastSendEbookToDeviceFailed\": \"Nepavyko nusiųsti e-knygos į įrenginį\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-knyga išsiųsta į įrenginį \\\"{0}\\\"\",\n  \"ToastSeriesUpdateFailed\": \"Serijos atnaujinti nepavyko\",\n  \"ToastSeriesUpdateSuccess\": \"Serijos atnaujintos\",\n  \"ToastSessionDeleteFailed\": \"Sesijos ištrinti nepavyko\",\n  \"ToastSessionDeleteSuccess\": \"Sesija ištrinta\",\n  \"ToastSocketConnected\": \"Serveris prijungtas\",\n  \"ToastSocketDisconnected\": \"Severis atjungtas\",\n  \"ToastSocketFailedToConnect\": \"Nepavyko prisijungti prie serverio\",\n  \"ToastUserDeleteFailed\": \"Nepavyko ištrinti naudotojo\",\n  \"ToastUserDeleteSuccess\": \"Naudotojas ištrintas\"\n}\n"
  },
  {
    "path": "client/strings/nl.json",
    "content": "{\n  \"ButtonAdd\": \"Toevoegen\",\n  \"ButtonAddApiKey\": \"API Key toevoegen\",\n  \"ButtonAddChapters\": \"Hoofdstukken toevoegen\",\n  \"ButtonAddDevice\": \"Toestel toevoegen\",\n  \"ButtonAddLibrary\": \"Bibliotheek toevoegen\",\n  \"ButtonAddPodcasts\": \"Podcasts toevoegen\",\n  \"ButtonAddUser\": \"Gebruiker toevoegen\",\n  \"ButtonAddYourFirstLibrary\": \"Voeg je eerste bibliotheek toe\",\n  \"ButtonApply\": \"Pas toe\",\n  \"ButtonApplyChapters\": \"Hoofdstukken toepassen\",\n  \"ButtonAuthors\": \"Auteurs\",\n  \"ButtonBack\": \"Terug\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Vul in met huidige\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Kaartgegevens invullen\",\n  \"ButtonBrowseForFolder\": \"Bladeren naar map\",\n  \"ButtonCancel\": \"Annuleren\",\n  \"ButtonCancelEncode\": \"Encoding annuleren\",\n  \"ButtonChangeRootPassword\": \"Root-wachtwoord wijzigen\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Check & Download nieuwe afleveringen\",\n  \"ButtonChooseAFolder\": \"Map kiezen\",\n  \"ButtonChooseFiles\": \"Bestanden kiezen\",\n  \"ButtonClearFilter\": \"Filter verwijderen\",\n  \"ButtonClose\": \"Sluiten\",\n  \"ButtonCloseFeed\": \"Feed sluiten\",\n  \"ButtonCloseSession\": \"Sluit Sessie\",\n  \"ButtonCollections\": \"Collecties\",\n  \"ButtonConfigureScanner\": \"Configureer scanner\",\n  \"ButtonCreate\": \"Aanmaken\",\n  \"ButtonCreateBackup\": \"Maak back-up\",\n  \"ButtonDelete\": \"Verwijder\",\n  \"ButtonDownloadQueue\": \"Wachtrij\",\n  \"ButtonEdit\": \"Wijzig\",\n  \"ButtonEditChapters\": \"Hoofdstukken wijzigen\",\n  \"ButtonEditPodcast\": \"Podcast wijzigen\",\n  \"ButtonEnable\": \"Aanzetten\",\n  \"ButtonFireAndFail\": \"Uitvoeren en falen\",\n  \"ButtonFireOnTest\": \"Test-Event uitvoeren\",\n  \"ButtonForceReScan\": \"Forceer nieuwe scan\",\n  \"ButtonFullPath\": \"Volledig pad\",\n  \"ButtonHide\": \"Verberg\",\n  \"ButtonHome\": \"Thuis\",\n  \"ButtonIssues\": \"Problemen\",\n  \"ButtonJumpBackward\": \"Spring achteruit\",\n  \"ButtonJumpForward\": \"Spring vooruit\",\n  \"ButtonLatest\": \"Meest recent\",\n  \"ButtonLibrary\": \"Bibliotheek\",\n  \"ButtonLogout\": \"Uitloggen\",\n  \"ButtonLookup\": \"Zoeken\",\n  \"ButtonManageTracks\": \"Tracks beheren\",\n  \"ButtonMapChapterTitles\": \"Hoofdstuktitels mappen\",\n  \"ButtonMatchAllAuthors\": \"Alle auteurs matchen\",\n  \"ButtonMatchBooks\": \"Alle boeken matchen\",\n  \"ButtonNevermind\": \"Laat maar\",\n  \"ButtonNext\": \"Volgende\",\n  \"ButtonNextChapter\": \"Volgend hoofdstuk\",\n  \"ButtonNextItemInQueue\": \"Volgend Item in Wachtrij\",\n  \"ButtonOk\": \"Akkoord\",\n  \"ButtonOpenFeed\": \"Feed openen\",\n  \"ButtonOpenManager\": \"Manager openen\",\n  \"ButtonPause\": \"Pauze\",\n  \"ButtonPlay\": \"Afspelen\",\n  \"ButtonPlayAll\": \"Alles Afspelen\",\n  \"ButtonPlaying\": \"Speelt\",\n  \"ButtonPlaylists\": \"Afspeellijsten\",\n  \"ButtonPrevious\": \"Vorige\",\n  \"ButtonPreviousChapter\": \"Vorig hoofdstuk\",\n  \"ButtonProbeAudioFile\": \"Onderzoek Audio Bestand\",\n  \"ButtonPurgeAllCache\": \"Volledige cache legen\",\n  \"ButtonPurgeItemsCache\": \"Onderdelen-cache legen\",\n  \"ButtonQueueAddItem\": \"In wachtrij zetten\",\n  \"ButtonQueueRemoveItem\": \"Uit wachtrij verwijderen\",\n  \"ButtonQuickEmbed\": \"Snel Embedden\",\n  \"ButtonQuickEmbedMetadata\": \"Snel Metadata Insluiten\",\n  \"ButtonQuickMatch\": \"Snelle match\",\n  \"ButtonReScan\": \"Nieuwe scan\",\n  \"ButtonRead\": \"Lezen\",\n  \"ButtonReadLess\": \"Lees minder\",\n  \"ButtonReadMore\": \"Lees meer\",\n  \"ButtonRefresh\": \"Verversen\",\n  \"ButtonRemove\": \"Verwijder\",\n  \"ButtonRemoveAll\": \"Alles verwijderen\",\n  \"ButtonRemoveAllLibraryItems\": \"Verwijder volledige bibliotheekinhoud\",\n  \"ButtonRemoveFromContinueListening\": \"Vewijder uit Verder luisteren\",\n  \"ButtonRemoveFromContinueReading\": \"Verwijder van Verder luisteren\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Verwijder serie uit Serie vervolgen\",\n  \"ButtonReset\": \"Opnieuw Instellen\",\n  \"ButtonResetToDefault\": \"Standaardwaarden Terugzetten\",\n  \"ButtonRestore\": \"Herstel\",\n  \"ButtonSave\": \"Opslaan\",\n  \"ButtonSaveAndClose\": \"Opslaan & sluiten\",\n  \"ButtonSaveTracklist\": \"Afspeellijst opslaan\",\n  \"ButtonScan\": \"Scannen\",\n  \"ButtonScanLibrary\": \"Scan bibliotheek\",\n  \"ButtonScrollLeft\": \"Scroll Links\",\n  \"ButtonScrollRight\": \"Scroll Rechts\",\n  \"ButtonSearch\": \"Zoeken\",\n  \"ButtonSelectFolderPath\": \"Maplocatie selecteren\",\n  \"ButtonSeries\": \"Series\",\n  \"ButtonSetChaptersFromTracks\": \"Maak hoofdstukken op basis van tracks\",\n  \"ButtonShare\": \"Deel\",\n  \"ButtonShiftTimes\": \"Tijden verschuiven\",\n  \"ButtonShow\": \"Toon\",\n  \"ButtonStartM4BEncode\": \"Start M4B-encoding\",\n  \"ButtonStartMetadataEmbed\": \"Start insluiten metadata\",\n  \"ButtonStats\": \"Statistieken\",\n  \"ButtonSubmit\": \"Indienen\",\n  \"ButtonTest\": \"Testen\",\n  \"ButtonUnlinkOpenId\": \"OpenID Ontkoppelen\",\n  \"ButtonUpload\": \"Upload\",\n  \"ButtonUploadBackup\": \"Upload back-up\",\n  \"ButtonUploadCover\": \"Omslag uploaden\",\n  \"ButtonUploadOPMLFile\": \"Upload OPML-bestand\",\n  \"ButtonUserDelete\": \"Verwijder gebruiker {0}\",\n  \"ButtonUserEdit\": \"Wijzig gebruiker {0}\",\n  \"ButtonViewAll\": \"Toon alle\",\n  \"ButtonYes\": \"Ja\",\n  \"ErrorUploadFetchMetadataAPI\": \"Error metadata ophalen\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Kan metadata niet ophalen - probeer de titel en/of auteur te updaten\",\n  \"ErrorUploadLacksTitle\": \"Moet een titel hebben\",\n  \"HeaderAccount\": \"Account\",\n  \"HeaderAddCustomMetadataProvider\": \"Aangepaste Metadataprovider Toevoegen\",\n  \"HeaderAdvanced\": \"Geavanceerd\",\n  \"HeaderApiKeys\": \"API Key\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise-notificatie instellingen\",\n  \"HeaderAudioTracks\": \"Audiotracks\",\n  \"HeaderAudiobookTools\": \"Audioboekbestandbeheer tools\",\n  \"HeaderAuthentication\": \"Authenticatie\",\n  \"HeaderBackups\": \"Back-ups\",\n  \"HeaderBulkChapterModal\": \"Meerdere hoofdstukken toevoegen\",\n  \"HeaderChangePassword\": \"Wachtwoord wijzigen\",\n  \"HeaderChapters\": \"Hoofdstukken\",\n  \"HeaderChooseAFolder\": \"Map kiezen\",\n  \"HeaderCollection\": \"Collectie\",\n  \"HeaderCollectionItems\": \"Collectie-objecten\",\n  \"HeaderCover\": \"Omslag\",\n  \"HeaderCurrentDownloads\": \"Huidige downloads\",\n  \"HeaderCustomMessageOnLogin\": \"Aangepast Bericht bij Aanmelden\",\n  \"HeaderCustomMetadataProviders\": \"Aangepaste Metadata Providers\",\n  \"HeaderDetails\": \"Details\",\n  \"HeaderDownloadQueue\": \"Download-wachtrij\",\n  \"HeaderEbookFiles\": \"Ebook bestanden\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"E-mail instellingen\",\n  \"HeaderEpisodes\": \"Afleveringen\",\n  \"HeaderEreaderDevices\": \"Ereader-apparaten\",\n  \"HeaderEreaderSettings\": \"Ereader-instellingen\",\n  \"HeaderFiles\": \"Bestanden\",\n  \"HeaderFindChapters\": \"Zoek hoofdstukken\",\n  \"HeaderIgnoredFiles\": \"Genegeerde bestanden\",\n  \"HeaderItemFiles\": \"Onderdeel-bestanden\",\n  \"HeaderItemMetadataUtils\": \"Onderdeel-metadata Utils\",\n  \"HeaderLastListeningSession\": \"Laatste luistersessie\",\n  \"HeaderLatestEpisodes\": \"Laatste afleveringen\",\n  \"HeaderLibraries\": \"Bibliotheken\",\n  \"HeaderLibraryFiles\": \"Bibliotheekbestanden\",\n  \"HeaderLibraryStats\": \"Bibliotheekstatistieken\",\n  \"HeaderListeningSessions\": \"Luistersessies\",\n  \"HeaderListeningStats\": \"Luisterstatistieken\",\n  \"HeaderLogin\": \"Aanmelden\",\n  \"HeaderLogs\": \"Logboek\",\n  \"HeaderManageGenres\": \"Genres beheren\",\n  \"HeaderManageTags\": \"Tags beheren\",\n  \"HeaderMapDetails\": \"Details map\",\n  \"HeaderMatch\": \"Vergelijken\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metadata volgorde\",\n  \"HeaderMetadataToEmbed\": \"In te sluiten metadata\",\n  \"HeaderNewAccount\": \"Nieuwe account\",\n  \"HeaderNewApiKey\": \"Nieuwe API Key\",\n  \"HeaderNewLibrary\": \"Nieuwe bibliotheek\",\n  \"HeaderNotificationCreate\": \"Notificatie Aanmaken\",\n  \"HeaderNotificationUpdate\": \"Update Notificatie\",\n  \"HeaderNotifications\": \"Notificaties\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect Authenticatie\",\n  \"HeaderOpenListeningSessions\": \"Open Luistersessies\",\n  \"HeaderOpenRSSFeed\": \"Open RSS-feed\",\n  \"HeaderOtherFiles\": \"Andere bestanden\",\n  \"HeaderPasswordAuthentication\": \"Wachtwoord Authenticatie\",\n  \"HeaderPermissions\": \"Toestemmingen\",\n  \"HeaderPlayerQueue\": \"Afspeelwachtrij\",\n  \"HeaderPlayerSettings\": \"Speler Instellingen\",\n  \"HeaderPlaylist\": \"Afspeellijst\",\n  \"HeaderPlaylistItems\": \"Onderdelen in afspeellijst\",\n  \"HeaderPodcastsToAdd\": \"Toe te voegen podcasts\",\n  \"HeaderPresets\": \"Voorinstellingen\",\n  \"HeaderPreviewCover\": \"Voorbeeld omslag\",\n  \"HeaderRSSFeedGeneral\": \"RSS-details\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-feed is open\",\n  \"HeaderRSSFeeds\": \"RSS-feeds\",\n  \"HeaderRemoveEpisode\": \"Aflevering verwijderen\",\n  \"HeaderRemoveEpisodes\": \"Verwijder {0} afleveringen\",\n  \"HeaderSavedMediaProgress\": \"Opgeslagen mediavoortgang\",\n  \"HeaderSchedule\": \"Schema\",\n  \"HeaderScheduleEpisodeDownloads\": \"Automatische afleveringsdownloads plannen\",\n  \"HeaderScheduleLibraryScans\": \"Schema automatische bibliotheekscans\",\n  \"HeaderSession\": \"Sessie\",\n  \"HeaderSetBackupSchedule\": \"Kies schema voor back-up\",\n  \"HeaderSettings\": \"Instellingen\",\n  \"HeaderSettingsDisplay\": \"Toon\",\n  \"HeaderSettingsExperimental\": \"Experimentele functies\",\n  \"HeaderSettingsGeneral\": \"Algemeen\",\n  \"HeaderSettingsScanner\": \"Scanner\",\n  \"HeaderSettingsSecurity\": \"Beveiliging\",\n  \"HeaderSettingsWebClient\": \"Web Client\",\n  \"HeaderSleepTimer\": \"Slaaptimer\",\n  \"HeaderStatsLargestItems\": \"Grootste items\",\n  \"HeaderStatsLongestItems\": \"Langste items (uren)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minuten geluisterd (laatste 7 dagen)\",\n  \"HeaderStatsRecentSessions\": \"Recente sessies\",\n  \"HeaderStatsTop10Authors\": \"Top 10 auteurs\",\n  \"HeaderStatsTop5Genres\": \"Top 5 genres\",\n  \"HeaderTableOfContents\": \"Inhoudsopgave\",\n  \"HeaderTools\": \"Gereedschap\",\n  \"HeaderUpdateAccount\": \"Account bijwerken\",\n  \"HeaderUpdateApiKey\": \"API Key updaten\",\n  \"HeaderUpdateAuthor\": \"Auteur bijwerken\",\n  \"HeaderUpdateDetails\": \"Details bijwerken\",\n  \"HeaderUpdateLibrary\": \"Bibliotheek bijwerken\",\n  \"HeaderUsers\": \"Gebruikers\",\n  \"HeaderYearReview\": \"Jaar {0} in Review\",\n  \"HeaderYourStats\": \"Je statistieken\",\n  \"LabelAbridged\": \"Verkort\",\n  \"LabelAbridgedChecked\": \"Verkort (gechecked)\",\n  \"LabelAbridgedUnchecked\": \"Onverkort (niet gechecked)\",\n  \"LabelAccessibleBy\": \"Toegankelijk door\",\n  \"LabelAccountType\": \"Accounttype\",\n  \"LabelAccountTypeAdmin\": \"Beheerder\",\n  \"LabelAccountTypeGuest\": \"Gast\",\n  \"LabelAccountTypeUser\": \"Gebruiker\",\n  \"LabelActivities\": \"Activiteiten\",\n  \"LabelActivity\": \"Activiteit\",\n  \"LabelAddToCollection\": \"Toevoegen aan collectie\",\n  \"LabelAddToCollectionBatch\": \"{0} boeken toevoegen aan collectie\",\n  \"LabelAddToPlaylist\": \"Toevoegen aan afspeellijst\",\n  \"LabelAddToPlaylistBatch\": \"{0} onderdelen toevoegen aan afspeellijst\",\n  \"LabelAddedAt\": \"Toegevoegd op\",\n  \"LabelAddedDate\": \"{0} toegevoegd\",\n  \"LabelAdminUsersOnly\": \"Enkel Admin gebruikers\",\n  \"LabelAll\": \"Alle\",\n  \"LabelAllEpisodesDownloaded\": \"Alle afleveringen gedownload\",\n  \"LabelAllUsers\": \"Alle gebruikers\",\n  \"LabelAllUsersExcludingGuests\": \"Alle gebruikers exclusief gasten\",\n  \"LabelAllUsersIncludingGuests\": \"Alle gebruikers inclusief gasten\",\n  \"LabelAlreadyInYourLibrary\": \"Reeds in je bibliotheek\",\n  \"LabelApiKeyCreated\": \"API Key \\\"{0}\\\" succesvol aangemaakt.\",\n  \"LabelApiKeyCreatedDescription\": \"Zorg ervoor dat je de API key nu kopieert, je kan deze later niet meer bekijken.\",\n  \"LabelApiKeyUser\": \"Uitvoeren namens de gebruiker\",\n  \"LabelApiKeyUserDescription\": \"Deze API key krijgt dezelfde rechten als de gebruiker waar deze zich tot voordoet. In de logs zullen de requests ook op naam van de gebruiker staan.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Achteraan toevoegen\",\n  \"LabelAudioBitrate\": \"Audio Bitrate (b.v. 128k)\",\n  \"LabelAudioChannels\": \"Audio Kanalen (1 of 2)\",\n  \"LabelAudioCodec\": \"Audio Codec\",\n  \"LabelAuthor\": \"Auteur\",\n  \"LabelAuthorFirstLast\": \"Auteur (Voornaam Achternaam)\",\n  \"LabelAuthorLastFirst\": \"Auteur (Achternaam, Voornaam)\",\n  \"LabelAuthors\": \"Auteurs\",\n  \"LabelAutoDownloadEpisodes\": \"Afleveringen automatisch downloaden\",\n  \"LabelAutoFetchMetadata\": \"Automatisch Metadata Ophalen\",\n  \"LabelAutoFetchMetadataHelp\": \"Haalt metadata op voor titel, auteur en serie om het uploaden te stroomlijnen. Aanvullende metadata moet mogelijk worden gematcht na het uploaden.\",\n  \"LabelAutoLaunch\": \"Automatisch Openen\",\n  \"LabelAutoLaunchDescription\": \"Automatisch doorverwijzen naar de auth-provider bij het navigeren naar de inlogpagina (handmatig pad <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatisch Registreren\",\n  \"LabelAutoRegisterDescription\": \"Automatisch nieuwe gebruikers aanmaken na inloggen\",\n  \"LabelBackToUser\": \"Terug naar gebruiker\",\n  \"LabelBackupAudioFiles\": \"Back-up audiobestanden\",\n  \"LabelBackupLocation\": \"Back-up locatie\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatische back-ups inschakelen\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Back-ups opgeslagen in /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maximale back-up-grootte (in GB) (0 voor ongelimiteerd)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Als een beveiliging tegen verkeerde instelling, zullen back-up mislukken als ze de ingestelde grootte overschrijden.\",\n  \"LabelBackupsNumberToKeep\": \"Aantal te bewaren back-ups\",\n  \"LabelBackupsNumberToKeepHelp\": \"Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Boeken\",\n  \"LabelButtonText\": \"Knop Tekst\",\n  \"LabelByAuthor\": \"Door {0}\",\n  \"LabelChangePassword\": \"Wachtwoord wijzigen\",\n  \"LabelChannels\": \"Kanalen\",\n  \"LabelChapterCount\": \"{0} Hoofdstukken\",\n  \"LabelChapterTitle\": \"Hoofdstuktitel\",\n  \"LabelChapters\": \"Hoofdstukken\",\n  \"LabelChaptersFound\": \"Hoofdstukken gevonden\",\n  \"LabelClickForMoreInfo\": \"Klik voor meer informatie\",\n  \"LabelClickToUseCurrentValue\": \"Klik om huidige waarde te gebruiken\",\n  \"LabelClosePlayer\": \"Sluit speler\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Series inklappen\",\n  \"LabelCollapseSubSeries\": \"Subserie samenvouwen\",\n  \"LabelCollection\": \"Collectie\",\n  \"LabelCollections\": \"Collecties\",\n  \"LabelComplete\": \"Compleet\",\n  \"LabelConfirmPassword\": \"Bevestig wachtwoord\",\n  \"LabelContinueListening\": \"Verder Luisteren\",\n  \"LabelContinueReading\": \"Verder lezen\",\n  \"LabelContinueSeries\": \"Doorgaan met Serie\",\n  \"LabelCorsAllowed\": \"CORS bronnen toestaan\",\n  \"LabelCover\": \"Omslag\",\n  \"LabelCoverImageURL\": \"Omslagafbeelding-URL\",\n  \"LabelCoverProvider\": \"Omslag bron\",\n  \"LabelCreatedAt\": \"Gecreëerd op\",\n  \"LabelCronExpression\": \"Cron-uitdrukking\",\n  \"LabelCurrent\": \"Huidig\",\n  \"LabelCurrently\": \"Op dit moment:\",\n  \"LabelCustomCronExpression\": \"Aangepaste Cron-uitdrukking:\",\n  \"LabelDatetime\": \"Datum-tijd\",\n  \"LabelDays\": \"Dagen\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Verwijderen uit bestandssysteem (uncheck om alleen uit database te verwijderen)\",\n  \"LabelDescription\": \"Beschrijving\",\n  \"LabelDeselectAll\": \"Deselecteer alle\",\n  \"LabelDetectedPattern\": \"Gedetecteerd patroon:\",\n  \"LabelDevice\": \"Apparaat\",\n  \"LabelDeviceInfo\": \"Apparaat info\",\n  \"LabelDeviceIsAvailableTo\": \"Apparaat is beschikbaar voor...\",\n  \"LabelDirectory\": \"Map\",\n  \"LabelDiscFromFilename\": \"Schijf uit bestandsnaam\",\n  \"LabelDiscFromMetadata\": \"Schijf uit metadata\",\n  \"LabelDiscover\": \"Ontdekken\",\n  \"LabelDownload\": \"Download\",\n  \"LabelDownloadNEpisodes\": \"Download {0} afleveringen\",\n  \"LabelDownloadable\": \"Downloadbaar\",\n  \"LabelDuration\": \"Duur\",\n  \"LabelDurationComparisonExactMatch\": \"(exacte overeenkomst)\",\n  \"LabelDurationComparisonLonger\": \"({0} langer)\",\n  \"LabelDurationComparisonShorter\": \"({0} korter)\",\n  \"LabelDurationFound\": \"Gevonden duur:\",\n  \"LabelEbook\": \"E-boek\",\n  \"LabelEbooks\": \"Eboeken\",\n  \"LabelEdit\": \"Wijzig\",\n  \"LabelEmail\": \"Email\",\n  \"LabelEmailSettingsFromAddress\": \"Van-adres\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Ongeautoriseerde certificaten afwijzen\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Het uitschakelen van SSL-certificaatvalidatie kan uw verbinding blootstellen aan beveiligingsrisico's, zoals man-in-the-middle-aanvallen. Schakel deze optie alleen uit als u de implicaties begrijpt en de mailserver waarmee u verbinding maakt vertrouwt.\",\n  \"LabelEmailSettingsSecure\": \"Veilig\",\n  \"LabelEmailSettingsSecureHelp\": \"Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Test-adres\",\n  \"LabelEmbeddedCover\": \"Omslag in bestand\",\n  \"LabelEnable\": \"Inschakelen\",\n  \"LabelEncodingBackupLocation\": \"Er wordt een back-up van uw originele audiobestanden opgeslagen in:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.\",\n  \"LabelEncodingClearItemCache\": \"Zorg ervoor dat u de cache van items regelmatig wist.\",\n  \"LabelEncodingFinishedM4B\": \"Een voltooide M4B wordt in uw audioboekfolder geplaatst in:\",\n  \"LabelEncodingInfoEmbedded\": \"Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.\",\n  \"LabelEncodingStartedNavigation\": \"Eenmaal de taak is gestart kan u weg navigeren van deze pagina.\",\n  \"LabelEncodingTimeWarning\": \"Encoding kan tot 30 minuten duren.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Waarschuwing: pas deze instellingen niet aan tenzij u bekend bent met de coderingsopties van ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.\",\n  \"LabelEnd\": \"Einde\",\n  \"LabelEndOfChapter\": \"Einde van het Hoofdstuk\",\n  \"LabelEpisode\": \"Aflevering\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Aflevering niet gelinkt aan RSS feed\",\n  \"LabelEpisodeNumber\": \"Aflevering #{0}\",\n  \"LabelEpisodeTitle\": \"Afleveringtitel\",\n  \"LabelEpisodeType\": \"Afleveringtype\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Aflevering URL van RSS feed\",\n  \"LabelEpisodes\": \"Afleveringen\",\n  \"LabelEpisodic\": \"Episodisch\",\n  \"LabelExample\": \"Voorbeeld\",\n  \"LabelExpandSeries\": \"Serie Uitvouwen\",\n  \"LabelExpandSubSeries\": \"Subserie Uitvouwen\",\n  \"LabelExpired\": \"Verlopen\",\n  \"LabelExpiresAt\": \"Loopt af op\",\n  \"LabelExpiresInSeconds\": \"Loopt af in (seconds) seconden\",\n  \"LabelExpiresNever\": \"Nooit\",\n  \"LabelExplicit\": \"Expliciet\",\n  \"LabelExplicitChecked\": \"Expliciet (gechecked)\",\n  \"LabelExplicitUnchecked\": \"Niet Expliciet (niet gechecked)\",\n  \"LabelExportOPML\": \"OPML exporteren\",\n  \"LabelFeedURL\": \"Feed URL\",\n  \"LabelFetchingMetadata\": \"Metadata ophalen\",\n  \"LabelFile\": \"Bestand\",\n  \"LabelFileBirthtime\": \"Aanmaaktijd bestand\",\n  \"LabelFileBornDate\": \"Geboren {0}\",\n  \"LabelFileModified\": \"Bestand gewijzigd\",\n  \"LabelFileModifiedDate\": \"Gewijzigd {0}\",\n  \"LabelFilename\": \"Bestandsnaam\",\n  \"LabelFilterByUser\": \"Filter op gebruiker\",\n  \"LabelFindEpisodes\": \"Zoek afleveringen\",\n  \"LabelFinished\": \"Voltooid\",\n  \"LabelFinishedDate\": \"Voltooid {0}\",\n  \"LabelFolder\": \"Map\",\n  \"LabelFolders\": \"Mappen\",\n  \"LabelFontBold\": \"Vetgedrukt\",\n  \"LabelFontBoldness\": \"Lettertype Dikte\",\n  \"LabelFontFamily\": \"Lettertypefamilie\",\n  \"LabelFontItalic\": \"Cursief\",\n  \"LabelFontScale\": \"Lettertype schaal\",\n  \"LabelFontStrikethrough\": \"Doorgestreept\",\n  \"LabelFormat\": \"Formaat\",\n  \"LabelFull\": \"Vol\",\n  \"LabelGenre\": \"Genre\",\n  \"LabelGenres\": \"Categorieën\",\n  \"LabelHardDeleteFile\": \"Bestand permanent verwijderen\",\n  \"LabelHasEbook\": \"Heeft Ebook\",\n  \"LabelHasSupplementaryEbook\": \"Heeft aanvullend Ebook\",\n  \"LabelHideSubtitles\": \"Ondertitels Verstoppen\",\n  \"LabelHighestPriority\": \"Hoogste Prioriteit\",\n  \"LabelHost\": \"Host\",\n  \"LabelHour\": \"Uur\",\n  \"LabelHours\": \"Uren\",\n  \"LabelIcon\": \"Icoon\",\n  \"LabelImageURLFromTheWeb\": \"Afbeelding URL van web\",\n  \"LabelInProgress\": \"Bezig\",\n  \"LabelIncludeInTracklist\": \"Includeer in tracklijst\",\n  \"LabelIncomplete\": \"Incompleet\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Aangepast dagelijks/wekelijks\",\n  \"LabelIntervalEvery12Hours\": \"Iedere 12 uur\",\n  \"LabelIntervalEvery15Minutes\": \"Iedere 15 minuten\",\n  \"LabelIntervalEvery2Hours\": \"Iedere 2 uur\",\n  \"LabelIntervalEvery30Minutes\": \"Iedere 30 minuten\",\n  \"LabelIntervalEvery6Hours\": \"Iedere 6 uur\",\n  \"LabelIntervalEveryDay\": \"Iedere dag\",\n  \"LabelIntervalEveryHour\": \"Ieder uur\",\n  \"LabelIntervalEveryMinute\": \"Elke minuut\",\n  \"LabelInvert\": \"Omdraaien\",\n  \"LabelItem\": \"Onderdeel\",\n  \"LabelJumpBackwardAmount\": \"Terugspoelen hoeveelheid\",\n  \"LabelJumpForwardAmount\": \"Vooruitspoelen hoeveelheid\",\n  \"LabelLanguage\": \"Taal\",\n  \"LabelLanguageDefaultServer\": \"Standaard servertaal\",\n  \"LabelLanguages\": \"Talen\",\n  \"LabelLastBookAdded\": \"Laatst toegevoegde boek\",\n  \"LabelLastBookUpdated\": \"Laatst bijgewerkte boek\",\n  \"LabelLastProgressDate\": \"Laatste vooruitgang: {0}\",\n  \"LabelLastSeen\": \"Laatst gezien\",\n  \"LabelLastTime\": \"Laatste keer\",\n  \"LabelLastUpdate\": \"Laatste wijziging\",\n  \"LabelLayout\": \"Layout\",\n  \"LabelLayoutSinglePage\": \"Enkele pagina\",\n  \"LabelLayoutSplitPage\": \"Gesplitste pagina\",\n  \"LabelLess\": \"Minder\",\n  \"LabelLibrariesAccessibleToUser\": \"Voor gebruiker toegankelijke bibliotheken\",\n  \"LabelLibrary\": \"Bibliotheek\",\n  \"LabelLibraryFilterSublistEmpty\": \"Nee {0}\",\n  \"LabelLibraryItem\": \"Bibliotheekonderdeel\",\n  \"LabelLibraryName\": \"Bibliotheeknaam\",\n  \"LabelLibrarySortByProgress\": \"Voortuigang geüpdatet\",\n  \"LabelLibrarySortByProgressFinished\": \"Datum voltooid\",\n  \"LabelLibrarySortByProgressStarted\": \"Datum gestart\",\n  \"LabelLimit\": \"Limiet\",\n  \"LabelLineSpacing\": \"Regelruimte\",\n  \"LabelListenAgain\": \"Opnieuw Beluisteren\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Informatie\",\n  \"LabelLogLevelWarn\": \"Waarschuwing\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Zoek naar nieuwe afleveringen na deze datum\",\n  \"LabelLowestPriority\": \"Laagste Prioriteit\",\n  \"LabelMatchConfidence\": \"Vertrouwen\",\n  \"LabelMatchExistingUsersBy\": \"Bestaande gebruikers matchen op\",\n  \"LabelMatchExistingUsersByDescription\": \"Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider\",\n  \"LabelMaxEpisodesToDownload\": \"Maximale # afleveringen om te downloaden. Gebruik 0 voor ongelimiteerd.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Maximale # nieuwe afleveringen om te downloaden per check\",\n  \"LabelMaxEpisodesToKeep\": \"Maximale # afleveringen om te houden\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Waarde van 0 stelt geen maximumlimiet in. Nadat een nieuwe aflevering automatisch is gedownload, wordt de oudste aflevering verwijderd als u meer dan X afleveringen hebt. Hiermee wordt slechts 1 aflevering per nieuwe download verwijderd.\",\n  \"LabelMediaPlayer\": \"Mediaspeler\",\n  \"LabelMediaType\": \"Mediatype\",\n  \"LabelMetaTag\": \"Meta-tag\",\n  \"LabelMetaTags\": \"Meta-tags\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Metadatabronnen met een hogere prioriteit zullen metadatabronnen met een lagere prioriteit overschrijven\",\n  \"LabelMetadataProvider\": \"Metadatabron\",\n  \"LabelMinute\": \"Minuut\",\n  \"LabelMinutes\": \"Minuten\",\n  \"LabelMissing\": \"Missende\",\n  \"LabelMissingEbook\": \"Heeft geen ebook\",\n  \"LabelMissingSupplementaryEbook\": \"Heeft geen supplementair ebook\",\n  \"LabelMobileRedirectURIs\": \"Toegestane mobiele omleidings-URL's\",\n  \"LabelMobileRedirectURIsDescription\": \"Dit is een whitelist met geldige redirect-URI's voor mobiele apps. De standaard is <code>audiobookshelf://oauth</code>, die u kunt verwijderen of aanvullen met extra URI's voor integratie met apps van derden. Als u een asterisk (<code>*</code>) als enige invoer gebruikt, is elke URI toegestaan.\",\n  \"LabelMore\": \"Meer\",\n  \"LabelMoreInfo\": \"Meer info\",\n  \"LabelName\": \"Naam\",\n  \"LabelNarrator\": \"Verteller\",\n  \"LabelNarrators\": \"Vertellers\",\n  \"LabelNew\": \"Nieuw\",\n  \"LabelNewPassword\": \"Nieuw wachtwoord\",\n  \"LabelNewestAuthors\": \"Nieuwste Auteurs\",\n  \"LabelNewestEpisodes\": \"Nieuwste Afleveringen\",\n  \"LabelNextBackupDate\": \"Volgende back-up datum\",\n  \"LabelNextChapters\": \"Volgende hoofdstukken zijn:\",\n  \"LabelNextScheduledRun\": \"Volgende geplande run\",\n  \"LabelNoApiKeys\": \"Geen API keys\",\n  \"LabelNoCustomMetadataProviders\": \"Geen custom metadata bronnen\",\n  \"LabelNoEpisodesSelected\": \"Geen afleveringen geselecteerd\",\n  \"LabelNotFinished\": \"Niet Voltooid\",\n  \"LabelNotStarted\": \"Niet Gestart\",\n  \"LabelNotes\": \"Notities\",\n  \"LabelNotificationAppriseURL\": \"URL(s) van kennisgeving\",\n  \"LabelNotificationAvailableVariables\": \"Beschikbare variabelen\",\n  \"LabelNotificationBodyTemplate\": \"Body-template\",\n  \"LabelNotificationEvent\": \"Notificatie gebeurtenis\",\n  \"LabelNotificationTitleTemplate\": \"Titel-template\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Max mislukte pogingen\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Notificaties worden uitgeschakeld als verzenden zo vaak mislukt\",\n  \"LabelNotificationsMaxQueueSize\": \"Max rijgrootte voor notificatie gebeurtenissen\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Gebeurtenissen zijn beperkt tot 1 aftrap per seconde. Gebeurtenissen zullen genegeerd worden als de rij aan de maximale grootte zit. Dit voorkomt notificatie-spamming.\",\n  \"LabelNumberOfBooks\": \"Aantal Boeken\",\n  \"LabelNumberOfChapters\": \"Aantal hoofdstukken:\",\n  \"LabelNumberOfEpisodes\": \"# Afleveringen\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Naam van de OpenID-claim die geavanceerde machtigingen bevat voor gebruikersacties binnen de applicatie die van toepassing zijn op niet-beheerdersrollen (<b>indien geconfigureerd</b>). Als de claim ontbreekt in het antwoord, wordt toegang tot ABS geweigerd. Als er één optie ontbreekt, wordt deze behandeld als <code>false</code>. Zorg ervoor dat de claim van de identiteitsprovider overeenkomt met de verwachte structuur:\",\n  \"LabelOpenIDClaims\": \"Laat de volgende opties leeg om geavanceerde groeps- en machtigingstoewijzing uit te schakelen en de groep 'Gebruiker' automatisch toe te wijzen.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Naam van de OpenID-claim die een lijst met de groepen van de gebruiker bevat. Vaak aangeduid als <code>groepen</code>. <b>Indien geconfigureerd</b>, zal de applicatie automatisch rollen toewijzen op basis van de groepslidmaatschappen van de gebruiker, op voorwaarde dat deze groepen hoofdlettergevoelig 'admin', 'gebruiker' of 'gast' worden genoemd in de claim. De claim moet een lijst bevatten en als een gebruiker tot meerdere groepen behoort, zal de applicatie de rol toewijzen die overeenkomt met het hoogste toegangsniveau. Als er geen groep overeenkomt, wordt de toegang geweigerd.\",\n  \"LabelOpenRSSFeed\": \"Open RSS-feed\",\n  \"LabelOverwrite\": \"Overschrijf\",\n  \"LabelPaginationPageXOfY\": \"Pagina {0} van {1}\",\n  \"LabelPassword\": \"Wachtwoord\",\n  \"LabelPath\": \"Pad\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Heeft toegang tot all bibliotheken\",\n  \"LabelPermissionsAccessAllTags\": \"Heeft toegang tot alle tags\",\n  \"LabelPermissionsAccessExplicitContent\": \"Heeft toegang tot expliciete inhoud\",\n  \"LabelPermissionsCreateEreader\": \"Kan Ereader Aanmaken\",\n  \"LabelPermissionsDelete\": \"Kan verwijderen\",\n  \"LabelPermissionsDownload\": \"Kan downloaden\",\n  \"LabelPermissionsUpdate\": \"Kan bijwerken\",\n  \"LabelPermissionsUpload\": \"Kan uploaden\",\n  \"LabelPersonalYearReview\": \"Jouw jaar in review ({0})\",\n  \"LabelPhotoPathURL\": \"Foto pad/URL\",\n  \"LabelPlayMethod\": \"Afspeelwijze\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Afspeel Snelheid Vermeerderen/Verminderen\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} van {1}\",\n  \"LabelPlaylists\": \"Afspeellijsten\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast zoekregio\",\n  \"LabelPodcastType\": \"Podcasttype\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Poort\",\n  \"LabelPrefixesToIgnore\": \"Te negeren voorzetsels (ongeacht hoofdlettergebruik)\",\n  \"LabelPreventIndexing\": \"Voorkom indexering van je feed door iTunes- en Google podcastmappen\",\n  \"LabelPrimaryEbook\": \"Primair ebook\",\n  \"LabelProgress\": \"Voortgang\",\n  \"LabelProvider\": \"Bron\",\n  \"LabelProviderAuthorizationValue\": \"Autorisatie Header Waarde\",\n  \"LabelPubDate\": \"Publicatiedatum\",\n  \"LabelPublishYear\": \"Jaar van uitgave\",\n  \"LabelPublishedDate\": \"Gepubliceerd {0}\",\n  \"LabelPublishedDecade\": \"Gepubliceerd Decennium\",\n  \"LabelPublishedDecades\": \"Gepubliceerd Decennia\",\n  \"LabelPublisher\": \"Uitgever\",\n  \"LabelPublishers\": \"Uitgevers\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Aangepast e-mailadres eigenaar\",\n  \"LabelRSSFeedCustomOwnerName\": \"Aangepaste naam eigenaar\",\n  \"LabelRSSFeedOpen\": \"RSS Feed open\",\n  \"LabelRSSFeedPreventIndexing\": \"Voorkom indexering\",\n  \"LabelRSSFeedSlug\": \"RSS-feed slug\",\n  \"LabelRSSFeedURL\": \"RSS-feed URL\",\n  \"LabelRandomly\": \"Willekeurig\",\n  \"LabelReAddSeriesToContinueListening\": \"Serie opnieuw toevoegen aan verder luisteren\",\n  \"LabelRead\": \"Lees\",\n  \"LabelReadAgain\": \"Opnieuw Lezen\",\n  \"LabelReadEbookWithoutProgress\": \"Lees ebook zonder voortgang bij te houden\",\n  \"LabelRecentSeries\": \"Recente Serie\",\n  \"LabelRecentlyAdded\": \"Recent Toegevoegd\",\n  \"LabelRecommended\": \"Aangeraden\",\n  \"LabelRedo\": \"Opnieuw\",\n  \"LabelRegion\": \"Regio\",\n  \"LabelReleaseDate\": \"Verschijningsdatum\",\n  \"LabelRemoveAllMetadataAbs\": \"Verwijder alle metadata.abs bestanden\",\n  \"LabelRemoveAllMetadataJson\": \"Verwijder alle metadata.json bestanden\",\n  \"LabelRemoveAudibleBranding\": \"Verwijder Audible intro en outro uit hoofdstukken\",\n  \"LabelRemoveCover\": \"Omslag verwijderen\",\n  \"LabelRemoveMetadataFile\": \"Verwijder metadata bestanden in bibliotheek item folders\",\n  \"LabelRemoveMetadataFileHelp\": \"Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.\",\n  \"LabelRowsPerPage\": \"Rijen per pagina\",\n  \"LabelSearchTerm\": \"Zoekterm\",\n  \"LabelSearchTitle\": \"Zoek titel\",\n  \"LabelSearchTitleOrASIN\": \"Zoek titel of ASIN\",\n  \"LabelSeason\": \"Seizoen\",\n  \"LabelSeasonNumber\": \"Seizoen #{0}\",\n  \"LabelSelectAll\": \"Alles selecteren\",\n  \"LabelSelectAllEpisodes\": \"Selecteer alle afleveringen\",\n  \"LabelSelectEpisodesShowing\": \"Selecteer {0} afleveringen laten zien\",\n  \"LabelSelectUser\": \"Gebruiker kiezen\",\n  \"LabelSelectUsers\": \"Selecteer gebruikers\",\n  \"LabelSendEbookToDevice\": \"Stuur ebook naar...\",\n  \"LabelSequence\": \"Sequentie\",\n  \"LabelSerial\": \"Serie\",\n  \"LabelSeries\": \"Serie\",\n  \"LabelSeriesName\": \"Naam serie\",\n  \"LabelSeriesProgress\": \"Voortgang serie\",\n  \"LabelServerLogLevel\": \"Server Log Niveau\",\n  \"LabelServerYearReview\": \"Server Jaar in Review ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Stel in als primair\",\n  \"LabelSetEbookAsSupplementary\": \"Stel in als supplementair\",\n  \"LabelSettingsAllowIframe\": \"Insluiten in iframe toestaan\",\n  \"LabelSettingsAudiobooksOnly\": \"Alleen audiobooks\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeumorphisch design met houten planken\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast ondersteuning\",\n  \"LabelSettingsDateFormat\": \"Datumnotatie\",\n  \"LabelSettingsEnableWatcher\": \"Bibliotheken automatisch scannen op wijzigingen\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Bibliotheek automatisch scannen op wijzigingen\",\n  \"LabelSettingsEnableWatcherHelp\": \"Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Sta scripted content toe in epubs\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.\",\n  \"LabelSettingsExperimentalFeatures\": \"Experimentele functies\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.\",\n  \"LabelSettingsFindCovers\": \"Omslagen zoeken\",\n  \"LabelSettingsFindCoversHelp\": \"Als je audioboek geen omslag in het bestand of in de map heeft, zal de scanner automatisch proberen een omslag te vinden.<br>Opmerking: Dit kan de scantijd verlengen\",\n  \"LabelSettingsHideSingleBookSeries\": \"Verberg series met een enkel boek\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Boekenplank-view voor homepagina\",\n  \"LabelSettingsLibraryBookshelfView\": \"Boekenplank-view voor bibliotheek\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Voltooid percentage is groter dan\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Resterende tijd is kleiner dan (seconden)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Markeer media item wanneer voltooid\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Sla eedere boeken in Serie Verderzetten over\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.\",\n  \"LabelSettingsParseSubtitles\": \"Subtitel afleiden uit foldernaam\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Haal subtitels uit mapnaam van audioboek.<br>Subtitel moet gescheiden zijn met \\\" - \\\"<br>b.v. \\\"Boektitel - Een Subtitel Hier\\\" heeft als subtitel \\\"Een Subtitel Hier\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Geef voorkeur aan gematchte metadata\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Sla matchen van boeken over die al over een ASIN beschikken\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Sla matchen van boeken over die al over een ISBN beschikken\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Negeer voorvoegsels bij sorteren\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"b.v. voor voorvoegsel \\\"The\\\" wordt titel \\\"The Title\\\" dan gesorteerd als \\\"Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Gebruik vierkante boekomslagen\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Gebruik vierkante boekomslagen in plaats van standaard 1,6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Bewaar omslagen bij onderdeel\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Omslagen worden standaard in /metadata/items opgeslagen. Bij inschakelen worden ze in de map van het bibliotheekitem zelf opgeslagen. Slechts een bestand genaamd \\\"cover\\\" zal worden bewaard\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Bewaar metadata bij onderdeel\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden\",\n  \"LabelSettingsTimeFormat\": \"Tijdformat\",\n  \"LabelShare\": \"Delen\",\n  \"LabelShareDownloadableHelp\": \"Gebruikers toestaan met share link om zip bestand te downloaden van het bibliotheek item.\",\n  \"LabelShareOpen\": \"Delen Open\",\n  \"LabelShareURL\": \"URL Delen\",\n  \"LabelShowAll\": \"Toon alle\",\n  \"LabelShowSeconds\": \"Laat seconden zien\",\n  \"LabelShowSubtitles\": \"Laat Ondertitels zien\",\n  \"LabelSize\": \"Grootte\",\n  \"LabelSleepTimer\": \"Slaaptimer\",\n  \"LabelSlug\": \"Slak\",\n  \"LabelSortAscending\": \"Oplopend\",\n  \"LabelSortDescending\": \"Aflopend\",\n  \"LabelSortPubDate\": \"Sorteer Pub Datum\",\n  \"LabelStart\": \"Start\",\n  \"LabelStartTime\": \"Starttijd\",\n  \"LabelStarted\": \"Gestart\",\n  \"LabelStartedAt\": \"Gestart op\",\n  \"LabelStartedDate\": \"Gestart {0}\",\n  \"LabelStatsAudioTracks\": \"Audiotracks\",\n  \"LabelStatsAuthors\": \"Auteurs\",\n  \"LabelStatsBestDay\": \"Beste dag\",\n  \"LabelStatsDailyAverage\": \"Dagelijks gemiddelde\",\n  \"LabelStatsDays\": \"Dagen\",\n  \"LabelStatsDaysListened\": \"Dagen geluisterd\",\n  \"LabelStatsHours\": \"Uren\",\n  \"LabelStatsInARow\": \"op een rij\",\n  \"LabelStatsItemsFinished\": \"Onderdelen voltooid\",\n  \"LabelStatsItemsInLibrary\": \"Onderdeel in bibliotheek\",\n  \"LabelStatsMinutes\": \"minuten\",\n  \"LabelStatsMinutesListening\": \"Minuten luisterend\",\n  \"LabelStatsOverallDays\": \"Overall dagen\",\n  \"LabelStatsOverallHours\": \"Overall uren\",\n  \"LabelStatsWeekListening\": \"Week luisterend\",\n  \"LabelSubtitle\": \"Subtitel\",\n  \"LabelSupportedFileTypes\": \"Ondersteunde bestandstypes\",\n  \"LabelTag\": \"Tag\",\n  \"LabelTags\": \"Tags\",\n  \"LabelTagsAccessibleToUser\": \"Tags toegankelijk voor de gebruiker\",\n  \"LabelTagsNotAccessibleToUser\": \"Tags niet toegankelijk voor de gebruiker\",\n  \"LabelTasks\": \"Lopende taken\",\n  \"LabelTextEditorBulletedList\": \"Opgesomde lijst\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"Genummerde lijst\",\n  \"LabelTextEditorUnlink\": \"Ontkoppelen\",\n  \"LabelTheme\": \"Thema\",\n  \"LabelThemeDark\": \"Donker\",\n  \"LabelThemeLight\": \"Licht\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Tijdsbasis\",\n  \"LabelTimeDurationXHours\": \"{0} Uren\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuten\",\n  \"LabelTimeDurationXSeconds\": \"{0} seconden\",\n  \"LabelTimeInMinutes\": \"Tijd in minuten\",\n  \"LabelTimeLeft\": \"{0} over\",\n  \"LabelTimeListened\": \"Tijd geluisterd\",\n  \"LabelTimeListenedToday\": \"Tijd geluisterd vandaag\",\n  \"LabelTimeRemaining\": \"{0} te gaan\",\n  \"LabelTimeToShift\": \"Tijd op te schuiven in seconden\",\n  \"LabelTitle\": \"Titel\",\n  \"LabelToolsEmbedMetadata\": \"Metadata insluiten\",\n  \"LabelToolsEmbedMetadataDescription\": \"Metadata insluiten in audiobestanden, inclusief omslagafbeelding en hoofdstukken.\",\n  \"LabelToolsM4bEncoder\": \"M4B Encoder\",\n  \"LabelToolsMakeM4b\": \"Maak M4B-audioboekbestand\",\n  \"LabelToolsMakeM4bDescription\": \"Genereer een .M4B-audioboekbestand met ingesloten metadata, omslagafbeelding en hoofdstukken.\",\n  \"LabelToolsSplitM4b\": \"Splitst M4B in MP3's\",\n  \"LabelToolsSplitM4bDescription\": \"Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, omslagafbeelding en hoofdstukken.\",\n  \"LabelTotalDuration\": \"Totale duur\",\n  \"LabelTotalTimeListened\": \"Totale tijd geluisterd\",\n  \"LabelTrackFromFilename\": \"Track vanuit bestandsnaam\",\n  \"LabelTrackFromMetadata\": \"Track vanuit metadata\",\n  \"LabelTracks\": \"Audiosporen\",\n  \"LabelTracksMultiTrack\": \"Multi-spoor\",\n  \"LabelTracksNone\": \"Geen tracks\",\n  \"LabelTracksSingleTrack\": \"Enkele track\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Type\",\n  \"LabelUnabridged\": \"Onverkort\",\n  \"LabelUndo\": \"Ongedaan maken\",\n  \"LabelUnknown\": \"Onbekend\",\n  \"LabelUnknownPublishDate\": \"Onbekende uitgeefdatum\",\n  \"LabelUpdateCover\": \"Omslag bijwerken\",\n  \"LabelUpdateCoverHelp\": \"Sta overschrijven van bestaande omslagen toe voor de geselecteerde boeken wanneer een match is gevonden\",\n  \"LabelUpdateDetails\": \"Details bijwerken\",\n  \"LabelUpdateDetailsHelp\": \"Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden\",\n  \"LabelUpdatedAt\": \"Bijgewerkt op\",\n  \"LabelUploaderDragAndDrop\": \"Slepen & neerzeten van bestanden of mappen\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Drag & drop bestanden\",\n  \"LabelUploaderDropFiles\": \"Bestanden neerzetten\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automatisch titel, auteur en serie ophalen\",\n  \"LabelUseAdvancedOptions\": \"Gebruik Geavanceerde Instellingen\",\n  \"LabelUseChapterTrack\": \"Gebruik hoofdstuktrack\",\n  \"LabelUseFullTrack\": \"Gebruik volledige track\",\n  \"LabelUseZeroForUnlimited\": \"Gebruik 0 voor ongelimiteerd\",\n  \"LabelUser\": \"Gebruiker\",\n  \"LabelUsername\": \"Gebruikersnaam\",\n  \"LabelValue\": \"Waarde\",\n  \"LabelVersion\": \"Versie\",\n  \"LabelViewBookmarks\": \"Bekijk boekwijzers\",\n  \"LabelViewChapters\": \"Bekijk hoofdstukken\",\n  \"LabelViewPlayerSettings\": \"Laat spelerinstellingen zien\",\n  \"LabelViewQueue\": \"Bekijk afspeelwachtrij\",\n  \"LabelVolume\": \"Volume\",\n  \"LabelWebRedirectURLsDescription\": \"Autoriseer deze URL's in uw OAuth-provider om na het inloggen omleiding terug naar de web-app toe te staan:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Subfolder voor Redirect URLs\",\n  \"LabelWeekdaysToRun\": \"Weekdagen om te draaien\",\n  \"LabelXBooks\": \"{0} boeken\",\n  \"LabelXItems\": \"{0} items\",\n  \"LabelYearReviewHide\": \"Verberg Jaar in Review\",\n  \"LabelYearReviewShow\": \"Laat Jaar in Review zien\",\n  \"LabelYourAudiobookDuration\": \"Je audioboekduur\",\n  \"LabelYourBookmarks\": \"Je boekwijzers\",\n  \"LabelYourPlaylists\": \"Je afspeellijsten\",\n  \"LabelYourProgress\": \"Je voortgang\",\n  \"MessageAddToPlayerQueue\": \"Toevoegen aan wachtrij\",\n  \"MessageAppriseDescription\": \"Om deze functie te gebruiken heb je een draaiende instantie van <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> nodig of een api die dezelfde requests afhandelt. <br />De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op <code>http://192.168.1.1:8337</code> dan zou je <code>http://192.168.1.1:8337/notify</code> gebruiken.\",\n  \"MessageAsinCheck\": \"Zorg ervoor dat u de ASIN van de juiste Audible-regio gebruikt, niet die van Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"API tokens zijn verouderd en worden in de toekomst niet meer ondersteund. Gebruik inplaats daarvan <a href=\\\"/config/api-keys\\\">API keys</a> .\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Start uw server opnieuw op nadat u het opslaan hebt uitgevoerd, om de OIDC-wijzigingen toe te passen.\",\n  \"MessageAuthenticationSecurityMessage\": \"Authenticatie is verbeterd omwille van veiligheid. Alle gebruikers moeten opnieuw inloggen.\",\n  \"MessageBackupsDescription\": \"Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in <code>/metadata/items</code> & <code>/metadata/authors</code>. Back-ups <strong>bevatten niet</strong> de bestanden bewaard in je bibliotheekmappen.\",\n  \"MessageBackupsLocationEditNote\": \"Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen\",\n  \"MessageBackupsLocationNoEditNote\": \"Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.\",\n  \"MessageBackupsLocationPathEmpty\": \"Backup locatie pad kan niet leeg zijn\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Vul actieve folder detail velden met de data van dit item\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match probeert ontbrekende omslagen en metadata toe te voegen aan de geselecteerde items. Schakel de opties hieronder in om Quick Match bestaande omslagen en/of metadata te laten overschrijven.\",\n  \"MessageBookshelfNoCollections\": \"Je hebt nog geen collecties gemaakt\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Geen RSS-feeds geopend\",\n  \"MessageBookshelfNoResultsForFilter\": \"Geen resultaten voor filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Geen resultaten voor query\",\n  \"MessageBookshelfNoSeries\": \"Je hebt geen series\",\n  \"MessageBulkChapterPattern\": \"Hoeveel hoofdstukken wilt u met dit nummeringspatroon toevoegen?\",\n  \"MessageChapterEndIsAfter\": \"Hoofdstukeinde is na het einde van je audioboek\",\n  \"MessageChapterErrorFirstNotZero\": \"Eerste hoofdstuk moet starten op 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Ongeldig: starttijd moet kleiner zijn dan duur van audioboek\",\n  \"MessageChapterErrorStartLtPrev\": \"Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk\",\n  \"MessageChapterStartIsAfter\": \"Start van hoofdstuk is na het einde van je audioboek\",\n  \"MessageChaptersNotFound\": \"Hoofdstukken niet gevonden\",\n  \"MessageCheckingCron\": \"Cron aan het checken...\",\n  \"MessageConfirmCloseFeed\": \"Ben je zeker dat je deze feed wil sluiten?\",\n  \"MessageConfirmDeleteApiKey\": \"Weet je zeker dat je deze API key \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmDeleteBackup\": \"Weet je zeker dat je de backup voor {0} wil verwijderen?\",\n  \"MessageConfirmDeleteDevice\": \"Ben je zeker dat je e-reader apparaat \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmDeleteFile\": \"Dit verwijdert het bestand uit het bestandssysteem. Weet je het zeker?\",\n  \"MessageConfirmDeleteLibrary\": \"Weet je zeker dat je de bibliotheek \\\"{0}\\\" permanent wil verwijderen?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Hiermee wordt het bibliotheekitem uit de database en uw bestandssysteem verwijderd. Bent u zeker?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Hiermee worden {0} bibliotheekitems uit de database en uw bestandssysteem verwijderd. Bent u zeker?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Weet u zeker dat u de aangepaste metadataprovider \\\"{0}\\\" wilt verwijderen?\",\n  \"MessageConfirmDeleteNotification\": \"Weet u zeker dat u deze melding wil verwijderen?\",\n  \"MessageConfirmDeleteSession\": \"Weet je zeker dat je deze sessie wil verwijderen?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Weet u zeker dat u metagegevens wilt insluiten in {0} audiobestanden?\",\n  \"MessageConfirmForceReScan\": \"Weet je zeker dat je geforceerd opnieuw wil scannen?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Weet je zeker dat je alle afleveringen als voltooid wil markeren?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?\",\n  \"MessageConfirmMarkItemFinished\": \"Weet u zeker dat u \\\"{0}\\\" als voltooid wilt markeren?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Weet u zeker dat u \\\"{0}\\\" als niet voltooid wilt markeren?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Trigger deze melding met test data?\",\n  \"MessageConfirmPurgeCache\": \"Met Purge cache wordt de gehele directory op <code>/metadata/cache</code> verwijderd. <br /><br />Weet u zeker dat u de cachedirectory wilt verwijderen?\",\n  \"MessageConfirmPurgeItemsCache\": \"Met Purge items cache wordt de gehele directory op <code>/metadata/cache/items</code> verwijderd.<br />Weet u het zeker?\",\n  \"MessageConfirmQuickEmbed\": \"Waarschuwing! Quick embed maakt geen back-up van uw audiobestanden. Zorg ervoor dat u een back-up van uw audiobestanden hebt. <br><br>Wilt u doorgaan?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Snel matchende afleveringen overschrijven details als er een match is gevonden. Alleen niet-matchende afleveringen worden bijgewerkt. Weet u het zeker?\",\n  \"MessageConfirmReScanLibraryItems\": \"Bent u zeker dat u {0} items opnieuw wil scannen?\",\n  \"MessageConfirmRemoveAllChapters\": \"Weet je zeker dat je alle hoofdstukken wil verwijderen?\",\n  \"MessageConfirmRemoveAuthor\": \"Weet je zeker dat je auteur \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmRemoveCollection\": \"Weet je zeker dat je de collectie \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmRemoveEpisode\": \"Weet je zeker dat je de aflevering \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Let op: Het audiobestand wordt niet verwijderd, tenzij je ‘Bestand permanent verwijderen’ inschakelt\",\n  \"MessageConfirmRemoveEpisodes\": \"Weet je zeker dat je {0} afleveringen wil verwijderen?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Weet je zeker dat je {0} luistersessies wilt verwijderen?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?\",\n  \"MessageConfirmRemoveNarrator\": \"Weet je zeker dat je verteller \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmRemovePlaylist\": \"Weet je zeker dat je afspeellijst \\\"{0}\\\" wil verwijderen?\",\n  \"MessageConfirmRenameGenre\": \"Weet je zeker dat je genre \\\"{0}\\\" wil hernoemen naar \\\"{1}\\\" voor alle onderdelen?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Opmerking: Dit genre bestaat al, dus zullen ze worden samengevoegd.\",\n  \"MessageConfirmRenameGenreWarning\": \"Waarschuwing! Een gelijknamig genre met ander hoofdlettergebruik bestaat al: \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Weet je zeker dat je tag \\\"{0}\\\" wil hernoemen naar\\\"{1}\\\" voor alle onderdelen?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.\",\n  \"MessageConfirmRenameTagWarning\": \"Waarschuwing! Een gelijknamige tag met ander hoofdlettergebruik bestaat al: \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Bet u zeker dat u uw voortgang wil resetten?\",\n  \"MessageConfirmSendEbookToDevice\": \"Weet je zeker dat je {0} ebook \\\"{1}\\\" naar apparaat \\\"{2}\\\" wil sturen?\",\n  \"MessageConfirmUnlinkOpenId\": \"Bent u zeker dat u deze gebruiker wil ontkoppelen van OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dagen geluisterd in het voorbije jaar\",\n  \"MessageDownloadingEpisode\": \"Aflevering aan het dowloaden\",\n  \"MessageDragFilesIntoTrackOrder\": \"Sleep bestanden in de juiste trackvolgorde\",\n  \"MessageEmbedFailed\": \"Insluiten Mislukt!\",\n  \"MessageEmbedFinished\": \"Insluiting voltooid!\",\n  \"MessageEmbedQueue\": \"In de wachtrij voor metadata-embed ({0} in wachtrij)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} aflevering(en) in de rij om te downloaden\",\n  \"MessageEreaderDevices\": \"Om de levering van e-books te garanderen, moet u mogelijk bovenstaand e-mailadres opgeven als geldige afzender voor elk hieronder vermeld apparaat.\",\n  \"MessageFeedURLWillBe\": \"Feed URL zal {0} zijn\",\n  \"MessageFetching\": \"Aan het ophalen...\",\n  \"MessageForceReScanDescription\": \"zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} luistert</strong> op {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Geen luistersessies op {0}\",\n  \"MessageImportantNotice\": \"Belangrijke opmerking!\",\n  \"MessageInsertChapterBelow\": \"Hoofdstuk hieronder invoegen\",\n  \"MessageInvalidAsin\": \"Ongeldige ASIN\",\n  \"MessageItemsSelected\": \"{0} items geselecteerd\",\n  \"MessageItemsUpdated\": \"{0} items bijgewerkt\",\n  \"MessageJoinUsOn\": \"Doe mee op\",\n  \"MessageLoading\": \"Aan het laden...\",\n  \"MessageLoadingFolders\": \"Mappen aan het laden...\",\n  \"MessageLogsDescription\": \"Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B mislukt!\",\n  \"MessageM4BFinished\": \"M4B voltooid!\",\n  \"MessageMapChapterTitles\": \"Map hoofdstuktitels naar je bestaande audioboekhoofdstukken zonder aanpassing van tijden\",\n  \"MessageMarkAllEpisodesFinished\": \"Markeer alle afleveringen als voltooid\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Markeer alle afleveringen als niet voltooid\",\n  \"MessageMarkAsFinished\": \"Markeer als Voltooid\",\n  \"MessageMarkAsNotFinished\": \"Markeer als Niet Voltooid\",\n  \"MessageMatchBooksDescription\": \"zal proberen boeken in de bibliotheek te koppelen aan een boek uit de geselecteerde bron en ontbrekende gegevens en een omslag toe te voegen. Overschrijft geen bestaande gegevens.\",\n  \"MessageNoAudioTracks\": \"Geen audiotracks\",\n  \"MessageNoAuthors\": \"Geen auteurs\",\n  \"MessageNoBackups\": \"Geen back-ups\",\n  \"MessageNoBookmarks\": \"Geen boekwijzers\",\n  \"MessageNoChapters\": \"Geen hoofdstukken\",\n  \"MessageNoCollections\": \"Geen collecties\",\n  \"MessageNoCoversFound\": \"Geen omslagen gevonden\",\n  \"MessageNoDescription\": \"Geen beschrijving\",\n  \"MessageNoDevices\": \"Geen Apparaten\",\n  \"MessageNoDownloadsInProgress\": \"Geen downloads bezig op dit moment\",\n  \"MessageNoDownloadsQueued\": \"Geen downloads in de wachtrij\",\n  \"MessageNoEpisodeMatchesFound\": \"Geen afleveringsmatches gevonden\",\n  \"MessageNoEpisodes\": \"Geen afleveringen\",\n  \"MessageNoFoldersAvailable\": \"Geen mappen beschikbaar\",\n  \"MessageNoGenres\": \"Geen genres\",\n  \"MessageNoIssues\": \"Geen issues\",\n  \"MessageNoItems\": \"Geen onderdelen\",\n  \"MessageNoItemsFound\": \"Geen onderdelen gevonden\",\n  \"MessageNoListeningSessions\": \"Geen luistersessies\",\n  \"MessageNoLogs\": \"Geen logbestanden\",\n  \"MessageNoMediaProgress\": \"Geen mediavoortgang\",\n  \"MessageNoNotifications\": \"Geen notificaties\",\n  \"MessageNoPodcastFeed\": \"Ongeldige podcast: Geen Feed\",\n  \"MessageNoPodcastsFound\": \"Geen podcasts gevonden\",\n  \"MessageNoResults\": \"Geen resultaten\",\n  \"MessageNoSearchResultsFor\": \"Geen zoekresultaten voor \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Geen series\",\n  \"MessageNoTags\": \"Geen tags\",\n  \"MessageNoTasksRunning\": \"Geen lopende taken\",\n  \"MessageNoUpdatesWereNecessary\": \"Geen bijwerkingen waren noodzakelijk\",\n  \"MessageNoUserPlaylists\": \"Je hebt geen afspeellijsten\",\n  \"MessageNoUserPlaylistsHelp\": \"Afspeellijsten zijn privaat. Alleen de gebruikers die ze hebben gemaakt kunnen ze zien.\",\n  \"MessageNotYetImplemented\": \"Nog niet geimplementeerd\",\n  \"MessageOpmlPreviewNote\": \"Let op: Dit is een preview van het geparseerde OPML-bestand. De werkelijke podcasttitel wordt overgenomen uit de RSS-feed.\",\n  \"MessageOr\": \"of\",\n  \"MessagePauseChapter\": \"Pauzeer afspelen hoofdstuk\",\n  \"MessagePlayChapter\": \"Luister naar begin van hoofdstuk\",\n  \"MessagePlaylistCreateFromCollection\": \"Afspeellijst aanmaken vanuit collectie\",\n  \"MessagePleaseWait\": \"Even geduld...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast heeft geen RSS-feed URL om te gebruiken voor matching\",\n  \"MessagePodcastSearchField\": \"Voer zoekterm of RSS-feed-URL in\",\n  \"MessageQuickEmbedInProgress\": \"Snelle inbedding in uitvoering\",\n  \"MessageQuickEmbedQueue\": \"In de wachtrij voor snelle insluiting ({0} in wachtrij)\",\n  \"MessageQuickMatchAllEpisodes\": \"Alle Afleveringen Snel Matchen\",\n  \"MessageQuickMatchDescription\": \"Vult ontbrekende gegevens & omslag met eerste matchresultaat van '{0}'. Overschrijft gegevens alleen als de serverinstelling ‘Geef voorkeur aan gematchte metadata’ is ingeschakeld.\",\n  \"MessageRemoveChapter\": \"Verwijder hoofdstuk\",\n  \"MessageRemoveEpisodes\": \"Verwijder {0} aflevering(en)\",\n  \"MessageRemoveFromPlayerQueue\": \"Verwijder uit afspeelwachtrij\",\n  \"MessageRemoveUserWarning\": \"Weet je zeker dat je gebruiker \\\"{0}\\\" permanent wil verwijderen?\",\n  \"MessageReportBugsAndContribute\": \"Rapporteer bugs, vraag functionaliteiten aan en draag bij op\",\n  \"MessageResetChaptersConfirm\": \"Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?\",\n  \"MessageRestoreBackupConfirm\": \"Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op\",\n  \"MessageRestoreBackupWarning\": \"Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.\",\n  \"MessageScheduleLibraryScanNote\": \"Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Elke {0} uitvoeren op {1}\",\n  \"MessageSearchResultsFor\": \"Zoekresultaten voor\",\n  \"MessageSelected\": \"{0} geselecteerd\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Serievolgorde mag geen spaties bevatten\",\n  \"MessageServerCouldNotBeReached\": \"Server niet bereikbaar\",\n  \"MessageSetChaptersFromTracksDescription\": \"Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel\",\n  \"MessageShareExpirationWillBe\": \"Vervaldatum is <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Vervalt in {0}\",\n  \"MessageShareURLWillBe\": \"De gedeelde URL wordt <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Afspelen van \\\"{0}\\\" beginnen op {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Audiobestand \\\"{0}\\\" is niet beschrijfbaar\",\n  \"MessageTaskCanceledByUser\": \"Taak geannuleerd door gebruiker\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Aflevering \\\"{0}\\\" downloaden\",\n  \"MessageTaskEmbeddingMetadata\": \"Metadata insluiten\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Metadata insluiten in audioboek \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"M4B Encoden\",\n  \"MessageTaskEncodingM4bDescription\": \"Audioboek \\\"{0}\\\" coderen in één m4b-bestand\",\n  \"MessageTaskFailed\": \"Mislukt\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Het is niet gelukt om een back-up te maken van audiobestand \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Het is niet gelukt om een cachemap te maken\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Het is niet gelukt om metagegevens in bestand \\\"{0}\\\" in te sluiten\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Audiobestanden samenvoegen mislukt\",\n  \"MessageTaskFailedToMoveM4bFile\": \"m4b bestand verplaatsen mislukt\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Metadata bestand schrijven mislukt\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Overeenkomende boeken in bibliotheek \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Geen bestanden om te scannen\",\n  \"MessageTaskOpmlImport\": \"OPML importeren\",\n  \"MessageTaskOpmlImportDescription\": \"Podcasts maken van {0} RSS feeds\",\n  \"MessageTaskOpmlImportFeed\": \"OPML feed importeren\",\n  \"MessageTaskOpmlImportFeedDescription\": \"RSS feed \\\"{0}\\\" importeren\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Podcastfeed kon niet worden opgehaald\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Podcast \\\"{0}\\\" maken\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast bestaat al in pad\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Mislukt om podcast aan te maken\",\n  \"MessageTaskOpmlImportFinished\": \"{0} podcasts toegevoegd\",\n  \"MessageTaskOpmlParseFailed\": \"Het is niet gelukt om het OPML-bestand te parseren\",\n  \"MessageTaskOpmlParseFastFail\": \"Ongeldig OPML-bestand <opml> tag niet gevonden OF een <outline> tag is niet gevonden\",\n  \"MessageTaskOpmlParseNoneFound\": \"Geen feeds gevonden in OPML bestand\",\n  \"MessageTaskScanItemsAdded\": \"{0} toegevoegd\",\n  \"MessageTaskScanItemsMissing\": \"{0} missend\",\n  \"MessageTaskScanItemsUpdated\": \"{0} bijgewerkt\",\n  \"MessageTaskScanNoChangesNeeded\": \"Geen aanpassingen nodig\",\n  \"MessageTaskScanningFileChanges\": \"Scannen van bestandswijzigingen in \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Scannen van bibliotheek \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Doelmap is niet beschrijfbaar\",\n  \"MessageThinking\": \"Aan het denken...\",\n  \"MessageUploaderItemFailed\": \"Uploaden mislukt\",\n  \"MessageUploaderItemSuccess\": \"Uploaden gelukt!\",\n  \"MessageUploading\": \"Aan het uploaden...\",\n  \"MessageValidCronExpression\": \"Geldige cron-uitdrukking\",\n  \"MessageWatcherIsDisabledGlobally\": \"Watcher is globaal uitgeschakeld in serverinstellingen\",\n  \"MessageXLibraryIsEmpty\": \"{0} bibliotheek is leeg!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Duur van je audioboek is langer dan de gevonden duur\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Duur van je audioboek is korter dan de gevonden duur\",\n  \"NoteChangeRootPassword\": \"Root-gebruiker is de enige gebruiker die een leeg wachtwoord kan hebben\",\n  \"NoteChapterEditorTimes\": \"Opmerking: Starttijd van het eerste hoofdstuk moet op 0:00 blijven en de starttijd van het laatste hoofdstuk mag niet de duur van het audioboek overschrijden.\",\n  \"NoteFolderPicker\": \"Opmerking: Reeds gemapte mappen worden niet getoond\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Waarschuwing: De meeste podcast-apps zullen eisen dat de RSS-feed URL HTTPS gebruikt\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Waarschuwing: 1 of meer van je afleveringen hebben geen Pub Datum. Sommige podcast-apps vereisen dit.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Bij uploaden van uitsluitend audiobestanden wordt ieder audiobestand als apart audiobook worden behandeld.\",\n  \"NoteUploaderUnsupportedFiles\": \"Niet-ondersteunde bestanden worden genegeerd. Bij het kiezen of neerzetten van een map worden andere bestanden die niet in de map staan genegeerd.\",\n  \"NotificationOnBackupCompletedDescription\": \"Wordt geactiveerd wanneer een back-up is voltooid\",\n  \"NotificationOnBackupFailedDescription\": \"Wordt geactiveerd wanneer een back-up mislukt\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Wordt geactiveerd wanneer een podcastaflevering automatisch wordt gedownload\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Wordt geactiveerd wanneer automatische afleveringsdownloads zijn uitgeschakeld vanwege te veel mislukte pogingen\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Getriggerd wanneer de RSS feed aanvraag faalt voor een automatische aflevering download\",\n  \"NotificationOnTestDescription\": \"Event voor het testen van het notificatiesysteem\",\n  \"PlaceholderBulkChapterInput\": \"Voer een hoofdstuktitel in of gebruik nummering (bijv. 'Aflevering 1', 'Hoofdstuk 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Nieuwe naam collectie\",\n  \"PlaceholderNewFolderPath\": \"Nieuwe locatie map\",\n  \"PlaceholderNewPlaylist\": \"Nieuwe naam afspeellijst\",\n  \"PlaceholderSearch\": \"Zoeken..\",\n  \"PlaceholderSearchEpisode\": \"Aflevering zoeken..\",\n  \"StatsAuthorsAdded\": \"auteurs toegevoegd\",\n  \"StatsBooksAdded\": \"boeken toegevoegd\",\n  \"StatsBooksAdditional\": \"Enkele toevoegingen zijn…\",\n  \"StatsBooksFinished\": \"boeken voltooid\",\n  \"StatsBooksFinishedThisYear\": \"Enkele boeken voltooid dit jaar…\",\n  \"StatsBooksListenedTo\": \"geluisterde boeken\",\n  \"StatsCollectionGrewTo\": \"Je boeken collectie groeide tot…\",\n  \"StatsSessions\": \"sessies\",\n  \"StatsSpentListening\": \"tijd geluisterd\",\n  \"StatsTopAuthor\": \"TOP AUTEUR\",\n  \"StatsTopAuthors\": \"TOP AUTEURS\",\n  \"StatsTopGenre\": \"TOP GENRE\",\n  \"StatsTopGenres\": \"TOP GENRES\",\n  \"StatsTopMonth\": \"TOP MAAND\",\n  \"StatsTopNarrator\": \"TOP VERTELLER\",\n  \"StatsTopNarrators\": \"TOP VERTELLERS\",\n  \"StatsTotalDuration\": \"Met een totale tijd van…\",\n  \"StatsYearInReview\": \"JAAR IN REVIEW\",\n  \"ToastAccountUpdateSuccess\": \"Account bijgewerkt\",\n  \"ToastAppriseUrlRequired\": \"Moet een Apprise URL invoeren\",\n  \"ToastAsinRequired\": \"ASIN is vereist\",\n  \"ToastAuthorImageRemoveSuccess\": \"Afbeelding auteur verwijderd\",\n  \"ToastAuthorNotFound\": \"Auteur \\\"{0}\\\" niet gevonden\",\n  \"ToastAuthorRemoveSuccess\": \"Auteur verwijderd\",\n  \"ToastAuthorSearchNotFound\": \"Auteur niet gevonden\",\n  \"ToastAuthorUpdateMerged\": \"Auteur samengevoegd\",\n  \"ToastAuthorUpdateSuccess\": \"Auteur bijgewerkt\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Auteur bijgewerkt (geen afbeelding gevonden)\",\n  \"ToastBackupAppliedSuccess\": \"Backup toegepast\",\n  \"ToastBackupCreateFailed\": \"Back-up maken mislukt\",\n  \"ToastBackupCreateSuccess\": \"Back-up gemaakt\",\n  \"ToastBackupDeleteFailed\": \"Verwijderen back-up mislukt\",\n  \"ToastBackupDeleteSuccess\": \"Back-up verwijderd\",\n  \"ToastBackupInvalidMaxKeep\": \"Ongeldig aantal backups om bij te houden\",\n  \"ToastBackupInvalidMaxSize\": \"Ongeldige maximum backupgrootte\",\n  \"ToastBackupRestoreFailed\": \"Herstellen back-up mislukt\",\n  \"ToastBackupUploadFailed\": \"Uploaden back-up mislukt\",\n  \"ToastBackupUploadSuccess\": \"Back-up geüpload\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Details toegepast op items\",\n  \"ToastBatchDeleteFailed\": \"Batch verwijderen mislukt\",\n  \"ToastBatchDeleteSuccess\": \"Batch verwijderen gelukt\",\n  \"ToastBatchQuickMatchFailed\": \"Batch Snel Vergelijken mislukt!\",\n  \"ToastBatchQuickMatchStarted\": \"Bulk Snel Vergelijken van {0} boeken gestart!\",\n  \"ToastBatchUpdateFailed\": \"Bulk-bijwerking mislukt\",\n  \"ToastBatchUpdateSuccess\": \"Bulk-bijwerking gelukt\",\n  \"ToastBookmarkCreateFailed\": \"Aanmaken boekwijzer mislukt\",\n  \"ToastBookmarkCreateSuccess\": \"boekwijzer toegevoegd\",\n  \"ToastBookmarkRemoveSuccess\": \"Boekwijzer verwijderd\",\n  \"ToastBulkChapterInvalidCount\": \"Voer een nummer in tussen 1 en 150\",\n  \"ToastCachePurgeFailed\": \"Cache wissen is mislukt\",\n  \"ToastCachePurgeSuccess\": \"Cache succesvol verwijderd\",\n  \"ToastChapterLocked\": \"Hoofdstuk is vergrendeld.\",\n  \"ToastChapterStartTimeAdjusted\": \"Hoofdstukstarttijd aangepast met {0} seconden\",\n  \"ToastChaptersAllLocked\": \"Alle hoofdstukken zijn vergrendeld. Ontgrendel sommige hoofdstukken om hun tijd te verschuiven.\",\n  \"ToastChaptersHaveErrors\": \"Hoofdstukken bevatten fouten\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Ongeldige shift-tijd. De starttijd van het laatste hoofdstuk zou langer zijn dan de duur van dit audioboek.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Ongeldige shift-lengte. Het eerste hoofdstuk zou nul of een negatieve lengte hebben en zou worden overschreven door het tweede hoofdstuk. Verleng de startduur van het tweede hoofdstuk.\",\n  \"ToastChaptersMustHaveTitles\": \"Hoofdstukken moeten titels hebben\",\n  \"ToastChaptersRemoved\": \"Hoofdstukken verwijderd\",\n  \"ToastChaptersUpdated\": \"Hoofdstukken bijgewerkt\",\n  \"ToastCollectionItemsAddFailed\": \"Item(s) toegevoegd aan collectie mislukt\",\n  \"ToastCollectionRemoveSuccess\": \"Collectie verwijderd\",\n  \"ToastCollectionUpdateSuccess\": \"Collectie bijgewerkt\",\n  \"ToastCoverUpdateFailed\": \"Omslag bijwerken mislukt\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Datum en tijd ongeldig of onvolledig\",\n  \"ToastDeleteFileFailed\": \"Bestand verwijderen mislukt\",\n  \"ToastDeleteFileSuccess\": \"Bestand verwijderd\",\n  \"ToastDeviceAddFailed\": \"Apparaat toevoegen mislukt\",\n  \"ToastDeviceNameAlreadyExists\": \"Er bestaat al een e-reader met die naam\",\n  \"ToastDeviceTestEmailFailed\": \"Het is niet gelukt om een test-e-mail te verzenden\",\n  \"ToastDeviceTestEmailSuccess\": \"Test e-mail verzonden\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Emaill intellingen bijgewerkt\",\n  \"ToastEncodeCancelFailed\": \"Het is niet gelukt om het coderen te annuleren\",\n  \"ToastEncodeCancelSucces\": \"Encode geannuleerd\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Wachtrij legen mislukt\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Aflevering download-wachtrij geleegt\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} afleveringen bijgewerkt\",\n  \"ToastErrorCannotShare\": \"Kan niet native delen op dit apparaat\",\n  \"ToastFailedToCreate\": \"Fout tijdens creëren\",\n  \"ToastFailedToDelete\": \"Fout tijdens verwijderen\",\n  \"ToastFailedToLoadData\": \"Data laden mislukt\",\n  \"ToastFailedToMatch\": \"Match mislukt\",\n  \"ToastFailedToShare\": \"Delen mislukt\",\n  \"ToastFailedToUpdate\": \"Update mislukt\",\n  \"ToastInvalidImageUrl\": \"Ongeldige afbeeldings-URL\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Ongeldig maximum aantal afleveringen om te downloaden\",\n  \"ToastInvalidUrl\": \"Ongeldige URL\",\n  \"ToastInvalidUrls\": \"Een of meerdere URLs zijn ongeldig\",\n  \"ToastItemCoverUpdateSuccess\": \"Omslag bijgewerkt\",\n  \"ToastItemDeletedFailed\": \"Item verwijderen mislukt\",\n  \"ToastItemDeletedSuccess\": \"Verwijderd item\",\n  \"ToastItemDetailsUpdateSuccess\": \"Details onderdeel bijgewerkt\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Markeren als Voltooid mislukt\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Onderdeel gemarkeerd als Voltooid\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Markeren als Niet Voltooid mislukt\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Onderdeel gemarkeerd als Niet Voltooid\",\n  \"ToastItemUpdateSuccess\": \"Item bijgewerkt\",\n  \"ToastLibraryCreateFailed\": \"Bibliotheek aanmaken mislukt\",\n  \"ToastLibraryCreateSuccess\": \"Bibliotheek \\\"{0}\\\" aangemaakt\",\n  \"ToastLibraryDeleteFailed\": \"Bibliotheek verwijderen mislukt\",\n  \"ToastLibraryDeleteSuccess\": \"Bibliotheek verwijderd\",\n  \"ToastLibraryScanFailedToStart\": \"Starten scan mislukt\",\n  \"ToastLibraryScanStarted\": \"Scannen bibliotheek gestart\",\n  \"ToastLibraryUpdateSuccess\": \"Bibliotheek \\\"{0}\\\" bijgewerkt\",\n  \"ToastMatchAllAuthorsFailed\": \"Alle auteurs matchen mislukt\",\n  \"ToastMetadataFilesRemovedError\": \"Fout bij verwijderen van metadata. {0} bestanden\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Geen metadata. {0} bestanden gevonden in bibliotheek\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Geen metadata. {0} bestanden verwijderd\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata. {1} bestanden verwijderd\",\n  \"ToastMustHaveAtLeastOnePath\": \"Moet ten minste een pad hebben\",\n  \"ToastNameEmailRequired\": \"Naam en email zijn vereist\",\n  \"ToastNameRequired\": \"Naam is vereist\",\n  \"ToastNewApiKeyUserError\": \"Selecteer een gebruiker\",\n  \"ToastNewEpisodesFound\": \"{0} nieuwe afleveringen gevonden\",\n  \"ToastNewUserCreatedFailed\": \"Account: \\\"{0}\\\" aanmaken mislukt\",\n  \"ToastNewUserCreatedSuccess\": \"Nieuw account aangemaakt\",\n  \"ToastNewUserLibraryError\": \"Moet ten minste een bibliotheek selecteren\",\n  \"ToastNewUserPasswordError\": \"Moet een wachtwoord hebben, enkel root gebruiker kan een leeg wachtwoord gebruiken\",\n  \"ToastNewUserTagError\": \"Moet ten minste een tag selecteren\",\n  \"ToastNewUserUsernameError\": \"Voer een gebruikersnaam in\",\n  \"ToastNoNewEpisodesFound\": \"Geen nieuwe afleveringen gevonden\",\n  \"ToastNoRSSFeed\": \"Podcast heeft geen RSS Feed\",\n  \"ToastNoUpdatesNecessary\": \"Geen updates nodig\",\n  \"ToastNotificationCreateFailed\": \"Nieuwe melding aanmaken mislukt\",\n  \"ToastNotificationDeleteFailed\": \"Melding verwijderen mislukt\",\n  \"ToastNotificationFailedMaximum\": \"Maximum aantal pogingen moet >=0\",\n  \"ToastNotificationQueueMaximum\": \"Maximale meldingen wachtrij moet >=0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Meldingsinstellingen bijgewerkt\",\n  \"ToastNotificationTestTriggerFailed\": \"Het is niet gelukt om een testmelding te activeren\",\n  \"ToastNotificationTestTriggerSuccess\": \"Geactiveerde testmelding\",\n  \"ToastNotificationUpdateSuccess\": \"Melding bijgewerkt\",\n  \"ToastPlaylistCreateFailed\": \"Aanmaken afspeellijst mislukt\",\n  \"ToastPlaylistCreateSuccess\": \"Afspeellijst aangemaakt\",\n  \"ToastPlaylistRemoveSuccess\": \"Afspeellijst verwijderd\",\n  \"ToastPlaylistUpdateSuccess\": \"Afspeellijst bijgewerkt\",\n  \"ToastPodcastCreateFailed\": \"Podcast aanmaken mislukt\",\n  \"ToastPodcastCreateSuccess\": \"Podcast aangemaakt\",\n  \"ToastPodcastEpisodeUpdated\": \"Aflevering bijgewerkt\",\n  \"ToastPodcastGetFeedFailed\": \"Podcast feed ophalen mislukt\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Geen afleveringen gevonden in RSS feed\",\n  \"ToastPodcastNoRssFeed\": \"Podcast heeft geen RSS feed\",\n  \"ToastProgressIsNotBeingSynced\": \"De voortgang wordt niet gesynchroniseerd, start het afspelen opnieuw\",\n  \"ToastProviderCreatedFailed\": \"Provider toevoegen mislukt\",\n  \"ToastProviderCreatedSuccess\": \"Nieuwe provider toegevoegd\",\n  \"ToastProviderNameAndUrlRequired\": \"Naam en URL vereist\",\n  \"ToastProviderRemoveSuccess\": \"Provider verwijderd\",\n  \"ToastRSSFeedCloseFailed\": \"Sluiten RSS-feed mislukt\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-feed gesloten\",\n  \"ToastRemoveFailed\": \"Verwijderen mislukt\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Onderdeel verwijderen uit collectie mislukt\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Onderdeel verwijderd uit collectie\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Verwijderen van bibliotheekitems met problemen mislukt\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Bibliotheekitems met problemen verwijderd\",\n  \"ToastRenameFailed\": \"Hernoemen mislukt\",\n  \"ToastRescanFailed\": \"Opnieuw scannen mislukt voor {0}\",\n  \"ToastRescanRemoved\": \"Opnieuw scannen voltooid, item is verwijderd\",\n  \"ToastRescanUpToDate\": \"Rescan voltooid, item is up to date\",\n  \"ToastRescanUpdated\": \"Rescan voltooid, item is geupdated\",\n  \"ToastScanFailed\": \"Bibliotheek item scannen mislukt\",\n  \"ToastSelectAtLeastOneUser\": \"Selecteer ten minste een gebruiker\",\n  \"ToastSendEbookToDeviceFailed\": \"Ebook naar apparaat sturen mislukt\",\n  \"ToastSendEbookToDeviceSuccess\": \"Ebook verstuurd naar apparaat \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Kan niet twee series met dezelfde naam toevoegen\",\n  \"ToastSeriesUpdateFailed\": \"Bijwerken serie mislukt\",\n  \"ToastSeriesUpdateSuccess\": \"Bijwerken serie gelukt\",\n  \"ToastServerSettingsUpdateSuccess\": \"Server instellingen bijgewerkt\",\n  \"ToastSessionCloseFailed\": \"Sessie sluiten mislukt\",\n  \"ToastSessionDeleteFailed\": \"Verwijderen sessie mislukt\",\n  \"ToastSessionDeleteSuccess\": \"Sessie verwijderd\",\n  \"ToastSleepTimerDone\": \"Slaap timer voltooid... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug bevat ongeldige symbolen\",\n  \"ToastSlugRequired\": \"Slug is vereist\",\n  \"ToastSocketConnected\": \"Socket verbonden\",\n  \"ToastSocketDisconnected\": \"Socket niet verbonden\",\n  \"ToastSocketFailedToConnect\": \"Verbinding Socket mislukt\",\n  \"ToastSortingPrefixesEmptyError\": \"Moet ten minste 1 sorteer-prefix bevatten\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Sorteer prefixes geupdated ({0} items)\",\n  \"ToastTitleRequired\": \"Titel is vereist\",\n  \"ToastUnknownError\": \"Onbekende fout\",\n  \"ToastUnlinkOpenIdFailed\": \"Gebruiker ontkoppelen van OpenID mislukt\",\n  \"ToastUnlinkOpenIdSuccess\": \"Gebruiker ontkoppeld van OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Bestandspad \\\"{0}\\\" bestaat al op de server\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Item \\\"{0}\\\" gebruikt een submap van het uploadpad.\",\n  \"ToastUserDeleteFailed\": \"Verwijderen gebruiker mislukt\",\n  \"ToastUserDeleteSuccess\": \"Gebruiker verwijderd\",\n  \"ToastUserPasswordChangeSuccess\": \"Wachtwoord succesvol gewijzigd\",\n  \"ToastUserPasswordMismatch\": \"Wachtwoorden komen niet overeen\",\n  \"ToastUserPasswordMustChange\": \"Het nieuwe wachtwoord kan niet overeenkomen met het oude wachtwoord\",\n  \"ToastUserRootRequireName\": \"U moet een root-gebruikersnaam invoeren\",\n  \"TooltipAddChapters\": \"Hoofdstuk(ken) toevoegen\",\n  \"TooltipAddOneSecond\": \"1 seconde toevoegen\",\n  \"TooltipAdjustChapterStart\": \"Klik om de starttijd aan te passen\",\n  \"TooltipLockAllChapters\": \"Alle hoofdstukken vergrendelen\",\n  \"TooltipLockChapter\": \"Hoofdstuk vergrendelen (Shift+klikken voor bereik)\",\n  \"TooltipSubtractOneSecond\": \"Trek 1 seconde af\",\n  \"TooltipUnlockAllChapters\": \"Alle hoofdstukken ontgrendelen\",\n  \"TooltipUnlockChapter\": \"Hoofdstuk ontgrendelen (Shift+klikken voor bereik)\"\n}\n"
  },
  {
    "path": "client/strings/no.json",
    "content": "{\n  \"ButtonAdd\": \"Legg til\",\n  \"ButtonAddApiKey\": \"Legg til API-nøkkel\",\n  \"ButtonAddChapters\": \"Legg til kapittel\",\n  \"ButtonAddDevice\": \"Legg til enhet\",\n  \"ButtonAddLibrary\": \"Legg til bibliotek\",\n  \"ButtonAddPodcasts\": \"Legg til podcast\",\n  \"ButtonAddUser\": \"Legg til bruker\",\n  \"ButtonAddYourFirstLibrary\": \"Legg til ditt første bibliotek\",\n  \"ButtonApply\": \"Bruk\",\n  \"ButtonApplyChapters\": \"Bruk kapittel\",\n  \"ButtonAuthors\": \"Forfattere\",\n  \"ButtonBack\": \"Tilbake\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Fyll ut fra eksisterende\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Legg til detaljer\",\n  \"ButtonBrowseForFolder\": \"Bla gjennom mappe\",\n  \"ButtonCancel\": \"Avbryt\",\n  \"ButtonCancelEncode\": \"Avbryt konvertering\",\n  \"ButtonChangeRootPassword\": \"Bytt Root-bruker passord\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Sjekk og last ned nye episoder\",\n  \"ButtonChooseAFolder\": \"Velg mappe\",\n  \"ButtonChooseFiles\": \"Velg filer\",\n  \"ButtonClearFilter\": \"Fjern filter\",\n  \"ButtonClose\": \"Lukk\",\n  \"ButtonCloseFeed\": \"Lukk Feed\",\n  \"ButtonCloseSession\": \"Lukk åpen økt\",\n  \"ButtonCollections\": \"Samlinger\",\n  \"ButtonConfigureScanner\": \"Konfigurer skanner\",\n  \"ButtonCreate\": \"Opprett\",\n  \"ButtonCreateBackup\": \"Opprett sikkerhetskopi\",\n  \"ButtonDelete\": \"Slett\",\n  \"ButtonDownloadQueue\": \"Kø\",\n  \"ButtonEdit\": \"Rediger\",\n  \"ButtonEditChapters\": \"Rediger kapittel\",\n  \"ButtonEditPodcast\": \"Rediger podcast\",\n  \"ButtonEnable\": \"Aktiver\",\n  \"ButtonFireAndFail\": \"Utfør og feil\",\n  \"ButtonFireOnTest\": \"Kjør onTest-kommando\",\n  \"ButtonForceReScan\": \"Tving skann\",\n  \"ButtonFullPath\": \"Full sti\",\n  \"ButtonHide\": \"Gjøm\",\n  \"ButtonHome\": \"Hjem\",\n  \"ButtonIssues\": \"Problemer\",\n  \"ButtonJumpBackward\": \"Hopp bakover\",\n  \"ButtonJumpForward\": \"Hopp frem\",\n  \"ButtonLatest\": \"Siste\",\n  \"ButtonLibrary\": \"Bibliotek\",\n  \"ButtonLogout\": \"Logg ut\",\n  \"ButtonLookup\": \"Slå opp\",\n  \"ButtonManageTracks\": \"Administrer spor\",\n  \"ButtonMapChapterTitles\": \"Kartlegg kapittel titler\",\n  \"ButtonMatchAllAuthors\": \"Søk opp alle forfattere\",\n  \"ButtonMatchBooks\": \"Søk opp bøker\",\n  \"ButtonNevermind\": \"Avbryt\",\n  \"ButtonNext\": \"Neste\",\n  \"ButtonNextChapter\": \"Neste Kapittel\",\n  \"ButtonNextItemInQueue\": \"Neste element i køen\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Åpne Feed\",\n  \"ButtonOpenManager\": \"Åpne behandler\",\n  \"ButtonPause\": \"Pause\",\n  \"ButtonPlay\": \"Spill av\",\n  \"ButtonPlayAll\": \"Spill av alle\",\n  \"ButtonPlaying\": \"Spiller av\",\n  \"ButtonPlaylists\": \"Spillelister\",\n  \"ButtonPrevious\": \"Forrige\",\n  \"ButtonPreviousChapter\": \"Forrige Kapittel\",\n  \"ButtonProbeAudioFile\": \"Analyser lydfil\",\n  \"ButtonPurgeAllCache\": \"Tøm alle mellomlager\",\n  \"ButtonPurgeItemsCache\": \"Tøm mellomlager\",\n  \"ButtonQueueAddItem\": \"Legg til kø\",\n  \"ButtonQueueRemoveItem\": \"Fjern fra kø\",\n  \"ButtonQuickEmbed\": \"Hurtiginnbygging\",\n  \"ButtonQuickEmbedMetadata\": \"Bygg inn metadata\",\n  \"ButtonQuickMatch\": \"Kjapt søk\",\n  \"ButtonReScan\": \"Skann på nytt\",\n  \"ButtonRead\": \"Les\",\n  \"ButtonReadLess\": \"Vis mindre\",\n  \"ButtonReadMore\": \"Vis mer\",\n  \"ButtonRefresh\": \"Oppdater\",\n  \"ButtonRemove\": \"Fjern\",\n  \"ButtonRemoveAll\": \"Fjern alle\",\n  \"ButtonRemoveAllLibraryItems\": \"Fjern alle bibliotekobjekter\",\n  \"ButtonRemoveFromContinueListening\": \"Fjern fra Fortsett å lytte\",\n  \"ButtonRemoveFromContinueReading\": \"Fjern fra Fortsett å lese\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Fjern serie fra Fortsett serie\",\n  \"ButtonReset\": \"Nullstill\",\n  \"ButtonResetToDefault\": \"Tilbakestill til standard\",\n  \"ButtonRestore\": \"Gjenopprett\",\n  \"ButtonSave\": \"Lagre\",\n  \"ButtonSaveAndClose\": \"Lagre og lukk\",\n  \"ButtonSaveTracklist\": \"Lagre spilleliste\",\n  \"ButtonScan\": \"Skann\",\n  \"ButtonScanLibrary\": \"Skann bibliotek\",\n  \"ButtonScrollLeft\": \"Rull til venstre\",\n  \"ButtonScrollRight\": \"Rull til høyre\",\n  \"ButtonSearch\": \"Søk\",\n  \"ButtonSelectFolderPath\": \"Velg mappe\",\n  \"ButtonSeries\": \"Serier\",\n  \"ButtonSetChaptersFromTracks\": \"Sett kapittel fra spor\",\n  \"ButtonShare\": \"Del\",\n  \"ButtonShiftTimes\": \"Forskyv tider\",\n  \"ButtonShow\": \"Vis\",\n  \"ButtonStartM4BEncode\": \"Start konvertering til M4B\",\n  \"ButtonStartMetadataEmbed\": \"Start Metadata innbaking\",\n  \"ButtonStats\": \"Statistikk\",\n  \"ButtonSubmit\": \"Lagre\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Koble fra OpenID\",\n  \"ButtonUpload\": \"Last opp\",\n  \"ButtonUploadBackup\": \"Last opp sikkerhetskopi\",\n  \"ButtonUploadCover\": \"Last opp cover\",\n  \"ButtonUploadOPMLFile\": \"Last opp OPML fil\",\n  \"ButtonUserDelete\": \"Slett bruker {0}\",\n  \"ButtonUserEdit\": \"Rediger bruker {0}\",\n  \"ButtonViewAll\": \"Vis alt\",\n  \"ButtonYes\": \"Ja\",\n  \"ErrorUploadFetchMetadataAPI\": \"Feil ved innhenting av metadata\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Kunne ikke hente metadata - forsøk å oppdatere tittel og/eller forfatter\",\n  \"ErrorUploadLacksTitle\": \"Tittel kreves\",\n  \"HeaderAccount\": \"Konto\",\n  \"HeaderAddCustomMetadataProvider\": \"Legg til egendefinert metadata tilbyder\",\n  \"HeaderAdvanced\": \"Avansert\",\n  \"HeaderApiKeys\": \"API-nøkler\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise varslingsinstillinger\",\n  \"HeaderAudioTracks\": \"Lydspor\",\n  \"HeaderAudiobookTools\": \"Lydbok Filbehandlingsverktøy\",\n  \"HeaderAuthentication\": \"Autentisering\",\n  \"HeaderBackups\": \"Sikkerhetskopier\",\n  \"HeaderBulkChapterModal\": \"Legg til flere kapitler\",\n  \"HeaderChangePassword\": \"Bytt passord\",\n  \"HeaderChapters\": \"Kapittel\",\n  \"HeaderChooseAFolder\": \"Velg en mappe\",\n  \"HeaderCollection\": \"Samlinger\",\n  \"HeaderCollectionItems\": \"Samlingsgjenstander\",\n  \"HeaderCover\": \"Omslag\",\n  \"HeaderCurrentDownloads\": \"Aktive nedlastinger\",\n  \"HeaderCustomMessageOnLogin\": \"Egendefinert melding ved pålogging\",\n  \"HeaderCustomMetadataProviders\": \"Egendefinerte metadata tilbydere\",\n  \"HeaderDetails\": \"Detaljer\",\n  \"HeaderDownloadQueue\": \"Last ned kø\",\n  \"HeaderEbookFiles\": \"Ebook filer\",\n  \"HeaderEmail\": \"Epost\",\n  \"HeaderEmailSettings\": \"Epost innstillinger\",\n  \"HeaderEpisodes\": \"Episoder\",\n  \"HeaderEreaderDevices\": \"Ereader enheter\",\n  \"HeaderEreaderSettings\": \"Ereader innstillinger\",\n  \"HeaderFiles\": \"Filer\",\n  \"HeaderFindChapters\": \"Finn Kapittel\",\n  \"HeaderIgnoredFiles\": \"Ignorerte filer\",\n  \"HeaderItemFiles\": \"Elementfiler\",\n  \"HeaderItemMetadataUtils\": \"Element Metadata verktøy\",\n  \"HeaderLastListeningSession\": \"Siste lyttesesjon\",\n  \"HeaderLatestEpisodes\": \"Siste episoder\",\n  \"HeaderLibraries\": \"Biblioteker\",\n  \"HeaderLibraryFiles\": \"Bibliotek filer\",\n  \"HeaderLibraryStats\": \"Bibliotekstatistikk\",\n  \"HeaderListeningSessions\": \"Lyttesesjoner\",\n  \"HeaderListeningStats\": \"Lyttestatistikk\",\n  \"HeaderLogin\": \"Logg inn\",\n  \"HeaderLogs\": \"Loggfiler\",\n  \"HeaderManageGenres\": \"Behandle sjangere\",\n  \"HeaderManageTags\": \"Behandle tags\",\n  \"HeaderMapDetails\": \"Kartleggingsdetaljer\",\n  \"HeaderMatch\": \"Tilpasse\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Prioriteringsrekkefølge for metadata\",\n  \"HeaderMetadataToEmbed\": \"Metadata å bake inn\",\n  \"HeaderNewAccount\": \"Ny konto\",\n  \"HeaderNewApiKey\": \"Ny API-nøkkel\",\n  \"HeaderNewLibrary\": \"Ny bibliotek\",\n  \"HeaderNotificationCreate\": \"Opprett varsling\",\n  \"HeaderNotificationUpdate\": \"Oppdater varsling\",\n  \"HeaderNotifications\": \"Varslinger\",\n  \"HeaderOpenIDConnectAuthentication\": \"Autentisering med OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Åpne lyttesesjoner\",\n  \"HeaderOpenRSSFeed\": \"Åpne RSS Feed\",\n  \"HeaderOtherFiles\": \"Andre filer\",\n  \"HeaderPasswordAuthentication\": \"Logg inn med brukernavn og passord\",\n  \"HeaderPermissions\": \"Rettigheter\",\n  \"HeaderPlayerQueue\": \"Spiller kø\",\n  \"HeaderPlayerSettings\": \"Avspillingsinnstillinger\",\n  \"HeaderPlaylist\": \"Spilleliste\",\n  \"HeaderPlaylistItems\": \"Spillelisteelement\",\n  \"HeaderPodcastsToAdd\": \"Podcaster å legge til\",\n  \"HeaderPresets\": \"Forhåndsinnstillinger\",\n  \"HeaderPreviewCover\": \"Forhåndsvis omslag\",\n  \"HeaderRSSFeedGeneral\": \"RSS Detailer\",\n  \"HeaderRSSFeedIsOpen\": \"RSS Feed er åpen\",\n  \"HeaderRSSFeeds\": \"RSS Feeder\",\n  \"HeaderRemoveEpisode\": \"Fjern episode\",\n  \"HeaderRemoveEpisodes\": \"Fjern {0} episoder\",\n  \"HeaderSavedMediaProgress\": \"Lagret mediefremgang\",\n  \"HeaderSchedule\": \"Timeplan\",\n  \"HeaderScheduleEpisodeDownloads\": \"Planlegg automatisk nedlasting av episoder\",\n  \"HeaderScheduleLibraryScans\": \"Planlegg automatisk bibliotek skann\",\n  \"HeaderSession\": \"Sesjon\",\n  \"HeaderSetBackupSchedule\": \"Sett timeplan for sikkerhetskopi\",\n  \"HeaderSettings\": \"Innstillinger\",\n  \"HeaderSettingsDisplay\": \"Vis\",\n  \"HeaderSettingsExperimental\": \"Eksperimentelle funksjoner\",\n  \"HeaderSettingsGeneral\": \"Generell\",\n  \"HeaderSettingsScanner\": \"Skanner\",\n  \"HeaderSettingsSecurity\": \"Sikkerhet\",\n  \"HeaderSettingsWebClient\": \"Webklient\",\n  \"HeaderSleepTimer\": \"Sove timer\",\n  \"HeaderStatsLargestItems\": \"Største enheter\",\n  \"HeaderStatsLongestItems\": \"Lengste enheter (timer)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minutter lyttet (siste 7 dagene)\",\n  \"HeaderStatsRecentSessions\": \"Siste sesjoner\",\n  \"HeaderStatsTop10Authors\": \"Top 10 forfattere\",\n  \"HeaderStatsTop5Genres\": \"Top 5 sjangere\",\n  \"HeaderTableOfContents\": \"Innholdsfortegnelse\",\n  \"HeaderTools\": \"Verktøy\",\n  \"HeaderUpdateAccount\": \"Oppdater konto\",\n  \"HeaderUpdateApiKey\": \"Oppdater API-nøkkel\",\n  \"HeaderUpdateAuthor\": \"Oppdater forfatter\",\n  \"HeaderUpdateDetails\": \"Oppdater detaljer\",\n  \"HeaderUpdateLibrary\": \"Oppdater bibliotek\",\n  \"HeaderUsers\": \"Brukere\",\n  \"HeaderYearReview\": \"{0} oppsummert\",\n  \"HeaderYourStats\": \"Din statistikk\",\n  \"LabelAbridged\": \"Forkortet\",\n  \"LabelAbridgedChecked\": \"Forkortet (valgt)\",\n  \"LabelAbridgedUnchecked\": \"Forkortet (ikke valgt)\",\n  \"LabelAccessibleBy\": \"Tilgjengelig via\",\n  \"LabelAccountType\": \"Kontotype\",\n  \"LabelAccountTypeAdmin\": \"Administrator\",\n  \"LabelAccountTypeGuest\": \"Gjest\",\n  \"LabelAccountTypeUser\": \"Bruker\",\n  \"LabelActivities\": \"Aktiviteter\",\n  \"LabelActivity\": \"Aktivitet\",\n  \"LabelAddToCollection\": \"Legg til i samling\",\n  \"LabelAddToCollectionBatch\": \"Legg {0} bøker til samling\",\n  \"LabelAddToPlaylist\": \"Legg til i spilleliste\",\n  \"LabelAddToPlaylistBatch\": \"Legg {0} enheter til i spilleliste\",\n  \"LabelAddedAt\": \"Lagt Til\",\n  \"LabelAddedDate\": \"La til {0}\",\n  \"LabelAdminUsersOnly\": \"Kun administratorer\",\n  \"LabelAll\": \"Alle\",\n  \"LabelAllEpisodesDownloaded\": \"Alle nedlastede episoder\",\n  \"LabelAllUsers\": \"Alle brukere\",\n  \"LabelAllUsersExcludingGuests\": \"Alle brukere bortsett fra gjester\",\n  \"LabelAllUsersIncludingGuests\": \"Alle brukere inkludert gjester\",\n  \"LabelAlreadyInYourLibrary\": \"Allerede i biblioteket\",\n  \"LabelApiKeyCreated\": \"API-nøkkel \\\"{0}\\\" ble opprettet.\",\n  \"LabelApiKeyCreatedDescription\": \"Husk å kopiere API-nøkkelen nå siden du ikke kan se den igjen senere.\",\n  \"LabelApiKeyUser\": \"Handle på vegne av bruker\",\n  \"LabelApiKeyUserDescription\": \"Denne API-nøkkelen vil ha de samme tillatelsene som brukeren den handler på vegne av. I loggene vil dette se ut som om brukeren selv foretok forespørselen.\",\n  \"LabelApiToken\": \"API token\",\n  \"LabelAppend\": \"Legge til\",\n  \"LabelAudioBitrate\": \"Bitrate for lyd (f.eks. 128k)\",\n  \"LabelAudioChannels\": \"Lydkanaler (1 eller 2)\",\n  \"LabelAudioCodec\": \"Audio Codec\",\n  \"LabelAuthor\": \"Forfatter\",\n  \"LabelAuthorFirstLast\": \"Forfatter (Fornavn Etternavn)\",\n  \"LabelAuthorLastFirst\": \"Forfatter (Etternavn Fornavn)\",\n  \"LabelAuthors\": \"Forfattere\",\n  \"LabelAutoDownloadEpisodes\": \"Last ned episoder automatisk\",\n  \"LabelAutoFetchMetadata\": \"Automatisk henting av metadata\",\n  \"LabelAutoFetchMetadataHelp\": \"Henter metadata for tittel, forfatter og serie for å optimalisere opplasting. Ekstra metadata må kanskje bekreftes etter opplasting.\",\n  \"LabelAutoLaunch\": \"Autostart\",\n  \"LabelAutoLaunchDescription\": \"Omdiriger til leverandør for innlogging automatisk når innloggingssiden åpnes. (Kan overstyres med <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatisk registrering\",\n  \"LabelAutoRegisterDescription\": \"Lag bruker automatisk ved første innlogging\",\n  \"LabelBackToUser\": \"Tilbake til bruker\",\n  \"LabelBackupAudioFiles\": \"Sikkerhetskopier lydfiler\",\n  \"LabelBackupLocation\": \"Mappe for sikkerhetskopiering\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatiske sikkerhetskopier\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Sikkerhetskopier lagret under /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maksimal størrelse for sikkerhetskopi (i GB) (0 for ubegrenset)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"For å forhindre feilkonfigurasjon, vil sikkerhetskopier mislykkes hvis de oveskride konfigurert størrelse.\",\n  \"LabelBackupsNumberToKeep\": \"Antall sikkerhetskopier som skal beholdes\",\n  \"LabelBackupsNumberToKeepHelp\": \"Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.\",\n  \"LabelBitrate\": \"Bithastighet\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Bøker\",\n  \"LabelButtonText\": \"Tekst på knappen\",\n  \"LabelByAuthor\": \"av {0}\",\n  \"LabelChangePassword\": \"Endre passord\",\n  \"LabelChannels\": \"Kanaler\",\n  \"LabelChapterCount\": \"{0} kapitler\",\n  \"LabelChapterTitle\": \"Kapittel tittel\",\n  \"LabelChapters\": \"Kapitler\",\n  \"LabelChaptersFound\": \"kapitler funnet\",\n  \"LabelClickForMoreInfo\": \"Klikk for mer informasjon\",\n  \"LabelClickToUseCurrentValue\": \"Klikk for å bruke valgt verdi\",\n  \"LabelClosePlayer\": \"Lukk spiller\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Minimer serier\",\n  \"LabelCollapseSubSeries\": \"Skjul underserier\",\n  \"LabelCollection\": \"Samling\",\n  \"LabelCollections\": \"Samlings\",\n  \"LabelComplete\": \"Fullfør\",\n  \"LabelConfirmPassword\": \"Bekreft passord\",\n  \"LabelContinueListening\": \"Fortsett lytting\",\n  \"LabelContinueReading\": \"Fortsett lesing\",\n  \"LabelContinueSeries\": \"Fortsett serier\",\n  \"LabelCorsAllowed\": \"Tillate CORS-opprinnelser\",\n  \"LabelCover\": \"Omslag\",\n  \"LabelCoverImageURL\": \"Omslagsbilde URL\",\n  \"LabelCoverProvider\": \"Tilbyder av omslagsbilde\",\n  \"LabelCreatedAt\": \"Dato opprettet\",\n  \"LabelCronExpression\": \"Cron uttrykk\",\n  \"LabelCurrent\": \"Nåværende\",\n  \"LabelCurrently\": \"Nåværende:\",\n  \"LabelCustomCronExpression\": \"Tilpasset Cron utrykk:\",\n  \"LabelDatetime\": \"Dato tid\",\n  \"LabelDays\": \"Dager\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Slett fra filsystemet (fjern haken for kun å ta bort fra databasen)\",\n  \"LabelDescription\": \"Beskrivelse\",\n  \"LabelDeselectAll\": \"Fjern valg\",\n  \"LabelDetectedPattern\": \"Oppdaget mønster:\",\n  \"LabelDevice\": \"Enhet\",\n  \"LabelDeviceInfo\": \"Enhetsinformasjon\",\n  \"LabelDeviceIsAvailableTo\": \"Enheten er tilgjengelig for...\",\n  \"LabelDirectory\": \"Mappe\",\n  \"LabelDiscFromFilename\": \"Disk fra filnavn\",\n  \"LabelDiscFromMetadata\": \"Disk fra metadata\",\n  \"LabelDiscover\": \"Oppdag\",\n  \"LabelDownload\": \"Last ned\",\n  \"LabelDownloadNEpisodes\": \"Last ned {0} episoder\",\n  \"LabelDownloadable\": \"Nedlastbar\",\n  \"LabelDuration\": \"Varighet\",\n  \"LabelDurationComparisonExactMatch\": \"(nøyaktig treff)\",\n  \"LabelDurationComparisonLonger\": \"({0} lenger)\",\n  \"LabelDurationComparisonShorter\": \"({0} kortere)\",\n  \"LabelDurationFound\": \"Varighet funnet:\",\n  \"LabelEbook\": \"Ebok\",\n  \"LabelEbooks\": \"E-bøker\",\n  \"LabelEdit\": \"Rediger\",\n  \"LabelEmail\": \"Epost\",\n  \"LabelEmailSettingsFromAddress\": \"Fra Adresse\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Avvis uautoriserte sertifikat\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Ved å deaktivere sjekk av SSL sertifikat eksponerer man tilkoblingen for sikkerhetsrisiko, som for eksempel mann-i-midten-angrep. Slå av kun om du forstår implikasjonene og stoler på e-post-serveren du kobler til!\",\n  \"LabelEmailSettingsSecure\": \"Sikker\",\n  \"LabelEmailSettingsSecureHelp\": \"Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Test Adresse\",\n  \"LabelEmbeddedCover\": \"Bak inn omslag\",\n  \"LabelEnable\": \"Aktiver\",\n  \"LabelEncodingBackupLocation\": \"En sikkerhetskopi av de originale lyd-filene lagres i mappen:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Kapitler er ikke bygget inn i flersporede lydbøker.\",\n  \"LabelEncodingClearItemCache\": \"Husk å tømme mellomlageret med jevne mellomrom.\",\n  \"LabelEncodingFinishedM4B\": \"Ferdig konvertert M4B-lydbøker legges i lydbok-mappen:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadata bygges inn i lydsporene i lydbokmappen.\",\n  \"LabelEncodingStartedNavigation\": \"Så snart oppgaven er startet kan du navigere bort fra denne siden.\",\n  \"LabelEncodingTimeWarning\": \"Konvertering kan ta opptil 30 minutter.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Advarsel: Ikke oppdater disse innstillingene med mindre du er godt kjent med hvordan ffmpeg og konverteringsvalgene fungerer.\",\n  \"LabelEncodingWatcherDisabled\": \"Hvis du har slått av overvåking så må du skanne dette biblioteket på nytt etterpå.\",\n  \"LabelEnd\": \"Slutt\",\n  \"LabelEndOfChapter\": \"Slutt på kapittel\",\n  \"LabelEpisode\": \"Episode\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episode er ikke koblet til en RSS feed\",\n  \"LabelEpisodeNumber\": \"Episode #{0}\",\n  \"LabelEpisodeTitle\": \"Episode tittel\",\n  \"LabelEpisodeType\": \"Episode type\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Episode URL fra RSS feed\",\n  \"LabelEpisodes\": \"Episoder\",\n  \"LabelEpisodic\": \"Episodisk\",\n  \"LabelExample\": \"Eksempel\",\n  \"LabelExpandSeries\": \"Vis serie\",\n  \"LabelExpandSubSeries\": \"Vis underserie\",\n  \"LabelExpired\": \"Utløpt\",\n  \"LabelExpiresAt\": \"Utløper\",\n  \"LabelExpiresInSeconds\": \"Utløper om (sekunder)\",\n  \"LabelExpiresNever\": \"Aldri\",\n  \"LabelExplicit\": \"Eksplisitt\",\n  \"LabelExplicitChecked\": \"Eksplisitt (avhuket)\",\n  \"LabelExplicitUnchecked\": \"Ikke eksplisitt (ikke avhuket)\",\n  \"LabelExportOPML\": \"Eksporter OPML\",\n  \"LabelFeedURL\": \"Feed Adresse\",\n  \"LabelFetchingMetadata\": \"Henter metadata\",\n  \"LabelFile\": \"Fil\",\n  \"LabelFileBirthtime\": \"Fil Opprettelsesdato\",\n  \"LabelFileBornDate\": \"Født {0}\",\n  \"LabelFileModified\": \"Fil Endret\",\n  \"LabelFileModifiedDate\": \"Redigert {0}\",\n  \"LabelFilename\": \"Filnavn\",\n  \"LabelFilterByUser\": \"Filtrer etter bruker\",\n  \"LabelFindEpisodes\": \"Finn episoder\",\n  \"LabelFinished\": \"Fullført\",\n  \"LabelFinishedDate\": \"Fullført {0}\",\n  \"LabelFolder\": \"Mappe\",\n  \"LabelFolders\": \"Mapper\",\n  \"LabelFontBold\": \"Fet\",\n  \"LabelFontBoldness\": \"Skrifttykkelse\",\n  \"LabelFontFamily\": \"Skriftfamilie\",\n  \"LabelFontItalic\": \"Kursiv\",\n  \"LabelFontScale\": \"Font størrelse\",\n  \"LabelFontStrikethrough\": \"Gjennomstreking\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Full\",\n  \"LabelGenre\": \"Sjanger\",\n  \"LabelGenres\": \"Sjangre\",\n  \"LabelHardDeleteFile\": \"Tving sletting av fil\",\n  \"LabelHasEbook\": \"Har e-bok\",\n  \"LabelHasSupplementaryEbook\": \"Har supplerende e-bok\",\n  \"LabelHideSubtitles\": \"Skjul undertitler\",\n  \"LabelHighestPriority\": \"Høyeste prioritet\",\n  \"LabelHost\": \"Tjener\",\n  \"LabelHour\": \"Time\",\n  \"LabelHours\": \"Timer\",\n  \"LabelIcon\": \"Ikon\",\n  \"LabelImageURLFromTheWeb\": \"Bilde-URL fra nett\",\n  \"LabelInProgress\": \"I gang\",\n  \"LabelIncludeInTracklist\": \"Inkluder i sporliste\",\n  \"LabelIncomplete\": \"Ufullstendig\",\n  \"LabelInterval\": \"Intervall\",\n  \"LabelIntervalCustomDailyWeekly\": \"Egendefinert daglig/ukentlig\",\n  \"LabelIntervalEvery12Hours\": \"Hver 12. timer\",\n  \"LabelIntervalEvery15Minutes\": \"Hver 15. minutter\",\n  \"LabelIntervalEvery2Hours\": \"Hver 2. timer\",\n  \"LabelIntervalEvery30Minutes\": \"Hver 30. minutter\",\n  \"LabelIntervalEvery6Hours\": \"Hver 6. timer\",\n  \"LabelIntervalEveryDay\": \"Hver dag\",\n  \"LabelIntervalEveryHour\": \"Hver time\",\n  \"LabelIntervalEveryMinute\": \"Hvert minutt\",\n  \"LabelInvert\": \"Inverter\",\n  \"LabelItem\": \"Enhet\",\n  \"LabelJumpBackwardAmount\": \"Hopp bakover med\",\n  \"LabelJumpForwardAmount\": \"Hopp forover med\",\n  \"LabelLanguage\": \"Språk\",\n  \"LabelLanguageDefaultServer\": \"Standard tjener språk\",\n  \"LabelLanguages\": \"Språk\",\n  \"LabelLastBookAdded\": \"Siste bok lagt til\",\n  \"LabelLastBookUpdated\": \"Siste bok oppdatert\",\n  \"LabelLastProgressDate\": \"Siste fremgang: {0}\",\n  \"LabelLastSeen\": \"Sist sett\",\n  \"LabelLastTime\": \"Siste tid\",\n  \"LabelLastUpdate\": \"Siste oppdatering\",\n  \"LabelLayout\": \"Utseende\",\n  \"LabelLayoutSinglePage\": \"Enkeltside\",\n  \"LabelLayoutSplitPage\": \"Del side\",\n  \"LabelLess\": \"Mindre\",\n  \"LabelLibrariesAccessibleToUser\": \"Biblioteker tilgjengelig for bruker\",\n  \"LabelLibrary\": \"Bibliotek\",\n  \"LabelLibraryFilterSublistEmpty\": \"Ingen {0}\",\n  \"LabelLibraryItem\": \"Bibliotek enhet\",\n  \"LabelLibraryName\": \"Bibliotek navn\",\n  \"LabelLibrarySortByProgress\": \"Fremdrift: Sist oppdatert\",\n  \"LabelLibrarySortByProgressFinished\": \"Fremdrift: Fullført\",\n  \"LabelLibrarySortByProgressStarted\": \"Fremdrift: Startet\",\n  \"LabelLimit\": \"Begrensning\",\n  \"LabelLineSpacing\": \"Linjemellomrom\",\n  \"LabelListenAgain\": \"Lytt igjen\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Varsel\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Se etter nye episoder etter denne datoen\",\n  \"LabelLowestPriority\": \"Laveste prioritet\",\n  \"LabelMatchConfidence\": \"Konfidens\",\n  \"LabelMatchExistingUsersBy\": \"Knytt sammen eksisterende brukere basert på\",\n  \"LabelMatchExistingUsersByDescription\": \"Brukes for å koble til eksisterende brukere. Når koblingen er i orden vil brukerne bli identifisert med en unik id fra SSO-tilbyderen\",\n  \"LabelMaxEpisodesToDownload\": \"Maksimalt antall episoder som skal lastes ned. Bruk 0 for ubegrenset.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Maksimalt antall nye episoder som skal lastes ned per sjekk\",\n  \"LabelMaxEpisodesToKeep\": \"Maksimalt antall episoder som skal beholdes\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Sett verdien til null (0) for ubegrenset. Etter at en episode lastes ned automatisk, så slettes den eldste episoden, om du har mer enn X episoder. Det slettes kun én episode per nye nedlasting.\",\n  \"LabelMediaPlayer\": \"Mediespiller\",\n  \"LabelMediaType\": \"Medie type\",\n  \"LabelMetaTag\": \"Meta tag\",\n  \"LabelMetaTags\": \"Meta tags\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Høyere prioritert kilder for metadata overstyrer laverer prioriterte kilder for metadata\",\n  \"LabelMetadataProvider\": \"Metadata Leverandør\",\n  \"LabelMinute\": \"Minutt\",\n  \"LabelMinutes\": \"Minutter\",\n  \"LabelMissing\": \"Mangler\",\n  \"LabelMissingEbook\": \"Har ingen e-bok\",\n  \"LabelMissingSupplementaryEbook\": \"Har ingen komplementær e-bok\",\n  \"LabelMobileRedirectURIs\": \"Tillatte URL-er for vidersending\",\n  \"LabelMobileRedirectURIsDescription\": \"Dette er en liste over godkjente videresendings-URL-er for mobil-apper. Standarden er <code>audiobookshelf://oauth</code>, som du kan fjerne eller supplere med ekstra URL-er for tredjeparts app-integrasjoner. For å tillate alle URL-er kan du bruke kun en (<code>*</code>) .\",\n  \"LabelMore\": \"Mer\",\n  \"LabelMoreInfo\": \"Mer info\",\n  \"LabelName\": \"Navn\",\n  \"LabelNarrator\": \"Forteller\",\n  \"LabelNarrators\": \"Fortellere\",\n  \"LabelNew\": \"Ny\",\n  \"LabelNewPassword\": \"Nytt passord\",\n  \"LabelNewestAuthors\": \"Nyeste forfattere\",\n  \"LabelNewestEpisodes\": \"Nyeste episoder\",\n  \"LabelNextBackupDate\": \"Neste sikkerhetskopi dato\",\n  \"LabelNextChapters\": \"Neste kapitler blir:\",\n  \"LabelNextScheduledRun\": \"Neste planlagte kjøring\",\n  \"LabelNoApiKeys\": \"Ingen API-nøkler\",\n  \"LabelNoCustomMetadataProviders\": \"Ingen egendefinerte tilbydere for metadata\",\n  \"LabelNoEpisodesSelected\": \"Ingen episoder valgt\",\n  \"LabelNotFinished\": \"Ikke fullført\",\n  \"LabelNotStarted\": \"Ikke startet\",\n  \"LabelNotes\": \"Notat\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(er)\",\n  \"LabelNotificationAvailableVariables\": \"Tilgjengelige variabler\",\n  \"LabelNotificationBodyTemplate\": \"Kroppsmal\",\n  \"LabelNotificationEvent\": \"Varsling\",\n  \"LabelNotificationTitleTemplate\": \"Tittel mal\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maks mislykkede forsøk\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Varslinger deaktiveres når sending feiles dette antallet ganger\",\n  \"LabelNotificationsMaxQueueSize\": \"Maksimalt antall varslinger i kø\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.\",\n  \"LabelNumberOfBooks\": \"Antall bøker\",\n  \"LabelNumberOfChapters\": \"Antall kapitler:\",\n  \"LabelNumberOfEpisodes\": \"# episoder\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Navnet på OpenID claim'et som inneholder avanserte tilganger for brukerhandlinger i applikasjonen som vil brukes for ikke-administratorroller (<b>hvis konfigurert</b>). Hvis claim'et mangler fra responsen, nektes tilgang til ABS. Hvis en enkelt opsjon mangler, blir behandlet som <code>false</code>. Påse at identitetstilbyderens claim stemmer overens med den forventede strukturen:\",\n  \"LabelOpenIDClaims\": \"La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \\\"Bruker\\\" vil da også automatisk legges til.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Navn på OpenID-forespørsel som inneholder en lite over brukerens grupper. Vanligvis kalt <code>grupper</code>. <b>Om konfigurert</b>, vil applikasjonen tildele roller baseret på brukerens gruppemedlemsskaper, gitt disse grupper er navngitt (uten forbehold for store og små bokstaver) 'admin', 'user' eller 'guest' i forespørsel. Forespørselen burde inneholde en liste (og hvis brukeren tilhører flere grupper), applikasjonen vil tildele rolle med høyeste adgangsnivå. Hvis ingen grupper matcher vil adgang bli nektet.\",\n  \"LabelOpenRSSFeed\": \"Åpne RSS Feed\",\n  \"LabelOverwrite\": \"Overskriv\",\n  \"LabelPaginationPageXOfY\": \"Side {0} av {1}\",\n  \"LabelPassword\": \"Passord\",\n  \"LabelPath\": \"Sti\",\n  \"LabelPermanent\": \"Fast\",\n  \"LabelPermissionsAccessAllLibraries\": \"Har tilgang til alle bibliotek\",\n  \"LabelPermissionsAccessAllTags\": \"Har til gang til alle tags\",\n  \"LabelPermissionsAccessExplicitContent\": \"Har tilgang til eksplisitt material\",\n  \"LabelPermissionsCreateEreader\": \"Kan opprette e-leser\",\n  \"LabelPermissionsDelete\": \"Kan slette\",\n  \"LabelPermissionsDownload\": \"Kan laste ned\",\n  \"LabelPermissionsUpdate\": \"Kan oppdatere\",\n  \"LabelPermissionsUpload\": \"Kan laste opp\",\n  \"LabelPersonalYearReview\": \"Oppsummering av året ditt ({0})\",\n  \"LabelPhotoPathURL\": \"Bilde sti/URL\",\n  \"LabelPlayMethod\": \"Avspillingsmetode\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Trinnstørrelse for økning/senking av avspillingshastighet\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} av {1}\",\n  \"LabelPlaylists\": \"Spilleliste\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast-søkeområde\",\n  \"LabelPodcastType\": \"Podcast type\",\n  \"LabelPodcasts\": \"Podcaster\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)\",\n  \"LabelPreventIndexing\": \"Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger\",\n  \"LabelPrimaryEbook\": \"Primær ebok\",\n  \"LabelProgress\": \"Framgang\",\n  \"LabelProvider\": \"Tilbyder\",\n  \"LabelProviderAuthorizationValue\": \"Autorisasjons header-verdi\",\n  \"LabelPubDate\": \"Publiseringsdato\",\n  \"LabelPublishYear\": \"Publikasjonsår\",\n  \"LabelPublishedDate\": \"Publisert {0}\",\n  \"LabelPublishedDecade\": \"Tiår for utgivelse\",\n  \"LabelPublishedDecades\": \"Tiår for utgivelse\",\n  \"LabelPublisher\": \"Forlegger\",\n  \"LabelPublishers\": \"Utgivere\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Tilpasset eier e-post\",\n  \"LabelRSSFeedCustomOwnerName\": \"Tilpasset eier Navn\",\n  \"LabelRSSFeedOpen\": \"RSS-strøm åpen\",\n  \"LabelRSSFeedPreventIndexing\": \"Forhindre indeksering\",\n  \"LabelRSSFeedSlug\": \"RSS-feed ID\",\n  \"LabelRSSFeedURL\": \"RSS-feed URL\",\n  \"LabelRandomly\": \"Tilfeldighet\",\n  \"LabelReAddSeriesToContinueListening\": \"Legg til igjen til \\\"Fortsett å lytte\\\"\",\n  \"LabelRead\": \"Les\",\n  \"LabelReadAgain\": \"Les igjen\",\n  \"LabelReadEbookWithoutProgress\": \"Les ebok uten å beholde fremgang\",\n  \"LabelRecentSeries\": \"Nylige serier\",\n  \"LabelRecentlyAdded\": \"Nylig tillagt\",\n  \"LabelRecommended\": \"Anbefalte\",\n  \"LabelRedo\": \"Gjenta\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Utgivelsesdato\",\n  \"LabelRemoveAllMetadataAbs\": \"Fjern alle metadata.abs filer\",\n  \"LabelRemoveAllMetadataJson\": \"Fjern alle metadata.json filer\",\n  \"LabelRemoveAudibleBranding\": \"Fjern Audible inn- og utledning fra kapitler\",\n  \"LabelRemoveCover\": \"Fjern omslag\",\n  \"LabelRemoveMetadataFile\": \"Fjern metadata-filer fra biblioteks-mapper\",\n  \"LabelRemoveMetadataFileHelp\": \"Fjern alle metadata.json og metadata.abs i alle {0} mappene.\",\n  \"LabelRowsPerPage\": \"Rader per side\",\n  \"LabelSearchTerm\": \"Søkeord\",\n  \"LabelSearchTitle\": \"Søk tittel\",\n  \"LabelSearchTitleOrASIN\": \"Søk tittel eller ASIN\",\n  \"LabelSeason\": \"Sesong\",\n  \"LabelSeasonNumber\": \"Sesong #{0}\",\n  \"LabelSelectAll\": \"Velg alt\",\n  \"LabelSelectAllEpisodes\": \"Velg alle episoder\",\n  \"LabelSelectEpisodesShowing\": \"Velg {0} episoder vist\",\n  \"LabelSelectUser\": \"Velg bruker\",\n  \"LabelSelectUsers\": \"Velg brukere\",\n  \"LabelSendEbookToDevice\": \"Send Ebok til...\",\n  \"LabelSequence\": \"Sekvens\",\n  \"LabelSerial\": \"Serienr.\",\n  \"LabelSeries\": \"Serier\",\n  \"LabelSeriesName\": \"Serier Navn\",\n  \"LabelSeriesProgress\": \"Serier fremgang\",\n  \"LabelServerLogLevel\": \"Server logg-nivå\",\n  \"LabelServerYearReview\": \"Server - Oppsummering av året ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Sett som primær\",\n  \"LabelSetEbookAsSupplementary\": \"Sett som supplerende\",\n  \"LabelSettingsAllowIframe\": \"Tillat å bygge inn i en iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Kun lydbøker\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Aktivering av dette valget til ignorere ebok filer utenom de er i en lydbok mappe hvor de vil bli satt som supplerende ebøker\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeuomorf design med hyller av ved\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast støtte\",\n  \"LabelSettingsDateFormat\": \"Dato Format\",\n  \"LabelSettingsEnableWatcher\": \"Skann biblioteker automatisk for endringer\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Skann bibliotek automatisk for endringer\",\n  \"LabelSettingsEnableWatcherHelp\": \"Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Tillat scripting i innholdet i ebub-bøker\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.\",\n  \"LabelSettingsExperimentalFeatures\": \"Eksperimentelle funksjoner\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.\",\n  \"LabelSettingsFindCovers\": \"Finn omslag\",\n  \"LabelSettingsFindCoversHelp\": \"Hvis lydboken ikke har innbakt omslag eller ett omslagsbilde i mappen, vil skanneren prøve å finne ett.<br>Notis: Dette vil øke søketiden\",\n  \"LabelSettingsHideSingleBookSeries\": \"Gjem bokserie med en bok\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Hjemmeside bruk bokhyllevisning\",\n  \"LabelSettingsLibraryBookshelfView\": \"Bibliotek bruk bokhyllevisning\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Prosent ferdig er større enn\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Gjenværende tid er mindre enn (sekunder)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Marker som ferdig når\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Hopp over tidligere bøker i \\\"Fortsett serien\\\"\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"\\\"Fortsett serie\\\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.\",\n  \"LabelSettingsParseSubtitles\": \"Analyser undertitler\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \\\" - \\\"<br>f.eks. \\\"Boktittel - En lengre tittel\\\" har undertittel \\\"En lengre tittel\\\".\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Foretrekk funnet metadata\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Hopp over bøker som allerede har ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Hopp over bøker som allerede har ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorer prefiks når under sortering\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"f.eks. for prefiks \\\"Den\\\" bok tittel \\\"Den Lille Havfruen\\\" vil bli sortert som \\\"Lille havfruen, Den\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Bruk kvadratiske bokomslag\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Foretrekk å bruke kvadratiske bokomslag i stedet for den standard 1.6:1 bokomslag\",\n  \"LabelSettingsStoreCoversWithItem\": \"Lagre bokomslag med gjenstand\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Som standard vil bokomslag bli lagret under /metadata/items, aktiveres dette valget vil bokomslag bli lagret i samme mappe som gjenstanden. Kun en fil med navn \\\"cover\\\" vil bli beholdt\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Lagre metadata med gjenstand\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden\",\n  \"LabelSettingsTimeFormat\": \"Tid format\",\n  \"LabelShare\": \"Dele\",\n  \"LabelShareDownloadableHelp\": \"Tillat brukere med en delt link å laste ned en zip-fil av elementet.\",\n  \"LabelShareOpen\": \"Åpne deling\",\n  \"LabelShareURL\": \"Dele URL\",\n  \"LabelShowAll\": \"Vis alle\",\n  \"LabelShowSeconds\": \"Vis sekunder\",\n  \"LabelShowSubtitles\": \"Vis undertitler\",\n  \"LabelSize\": \"Størrelse\",\n  \"LabelSleepTimer\": \"Sove-timer\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Stigende\",\n  \"LabelSortDescending\": \"Synkende\",\n  \"LabelSortPubDate\": \"Sorter etter publiseringsdato\",\n  \"LabelStart\": \"Start\",\n  \"LabelStartTime\": \"Start Tid\",\n  \"LabelStarted\": \"Startet\",\n  \"LabelStartedAt\": \"Startet\",\n  \"LabelStartedDate\": \"Startet {0}\",\n  \"LabelStatsAudioTracks\": \"Lydspor\",\n  \"LabelStatsAuthors\": \"Forfattere\",\n  \"LabelStatsBestDay\": \"Beste dag\",\n  \"LabelStatsDailyAverage\": \"Daglig gjennomsnitt\",\n  \"LabelStatsDays\": \"Dager\",\n  \"LabelStatsDaysListened\": \"Dager lyttet\",\n  \"LabelStatsHours\": \"Timer\",\n  \"LabelStatsInARow\": \"på rad\",\n  \"LabelStatsItemsFinished\": \"Gjenstander fullført\",\n  \"LabelStatsItemsInLibrary\": \"Gjenstander i biblioteket\",\n  \"LabelStatsMinutes\": \"minuter\",\n  \"LabelStatsMinutesListening\": \"Minutter lyttet\",\n  \"LabelStatsOverallDays\": \"Totale dager\",\n  \"LabelStatsOverallHours\": \"Totale timer\",\n  \"LabelStatsWeekListening\": \"Uker lyttet\",\n  \"LabelSubtitle\": \"Undertittel\",\n  \"LabelSupportedFileTypes\": \"Støttede filtyper\",\n  \"LabelTag\": \"Merke\",\n  \"LabelTags\": \"Tagger\",\n  \"LabelTagsAccessibleToUser\": \"Tagger tilgjengelig for bruker\",\n  \"LabelTagsNotAccessibleToUser\": \"Tagger ikke tilgjengelig for bruker\",\n  \"LabelTasks\": \"Oppgaver som kjører\",\n  \"LabelTextEditorBulletedList\": \"Punkt-liste\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"Nummerert liste\",\n  \"LabelTextEditorUnlink\": \"Fjern link\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Mørk\",\n  \"LabelThemeLight\": \"Lys\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Tidsbase\",\n  \"LabelTimeDurationXHours\": \"{0} timer\",\n  \"LabelTimeDurationXMinutes\": \"{0} minutter\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekunder\",\n  \"LabelTimeInMinutes\": \"Timer i minutter\",\n  \"LabelTimeLeft\": \"{0} gjenstår\",\n  \"LabelTimeListened\": \"Tid lyttet\",\n  \"LabelTimeListenedToday\": \"Tid lyttet idag\",\n  \"LabelTimeRemaining\": \"{0} gjennstående\",\n  \"LabelTimeToShift\": \"Tid å forflytte i sekunder\",\n  \"LabelTitle\": \"Tittel\",\n  \"LabelToolsEmbedMetadata\": \"Bygg inn metadata\",\n  \"LabelToolsEmbedMetadataDescription\": \"Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.\",\n  \"LabelToolsM4bEncoder\": \"M4B enkoder\",\n  \"LabelToolsMakeM4b\": \"Lag M4B Lydbokfil\",\n  \"LabelToolsMakeM4bDescription\": \"Lager en M4B lydbokfil med innbakt omslagsbilde og kapitler.\",\n  \"LabelToolsSplitM4b\": \"Del M4B inn i MP3er\",\n  \"LabelToolsSplitM4bDescription\": \"Lager MP3er fra en M4B inndelt i kapitler med innbakt metadata, omslagsbilde og kapitler.\",\n  \"LabelTotalDuration\": \"Total lengde\",\n  \"LabelTotalTimeListened\": \"Total tid lyttet\",\n  \"LabelTrackFromFilename\": \"Spor fra Filnavn\",\n  \"LabelTrackFromMetadata\": \"Spor fra Metadata\",\n  \"LabelTracks\": \"Spor\",\n  \"LabelTracksMultiTrack\": \"Flerspor\",\n  \"LabelTracksNone\": \"Ingen spor\",\n  \"LabelTracksSingleTrack\": \"Enkelspor\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Type\",\n  \"LabelUnabridged\": \"Uavkortet\",\n  \"LabelUndo\": \"Angre\",\n  \"LabelUnknown\": \"Ukjent\",\n  \"LabelUnknownPublishDate\": \"Ukjent publiseringsdato\",\n  \"LabelUpdateCover\": \"Oppdater omslag\",\n  \"LabelUpdateCoverHelp\": \"Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet\",\n  \"LabelUpdateDetails\": \"Oppdater detaljer\",\n  \"LabelUpdateDetailsHelp\": \"Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet\",\n  \"LabelUpdatedAt\": \"Oppdatert\",\n  \"LabelUploaderDragAndDrop\": \"Dra og slipp filer eller mapper\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Dra & slipp filer\",\n  \"LabelUploaderDropFiles\": \"Slipp filer\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Hent tittel, forfatter og serie automatisk\",\n  \"LabelUseAdvancedOptions\": \"Bruk avanserte valg\",\n  \"LabelUseChapterTrack\": \"Bruk kapittelspor\",\n  \"LabelUseFullTrack\": \"Bruke hele sporet\",\n  \"LabelUseZeroForUnlimited\": \"Bruk 0 for ubegrenset\",\n  \"LabelUser\": \"Bruker\",\n  \"LabelUsername\": \"Brukernavn\",\n  \"LabelValue\": \"Verdi\",\n  \"LabelVersion\": \"Versjon\",\n  \"LabelViewBookmarks\": \"Vis bokmerker\",\n  \"LabelViewChapters\": \"Vis kapitler\",\n  \"LabelViewPlayerSettings\": \"Vis innstillinger for avspiller\",\n  \"LabelViewQueue\": \"Vis spillerkø\",\n  \"LabelVolume\": \"Volum\",\n  \"LabelWebRedirectURLsDescription\": \"Godkjenn disse URL-ene hos OAuth-tilbyder for å tillate videresending til web-appen etter innlogging:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Undermapper for videresendings-URL-er\",\n  \"LabelWeekdaysToRun\": \"Ukedager å kjøre\",\n  \"LabelXBooks\": \"{0} bøker\",\n  \"LabelXItems\": \"{0} elementer\",\n  \"LabelYearReviewHide\": \"Skjul oppsummering av året\",\n  \"LabelYearReviewShow\": \"Vis oppsummering av året\",\n  \"LabelYourAudiobookDuration\": \"Din lydbok lengde\",\n  \"LabelYourBookmarks\": \"Dine bokmerker\",\n  \"LabelYourPlaylists\": \"Dine spillelister\",\n  \"LabelYourProgress\": \"Din fremgang\",\n  \"MessageAddToPlayerQueue\": \"Legg til i kø\",\n  \"MessageAppriseDescription\": \"For å bruke denne funksjonen trenger du en instans av <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> kjørende eller et API som håndterer disse forespørslene. <br />Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på <code>http://192.168.1.1:8337</code> så skal du bruke <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Påse at du bruker ASIN fra den riktige Audible-regionen, ikke Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Eldre API-tokener vil bli fjernet i fremtiden. Bruk <a href=\\\"/config/api-keys\\\">API-nøkler</a> i stedet.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Etter å ha lagret, start serveren din på nytt for at OIDC-endringene skal tre i kraft.\",\n  \"MessageAuthenticationSecurityMessage\": \"Autentisering er forbedret av sikkerhetshensyn. Alle brukere må logge inn på nytt.\",\n  \"MessageBackupsDescription\": \"Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.\",\n  \"MessageBackupsLocationEditNote\": \"Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!\",\n  \"MessageBackupsLocationNoEditNote\": \"Viktig: Mappen for sikkerhetskopi satt i en miljøvariabel og kan ikke endres her.\",\n  \"MessageBackupsLocationPathEmpty\": \"Mappen for sikkerhetskopiering må angis\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Fyll aktiverte felt med data fra alle elementer. Felt med flere verdier blir slått sammen\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Fyll aktiverte kartdetaljfelt med data fra dette elementet\",\n  \"MessageBatchQuickMatchDescription\": \"Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.\",\n  \"MessageBookshelfNoCollections\": \"Du har ikke laget noen samlinger ennå\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Samlinger er offentlige. Alle brukere med tilgang til biblioteket kan se dem.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Ingen RSS feed er åpen\",\n  \"MessageBookshelfNoResultsForFilter\": \"Ingen resultat for filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Ingen resultater for søket\",\n  \"MessageBookshelfNoSeries\": \"Du har ingen serier\",\n  \"MessageBulkChapterPattern\": \"Hvor mange kapitler vil du legge til med dette nummereringsmønsteret?\",\n  \"MessageChapterEndIsAfter\": \"Kapittel slutt er etter slutt av lydboken\",\n  \"MessageChapterErrorFirstNotZero\": \"Første kapittel starter på 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Feil start tid, må være mindre enn lengde på lydbok\",\n  \"MessageChapterErrorStartLtPrev\": \"Feil start tid, må være større eller det samme som forrige kapittel start tid\",\n  \"MessageChapterStartIsAfter\": \"Kapittel start er etter slutten av din lydbok\",\n  \"MessageChaptersNotFound\": \"Fant ikke kapitler\",\n  \"MessageCheckingCron\": \"Sjekker cron...\",\n  \"MessageConfirmCloseFeed\": \"Er du sikker på at du vil lukke denne feeden?\",\n  \"MessageConfirmDeleteApiKey\": \"Er du sikker på at du vil slette API-nøkkelen \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Er du sikker på at du vil slette sikkerhetskopi for {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Er du sikker på at du vil slette e-leser enheten \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Dette vil slette filen fra filsystemet. Er du sikker?\",\n  \"MessageConfirmDeleteLibrary\": \"Er du sikker på at du vil slette biblioteket \\\"{0}\\\" for godt?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Nå slettes elementet fra databasen og fil-systemet. Er du sikker?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Nå slettes {0} elementer fra databasen og fil-systemet. Er du sikker?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Er du sikker på at du vil slette den egendefinerte leverandøren av metadata: \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Er du sikker på at du vil slette dette varselet?\",\n  \"MessageConfirmDeleteSession\": \"Er du sikker på at du vil slette denne sesjonen?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Er du sikker på at du vil legge til metadata i {0} lyd-filer?\",\n  \"MessageConfirmForceReScan\": \"Er du sikker på at du vil tvinge en ny skann?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Er du sikker på at du vil markere alle episodene som fullført?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Er du sikker på at du vil markere alle episodene som ikke fullført?\",\n  \"MessageConfirmMarkItemFinished\": \"Er du sikker på at du vil markere {0} som ferdig?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Er du sikker på at du vil markere {0} som ikke ferdig?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Er du sikker på at du vil markere alle bøkene i serien som fullført?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Utløs dette varselet med test-data?\",\n  \"MessageConfirmPurgeCache\": \"Tømming av mellomlagring vil slette hele mappen <code>/metadata/cache</code>. <br /><br />Er du sikker på at du vil slette mappen?\",\n  \"MessageConfirmPurgeItemsCache\": \"(Purge items cache) Dette vil sletter hele mappen <code>/metadata/cache/items</code>.<br />Er du sikker?\",\n  \"MessageConfirmQuickEmbed\": \"Advarsel! Rask innbygging av metadata tar ikke backup av lyd-filene først. Forsikre deg om at du har sikkerhetskopi av filene. <br><br> Fortsett?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Hurtig gjenkjenning av episoder overskriver detaljene hvis en match blir funnet. Kun episoder som ikke allerede er matchet blir oppdatert. Er du sikker?\",\n  \"MessageConfirmReScanLibraryItems\": \"Er du sikker på at du ønsker å skanne {0} elementer på nytt?\",\n  \"MessageConfirmRemoveAllChapters\": \"Er du sikker på at du vil fjerne alle kapitler?\",\n  \"MessageConfirmRemoveAuthor\": \"Er du sikker på at du vil fjerne forfatteren \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Er du sikker på at du vil fjerne samling\\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Er du sikker på at du vil fjerne episode \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Merk: Dette sletter ikke lydfilen med mindre du slår på \\\"Hard delete file\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Er du sikker på at du vil fjerne {0} episoder?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Er du sikker på at du vil fjerne {0} lytte-sesjoner?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Er du sikker på at du vil fjerne alle metadata.{0}-filer i mappene for biblioteks-elementer?\",\n  \"MessageConfirmRemoveNarrator\": \"Er du sikker på at du vil fjerne forteller \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Er du sikker på at du vil fjerne spillelisten \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Er du sikker på at du vil endre sjanger \\\"{0}\\\" til \\\"{1}\\\" for alle gjenstandene?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Notis: Denne sjangeren finnes allerede så de vil bli slått sammen.\",\n  \"MessageConfirmRenameGenreWarning\": \"Advarsel! En lignende sjanger eksisterer allerede (med forsjellige store / små bokstaver) \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Er du sikker på at du vil endre tag \\\"{0}\\\" til \\\"{1}\\\" for alle gjenstandene?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Notis: Denne taggen finnes allerede så de vil bli slått sammen.\",\n  \"MessageConfirmRenameTagWarning\": \"Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Er du sikkert på at du vil tilbakestille fremgangen?\",\n  \"MessageConfirmSendEbookToDevice\": \"Er du sikker på at du vil sende {0} ebok \\\"{1}\\\" til enhet \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Er du sikker på at du vil koble denne brukeren fra OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dager med lytting siste året\",\n  \"MessageDownloadingEpisode\": \"Laster ned episode\",\n  \"MessageDragFilesIntoTrackOrder\": \"Dra filene i rett spor rekkefølge\",\n  \"MessageEmbedFailed\": \"Innbygging feilet!\",\n  \"MessageEmbedFinished\": \"Bak inn Fullført!\",\n  \"MessageEmbedQueue\": \"Lagt i køen for innbygging av metadata ({0} i kø)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Episode(r) lagt til i kø for nedlasting\",\n  \"MessageEreaderDevices\": \"For å sikre sendingen av e-bøker, så må du kanskje legge til e-postadressen over som en gyldig avsender for hver enhet i listen over.\",\n  \"MessageFeedURLWillBe\": \"Feed URL vil bli {0}\",\n  \"MessageFetching\": \"Henter...\",\n  \"MessageForceReScanDescription\": \"vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} lytter</strong> på {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Ingen lytteøkter på {0}\",\n  \"MessageImportantNotice\": \"Viktig varsel!\",\n  \"MessageInsertChapterBelow\": \"Sett inn kapittel under\",\n  \"MessageInvalidAsin\": \"Ugyldig ASIN\",\n  \"MessageItemsSelected\": \"{0} Gjenstander valgt\",\n  \"MessageItemsUpdated\": \"{0} Gjenstander oppdatert\",\n  \"MessageJoinUsOn\": \"Følg oss nå\",\n  \"MessageLoading\": \"Laster...\",\n  \"MessageLoadingFolders\": \"Laster mapper...\",\n  \"MessageLogsDescription\": \"Logger lagres i <code>/metadata/logs</code> som JSON-filer. Krasjlogger lagres i <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B mislykkes!\",\n  \"MessageM4BFinished\": \"M4B fullført!\",\n  \"MessageMapChapterTitles\": \"Bruk kapittel titler fra din eksisterende lydbok kapitler uten å justere tidsstempel\",\n  \"MessageMarkAllEpisodesFinished\": \"Marker alle episoder som fullført\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Marker alle episoder som ikke fullført\",\n  \"MessageMarkAsFinished\": \"Marker som Fullført\",\n  \"MessageMarkAsNotFinished\": \"Marker som Ikke Fullført\",\n  \"MessageMatchBooksDescription\": \"vil forsøke å oppdatere en bok i ditt bibliotek med en bok fra den valgte søketilbyderen og legge til manglende detaljer og omslag. Overskriver ikke detaljer.\",\n  \"MessageNoAudioTracks\": \"Ingen lydspor\",\n  \"MessageNoAuthors\": \"Ingen forfatter\",\n  \"MessageNoBackups\": \"Ingen sikkerhetskopier\",\n  \"MessageNoBookmarks\": \"Ingen bokmerker\",\n  \"MessageNoChapters\": \"Ingen kapitler\",\n  \"MessageNoCollections\": \"Ingen samlinger\",\n  \"MessageNoCoversFound\": \"Ingen omslagsbilde funnet\",\n  \"MessageNoDescription\": \"Ingen beskrivelse\",\n  \"MessageNoDevices\": \"Ingen enheter\",\n  \"MessageNoDownloadsInProgress\": \"Ingen aktive nedlastinger\",\n  \"MessageNoDownloadsQueued\": \"Ingen nedlastinger i kø\",\n  \"MessageNoEpisodeMatchesFound\": \"Ingen lik episode funnet\",\n  \"MessageNoEpisodes\": \"Ingen Episoder\",\n  \"MessageNoFoldersAvailable\": \"Ingen mapper tilgjengelige\",\n  \"MessageNoGenres\": \"Ingen sjangere\",\n  \"MessageNoIssues\": \"Ingen feil\",\n  \"MessageNoItems\": \"Ingen gjenstander\",\n  \"MessageNoItemsFound\": \"Ingen gjenstander funnet\",\n  \"MessageNoListeningSessions\": \"Ingen Lyttesesjoner\",\n  \"MessageNoLogs\": \"Ingen logger\",\n  \"MessageNoMediaProgress\": \"Ingen mediefremgang\",\n  \"MessageNoNotifications\": \"Ingen varslinger\",\n  \"MessageNoPodcastFeed\": \"Ugyldig podcast: Ingen feed\",\n  \"MessageNoPodcastsFound\": \"Ingen podcaster funnet\",\n  \"MessageNoResults\": \"Ingen resultat\",\n  \"MessageNoSearchResultsFor\": \"Ingen søkeresultat for \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Ingen serier\",\n  \"MessageNoTags\": \"Ingen tags\",\n  \"MessageNoTasksRunning\": \"Ingen oppgaver kjører\",\n  \"MessageNoUpdatesWereNecessary\": \"Ingen oppdatering var nødvendig\",\n  \"MessageNoUserPlaylists\": \"Du har ingen spillelister\",\n  \"MessageNoUserPlaylistsHelp\": \"Spillelister er private. Bare brukeren som oppretter dem kan se dem.\",\n  \"MessageNotYetImplemented\": \"Ikke implementert ennå\",\n  \"MessageOpmlPreviewNote\": \"PS: Dette er en forhåndvisning av en OPML-fil. Den faktiske podcast-tittelen hentes direkte fra RSS-feeden.\",\n  \"MessageOr\": \"eller\",\n  \"MessagePauseChapter\": \"Pause avspilling av kapittel\",\n  \"MessagePlayChapter\": \"Lytter på begynnelsen av kapittel\",\n  \"MessagePlaylistCreateFromCollection\": \"Lag spilleliste fra samling\",\n  \"MessagePleaseWait\": \"Vennligst vent...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast har ingen RSS feed url til bruk av sammenligning\",\n  \"MessagePodcastSearchField\": \"Skriv inn søkeord eller URL til en RSS-strøm\",\n  \"MessageQuickEmbedInProgress\": \"Hurtiginnbygging pågår\",\n  \"MessageQuickEmbedQueue\": \"Kø for hurtiginnbygging ({0} i kø)\",\n  \"MessageQuickMatchAllEpisodes\": \"Kjapp matching av alle episoder\",\n  \"MessageQuickMatchDescription\": \"Fyll inn tomme gjenstandsdetaljer og omslagsbilde med første resultat fra '{0}'. Overskriver ikke detaljene utenom 'Foretrekk funnet metadata' tjenerinstilling er aktivert.\",\n  \"MessageRemoveChapter\": \"fjerne kapittel\",\n  \"MessageRemoveEpisodes\": \"fjerne {0} kapitler\",\n  \"MessageRemoveFromPlayerQueue\": \"fjerne fra avspillingskø\",\n  \"MessageRemoveUserWarning\": \"Er du sikker på at du vil slette bruker \\\"{0}\\\" for godt?\",\n  \"MessageReportBugsAndContribute\": \"Rapporter feil, forespør funksjoner og tillegg og bidra på\",\n  \"MessageResetChaptersConfirm\": \"Er du sikker på at du vil nullstille kapitler og angre endringene du har gjort?\",\n  \"MessageRestoreBackupConfirm\": \"Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget\",\n  \"MessageRestoreBackupWarning\": \"gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.\",\n  \"MessageScheduleLibraryScanNote\": \"For de fleste brukere er det anbefalt å la denne funksjonen være slått av, og la mappeovervåkeren stå på. Mappeovervåkeren oppdager automatisk endringer i biblioteksmappene. Mappeovervåkeren fungerer ikke med alle filsystemer (f.eks. NFS) og da kan planlagt skanning av bibliotekene brukes i steden for.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Kjør hver {0} kl. {1}\",\n  \"MessageSearchResultsFor\": \"Søk resultat for\",\n  \"MessageSelected\": \"{0} valgt\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Serienummer kan ikke inneholde mellomrom\",\n  \"MessageServerCouldNotBeReached\": \"Tjener kunne ikke bli nådd\",\n  \"MessageSetChaptersFromTracksDescription\": \"Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet\",\n  \"MessageShareExpirationWillBe\": \"Utløp vil være <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Utløper om {0}\",\n  \"MessageShareURLWillBe\": \"URL for deling blir <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Start avspilling av \\\"{0}\\\" ved {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Lydfilen \\\"{0}\\\" kan ikke skrives til\",\n  \"MessageTaskCanceledByUser\": \"Oppgave kansellert av bruker\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Laster ned episode \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Bygger inn metadata\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Bygger inn metadata i lydboken \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Konverterer til M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Konverterer lydboken \\\"{0}\\\" til én M4B-fil\",\n  \"MessageTaskFailed\": \"Feilet\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Feil ved sikkerhetskopiering av lydfilen \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Kunne ikke opprette mappe for mellomlagring (cache)\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Kunne ikke bygge inn metadata i filen \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Kunne ikke slå sammen lydfiler\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Kunne ikke flytte M4B-fil\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Kunne ikke lagre metadata-fil\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Samsvarende bøker i biblioteket \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Ingen filer å skanne\",\n  \"MessageTaskOpmlImport\": \"OPML-import\",\n  \"MessageTaskOpmlImportDescription\": \"Oppretter podkaster fra {0} RSS-feeder\",\n  \"MessageTaskOpmlImportFeed\": \"OPML-importfeed\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importerer RSS-feed \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Kunne ikke hente podcast-feed\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Oppretter podkast \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podkast finnes allerede på stien\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Misslykkes å opprette podcast\",\n  \"MessageTaskOpmlImportFinished\": \"La til {0} podkaster\",\n  \"MessageTaskOpmlParseFailed\": \"Klarte ikke å tolke OPML-fil\",\n  \"MessageTaskOpmlParseFastFail\": \"Ugyldig OPML-fil: <opml>-tagg ble ikke funnet ELLER en <outline>-tagg ble ikke funnet\",\n  \"MessageTaskOpmlParseNoneFound\": \"Fant ingen feeder i OPML-filen\",\n  \"MessageTaskScanItemsAdded\": \"{0} lagt til\",\n  \"MessageTaskScanItemsMissing\": \"{0} mangler\",\n  \"MessageTaskScanItemsUpdated\": \"{0} oppdatert\",\n  \"MessageTaskScanNoChangesNeeded\": \"Ingen endringer nødvendig\",\n  \"MessageTaskScanningFileChanges\": \"Skanner filendringer i \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Skanner biblioteket \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Målkatalogen er ikke skrivbar\",\n  \"MessageThinking\": \"Tenker...\",\n  \"MessageUploaderItemFailed\": \"Opplastning mislykkes\",\n  \"MessageUploaderItemSuccess\": \"Opplastning fullført!\",\n  \"MessageUploading\": \"Laster opp...\",\n  \"MessageValidCronExpression\": \"Gjyldig cron uttrykk\",\n  \"MessageWatcherIsDisabledGlobally\": \"Overvåer er deaktivert globalt i tjenerinstillingene\",\n  \"MessageXLibraryIsEmpty\": \"{0} Bibliotek er tumt!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Lydboklengden er lengre enn lengde som var funnet\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Lydboklengden er kortere enn lengde som var funnet\",\n  \"NoteChangeRootPassword\": \"Root-bruker er eneste bruker som kan ha tumt passord\",\n  \"NoteChapterEditorTimes\": \"Notis: Første kapittel start tid må være 0:00 og siste kapittel start tid kan ikke overskride denne lydbokens lengde.\",\n  \"NoteFolderPicker\": \"Notis: allerede funnet mapper vil ikke bli vist\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Advarsel! De fleste podcast applikasjoner trenger RSS feed URL som bruker HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Advarsel! 1 eller flere av episodene har ikke publikasjonsdato. Noen podcast applikasjoner trenger dette.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mapper med mediefiler vil bli behandlet som separate bibliotekgjenstander.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Om man laster opp kun lydfiler så vil hver lydfil bli behandlet som en separat lydbok.\",\n  \"NoteUploaderUnsupportedFiles\": \"Filer som ikke er støttet vil bli ignorert. Når man velger eller slipper en mappe, filer som ikke er en mappe vil bli ignorert.\",\n  \"NotificationOnBackupCompletedDescription\": \"Utløses når en sikkerhetskopi er fullført\",\n  \"NotificationOnBackupFailedDescription\": \"Utløses når en sikkerhetskopi mislykkes\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Utløses når en podkastepisode lastes ned automatisk\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Utløses når automatiske episodenedlastinger deaktiveres på grunn av for mange mislykkede forsøk\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Utløses når RSS-feedforespørselen mislykkes for en automatisk episodenedlasting\",\n  \"NotificationOnTestDescription\": \"Hendelse for testing av varslingssystemet\",\n  \"PlaceholderBulkChapterInput\": \"Skriv inn kapitteltittel eller bruk nummerering (f.eks. 'Episode 1', 'Kapittel 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Ny samlingsnavn\",\n  \"PlaceholderNewFolderPath\": \"Ny mappesti\",\n  \"PlaceholderNewPlaylist\": \"Ny spillelistenavn\",\n  \"PlaceholderSearch\": \"Søk..\",\n  \"PlaceholderSearchEpisode\": \"Søk episode..\",\n  \"StatsAuthorsAdded\": \"forfattere lagt til\",\n  \"StatsBooksAdded\": \"bøker lagt til\",\n  \"StatsBooksAdditional\": \"Noen av tilleggene inkluderer…\",\n  \"StatsBooksFinished\": \"bøker fullført\",\n  \"StatsBooksFinishedThisYear\": \"Noen bøker fullført i år…\",\n  \"StatsBooksListenedTo\": \"bøker lyttet til\",\n  \"StatsCollectionGrewTo\": \"Boksamlingen din vokste til…\",\n  \"StatsSessions\": \"økter\",\n  \"StatsSpentListening\": \"brukt på lytting\",\n  \"StatsTopAuthor\": \"BESTE FORFATTER\",\n  \"StatsTopAuthors\": \"BESTE FORFATTERE\",\n  \"StatsTopGenre\": \"BESTE SJANGER\",\n  \"StatsTopGenres\": \"BESTE SJANGRE\",\n  \"StatsTopMonth\": \"BESTE MÅNED\",\n  \"StatsTopNarrator\": \"BESTE FORTELLER\",\n  \"StatsTopNarrators\": \"BESTE FORTELLERE\",\n  \"StatsTotalDuration\": \"Med en total varighet på…\",\n  \"StatsYearInReview\": \"ÅRET SOM GIKK\",\n  \"ToastAccountUpdateSuccess\": \"Konto oppdatert\",\n  \"ToastAppriseUrlRequired\": \"Du må angi en Apprise-URL\",\n  \"ToastAsinRequired\": \"ASIN er påkrevd\",\n  \"ToastAuthorImageRemoveSuccess\": \"Forfatter bilde fjernet\",\n  \"ToastAuthorNotFound\": \"Fant ikke forfatter \\\"{0}\\\"\",\n  \"ToastAuthorRemoveSuccess\": \"Forfatter fjernet\",\n  \"ToastAuthorSearchNotFound\": \"Fant ikke forfatter\",\n  \"ToastAuthorUpdateMerged\": \"Forfatter slått sammen\",\n  \"ToastAuthorUpdateSuccess\": \"Forfatter oppdatert\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Forfatter oppdater (ingen bilde funnet)\",\n  \"ToastBackupAppliedSuccess\": \"Sikkerhetskopi slått på\",\n  \"ToastBackupCreateFailed\": \"Mislykkes å lage sikkerhetskopi\",\n  \"ToastBackupCreateSuccess\": \"Sikkerhetskopi opprettet\",\n  \"ToastBackupDeleteFailed\": \"Mislykkes å slette sikkerhetskopi\",\n  \"ToastBackupDeleteSuccess\": \"Sikkerhetskopi slettet\",\n  \"ToastBackupInvalidMaxKeep\": \"Ugyldig antall sikkerhetskopier ønskes beholdt\",\n  \"ToastBackupInvalidMaxSize\": \"Ugyldig maksimal størrelse for sikkerhetskopi\",\n  \"ToastBackupRestoreFailed\": \"Misslykkes å gjenopprette sikkerhetskopi\",\n  \"ToastBackupUploadFailed\": \"Misslykkes å laste opp sikkerhetskopi\",\n  \"ToastBackupUploadSuccess\": \"Sikkerhetskopi lastet opp\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Detaljer brukt på elementene\",\n  \"ToastBatchDeleteFailed\": \"Sletting feilet på utvalget\",\n  \"ToastBatchDeleteSuccess\": \"Sletting av samling utført\",\n  \"ToastBatchQuickMatchFailed\": \"Feil ved rask integrering av metadata!\",\n  \"ToastBatchQuickMatchStarted\": \"Rask integrering av metadata for {0} bøker startet!\",\n  \"ToastBatchUpdateFailed\": \"Bulk oppdatering mislykket\",\n  \"ToastBatchUpdateSuccess\": \"Bulk oppdatering fullført\",\n  \"ToastBookmarkCreateFailed\": \"Misslykkes å opprette bokmerke\",\n  \"ToastBookmarkCreateSuccess\": \"Bokmerke lagt til\",\n  \"ToastBookmarkRemoveSuccess\": \"Bokmerke fjernet\",\n  \"ToastBulkChapterInvalidCount\": \"Skriv inn et tall mellom 1 og 150\",\n  \"ToastCachePurgeFailed\": \"Kunne ikke å slette mellomlager\",\n  \"ToastCachePurgeSuccess\": \"Mellomlager slettet\",\n  \"ToastChapterLocked\": \"Kapittelet er låst.\",\n  \"ToastChapterStartTimeAdjusted\": \"Kapittelstart ble justert med {0} sekunder\",\n  \"ToastChaptersAllLocked\": \"Alle kapitler er låst. Lås opp noen kapitler for å flytte tidene.\",\n  \"ToastChaptersHaveErrors\": \"Kapittel har feil\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Ugyldig forskyvningsverdi. Starttid for siste kapittel vil gå utover varigheten til denne lydboken.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Ugyldig forskyvningsverdi. Det første kapitlet ville fått null eller negativ lengde og blitt overskrevet av det andre kapitlet. Øk starttiden til det andre kapitlet.\",\n  \"ToastChaptersMustHaveTitles\": \"Kapittel må ha titler\",\n  \"ToastChaptersRemoved\": \"Kapitler fjernet\",\n  \"ToastChaptersUpdated\": \"Kapitler oppdatert\",\n  \"ToastCollectionItemsAddFailed\": \"Feil med å legge til element(er)\",\n  \"ToastCollectionRemoveSuccess\": \"Samling fjernet\",\n  \"ToastCollectionUpdateSuccess\": \"samlingupdated\",\n  \"ToastConnectionNotAvailable\": \"Tilkobling er ikke tilgjengelig. Prøv igjen senere\",\n  \"ToastCoverSearchFailed\": \"Finner ikke bokomslag\",\n  \"ToastCoverUpdateFailed\": \"Oppdatering av bilde feilet\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Dato og klokkeslett er ugyldig eller ufullstendig\",\n  \"ToastDeleteFileFailed\": \"Kunne ikke slette fil\",\n  \"ToastDeleteFileSuccess\": \"Fil slettet\",\n  \"ToastDeviceAddFailed\": \"Kunne ikke legge til enhet\",\n  \"ToastDeviceNameAlreadyExists\": \"E-leser med dette navnet eksisterer allerede\",\n  \"ToastDeviceTestEmailFailed\": \"Kunne ikke sende test e-post\",\n  \"ToastDeviceTestEmailSuccess\": \"E-post for testing er sendt\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Innstillinger for e-post oppdatert\",\n  \"ToastEncodeCancelFailed\": \"Kunne ikke stoppe konverteringen\",\n  \"ToastEncodeCancelSucces\": \"Konvertering kansellert\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Kunne ikke tømme køen\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Nedlastingskø for eposider tømt\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} episoder oppdatert\",\n  \"ToastErrorCannotShare\": \"Kan ikke dele direkte på denne enheten\",\n  \"ToastFailedToCreate\": \"Kunne ikke opprette\",\n  \"ToastFailedToDelete\": \"Kunne ikke slette\",\n  \"ToastFailedToLoadData\": \"Kunne ikke laste inn data\",\n  \"ToastFailedToMatch\": \"Kunne ikke matche\",\n  \"ToastFailedToShare\": \"Deling feilet\",\n  \"ToastFailedToUpdate\": \"Oppdatering feilet\",\n  \"ToastInvalidImageUrl\": \"Ugyldig URL for bilde\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Ugyldig maksimalt antall for nedlasting av episoder\",\n  \"ToastInvalidUrl\": \"Ugyldig URL\",\n  \"ToastInvalidUrls\": \"Én eller flere URL-er er ugyldige\",\n  \"ToastItemCoverUpdateSuccess\": \"Omslag oppdatert\",\n  \"ToastItemDeletedFailed\": \"Kunne ikke slette element\",\n  \"ToastItemDeletedSuccess\": \"Element slettet\",\n  \"ToastItemDetailsUpdateSuccess\": \"Detaljer oppdatert\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Misslykkes å markere som Fullført\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Gjenstand marker som Fullført\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Misslykkes å markere som Ikke Fullført\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Markert som Ikke Fullført\",\n  \"ToastItemUpdateSuccess\": \"Element oppdatert\",\n  \"ToastLibraryCreateFailed\": \"Misslykkes å opprette bibliotek\",\n  \"ToastLibraryCreateSuccess\": \"Bibliotek \\\"{0}\\\" opprettet\",\n  \"ToastLibraryDeleteFailed\": \"Misslykkes å slette bibliotek\",\n  \"ToastLibraryDeleteSuccess\": \"Bibliotek slettet\",\n  \"ToastLibraryScanFailedToStart\": \"Misslykkes å starte skann\",\n  \"ToastLibraryScanStarted\": \"Bibliotek skann startet\",\n  \"ToastLibraryUpdateSuccess\": \"Bibliotek \\\"{0}\\\" oppdatert\",\n  \"ToastMatchAllAuthorsFailed\": \"Kunne ikke finne match for alle forfattere\",\n  \"ToastMetadataFilesRemovedError\": \"Feil ved fjerning av metadata.{0}-filer\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Ingen metata.{0}-filer funnet i biblioteket\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Ingen metata.{0}-filer fjernet\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metata.{1}-filer fjernet\",\n  \"ToastMustHaveAtLeastOnePath\": \"Påkrevd med minst én mappe\",\n  \"ToastNameEmailRequired\": \"Navn og e-post påkrevd\",\n  \"ToastNameRequired\": \"Navn er påkrevd\",\n  \"ToastNewApiKeyUserError\": \"Du må velge en bruker\",\n  \"ToastNewEpisodesFound\": \"{0} nye episoder funnet\",\n  \"ToastNewUserCreatedFailed\": \"Kunne ikke opprette konto: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Ny konto opprettet\",\n  \"ToastNewUserLibraryError\": \"Velg minst ett bibliotek\",\n  \"ToastNewUserPasswordError\": \"Passord kreves. Kun root-bruker kan ha blankt passord\",\n  \"ToastNewUserTagError\": \"Velg minst en tag\",\n  \"ToastNewUserUsernameError\": \"Skriv inn brukernavn\",\n  \"ToastNoNewEpisodesFound\": \"Ingen nye episoder funnet\",\n  \"ToastNoRSSFeed\": \"Podkasten har ikke en RSS-feed\",\n  \"ToastNoUpdatesNecessary\": \"Ingen oppdateringer nødvendig\",\n  \"ToastNotificationCreateFailed\": \"Kunne ikke opprette varsling\",\n  \"ToastNotificationDeleteFailed\": \"Kunne ikke slette varsling\",\n  \"ToastNotificationFailedMaximum\": \"Maksimalt antall forsøk som feiler må være større eller lik null (0)\",\n  \"ToastNotificationQueueMaximum\": \"Maksimal størrelse på varsel-kø må være større eller lik null (0)\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Innstillinger for varsling oppdatert\",\n  \"ToastNotificationTestTriggerFailed\": \"Kunne ikke utløse test-varsel\",\n  \"ToastNotificationTestTriggerSuccess\": \"Test-varsel utløst\",\n  \"ToastNotificationUpdateSuccess\": \"Varsel oppdatert\",\n  \"ToastPlaylistCreateFailed\": \"Misslykkes å opprette spilleliste\",\n  \"ToastPlaylistCreateSuccess\": \"Spilleliste opprettet\",\n  \"ToastPlaylistRemoveSuccess\": \"Spilleliste fjernet\",\n  \"ToastPlaylistUpdateSuccess\": \"Spilleliste oppdatert\",\n  \"ToastPodcastCreateFailed\": \"Misslykkes å opprette podcast\",\n  \"ToastPodcastCreateSuccess\": \"Podcast opprettet\",\n  \"ToastPodcastEpisodeUpdated\": \"Episode oppdatert\",\n  \"ToastPodcastGetFeedFailed\": \"Kunne ikke hente podcast-feed\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Ingen episoder funnet i RSS-feed\",\n  \"ToastPodcastNoRssFeed\": \"Podcast har ingen RSS-feed\",\n  \"ToastProgressIsNotBeingSynced\": \"Progresjon synkroniserer ikke, start avspilling på nytt\",\n  \"ToastProviderCreatedFailed\": \"Kunne ikke legge til tilbyder\",\n  \"ToastProviderCreatedSuccess\": \"Ny tilbyder lagt til\",\n  \"ToastProviderNameAndUrlRequired\": \"Navn og URL er påkrevd\",\n  \"ToastProviderRemoveSuccess\": \"Tilbyder fjernet\",\n  \"ToastRSSFeedCloseFailed\": \"Misslykkes å lukke RSS feed\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS feed lukket\",\n  \"ToastRemoveFailed\": \"Kunne ikke fjerne\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Misslykkes å fjerne gjenstsand fra samling\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Gjenstand fjernet fra samling\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Kunne ikke fjerne bibliotek-elementer med feil\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Fjernet bibliotek-elementer med feil\",\n  \"ToastRenameFailed\": \"Kunne ikke endre navn\",\n  \"ToastRescanFailed\": \"Ny skanning feilet for {0}\",\n  \"ToastRescanRemoved\": \"Ny skanning utført og element fjernet\",\n  \"ToastRescanUpToDate\": \"Ny skanning utført og element var oppdatert\",\n  \"ToastRescanUpdated\": \"Ny skanning utført og element oppdatert\",\n  \"ToastScanFailed\": \"Kunne ikke skanne bibliotek-element\",\n  \"ToastSelectAtLeastOneUser\": \"Velg minst én bruker\",\n  \"ToastSendEbookToDeviceFailed\": \"Misslykkes å sende ebok\",\n  \"ToastSendEbookToDeviceSuccess\": \"Ebok sendt til \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Kan ikke legge til to serier med samme navn\",\n  \"ToastSeriesUpdateFailed\": \"Misslykkes å oppdatere serie\",\n  \"ToastSeriesUpdateSuccess\": \"Serie oppdatert\",\n  \"ToastServerSettingsUpdateSuccess\": \"Server-innstillinger oppdatert\",\n  \"ToastSessionCloseFailed\": \"Kunne ikke avslutte sesjon\",\n  \"ToastSessionDeleteFailed\": \"Misslykkes å slette sesjon\",\n  \"ToastSessionDeleteSuccess\": \"Sesjon slettet\",\n  \"ToastSleepTimerDone\": \"Søvn-timer ferdig... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug inneholder ugyldige tegn\",\n  \"ToastSlugRequired\": \"Slug påkrevd\",\n  \"ToastSocketConnected\": \"Socket koblet til\",\n  \"ToastSocketDisconnected\": \"Socket koblet fra\",\n  \"ToastSocketFailedToConnect\": \"Misslykkes å koble til Socket\",\n  \"ToastSortingPrefixesEmptyError\": \"Må ha minst én sorteringsprefiks\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Sorteringsprefiks oppdatert ({0} element)\",\n  \"ToastTitleRequired\": \"Tittel påkrevd\",\n  \"ToastUnknownError\": \"Ukjent feil\",\n  \"ToastUnlinkOpenIdFailed\": \"Kunne ikke koble bruker fra OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Bruker koblet fra OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Filstien \\\"{0}\\\" finnes allerede på serveren\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Elementet \\\"{0}\\\" bruker en underkatalog av opplastingsstien.\",\n  \"ToastUserDeleteFailed\": \"Misslykkes å slette bruker\",\n  \"ToastUserDeleteSuccess\": \"Bruker slettet\",\n  \"ToastUserPasswordChangeSuccess\": \"Passord ble endret\",\n  \"ToastUserPasswordMismatch\": \"Passord må stemme overens\",\n  \"ToastUserPasswordMustChange\": \"Nytt passord kan ikke være identisk med gammelt passord\",\n  \"ToastUserRootRequireName\": \"Root-brukernavn er påkrevd\",\n  \"TooltipAddChapters\": \"Legg til kapittel(er)\",\n  \"TooltipAddOneSecond\": \"Legg til 1 sekund\",\n  \"TooltipAdjustChapterStart\": \"Klikk for å justere starttid\",\n  \"TooltipLockAllChapters\": \"Lås alle kapitler\",\n  \"TooltipLockChapter\": \"Lås kapittel (Shift+klikk for område)\",\n  \"TooltipSubtractOneSecond\": \"Trekk fra 1 sekund\",\n  \"TooltipUnlockAllChapters\": \"Lås opp alle kapitler\",\n  \"TooltipUnlockChapter\": \"Lås opp kapittel (Shift+klikk for område)\"\n}\n"
  },
  {
    "path": "client/strings/pl.json",
    "content": "{\n  \"ButtonAdd\": \"Dodaj\",\n  \"ButtonAddApiKey\": \"Dodaj klucz API\",\n  \"ButtonAddChapters\": \"Dodaj rozdziały\",\n  \"ButtonAddDevice\": \"Dodaj urządzenie\",\n  \"ButtonAddLibrary\": \"Dodaj bibliotekę\",\n  \"ButtonAddPodcasts\": \"Dodaj podcasty\",\n  \"ButtonAddUser\": \"Dodaj użytkownika\",\n  \"ButtonAddYourFirstLibrary\": \"Dodaj swoją pierwszą bibliotekę\",\n  \"ButtonApply\": \"Zatwierdź\",\n  \"ButtonApplyChapters\": \"Zatwierdź rozdziały\",\n  \"ButtonAuthors\": \"Autorzy\",\n  \"ButtonBack\": \"Wstecz\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Uzupełnij na podstawie istniejących\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Powiel szczegóły mapy\",\n  \"ButtonBrowseForFolder\": \"Wyszukaj folder\",\n  \"ButtonCancel\": \"Anuluj\",\n  \"ButtonCancelEncode\": \"Anuluj enkodowanie\",\n  \"ButtonChangeRootPassword\": \"Zmień hasło roota\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Sprawdź i pobierz nowe odcinki\",\n  \"ButtonChooseAFolder\": \"Wybierz folder\",\n  \"ButtonChooseFiles\": \"Wybierz pliki\",\n  \"ButtonClearFilter\": \"Wyczyść filtr\",\n  \"ButtonClose\": \"Zamknij\",\n  \"ButtonCloseFeed\": \"Zamknij kanał\",\n  \"ButtonCloseSession\": \"Zamknij otwartą sesję\",\n  \"ButtonCollections\": \"Kolekcje\",\n  \"ButtonConfigureScanner\": \"Skonfiguruj skaner\",\n  \"ButtonCreate\": \"Utwórz\",\n  \"ButtonCreateBackup\": \"Utwórz kopię zapasową\",\n  \"ButtonDelete\": \"Usuń\",\n  \"ButtonDownloadQueue\": \"Kolejka\",\n  \"ButtonEdit\": \"Edycja\",\n  \"ButtonEditChapters\": \"Edytuj rozdziały\",\n  \"ButtonEditPodcast\": \"Edytuj podcast\",\n  \"ButtonEnable\": \"Włącz\",\n  \"ButtonFireAndFail\": \"Fail start\",\n  \"ButtonFireOnTest\": \"Uruchom po zdarzeniu testowym\",\n  \"ButtonForceReScan\": \"Wymuś ponowne skanowanie\",\n  \"ButtonFullPath\": \"Pełna ścieżka\",\n  \"ButtonHide\": \"Ukryj\",\n  \"ButtonHome\": \"Strona główna\",\n  \"ButtonIssues\": \"Błędy\",\n  \"ButtonJumpBackward\": \"Skocz do tyłu\",\n  \"ButtonJumpForward\": \"Skocz do przodu\",\n  \"ButtonLatest\": \"Aktualna wersja\",\n  \"ButtonLibrary\": \"Biblioteka\",\n  \"ButtonLogout\": \"Wyloguj\",\n  \"ButtonLookup\": \"Importuj\",\n  \"ButtonManageTracks\": \"Zarządzaj ścieżkami\",\n  \"ButtonMapChapterTitles\": \"Mapuj nazwy rozdziałów\",\n  \"ButtonMatchAllAuthors\": \"Dopasuj wszystkich autorów\",\n  \"ButtonMatchBooks\": \"Dopasuj książki\",\n  \"ButtonNevermind\": \"Anuluj\",\n  \"ButtonNext\": \"Następny\",\n  \"ButtonNextChapter\": \"Następny rozdział\",\n  \"ButtonNextItemInQueue\": \"Następny element w kolejce\",\n  \"ButtonOk\": \"OK\",\n  \"ButtonOpenFeed\": \"Otwórz feed\",\n  \"ButtonOpenManager\": \"Otwórz menadżera\",\n  \"ButtonPause\": \"Wstrzymaj\",\n  \"ButtonPlay\": \"Odtwarzaj\",\n  \"ButtonPlayAll\": \"Odtwórz wszystko\",\n  \"ButtonPlaying\": \"Odtwarzane\",\n  \"ButtonPlaylists\": \"Listy odtwarzania\",\n  \"ButtonPrevious\": \"Poprzedni\",\n  \"ButtonPreviousChapter\": \"Poprzedni rozdział\",\n  \"ButtonProbeAudioFile\": \"Próbka audio\",\n  \"ButtonPurgeAllCache\": \"Wyczyść dane tymczasowe\",\n  \"ButtonPurgeItemsCache\": \"Wyczyść dane tymczasowe pozycji\",\n  \"ButtonQueueAddItem\": \"Dodaj do kolejki\",\n  \"ButtonQueueRemoveItem\": \"Usuń z kolejki\",\n  \"ButtonQuickEmbed\": \"Szybkie wstawienie\",\n  \"ButtonQuickEmbedMetadata\": \"Szybkie wstawianie metadanych\",\n  \"ButtonQuickMatch\": \"Szybkie dopasowanie\",\n  \"ButtonReScan\": \"Ponowne skanowanie\",\n  \"ButtonRead\": \"Czytaj\",\n  \"ButtonReadLess\": \"Pokaż mniej\",\n  \"ButtonReadMore\": \"Czytaj więcej\",\n  \"ButtonRefresh\": \"Odśwież\",\n  \"ButtonRemove\": \"Usuń\",\n  \"ButtonRemoveAll\": \"Usuń wszystko\",\n  \"ButtonRemoveAllLibraryItems\": \"Usuń wszystkie elementy z biblioteki\",\n  \"ButtonRemoveFromContinueListening\": \"Usuń z listy odtwarzania\",\n  \"ButtonRemoveFromContinueReading\": \"Usuń z Kontynuuj czytanie\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Usuń serię z listy odtwarzania\",\n  \"ButtonReset\": \"Resetowanie\",\n  \"ButtonResetToDefault\": \"Przywróć ustawienia domyślne\",\n  \"ButtonRestore\": \"Przywróć\",\n  \"ButtonSave\": \"Zapisz\",\n  \"ButtonSaveAndClose\": \"Zapisz i zamknij\",\n  \"ButtonSaveTracklist\": \"Zapisz listę odtwarzania\",\n  \"ButtonScan\": \"Zeskanuj\",\n  \"ButtonScanLibrary\": \"Skanuj bibliotekę\",\n  \"ButtonScrollLeft\": \"Przewiń w lewo\",\n  \"ButtonScrollRight\": \"Przewiń w prawo\",\n  \"ButtonSearch\": \"Szukaj\",\n  \"ButtonSelectFolderPath\": \"Wybierz ścieżkę folderu\",\n  \"ButtonSeries\": \"Serie\",\n  \"ButtonSetChaptersFromTracks\": \"Ustawiaj rozdziały na podstawie utworów\",\n  \"ButtonShare\": \"Udostępnij\",\n  \"ButtonShiftTimes\": \"Przesunięcie czasowe\",\n  \"ButtonShow\": \"Pokaż\",\n  \"ButtonStartM4BEncode\": \"Eksportuj jako plik M4B\",\n  \"ButtonStartMetadataEmbed\": \"Osadź metadane\",\n  \"ButtonStats\": \"Statystyki\",\n  \"ButtonSubmit\": \"Zapisz\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Odłącz OpenID\",\n  \"ButtonUpload\": \"Wgraj\",\n  \"ButtonUploadBackup\": \"Wgraj kopię zapasową\",\n  \"ButtonUploadCover\": \"Wgraj okładkę\",\n  \"ButtonUploadOPMLFile\": \"Wgraj plik OPML\",\n  \"ButtonUserDelete\": \"Usuń użytkownika {0}\",\n  \"ButtonUserEdit\": \"Edytuj użytkownika {0}\",\n  \"ButtonViewAll\": \"Zobacz wszystko\",\n  \"ButtonYes\": \"Tak\",\n  \"ErrorUploadFetchMetadataAPI\": \"Błąd podczas pobierania metadanych\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Nie można pobrać metadanych — spróbuj zaktualizować tytuł i/lub autora\",\n  \"ErrorUploadLacksTitle\": \"Musi mieć tytuł\",\n  \"HeaderAccount\": \"Konto\",\n  \"HeaderAddCustomMetadataProvider\": \"Dodaj niestandardowego dostawcę metadanych\",\n  \"HeaderAdvanced\": \"Zaawansowane\",\n  \"HeaderApiKeys\": \"Klucze API\",\n  \"HeaderAppriseNotificationSettings\": \"Ustawienia powiadomień Apprise\",\n  \"HeaderAudioTracks\": \"Ścieżki audio\",\n  \"HeaderAudiobookTools\": \"Narzędzia do zarządzania audiobookami\",\n  \"HeaderAuthentication\": \"Uwierzytelnianie\",\n  \"HeaderBackups\": \"Kopie zapasowe\",\n  \"HeaderBulkChapterModal\": \"Dodaj kilka rozdziałów\",\n  \"HeaderChangePassword\": \"Zmień hasło\",\n  \"HeaderChapters\": \"Rozdziały\",\n  \"HeaderChooseAFolder\": \"Wybierz folder\",\n  \"HeaderCollection\": \"Kolekcja\",\n  \"HeaderCollectionItems\": \"Elementy kolekcji\",\n  \"HeaderCover\": \"Okładka\",\n  \"HeaderCurrentDownloads\": \"Obecnie ściągane\",\n  \"HeaderCustomMessageOnLogin\": \"Własny tekst podczas logowania\",\n  \"HeaderCustomMetadataProviders\": \"Niestandardowi dostawcy metadanych\",\n  \"HeaderDetails\": \"Szczegóły\",\n  \"HeaderDownloadQueue\": \"Kolejka do ściągania\",\n  \"HeaderEbookFiles\": \"Pliki Ebook\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"Ustawienia e-mail\",\n  \"HeaderEpisodes\": \"Rozdziały\",\n  \"HeaderEreaderDevices\": \"Czytniki\",\n  \"HeaderEreaderSettings\": \"Ustawienia czytnika\",\n  \"HeaderFiles\": \"Pliki\",\n  \"HeaderFindChapters\": \"Wyszukaj rozdziały\",\n  \"HeaderIgnoredFiles\": \"Zignoruj pliki\",\n  \"HeaderItemFiles\": \"Pliki\",\n  \"HeaderItemMetadataUtils\": \"Narzędzia dla metadanych\",\n  \"HeaderLastListeningSession\": \"Ostatnia sesja słuchania\",\n  \"HeaderLatestEpisodes\": \"Najnowsze odcinki\",\n  \"HeaderLibraries\": \"Biblioteki\",\n  \"HeaderLibraryFiles\": \"Pliki w bibliotece\",\n  \"HeaderLibraryStats\": \"Statystyki biblioteki\",\n  \"HeaderListeningSessions\": \"Sesje słuchania\",\n  \"HeaderListeningStats\": \"Statystyki słuchania\",\n  \"HeaderLogin\": \"Zaloguj się\",\n  \"HeaderLogs\": \"Logi\",\n  \"HeaderManageGenres\": \"Zarządzaj gatunkami\",\n  \"HeaderManageTags\": \"Zarządzaj tagami\",\n  \"HeaderMapDetails\": \"Szczegóły mapowania\",\n  \"HeaderMatch\": \"Dopasuj\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Kolejność metadanych\",\n  \"HeaderMetadataToEmbed\": \"Metadane do osadzenia\",\n  \"HeaderNewAccount\": \"Nowe konto\",\n  \"HeaderNewApiKey\": \"Nowy klucz API\",\n  \"HeaderNewLibrary\": \"Nowa biblioteka\",\n  \"HeaderNotificationCreate\": \"Utwórz powiadomienie\",\n  \"HeaderNotificationUpdate\": \"Zaktualizuj powiadomienie\",\n  \"HeaderNotifications\": \"Powiadomienia\",\n  \"HeaderOpenIDConnectAuthentication\": \"Uwierzytelnianie OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Otwarte sesje słuchania\",\n  \"HeaderOpenRSSFeed\": \"Utwórz kanał RSS\",\n  \"HeaderOtherFiles\": \"Inne pliki\",\n  \"HeaderPasswordAuthentication\": \"Uwierzytelnianie hasłem\",\n  \"HeaderPermissions\": \"Uprawnienia\",\n  \"HeaderPlayerQueue\": \"Kolejka odtwarzania\",\n  \"HeaderPlayerSettings\": \"Ustawienia Odtwarzania\",\n  \"HeaderPlaylist\": \"Playlista\",\n  \"HeaderPlaylistItems\": \"Pozycje listy odtwarzania\",\n  \"HeaderPodcastsToAdd\": \"Podcasty do dodania\",\n  \"HeaderPresets\": \"Ustawienia wstępne\",\n  \"HeaderPreviewCover\": \"Podgląd okładki\",\n  \"HeaderRSSFeedGeneral\": \"Szczegóły RSS\",\n  \"HeaderRSSFeedIsOpen\": \"Kanał RSS jest otwarty\",\n  \"HeaderRSSFeeds\": \"Kanały RSS\",\n  \"HeaderRemoveEpisode\": \"Usuń odcinek\",\n  \"HeaderRemoveEpisodes\": \"Usuń {0} odcinków\",\n  \"HeaderSavedMediaProgress\": \"Zapisany postęp\",\n  \"HeaderSchedule\": \"Harmonogram\",\n  \"HeaderScheduleEpisodeDownloads\": \"Planowanie automatycznego ściągania odcinków\",\n  \"HeaderScheduleLibraryScans\": \"Zaplanuj automatyczne skanowanie biblioteki\",\n  \"HeaderSession\": \"Sesja\",\n  \"HeaderSetBackupSchedule\": \"Ustaw harmonogram tworzenia kopii zapasowej\",\n  \"HeaderSettings\": \"Ustawienia\",\n  \"HeaderSettingsDisplay\": \"Wyświetlanie\",\n  \"HeaderSettingsExperimental\": \"Funkcje eksperymentalne\",\n  \"HeaderSettingsGeneral\": \"Ogólne\",\n  \"HeaderSettingsScanner\": \"Skanowanie\",\n  \"HeaderSettingsSecurity\": \"Bezpieczeństwo\",\n  \"HeaderSettingsWebClient\": \"Klient webowy\",\n  \"HeaderSleepTimer\": \"Wyłącznik czasowy\",\n  \"HeaderStatsLargestItems\": \"Największe pozycje\",\n  \"HeaderStatsLongestItems\": \"Najdłuższe pozycje (godziny)\",\n  \"HeaderStatsMinutesListeningChart\": \"Czas słuchania w minutach (ostatnie 7 dni)\",\n  \"HeaderStatsRecentSessions\": \"Ostatnie sesje\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autorów\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Gatunków\",\n  \"HeaderTableOfContents\": \"Spis treści\",\n  \"HeaderTools\": \"Narzędzia\",\n  \"HeaderUpdateAccount\": \"Zaktualizuj konto\",\n  \"HeaderUpdateApiKey\": \"Aktualizuj klucz API\",\n  \"HeaderUpdateAuthor\": \"Zaktualizuj autorów\",\n  \"HeaderUpdateDetails\": \"Zaktualizuj szczegóły\",\n  \"HeaderUpdateLibrary\": \"Zaktualizuj bibliotekę\",\n  \"HeaderUsers\": \"Użytkownicy\",\n  \"HeaderYearReview\": \"Podsumowanie roku {0}\",\n  \"HeaderYourStats\": \"Twoje statystyki\",\n  \"LabelAbridged\": \"Skrócony\",\n  \"LabelAbridgedChecked\": \"Skrócony (zaznaczono)\",\n  \"LabelAbridgedUnchecked\": \"Nieskrócony (nie zaznaczone)\",\n  \"LabelAccessibleBy\": \"Dostęp przez\",\n  \"LabelAccountType\": \"Typ konta\",\n  \"LabelAccountTypeAdmin\": \"Administrator\",\n  \"LabelAccountTypeGuest\": \"Gość\",\n  \"LabelAccountTypeUser\": \"Użytkownik\",\n  \"LabelActivities\": \"Aktywności\",\n  \"LabelActivity\": \"Aktywność\",\n  \"LabelAddToCollection\": \"Dodaj do kolekcji\",\n  \"LabelAddToCollectionBatch\": \"Dodaj {0} książki do kolekcji\",\n  \"LabelAddToPlaylist\": \"Dodaj do playlisty\",\n  \"LabelAddToPlaylistBatch\": \"Dodaj {0} pozycji do playlisty\",\n  \"LabelAddedAt\": \"Dodano\",\n  \"LabelAddedDate\": \"Dodano {0}\",\n  \"LabelAdminUsersOnly\": \"Tylko użytkownicy administracyjni\",\n  \"LabelAll\": \"Wszystkie\",\n  \"LabelAllEpisodesDownloaded\": \"Wszystkie odcinki pobrane\",\n  \"LabelAllUsers\": \"Wszyscy użytkownicy\",\n  \"LabelAllUsersExcludingGuests\": \"Wszyscy użytkownicy z wyłączeniem gości\",\n  \"LabelAllUsersIncludingGuests\": \"Wszyscy użytkownicy, łącznie z gośćmi\",\n  \"LabelAlreadyInYourLibrary\": \"Już istnieje w twojej bibliotece\",\n  \"LabelApiKeyCreated\": \"Klucz API \\\"{0}\\\" został pomyślnie utworzony.\",\n  \"LabelApiKeyCreatedDescription\": \"Pamiętaj o skopiowaniu klucza API, ponieważ nie będziesz już mógł go zobaczyć.\",\n  \"LabelApiKeyUser\": \"Wykonaj w imieniu innego użytkownika\",\n  \"LabelApiKeyUserDescription\": \"Ten klucz API będzie miał te same uprawnienia co użytkownik, w którego imieniu ma być używany. Wpisy w logach będą identyczne z tymi, wywołanymi przez samego użytkownika.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Dołącz\",\n  \"LabelAudioBitrate\": \"Audio Bitrate (np. 128k)\",\n  \"LabelAudioChannels\": \"Kanały dźwięku (1 lub 2)\",\n  \"LabelAudioCodec\": \"Kodek audio\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Rosnąco)\",\n  \"LabelAuthorLastFirst\": \"Author (Malejąco)\",\n  \"LabelAuthors\": \"Autorzy\",\n  \"LabelAutoDownloadEpisodes\": \"Automatyczne pobieranie odcinków\",\n  \"LabelAutoFetchMetadata\": \"Automatycznie pobierz metadane\",\n  \"LabelAutoFetchMetadataHelp\": \"Pobiera metadane dotyczące tytułu, autora i serii, aby usprawnić przesyłanie. Po przesłaniu może być konieczne dopasowanie dodatkowych metadanych.\",\n  \"LabelAutoLaunch\": \"Uruchom automatycznie\",\n  \"LabelAutoLaunchDescription\": \"Automatyczne przekierowanie do dostawcy uwierzytelniania podczas przechodzenia na stronę logowania (ręczna zamiana ścieżki <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatyczna rejestracja\",\n  \"LabelAutoRegisterDescription\": \"Automatycznie utwórz nowych użytkowników po zalogowaniu\",\n  \"LabelBackToUser\": \"Powrót\",\n  \"LabelBackupAudioFiles\": \"Kopia zapasowa plików audio\",\n  \"LabelBackupLocation\": \"Lokalizacja kopii zapasowej\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Włącz automatyczne kopie zapasowe\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Kopie zapasowe są zapisywane w folderze /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maksymalny rozmiar kopii zapasowej (w GB) (0 oznacza nieograniczony)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.\",\n  \"LabelBackupsNumberToKeep\": \"Liczba kopii zapasowych do przechowywania\",\n  \"LabelBackupsNumberToKeepHelp\": \"Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Książki\",\n  \"LabelButtonText\": \"Tekst przycisku\",\n  \"LabelByAuthor\": \"Autor {0}\",\n  \"LabelChangePassword\": \"Zmień hasło\",\n  \"LabelChannels\": \"Kanały\",\n  \"LabelChapterCount\": \"{0} rozdziałów\",\n  \"LabelChapterTitle\": \"Tytuł rozdziału\",\n  \"LabelChapters\": \"Rozdziały\",\n  \"LabelChaptersFound\": \"Znalezione rozdziały\",\n  \"LabelClickForMoreInfo\": \"Kliknij po więcej szczegółów\",\n  \"LabelClickToUseCurrentValue\": \"Kliknij by zastosować aktualną wartość\",\n  \"LabelClosePlayer\": \"Zamknij odtwarzacz\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Zwiń serię\",\n  \"LabelCollapseSubSeries\": \"Zwiń podserie\",\n  \"LabelCollection\": \"Kolekcja\",\n  \"LabelCollections\": \"Kolekcje\",\n  \"LabelComplete\": \"Ukończone\",\n  \"LabelConfirmPassword\": \"Potwierdź hasło\",\n  \"LabelContinueListening\": \"Kontynuuj słuchanie\",\n  \"LabelContinueReading\": \"Kontynuuj czytanie\",\n  \"LabelContinueSeries\": \"Kontynuuj serię\",\n  \"LabelCorsAllowed\": \"Dozwolone źródła CORS\",\n  \"LabelCover\": \"Okładka\",\n  \"LabelCoverImageURL\": \"URL okładki\",\n  \"LabelCoverProvider\": \"Dostawca okładki\",\n  \"LabelCreatedAt\": \"Utworzone\",\n  \"LabelCronExpression\": \"Wyrażenie harmonogramowania zadań cron\",\n  \"LabelCurrent\": \"Aktualny\",\n  \"LabelCurrently\": \"Obecnie:\",\n  \"LabelCustomCronExpression\": \"Niestandardowe wyrażenie Cron:\",\n  \"LabelDatetime\": \"Data i godzina\",\n  \"LabelDays\": \"Dni\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Usuń z systemu plików (odznacz, aby usunąć tylko z bazy danych)\",\n  \"LabelDescription\": \"Opis\",\n  \"LabelDeselectAll\": \"Odznacz wszystko\",\n  \"LabelDetectedPattern\": \"Wykryty schemat:\",\n  \"LabelDevice\": \"Urządzenie\",\n  \"LabelDeviceInfo\": \"Informacja o urządzeniu\",\n  \"LabelDeviceIsAvailableTo\": \"Urządzenie jest dostępne do...\",\n  \"LabelDirectory\": \"Katalog\",\n  \"LabelDiscFromFilename\": \"Oznaczenie dysku z nazwy pliku\",\n  \"LabelDiscFromMetadata\": \"Oznaczenie dysku z metadanych\",\n  \"LabelDiscover\": \"Odkrywaj\",\n  \"LabelDownload\": \"Pobierz\",\n  \"LabelDownloadNEpisodes\": \"Ściąganie {0} odcinków\",\n  \"LabelDownloadable\": \"Do pobrania\",\n  \"LabelDuration\": \"Czas trwania\",\n  \"LabelDurationComparisonExactMatch\": \"(dokładne dopasowanie)\",\n  \"LabelDurationComparisonLonger\": \"({0} dłużej)\",\n  \"LabelDurationComparisonShorter\": \"({0} krócej)\",\n  \"LabelDurationFound\": \"Znaleziona długość:\",\n  \"LabelEbook\": \"Ebook\",\n  \"LabelEbooks\": \"Ebooki\",\n  \"LabelEdit\": \"Edytuj\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Z adresu\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Odrzuć nieautoryzowane certyfikaty\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Wyłączenie walidacji certyfikatów SSL może narazić cię na ryzyka bezpieczeństwa, takie jak ataki man-in-the-middle. Wyłącz tą opcję wyłącznie jeśli rozumiesz tego skutki i ufasz serwerowi pocztowemu, do którego się podłączasz.\",\n  \"LabelEmailSettingsSecure\": \"Bezpieczeństwo\",\n  \"LabelEmailSettingsSecureHelp\": \"Jeśli włączysz, połączenie będzie korzystać z TLS podczas łączenia do serwera. Jeśli wyłączysz, TLS będzie wykorzystane jeśli serwer wspiera rozszerzenie STARTTLS. W większości przypadków włącz to ustawienie jeśli łączysz się do portu 465. Dla portów 587 lub 25 pozostaw to ustawienie wyłączone. (na podstawie nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Adres testowy\",\n  \"LabelEmbeddedCover\": \"Wbudowana okładka\",\n  \"LabelEnable\": \"Włącz\",\n  \"LabelEncodingBackupLocation\": \"Kopia zapasowa twoich oryginalnych plików audio będzie się znajdować w:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"W audiobookach wielościeżkowych rozdziały nie są osadzone.\",\n  \"LabelEncodingClearItemCache\": \"Pamiętaj, aby okresowo czyścić pamięć podręczną elementów.\",\n  \"LabelEncodingFinishedM4B\": \"Ukończony plik M4B zostanie umieszczony w folderze audiobooka pod adresem:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadane zostaną osadzone w ścieżkach audio w folderze z audiobookiem.\",\n  \"LabelEncodingStartedNavigation\": \"Po uruchomieniu zadania możesz opuścić tę stronę.\",\n  \"LabelEncodingTimeWarning\": \"Kodowanie może potrwać do 30 minut.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Ostrzeżenie: Nie aktualizuj tych ustawień, jeśli nie jesteś zaznajomiony ze sposobem działania ffmpeg i opcji konwersji.\",\n  \"LabelEncodingWatcherDisabled\": \"Jeśli monitorowanie folderów jest wyłączone, należy ponownie przeskanować audiobooka.\",\n  \"LabelEnd\": \"Zakończ\",\n  \"LabelEndOfChapter\": \"Koniec rozdziału\",\n  \"LabelEpisode\": \"Odcinek\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Odcinek nie jest powiązany z kanałem RSS\",\n  \"LabelEpisodeNumber\": \"Odcinek #{0}\",\n  \"LabelEpisodeTitle\": \"Tytuł odcinka\",\n  \"LabelEpisodeType\": \"Typ odcinka\",\n  \"LabelEpisodeUrlFromRssFeed\": \"Adres URL odcinka z kanału RSS\",\n  \"LabelEpisodes\": \"Epizody\",\n  \"LabelEpisodic\": \"Epizodyczny\",\n  \"LabelExample\": \"Przykład\",\n  \"LabelExpandSeries\": \"Rozwiń serie\",\n  \"LabelExpandSubSeries\": \"Rozwiń podserie\",\n  \"LabelExpired\": \"Wygasły\",\n  \"LabelExpiresAt\": \"Wygasa o\",\n  \"LabelExpiresInSeconds\": \"Wygasa za (sekund)\",\n  \"LabelExpiresNever\": \"Nigdy\",\n  \"LabelExplicit\": \"18+\",\n  \"LabelExplicitChecked\": \"Nieprzyzwoite (sprawdzone)\",\n  \"LabelExplicitUnchecked\": \"Przyzwoite (niesprawdzone)\",\n  \"LabelExportOPML\": \"Wyeksportuj OPML\",\n  \"LabelFeedURL\": \"URL kanału\",\n  \"LabelFetchingMetadata\": \"Pobieranie metadanych\",\n  \"LabelFile\": \"Plik\",\n  \"LabelFileBirthtime\": \"Data utworzenia pliku\",\n  \"LabelFileBornDate\": \"Utworzony {0}\",\n  \"LabelFileModified\": \"Data modyfikacji pliku\",\n  \"LabelFileModifiedDate\": \"Modyfikowany {0}\",\n  \"LabelFilename\": \"Nazwa pliku\",\n  \"LabelFilterByUser\": \"Filtruj według danego użytkownika\",\n  \"LabelFindEpisodes\": \"Znajdź odcinki\",\n  \"LabelFinished\": \"Zakończone\",\n  \"LabelFinishedDate\": \"Ukończone {0}\",\n  \"LabelFolder\": \"Katalog\",\n  \"LabelFolders\": \"Foldery\",\n  \"LabelFontBold\": \"Pogrubiony\",\n  \"LabelFontBoldness\": \"Grubość czcionki\",\n  \"LabelFontFamily\": \"Krój pisma\",\n  \"LabelFontItalic\": \"Kursywa\",\n  \"LabelFontScale\": \"Rozmiar czcionki\",\n  \"LabelFontStrikethrough\": \"Przekreślony\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Pełny\",\n  \"LabelGenre\": \"Gatunek\",\n  \"LabelGenres\": \"Gatunki\",\n  \"LabelHardDeleteFile\": \"Usuń trwale plik\",\n  \"LabelHasEbook\": \"Ma ebooka\",\n  \"LabelHasSupplementaryEbook\": \"Posiada dodatkowy ebook\",\n  \"LabelHideSubtitles\": \"Ukryj napisy\",\n  \"LabelHighestPriority\": \"Najwyższy priorytet\",\n  \"LabelHost\": \"Host\",\n  \"LabelHour\": \"Godzina\",\n  \"LabelHours\": \"Godziny\",\n  \"LabelIcon\": \"Ikona\",\n  \"LabelImageURLFromTheWeb\": \"Link do obrazu w sieci\",\n  \"LabelInProgress\": \"W toku\",\n  \"LabelIncludeInTracklist\": \"Dołącz do listy odtwarzania\",\n  \"LabelIncomplete\": \"Nieukończone\",\n  \"LabelInterval\": \"Interwał\",\n  \"LabelIntervalCustomDailyWeekly\": \"Niestandardowy dzienny/tygodniowy\",\n  \"LabelIntervalEvery12Hours\": \"Co 12 godzin\",\n  \"LabelIntervalEvery15Minutes\": \"Co 15 minut\",\n  \"LabelIntervalEvery2Hours\": \"Co 2 godziny\",\n  \"LabelIntervalEvery30Minutes\": \"Co 30 minut\",\n  \"LabelIntervalEvery6Hours\": \"Co 6 godzin\",\n  \"LabelIntervalEveryDay\": \"Każdego dnia\",\n  \"LabelIntervalEveryHour\": \"Każdej godziny\",\n  \"LabelIntervalEveryMinute\": \"Co minutę\",\n  \"LabelInvert\": \"Inversja\",\n  \"LabelItem\": \"Pozycja\",\n  \"LabelJumpBackwardAmount\": \"Przeskocz do tyłu o:\",\n  \"LabelJumpForwardAmount\": \"Przeskocz do przodu o:\",\n  \"LabelLanguage\": \"Język\",\n  \"LabelLanguageDefaultServer\": \"Domyślny język serwera\",\n  \"LabelLanguages\": \"Języki\",\n  \"LabelLastBookAdded\": \"Ostatnio dodana książka\",\n  \"LabelLastBookUpdated\": \"Ostatnio modyfikowana książka\",\n  \"LabelLastProgressDate\": \"Ostatni postęp: {0}\",\n  \"LabelLastSeen\": \"Ostatnio widziany\",\n  \"LabelLastTime\": \"Ostatni czas\",\n  \"LabelLastUpdate\": \"Ostatnia aktualizacja\",\n  \"LabelLayout\": \"Układ\",\n  \"LabelLayoutSinglePage\": \"Pojedyncza strona\",\n  \"LabelLayoutSplitPage\": \"Podział strony\",\n  \"LabelLess\": \"Mniej\",\n  \"LabelLibrariesAccessibleToUser\": \"Biblioteki dostępne dla użytkownika\",\n  \"LabelLibrary\": \"Biblioteka\",\n  \"LabelLibraryFilterSublistEmpty\": \"Brak {0}\",\n  \"LabelLibraryItem\": \"Element biblioteki\",\n  \"LabelLibraryName\": \"Nazwa biblioteki\",\n  \"LabelLibrarySortByProgress\": \"Postęp: Ostatnia aktualizacja\",\n  \"LabelLibrarySortByProgressFinished\": \"Postęp: Ukończony\",\n  \"LabelLibrarySortByProgressStarted\": \"Postęp: Rozpoczęty\",\n  \"LabelLimit\": \"Limit\",\n  \"LabelLineSpacing\": \"Odstęp między wierszami\",\n  \"LabelListenAgain\": \"Słuchaj ponownie\",\n  \"LabelLogLevelDebug\": \"Debugowanie\",\n  \"LabelLogLevelInfo\": \"Informacja\",\n  \"LabelLogLevelWarn\": \"Ostrzeżenie\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Szukaj nowych odcinków po dacie\",\n  \"LabelLowestPriority\": \"Najniższy priorytet\",\n  \"LabelMatchConfidence\": \"Zaufanie\",\n  \"LabelMatchExistingUsersBy\": \"Dopasuje istniejących użytkowników poprzez\",\n  \"LabelMatchExistingUsersByDescription\": \"Służy do łączenia istniejących użytkowników. Po połączeniu użytkownicy zostaną dopasowani za pomocą unikalnego identyfikatora od dostawcy SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Maksymalna liczba odcinków do pobrania. Użyj 0, aby uzyskać nieograniczoną liczbę.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Maksymalna liczba nowych odcinków do pobrania na jedno sprawdzenie\",\n  \"LabelMaxEpisodesToKeep\": \"Maksymalna liczba odcinków do zachowania\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Wartość 0 wyłącza maksymalny limit. Po automatycznym pobraniu nowego odcinka, najstarszy odcinek zostanie usunięty, jeśli masz ich więcej niż X. Spowoduje to usunięcie tylko 1 odcinka na nowe pobieranie.\",\n  \"LabelMediaPlayer\": \"Odtwarzacz\",\n  \"LabelMediaType\": \"Typ mediów\",\n  \"LabelMetaTag\": \"Tag\",\n  \"LabelMetaTags\": \"Meta Tagi\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Źródła metadanych o wyższym priorytecie będą zastępują źródła o niższym priorytecie\",\n  \"LabelMetadataProvider\": \"Dostawca metadanych\",\n  \"LabelMinute\": \"Minuta\",\n  \"LabelMinutes\": \"Minuty\",\n  \"LabelMissing\": \"Brakujący\",\n  \"LabelMissingEbook\": \"Nie posiada ebooka\",\n  \"LabelMissingSupplementaryEbook\": \"Nie posiada dodatkowego ebooka\",\n  \"LabelMobileRedirectURIs\": \"Dozwolone URI przekierowań mobilnych\",\n  \"LabelMobileRedirectURIsDescription\": \"To jest biała lista prawidłowych adresów URI przekierowań dla aplikacji mobilnych. Domyślny adres to <code>audiobookshelf://oauth</code>, który można usunąć lub dodać inne adresy URI w celu integracji z aplikacjami innych firm. Użycie gwiazdki (<code>*</code>) jako jedynego wpisu zezwala na dowolny URI.\",\n  \"LabelMore\": \"Więcej\",\n  \"LabelMoreInfo\": \"Więcej informacji\",\n  \"LabelName\": \"Nazwa\",\n  \"LabelNarrator\": \"Lektor\",\n  \"LabelNarrators\": \"Lektorzy\",\n  \"LabelNew\": \"Nowy\",\n  \"LabelNewPassword\": \"Nowe hasło\",\n  \"LabelNewestAuthors\": \"Najnowsi autorzy\",\n  \"LabelNewestEpisodes\": \"Najnowsze odcinki\",\n  \"LabelNextBackupDate\": \"Data kolejnej kopii zapasowej\",\n  \"LabelNextChapters\": \"Następny odcinek:\",\n  \"LabelNextScheduledRun\": \"Następne uruchomienie\",\n  \"LabelNoApiKeys\": \"Brak kluczy API\",\n  \"LabelNoCustomMetadataProviders\": \"Brak niestandardowych dostawców metadanych\",\n  \"LabelNoEpisodesSelected\": \"Nie wybrano żadnych odcinków\",\n  \"LabelNotFinished\": \"Nieukończone\",\n  \"LabelNotStarted\": \"Nie rozpoczęto\",\n  \"LabelNotes\": \"Uwagi\",\n  \"LabelNotificationAppriseURL\": \"URLe Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Dostępne zmienne\",\n  \"LabelNotificationBodyTemplate\": \"Szablon treści powiadomienia\",\n  \"LabelNotificationEvent\": \"Zdarzenie\",\n  \"LabelNotificationTitleTemplate\": \"Szablon tytułu powiadmienia\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maksymalna liczba nieudanych prób\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Powiadomienia są wyłączane, gdy próba ich wysyłki nie powiedzie się kilkukrotnie\",\n  \"LabelNotificationsMaxQueueSize\": \"Maksymalny rozmiar kolejki dla powiadomień\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Zdarzenia są ograniczone do 1 na sekundę. Zdarzenia będą ignorowane jeśli kolejka ma maksymalny rozmiar. Zapobiega to spamowaniu powiadomieniami.\",\n  \"LabelNumberOfBooks\": \"Liczba książek\",\n  \"LabelNumberOfChapters\": \"Liczba rozdziałów:\",\n  \"LabelNumberOfEpisodes\": \"# Odcinków\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Nazwa deklaracji OpenID zawierającej zaawansowane uprawnienia do działań użytkownika w aplikacji, które będą miały zastosowanie do ról innych niż administracyjne (<b>jeśli skonfigurowano</b>). Jeśli deklaracja nie zostanie uwzględniona w odpowiedzi, dostęp do ABS zostanie zablokowany. Brak jednej opcji zostanie uznany za <code>fałsz</code>. Upewnij się, że deklaracja dostawcy tożsamości jest zgodna z oczekiwaną strukturą:\",\n  \"LabelOpenIDClaims\": \"Pozostaw poniższe opcje puste, aby wyłączyć zaawansowane przypisywanie grup i uprawnień. Wówczas automatycznie zostanie przypisana grupa „Użytkownik”.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Nazwa roszczenia OpenID zawierającego listę grup użytkownika. Powszechnie nazywane <code>grupami</code>. <b>Jeśli skonfigurowano</b>, aplikacja automatycznie przypisze role na podstawie przynależności użytkownika do grup, pod warunkiem, że te grupy są nazwane bez uwzględniania wielkości liter „admin”, „user” lub „guest” w roszczeniu. Roszczenie powinno zawierać listę, a jeśli użytkownik należy do wielu grup, aplikacja przypisze rolę odpowiadającą najwyższemu poziomowi dostępu. Jeśli żadna grupa nie będzie pasować, dostęp zostanie odrzucony.\",\n  \"LabelOpenRSSFeed\": \"Otwórz kanał RSS\",\n  \"LabelOverwrite\": \"Nadpisz\",\n  \"LabelPaginationPageXOfY\": \"Strona {0} z {1}\",\n  \"LabelPassword\": \"Hasło\",\n  \"LabelPath\": \"Ścieżka\",\n  \"LabelPermanent\": \"Stałe\",\n  \"LabelPermissionsAccessAllLibraries\": \"Ma dostęp do wszystkich bibliotek\",\n  \"LabelPermissionsAccessAllTags\": \"Ma dostęp do wszystkich tagów\",\n  \"LabelPermissionsAccessExplicitContent\": \"Ma dostęp do treści oznacznych jako nieprzyzwoite\",\n  \"LabelPermissionsCreateEreader\": \"Możliwość stworzenia czytnika e-booków\",\n  \"LabelPermissionsDelete\": \"Ma możliwość usuwania\",\n  \"LabelPermissionsDownload\": \"Ma możliwość pobierania\",\n  \"LabelPermissionsUpdate\": \"Ma możliwość aktualizowania\",\n  \"LabelPermissionsUpload\": \"Ma możliwość dodawania\",\n  \"LabelPersonalYearReview\": \"Podsumowanie twojego roku ({0})\",\n  \"LabelPhotoPathURL\": \"Scieżka/URL do zdjęcia\",\n  \"LabelPlayMethod\": \"Metoda odtwarzania\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Zwiększenie/zmniejszenie szybkości odtwarzania\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} z {1}\",\n  \"LabelPlaylists\": \"Listy odtwarzania\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Obszar wyszukiwania podcastów\",\n  \"LabelPodcastType\": \"Typ podcastu\",\n  \"LabelPodcasts\": \"Podcasty\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Ignorowane prefiksy (wielkość liter nie ma znaczenia)\",\n  \"LabelPreventIndexing\": \"Zapobiega indeksowaniu przez iTunes i Google\",\n  \"LabelPrimaryEbook\": \"Główny ebook\",\n  \"LabelProgress\": \"Postęp\",\n  \"LabelProvider\": \"Dostawca\",\n  \"LabelProviderAuthorizationValue\": \"Wartość nagłówka autoryzacji\",\n  \"LabelPubDate\": \"Data publikacji\",\n  \"LabelPublishYear\": \"Rok publikacji\",\n  \"LabelPublishedDate\": \"Opublikowano {0}\",\n  \"LabelPublishedDecade\": \"Dekada publikacji\",\n  \"LabelPublishedDecades\": \"Dekada publikacji\",\n  \"LabelPublisher\": \"Wydawca\",\n  \"LabelPublishers\": \"Wydawcy\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Email właściciela\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nazwa właściciela\",\n  \"LabelRSSFeedOpen\": \"Otwarty Kanał RSS\",\n  \"LabelRSSFeedPreventIndexing\": \"Zapobiegaj indeksowaniu\",\n  \"LabelRSSFeedSlug\": \"Numer Kanału RSS\",\n  \"LabelRSSFeedURL\": \"URL kanały RSS\",\n  \"LabelRandomly\": \"Losowo\",\n  \"LabelReAddSeriesToContinueListening\": \"Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie\",\n  \"LabelRead\": \"Czytaj\",\n  \"LabelReadAgain\": \"Czytaj ponownie\",\n  \"LabelReadEbookWithoutProgress\": \"Czytaj książkę bez zapamiętywania postępu\",\n  \"LabelRecentSeries\": \"Ostatnie serie\",\n  \"LabelRecentlyAdded\": \"Niedawno dodane\",\n  \"LabelRecommended\": \"Polecane\",\n  \"LabelRedo\": \"Wycofaj\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Data wydania\",\n  \"LabelRemoveAllMetadataAbs\": \"Usuń wszystkie pliki metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Usuń wszystkie pliki metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Usuń Audible intro i outro z rozdziałów\",\n  \"LabelRemoveCover\": \"Usuń okładkę\",\n  \"LabelRemoveMetadataFile\": \"Usuń pliki metadanych z folderów biblioteki\",\n  \"LabelRemoveMetadataFileHelp\": \"Usuń wszystkie pliki metadata.json i metadata.abs z {0} folderów.\",\n  \"LabelRowsPerPage\": \"Wierszy na stronę\",\n  \"LabelSearchTerm\": \"Wyszukiwanie frazy\",\n  \"LabelSearchTitle\": \"Wyszukaj tytuł\",\n  \"LabelSearchTitleOrASIN\": \"Szukaj tytuł lub ASIN\",\n  \"LabelSeason\": \"Sezon\",\n  \"LabelSeasonNumber\": \"Sezon #{0}\",\n  \"LabelSelectAll\": \"Wybierz wszystko\",\n  \"LabelSelectAllEpisodes\": \"Wybierz wszystkie odcinki\",\n  \"LabelSelectEpisodesShowing\": \"Wybierz {0} wyświetlanych odcinków\",\n  \"LabelSelectUser\": \"Wybierz użytkownika\",\n  \"LabelSelectUsers\": \"Wybór użytkowników\",\n  \"LabelSendEbookToDevice\": \"Wyślij ebook do...\",\n  \"LabelSequence\": \"Kolejność\",\n  \"LabelSerial\": \"Numer serii\",\n  \"LabelSeries\": \"Serie\",\n  \"LabelSeriesName\": \"Nazwy serii\",\n  \"LabelSeriesProgress\": \"Postęp w serii\",\n  \"LabelServerLogLevel\": \"Poziom logów servera\",\n  \"LabelServerYearReview\": \"Podsumowanie serwera w roku ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Ustaw jako pierwszy\",\n  \"LabelSetEbookAsSupplementary\": \"Ustaw jako dodatkowy\",\n  \"LabelSettingsAllowIframe\": \"Zezwól na osadzanie w ramce iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Wyłącznie audiobooki\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Włączenie tej funkcji spowoduje ignorowanie plików ebooków, chyba że znajdują się wewnątrz folderu audiobooka kiedy to będą pokazywane jako dodatkowe ebooki\",\n  \"LabelSettingsBookshelfViewHelp\": \"Widok półki z książkami\",\n  \"LabelSettingsChromecastSupport\": \"Wsparcie Chromecast\",\n  \"LabelSettingsDateFormat\": \"Format daty\",\n  \"LabelSettingsEnableWatcher\": \"Automatyczne monitorowanie bibliotek w poszukiwaniu zmian\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Automatyczne monitorowanie biblioteki w poszukiwaniu zmian\",\n  \"LabelSettingsEnableWatcherHelp\": \"Włącza automatyczne dodawanie/aktualizację pozycji gdy wykryte zostaną zmiany w plikach. Wymaga restartu serwera\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Zezwalanie na skrypty w plikach epub\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Zezwala plikom epub na wykonywanie skryptów. Zaleca się mieć to ustawienie wyłączone, chyba że ma się zaufanie do źródła plików epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Funkcje eksperymentalne\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.\",\n  \"LabelSettingsFindCovers\": \"Szukanie okładek\",\n  \"LabelSettingsFindCoversHelp\": \"Jeśli audiobook nie posiada zintegrowanej okładki albo w folderze nie zostanie znaleziony plik okładki, skaner podejmie próbę pobrania okładki z sieci. <br>Uwaga: może to wydłuzyć proces skanowania\",\n  \"LabelSettingsHideSingleBookSeries\": \"Ukryj serie z jedną książką\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serie, które posiadają tylko jedną książkę, nie będą pokazywane na stronie z seriami i na stronie domowej z półkami.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Widok półki z książkami na stronie głównej\",\n  \"LabelSettingsLibraryBookshelfView\": \"Widok półki z książkami na stronie biblioteki\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Procent ukończenia jest większy niż\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Pozostały czas jest mniejszy niż (sekund)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Oznacz element multimedialny jako ukończony, gdy\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Pomiń poprzednie książki przy kontynuacji serii\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Strona „Kontynuuj serię” wyświetla pierwszą nierozpoczętą książkę z serii, w której ukończono co najmniej jedną książkę i żadnej nie rozpoczęto. Włączając to ustawienie, będziesz kontynuować serię po przeczytaniu ostatniej książki, a nie od pierwszej nierozpoczętej książki z serii.\",\n  \"LabelSettingsParseSubtitles\": \"Przetwarzaj podtytuły\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Opcja pozwala na pobranie podtytułu z nazwy folderu z audiobookiem. <br>Podtytuł musi być rozdzielony za pomocą separatora \\\" - \\\"<br>Przykład: \\\"Tytuł książki - Podtytuł\\\" podtytuł \\\"Podtytuł\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferowanie dopasowanych metadanych\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Dopasowane dane będą miały pierwszeństwo nad szczegółami pozycji podczas używania Szybkiego dopasowania. Domyślnie Szybkie dopasowanie uzupełnia tylko brakujące szczegóły.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Pomiń dopasowanie książek, które już mają ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Pomiń dopasowanie książek, które już mają ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignoruj prefiksy podczas sortowania\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"np. dla prefiksu \\\"the\\\" tytuł ksiązki \\\"The Book Title\\\" będzie sortowany jako \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Używaj kwadratowych okładek książek\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Preferuj stosowanie kwadratowych okładek zamiast standardowych okładek książkowych o propocji 1,6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Przechowuj okładkę w folderze książki\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Domyślnie okładki są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \\\"cover\\\" będzie przechowywana\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Przechowuj metadane w folderze książki\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \\\"cover\\\" będzie przechowywana\",\n  \"LabelSettingsTimeFormat\": \"Format czasu\",\n  \"LabelShare\": \"Udostępnij\",\n  \"LabelShareDownloadableHelp\": \"Zezwala użytkownikom z linkiem udostępniania na pobranie pliku zip elementu biblioteki.\",\n  \"LabelShareOpen\": \"Otwórz udział\",\n  \"LabelShareURL\": \"Link do udziału\",\n  \"LabelShowAll\": \"Pokaż wszystko\",\n  \"LabelShowSeconds\": \"Pokaż sekundy\",\n  \"LabelShowSubtitles\": \"Pokaż Napisy\",\n  \"LabelSize\": \"Rozmiar\",\n  \"LabelSleepTimer\": \"Wyłącznik czasowy\",\n  \"LabelSlug\": \"slug (części URL która identyfikuje konkretny zasób)\",\n  \"LabelSortAscending\": \"Rosnąco\",\n  \"LabelSortDescending\": \"Malejąco\",\n  \"LabelSortPubDate\": \"Sortuj według daty publikacji\",\n  \"LabelStart\": \"Rozpocznij\",\n  \"LabelStartTime\": \"Czas rozpoczęcia\",\n  \"LabelStarted\": \"Rozpoczęty\",\n  \"LabelStartedAt\": \"Rozpoczęto\",\n  \"LabelStartedDate\": \"Rozpoczęto {0}\",\n  \"LabelStatsAudioTracks\": \"Ścieżki audio\",\n  \"LabelStatsAuthors\": \"Autorzy\",\n  \"LabelStatsBestDay\": \"Najlepszy dzień\",\n  \"LabelStatsDailyAverage\": \"Średnia dzienna\",\n  \"LabelStatsDays\": \"Dni\",\n  \"LabelStatsDaysListened\": \"Dni słuchania\",\n  \"LabelStatsHours\": \"Godziny\",\n  \"LabelStatsInARow\": \"z rzędu\",\n  \"LabelStatsItemsFinished\": \"Pozycje zakończone\",\n  \"LabelStatsItemsInLibrary\": \"Pozycje w bibliotece\",\n  \"LabelStatsMinutes\": \"Minuty\",\n  \"LabelStatsMinutesListening\": \"Minuty słuchania\",\n  \"LabelStatsOverallDays\": \"Całkowity czas (dni)\",\n  \"LabelStatsOverallHours\": \"Całkowity czas (godziny)\",\n  \"LabelStatsWeekListening\": \"Tydzień słuchania\",\n  \"LabelSubtitle\": \"Podtytuł\",\n  \"LabelSupportedFileTypes\": \"Obsługiwane typy plików\",\n  \"LabelTag\": \"Znacznik\",\n  \"LabelTags\": \"Tagi\",\n  \"LabelTagsAccessibleToUser\": \"Tagi dostępne dla użytkownika\",\n  \"LabelTagsNotAccessibleToUser\": \"Znaczniki niedostępne dla użytkownika\",\n  \"LabelTasks\": \"Uruchomione zadania\",\n  \"LabelTextEditorBulletedList\": \"Lista wypunktowana\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"Lista numerowana\",\n  \"LabelTextEditorUnlink\": \"Usuń link\",\n  \"LabelTheme\": \"Motyw\",\n  \"LabelThemeDark\": \"Ciemny\",\n  \"LabelThemeLight\": \"Jasny\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Podstawa czasu\",\n  \"LabelTimeDurationXHours\": \"{0} godzin\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuty\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekundy\",\n  \"LabelTimeInMinutes\": \"Czas w minutach\",\n  \"LabelTimeLeft\": \"pozostało {0}\",\n  \"LabelTimeListened\": \"Czas odtwarzania\",\n  \"LabelTimeListenedToday\": \"Czas odtwarzania dzisiaj\",\n  \"LabelTimeRemaining\": \"Pozostało {0}\",\n  \"LabelTimeToShift\": \"Czas do przesunięcia w sekundach\",\n  \"LabelTitle\": \"Tytuł\",\n  \"LabelToolsEmbedMetadata\": \"Załącz metadane\",\n  \"LabelToolsEmbedMetadataDescription\": \"Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów).\",\n  \"LabelToolsM4bEncoder\": \"Enkoder M4B\",\n  \"LabelToolsMakeM4b\": \"Generuj plik M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.\",\n  \"LabelToolsSplitM4b\": \"Podziel plik .M4B na pliki .MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Podziel plik .M4B na pliki .MP3 na rozdziały z załączonymi metadanymi oraz okładką.\",\n  \"LabelTotalDuration\": \"Całkowita długość\",\n  \"LabelTotalTimeListened\": \"Całkowity czas odtwarzania\",\n  \"LabelTrackFromFilename\": \"Ścieżka z nazwy pliku\",\n  \"LabelTrackFromMetadata\": \"Ścieżka z metadanych\",\n  \"LabelTracks\": \"Ścieżki\",\n  \"LabelTracksMultiTrack\": \"Wielościeżkowy\",\n  \"LabelTracksNone\": \"Brak utworów\",\n  \"LabelTracksSingleTrack\": \"Pojedyncza ścieżka\",\n  \"LabelTrailer\": \"Zwiastun\",\n  \"LabelType\": \"Typ\",\n  \"LabelUnabridged\": \"Pełna wersja\",\n  \"LabelUndo\": \"Wycofaj\",\n  \"LabelUnknown\": \"Nieznany\",\n  \"LabelUnknownPublishDate\": \"Nieznana data publikacji\",\n  \"LabelUpdateCover\": \"Zaktalizuj odkładkę\",\n  \"LabelUpdateCoverHelp\": \"Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania\",\n  \"LabelUpdateDetails\": \"Zaktualizuj szczegóły\",\n  \"LabelUpdateDetailsHelp\": \"Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania\",\n  \"LabelUpdatedAt\": \"Zaktualizowano\",\n  \"LabelUploaderDragAndDrop\": \"Przeciągnij i puść foldery lub pliki\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Przeciągnij i upuść pliki\",\n  \"LabelUploaderDropFiles\": \"Puść pliki\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automatycznie pobierz tytuł, autora i serie\",\n  \"LabelUseAdvancedOptions\": \"Użyj ustawień zaawansowanych\",\n  \"LabelUseChapterTrack\": \"Użyj ścieżki rozdziału\",\n  \"LabelUseFullTrack\": \"Użycie ścieżki rozdziału\",\n  \"LabelUseZeroForUnlimited\": \"Użyj 0, aby wyłączyć ograniczenia\",\n  \"LabelUser\": \"Użytkownik\",\n  \"LabelUsername\": \"Nazwa użytkownika\",\n  \"LabelValue\": \"Wartość\",\n  \"LabelVersion\": \"Wersja\",\n  \"LabelViewBookmarks\": \"Wyświetlaj zakładki\",\n  \"LabelViewChapters\": \"Wyświetlaj rozdziały\",\n  \"LabelViewPlayerSettings\": \"Zobacz ustawienia odtwarzacza\",\n  \"LabelViewQueue\": \"Wyświetlaj kolejkę odtwarzania\",\n  \"LabelVolume\": \"Głośność\",\n  \"LabelWebRedirectURLsDescription\": \"Zezwól na te adresy URL w swoim dostawcy OAuth, aby umożliwić przekierowanie z powrotem do aplikacji internetowej po zalogowaniu:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Podfolder dla adresów URL przekierowań\",\n  \"LabelWeekdaysToRun\": \"Dni tygodnia\",\n  \"LabelXBooks\": \"{0} książek\",\n  \"LabelXItems\": \"{0} elementów\",\n  \"LabelYearReviewHide\": \"Ukryj Podsumowanie Roku\",\n  \"LabelYearReviewShow\": \"Pokaż Podsumowanie Roku\",\n  \"LabelYourAudiobookDuration\": \"Czas trwania audiobooka\",\n  \"LabelYourBookmarks\": \"Twoje zakładki\",\n  \"LabelYourPlaylists\": \"Twoje playlisty\",\n  \"LabelYourProgress\": \"Twój postęp\",\n  \"MessageAddToPlayerQueue\": \"Dodaj do kolejki odtwarzania\",\n  \"MessageAppriseDescription\": \"Aby użyć tej funkcji, konieczne jest posiadanie instancji <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> albo innego rozwiązania, które obsługuje schemat zapytań Apprise. <br />URL do interfejsu API powinno być całkowitą ścieżką, np., jeśli Twoje API do powiadomień jest dostępne pod adresem <code>http://192.168.1.1:8337</code> to wpisany tutaj URL powinien mieć postać: <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Upewnij się, że używasz ASIN z poprawnego regionu Audible, nie Amazona.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Starsze tokeny API zostaną w przyszłości usunięte. Zamiast nich należy używać <a href=\\\"/config/api-keys\\\">kluczy API</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Zrestartuj serwer aby zastosować zmiany w OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"Uwierzytelnianie zostało ulepszone ze względów bezpieczeństwa. Wszyscy użytkownicy muszą się ponownie zalogować.\",\n  \"MessageBackupsDescription\": \"Kopie zapasowe obejmują użytkowników, postępy użytkowników, szczegóły pozycji biblioteki, ustawienia serwera i obrazy przechowywane w <code>/metadata/items</code> & <code>/metadata/authors</code>. Kopie zapasowe nie obejmują żadnych plików przechowywanych w folderach biblioteki.\",\n  \"MessageBackupsLocationEditNote\": \"Uwaga: Zmiana lokalizacji kopii zapasowej nie przenosi ani nie modyfikuje istniejących kopii zapasowych\",\n  \"MessageBackupsLocationNoEditNote\": \"Uwaga: Lokalizacja kopii zapasowej jest ustawiona poprzez zmienną środowiskową i nie może być tutaj zmieniona.\",\n  \"MessageBackupsLocationPathEmpty\": \"Ścieżka do kopii zapasowej nie może być pusta\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Wypełnij włączone pola danymi ze wszystkich elementów. Pola z wieloma wartościami zostaną scalone.\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Wypełnia włączone pola szczegółów mapy danymi z tego elementu\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match będzie próbował dodać brakujące okładki i metadane dla wybranych elementów. Włącz poniższe opcje, aby umożliwić Quick Match nadpisanie istniejących okładek i/lub metadanych.\",\n  \"MessageBookshelfNoCollections\": \"Nie posiadasz jeszcze żadnych kolekcji\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Kolekcje są publiczne. Wszyscy użytkownicy mający dostęp do biblioteki mogą je zobaczyć.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Nie posiadasz żadnych otwartych feedów RSS\",\n  \"MessageBookshelfNoResultsForFilter\": \"Nie znaleziono żadnych pozycji przy aktualnym filtrowaniu \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Brak wyników zapytania\",\n  \"MessageBookshelfNoSeries\": \"Nie masz jeszcze żadnych serii\",\n  \"MessageBulkChapterPattern\": \"Ile rozdziałów chcesz dodać przy pomocy tego wzorca numeracji?\",\n  \"MessageChapterEndIsAfter\": \"Koniec rozdziału następuje po zakończeniu audiobooka\",\n  \"MessageChapterErrorFirstNotZero\": \"Pierwszy rozdział musi rozpoczynać się na 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Nieprawidłowy czas rozpoczęcia, musi być krótszy niż długość audiobooka\",\n  \"MessageChapterErrorStartLtPrev\": \"Nieprawidłowy czas rozpoczęcia, musi być większy lub taki sam, jak czas rozpoczęcia poprzedniego rozdziału.\",\n  \"MessageChapterStartIsAfter\": \"Początek rozdziału następuje po zakończeniu audiobooka\",\n  \"MessageChaptersNotFound\": \"Nie znaleziono rozdziałów\",\n  \"MessageCheckingCron\": \"Sprawdzanie cron...\",\n  \"MessageConfirmCloseFeed\": \"Czy na pewno chcesz zamknąć ten kanał?\",\n  \"MessageConfirmDeleteApiKey\": \"Czy na pewno chcesz usunąć klucz API \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Czy na pewno chcesz usunąć kopię zapasową dla {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Czy na pewno chcesz usunąć czytnik e-booków \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Ta operacja usunie plik z twojego dysku. Jesteś pewien?\",\n  \"MessageConfirmDeleteLibrary\": \"Czy na pewno chcesz trwale usunąć bibliotekę \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Ta operacja usunie pozycję biblioteki z bazy danych i z dysku. Czy jesteś pewien?\",\n  \"MessageConfirmDeleteLibraryItems\": \"{0} element(ów) zostanie teraz usuniętych z bazy danych i systemu plików. Czy jesteś pewien?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Czy na pewno chcesz usunąć niestandardowego dostawcę metadanych: \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Czy na pewno chcesz usunąć to powiadomienie?\",\n  \"MessageConfirmDeleteSession\": \"Czy na pewno chcesz usunąć tę sesję?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Czy na pewno chcesz osadzić metadane w {0} plikach audio?\",\n  \"MessageConfirmForceReScan\": \"Czy na pewno chcesz wymusić ponowne skanowanie?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Czy na pewno chcesz oznaczyć wszystkie odcinki jako ukończone?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Czy na pewno chcesz oznaczyć wszystkie odcinki jako nieukończone?\",\n  \"MessageConfirmMarkItemFinished\": \"Czy na pewno chcesz oznaczyć \\\"{0}\\\" jako zakończone?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Czy na pewno chcesz oznaczyć \\\"{0}\\\" jako nieukończone?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Czy na pewno chcesz oznaczyć wszystkie książki w tej serii jako ukończone?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Czy na pewno chcesz oznaczyć wszystkie książki w tej serii jako nieukończone?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Czy wywołać to powiadomienie za pomocą danych testowych?\",\n  \"MessageConfirmPurgeCache\": \"Wyczyszczenie pamięci podręcznej spowoduje usunięcie całego katalogu <code>/metadata/cache</code>. <br /><br />Czy na pewno chcesz usunąć katalog pamięci podręcznej?\",\n  \"MessageConfirmPurgeItemsCache\": \"Wyczyszczenie pamięci podręcznej elementów spowoduje usunięcie całego katalogu <code>/metadata/cache/items</code>.<br />Czy jesteś pewien?\",\n  \"MessageConfirmQuickEmbed\": \"Ostrzeżenie! Szybkie osadzanie nie utworzy kopii zapasowej plików audio. Upewnij się, że masz kopię zapasową plików audio. <br><br>Czy chcesz kontynuować?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Szybkie dopasowywanie odcinków spowoduje nadpisanie szczegółów w przypadku znalezienia dopasowania. Zaktualizowane zostaną tylko niedopasowane odcinki. Jesteś pewien?\",\n  \"MessageConfirmReScanLibraryItems\": \"Czy na pewno chcesz ponownie zeskanować {0} pozycji?\",\n  \"MessageConfirmRemoveAllChapters\": \"Czy na pewno chcesz usunąć wszystkie rozdziały?\",\n  \"MessageConfirmRemoveAuthor\": \"Czy na pewno chcesz usunąć autora \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Czy na pewno chcesz usunąć kolekcję \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Czy na pewno chcesz usunąć odcinek \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Uwaga: Plik audio nie zostanie usunięty, chyba że przełączysz opcję „Twarde usunięcie pliku”\",\n  \"MessageConfirmRemoveEpisodes\": \"Czy na pewno chcesz usunąć {0} odcinki?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Czy na pewno chcesz usunąć {0} sesji słuchania?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Czy na pewno chcesz usunąć wszystkie metadane.{0} plików w folderach elementów biblioteki?\",\n  \"MessageConfirmRemoveNarrator\": \"Czy na pewno chcesz usunąć lektora \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Czy jesteś pewien, że chcesz usunąć twoją playlistę \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Czy na pewno chcesz zmienić nazwę gatunku \\\"{0}\\\" na \\\"{1}\\\" dla wszystkich elementów?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Uwaga: Ten gatunek już istnieje, więc zostaną połączone.\",\n  \"MessageConfirmRenameGenreWarning\": \"Uwaga! Podobny gatunek z inną wielkością liter już istnieje: \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Czy na pewno chcesz zmienić nazwę tagu \\\"{0}\\\" na \\\"{1}\\\" dla wszystkich elementów?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Uwaga: Ten tag już istnieje, więc zostaną scalone.\",\n  \"MessageConfirmRenameTagWarning\": \"Uwaga! Podobny tag z inną wielkością liter już istnieje: \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Czy na pewno chcesz zresetować swój postęp?\",\n  \"MessageConfirmSendEbookToDevice\": \"Czy na pewno chcesz wysłać {0} e-booka \\\"{1}\\\" na urządzenie \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Czy na pewno chcesz odłączyć tego użytkownika od OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dni słuchania w ciągu ostatniego roku\",\n  \"MessageDownloadingEpisode\": \"Pobieranie odcinka\",\n  \"MessageDragFilesIntoTrackOrder\": \"przeciągnij pliki aby ustawić właściwą kolejność utworów\",\n  \"MessageEmbedFailed\": \"Niepowodzenie wstawiania!\",\n  \"MessageEmbedFinished\": \"Osadzanie zakończone!\",\n  \"MessageEmbedQueue\": \"W kolejce do osadzenia metadanych ({0} w kolejce)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} odcinki w kolejce do pobrania\",\n  \"MessageEreaderDevices\": \"Aby zagwarantować dostawę e-booków, konieczne może być dodanie powyższego adresu e-mail jako prawidłowego nadawcy dla każdego z urządzeń wymienionych poniżej.\",\n  \"MessageFeedURLWillBe\": \"URL kanału: {0}\",\n  \"MessageFetching\": \"Pobieranie...\",\n  \"MessageForceReScanDescription\": \"przeskanuje wszystkie pliki ponownie, jak przy świeżym skanowaniu. Tagi ID3 plików audio, pliki OPF i pliki tekstowe będą skanowane jak nowe.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} słucha</strong> na {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Brak sesji słuchania na {0}\",\n  \"MessageImportantNotice\": \"Ważna informacja!\",\n  \"MessageInsertChapterBelow\": \"Wstaw rozdział poniżej\",\n  \"MessageInvalidAsin\": \"Nieprawidłowy ASIN\",\n  \"MessageItemsSelected\": \"{0} zaznaczone elementy\",\n  \"MessageItemsUpdated\": \"Zaktualizowano {0} elementów\",\n  \"MessageJoinUsOn\": \"Dołącz do nas na\",\n  \"MessageLoading\": \"Ładowanie...\",\n  \"MessageLoadingFolders\": \"Ładowanie folderów...\",\n  \"MessageLogsDescription\": \"Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"Tworzenie pliku M4B nie powiodło się!\",\n  \"MessageM4BFinished\": \"Tworzenie pliku M4B zakończyło się!\",\n  \"MessageMapChapterTitles\": \"Mapowanie tytułów rozdziałów do istniejących rozdziałów audiobooka bez dostosowywania znaczników czasu\",\n  \"MessageMarkAllEpisodesFinished\": \"Oznacz wszystkie odcinki jako ukończone\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Oznacz wszystkie odcinki jako nieukończone\",\n  \"MessageMarkAsFinished\": \"Oznacz jako ukończone\",\n  \"MessageMarkAsNotFinished\": \"Oznacz jako nieukończone\",\n  \"MessageMatchBooksDescription\": \"spróbuje dopasować książki w bibliotece bez plików audio, korzystając z wybranego dostawcy wyszukiwania i wypełnić puste szczegóły i okładki. Nie nadpisuje informacji.\",\n  \"MessageNoAudioTracks\": \"Brak ścieżek audio\",\n  \"MessageNoAuthors\": \"Brak autorów\",\n  \"MessageNoBackups\": \"Brak kopii zapasowych\",\n  \"MessageNoBookmarks\": \"Brak zakładek\",\n  \"MessageNoChapters\": \"Brak rozdziałów\",\n  \"MessageNoCollections\": \"Brak kolekcji\",\n  \"MessageNoCoversFound\": \"Okładki nieznalezione\",\n  \"MessageNoDescription\": \"Brak opisu\",\n  \"MessageNoDevices\": \"Brak urządzeń\",\n  \"MessageNoDownloadsInProgress\": \"Brak aktualnie trwających pobrań\",\n  \"MessageNoDownloadsQueued\": \"Brak pobrań w kolejce\",\n  \"MessageNoEpisodeMatchesFound\": \"Nie znaleziono pasujących odcinków\",\n  \"MessageNoEpisodes\": \"Brak odcinków\",\n  \"MessageNoFoldersAvailable\": \"Brak dostępnych folderów\",\n  \"MessageNoGenres\": \"Brak gatunków\",\n  \"MessageNoIssues\": \"Brak problemów\",\n  \"MessageNoItems\": \"Brak elementów\",\n  \"MessageNoItemsFound\": \"Nie znaleziono żadnych elementów\",\n  \"MessageNoListeningSessions\": \"Brak sesji słuchania\",\n  \"MessageNoLogs\": \"Brak logów\",\n  \"MessageNoMediaProgress\": \"Brak postępu\",\n  \"MessageNoNotifications\": \"Brak powiadomień\",\n  \"MessageNoPodcastFeed\": \"Nieprawidłowy podcast: Brak kanału\",\n  \"MessageNoPodcastsFound\": \"Nie znaleziono podcastów\",\n  \"MessageNoResults\": \"Brak wyników\",\n  \"MessageNoSearchResultsFor\": \"Brak wyników wyszukiwania dla \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Brak serii\",\n  \"MessageNoTags\": \"Brak tagów\",\n  \"MessageNoTasksRunning\": \"Brak uruchomionych zadań\",\n  \"MessageNoUpdatesWereNecessary\": \"Brak aktualizacji\",\n  \"MessageNoUserPlaylists\": \"Nie masz żadnych list odtwarzania\",\n  \"MessageNoUserPlaylistsHelp\": \"Listy odtwarzania są prywatne. Tylko użytkownik, który je utworzył, może je zobaczyć.\",\n  \"MessageNotYetImplemented\": \"Jeszcze nie zaimplementowane\",\n  \"MessageOpmlPreviewNote\": \"Uwaga: To jest podgląd sparsowanego pliku OPML. Tytuł podcastu wzięty został z wątku RSS.\",\n  \"MessageOr\": \"lub\",\n  \"MessagePauseChapter\": \"Zatrzymaj odtwarzanie rozdziały\",\n  \"MessagePlayChapter\": \"Rozpocznij odtwarzanie od początku rozdziału\",\n  \"MessagePlaylistCreateFromCollection\": \"Utwórz listę odtwarzania na podstawie kolekcji\",\n  \"MessagePleaseWait\": \"Proszę czekać...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania\",\n  \"MessagePodcastSearchField\": \"Wprowadź wyszukiwane hasło lub adres URL kanału RSS\",\n  \"MessageQuickEmbedInProgress\": \"Szybkie osadzanie w toku\",\n  \"MessageQuickEmbedQueue\": \"W kolejce do szybkiego osadzenia ({0} w kolejce)\",\n  \"MessageQuickMatchAllEpisodes\": \"Szybkie dopasowanie wszystkich odcinków\",\n  \"MessageQuickMatchDescription\": \"Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.\",\n  \"MessageRemoveChapter\": \"Usuń rozdział\",\n  \"MessageRemoveEpisodes\": \"Usuń {0} odcinków\",\n  \"MessageRemoveFromPlayerQueue\": \"Usuń z kolejki odtwarzacza\",\n  \"MessageRemoveUserWarning\": \"Czy na pewno chcesz trwale usunąć użytkownika \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Zgłoś błędy, pomysły i pomóż rozwijać aplikację na\",\n  \"MessageResetChaptersConfirm\": \"Czy na pewno chcesz zresetować rozdziały i cofnąć wprowadzone zmiany?\",\n  \"MessageRestoreBackupConfirm\": \"Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu\",\n  \"MessageRestoreBackupWarning\": \"Przywrócenie kopii zapasowej spowoduje nadpisanie bazy danych w folderze /config oraz okładek w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani.\",\n  \"MessageScheduleLibraryScanNote\": \"W przypadku większości użytkowników zaleca się pozostawienie tej funkcji wyłączonej i włączenie opcji monitorowania folderów. Monitor folderów automatycznie wykrywa zmiany w folderach biblioteki. Monitor folderów nie działa w przypadku wszystkich systemów plików (np. NFS), dlatego zamiast niego można używać zaplanowanych skanowań biblioteki.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Uruchom w każdy {0} o {1}\",\n  \"MessageSearchResultsFor\": \"Wyniki wyszukiwania dla\",\n  \"MessageSelected\": \"{0} wybranych\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Sekwencja serii nie może zawierać spacji\",\n  \"MessageServerCouldNotBeReached\": \"Nie udało się uzyskać połączenia z serwerem\",\n  \"MessageSetChaptersFromTracksDescription\": \"Ustaw rozdziały, używając każdego pliku audio jako rozdziału, a tytuł rozdziału jako nazwy pliku audio.\",\n  \"MessageShareExpirationWillBe\": \"Czas udostępniania <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Wygaśnie za {0}\",\n  \"MessageShareURLWillBe\": \"Udostępnione pod linkiem <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Rozpoczęcie odtwarzania \\\"{0}\\\" od {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Plik audio \\\"{0}\\\" jest niemodyfikowalny\",\n  \"MessageTaskCanceledByUser\": \"Zadanie anulowane przez użytkownika\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Ściąganie odcinka \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Wbudowywanie medatanych\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Wbudowywanie metadanych do audiobooka \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Kodowanie M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Konwersja audiobooka \\\"{0}\\\" do pojedynczego pliku m4b\",\n  \"MessageTaskFailed\": \"Niepowodzenie\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Nieudana próba wykonania kopii zapasowego pliku audio \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Nie udało się utworzyć katalogu cache\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Nie udało się wbudować metadanych do pliku \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Nie udało się połączyć plików audio\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Nie udało się przenieść pliku m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Niepowodzenie zapisania pliku metadanych\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Pasujące książki w bibliotece \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Brak plików do skanowania\",\n  \"MessageTaskOpmlImport\": \"Importuj OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Tworzenie {0} podcastów z kanałów RSS\",\n  \"MessageTaskOpmlImportFeed\": \"Importuje plik OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importowanie kanału RSS „{0}”\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Nie udało się pobrać kanału podcastowego\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Tworzenie podcastu \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast już istnieje pod podaną ścieżką\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Nie udało się utworzyć podcastu\",\n  \"MessageTaskOpmlImportFinished\": \"Dodano {0} podcastów\",\n  \"MessageTaskOpmlParseFailed\": \"Błąd parsowania pliku OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"Nieprawidłowy plik OPML. Nie znaleziono tagu <opml> LUB nie znaleziono tagu <outline>.\",\n  \"MessageTaskOpmlParseNoneFound\": \"Nie znaleziono kanałów w pliku OPML\",\n  \"MessageTaskScanItemsAdded\": \"Dodano {0}\",\n  \"MessageTaskScanItemsMissing\": \"Brakuje {0}\",\n  \"MessageTaskScanItemsUpdated\": \"Zaktualizowano {0}\",\n  \"MessageTaskScanNoChangesNeeded\": \"Brak zmian\",\n  \"MessageTaskScanningFileChanges\": \"Skanowanie zmian w plikach w „{0}”\",\n  \"MessageTaskScanningLibrary\": \"Skanowanie biblioteki \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Brak prawa zapisu do folderu docelowego\",\n  \"MessageThinking\": \"Myślę...\",\n  \"MessageUploaderItemFailed\": \"Nie udało się przesłać\",\n  \"MessageUploaderItemSuccess\": \"Przesłanie powiodło się!\",\n  \"MessageUploading\": \"Przesyłanie...\",\n  \"MessageValidCronExpression\": \"Sprawdź wyrażenie CRON\",\n  \"MessageWatcherIsDisabledGlobally\": \"Watcher jest wyłączony globalnie w ustawieniach serwera\",\n  \"MessageXLibraryIsEmpty\": \"{0} Biblioteka jest pusta!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Czas trwania Twojego audiobooka jest dłuższy niż znaleziony czas trwania\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Czas trwania Twojego audiobooka jest krótszy niż znaleziony czas trwania\",\n  \"NoteChangeRootPassword\": \"Tylko użytkownik root, może posiadać puste hasło\",\n  \"NoteChapterEditorTimes\": \"Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.\",\n  \"NoteFolderPicker\": \"Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.\",\n  \"NotificationOnTestDescription\": \"Zdarzenie używane do testowania systemu powiadomień\",\n  \"PlaceholderBulkChapterInput\": \"Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)\",\n  \"PlaceholderNewCollection\": \"Nowa nazwa kolekcji\",\n  \"PlaceholderNewFolderPath\": \"Nowa ścieżka folderu\",\n  \"PlaceholderNewPlaylist\": \"Nowa nazwa playlisty\",\n  \"PlaceholderSearch\": \"Szukanie..\",\n  \"PlaceholderSearchEpisode\": \"Szukanie odcinka..\",\n  \"StatsAuthorsAdded\": \"dodano autorów\",\n  \"StatsBooksAdded\": \"dodano książki\",\n  \"StatsBooksFinished\": \"ukończone książki\",\n  \"StatsBooksFinishedThisYear\": \"Wybrane książki ukończone w tym roku…\",\n  \"StatsBooksListenedTo\": \"książki wysłuchane\",\n  \"StatsCollectionGrewTo\": \"Twoja kolekcja książek wzrosła do…\",\n  \"StatsSessions\": \"sesje\",\n  \"StatsSpentListening\": \"spędzono na słuchaniu\",\n  \"StatsTopAuthor\": \"TOPOWY AUTOR\",\n  \"StatsTopAuthors\": \"TOPOWI AUTORZY\",\n  \"StatsTopGenre\": \"TOPOWY GATUNEK\",\n  \"StatsTopGenres\": \"TOPOWE GATUNKI\",\n  \"StatsTopMonth\": \"TOPOWY MIESIĄC\",\n  \"StatsTopNarrator\": \"TOPOWY NARRATOR\",\n  \"StatsTopNarrators\": \"TOPOWI NARRATORZY\",\n  \"StatsTotalDuration\": \"O sumarycznej długości…\",\n  \"StatsYearInReview\": \"PRZEGLĄD ROKU\",\n  \"ToastAccountUpdateSuccess\": \"Zaktualizowano konto\",\n  \"ToastAsinRequired\": \"ASIN jest wymagany\",\n  \"ToastAuthorImageRemoveSuccess\": \"Zdjęcie autora usunięte\",\n  \"ToastAuthorNotFound\": \"Autor \\\"{0}\\\" nie został znaleziony\",\n  \"ToastAuthorRemoveSuccess\": \"Autor usunięty\",\n  \"ToastAuthorSearchNotFound\": \"Autor nie odnaleziony\",\n  \"ToastAuthorUpdateMerged\": \"Autor scalony\",\n  \"ToastAuthorUpdateSuccess\": \"Autor zaktualizowany\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor zaktualizowany (nie znaleziono obrazu)\",\n  \"ToastBackupAppliedSuccess\": \"Kopia zapasowa została przywrócona\",\n  \"ToastBackupCreateFailed\": \"Nie udało się utworzyć kopii zapasowej\",\n  \"ToastBackupCreateSuccess\": \"Utworzono kopię zapasową\",\n  \"ToastBackupDeleteFailed\": \"Nie udało się usunąć kopii zapasowej\",\n  \"ToastBackupDeleteSuccess\": \"Udało się usunąć kopie zapasowej\",\n  \"ToastBackupInvalidMaxKeep\": \"Nieprawidłowa ilość kopii zapasowych do przechowania\",\n  \"ToastBackupInvalidMaxSize\": \"Nieprawidłowy rozmiar maksymalny kopii zapasowej\",\n  \"ToastBackupRestoreFailed\": \"Nie udało się przywrócić kopii zapasowej\",\n  \"ToastBackupUploadFailed\": \"Nie udało się przesłać kopii zapasowej\",\n  \"ToastBackupUploadSuccess\": \"Kopia zapasowa została przesłana\",\n  \"ToastBatchDeleteFailed\": \"Usuwanie zbiorcze nie powiodło się\",\n  \"ToastBatchDeleteSuccess\": \"Usuwanie zbiorcze powiodło się\",\n  \"ToastBatchUpdateFailed\": \"Aktualizacja zbiorcza nie powiodła się\",\n  \"ToastBatchUpdateSuccess\": \"Aktualizacja zbiorcza powiodła się\",\n  \"ToastBookmarkCreateFailed\": \"Nie udało się utworzyć zakładki\",\n  \"ToastBookmarkCreateSuccess\": \"Dodano zakładkę\",\n  \"ToastBookmarkRemoveSuccess\": \"Zakładka została usunięta\",\n  \"ToastBulkChapterInvalidCount\": \"Wprowadź liczbę z przedziału od 1 do 150\",\n  \"ToastCachePurgeFailed\": \"Nie udało się wyczyścić pamięci cache\",\n  \"ToastCachePurgeSuccess\": \"Wyczyszczono pamięć cache\",\n  \"ToastChapterLocked\": \"Rozdział jest zablokowany.\",\n  \"ToastChapterStartTimeAdjusted\": \"Czas rozpoczęcia rozdziału przesunięty o \\\"{0}\\\" sekund\",\n  \"ToastChaptersAllLocked\": \"Wszystkie rozdziały są zablokowane. Odblokuj edycję, aby użyć przesunięcia czasowego.\",\n  \"ToastChaptersHaveErrors\": \"Rozdziały posiadają błędy\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Niepoprawna wartość przesunięcia. Czas rozpoczęcia ostatniego rozdziału wykroczyłby poza długość tego audiobooka.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Niepoprawna wartość przesunięcia. Pierwszy rozdział miałby długość mniejszą lub równą zeru oraz on zostałby nadpisany przez rozdział drugi. Ustaw późniejszy czas rozpoczęcia drugiego rozdziału.\",\n  \"ToastChaptersMustHaveTitles\": \"Rozdziały muszą posiadać tytuł\",\n  \"ToastChaptersRemoved\": \"Rozdziały usunięte\",\n  \"ToastChaptersUpdated\": \"Rozdziały zaktualizowane\",\n  \"ToastCollectionItemsAddFailed\": \"Dodanie elementów do kolekcji nie powiodło się\",\n  \"ToastCollectionRemoveSuccess\": \"Kolekcja usunięta\",\n  \"ToastCollectionUpdateSuccess\": \"Zaktualizowano kolekcję\",\n  \"ToastConnectionNotAvailable\": \"Brak połączenia. Spróbuj ponownie później\",\n  \"ToastCoverSearchFailed\": \"Nieudane wyszukiwanie okładki\",\n  \"ToastCoverUpdateFailed\": \"Nieudana aktualizacja okładki\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Niepoprawna data i czas\",\n  \"ToastDeleteFileFailed\": \"Usunięcie pliku nie powiodło się\",\n  \"ToastDeleteFileSuccess\": \"Plik został usunięty\",\n  \"ToastDeviceAddFailed\": \"Nieudana próba dodania urządzenia\",\n  \"ToastDeviceNameAlreadyExists\": \"Czytnik z taką nazwą już istnieje\",\n  \"ToastDeviceTestEmailFailed\": \"NIeudana próba wysłania testowego maila\",\n  \"ToastDeviceTestEmailSuccess\": \"Testowy email został wysłany\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Ustawienia email zaktualizowane\",\n  \"ToastEncodeCancelFailed\": \"Nie udało się anulować kodowania\",\n  \"ToastEncodeCancelSucces\": \"Kodowanie anulowane\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Nie udało się wyczyścić kolejki\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Wyczyszczono kolejkę epizodów do ściągnięcia\",\n  \"ToastEpisodeUpdateSuccess\": \"Zaktualizowano {0} odcinków\",\n  \"ToastErrorCannotShare\": \"Nie można udostępniać natywnie na tym urządzeniu.\",\n  \"ToastInvalidImageUrl\": \"Nieprawidłowy URL obrazu\",\n  \"ToastInvalidUrl\": \"Nieprawidłowy URL\",\n  \"ToastInvalidUrls\": \"Jeden lub więcej URL-i są nieprawidłowe\",\n  \"ToastItemCoverUpdateSuccess\": \"Zaktualizowano okładkę\",\n  \"ToastItemDeletedFailed\": \"Nie udało się usunąć elementu\",\n  \"ToastItemDeletedSuccess\": \"Element usunięty\",\n  \"ToastItemDetailsUpdateSuccess\": \"Zaktualizowano szczegóły\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Nie udało się oznaczyć jako ukończone\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Pozycja oznaczona jako ukończona\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Oznaczenie pozycji jako ukończonej nie powiodło się\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Pozycja oznaczona jako nieukończona\",\n  \"ToastLibraryCreateFailed\": \"Nie udało się utworzyć biblioteki\",\n  \"ToastLibraryCreateSuccess\": \"Biblioteka \\\"{0}\\\" stworzona\",\n  \"ToastLibraryDeleteFailed\": \"Nie udało się usunąć biblioteki\",\n  \"ToastLibraryDeleteSuccess\": \"Biblioteka usunięta\",\n  \"ToastLibraryScanFailedToStart\": \"Nie udało się rozpocząć skanowania\",\n  \"ToastLibraryScanStarted\": \"Rozpoczęto skanowanie biblioteki\",\n  \"ToastLibraryUpdateSuccess\": \"Zaktualizowano \\\"{0}\\\" pozycji\",\n  \"ToastMatchAllAuthorsFailed\": \"Nie udało się dopasować wszystkich autorów\",\n  \"ToastMustHaveAtLeastOnePath\": \"Musi mieć przynajmniej jedną ścieżkę\",\n  \"ToastNameEmailRequired\": \"Nazwa i email są wymagane\",\n  \"ToastNameRequired\": \"Imię jest wymagane\",\n  \"ToastNewApiKeyUserError\": \"Trzeba wybrać użytkownika\",\n  \"ToastNewEpisodesFound\": \"Znaleziono {0} nowych odcinków\",\n  \"ToastNewUserCreatedFailed\": \"Nie udało się utworzyć konta: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Utworzono nowe konto\",\n  \"ToastNewUserLibraryError\": \"Trzeba wybrać co najmniej jedną bibliotekę\",\n  \"ToastNewUserPasswordError\": \"Hasło jest wymagane, jedynie użytkownik \\\"root\\\" może posiadać puste hasło\",\n  \"ToastNewUserTagError\": \"Trzeba wybrać chociaż jeden tag\",\n  \"ToastNewUserUsernameError\": \"Wprowadź nazwę użytkownika\",\n  \"ToastNoNewEpisodesFound\": \"Nie znaleziono nowych odcinków\",\n  \"ToastNoRSSFeed\": \"Podcast nie posiada RSS Feed\",\n  \"ToastNotificationFailedMaximum\": \"Maks. ilość nieudanych prób musi być >= 0\",\n  \"ToastPlaylistCreateFailed\": \"Nie udało się utworzyć playlisty\",\n  \"ToastPlaylistCreateSuccess\": \"Playlista utworzona\",\n  \"ToastPlaylistRemoveSuccess\": \"Playlista usunięta\",\n  \"ToastPlaylistUpdateSuccess\": \"Playlista zaktualizowana\",\n  \"ToastPodcastCreateFailed\": \"Nie udało się utworzyć podcastu\",\n  \"ToastPodcastCreateSuccess\": \"Podcast został pomyślnie utworzony\",\n  \"ToastPodcastEpisodeUpdated\": \"Zaktualizowano odcinki\",\n  \"ToastRSSFeedCloseFailed\": \"Zamknięcie kanału RSS nie powiodło się\",\n  \"ToastRSSFeedCloseSuccess\": \"Zamknięcie kanału RSS powiodło się\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Nie udało się usunąć elementu z kolekcji\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Pozycja usunięta z kolekcji\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Nie udało się usunąć wadliwych elementów z biblioteki\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Usunięto wadliwe elementy z biblioteki\",\n  \"ToastRenameFailed\": \"Nie udało się zmienić nazwy\",\n  \"ToastRescanFailed\": \"Ponowne skanowanie nie powiodło się dla {0}\",\n  \"ToastRescanRemoved\": \"Ponowne skanowanie powiodło się – element został usunięty\",\n  \"ToastRescanUpToDate\": \"Ponowne skanowanie powiodło się – element był aktualny\",\n  \"ToastRescanUpdated\": \"Ponowne skanowanie powiodło się – element został zaktualizowany\",\n  \"ToastScanFailed\": \"Nie powiódł się skan elementu biblioteki\",\n  \"ToastSelectAtLeastOneUser\": \"Zaznacz co najmniej jednego użytkownika\",\n  \"ToastSendEbookToDeviceFailed\": \"Failed to Send Ebook to device\",\n  \"ToastSendEbookToDeviceSuccess\": \"Ebook wysłany na urządzenie \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Nie można dodać dwóch serii pod tą samą nazwą\",\n  \"ToastSeriesUpdateFailed\": \"Aktualizacja serii nie powiodła się\",\n  \"ToastSeriesUpdateSuccess\": \"Aktualizacja serii powiodła się\",\n  \"ToastServerSettingsUpdateSuccess\": \"Zaktualizowano ustawienia serwera\",\n  \"ToastSessionCloseFailed\": \"Nie udało się zamknąć sesji\",\n  \"ToastSessionDeleteFailed\": \"Nie udało się usunąć sesji\",\n  \"ToastSessionDeleteSuccess\": \"Sesja usunięta\",\n  \"ToastSleepTimerDone\": \"Słodkich snów... zZzzZz\",\n  \"ToastSocketConnected\": \"Nawiązano połączenie z serwerem\",\n  \"ToastSocketDisconnected\": \"Połączenie z serwerem zostało zamknięte\",\n  \"ToastSocketFailedToConnect\": \"Poączenie z serwerem nie powiodło się\",\n  \"ToastTitleRequired\": \"Tytuł jest wymagany\",\n  \"ToastUnknownError\": \"Nieznany błąd\",\n  \"ToastUnlinkOpenIdFailed\": \"Nie udało się odpiąć użytkownika z OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Użytkownik odpięty z OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Ścieżka \\\"{0}\\\" już istnieje na serwerze\",\n  \"ToastUserDeleteFailed\": \"Nie udało się usunąć użytkownika\",\n  \"ToastUserDeleteSuccess\": \"Użytkownik usunięty\",\n  \"TooltipAddChapters\": \"Dodaj rozdział(y)\",\n  \"TooltipAddOneSecond\": \"Dodaj sekundę\",\n  \"TooltipAdjustChapterStart\": \"Kliknij, aby skorygować czas początkowy\",\n  \"TooltipLockAllChapters\": \"Zablokuj wszystkie rozdziały\",\n  \"TooltipLockChapter\": \"Zablokuj rozdział (przytrzymaj Shift i kliknij, aby zaznaczyć zakres)\",\n  \"TooltipSubtractOneSecond\": \"Odejmij sekundę\",\n  \"TooltipUnlockAllChapters\": \"Odblokuj wszystkie rozdziały\",\n  \"TooltipUnlockChapter\": \"Odblokuj rozdział (przytrzymaj Shift i kliknij, aby zaznaczyć zakres)\"\n}\n"
  },
  {
    "path": "client/strings/pt-br.json",
    "content": "{\n  \"ButtonAdd\": \"Adicionar\",\n  \"ButtonAddApiKey\": \"Adicionar chave de API\",\n  \"ButtonAddChapters\": \"Adicionar Capítulos\",\n  \"ButtonAddDevice\": \"Adicionar Dispositivo\",\n  \"ButtonAddLibrary\": \"Adicionar Biblioteca\",\n  \"ButtonAddPodcasts\": \"Adicionar Podcasts\",\n  \"ButtonAddUser\": \"Adicionar Usuário\",\n  \"ButtonAddYourFirstLibrary\": \"Adicionar sua primeira biblioteca\",\n  \"ButtonApply\": \"Aplicar\",\n  \"ButtonApplyChapters\": \"Aplicar Capítulos\",\n  \"ButtonAuthors\": \"Autores\",\n  \"ButtonBack\": \"Voltar\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Popular de um existente\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Popular mapeamento de detalhes\",\n  \"ButtonBrowseForFolder\": \"Procurar por Pasta\",\n  \"ButtonCancel\": \"Cancelar\",\n  \"ButtonCancelEncode\": \"Cancelar Codificação\",\n  \"ButtonChangeRootPassword\": \"Alterar senha do administrador\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Verificar & Baixar Novos Episódios\",\n  \"ButtonChooseAFolder\": \"Escolha uma pasta\",\n  \"ButtonChooseFiles\": \"Escolha arquivos\",\n  \"ButtonClearFilter\": \"Limpar Filtro\",\n  \"ButtonClose\": \"Fechar\",\n  \"ButtonCloseFeed\": \"Fechar Feed\",\n  \"ButtonCloseSession\": \"Fechar Sessão Aberta\",\n  \"ButtonCollections\": \"Coleções\",\n  \"ButtonConfigureScanner\": \"Configurar Verificador\",\n  \"ButtonCreate\": \"Criar\",\n  \"ButtonCreateBackup\": \"Criar Backup\",\n  \"ButtonDelete\": \"Apagar\",\n  \"ButtonDownloadQueue\": \"Fila de download\",\n  \"ButtonEdit\": \"Editar\",\n  \"ButtonEditChapters\": \"Editar Capítulos\",\n  \"ButtonEditPodcast\": \"Editar Podcast\",\n  \"ButtonEnable\": \"Ativar\",\n  \"ButtonFireAndFail\": \"Disparar e Falhar\",\n  \"ButtonFireOnTest\": \"Disparar evento onTest\",\n  \"ButtonForceReScan\": \"Forcar Nova Verificação\",\n  \"ButtonFullPath\": \"Caminho Completo\",\n  \"ButtonHide\": \"Ocultar\",\n  \"ButtonHome\": \"Principal\",\n  \"ButtonIssues\": \"Problemas\",\n  \"ButtonJumpBackward\": \"Retroceder\",\n  \"ButtonJumpForward\": \"Adiantar\",\n  \"ButtonLatest\": \"Mais Recentes\",\n  \"ButtonLibrary\": \"Biblioteca\",\n  \"ButtonLogout\": \"Logout\",\n  \"ButtonLookup\": \"Procurar\",\n  \"ButtonManageTracks\": \"Gerenciar Faixas\",\n  \"ButtonMapChapterTitles\": \"Designar Títulos de Capítulos\",\n  \"ButtonMatchAllAuthors\": \"Consultar Todos os Autores\",\n  \"ButtonMatchBooks\": \"Consultar Livros\",\n  \"ButtonNevermind\": \"Cancelar\",\n  \"ButtonNext\": \"Próximo\",\n  \"ButtonNextChapter\": \"Próximo Capítulo\",\n  \"ButtonNextItemInQueue\": \"Próximo Item na Fila\",\n  \"ButtonOk\": \"Ok\",\n  \"ButtonOpenFeed\": \"Abrir Feed\",\n  \"ButtonOpenManager\": \"Abrir Gerenciador\",\n  \"ButtonPause\": \"Pausar\",\n  \"ButtonPlay\": \"Reproduzir\",\n  \"ButtonPlayAll\": \"Reproduzir Tudo\",\n  \"ButtonPlaying\": \"Reproduzindo\",\n  \"ButtonPlaylists\": \"Lista de Reprodução\",\n  \"ButtonPrevious\": \"Anterior\",\n  \"ButtonPreviousChapter\": \"Capítulo Anterior\",\n  \"ButtonProbeAudioFile\": \"Sondar Arquivo de Áudio\",\n  \"ButtonPurgeAllCache\": \"Apagar Todo o Cache\",\n  \"ButtonPurgeItemsCache\": \"Apagar o Cache de Itens\",\n  \"ButtonQueueAddItem\": \"Adicionar à Lista\",\n  \"ButtonQueueRemoveItem\": \"Remover da Lista\",\n  \"ButtonQuickEmbed\": \"Incorporação Rápida\",\n  \"ButtonQuickEmbedMetadata\": \"Incorporação Rápida de Metadata\",\n  \"ButtonQuickMatch\": \"Consulta rápida\",\n  \"ButtonReScan\": \"Nova Verificação\",\n  \"ButtonRead\": \"Ler\",\n  \"ButtonReadLess\": \"Ler menos\",\n  \"ButtonReadMore\": \"Ler mais\",\n  \"ButtonRefresh\": \"Atualizar\",\n  \"ButtonRemove\": \"Remover\",\n  \"ButtonRemoveAll\": \"Remover Todos\",\n  \"ButtonRemoveAllLibraryItems\": \"Remover Todos os Itens da Biblioteca\",\n  \"ButtonRemoveFromContinueListening\": \"Remover de Continuar Escutando\",\n  \"ButtonRemoveFromContinueReading\": \"Remover de Continuar Lendo\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Remover Série de Continuar Série\",\n  \"ButtonReset\": \"Resetar\",\n  \"ButtonResetToDefault\": \"Resetar para valores padrão\",\n  \"ButtonRestore\": \"Restaurar\",\n  \"ButtonSave\": \"Salvar\",\n  \"ButtonSaveAndClose\": \"Salvar & Fechar\",\n  \"ButtonSaveTracklist\": \"Salvar Lista de Faixas\",\n  \"ButtonScan\": \"Verificar\",\n  \"ButtonScanLibrary\": \"Verificar Biblioteca\",\n  \"ButtonScrollLeft\": \"Arrastar para Esquerda\",\n  \"ButtonScrollRight\": \"Arrastar para Direita\",\n  \"ButtonSearch\": \"Pesquisar\",\n  \"ButtonSelectFolderPath\": \"Selecionar Caminho da Pasta\",\n  \"ButtonSeries\": \"Séries\",\n  \"ButtonSetChaptersFromTracks\": \"Definir Capítulos Segundo Faixas\",\n  \"ButtonShare\": \"Compartilhar\",\n  \"ButtonShiftTimes\": \"Deslocar tempos\",\n  \"ButtonShow\": \"Exibir\",\n  \"ButtonStartM4BEncode\": \"Iniciar Codificação M4B\",\n  \"ButtonStartMetadataEmbed\": \"Iniciar Inclusão de Metadados\",\n  \"ButtonStats\": \"Estatísticas\",\n  \"ButtonSubmit\": \"Enviar\",\n  \"ButtonTest\": \"Testar\",\n  \"ButtonUnlinkOpenId\": \"Desvincular OpenID\",\n  \"ButtonUpload\": \"Fazer Upload\",\n  \"ButtonUploadBackup\": \"Upload de Backup\",\n  \"ButtonUploadCover\": \"Upload de Capa\",\n  \"ButtonUploadOPMLFile\": \"Upload Arquivo OPML\",\n  \"ButtonUserDelete\": \"Apagar usuário {0}\",\n  \"ButtonUserEdit\": \"Editar usuário {0}\",\n  \"ButtonViewAll\": \"Ver tudo\",\n  \"ButtonYes\": \"Sim\",\n  \"ErrorUploadFetchMetadataAPI\": \"Erro buscando metadados\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Não foi possível buscar metadados - tente atualizar o título e/ou autor\",\n  \"ErrorUploadLacksTitle\": \"É preciso ter um título\",\n  \"HeaderAccount\": \"Conta\",\n  \"HeaderAddCustomMetadataProvider\": \"Adicionar Provedor de Metadados Personalizado\",\n  \"HeaderAdvanced\": \"Avançado\",\n  \"HeaderApiKeys\": \"Chaves de API\",\n  \"HeaderAppriseNotificationSettings\": \"Configuração de notificações Apprise\",\n  \"HeaderAudioTracks\": \"Trilhas de áudio\",\n  \"HeaderAudiobookTools\": \"Ferramentas de Gerenciamento de Arquivos de Audiobooks\",\n  \"HeaderAuthentication\": \"Autenticação\",\n  \"HeaderBackups\": \"Backups\",\n  \"HeaderBulkChapterModal\": \"Adicionar vários capítulos\",\n  \"HeaderChangePassword\": \"Trocar Senha\",\n  \"HeaderChapters\": \"Capítulos\",\n  \"HeaderChooseAFolder\": \"Escolha uma Pasta\",\n  \"HeaderCollection\": \"Coleção\",\n  \"HeaderCollectionItems\": \"Itens da Coleção\",\n  \"HeaderCover\": \"Capas\",\n  \"HeaderCurrentDownloads\": \"Downloads em andamento\",\n  \"HeaderCustomMessageOnLogin\": \"Mensagem personalizada no login\",\n  \"HeaderCustomMetadataProviders\": \"Fontes de Metadados Customizados\",\n  \"HeaderDetails\": \"Detalhes\",\n  \"HeaderDownloadQueue\": \"Fila de Download\",\n  \"HeaderEbookFiles\": \"Arquivos Ebook\",\n  \"HeaderEmail\": \"Email\",\n  \"HeaderEmailSettings\": \"Configurações de Email\",\n  \"HeaderEpisodes\": \"Episódios\",\n  \"HeaderEreaderDevices\": \"Dispositivos leitores de ebook\",\n  \"HeaderEreaderSettings\": \"Configurações do leitor de ebook\",\n  \"HeaderFiles\": \"Arquivos\",\n  \"HeaderFindChapters\": \"Localizar Capítulos\",\n  \"HeaderIgnoredFiles\": \"Arquivos Ignorados\",\n  \"HeaderItemFiles\": \"Arquivos de Itens\",\n  \"HeaderItemMetadataUtils\": \"Utilidades para Metadados dos Itens\",\n  \"HeaderLastListeningSession\": \"Última sessão\",\n  \"HeaderLatestEpisodes\": \"Últimos episódios\",\n  \"HeaderLibraries\": \"Bibliotecas\",\n  \"HeaderLibraryFiles\": \"Arquivos da Biblioteca\",\n  \"HeaderLibraryStats\": \"Estatísticas da Biblioteca\",\n  \"HeaderListeningSessions\": \"Sessões\",\n  \"HeaderListeningStats\": \"Estatísticas\",\n  \"HeaderLogin\": \"Login\",\n  \"HeaderLogs\": \"Logs\",\n  \"HeaderManageGenres\": \"Gerenciar Gêneros\",\n  \"HeaderManageTags\": \"Gerenciar Etiquetas\",\n  \"HeaderMapDetails\": \"Designar Detalhes\",\n  \"HeaderMatch\": \"Consultar\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Ordem de Prioridade dos Metadados\",\n  \"HeaderMetadataToEmbed\": \"Metadados a Serem Incluídos\",\n  \"HeaderNewAccount\": \"Nova Conta\",\n  \"HeaderNewApiKey\": \"Nova chave de API\",\n  \"HeaderNewLibrary\": \"Nova Biblioteca\",\n  \"HeaderNotificationCreate\": \"Criar Notificação\",\n  \"HeaderNotificationUpdate\": \"Atualizar Notificação\",\n  \"HeaderNotifications\": \"Notificações\",\n  \"HeaderOpenIDConnectAuthentication\": \"Autenticação via OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Abrir Sessões de Escuta\",\n  \"HeaderOpenRSSFeed\": \"Abrir Feed RSS\",\n  \"HeaderOtherFiles\": \"Outros Arquivos\",\n  \"HeaderPasswordAuthentication\": \"Autenticação por Senha\",\n  \"HeaderPermissions\": \"Permissões\",\n  \"HeaderPlayerQueue\": \"Fila do reprodutor\",\n  \"HeaderPlayerSettings\": \"Configurações do Reprodutor\",\n  \"HeaderPlaylist\": \"Lista de Reprodução\",\n  \"HeaderPlaylistItems\": \"Itens da lista de reprodução\",\n  \"HeaderPodcastsToAdd\": \"Podcasts para Adicionar\",\n  \"HeaderPresets\": \"Valores predefinidos\",\n  \"HeaderPreviewCover\": \"Visualização da Capa\",\n  \"HeaderRSSFeedGeneral\": \"Detalhes RSS\",\n  \"HeaderRSSFeedIsOpen\": \"Feed RSS está Aberto\",\n  \"HeaderRSSFeeds\": \"Feeds RSS\",\n  \"HeaderRemoveEpisode\": \"Remover Episódio\",\n  \"HeaderRemoveEpisodes\": \"Remover {0} Episódios\",\n  \"HeaderSavedMediaProgress\": \"Progresso da gravação das mídias\",\n  \"HeaderSchedule\": \"Programação\",\n  \"HeaderScheduleEpisodeDownloads\": \"Programar Download Automático de Episódios\",\n  \"HeaderScheduleLibraryScans\": \"Programar Verificação Automática da Biblioteca\",\n  \"HeaderSession\": \"Sessão\",\n  \"HeaderSetBackupSchedule\": \"Definir Programação de Backup\",\n  \"HeaderSettings\": \"Configurações\",\n  \"HeaderSettingsDisplay\": \"Exibição\",\n  \"HeaderSettingsExperimental\": \"Funcionalidades experimentais\",\n  \"HeaderSettingsGeneral\": \"Geral\",\n  \"HeaderSettingsScanner\": \"Verificador\",\n  \"HeaderSettingsSecurity\": \"Segurança\",\n  \"HeaderSettingsWebClient\": \"Cliente Web\",\n  \"HeaderSleepTimer\": \"Timer\",\n  \"HeaderStatsLargestItems\": \"Maiores Itens\",\n  \"HeaderStatsLongestItems\": \"Itens mais longos (hrs)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minutos Escutados (últimos 7 dias)\",\n  \"HeaderStatsRecentSessions\": \"Sessões Recentes\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autores\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Gêneros\",\n  \"HeaderTableOfContents\": \"Sumário\",\n  \"HeaderTools\": \"Ferramentas\",\n  \"HeaderUpdateAccount\": \"Atualizar Conta\",\n  \"HeaderUpdateApiKey\": \"Atualizar Chave de API\",\n  \"HeaderUpdateAuthor\": \"Atualizar Autor\",\n  \"HeaderUpdateDetails\": \"Atualizar Detalhes\",\n  \"HeaderUpdateLibrary\": \"Atualizar Biblioteca\",\n  \"HeaderUsers\": \"Usuários\",\n  \"HeaderYearReview\": \"Retrospectiva de {0}\",\n  \"HeaderYourStats\": \"Suas Estatísticas\",\n  \"LabelAbridged\": \"Versão Abreviada\",\n  \"LabelAbridgedChecked\": \"Abreviada (verificada)\",\n  \"LabelAbridgedUnchecked\": \"Não Abreviada (não verificada)\",\n  \"LabelAccessibleBy\": \"Acessível por\",\n  \"LabelAccountType\": \"Tipo de Conta\",\n  \"LabelAccountTypeAdmin\": \"Administrador\",\n  \"LabelAccountTypeGuest\": \"Convidado\",\n  \"LabelAccountTypeUser\": \"Usuário\",\n  \"LabelActivities\": \"Atividades\",\n  \"LabelActivity\": \"Atividade\",\n  \"LabelAddToCollection\": \"Adicionar à Coleção\",\n  \"LabelAddToCollectionBatch\": \"Adicionar {0} Livros à Coleção\",\n  \"LabelAddToPlaylist\": \"Adicionar à Lista de Reprodução\",\n  \"LabelAddToPlaylistBatch\": \"Adicionar {0} itens à Lista de Reprodução\",\n  \"LabelAddedAt\": \"Acrescentado em\",\n  \"LabelAddedDate\": \"Adicionado {0}\",\n  \"LabelAdminUsersOnly\": \"Apenas usuários administradores\",\n  \"LabelAll\": \"Todos\",\n  \"LabelAllEpisodesDownloaded\": \"Todos os episódios baixados\",\n  \"LabelAllUsers\": \"Todos Usuários\",\n  \"LabelAllUsersExcludingGuests\": \"Todos usuários exceto convidados\",\n  \"LabelAllUsersIncludingGuests\": \"Todos usuários incluindo convidados\",\n  \"LabelAlreadyInYourLibrary\": \"Já na sua biblioteca\",\n  \"LabelApiKeyCreated\": \"Chave de API \\\"{0}\\\" criada com sucesso.\",\n  \"LabelApiKeyCreatedDescription\": \"Certifique-se de copiar a chave de API agora pois não será possível vê-la novamente.\",\n  \"LabelApiKeyUser\": \"Agir em nome do usuário\",\n  \"LabelApiKeyUserDescription\": \"Esta chave de API terá as mesmas permissões que o usuário em nome de quem ela está agindo. Isso aparecerá nos logs como se o usuário estivesse fazendo a solicitação.\",\n  \"LabelApiToken\": \"Token de API\",\n  \"LabelAppend\": \"Acrescentar\",\n  \"LabelAudioBitrate\": \"Bitrate de áudio (por exemplo, 128k)\",\n  \"LabelAudioChannels\": \"Canais de áudio (1 ou 2)\",\n  \"LabelAudioCodec\": \"Codec de áudio\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Nome Sobrenome)\",\n  \"LabelAuthorLastFirst\": \"Autor (Sobrenome, Nome)\",\n  \"LabelAuthors\": \"Autores\",\n  \"LabelAutoDownloadEpisodes\": \"Download Automático de Episódios\",\n  \"LabelAutoFetchMetadata\": \"Buscar Metadados Automaticamente\",\n  \"LabelAutoFetchMetadataHelp\": \"Busca metadados de título, autor e série para otimizar o upload. Pode ser necessário buscas metadados adicionais após o upload.\",\n  \"LabelAutoLaunch\": \"Iniciar Automaticamente\",\n  \"LabelAutoLaunchDescription\": \"Redireciona para o fornecedor de autenticação automaticamente ao navegar para a tela de login (caminho para substituição manual <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Registrar Automaticamente\",\n  \"LabelAutoRegisterDescription\": \"Registra automaticamente novos usuários após login\",\n  \"LabelBackToUser\": \"Voltar para Usuário\",\n  \"LabelBackupAudioFiles\": \"Backup dos Arquivos de Áudio\",\n  \"LabelBackupLocation\": \"Localização do Backup\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Backups automáticos\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Backups salvos em /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Tamanho máximo do backup (em GB) (0 para ilimitado)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Como proteção contra uma configuração incorreta, backups darão erro se excederem o tamanho configurado.\",\n  \"LabelBackupsNumberToKeep\": \"Número de backups para guardar\",\n  \"LabelBackupsNumberToKeepHelp\": \"Apenas 1 backup será removido por vez, então, se já existem mais backups, você deve apagá-los manualmente.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bônus\",\n  \"LabelBooks\": \"Livros\",\n  \"LabelButtonText\": \"Texto do botão\",\n  \"LabelByAuthor\": \"por {0}\",\n  \"LabelChangePassword\": \"Trocar Senha\",\n  \"LabelChannels\": \"Canais\",\n  \"LabelChapterCount\": \"{0} Capítulos\",\n  \"LabelChapterTitle\": \"Título do Capítulo\",\n  \"LabelChapters\": \"Capítulos\",\n  \"LabelChaptersFound\": \"capítulos encontrados\",\n  \"LabelClickForMoreInfo\": \"Clique para mais informações\",\n  \"LabelClickToUseCurrentValue\": \"Clique para usar o valor atual\",\n  \"LabelClosePlayer\": \"Fechar Reprodutor\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Fechar Série\",\n  \"LabelCollapseSubSeries\": \"Fechar Sub Séries\",\n  \"LabelCollection\": \"Coleção\",\n  \"LabelCollections\": \"Coleções\",\n  \"LabelComplete\": \"Concluído\",\n  \"LabelConfirmPassword\": \"Confirmar Senha\",\n  \"LabelContinueListening\": \"Continuar Escutando\",\n  \"LabelContinueReading\": \"Continuar Lendo\",\n  \"LabelContinueSeries\": \"Continuar Série\",\n  \"LabelCorsAllowed\": \"Origens Permitidas para CORS\",\n  \"LabelCover\": \"Capa\",\n  \"LabelCoverImageURL\": \"URL da Imagem da Capa\",\n  \"LabelCoverProvider\": \"Provedor de Capas\",\n  \"LabelCreatedAt\": \"Criado em\",\n  \"LabelCronExpression\": \"Expressão para o Cron\",\n  \"LabelCurrent\": \"Atual\",\n  \"LabelCurrently\": \"Atualmente:\",\n  \"LabelCustomCronExpression\": \"Expressão personalizada para o Cron:\",\n  \"LabelDatetime\": \"Data e Hora\",\n  \"LabelDays\": \"Dias\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Apagar do sistema de arquivos (desmarcar para remover apenas da base de dados)\",\n  \"LabelDescription\": \"Descrição\",\n  \"LabelDeselectAll\": \"Desmarcar tudo\",\n  \"LabelDetectedPattern\": \"Padrão detectado:\",\n  \"LabelDevice\": \"Dispositivo\",\n  \"LabelDeviceInfo\": \"Informação do Dispositivo\",\n  \"LabelDeviceIsAvailableTo\": \"Dispositivo está disponível para...\",\n  \"LabelDirectory\": \"Diretório\",\n  \"LabelDiscFromFilename\": \"Disco a partir do nome do arquivo\",\n  \"LabelDiscFromMetadata\": \"Disco a partir dos metadados\",\n  \"LabelDiscover\": \"Descobrir\",\n  \"LabelDownload\": \"Download\",\n  \"LabelDownloadNEpisodes\": \"Download de {0} Episódios\",\n  \"LabelDownloadable\": \"Baixável\",\n  \"LabelDuration\": \"Duração\",\n  \"LabelDurationComparisonExactMatch\": \"(exato)\",\n  \"LabelDurationComparisonLonger\": \"({0} maior)\",\n  \"LabelDurationComparisonShorter\": \"({0} menor)\",\n  \"LabelDurationFound\": \"Duração comprovada:\",\n  \"LabelEbook\": \"Ebook\",\n  \"LabelEbooks\": \"Ebooks\",\n  \"LabelEdit\": \"Editar\",\n  \"LabelEmail\": \"Email\",\n  \"LabelEmailSettingsFromAddress\": \"Remetente\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Rejeitar certificados não autorizados\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Desativar a validação de certificados SSL pode expor sua conexão a riscos de segurança, como ataques \\\"man-in-the-middle\\\". Desative essa opção apenas se entender suas consequências e se puder confiar no servidor de email ao qual você está se conectando.\",\n  \"LabelEmailSettingsSecure\": \"Seguro\",\n  \"LabelEmailSettingsSecureHelp\": \"Se ativado, a conexão utilizará TLS para a conexão ao servidor. Se desativado TLS será usado se o servidor suportar a extensão STARTTLS. Na maioria dos casos ative esse valor se estiver conectando pela porta 465. Para portas 587 ou 25, mantenha inativo. (de nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Endereço de teste\",\n  \"LabelEmbeddedCover\": \"Capa Integrada\",\n  \"LabelEnable\": \"Habilitar\",\n  \"LabelEncodingBackupLocation\": \"Um backup dos seus arquivos de áudio original será gravado em:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Capítulos não são integrados em audiobooks com várias trilhas.\",\n  \"LabelEncodingClearItemCache\": \"Certifique-se de, periodicamente, apagar os itens do cache.\",\n  \"LabelEncodingFinishedM4B\": \"O arquivo M4B final será colocado na sua pasta de audiobooks em:\",\n  \"LabelEncodingInfoEmbedded\": \"Os metadados serão integrados nas trilhas de áudio dentro da sua pasta de audiobooks.\",\n  \"LabelEncodingStartedNavigation\": \"Assim que a tarefa for iniciada você pode sair dessa página.\",\n  \"LabelEncodingTimeWarning\": \"A codificação pode durar até 30 minutos.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Aviso: não atualize essas configurações se não estiver familiarizado com as opções de codificação do ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Se você desabilitou o monitoramento, será necessário fazer uma nova verificação deste audiobook depois.\",\n  \"LabelEnd\": \"Fim\",\n  \"LabelEndOfChapter\": \"Fim do Capítulo\",\n  \"LabelEpisode\": \"Episódio\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episódio não vinculado ao feed RSS\",\n  \"LabelEpisodeNumber\": \"Episódio #{0}\",\n  \"LabelEpisodeTitle\": \"Título do Episódio\",\n  \"LabelEpisodeType\": \"Tipo do Episódio\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL do episódio a partir do feed RSS\",\n  \"LabelEpisodes\": \"Episódios\",\n  \"LabelEpisodic\": \"Episódico\",\n  \"LabelExample\": \"Exemplo\",\n  \"LabelExpandSeries\": \"Expandir Série\",\n  \"LabelExpandSubSeries\": \"Expandir Subséries\",\n  \"LabelExpired\": \"Expirado\",\n  \"LabelExpiresAt\": \"Expira às\",\n  \"LabelExpiresInSeconds\": \"Expira em (segundos)\",\n  \"LabelExpiresNever\": \"Nunca\",\n  \"LabelExplicit\": \"Explícito\",\n  \"LabelExplicitChecked\": \"Explícito (verificado)\",\n  \"LabelExplicitUnchecked\": \"Não explícito (não verificado)\",\n  \"LabelExportOPML\": \"Exportar OPML\",\n  \"LabelFeedURL\": \"URL do Feed\",\n  \"LabelFetchingMetadata\": \"Buscando Metadados\",\n  \"LabelFile\": \"Arquivo\",\n  \"LabelFileBirthtime\": \"Criação do Arquivo\",\n  \"LabelFileBornDate\": \"Criado {0}\",\n  \"LabelFileModified\": \"Modificação do Arquivo\",\n  \"LabelFileModifiedDate\": \"Modificado {0}\",\n  \"LabelFilename\": \"Nome do Arquivo\",\n  \"LabelFilterByUser\": \"Filtrar por Usuário\",\n  \"LabelFindEpisodes\": \"Localizar Episódios\",\n  \"LabelFinished\": \"Concluído\",\n  \"LabelFinishedDate\": \"Concluído {0}\",\n  \"LabelFolder\": \"Pasta\",\n  \"LabelFolders\": \"Pastas\",\n  \"LabelFontBold\": \"Negrito\",\n  \"LabelFontBoldness\": \"Intensidade do negrito\",\n  \"LabelFontFamily\": \"Família de fontes\",\n  \"LabelFontItalic\": \"Itálico\",\n  \"LabelFontScale\": \"Escala de fonte\",\n  \"LabelFontStrikethrough\": \"Tachado\",\n  \"LabelFormat\": \"Formato\",\n  \"LabelFull\": \"Cheio\",\n  \"LabelGenre\": \"Gênero\",\n  \"LabelGenres\": \"Gêneros\",\n  \"LabelHardDeleteFile\": \"Apagar definitivamente\",\n  \"LabelHasEbook\": \"Tem ebook\",\n  \"LabelHasSupplementaryEbook\": \"Tem ebook complementar\",\n  \"LabelHideSubtitles\": \"Esconder Legendas\",\n  \"LabelHighestPriority\": \"Prioridade mais alta\",\n  \"LabelHost\": \"Host\",\n  \"LabelHour\": \"Hora\",\n  \"LabelHours\": \"Horas\",\n  \"LabelIcon\": \"Ícone\",\n  \"LabelImageURLFromTheWeb\": \"URL da imagem na internet\",\n  \"LabelInProgress\": \"Em Andamento\",\n  \"LabelIncludeInTracklist\": \"Incluir na Lista de Faixas\",\n  \"LabelIncomplete\": \"Incompleto\",\n  \"LabelInterval\": \"Intervalo\",\n  \"LabelIntervalCustomDailyWeekly\": \"Personalizar diário/semanal\",\n  \"LabelIntervalEvery12Hours\": \"A cada 12 horas\",\n  \"LabelIntervalEvery15Minutes\": \"A cada 15 minutos\",\n  \"LabelIntervalEvery2Hours\": \"A cada 2 horas\",\n  \"LabelIntervalEvery30Minutes\": \"A cada 30 minutos\",\n  \"LabelIntervalEvery6Hours\": \"A cada 6 horas\",\n  \"LabelIntervalEveryDay\": \"Todo dia\",\n  \"LabelIntervalEveryHour\": \"Toda hora\",\n  \"LabelIntervalEveryMinute\": \"A cada minuto\",\n  \"LabelInvert\": \"Inverter\",\n  \"LabelItem\": \"Item\",\n  \"LabelJumpBackwardAmount\": \"Tempo de retrocesso\",\n  \"LabelJumpForwardAmount\": \"Tempo de avanço\",\n  \"LabelLanguage\": \"Idioma\",\n  \"LabelLanguageDefaultServer\": \"Idioma Padrão do Servidor\",\n  \"LabelLanguages\": \"Idiomas\",\n  \"LabelLastBookAdded\": \"Último Livro Acrescentado\",\n  \"LabelLastBookUpdated\": \"Último Livro Atualizado\",\n  \"LabelLastProgressDate\": \"Último progresso: {0}\",\n  \"LabelLastSeen\": \"Visto pela Última Vez\",\n  \"LabelLastTime\": \"Progresso\",\n  \"LabelLastUpdate\": \"Última Atualização\",\n  \"LabelLayout\": \"Layout\",\n  \"LabelLayoutSinglePage\": \"Uma página\",\n  \"LabelLayoutSplitPage\": \"Página dividida\",\n  \"LabelLess\": \"Menos\",\n  \"LabelLibrariesAccessibleToUser\": \"Bibliotecas Acessíveis ao Usuário\",\n  \"LabelLibrary\": \"Biblioteca\",\n  \"LabelLibraryFilterSublistEmpty\": \"Sem {0}\",\n  \"LabelLibraryItem\": \"Item da Biblioteca\",\n  \"LabelLibraryName\": \"Nome da Biblioteca\",\n  \"LabelLibrarySortByProgress\": \"Progresso: Ultima Atualização\",\n  \"LabelLibrarySortByProgressFinished\": \"Progresso: Terminado\",\n  \"LabelLibrarySortByProgressStarted\": \"Progresso: Iniciado\",\n  \"LabelLimit\": \"Limite\",\n  \"LabelLineSpacing\": \"Espaçamento entre linhas\",\n  \"LabelListenAgain\": \"Escutar novamente\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Atenção\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Procurar por novos Episódios após essa data\",\n  \"LabelLowestPriority\": \"Prioridade mais baixa\",\n  \"LabelMatchConfidence\": \"Confiabilidade\",\n  \"LabelMatchExistingUsersBy\": \"Consultar usuários existentes usando\",\n  \"LabelMatchExistingUsersByDescription\": \"Utilizado para conectar usuários já existentes. Uma vez conectados, usuários serão consultados utilizando uma identificação única do seu provedor de SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Número máximo de episódios para baixar. Use 0 para download ilimitado.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Número máximo de novos episódios para baixar por verificação\",\n  \"LabelMaxEpisodesToKeep\": \"Número máximo de episódios para manter\",\n  \"LabelMaxEpisodesToKeepHelp\": \"O valor 0 define que não há limite máximo. Após um novo episódio ser baixado automaticamente, o episódio mais antigo será excluído caso você tenha mais de X episódios. Apenas um episódio será excluído a cada novo download.\",\n  \"LabelMediaPlayer\": \"Reprodutor de mídia\",\n  \"LabelMediaType\": \"Tipo de Mídia\",\n  \"LabelMetaTag\": \"Etiqueta Meta\",\n  \"LabelMetaTags\": \"Etiquetas Meta\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Fontes de metadados de alta prioridade terão preferência sobre as fontes de metadados de prioridade baixa\",\n  \"LabelMetadataProvider\": \"Fonte de Metadados\",\n  \"LabelMinute\": \"Minuto\",\n  \"LabelMinutes\": \"Minutos\",\n  \"LabelMissing\": \"Ausente\",\n  \"LabelMissingEbook\": \"Ebook não existe\",\n  \"LabelMissingSupplementaryEbook\": \"Ebook complementar não existe\",\n  \"LabelMobileRedirectURIs\": \"URIs de redirecionamento móveis permitidas\",\n  \"LabelMobileRedirectURIsDescription\": \"Essa é uma lista de permissionamento para URIs válidas para o redirecionamento de aplicativos móveis. A padrão é <code>audiobookshelf://oauth</code>, que pode ser removida ou acrescentada com novas URIs para integração com apps de terceiros. Usando um asterisco (<code>*</code>) como um item único dará permissão para qualquer URI.\",\n  \"LabelMore\": \"Mais\",\n  \"LabelMoreInfo\": \"Mais Informações\",\n  \"LabelName\": \"Nome\",\n  \"LabelNarrator\": \"Narrador\",\n  \"LabelNarrators\": \"Narradores\",\n  \"LabelNew\": \"Novo\",\n  \"LabelNewPassword\": \"Nova Senha\",\n  \"LabelNewestAuthors\": \"Novos Autores\",\n  \"LabelNewestEpisodes\": \"Episódios mais recentes\",\n  \"LabelNextBackupDate\": \"Data do próximo backup\",\n  \"LabelNextChapters\": \"Próximo capítulo será:\",\n  \"LabelNextScheduledRun\": \"Próxima execução programada\",\n  \"LabelNoApiKeys\": \"Sem chaves de API\",\n  \"LabelNoCustomMetadataProviders\": \"Não existem fontes de metadados customizados\",\n  \"LabelNoEpisodesSelected\": \"Nenhum episódio selecionado\",\n  \"LabelNotFinished\": \"Não concluído\",\n  \"LabelNotStarted\": \"Não iniciado\",\n  \"LabelNotes\": \"Notas\",\n  \"LabelNotificationAppriseURL\": \"URL(s) Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Variáveis disponíveis\",\n  \"LabelNotificationBodyTemplate\": \"Modelo de Corpo\",\n  \"LabelNotificationEvent\": \"Evento de Notificação\",\n  \"LabelNotificationTitleTemplate\": \"Modelo de Título\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Máximo de tentativas com falhas\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Notificações serão desabilitadas após falharem este número de vezes\",\n  \"LabelNotificationsMaxQueueSize\": \"Tamanho máximo da fila de eventos de notificação\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Eventos estão limitados a um disparo por segundo. Eventos serão ignorados se a fila estiver no tamanho máximo. Isso evita o excesso de notificações.\",\n  \"LabelNumberOfBooks\": \"Número de Livros\",\n  \"LabelNumberOfChapters\": \"Número de capítulos:\",\n  \"LabelNumberOfEpisodes\": \"# de Episódios\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Nome do claim OpenID contendo as permissões avançadas para ações do usuário na aplicação para serem aplicadas aos perfis não-administradores (<b>se configurados</b>). Se o claim não estiver presente na resposta, acesso ao ABS será negado. Se apenas uma opção estiver ausente, ela será tratada como <code>false</code>. Garanta que o claim do provedor de identidade segue a estrutura esperada:\",\n  \"LabelOpenIDClaims\": \"Deixe as opções a seguir em branco para desativar a atribuição de grupos e permissões avançadas; nesse caso, o grupo 'Usuário' será atribuído automaticamente.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Nome do claim OpenID contendo a lista de grupos do usuário, normalmente chamada de <code>groups</code>. <b>Se configurada</b>, a aplicação atribuirá automaticamente os perfis com base na participação do usuário nos grupos, contanto que os nomes desses grupos no claim, sem distinção entre maiúsculas e minúsculas, sejam 'admin', 'user' ou 'guest'. O claim deve conter uma lista e, se o usuário pertencer a múltiplos grupos, a aplicação atribuirá o perfil correspondendo ao maior nível de acesso. Se não houver correspondência a qualquer grupo, o acesso será negado.\",\n  \"LabelOpenRSSFeed\": \"Abrir Feed RSS\",\n  \"LabelOverwrite\": \"Sobrescrever\",\n  \"LabelPaginationPageXOfY\": \"Página {0} de {1}\",\n  \"LabelPassword\": \"Senha\",\n  \"LabelPath\": \"Caminho\",\n  \"LabelPermanent\": \"Permanente\",\n  \"LabelPermissionsAccessAllLibraries\": \"Pode Acessar Todas Bibliotecas\",\n  \"LabelPermissionsAccessAllTags\": \"Pode Acessar Todas as Etiquetas\",\n  \"LabelPermissionsAccessExplicitContent\": \"Pode Acessar Conteúdos Explícitos\",\n  \"LabelPermissionsCreateEreader\": \"Pode criar leitor de ebooks\",\n  \"LabelPermissionsDelete\": \"Pode Apagar\",\n  \"LabelPermissionsDownload\": \"Pode Fazer Download\",\n  \"LabelPermissionsUpdate\": \"Pode Atualizar\",\n  \"LabelPermissionsUpload\": \"Pode Fazer Upload\",\n  \"LabelPersonalYearReview\": \"Sua Retrospectiva Anual ({0})\",\n  \"LabelPhotoPathURL\": \"Caminho/URL para Foto\",\n  \"LabelPlayMethod\": \"Método de Reprodução\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Valor de incremento/decremento da taxa de reprodução\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} de {1}\",\n  \"LabelPlaylists\": \"Listas de Reprodução\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Região de busca do podcast\",\n  \"LabelPodcastType\": \"Tipo de Podcast\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Porta\",\n  \"LabelPrefixesToIgnore\": \"Prefixos para Ignorar (sem distinção entre maiúsculas e minúsculas)\",\n  \"LabelPreventIndexing\": \"Evitar que o seu feed seja indexado pelos diretórios de podcast do iTunes e Google\",\n  \"LabelPrimaryEbook\": \"Ebook principal\",\n  \"LabelProgress\": \"Progresso\",\n  \"LabelProvider\": \"Fonte\",\n  \"LabelProviderAuthorizationValue\": \"Valor do Cabeçalho de Autorização\",\n  \"LabelPubDate\": \"Data de Publicação\",\n  \"LabelPublishYear\": \"Ano de Publicação\",\n  \"LabelPublishedDate\": \"Publicado {0}\",\n  \"LabelPublishedDecade\": \"Década de Publicação\",\n  \"LabelPublishedDecades\": \"Décadas de Publicação\",\n  \"LabelPublisher\": \"Editora\",\n  \"LabelPublishers\": \"Editoras\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"E-mail do dono personalizado\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nome do dono personalizado\",\n  \"LabelRSSFeedOpen\": \"Feed de RSS Aberto\",\n  \"LabelRSSFeedPreventIndexing\": \"Impedir Indexação\",\n  \"LabelRSSFeedSlug\": \"Slug do Feed RSS\",\n  \"LabelRSSFeedURL\": \"URL do Feed RSS\",\n  \"LabelRandomly\": \"Aleatoriamente\",\n  \"LabelReAddSeriesToContinueListening\": \"Adicionar novamente a série à \\\"Continuar ouvindo\\\"\",\n  \"LabelRead\": \"Lido\",\n  \"LabelReadAgain\": \"Ler novamente\",\n  \"LabelReadEbookWithoutProgress\": \"Ler ebook sem armazenar progresso\",\n  \"LabelRecentSeries\": \"Séries Recentes\",\n  \"LabelRecentlyAdded\": \"Novidades\",\n  \"LabelRecommended\": \"Recomendado\",\n  \"LabelRedo\": \"Refazer\",\n  \"LabelRegion\": \"Região\",\n  \"LabelReleaseDate\": \"Data de Lançamento\",\n  \"LabelRemoveAllMetadataAbs\": \"Remover todos os arquivos metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Remover todos os arquivos metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Remover introdução e encerramento da Audible dos capítulos\",\n  \"LabelRemoveCover\": \"Remover capa\",\n  \"LabelRemoveMetadataFile\": \"Remover arquivos de metadados nas pastas da biblioteca\",\n  \"LabelRemoveMetadataFileHelp\": \"Remover todos os arquivos metadata.json e metadata.abs em suas {0} pastas.\",\n  \"LabelRowsPerPage\": \"Linhas por Página\",\n  \"LabelSearchTerm\": \"Busca por Termo\",\n  \"LabelSearchTitle\": \"Busca por Título\",\n  \"LabelSearchTitleOrASIN\": \"Busca por Título ou ASIN\",\n  \"LabelSeason\": \"Temporada\",\n  \"LabelSeasonNumber\": \"Temporada #{0}\",\n  \"LabelSelectAll\": \"Selecionar todos\",\n  \"LabelSelectAllEpisodes\": \"Selecionar todos os Episódios\",\n  \"LabelSelectEpisodesShowing\": \"Selecionar os {0} Episódios Visíveis\",\n  \"LabelSelectUser\": \"Selecionar usuário\",\n  \"LabelSelectUsers\": \"Selecionar usuários\",\n  \"LabelSendEbookToDevice\": \"Enviar Ebook para...\",\n  \"LabelSequence\": \"Sequência\",\n  \"LabelSerial\": \"Serial\",\n  \"LabelSeries\": \"Série\",\n  \"LabelSeriesName\": \"Nome da Série\",\n  \"LabelSeriesProgress\": \"Progresso da Série\",\n  \"LabelServerLogLevel\": \"Nível de Logs do Servidor\",\n  \"LabelServerYearReview\": \"Retrospectiva Anual do Servidor ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Definir como principal\",\n  \"LabelSetEbookAsSupplementary\": \"Definir como complementar\",\n  \"LabelSettingsAllowIframe\": \"Permitir incorporação em iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Apenas Audiobooks\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Ao ativar essa configuração os arquivos de ebooks serão ignorados a não ser que estejam dentro de uma pasta com um audiobook. Nesse caso eles serão definidos como ebooks complementares\",\n  \"LabelSettingsBookshelfViewHelp\": \"Aparência esqueomorfa com prateleiras de madeira\",\n  \"LabelSettingsChromecastSupport\": \"Suporte ao Chromecast\",\n  \"LabelSettingsDateFormat\": \"Formato de data\",\n  \"LabelSettingsEnableWatcher\": \"Monitorar automaticamente alterações nas bibliotecas\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Monitorar automaticamente alterações na biblioteca\",\n  \"LabelSettingsEnableWatcherHelp\": \"Ativa o acréscimo/atualização de itens quando forem detectadas mudanças no arquivo. *Requer reiniciar o servidor\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Permitir scripts em epubs\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Permitir que arquivos epub executem scripts. É recomendado manter essa configuração desativada, a não ser que confie na fonte dos arquivos epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Funcionalidade experimentais\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funcionalidade em desenvolvimento que se beneficiairam dos seus comentários e da sua ajuda para testar. Clique para abrir a discussão no github.\",\n  \"LabelSettingsFindCovers\": \"Localizar capas\",\n  \"LabelSettingsFindCoversHelp\": \"Se o seu audiobook não tiver uma capa incluída ou uma imagem de capa na pasta, o verificador tentará localizar uma capa.<br>Atenção: Isso irá estender o tempo de análise\",\n  \"LabelSettingsHideSingleBookSeries\": \"Ocultar séries com um só livro\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Séries com um só livro serão ocultadas na página de séries e na prateleira de séries na página principal.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Usar visão estante na página principal\",\n  \"LabelSettingsLibraryBookshelfView\": \"Usar visão estante na página da biblioteca\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"O percentual de conclusão é maior que\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"O tempo restante é inferior a (segundos)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Marcar o item de mídia como concluído quando\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Pular livros anteriores em Continuar Série\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"A prateleira Continuar Série na página principal de exibe o primeiro livro não iniciado em uma série que tem pelo menos um livro concluído e nenhum livro em andamento. Ativar essa configuração irá continuar a série a partir do livro mais recentemente concluído ao invés do primeiro livro não iniciado.\",\n  \"LabelSettingsParseSubtitles\": \"Analisar subtítulos\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Extrair subtítulos do nome da pasta do audiobook.<br>Subtítulo deve estar separado por \\\" - \\\"<br>ex: \\\"Título do Livro - Um Subtítulo Aqui\\\" tem o subtítulo \\\"Um Subtítulo Aqui\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferir metadados consultados\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Dados consultados serão priorizados sobre os detalhes do item quando usada a Consulta Rápida. Por padrão, Consulta Rápida só preencherá os detalhes ausentes.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Pular consulta de livros que já têm um ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Pular consulta de livros que já têm um ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorar prefixos ao ordenar\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"ex: o prefixo \\\"o\\\" do título \\\"O Título do Livro\\\" seria ordenado como \\\"Título do Livro, O\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Usar capas de livro quadradas\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Preferir capas quadradas ao invés das capas 1.6:1 padrão\",\n  \"LabelSettingsStoreCoversWithItem\": \"Armazenar capas com o item\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Por padrão as capas são armazenadas em /metadata/items. Ao ativar essa configuração as capas serão armazenadas na pasta do item na sua biblioteca. Apenas um arquivo chamado \\\"cover\\\" será mantido\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Armazenar metadados com o item\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Por padrão os arquivos de metadados são armazenados em /metadata/items. Ao ativar essa configuração os arquivos de metadados serão armazenadas nas pastas dos itens na sua biblioteca\",\n  \"LabelSettingsTimeFormat\": \"Formato da Tempo\",\n  \"LabelShare\": \"Compartilhar\",\n  \"LabelShareDownloadableHelp\": \"Permitir que os usuários com o link de compartilhamento baixem um arquivo zip do item da biblioteca.\",\n  \"LabelShareOpen\": \"Abrir compartilhamento\",\n  \"LabelShareURL\": \"Compartilhar URL\",\n  \"LabelShowAll\": \"Exibir Todos\",\n  \"LabelShowSeconds\": \"Exibir segundos\",\n  \"LabelShowSubtitles\": \"Mostrar Legendas\",\n  \"LabelSize\": \"Tamanho\",\n  \"LabelSleepTimer\": \"Timer\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Crescente\",\n  \"LabelSortDescending\": \"Decrescente\",\n  \"LabelSortPubDate\": \"Ordenar por data de publicação\",\n  \"LabelStart\": \"Iniciar\",\n  \"LabelStartTime\": \"Horário do Início\",\n  \"LabelStarted\": \"Iniciado\",\n  \"LabelStartedAt\": \"Iniciado Em\",\n  \"LabelStartedDate\": \"Iniciado {0}\",\n  \"LabelStatsAudioTracks\": \"Trilhas de Áudio\",\n  \"LabelStatsAuthors\": \"Autores\",\n  \"LabelStatsBestDay\": \"Melhor Dia\",\n  \"LabelStatsDailyAverage\": \"Média Diária\",\n  \"LabelStatsDays\": \"Dias\",\n  \"LabelStatsDaysListened\": \"Dias Escutando\",\n  \"LabelStatsHours\": \"Horas\",\n  \"LabelStatsInARow\": \"seguidos\",\n  \"LabelStatsItemsFinished\": \"itens Concluídos\",\n  \"LabelStatsItemsInLibrary\": \"itens na biblioteca\",\n  \"LabelStatsMinutes\": \"minutos\",\n  \"LabelStatsMinutesListening\": \"Minutos Escutando\",\n  \"LabelStatsOverallDays\": \"Total de Dias\",\n  \"LabelStatsOverallHours\": \"Total de Horas\",\n  \"LabelStatsWeekListening\": \"Tempo escutando na semana\",\n  \"LabelSubtitle\": \"Subtítulo\",\n  \"LabelSupportedFileTypes\": \"Tipos de arquivos suportados\",\n  \"LabelTag\": \"Etiqueta\",\n  \"LabelTags\": \"Etiquetas\",\n  \"LabelTagsAccessibleToUser\": \"Etiquetas Acessíveis ao Usuário\",\n  \"LabelTagsNotAccessibleToUser\": \"Etiquetas não Acessíveis Usuário\",\n  \"LabelTasks\": \"Tarefas em Execuçào\",\n  \"LabelTextEditorBulletedList\": \"Lista com marcadores\",\n  \"LabelTextEditorLink\": \"Link\",\n  \"LabelTextEditorNumberedList\": \"Lista numerada\",\n  \"LabelTextEditorUnlink\": \"Remover link\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Escuro\",\n  \"LabelThemeLight\": \"Claro\",\n  \"LabelThemeSepia\": \"Sépia\",\n  \"LabelTimeBase\": \"Base de tempo\",\n  \"LabelTimeDurationXHours\": \"{0} horas\",\n  \"LabelTimeDurationXMinutes\": \"{0} minutos\",\n  \"LabelTimeDurationXSeconds\": \"{0} segundos\",\n  \"LabelTimeInMinutes\": \"Tempo em minutos\",\n  \"LabelTimeLeft\": \"{0} restante\",\n  \"LabelTimeListened\": \"Tempo de escuta\",\n  \"LabelTimeListenedToday\": \"Tempo de escuta hoje\",\n  \"LabelTimeRemaining\": \"{0} restantes\",\n  \"LabelTimeToShift\": \"Deslocamento de tempo em segundos\",\n  \"LabelTitle\": \"Título\",\n  \"LabelToolsEmbedMetadata\": \"Incluir Metadados\",\n  \"LabelToolsEmbedMetadataDescription\": \"Incluir metadados no arquivo de áudio, com imagem da capa e capítulos.\",\n  \"LabelToolsM4bEncoder\": \"Codificador M4B\",\n  \"LabelToolsMakeM4b\": \"Gerar audiobook no formato M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Gerar um arquivo de audiobook no formato .M4B com metadados, imagem da capa e capítulos.\",\n  \"LabelToolsSplitM4b\": \"Dividir um M4B em MP3s\",\n  \"LabelToolsSplitM4bDescription\": \"Criar arquivos MP3s a partir da divisão de um M4B em capítulos, com metadados e imagem de capa.\",\n  \"LabelTotalDuration\": \"Duração Total\",\n  \"LabelTotalTimeListened\": \"Tempo Total Escutado\",\n  \"LabelTrackFromFilename\": \"Trilha a partir do nome do arquivo\",\n  \"LabelTrackFromMetadata\": \"Trilha a partir dos Metadados\",\n  \"LabelTracks\": \"Trilhas\",\n  \"LabelTracksMultiTrack\": \"Várias trilhas\",\n  \"LabelTracksNone\": \"Sem trilha\",\n  \"LabelTracksSingleTrack\": \"Trilha única\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Tipo\",\n  \"LabelUnabridged\": \"Não Abreviada\",\n  \"LabelUndo\": \"Desfazer\",\n  \"LabelUnknown\": \"Desconhecido\",\n  \"LabelUnknownPublishDate\": \"Data de publicação desconhecida\",\n  \"LabelUpdateCover\": \"Atualizar Capa\",\n  \"LabelUpdateCoverHelp\": \"Permite sobrescrever capas existentes para os livros selecionados quando uma consulta for localizada\",\n  \"LabelUpdateDetails\": \"Atualizar Detalhes\",\n  \"LabelUpdateDetailsHelp\": \"Permite sobrescrever detalhes existentes para os livros selecionados quando uma consulta for localizada\",\n  \"LabelUpdatedAt\": \"Atualizado em\",\n  \"LabelUploaderDragAndDrop\": \"Arraste e solte arquivos ou pastas\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Arraste e solte arquivos\",\n  \"LabelUploaderDropFiles\": \"Solte os arquivos\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Busca título, autor e série automaticamente\",\n  \"LabelUseAdvancedOptions\": \"Usar Opções Avançadas\",\n  \"LabelUseChapterTrack\": \"Usar a trilha do capítulo\",\n  \"LabelUseFullTrack\": \"Usar a trilha toda\",\n  \"LabelUseZeroForUnlimited\": \"Use 0 para ilimitado\",\n  \"LabelUser\": \"Usuário\",\n  \"LabelUsername\": \"Nome do usuário\",\n  \"LabelValue\": \"Valor\",\n  \"LabelVersion\": \"Versão\",\n  \"LabelViewBookmarks\": \"Ver marcadores\",\n  \"LabelViewChapters\": \"Ver capítulos\",\n  \"LabelViewPlayerSettings\": \"Ver configurações do reprodutor\",\n  \"LabelViewQueue\": \"Ver fila do reprodutor\",\n  \"LabelVolume\": \"Volume\",\n  \"LabelWebRedirectURLsDescription\": \"Autorize esses URLs em seu provedor OAuth para permitir o redirecionamento de volta ao aplicativo web após o login:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Subpasta de URLs de redirecionamento\",\n  \"LabelWeekdaysToRun\": \"Dias da semana para executar\",\n  \"LabelXBooks\": \"{0} livros\",\n  \"LabelXItems\": \"{0} itens\",\n  \"LabelYearReviewHide\": \"Ocultar Retrospectiva\",\n  \"LabelYearReviewShow\": \"Exibir Retrospectiva\",\n  \"LabelYourAudiobookDuration\": \"Duração do seu audiobook\",\n  \"LabelYourBookmarks\": \"Seus Marcadores\",\n  \"LabelYourPlaylists\": \"Suas Listas de Reprodução\",\n  \"LabelYourProgress\": \"Seu Progresso\",\n  \"MessageAddToPlayerQueue\": \"Adicionar à lista do reprodutor\",\n  \"MessageAppriseDescription\": \"Para utilizar essa funcionalidade é preciso ter uma instância da <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">API do Apprise</a> em execução ou uma API que possa tratar esses mesmos chamados. <br />A URL da API do Apprise deve conter o caminho completo da URL para enviar as notificações. Ex: se a sua instância da API estiver em <code>http://192.168.1.1:8337</code> você deve utilizar <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Certifique-se de estar usando o ASIN da região correta da Audible, e não da Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Os tokens de API legados serão removidos no futuro. Use <a href=\\\"/config/api-keys\\\">Chaves de API</a> em vez disso.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Reinicie o servidor após salvar para aplicar as alterações do OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"A autenticação foi aprimorada para maior segurança. Todos os usuários precisam fazer login novamente.\",\n  \"MessageBackupsDescription\": \"Backups incluem usuários, progresso dos usuários, detalhes dos itens da biblioteca, configurações do servidor e imagens armazenadas em <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>não</strong> incluem quaisquer arquivos armazenados nas pastas da sua biblioteca.\",\n  \"MessageBackupsLocationEditNote\": \"Observação: Atualizar o local de backup não moverá nem modificará os backups existentes\",\n  \"MessageBackupsLocationNoEditNote\": \"Observação: o local de backup é definido por meio de uma variável de ambiente e não pode ser alterado aqui.\",\n  \"MessageBackupsLocationPathEmpty\": \"O caminho do local de backup não pode ser vazio\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Preencha os campos habilitados com dados de todos os itens. Os campos com múltiplos valores serão mesclados\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Preencha os campos de detalhes do mapa habilitados com dados deste item\",\n  \"MessageBatchQuickMatchDescription\": \"Consulta Rápida tentará adicionar capas e metadados ausentes para os itens selecionados. Ative as opções abaixo para permitir que a Consulta Rápida sobrescreva capas e/ou metadados existentes.\",\n  \"MessageBookshelfNoCollections\": \"Você ainda não criou coleções\",\n  \"MessageBookshelfNoCollectionsHelp\": \"As coleções são públicas. Todos os usuários com acesso à biblioteca podem vê-las.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Não existem feeds RSS abertos\",\n  \"MessageBookshelfNoResultsForFilter\": \"Sem Resultados para o filtro \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Sem resultados para a consulta\",\n  \"MessageBookshelfNoSeries\": \"Você não tem séries\",\n  \"MessageBulkChapterPattern\": \"Quantos capítulos você gostaria de adicionar seguindo esse padrão de numeração?\",\n  \"MessageChapterEndIsAfter\": \"O final do capítulo está além do final do seu audiobook\",\n  \"MessageChapterErrorFirstNotZero\": \"O primeiro capítulo precisa começar no 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Tempo de início não é válido pois precisa ser menor do que a duração do audioboook\",\n  \"MessageChapterErrorStartLtPrev\": \"Tempo de início não é válido pois precisa ser igual ou maior que o tempo de início do capítulo anterior\",\n  \"MessageChapterStartIsAfter\": \"Início do capítulo está além do final do seu audiobook\",\n  \"MessageChaptersNotFound\": \"Capítulos não encontrados\",\n  \"MessageCheckingCron\": \"Verificando o cron...\",\n  \"MessageConfirmCloseFeed\": \"Tem certeza de que deseja fechar esse feed?\",\n  \"MessageConfirmDeleteApiKey\": \"Tem certeza de que deseja excluir a chave de API \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Tem certeza de que deseja apagar o backup {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Tem certeza de que deseja excluir o leitor de ebook \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Essa ação apagará o arquivo do seu sistema de arquivos. Tem certeza?\",\n  \"MessageConfirmDeleteLibrary\": \"Tem certeza de que deseja apagar a biblioteca \\\"{0}\\\" definitivamente?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Essa ação apagará o item da biblioteca do banco de dados e do seu sistema de arquivos. Tem certeza?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Essa ação apagará {0} itens da biblioteca do banco de dados e do seu sistema de arquivos. Tem certeza?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Tem certeza de que deseja excluir o provedor de metadados personalizado \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Tem certeza de que deseja excluir esta notificação?\",\n  \"MessageConfirmDeleteSession\": \"Tem certeza de que deseja apagar essa sessão?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Tem certeza de que deseja incorporar metadados em arquivos de áudio {0}?\",\n  \"MessageConfirmForceReScan\": \"Tem certeza de que deseja forçar a nova verificação?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Tem certeza de que deseja marcar todos os episódios como concluídos?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Tem certeza de que deseja marcar todos os episódios como não concluídos?\",\n  \"MessageConfirmMarkItemFinished\": \"Tem certeza de que deseja marcar \\\"{0}\\\" como concluído?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Tem certeza de que deseja marcar \\\"{0}\\\" como não concluído?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Tem certeza de que deseja marcar todos os livros nesta série como concluídos?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Tem certeza de que deseja marcar todos os livros nesta série como não concluídos?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Acionar esta notificação com dados de teste?\",\n  \"MessageConfirmPurgeCache\": \"Apagar o cache irá apagar o diretório todo localizado em <code>/metadata/cache</code>. <br /><br />Tem certeza que deseja apagar o diretório de cache?\",\n  \"MessageConfirmPurgeItemsCache\": \"Apagar o cache de itens irá apagar todo conteúdo da pasta <code>/metadata/cache/items</code>.<br />Tem certeza?\",\n  \"MessageConfirmQuickEmbed\": \"Aviso! Inclusão rápida não fará backup dos seus arquivos de áudio. Verifique se tem um backup dos seus arquivos de áudio. <br><br>Quer continuar?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"A correspondência rápida de episódios sobrescreverá os detalhes se uma correspondência for encontrada. Somente os episódios sem correspondência serão atualizados. Tem certeza?\",\n  \"MessageConfirmReScanLibraryItems\": \"Tem certeza de que deseja uma nova verificação de {0} itens?\",\n  \"MessageConfirmRemoveAllChapters\": \"Tem certeza de que deseja remover todos os capítulos?\",\n  \"MessageConfirmRemoveAuthor\": \"Tem certeza de que deseja remover o autor \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Tem certeza de que deseja remover a coleção \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Tem certeza de que deseja remover o episódio \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Observação: Isso não exclui o arquivo de áudio, a menos que você ative a opção \\\"Excluir arquivo permanentemente\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Tem certeza de que deseja remover os {0} episódios?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Tem certeza de que deseja remover as {0} sessões de escuta?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Tem certeza de que deseja remover todos os arquivos metadata.{0} nas pastas dos itens da sua biblioteca?\",\n  \"MessageConfirmRemoveNarrator\": \"Tem certeza de que deseja remover o narrador \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Tem certeza de que deseja remover a sua lista de reprodução \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Tem certeza de que deseja renomear o gênero \\\"{0}\\\" para \\\"{1}\\\" em todos os itens?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Aviso: Este gênero já existe então eles serão combinados.\",\n  \"MessageConfirmRenameGenreWarning\": \"Atenção! Um gênero com um nome semelhante já existe \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Tem certeza de que deseja renomear a etiqueta \\\"{0}\\\" para \\\"{1}\\\" em todos os itens?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Aviso: Esta etiqueta já existe então elas serão combinadas.\",\n  \"MessageConfirmRenameTagWarning\": \"Atenção! Uma etiqueta com um nome semelhante já existe \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Tem certeza de que deseja redefinir seu progresso?\",\n  \"MessageConfirmSendEbookToDevice\": \"Tem certeza de que deseja enviar {0} ebook(s) \\\"{1}\\\" para o dispositivo \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Tem certeza de que deseja desvincular este usuário do OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dias ouvidos no último ano\",\n  \"MessageDownloadingEpisode\": \"Realizando o download do episódio\",\n  \"MessageDragFilesIntoTrackOrder\": \"Arraste os arquivos para ordenar as trilhas corretamente\",\n  \"MessageEmbedFailed\": \"Falha ao incorporar!\",\n  \"MessageEmbedFinished\": \"Inclusão Concluída!\",\n  \"MessageEmbedQueue\": \"Enfileirado para incorporação de metadados ({0} na fila)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Episódio(s) na fila de download\",\n  \"MessageEreaderDevices\": \"Para garantir a entrega dos ebooks, você pode precisar adicionar o endereço de email acima como um remetente válido para cada um dos dispositivos listados abaixo.\",\n  \"MessageFeedURLWillBe\": \"URL do Feed será {0}\",\n  \"MessageFetching\": \"Buscando...\",\n  \"MessageForceReScanDescription\": \"verificará todos os arquivos, como uma verificação nova. Etiquetas ID3 de arquivos de áudio, arquivos OPF e arquivos de texto serão tratados como novos.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} ouvindo</strong> em {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Nenhuma sessão de reprodução em {0}\",\n  \"MessageImportantNotice\": \"Aviso Importante!\",\n  \"MessageInsertChapterBelow\": \"Inserir capítulo abaixo\",\n  \"MessageInvalidAsin\": \"ASIN inválido\",\n  \"MessageItemsSelected\": \"{0} itens selecionados\",\n  \"MessageItemsUpdated\": \"{0} itens atualizados\",\n  \"MessageJoinUsOn\": \"Junte-se a nós\",\n  \"MessageLoading\": \"Carregando...\",\n  \"MessageLoadingFolders\": \"Carregando pastas...\",\n  \"MessageLogsDescription\": \"Os logs estão armazenados em <code>/metadata/logs</code> como arquivos JSON. Logs de crash estão armazenados em <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"Falha no M4B!\",\n  \"MessageM4BFinished\": \"M4B Concluído!\",\n  \"MessageMapChapterTitles\": \"Designar títulos de capítulos a partir dos capítulos existentes no audiobook sem ajustar seus tempos\",\n  \"MessageMarkAllEpisodesFinished\": \"Marcar todos os episódios como concluídos\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Marcar todos os episódios como não concluídos\",\n  \"MessageMarkAsFinished\": \"Marcar como Concluído\",\n  \"MessageMarkAsNotFinished\": \"Marcar como Não Concluído\",\n  \"MessageMatchBooksDescription\": \"tentará consultar os livros da biblioteca no fornecedor de busca selecionado e preencher os detalhes ausentes e a capa. Não sobrescreve os detalhes.\",\n  \"MessageNoAudioTracks\": \"Sem trilhas de áudio\",\n  \"MessageNoAuthors\": \"Sem Autores\",\n  \"MessageNoBackups\": \"Sem Backups\",\n  \"MessageNoBookmarks\": \"Sem Marcadores\",\n  \"MessageNoChapters\": \"Sem Capítulos\",\n  \"MessageNoCollections\": \"Sem Coleções\",\n  \"MessageNoCoversFound\": \"Nenhuma Capa Encontrada\",\n  \"MessageNoDescription\": \"Sem Descrições\",\n  \"MessageNoDevices\": \"Nenhum dispositivo\",\n  \"MessageNoDownloadsInProgress\": \"Não existem downloads em andamento\",\n  \"MessageNoDownloadsQueued\": \"Não existem itens na fila de download\",\n  \"MessageNoEpisodeMatchesFound\": \"Não existem episódios correspondentes\",\n  \"MessageNoEpisodes\": \"Sem Episódios\",\n  \"MessageNoFoldersAvailable\": \"Nenhuma Pasta Disponível\",\n  \"MessageNoGenres\": \"Sem Gêneros\",\n  \"MessageNoIssues\": \"Sem Problemas\",\n  \"MessageNoItems\": \"Sem Itens\",\n  \"MessageNoItemsFound\": \"Nenhum item encontrado\",\n  \"MessageNoListeningSessions\": \"Sem Sessões de Escuta\",\n  \"MessageNoLogs\": \"Sem Logs\",\n  \"MessageNoMediaProgress\": \"Sem Progresso de Mídia\",\n  \"MessageNoNotifications\": \"Sem Notificações\",\n  \"MessageNoPodcastFeed\": \"Podcast inválido: Sem feed\",\n  \"MessageNoPodcastsFound\": \"Nenhum podcast encontrado\",\n  \"MessageNoResults\": \"Sem resultados\",\n  \"MessageNoSearchResultsFor\": \"Sem resultados para \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Sem Séries\",\n  \"MessageNoTags\": \"Sem etiquetas\",\n  \"MessageNoTasksRunning\": \"Sem Tarefas em Execução\",\n  \"MessageNoUpdatesWereNecessary\": \"Nenhuma atualização é necessária\",\n  \"MessageNoUserPlaylists\": \"Você não tem listas de reprodução\",\n  \"MessageNoUserPlaylistsHelp\": \"As listas de reprodução são privadas. Somente o usuário que as criou pode vê-las.\",\n  \"MessageNotYetImplemented\": \"Ainda não implementado\",\n  \"MessageOpmlPreviewNote\": \"Nota: esta é uma visualização do arquivo OPML processado. O título verdadeiro do podcast será recuperado do feed RSS.\",\n  \"MessageOr\": \"ou\",\n  \"MessagePauseChapter\": \"Pausar reprodução do capítulo\",\n  \"MessagePlayChapter\": \"Escutar o início do capítulo\",\n  \"MessagePlaylistCreateFromCollection\": \"Criar uma lista de reprodução a partir da coleção\",\n  \"MessagePleaseWait\": \"Por favor, aguarde...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast não tem uma URL do feed RSS para ser usada na consulta\",\n  \"MessagePodcastSearchField\": \"Digite um termo para a busca ou a URL de um feed RSS\",\n  \"MessageQuickEmbedInProgress\": \"Incorporação rápida em andamento\",\n  \"MessageQuickEmbedQueue\": \"Enfileirado para incorporação rápida ({0} na fila)\",\n  \"MessageQuickMatchAllEpisodes\": \"Correspondência Rápida de Todos os Episódios\",\n  \"MessageQuickMatchDescription\": \"Preenche detalhes vazios do item & capa com o primeiro resultado de '{0}'. Não sobrescreve detalhes a não ser que a configuração 'Preferir metadados consultados' do servidor esteja ativa.\",\n  \"MessageRemoveChapter\": \"Remover capítulo\",\n  \"MessageRemoveEpisodes\": \"Remover {0} episódio(s)\",\n  \"MessageRemoveFromPlayerQueue\": \"Remover da lista do reprodutor\",\n  \"MessageRemoveUserWarning\": \"Tem certeza de que deseja apagar definitivamente o usuário \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Reporte bugs, peça funcionalidades e contribua em\",\n  \"MessageResetChaptersConfirm\": \"Tem certeza de que deseja resetar os capítulos e desfazer as alterações realizadas?\",\n  \"MessageRestoreBackupConfirm\": \"Tem certeza de que deseja restaurar o backup criado em\",\n  \"MessageRestoreBackupWarning\": \"Restaurar um backup sobrescreverá totalmente o banco de dados localizado em /config e as imagens de capa em /metadata/items & /metadata/authors.<br /><br />Backups não alteram quaisquer arquivos nas pastas da sua biblioteca. Se a configuração do servidor de armazenar a arte da capa e os metadados nas pastas da sua biblioteca estiver ativa, esses itens não estão no backup e não serão sobrescritos.<br /><br />Todos os clientes usando o seu servidor serão atualizados automaticamente.\",\n  \"MessageScheduleLibraryScanNote\": \"Para a maioria dos usuários, é recomendável deixar este recurso desativado e manter a configuração \\\"Monitorar automaticamente alterações na biblioteca\\\" ativada. Dessa forma alterações nas pastas da sua biblioteca serão automáticamente detectadas. Habilite essa opção se \\\"Monitorar automaticamente alterações na biblioteca\\\" não fucnionara para o seu sistema de arquivos (Como NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Executar a cada {0} às {1}\",\n  \"MessageSearchResultsFor\": \"Resultado da busca por\",\n  \"MessageSelected\": \"{0} selecionado(s)\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"O identificador da série não pode conter espaços\",\n  \"MessageServerCouldNotBeReached\": \"Não foi possível estabelecer conexão com o servidor\",\n  \"MessageSetChaptersFromTracksDescription\": \"Definir os capítulos usando cada arquivo de áudio como um capítulo e o nome do arquivo como o título do capítulo\",\n  \"MessageShareExpirationWillBe\": \"Válido até <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Expira em {0}\",\n  \"MessageShareURLWillBe\": \"A URL de compartilhamento será <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Iniciar a reprodução de \\\"{0}\\\" em {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"O arquivo de áudio \\\"{0}\\\" não pode ser gravado\",\n  \"MessageTaskCanceledByUser\": \"Tarefa cancelada pelo usuário\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Baixando episódio \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Incorporação de metadados\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Incorporando metadados no audiolivro \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Codificação M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Codificação do audiolivro \\\"{0}\\\" em um único arquivo m4b\",\n  \"MessageTaskFailed\": \"Falhou\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Falha ao fazer backup do arquivo de áudio \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Falha ao criar o diretório de cache\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Falha ao incorporar metadados no arquivo \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Falha ao mesclar arquivos de áudio\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Falha ao mover o arquivo m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Falha ao gravar o arquivo de metadados\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Livros correspondentes na biblioteca \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Não há arquivos para verificar\",\n  \"MessageTaskOpmlImport\": \"Importação OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Criando podcasts a partir de feeds RSS {0}\",\n  \"MessageTaskOpmlImportFeed\": \"Feed de importação OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importando o feed RSS \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Não foi possível obter o feed do podcast\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Criando podcast \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"O podcast já existe no caminho\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Falha ao criar o podcast\",\n  \"MessageTaskOpmlImportFinished\": \"Adicionados {0} podcasts\",\n  \"MessageTaskOpmlParseFailed\": \"Falha ao analisar o arquivo OPML\",\n  \"MessageTaskOpmlParseFastFail\": \"Arquivo OPML inválido. Tag <opml> não encontrada OU tag <outline> não encontrada\",\n  \"MessageTaskOpmlParseNoneFound\": \"Nenhum feed encontrado no arquivo OPML\",\n  \"MessageTaskScanItemsAdded\": \"{0} adicionado\",\n  \"MessageTaskScanItemsMissing\": \"{0} não encontrado\",\n  \"MessageTaskScanItemsUpdated\": \"{0} atualizado\",\n  \"MessageTaskScanNoChangesNeeded\": \"Nenhuma alteração necessária\",\n  \"MessageTaskScanningFileChanges\": \"Verificando alterações de arquivo em \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Analisando a biblioteca \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Diretório de destino sem permissão de escrita\",\n  \"MessageThinking\": \"Pensando...\",\n  \"MessageUploaderItemFailed\": \"Falha no upload\",\n  \"MessageUploaderItemSuccess\": \"Upload realizado!\",\n  \"MessageUploading\": \"Realizando o upload...\",\n  \"MessageValidCronExpression\": \"Expressão do cron válida\",\n  \"MessageWatcherIsDisabledGlobally\": \"Monitoramento está desativado nas configurações do servidor\",\n  \"MessageXLibraryIsEmpty\": \"Biblioteca {0} está vazia!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"A duração do seu audiobook é maior do que a duração encontrada\",\n  \"MessageYourAudiobookDurationIsShorter\": \"A duração do seu audiobook é menor do que a duração encontrada\",\n  \"NoteChangeRootPassword\": \"O usuário Admiistrador é o único usuário que pode não ter uma senha\",\n  \"NoteChapterEditorTimes\": \"Aviso: O tempo de início do primeiro capítulo precisa ficar em 0:00 e o tempo de início do último capítulo não pode exceder a duração deste audiobook.\",\n  \"NoteFolderPicker\": \"Aviso: pastas já designadas não serão exibidas\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Atenção: A maioria dos aplicativos de podcasts requer que a URL do feed RSS use HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Atenção: Um ou mais dos seus episódios não tem uma data de publicação. Alguns aplicativos de podcasts requerem isto.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Pastas com arquivos de mídia serão tratadas como itens de biblioteca distintos.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Ao subir apenas arquivos de áudio, cada arquivo será tratado como um audiobook distinto.\",\n  \"NoteUploaderUnsupportedFiles\": \"Arquivos não suportados serão ignorados. Ao escolher ou arrastar uma pasta, outros arquivos que não estão em uma pasta dentro do item serão ignorados.\",\n  \"NotificationOnBackupCompletedDescription\": \"Acionado quando um backup é concluído\",\n  \"NotificationOnBackupFailedDescription\": \"Acionado quando um backup falha\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Acionado quando um episódio de podcast é baixado automaticamente\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Acionado quando os downloads automáticos de episódios são desativados devido a muitas tentativas falhas\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Acionado quando a solicitação do feed RSS falha durante o download automático de um episódio\",\n  \"NotificationOnTestDescription\": \"Evento para testar o sistema de notificação\",\n  \"PlaceholderBulkChapterInput\": \"Digite o título de um capítulo ou use uma numeração (por exemplo, 'Episódio 1', 'Capítulo 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Novo nome da coleção\",\n  \"PlaceholderNewFolderPath\": \"Novo caminho para a pasta\",\n  \"PlaceholderNewPlaylist\": \"Novo nome da lista de reprodução\",\n  \"PlaceholderSearch\": \"Buscar..\",\n  \"PlaceholderSearchEpisode\": \"Buscar Episódio..\",\n  \"StatsAuthorsAdded\": \"autores adicionados\",\n  \"StatsBooksAdded\": \"livros adicionados\",\n  \"StatsBooksAdditional\": \"Algumas adições incluem…\",\n  \"StatsBooksFinished\": \"livros concluídos\",\n  \"StatsBooksFinishedThisYear\": \"Alguns livros foram finalizados este ano…\",\n  \"StatsBooksListenedTo\": \"livros ouvidos\",\n  \"StatsCollectionGrewTo\": \"Sua coleção de livros cresceu para…\",\n  \"StatsSessions\": \"sessões\",\n  \"StatsSpentListening\": \"tempo gasto ouvindo\",\n  \"StatsTopAuthor\": \"TOP AUTOR\",\n  \"StatsTopAuthors\": \"TOP AUTORES\",\n  \"StatsTopGenre\": \"PRINCIPAL GÊNERO\",\n  \"StatsTopGenres\": \"PRINCIPAL GÊNEROS\",\n  \"StatsTopMonth\": \"PRINCIPAL MÊS\",\n  \"StatsTopNarrator\": \"PRINCIPAL NARRADOR\",\n  \"StatsTopNarrators\": \"PRINCIPAIS NARRADORES\",\n  \"StatsTotalDuration\": \"Com duração total de…\",\n  \"StatsYearInReview\": \"RESUMO DO ANO\",\n  \"ToastAccountUpdateSuccess\": \"Conta atualizada\",\n  \"ToastAppriseUrlRequired\": \"É preciso digitar uma URL Apprise\",\n  \"ToastAsinRequired\": \"É necessário o ASIN\",\n  \"ToastAuthorImageRemoveSuccess\": \"Imagem do autor removida\",\n  \"ToastAuthorNotFound\": \"Autor \\\"{0}\\\" não encontrado\",\n  \"ToastAuthorRemoveSuccess\": \"Autor removido\",\n  \"ToastAuthorSearchNotFound\": \"Autor não encontrado\",\n  \"ToastAuthorUpdateMerged\": \"Autor combinado\",\n  \"ToastAuthorUpdateSuccess\": \"Autor atualizado\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor atualizado (nenhuma imagem encontrada)\",\n  \"ToastBackupAppliedSuccess\": \"Backup aplicado\",\n  \"ToastBackupCreateFailed\": \"Falha ao criar backup\",\n  \"ToastBackupCreateSuccess\": \"Backup criado\",\n  \"ToastBackupDeleteFailed\": \"Falha ao apagar backup\",\n  \"ToastBackupDeleteSuccess\": \"Backup apagado\",\n  \"ToastBackupInvalidMaxKeep\": \"Número inválido de backups a serem mantidos\",\n  \"ToastBackupInvalidMaxSize\": \"Tamanho máximo de backup inválido\",\n  \"ToastBackupRestoreFailed\": \"Falha ao restaurar backup\",\n  \"ToastBackupUploadFailed\": \"Falha no upload do backup\",\n  \"ToastBackupUploadSuccess\": \"Upload do backup realizado\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Detalhes aplicados aos itens\",\n  \"ToastBatchDeleteFailed\": \"A exclusão em lote falhou\",\n  \"ToastBatchDeleteSuccess\": \"Exclusão em lote concluída com sucesso\",\n  \"ToastBatchQuickMatchFailed\": \"A correspondência rápida em lote falhou!\",\n  \"ToastBatchQuickMatchStarted\": \"Correspondência rápida em lote de {0} livros iniciada!\",\n  \"ToastBatchUpdateFailed\": \"Falha na atualização em lote\",\n  \"ToastBatchUpdateSuccess\": \"Atualização em lote realizada\",\n  \"ToastBookmarkCreateFailed\": \"Falha ao criar marcador\",\n  \"ToastBookmarkCreateSuccess\": \"Marcador adicionado\",\n  \"ToastBookmarkRemoveSuccess\": \"Marcador removido\",\n  \"ToastBulkChapterInvalidCount\": \"Digite um número entre 1 e 150\",\n  \"ToastCachePurgeFailed\": \"Falha ao apagar o cache\",\n  \"ToastCachePurgeSuccess\": \"Cache apagado com sucesso\",\n  \"ToastChapterLocked\": \"Capítulo bloqueado.\",\n  \"ToastChapterStartTimeAdjusted\": \"O horário de início do capítulo foi ajustado em {0} segundos\",\n  \"ToastChaptersAllLocked\": \"Todos os capítulos estão bloqueados. Desbloqueie alguns capítulos para alterar seus horários.\",\n  \"ToastChaptersHaveErrors\": \"Capítulos com erro\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Quantidade de deslocamento inválida. O horário de início do último capítulo ultrapassaria a duração deste audiolivro.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Valor de deslocamento inválido. O primeiro capítulo teria duração zero ou negativa e seria sobrescrito pelo segundo capítulo. Aumente a duração inicial do segundo capítulo.\",\n  \"ToastChaptersMustHaveTitles\": \"Capítulos precisam ter títulos\",\n  \"ToastChaptersRemoved\": \"Capítulos removidos\",\n  \"ToastChaptersUpdated\": \"Capítulos atualizados\",\n  \"ToastCollectionItemsAddFailed\": \"Falha ao adicionar itens à coleção\",\n  \"ToastCollectionRemoveSuccess\": \"Coleção removida\",\n  \"ToastCollectionUpdateSuccess\": \"Coleção atualizada\",\n  \"ToastConnectionNotAvailable\": \"Conexão indisponível. Tente novamente mais tarde\",\n  \"ToastCoverSearchFailed\": \"A busca pela capa falhou\",\n  \"ToastCoverUpdateFailed\": \"Falha na atualização da capa\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"A data e a hora são inválidas ou estão incompletas\",\n  \"ToastDeleteFileFailed\": \"Falha ao apagar arquivo\",\n  \"ToastDeleteFileSuccess\": \"Arquivo apagado\",\n  \"ToastDeviceAddFailed\": \"Falha ao adicionar dispositivo\",\n  \"ToastDeviceNameAlreadyExists\": \"Já existe um leitor de ebook com esse nome\",\n  \"ToastDeviceTestEmailFailed\": \"Falha ao enviar o e-mail de teste\",\n  \"ToastDeviceTestEmailSuccess\": \"E-mail de teste enviado\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Configurações de e-mail atualizadas\",\n  \"ToastEncodeCancelFailed\": \"Falha ao cancelar a codificação\",\n  \"ToastEncodeCancelSucces\": \"Codificação cancelada\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Falha ao limpar a fila\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Fila de downloads de episódios limpa\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} episódios atualizados\",\n  \"ToastErrorCannotShare\": \"Não é possível compartilhar nativamente neste dispositivo\",\n  \"ToastFailedToCreate\": \"Falha ao criar\",\n  \"ToastFailedToDelete\": \"Falha ao excluir\",\n  \"ToastFailedToLoadData\": \"Falha ao carregar dados\",\n  \"ToastFailedToMatch\": \"Não foi possível encontrar correspondência\",\n  \"ToastFailedToShare\": \"Não foi possível compartilhar\",\n  \"ToastFailedToUpdate\": \"Falha ao atualizar\",\n  \"ToastInvalidImageUrl\": \"URL de imagem inválida\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Número máximo de episódios para baixar inválido\",\n  \"ToastInvalidUrl\": \"URL inválida\",\n  \"ToastInvalidUrls\": \"Um ou mais URLs são inválidos\",\n  \"ToastItemCoverUpdateSuccess\": \"Capa do item atualizada\",\n  \"ToastItemDeletedFailed\": \"Falha ao excluir o item\",\n  \"ToastItemDeletedSuccess\": \"Item excluído\",\n  \"ToastItemDetailsUpdateSuccess\": \"Detalhes do item atualizados\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Falha ao marcar como Concluído\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Item marcado como Concluído\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Falha ao marcar como Não Concluído\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Item marcado como Não Concluído\",\n  \"ToastItemUpdateSuccess\": \"Item atualizado\",\n  \"ToastLibraryCreateFailed\": \"Falha ao criar biblioteca\",\n  \"ToastLibraryCreateSuccess\": \"Biblioteca \\\"{0}\\\" criada\",\n  \"ToastLibraryDeleteFailed\": \"Falha ao apagar biblioteca\",\n  \"ToastLibraryDeleteSuccess\": \"Biblioteca apagada\",\n  \"ToastLibraryScanFailedToStart\": \"Falha ao iniciar verificação\",\n  \"ToastLibraryScanStarted\": \"Verificação da biblioteca iniciada\",\n  \"ToastLibraryUpdateSuccess\": \"Biblioteca \\\"{0}\\\" atualizada\",\n  \"ToastMatchAllAuthorsFailed\": \"Não foi possível encontrar correspondência para todos os autores\",\n  \"ToastMetadataFilesRemovedError\": \"Erro ao remover arquivos de metadados.{0}\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Nenhum arquivo metadados.{0} encontrado na biblioteca\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Nenhum arquivo metadados.{0} removido\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} arquivos metadados.{1} removidos\",\n  \"ToastMustHaveAtLeastOnePath\": \"Deve ter pelo menos um caminho\",\n  \"ToastNameEmailRequired\": \"Nome e e-mail são obrigatórios\",\n  \"ToastNameRequired\": \"O nome é obrigatório\",\n  \"ToastNewApiKeyUserError\": \"É necessário selecionar um usuário\",\n  \"ToastNewEpisodesFound\": \"{0} novos episódios encontrados\",\n  \"ToastNewUserCreatedFailed\": \"Falha ao criar a conta: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Nova conta criada\",\n  \"ToastNewUserLibraryError\": \"É necessário selecionar pelo menos uma biblioteca\",\n  \"ToastNewUserPasswordError\": \"É obrigatória a criação de uma senha; somente o usuário root pode ter uma senha vazia\",\n  \"ToastNewUserTagError\": \"É necessário selecionar pelo menos uma etiqueta\",\n  \"ToastNewUserUsernameError\": \"Digite um nome de usuário\",\n  \"ToastNoNewEpisodesFound\": \"Nenhum episódio novo encontrado\",\n  \"ToastNoRSSFeed\": \"O Podcast não possui um feed RSS\",\n  \"ToastNoUpdatesNecessary\": \"Nenhuma atualização necessária\",\n  \"ToastNotificationCreateFailed\": \"Falha ao criar notificação\",\n  \"ToastNotificationDeleteFailed\": \"Falha ao excluir a notificação\",\n  \"ToastNotificationFailedMaximum\": \"O número máximo de tentativas falhas deve ser >= 0\",\n  \"ToastNotificationQueueMaximum\": \"A fila máxima de notificações deve ser >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Configurações de notificação atualizadas\",\n  \"ToastNotificationTestTriggerFailed\": \"Falha ao acionar a notificação de teste\",\n  \"ToastNotificationTestTriggerSuccess\": \"Notificação de teste acionada\",\n  \"ToastNotificationUpdateSuccess\": \"Notificação atualizada\",\n  \"ToastPlaylistCreateFailed\": \"Falha ao criar lista de reprodução\",\n  \"ToastPlaylistCreateSuccess\": \"Lista de reprodução criada\",\n  \"ToastPlaylistRemoveSuccess\": \"Lista de reprodução removida\",\n  \"ToastPlaylistUpdateSuccess\": \"Lista de reprodução atualizada\",\n  \"ToastPodcastCreateFailed\": \"Falha ao criar podcast\",\n  \"ToastPodcastCreateSuccess\": \"Podcast criado\",\n  \"ToastPodcastEpisodeUpdated\": \"Episódio atualizado\",\n  \"ToastPodcastGetFeedFailed\": \"Não foi possível obter o feed do podcast\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Nenhum episódio encontrado no feed RSS\",\n  \"ToastPodcastNoRssFeed\": \"O podcast não possui um feed RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"O progresso não está sendo sincronizado, reinicie a reprodução\",\n  \"ToastProviderCreatedFailed\": \"Falha ao adicionar o provedor\",\n  \"ToastProviderCreatedSuccess\": \"Novo provedor adicionado\",\n  \"ToastProviderNameAndUrlRequired\": \"Nome e URL obrigatórios\",\n  \"ToastProviderRemoveSuccess\": \"Fornecedor removido\",\n  \"ToastRSSFeedCloseFailed\": \"Falha ao fechar feed RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"Feed RSS fechado\",\n  \"ToastRemoveFailed\": \"Falha ao remover\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Falha ao remover item da coleção\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Item removido da coleção\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Falha ao remover itens da biblioteca com problemas\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Itens da biblioteca com problemas foram removidos\",\n  \"ToastRenameFailed\": \"Falha ao renomear\",\n  \"ToastRescanFailed\": \"Falha na nova verificação para {0}\",\n  \"ToastRescanRemoved\": \"O item foi removido após a digitalização ser concluída\",\n  \"ToastRescanUpToDate\": \"A nova verificação completou o item, que estava atualizado\",\n  \"ToastRescanUpdated\": \"O item foi atualizado após a digitalização\",\n  \"ToastScanFailed\": \"Falha ao digitalizar o item da biblioteca\",\n  \"ToastSelectAtLeastOneUser\": \"Selecione pelo menos um usuário\",\n  \"ToastSendEbookToDeviceFailed\": \"Falha ao enviar ebook para dispositivo\",\n  \"ToastSendEbookToDeviceSuccess\": \"Ebook enviado para o dispositivo \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Não é possível adicionar duas séries com o mesmo nome\",\n  \"ToastSeriesUpdateFailed\": \"Falha ao atualizar série\",\n  \"ToastSeriesUpdateSuccess\": \"Série atualizada\",\n  \"ToastServerSettingsUpdateSuccess\": \"Configurações do servidor atualizadas\",\n  \"ToastSessionCloseFailed\": \"Falha ao encerrar a sessão\",\n  \"ToastSessionDeleteFailed\": \"Falha ao apagar sessão\",\n  \"ToastSessionDeleteSuccess\": \"Sessão apagada\",\n  \"ToastSleepTimerDone\": \"Temporizador de sono concluído... zZzzZz\",\n  \"ToastSlugMustChange\": \"O slug possui caracteres inválidos\",\n  \"ToastSlugRequired\": \"Slug é necessário\",\n  \"ToastSocketConnected\": \"Socket conectado\",\n  \"ToastSocketDisconnected\": \"Socket desconectado\",\n  \"ToastSocketFailedToConnect\": \"Falha na conexão do socket\",\n  \"ToastSortingPrefixesEmptyError\": \"É preciso ter pelo menos um prefixo de ordenação\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Prefixos de ordenação atualizados ({0} item(ns))\",\n  \"ToastTitleRequired\": \"É necessário ter um título\",\n  \"ToastUnknownError\": \"Erro desconhecido\",\n  \"ToastUnlinkOpenIdFailed\": \"Falha ao desvincular o usuário do OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Usuário desvinculado do OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"O caminho de arquivo \\\"{0}\\\" já existe no servidor\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"O item \\\"{0}\\\" está usando um subdiretório do caminho de upload.\",\n  \"ToastUserDeleteFailed\": \"Falha ao apagar usuário\",\n  \"ToastUserDeleteSuccess\": \"Usuário apagado\",\n  \"ToastUserPasswordChangeSuccess\": \"Senha alterada com sucesso\",\n  \"ToastUserPasswordMismatch\": \"As senhas não coincidem\",\n  \"ToastUserPasswordMustChange\": \"A nova senha não pode ser igual à senha antiga\",\n  \"ToastUserRootRequireName\": \"É preciso entrar com um nome de usuário root\",\n  \"TooltipAddChapters\": \"Adicionar capítulo(s)\",\n  \"TooltipAddOneSecond\": \"Adicionar 1 segundo\",\n  \"TooltipAdjustChapterStart\": \"Clique para ajustar a hora de início\",\n  \"TooltipLockAllChapters\": \"Bloquear todos os capítulos\",\n  \"TooltipLockChapter\": \"Bloquear capítulo (Shift+clique para selecionar o intervalo)\",\n  \"TooltipSubtractOneSecond\": \"Subtrair 1 segundo\",\n  \"TooltipUnlockAllChapters\": \"Desbloqueie todos os capítulos\",\n  \"TooltipUnlockChapter\": \"Desbloquear capítulo (Shift + clique para selecionar o intervalo)\"\n}\n"
  },
  {
    "path": "client/strings/ro.json",
    "content": "{\n  \"ButtonAdd\": \"Adaugă\",\n  \"ButtonAddApiKey\": \"Adaugă cheia API\",\n  \"ButtonAddChapters\": \"Adaugă Capitole\",\n  \"ButtonAddDevice\": \"Adaugă Dispozitiv\",\n  \"ButtonAddLibrary\": \"Adaugă Librărie\",\n  \"ButtonAddPodcasts\": \"Adaugă Podcasturi\",\n  \"ButtonAddUser\": \"Adaugă Utilizator\",\n  \"ButtonAddYourFirstLibrary\": \"Adaugă prima librărie\",\n  \"ButtonApply\": \"Aplică\",\n  \"ButtonApplyChapters\": \"Aplică Capitole\",\n  \"ButtonAuthors\": \"Autori\",\n  \"ButtonBack\": \"Înapoi\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Populează din existente\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Populează detaliile hărții\",\n  \"ButtonBrowseForFolder\": \"Caută un dosar\",\n  \"ButtonCancel\": \"Anulează\",\n  \"ButtonCancelEncode\": \"Anulare codificare\",\n  \"ButtonChangeRootPassword\": \"Schimbare parolă de root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Verifică și descarcă episoade noi\",\n  \"ButtonChooseAFolder\": \"Alege un dosar\",\n  \"ButtonChooseFiles\": \"Alege fișiere\",\n  \"ButtonClearFilter\": \"Șterge filtrul\",\n  \"ButtonClose\": \"Închide\",\n  \"ButtonCloseFeed\": \"Închide sursa\",\n  \"ButtonCloseSession\": \"Închide Sesiunea Curentă\",\n  \"ButtonCollections\": \"Colecții\",\n  \"ButtonConfigureScanner\": \"Configurare scaner\",\n  \"ButtonCreate\": \"Creează\",\n  \"ButtonCreateBackup\": \"Creează backup\",\n  \"ButtonDelete\": \"Șterge\",\n  \"ButtonDownloadQueue\": \"Coadă\",\n  \"ButtonEdit\": \"Editare\",\n  \"ButtonEditChapters\": \"Editare capitole\",\n  \"ButtonEditPodcast\": \"Editare podcast\",\n  \"ButtonEnable\": \"Activează\",\n  \"ButtonForceReScan\": \"Forțează rescanare\",\n  \"ButtonFullPath\": \"Calea completă\",\n  \"ButtonHide\": \"Ascunde\",\n  \"ButtonHome\": \"Acasă\",\n  \"ButtonIssues\": \"Probleme\",\n  \"ButtonJumpBackward\": \"Sari înapoi\",\n  \"ButtonJumpForward\": \"Sari înainte\",\n  \"ButtonLatest\": \"Noutăți\",\n  \"ButtonLibrary\": \"Bibliotecă\",\n  \"ButtonLogout\": \"Deconectare\",\n  \"ButtonLookup\": \"Căutare\",\n  \"ButtonManageTracks\": \"Gestionează pista\",\n  \"ButtonMapChapterTitles\": \"Maparea titlurilor capitolelor\",\n  \"ButtonMatchAllAuthors\": \"Potriviește toți autorii\",\n  \"ButtonMatchBooks\": \"Potrivește Cărți\",\n  \"ButtonNevermind\": \"Anulează\",\n  \"ButtonNext\": \"Următorul\",\n  \"ButtonNextChapter\": \"Următorul Capitol\",\n  \"ButtonNextItemInQueue\": \"Următorul Articol în Coadă\",\n  \"ButtonOk\": \"OK\",\n  \"ButtonOpenFeed\": \"Vezi noutățile\",\n  \"ButtonOpenManager\": \"Deschide Managerul\",\n  \"ButtonPause\": \"Pauză\",\n  \"ButtonPlay\": \"Redă\",\n  \"ButtonPlayAll\": \"Redă tot\",\n  \"ButtonPlaying\": \"Redare\",\n  \"ButtonPlaylists\": \"Liste\",\n  \"ButtonPrevious\": \"Anterior\",\n  \"ButtonPreviousChapter\": \"Capitolul Anterior\",\n  \"ButtonProbeAudioFile\": \"Analizare Fișier Audio\",\n  \"ButtonPurgeAllCache\": \"Golire Cache Completă\",\n  \"ButtonPurgeItemsCache\": \"Golire Cache Articole\",\n  \"ButtonQueueAddItem\": \"Adaugă la Coadă\",\n  \"ButtonQueueRemoveItem\": \"Sterge din Coadă\",\n  \"ButtonQuickEmbed\": \"Încorporare Rapidă\",\n  \"ButtonQuickEmbedMetadata\": \"Metadate pentru Încorporare Rapidă\",\n  \"ButtonQuickMatch\": \"Potrivire Rapidă\",\n  \"ButtonReScan\": \"Rescanare\",\n  \"ButtonRead\": \"Citește\",\n  \"ButtonReadLess\": \"Citește Mai Puțin\",\n  \"ButtonReadMore\": \"Afișează mai mult\",\n  \"ButtonRefresh\": \"Reîmprospătare\",\n  \"ButtonRemove\": \"Elimină\",\n  \"ButtonRemoveAll\": \"Eliminați Tot\",\n  \"ButtonRemoveAllLibraryItems\": \"Ștergerea tuturor Articolelor din Librărie\",\n  \"ButtonRemoveFromContinueListening\": \"Ștergere din \\\"Continuă să Asculți\\\"\",\n  \"ButtonRemoveFromContinueReading\": \"Ștergere din \\\"Continuă să citești\\\"\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Ștergere Serie din \\\"Continuă Seria\\\"\",\n  \"ButtonReset\": \"Resetează\",\n  \"ButtonResetToDefault\": \"Resetează la valorile implicite\",\n  \"ButtonRestore\": \"Restaurare\",\n  \"ButtonSave\": \"Salvează\",\n  \"ButtonSaveAndClose\": \"Salvează și Închide\",\n  \"ButtonSaveTracklist\": \"Salvare Pistă\",\n  \"ButtonScan\": \"Scanează\",\n  \"ButtonScanLibrary\": \"Scanează Librăria\",\n  \"ButtonScrollLeft\": \"Derulează spre stânga\",\n  \"ButtonScrollRight\": \"Derulează spre Dreapta\",\n  \"ButtonSearch\": \"Caută\",\n  \"ButtonSelectFolderPath\": \"Selectează Calea către Dosar\",\n  \"ButtonSeries\": \"Serii\",\n  \"ButtonSetChaptersFromTracks\": \"Setează capitole din piste\",\n  \"ButtonShare\": \"Distribuie\",\n  \"ButtonShiftTimes\": \"Aliniează timpi\",\n  \"ButtonShow\": \"Arată\",\n  \"ButtonStartM4BEncode\": \"Începe Codarea M4B\",\n  \"ButtonStartMetadataEmbed\": \"Începe Încorporarea Metadatelor\",\n  \"ButtonStats\": \"Statistici\",\n  \"ButtonSubmit\": \"Trimite\",\n  \"ButtonTest\": \"Testează\",\n  \"ButtonUnlinkOpenId\": \"Deconectare OpenID\",\n  \"ButtonUpload\": \"Încarcă\",\n  \"ButtonUploadBackup\": \"Încarcă Backup\",\n  \"ButtonUploadCover\": \"Încarcă Copertă\",\n  \"ButtonUploadOPMLFile\": \"Încarcă Fișier OPML\",\n  \"ButtonUserDelete\": \"Șterge userul {0}\",\n  \"ButtonUserEdit\": \"Editează userul {0}\",\n  \"ButtonViewAll\": \"Vizualizează tot\",\n  \"ButtonYes\": \"Da\",\n  \"ErrorUploadFetchMetadataAPI\": \"Eroare în descărcarea metadatelor\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Nu s-au putut prelua metadatele - încearcă să editezi titlul și/sau autorul\",\n  \"ErrorUploadLacksTitle\": \"Trebuie să aibă un titlu\",\n  \"HeaderAccount\": \"Cont\",\n  \"HeaderAddCustomMetadataProvider\": \"Adaugă Furnizor de Metadate Personalizat\",\n  \"HeaderAdvanced\": \"Avansat\",\n  \"HeaderApiKeys\": \"Chei API\",\n  \"HeaderAppriseNotificationSettings\": \"Setări Notificări Apprise\",\n  \"HeaderAudioTracks\": \"Înregistrări audio\",\n  \"HeaderAudiobookTools\": \"Instrumente pentru Gestionarea Fișierelor Audiobook\",\n  \"HeaderAuthentication\": \"Autentificare\",\n  \"HeaderBackups\": \"Copii de siguranță\",\n  \"HeaderBulkChapterModal\": \"Adaugă Multiple Capitole\",\n  \"HeaderChangePassword\": \"Schimbă Parola\",\n  \"HeaderChapters\": \"Capitole\",\n  \"HeaderChooseAFolder\": \"Alege Dosar\",\n  \"HeaderCollection\": \"Colecție\",\n  \"HeaderCollectionItems\": \"Conținutul colecției\",\n  \"HeaderCover\": \"Copertă\",\n  \"HeaderCurrentDownloads\": \"Descărcări Curente\",\n  \"HeaderCustomMessageOnLogin\": \"Mesaj Personalizat la Autentificare\",\n  \"HeaderCustomMetadataProviders\": \"Furnizor de Metadate Personalizat\",\n  \"HeaderDetails\": \"Detalii\",\n  \"HeaderDownloadQueue\": \"Coadă de Descărcare\",\n  \"HeaderEbookFiles\": \"Ebook-uri\",\n  \"HeaderEmail\": \"Email\",\n  \"HeaderEmailSettings\": \"Setări Email\",\n  \"HeaderEpisodes\": \"Episoade\",\n  \"HeaderEreaderDevices\": \"Dispozitive eReader\",\n  \"HeaderEreaderSettings\": \"Setări eReader\",\n  \"HeaderFiles\": \"Fișiere\",\n  \"HeaderFindChapters\": \"Caută Capitol\",\n  \"HeaderIgnoredFiles\": \"Fișiere Ignorate\",\n  \"HeaderItemFiles\": \"Fișiere Articol\",\n  \"HeaderLastListeningSession\": \"Ultima Sesiune de Ascultare\",\n  \"HeaderLatestEpisodes\": \"Episoade recente\",\n  \"HeaderLibraries\": \"Biblioteci\",\n  \"HeaderLibraryFiles\": \"Fișiere in Librărie\",\n  \"HeaderLibraryStats\": \"Statistici Librărie\",\n  \"HeaderListeningSessions\": \"Sesiuni de Ascultare\",\n  \"HeaderListeningStats\": \"Statistici Ascultare\",\n  \"HeaderLogin\": \"Autentifică\",\n  \"HeaderLogs\": \"Loguri\",\n  \"HeaderManageGenres\": \"Gestionează Genuri\",\n  \"HeaderManageTags\": \"Gestionează Etichete\",\n  \"HeaderMapDetails\": \"Detaliile Hărții\",\n  \"HeaderMatch\": \"Potrivește\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Prioritatea Metadatelor\",\n  \"HeaderMetadataToEmbed\": \"Metadate pentru Încorporare\",\n  \"HeaderNewAccount\": \"Cont nou\",\n  \"HeaderNewApiKey\": \"Cheie API Nouă\",\n  \"HeaderNewLibrary\": \"Librărie Nouă\",\n  \"HeaderNotificationCreate\": \"Creează Notificare\",\n  \"HeaderNotificationUpdate\": \"Actualizare Notificare\",\n  \"HeaderNotifications\": \"Notificări\",\n  \"HeaderOpenIDConnectAuthentication\": \"Autentificare prin OpenID\",\n  \"HeaderOpenListeningSessions\": \"Deschide Sesiuni de Ascultare\",\n  \"HeaderOpenRSSFeed\": \"Deschide flux RSS\",\n  \"HeaderOtherFiles\": \"Alte Fișiere\",\n  \"HeaderPasswordAuthentication\": \"Autentificare cu Parolă\",\n  \"HeaderPermissions\": \"Permisiuni\",\n  \"HeaderPlayerQueue\": \"Coadă Player\",\n  \"HeaderPlayerSettings\": \"Setări Player\",\n  \"HeaderPlaylist\": \"Listă de redare\",\n  \"HeaderPlaylistItems\": \"Conținut listă\",\n  \"HeaderPodcastsToAdd\": \"Podcast de Adăugat\",\n  \"HeaderPresets\": \"Presetări\",\n  \"HeaderPreviewCover\": \"Previzualizare Copertă\",\n  \"HeaderRSSFeedGeneral\": \"Date RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS activ\",\n  \"HeaderRSSFeeds\": \"Fluxuri RSS\",\n  \"HeaderRemoveEpisode\": \"Elimină Episod\",\n  \"HeaderRemoveEpisodes\": \"Elimină {0} Episoade\",\n  \"HeaderSavedMediaProgress\": \"Progres Media Salvat\",\n  \"HeaderSchedule\": \"Planifică\",\n  \"HeaderScheduleEpisodeDownloads\": \"Planifică Descărcare Automată a Episoadelor\",\n  \"HeaderScheduleLibraryScans\": \"Planifică Scanarea Automată a Librăriei\",\n  \"HeaderSession\": \"Sesiuni\",\n  \"HeaderSetBackupSchedule\": \"Planifică Backup\",\n  \"HeaderSettings\": \"Setări\",\n  \"HeaderSettingsDisplay\": \"Afișaj\",\n  \"HeaderSettingsExperimental\": \"Caracteristici Experimentale\",\n  \"HeaderSettingsGeneral\": \"General\",\n  \"HeaderSettingsScanner\": \"Scaner\",\n  \"HeaderSettingsSecurity\": \"Securitate\",\n  \"HeaderSettingsWebClient\": \"Client Web\",\n  \"HeaderSleepTimer\": \"Timer de somn\",\n  \"HeaderStatsLargestItems\": \"Cele mai mari articole\",\n  \"HeaderStatsLongestItems\": \"Cele mai lungi articole (ore)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minute ascultate (ultimele 7 zile)\",\n  \"HeaderStatsRecentSessions\": \"Sesiuni recente\",\n  \"HeaderStatsTop10Authors\": \"Top 10 Autori\",\n  \"HeaderStatsTop5Genres\": \"Top 5 Genuri\",\n  \"HeaderTableOfContents\": \"Cuprins\",\n  \"HeaderTools\": \"Unelte\",\n  \"HeaderUpdateAccount\": \"Actualizare Cont\",\n  \"HeaderUpdateApiKey\": \"Actualizare Cheie API\",\n  \"HeaderUpdateAuthor\": \"Actualizare Autor\",\n  \"HeaderUpdateDetails\": \"Actualizare Detalii\",\n  \"HeaderUpdateLibrary\": \"Actualizare Librărie\",\n  \"HeaderUsers\": \"Utilizatori\",\n  \"HeaderYearReview\": \"Trecere în revistă a anului {0}\",\n  \"HeaderYourStats\": \"Progresul tău\",\n  \"LabelAbridged\": \"Abreviat\",\n  \"LabelAbridgedChecked\": \"Abreviat (verificat)\",\n  \"LabelAbridgedUnchecked\": \"Neprescurtat (neverificat)\",\n  \"LabelAccessibleBy\": \"Accesibil prin\",\n  \"LabelAccountType\": \"Tip de Cont\",\n  \"LabelAccountTypeAdmin\": \"Administrator\",\n  \"LabelAccountTypeGuest\": \"Oaspete\",\n  \"LabelAccountTypeUser\": \"Utilizator\",\n  \"LabelActivities\": \"Activități\",\n  \"LabelActivity\": \"Activitate\",\n  \"LabelAddToCollection\": \"Adaugă la Colecție\",\n  \"LabelAddToCollectionBatch\": \"Adaugare {0} Cărți la Colecție\",\n  \"LabelAddToPlaylist\": \"Adaugă în listă\",\n  \"LabelAddToPlaylistBatch\": \"Adaugare {0} Articole la Listă\",\n  \"LabelAddedAt\": \"Adăugat la\",\n  \"LabelAddedDate\": \"Adăugat {0}\",\n  \"LabelAdminUsersOnly\": \"Doar Administratori\",\n  \"LabelAll\": \"Toate\",\n  \"LabelAllEpisodesDownloaded\": \"Toate episoadele descărcate\",\n  \"LabelAllUsers\": \"Toți Utilizatorii\",\n  \"LabelAllUsersExcludingGuests\": \"Toți utilizatorii cu excepția oaspeților\",\n  \"LabelAllUsersIncludingGuests\": \"Toți utilizatorii inclusiv oaspeții\",\n  \"LabelAlreadyInYourLibrary\": \"Deja în bibliotecă\",\n  \"LabelApiKeyCreated\": \"Cheia API \\\"{0}\\\" creată cu succes.\",\n  \"LabelApiKeyCreatedDescription\": \"Copiază cheia API acum deoarece nu va mai fi disponibilă pentru vizualizare.\",\n  \"LabelApiKeyUser\": \"Acționează în numele utilizatorului\",\n  \"LabelApiKeyUserDescription\": \"Această cheie API va avea aceleași permisiuni ca utilizatorul în numele căruia acționează. In loguri va părea că utilizatorul lansa cererile.\",\n  \"LabelApiToken\": \"Token API\",\n  \"LabelAppend\": \"Atașează\",\n  \"LabelAudioBitrate\": \"Rata de Biți Audio (e.g. 128k)\",\n  \"LabelAudioChannels\": \"Canale Audio (1 sau 2)\",\n  \"LabelAudioCodec\": \"Codec Audio\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Prenume Nume)\",\n  \"LabelAuthorLastFirst\": \"Autor (Nume, Prenume)\",\n  \"LabelAuthors\": \"Autori\",\n  \"LabelAutoDownloadEpisodes\": \"Descarcă automat episoadele\",\n  \"LabelAutoFetchMetadata\": \"Descarcă Automat Metadate\",\n  \"LabelAutoFetchMetadataHelp\": \"Descarcă metadate pentru titlu, autor si serii pentru eficientizarea încărcării. Metadatele suplimentare s-ar putea să trebuiască potrivite după încărcare.\",\n  \"LabelAutoLaunch\": \"Lansare automată\",\n  \"LabelAutoLaunchDescription\": \"Redirecționează automat către furnizorul de autentificare când navighez la pagina de autentificare (cale de suprascriere manuală <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Înregistrare Automată\",\n  \"LabelAutoRegisterDescription\": \"Creează utilizatori automat dupa autentificare\",\n  \"LabelBackToUser\": \"Înapoi la Utilizator\",\n  \"LabelBackupAudioFiles\": \"Copii de rezervă a Fișierelor Audio\",\n  \"LabelBackupLocation\": \"Locația Copiilor de Rezervă\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Copii de Rezervă Automate\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Copiile de Rezervă au fost salvate în /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Dimensiunea maximă a copiilor de rezervă (în GB) (0 pentru nelimitat)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Ca protecție împotriva configurațiilor greșite, backup-ul va eșua dacă trece de limita de dimensiune configurată.\",\n  \"LabelBackupsNumberToKeep\": \"Numărul copiilor de siguranță de păstrat\",\n  \"LabelBackupsNumberToKeepHelp\": \"Doar 1 copie de siguranță va fi ștearsă odata deci dacă există mai multe copii de siguranță vor trebui șterse manual.\",\n  \"LabelBitrate\": \"Rată de biți\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Cărți\",\n  \"LabelButtonText\": \"Textul Butonului\",\n  \"LabelByAuthor\": \"de {0}\",\n  \"LabelChangePassword\": \"Schimbare Parolă\",\n  \"LabelChannels\": \"Canale\",\n  \"LabelChapterCount\": \"{0} Capitole\",\n  \"LabelChapterTitle\": \"Titlul Capitolului\",\n  \"LabelChapters\": \"Capitole\",\n  \"LabelChaptersFound\": \"capitole găsite\",\n  \"LabelClickForMoreInfo\": \"Click pentru mai multe informații\",\n  \"LabelClickToUseCurrentValue\": \"Click pentru a folosi valoarea curentă\",\n  \"LabelClosePlayer\": \"Închide playerul\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Restrânge seriile\",\n  \"LabelCollapseSubSeries\": \"Restrânge Sub-Seriile\",\n  \"LabelCollection\": \"Colecție\",\n  \"LabelCollections\": \"Colecții\",\n  \"LabelComplete\": \"Finalizat\",\n  \"LabelConfirmPassword\": \"Confirmare Parolă\",\n  \"LabelContinueListening\": \"Ascultă în continuare\",\n  \"LabelContinueReading\": \"Continuă lectura\",\n  \"LabelContinueSeries\": \"Continuă seria\",\n  \"LabelCorsAllowed\": \"Origini CORS Permise\",\n  \"LabelCover\": \"Copertă\",\n  \"LabelCoverImageURL\": \"URL-ul Imaginii de Copertă\",\n  \"LabelCoverProvider\": \"Furnizor Copertă\",\n  \"LabelCreatedAt\": \"Creat la\",\n  \"LabelCronExpression\": \"Expresie Cron\",\n  \"LabelCurrent\": \"Curent\",\n  \"LabelCurrently\": \"Acum:\",\n  \"LabelCustomCronExpression\": \"Expresie Cron Personalizată:\",\n  \"LabelDatetime\": \"Data și ora\",\n  \"LabelDays\": \"Zile\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Șterge fișierele din sistem (debifeaza pentru a șterge doar din baza de date)\",\n  \"LabelDescription\": \"Descriere\",\n  \"LabelDeselectAll\": \"Deselectați Tot\",\n  \"LabelDetectedPattern\": \"Tipar Identificat:\",\n  \"LabelDevice\": \"Dispozitiv\",\n  \"LabelDeviceInfo\": \"Informații Dispozitiv\",\n  \"LabelDeviceIsAvailableTo\": \"Dispozitiv accesibil lui...\",\n  \"LabelDirectory\": \"Dosar\",\n  \"LabelDiscFromFilename\": \"Disc din Numele Fișierului\",\n  \"LabelDiscFromMetadata\": \"Disc din Metadate\",\n  \"LabelDiscover\": \"Descoperă\",\n  \"LabelDownload\": \"Descarcă\",\n  \"LabelDownloadNEpisodes\": \"Descarcă {0} episoade\",\n  \"LabelDownloadable\": \"Descărcabil\",\n  \"LabelDuration\": \"Durată\",\n  \"LabelDurationComparisonExactMatch\": \"(potrivire exactă)\",\n  \"LabelDurationComparisonLonger\": \"({0} mai lung)\",\n  \"LabelDurationComparisonShorter\": \"({0} mai scurt)\",\n  \"LabelDurationFound\": \"Durată identificată:\",\n  \"LabelEbook\": \"Carte electronică\",\n  \"LabelEbooks\": \"Cărți electronice\",\n  \"LabelEdit\": \"Editare\",\n  \"LabelEmail\": \"Email\",\n  \"LabelEmailSettingsFromAddress\": \"De la Adresa\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Respingere certificate neautorizate\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Dezactivarea verificării certificatelor SSL vă poate expune conexiunea la riscuri de securitate, cum ar fi atacuri de tip man-in-the-middle. Dezactivați această opțiune dacă înțelegeti implicațiile și aveți încredere în serverul de mail la care vă conectați.\",\n  \"LabelEmailSettingsSecure\": \"Sigur\",\n  \"LabelEmailSettingsSecureHelp\": \"Dacă e adevărat, conexiunea se va realiza prin TLS către server. Dacă e fals, TLS este folosit dacă serverul suporta extensia STARTTLS. În majoritatea cazurilor setati adevărat dacă folosiți portul 465. Pentru portul 587 sau 25 setati fals. (referinta nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Adresă de Test\",\n  \"LabelEmbeddedCover\": \"Încorporează Copertă\",\n  \"LabelEnable\": \"Activează\",\n  \"LabelEncodingBackupLocation\": \"O copie de siguranță a fișierului audio original va fi salvată în:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Capitolele nu sunt încorporate în cărțile audio cu mai multe track-uri.\",\n  \"LabelEncodingClearItemCache\": \"Asigurați-vă că ștergeți articolele din cache periodic.\",\n  \"LabelEncodingFinishedM4B\": \"Fișierul M4B va fi adaugat în dosarul dvs. de cărți audio când codificarea e terminată:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadatele vor fi încorporate în fișierele audio din interiorul dosarului dvs. cu cărți audio.\",\n  \"LabelEncodingStartedNavigation\": \"Odată pornită sarcina poti naviga din această pagină.\",\n  \"LabelEncodingTimeWarning\": \"Codificarea poate dura până la 30 de minute.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Avertizare: Nu modificați aceste setări dacă nu sunteți familiar cu opțiunile de codare ffmpeg .\",\n  \"LabelEncodingWatcherDisabled\": \"Dacă ați dezactivat funcția de urmările va trebui sa rescanați acestă carte audio la ulterior.\",\n  \"LabelEnd\": \"Sfârșit\",\n  \"LabelEndOfChapter\": \"Sfârșitul capitolului\",\n  \"LabelEpisode\": \"Episod\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Episoade nelegate de un flux RSS\",\n  \"LabelEpisodeNumber\": \"Episodul #{0}\",\n  \"LabelEpisodeTitle\": \"Titlul Episodului\",\n  \"LabelEpisodeType\": \"Tipul Episodului\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL-ul Episodului din Fluxul RSS\",\n  \"LabelEpisodes\": \"Episoade\",\n  \"LabelEpisodic\": \"Episodic\",\n  \"LabelExample\": \"Exemplu\",\n  \"LabelExpandSeries\": \"Extinde Seriile\",\n  \"LabelExpandSubSeries\": \"Extinde Sub-Seriile\",\n  \"LabelExpired\": \"Expirat\",\n  \"LabelExpiresAt\": \"Expiră La\",\n  \"LabelExpiresInSeconds\": \"Expiră în (secunde)\",\n  \"LabelExpiresNever\": \"Niciodată\",\n  \"LabelExplicit\": \"Explicit\",\n  \"LabelExplicitChecked\": \"Explicit (verificat)\",\n  \"LabelExplicitUnchecked\": \"Neexplicit (neverificat)\",\n  \"LabelExportOPML\": \"Exportă OPML\",\n  \"LabelFeedURL\": \"Flux URL\",\n  \"LabelFetchingMetadata\": \"Aducere Metadate\",\n  \"LabelFile\": \"Fișier\",\n  \"LabelFileBirthtime\": \"Data creării fișierului\",\n  \"LabelFileBornDate\": \"Creat {0}\",\n  \"LabelFileModified\": \"Fișier modificat\",\n  \"LabelFileModifiedDate\": \"Modificat {0}\",\n  \"LabelFilename\": \"Nume fișier\",\n  \"LabelFilterByUser\": \"Filtrare după Utilizator\",\n  \"LabelFindEpisodes\": \"Găsire Episoade\",\n  \"LabelFinished\": \"Finalizat\",\n  \"LabelFinishedDate\": \"Finalizat {0}\",\n  \"LabelFolder\": \"Dosar\",\n  \"LabelFolders\": \"Dosare\",\n  \"LabelFontBold\": \"Îngroșat\",\n  \"LabelFontBoldness\": \"Grosimea fontului\",\n  \"LabelFontFamily\": \"Familia Fontului\",\n  \"LabelFontItalic\": \"Cursiv\",\n  \"LabelFontScale\": \"Mărimea fontului\",\n  \"LabelFontStrikethrough\": \"Tăiat cu o linie\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Întreg\",\n  \"LabelGenre\": \"Gen\",\n  \"LabelGenres\": \"Genuri\",\n  \"LabelHardDeleteFile\": \"Ștergere definitivă a fișierului\",\n  \"LabelHasEbook\": \"Are carte electronică\",\n  \"LabelHasSupplementaryEbook\": \"Are carte electronică suplimentară\",\n  \"LabelHideSubtitles\": \"Ascunde Subtitrări\",\n  \"LabelHighestPriority\": \"Prioritatea cea mai ridicată\",\n  \"LabelHost\": \"Gazdă\",\n  \"LabelHour\": \"Ora\",\n  \"LabelHours\": \"Ore\",\n  \"LabelIcon\": \"Pictogramă\",\n  \"LabelImageURLFromTheWeb\": \"URL-ul imaginii de pe web\",\n  \"LabelInProgress\": \"În desfășurare\",\n  \"LabelIncludeInTracklist\": \"Include în Lista de Melodii\",\n  \"LabelIncomplete\": \"Incomplet\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Personalizat zilnic/saptămânal\",\n  \"LabelIntervalEvery12Hours\": \"La fiecare 12 ore\",\n  \"LabelIntervalEvery15Minutes\": \"La fiecare 15 minute\",\n  \"LabelIntervalEvery2Hours\": \"La fiecare 2 ore\",\n  \"LabelIntervalEvery30Minutes\": \"La fiecare 30 minute\",\n  \"LabelIntervalEvery6Hours\": \"La fiecare 6 ore\",\n  \"LabelIntervalEveryDay\": \"În fiecare zi\",\n  \"LabelIntervalEveryHour\": \"În fiecare oră\",\n  \"LabelIntervalEveryMinute\": \"La fiecare minut\",\n  \"LabelInvert\": \"Inversează\",\n  \"LabelItem\": \"Articol\",\n  \"LabelJumpBackwardAmount\": \"Sari înapoi cu\",\n  \"LabelJumpForwardAmount\": \"Sari înainte cu\",\n  \"LabelLanguage\": \"Limbă\",\n  \"LabelLanguageDefaultServer\": \"Limba Prestabilită a Serverului\",\n  \"LabelLanguages\": \"Limbi\",\n  \"LabelLastBookAdded\": \"Ultima Carte Adăugată\",\n  \"LabelLastBookUpdated\": \"Ultima Carte Actualizată\",\n  \"LabelLastProgressDate\": \"Ultimul progres: {0}\",\n  \"LabelLastSeen\": \"Ultima dată văzut\",\n  \"LabelLastTime\": \"Ultima dată\",\n  \"LabelLastUpdate\": \"Ultima actualizare\",\n  \"LabelLayout\": \"Aspect\",\n  \"LabelLayoutSinglePage\": \"Pagină unică\",\n  \"LabelLayoutSplitPage\": \"Pagină împărțită\",\n  \"LabelLess\": \"Mai puțin\",\n  \"LabelLibrariesAccessibleToUser\": \"Biblioteci Accesibile Utilizatorului\",\n  \"LabelLibrary\": \"Bibliotecă\",\n  \"LabelLibraryFilterSublistEmpty\": \"Numărul {0}\",\n  \"LabelLibraryItem\": \"Articol din Bibliotecă\",\n  \"LabelLibraryName\": \"Numele Bibliotecii\",\n  \"LabelLibrarySortByProgress\": \"Progres: Ultima Actualizare\",\n  \"LabelLibrarySortByProgressFinished\": \"Progres: Finalizat\",\n  \"LabelLibrarySortByProgressStarted\": \"Progres: Început\",\n  \"LabelLimit\": \"Limită\",\n  \"LabelLineSpacing\": \"Spațiere între rânduri\",\n  \"LabelListenAgain\": \"Ascultă din nou\",\n  \"LabelLogLevelDebug\": \"Depanare\",\n  \"LabelLogLevelInfo\": \"Informații\",\n  \"LabelLogLevelWarn\": \"Avertizare\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Caută episoade noi după această dată\",\n  \"LabelLowestPriority\": \"Cea Mai Scăzută Prioritate\",\n  \"LabelMatchConfidence\": \"Încredere\",\n  \"LabelMatchExistingUsersBy\": \"Potrivire utilizatori existenți prin\",\n  \"LabelMatchExistingUsersByDescription\": \"Folosit pentru a conecta utilizatorii existenți. Odata conectați, utilizatorii vor fi potriviți după un ID unic trimis de furnizorul SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Numarul maxim # de episoade de descărcat. Folosiți 0 pentru nelimitat.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Numărul maxim # de episoade de descărcat per verificare\",\n  \"LabelMaxEpisodesToKeep\": \"Numarul maxim # de episoade păstrate\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Valorea 0 nu stabilește o limită maximă. După ce un episod nou a fost descărcat automat, cel mai vechi episod va fi șters dacă aveți mai mult de X episoade. Se va șterge câte un episod vechi pentru fiecare episod nou descărcat.\",\n  \"LabelMediaPlayer\": \"Player Media\",\n  \"LabelMediaType\": \"Tip media\",\n  \"LabelMetaTag\": \"Etichetă Meta\",\n  \"LabelMetaTags\": \"Etichete Meta\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Sursele de metadate cu prioritate mai mare o să suprascrie sursele de metadate cu prioritate mai mică\",\n  \"LabelMetadataProvider\": \"Furnizor Metadate\",\n  \"LabelMinute\": \"Minut\",\n  \"LabelMinutes\": \"Minute\",\n  \"LabelMissing\": \"Lipsă\",\n  \"LabelMissingEbook\": \"Nu are carte electronică\",\n  \"LabelMissingSupplementaryEbook\": \"Nu are carte electronică adițională\",\n  \"LabelMobileRedirectURIs\": \"URL-uri de redirecționare Mobile Permise\",\n  \"LabelMobileRedirectURIsDescription\": \"Aceasta este o listă cu URI-uri valide pentru redirectionare a aplicațiilor mobile. URI-ul predefinit este <code>audiobookshelf://oauth</code>,, care poate fi sters sau suplimentat cu URI-uri adiționale pentru integrarea cu alte aplicații. Folosirea unui asterisc (<code>*</code>) ca singur element permite orice URI.\",\n  \"LabelMore\": \"Mai multe\",\n  \"LabelMoreInfo\": \"Mai multe informații\",\n  \"LabelName\": \"Nume\",\n  \"LabelNarrator\": \"Narator\",\n  \"LabelNarrators\": \"Naratori\",\n  \"LabelNew\": \"Nou\",\n  \"LabelNewPassword\": \"Parolă Nouă\",\n  \"LabelNewestAuthors\": \"Autori noi\",\n  \"LabelNewestEpisodes\": \"Episoade noi\",\n  \"LabelNextBackupDate\": \"Următoarea dată a copiilor de siguranță\",\n  \"LabelNextChapters\": \"Următoarele capitole vor fi:\",\n  \"LabelNextScheduledRun\": \"Urmatoarea rulare programată\",\n  \"LabelNoApiKeys\": \"Nu exista chei API\",\n  \"LabelNoCustomMetadataProviders\": \"Nu există furnizori de metadate personalizați\",\n  \"LabelNoEpisodesSelected\": \"Nici un episod selectat\",\n  \"LabelNotFinished\": \"Nefinalizat\",\n  \"LabelNotStarted\": \"Neînceput\",\n  \"LabelNotes\": \"Note\",\n  \"LabelNotificationAppriseURL\": \"URL-ul(urile) Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Variabile disponibile\",\n  \"LabelNotificationBodyTemplate\": \"Corpul Șablonului\",\n  \"LabelNotificationEvent\": \"Eveniment de notificare\",\n  \"LabelNotificationTitleTemplate\": \"Titlul Șablonului\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Număr de încercări eșuate maxim\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Notificările sunt dezactivate dacă nu reușesc să fie trimise de acest număr de ori\",\n  \"LabelNotificationsMaxQueueSize\": \"Dimensiunea maximă a cozii pentru evenimentele de notificare\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Evenimentele sunt limitate la 1 per secunda. Evenimentele vor fi ignorate dacă coada este plină. Previne spamarea cu notificări.\",\n  \"LabelNumberOfBooks\": \"Numărul de Cărți\",\n  \"LabelNumberOfChapters\": \"Număr de capitole:\",\n  \"LabelNumberOfEpisodes\": \"# de Episoade\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Numele revendicării OpenID care conține permisiuni avansate pentru acțiunile utilizatorului în interiorul aplicației care vor fi aplicate rolurilor non-administrator (<b>dacă e configurat</b>). Dacă revendicarea nu e prezentă în răspunsul primit, accesul către ABS e refuzat. Dacă o singură opțiune lipsește, va fi tratată ca <code>falsă</code>. Asigurați-vă că revendicările furnizorului de securitate corespund structurii așteptate:\",\n  \"LabelOpenIDClaims\": \"Lăsați urmatoarele opțiuni goale pentru a dezactiva atribuirea avansată de grupuri și permisiuni, asignând grupul \\\"Utilizatori\\\" automat.\",\n  \"LabelOpenRSSFeed\": \"Flux Open RSS\",\n  \"LabelOverwrite\": \"Suprascrie\",\n  \"LabelPaginationPageXOfY\": \"Pagina {0} din {1}\",\n  \"LabelPassword\": \"Parolă\",\n  \"LabelPath\": \"Cale\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Poate accesa toate bibliotecile\",\n  \"LabelPermissionsAccessAllTags\": \"Poate accesa toate etichetele\",\n  \"LabelPermissionsAccessExplicitContent\": \"Poate Accesa Conținut Explicit\",\n  \"LabelPermissionsCreateEreader\": \"Poate Crea Cititoare Electronice\",\n  \"LabelPermissionsDelete\": \"Poate Șterge\",\n  \"LabelPermissionsDownload\": \"Poate Descărca\",\n  \"LabelPermissionsUpdate\": \"Poate Actualiza\",\n  \"LabelPermissionsUpload\": \"Poate Încărca\",\n  \"LabelPersonalYearReview\": \"Recapitularea Anului tău ({0})\",\n  \"LabelPhotoPathURL\": \"Calea/URL-ul Fotografiei\",\n  \"LabelPlayMethod\": \"Metoda de Redare\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Incrementare/Decrementare a Ratei de Redare cu\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} din {1}\",\n  \"LabelPlaylists\": \"Liste de redare\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Regiunea căutării podcastului\",\n  \"LabelPodcastType\": \"Tipul Podcastului\",\n  \"LabelPodcasts\": \"Podcasturi\",\n  \"LabelPort\": \"Portul\",\n  \"LabelPrefixesToIgnore\": \"Prefix de ignorat (fără a ține cont de majuscule)\",\n  \"LabelPreventIndexing\": \"Împiedică indexarea fluxului în directoarele iTunes și Google Podcasts\",\n  \"LabelPrimaryEbook\": \"eCarte Principală\",\n  \"LabelProgress\": \"Progres\",\n  \"LabelProvider\": \"Furnizor\",\n  \"LabelProviderAuthorizationValue\": \"Valoarea Antetului de Autorizare\",\n  \"LabelPubDate\": \"Data publicării\",\n  \"LabelPublishYear\": \"Anul publicării\",\n  \"LabelPublishedDate\": \"Publicat la {0}\",\n  \"LabelPublishedDecade\": \"Deceniul Publicării\",\n  \"LabelPublishedDecades\": \"Deceniile Publicării\",\n  \"LabelPublisher\": \"Editor\",\n  \"LabelPublishers\": \"Editori\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Email personalizat al proprietarului\",\n  \"LabelRSSFeedCustomOwnerName\": \"Nume personalizat al proprietarului\",\n  \"LabelRSSFeedOpen\": \"Flux RSS deschis\",\n  \"LabelRSSFeedPreventIndexing\": \"Previne indexarea\",\n  \"LabelRSSFeedSlug\": \"Identificator flux RSS\",\n  \"LabelRSSFeedURL\": \"URL-ul Fluxului RSS\",\n  \"LabelRandomly\": \"Aleatoriu\",\n  \"LabelReAddSeriesToContinueListening\": \"Readăugare serie la \\\"Continuă să asculți\\\"\",\n  \"LabelRead\": \"Citește\",\n  \"LabelReadAgain\": \"Citește din nou\",\n  \"LabelReadEbookWithoutProgress\": \"Citire eCarte fără a memora progresul\",\n  \"LabelRecentSeries\": \"Serii recente\",\n  \"LabelRecentlyAdded\": \"Adăugate recent\",\n  \"LabelRecommended\": \"Recomandat\",\n  \"LabelRedo\": \"Refă\",\n  \"LabelRegion\": \"Regiune\",\n  \"LabelReleaseDate\": \"Data Lansării\",\n  \"LabelRemoveAllMetadataAbs\": \"Ștergerea tuturor fișierelor metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Ștergerea tuturor fișierelor metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Ștergerea Audible intro și outro din capitole\",\n  \"LabelRemoveCover\": \"Șterge coperta\",\n  \"LabelRemoveMetadataFile\": \"Șterge fisierele metadate din dosarele bibliotecii\",\n  \"LabelRemoveMetadataFileHelp\": \"Șterge toate fișierele metadata.json și metadata.abs din {0} dosare.\",\n  \"LabelRowsPerPage\": \"Rânduri pe pagină\",\n  \"LabelSearchTerm\": \"Termen de căutat\",\n  \"LabelSearchTitle\": \"Titlu de căutat\",\n  \"LabelSearchTitleOrASIN\": \"Titlu de căutat sau ASN\",\n  \"LabelSeason\": \"Sezon\",\n  \"LabelSeasonNumber\": \"Sezonul #{0}\",\n  \"LabelSelectAll\": \"Selectează tot\",\n  \"LabelSelectAllEpisodes\": \"Selectează toate episoadele\",\n  \"LabelSelectEpisodesShowing\": \"Selectează {0} episoade dintre cele afișate\",\n  \"LabelSelectUser\": \"Selectare utilizator\",\n  \"LabelSelectUsers\": \"Selectare utilizatori\",\n  \"LabelSendEbookToDevice\": \"Trimite eCarte către...\",\n  \"LabelSequence\": \"Secvență\",\n  \"LabelSerial\": \"Serie\",\n  \"LabelSeries\": \"Serii\",\n  \"LabelSeriesName\": \"Numele Seriilor\",\n  \"LabelSeriesProgress\": \"Progresul Seriilor\",\n  \"LabelServerLogLevel\": \"Nivelul de Jurnal al Serverului\",\n  \"LabelServerYearReview\": \"Anul Serverului în Retrospectivă ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Setează ca principală\",\n  \"LabelSetEbookAsSupplementary\": \"Setează ca suplimentară\",\n  \"LabelSettingsAllowIframe\": \"Permite încorporarea intr-un iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Doar cărți audio\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Activarea acestei set[ri va ignora fișierele eBook daca acestea nu se află într-un dosar al unei cărți audio, caz în care vor fi setate ca eBook suplimentar\",\n  \"LabelSettingsBookshelfViewHelp\": \"Design scheumorf cu rafturi de lemn\",\n  \"LabelSettingsChromecastSupport\": \"Suport Chromecast\",\n  \"LabelSettingsDateFormat\": \"Formatul Datei\",\n  \"LabelSettingsEnableWatcher\": \"Urmărește în mod automat bibliotecile pentru schimbări\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Urmărește în mod automat biblioteca pentru schimbări\",\n  \"LabelShowAll\": \"Afișează tot\",\n  \"LabelSize\": \"Dimensiune\",\n  \"LabelSleepTimer\": \"Timer de somn\",\n  \"LabelStart\": \"Pornește\",\n  \"LabelStatsBestDay\": \"Ziua cea mai bună\",\n  \"LabelStatsDailyAverage\": \"Medie zilnică\",\n  \"LabelStatsDays\": \"Zile\",\n  \"LabelStatsDaysListened\": \"Zile ascultate\",\n  \"LabelStatsInARow\": \"la rând\",\n  \"LabelStatsItemsFinished\": \"Finalizate\",\n  \"LabelStatsMinutes\": \"minute\",\n  \"LabelStatsMinutesListening\": \"Minute ascultate\",\n  \"LabelStatsWeekListening\": \"Ascultare săptămânală\",\n  \"LabelTag\": \"Etichetă\",\n  \"LabelTags\": \"Etichete\",\n  \"LabelTheme\": \"Temă\",\n  \"LabelThemeDark\": \"Întunecat\",\n  \"LabelThemeLight\": \"Deschis\",\n  \"LabelTimeRemaining\": \"{0} rămase\",\n  \"LabelTitle\": \"Titlu\",\n  \"LabelTracks\": \"Fișiere audio\",\n  \"LabelType\": \"Tip\",\n  \"LabelUnknown\": \"Necunoscut\",\n  \"LabelUser\": \"Utilizator\",\n  \"LabelUsername\": \"Nume utilizator\",\n  \"LabelYearReviewHide\": \"Ascunde retrospectiva anului\",\n  \"LabelYearReviewShow\": \"Vezi retrospectiva anului\",\n  \"LabelYourBookmarks\": \"Semnele tale de carte\",\n  \"LabelYourProgress\": \"Progresul tău\",\n  \"MessageDownloadingEpisode\": \"Se descarcă episodul\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} episod(e) în așteptare pentru descărcare\",\n  \"MessageFeedURLWillBe\": \"Adresa fluxului va fi {0}\",\n  \"MessageFetching\": \"Se preiau date…\",\n  \"MessageLoading\": \"Se încarcă…\",\n  \"MessageMarkAsFinished\": \"Marchează ca finalizat\",\n  \"MessageNoBookmarks\": \"Fără semne de carte\",\n  \"MessageNoChapters\": \"Fără capitole\",\n  \"MessageNoCollections\": \"Fără colecții\",\n  \"MessageNoItems\": \"Niciun element\",\n  \"MessageNoItemsFound\": \"Nu s-au găsit elemente\",\n  \"MessageNoListeningSessions\": \"Nicio sesiune de ascultare\",\n  \"MessageNoPodcastsFound\": \"Nu s-au găsit podcasturi\",\n  \"MessageNoUpdatesWereNecessary\": \"Nu au fost necesare actualizări\",\n  \"MessageNoUserPlaylists\": \"Nu ai nicio listă de redare\",\n  \"MessagePodcastSearchField\": \"Introdu termenul de căutare sau URL-ul unui flux RSS\",\n  \"MessageReportBugsAndContribute\": \"Raportează erori, cere funcții noi și contribuie pe\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Atenționare: Majoritatea aplicațiilor de podcast cer ca URL-ul fluxului RSS să folosească HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Atenționare: unul sau mai multe episoade nu au data publicării (Pub Date). Unele aplicații de podcast o cer.\",\n  \"ToastBookmarkCreateFailed\": \"Nu s-a putut crea semnul de carte\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Nu s-a putut marca drept finalizat\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Nu s-a putut marca drept nefinalizat\",\n  \"ToastPlaylistCreateFailed\": \"Nu s-a putut crea lista de redare\",\n  \"ToastPodcastCreateFailed\": \"Nu s-a putut crea podcastul\",\n  \"ToastPodcastCreateSuccess\": \"Podcast creat cu succes\",\n  \"ToastRSSFeedCloseFailed\": \"Nu s-a putut închide fluxul RSS\",\n  \"ToastRSSFeedCloseSuccess\": \"Flux RSS închis\"\n}\n"
  },
  {
    "path": "client/strings/ru.json",
    "content": "{\n  \"ButtonAdd\": \"Добавить\",\n  \"ButtonAddApiKey\": \"Добавить API ключ\",\n  \"ButtonAddChapters\": \"Добавить главы\",\n  \"ButtonAddDevice\": \"Добавить устройство\",\n  \"ButtonAddLibrary\": \"Добавить библиотеку\",\n  \"ButtonAddPodcasts\": \"Добавить подкасты\",\n  \"ButtonAddUser\": \"Добавить пользователя\",\n  \"ButtonAddYourFirstLibrary\": \"Добавьте Вашу первую библиотеку\",\n  \"ButtonApply\": \"Применить\",\n  \"ButtonApplyChapters\": \"Применить главы\",\n  \"ButtonAuthors\": \"Авторы\",\n  \"ButtonBack\": \"Назад\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Заполнить из существующих\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Заполнить данные карты\",\n  \"ButtonBrowseForFolder\": \"Выбрать папку\",\n  \"ButtonCancel\": \"Отмена\",\n  \"ButtonCancelEncode\": \"Отменить кодирование\",\n  \"ButtonChangeRootPassword\": \"Поменять мастер пароль\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Скачать новые выпуски\",\n  \"ButtonChooseAFolder\": \"Выбор папки\",\n  \"ButtonChooseFiles\": \"Выбор файлов\",\n  \"ButtonClearFilter\": \"Очистить фильтр\",\n  \"ButtonClose\": \"Закрыть\",\n  \"ButtonCloseFeed\": \"Закрыть ленту\",\n  \"ButtonCloseSession\": \"Закрыть открытый сеанс\",\n  \"ButtonCollections\": \"Коллекции\",\n  \"ButtonConfigureScanner\": \"Конфигурация сканера\",\n  \"ButtonCreate\": \"Создать\",\n  \"ButtonCreateBackup\": \"Создать бэкап\",\n  \"ButtonDelete\": \"Удалить\",\n  \"ButtonDownloadQueue\": \"Очередь\",\n  \"ButtonEdit\": \"Редактировать\",\n  \"ButtonEditChapters\": \"Редактировать главы\",\n  \"ButtonEditPodcast\": \"Редактировать подкаст\",\n  \"ButtonEnable\": \"Включить\",\n  \"ButtonFireAndFail\": \"Пожар и неудача\",\n  \"ButtonFireOnTest\": \"Испытание на огнестойкость\",\n  \"ButtonForceReScan\": \"Принудительно пересканировать\",\n  \"ButtonFullPath\": \"Полный путь\",\n  \"ButtonHide\": \"Скрыть\",\n  \"ButtonHome\": \"Домой\",\n  \"ButtonIssues\": \"Проблемы\",\n  \"ButtonJumpBackward\": \"Назад\",\n  \"ButtonJumpForward\": \"Вперед\",\n  \"ButtonLatest\": \"Последнее\",\n  \"ButtonLibrary\": \"Библиотека\",\n  \"ButtonLogout\": \"Выход\",\n  \"ButtonLookup\": \"Найти\",\n  \"ButtonManageTracks\": \"Управление треками\",\n  \"ButtonMapChapterTitles\": \"Найти названия глав\",\n  \"ButtonMatchAllAuthors\": \"Найти всех авторов\",\n  \"ButtonMatchBooks\": \"Найти книги\",\n  \"ButtonNevermind\": \"Не важно\",\n  \"ButtonNext\": \"Следующий\",\n  \"ButtonNextChapter\": \"Следующая глава\",\n  \"ButtonNextItemInQueue\": \"Следующий элемент в очереди\",\n  \"ButtonOk\": \"Ок\",\n  \"ButtonOpenFeed\": \"Открыть ленту\",\n  \"ButtonOpenManager\": \"Открыть менеджер\",\n  \"ButtonPause\": \"Пауза\",\n  \"ButtonPlay\": \"Слушать\",\n  \"ButtonPlayAll\": \"Играть все\",\n  \"ButtonPlaying\": \"Проигрывается\",\n  \"ButtonPlaylists\": \"Плейлисты\",\n  \"ButtonPrevious\": \"Предыдущий\",\n  \"ButtonPreviousChapter\": \"Предыдущая глава\",\n  \"ButtonProbeAudioFile\": \"Сканирование аудиофайла\",\n  \"ButtonPurgeAllCache\": \"Очистить весь кэш\",\n  \"ButtonPurgeItemsCache\": \"Очистить кэш элементов\",\n  \"ButtonQueueAddItem\": \"Добавить в очередь\",\n  \"ButtonQueueRemoveItem\": \"Удалить из очереди\",\n  \"ButtonQuickEmbed\": \"Быстрое внедрение\",\n  \"ButtonQuickEmbedMetadata\": \"Быстрое встраивание метаданных\",\n  \"ButtonQuickMatch\": \"Быстрый поиск\",\n  \"ButtonReScan\": \"Пересканировать\",\n  \"ButtonRead\": \"Читать\",\n  \"ButtonReadLess\": \"Читать меньше\",\n  \"ButtonReadMore\": \"Читать больше\",\n  \"ButtonRefresh\": \"Обновить\",\n  \"ButtonRemove\": \"Удалить\",\n  \"ButtonRemoveAll\": \"Удалить всё\",\n  \"ButtonRemoveAllLibraryItems\": \"Удалить все элементы библиотеки\",\n  \"ButtonRemoveFromContinueListening\": \"Удалить из Продолжить слушать\",\n  \"ButtonRemoveFromContinueReading\": \"Удалить из Продолжить читать\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Удалить серию из Продолжить серию\",\n  \"ButtonReset\": \"Сбросить\",\n  \"ButtonResetToDefault\": \"Сборосить по умолчанию\",\n  \"ButtonRestore\": \"Восстановить\",\n  \"ButtonSave\": \"Сохранить\",\n  \"ButtonSaveAndClose\": \"Сохранить и закрыть\",\n  \"ButtonSaveTracklist\": \"Сохранить список треков\",\n  \"ButtonScan\": \"Сканировать\",\n  \"ButtonScanLibrary\": \"Сканировать библиотеку\",\n  \"ButtonScrollLeft\": \"Перемотать влево\",\n  \"ButtonScrollRight\": \"Перемотать вправо\",\n  \"ButtonSearch\": \"Поиск\",\n  \"ButtonSelectFolderPath\": \"Выберите путь папки\",\n  \"ButtonSeries\": \"Серии\",\n  \"ButtonSetChaptersFromTracks\": \"Установить главы из треков\",\n  \"ButtonShare\": \"Поделиться\",\n  \"ButtonShiftTimes\": \"Смещение\",\n  \"ButtonShow\": \"Показать\",\n  \"ButtonStartM4BEncode\": \"Начать кодирование M4B\",\n  \"ButtonStartMetadataEmbed\": \"Начать встраивание метаданных\",\n  \"ButtonStats\": \"Статистика\",\n  \"ButtonSubmit\": \"Применить\",\n  \"ButtonTest\": \"Тест\",\n  \"ButtonUnlinkOpenId\": \"Отключить OpenID\",\n  \"ButtonUpload\": \"Загрузить\",\n  \"ButtonUploadBackup\": \"Загрузить бэкап\",\n  \"ButtonUploadCover\": \"Загрузить обложку\",\n  \"ButtonUploadOPMLFile\": \"Загрузить Файл OPML\",\n  \"ButtonUserDelete\": \"Удалить пользователя {0}\",\n  \"ButtonUserEdit\": \"Редактировать пользователя {0}\",\n  \"ButtonViewAll\": \"Посмотреть все\",\n  \"ButtonYes\": \"Да\",\n  \"ErrorUploadFetchMetadataAPI\": \"Ошибка при получении метаданных\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Не удалось получить метаданные - попробуйте обновить название и/или автора\",\n  \"ErrorUploadLacksTitle\": \"Название должно быть заполнено\",\n  \"HeaderAccount\": \"Учетная запись\",\n  \"HeaderAddCustomMetadataProvider\": \"Добавление пользовательского поставщика метаданных\",\n  \"HeaderAdvanced\": \"Дополнительно\",\n  \"HeaderApiKeys\": \"API ключи\",\n  \"HeaderAppriseNotificationSettings\": \"Настройки оповещений\",\n  \"HeaderAudioTracks\": \"Аудио треки\",\n  \"HeaderAudiobookTools\": \"Инструменты файлов аудиокниг\",\n  \"HeaderAuthentication\": \"Аутентификация\",\n  \"HeaderBackups\": \"Бэкапы\",\n  \"HeaderBulkChapterModal\": \"Добавление нескольких глав\",\n  \"HeaderChangePassword\": \"Изменить пароль\",\n  \"HeaderChapters\": \"Главы\",\n  \"HeaderChooseAFolder\": \"Выберите папку\",\n  \"HeaderCollection\": \"Коллекция\",\n  \"HeaderCollectionItems\": \"Элементы коллекции\",\n  \"HeaderCover\": \"Обложка\",\n  \"HeaderCurrentDownloads\": \"Текущие закачки\",\n  \"HeaderCustomMessageOnLogin\": \"Пользовательское сообщение при входе\",\n  \"HeaderCustomMetadataProviders\": \"Пользовательские поставщики метаданных\",\n  \"HeaderDetails\": \"Подробности\",\n  \"HeaderDownloadQueue\": \"Очередь скачивания\",\n  \"HeaderEbookFiles\": \"Файлы e-книг\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"Настройки Email\",\n  \"HeaderEpisodes\": \"Выпуски\",\n  \"HeaderEreaderDevices\": \"Устройства E-книга\",\n  \"HeaderEreaderSettings\": \"Настройки E-ридера\",\n  \"HeaderFiles\": \"Файлы\",\n  \"HeaderFindChapters\": \"Найти главы\",\n  \"HeaderIgnoredFiles\": \"Игнорируемые Файлы\",\n  \"HeaderItemFiles\": \"Файлы элемента\",\n  \"HeaderItemMetadataUtils\": \"Утилиты\",\n  \"HeaderLastListeningSession\": \"Последний сеанс прослушивания\",\n  \"HeaderLatestEpisodes\": \"Последние выпуски\",\n  \"HeaderLibraries\": \"Библиотеки\",\n  \"HeaderLibraryFiles\": \"Файлы библиотеки\",\n  \"HeaderLibraryStats\": \"Статистика библиотеки\",\n  \"HeaderListeningSessions\": \"Сеансы\",\n  \"HeaderListeningStats\": \"Статистика прослушивания\",\n  \"HeaderLogin\": \"Логин\",\n  \"HeaderLogs\": \"Логи\",\n  \"HeaderManageGenres\": \"Редактировать жанры\",\n  \"HeaderManageTags\": \"Редактировать теги\",\n  \"HeaderMapDetails\": \"Найти подробности\",\n  \"HeaderMatch\": \"Поиск\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Порядок приоритета метаданных\",\n  \"HeaderMetadataToEmbed\": \"Метаинформация для встраивания\",\n  \"HeaderNewAccount\": \"Новая учетная запись\",\n  \"HeaderNewApiKey\": \"Новый API ключ\",\n  \"HeaderNewLibrary\": \"Новая библиотека\",\n  \"HeaderNotificationCreate\": \"Создать уведомление\",\n  \"HeaderNotificationUpdate\": \"Уведомление об обновлении\",\n  \"HeaderNotifications\": \"Уведомления\",\n  \"HeaderOpenIDConnectAuthentication\": \"Аутентификация OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Открытые сеансы прослушивания\",\n  \"HeaderOpenRSSFeed\": \"Открыть RSS-ленту\",\n  \"HeaderOtherFiles\": \"Другие файлы\",\n  \"HeaderPasswordAuthentication\": \"Аутентификация по паролю\",\n  \"HeaderPermissions\": \"Разрешения\",\n  \"HeaderPlayerQueue\": \"Очередь воспроизведения\",\n  \"HeaderPlayerSettings\": \"Настройки плеера\",\n  \"HeaderPlaylist\": \"Плейлист\",\n  \"HeaderPlaylistItems\": \"Элементы списка воспроизведения\",\n  \"HeaderPodcastsToAdd\": \"Подкасты для добавления\",\n  \"HeaderPresets\": \"Пресеты\",\n  \"HeaderPreviewCover\": \"Предпросмотр обложки\",\n  \"HeaderRSSFeedGeneral\": \"Сведения о RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-лента открыта\",\n  \"HeaderRSSFeeds\": \"RSS-ленты\",\n  \"HeaderRemoveEpisode\": \"Удалить выпуск\",\n  \"HeaderRemoveEpisodes\": \"Удалить {0} выпусков\",\n  \"HeaderSavedMediaProgress\": \"Прогресс медиа сохранен\",\n  \"HeaderSchedule\": \"Планировщик\",\n  \"HeaderScheduleEpisodeDownloads\": \"Запланировать автоматическое скачивание выпусков\",\n  \"HeaderScheduleLibraryScans\": \"Планировщик автоматического сканирования библиотеки\",\n  \"HeaderSession\": \"Сеансы\",\n  \"HeaderSetBackupSchedule\": \"Установить планировщик бэкапов\",\n  \"HeaderSettings\": \"Настройки\",\n  \"HeaderSettingsDisplay\": \"Дисплей\",\n  \"HeaderSettingsExperimental\": \"Экспериментальные функции\",\n  \"HeaderSettingsGeneral\": \"Основные\",\n  \"HeaderSettingsScanner\": \"Сканер\",\n  \"HeaderSettingsSecurity\": \"Безопасность\",\n  \"HeaderSettingsWebClient\": \"Веб-клиент\",\n  \"HeaderSleepTimer\": \"Таймер сна\",\n  \"HeaderStatsLargestItems\": \"Самые большые элементы\",\n  \"HeaderStatsLongestItems\": \"Самые длинные элементы (часов)\",\n  \"HeaderStatsMinutesListeningChart\": \"Минут прослушивания (последние 7 дней)\",\n  \"HeaderStatsRecentSessions\": \"Последние сеансы\",\n  \"HeaderStatsTop10Authors\": \"Топ 10 авторов\",\n  \"HeaderStatsTop5Genres\": \"Топ 5 жанров\",\n  \"HeaderTableOfContents\": \"Содержание\",\n  \"HeaderTools\": \"Инструменты\",\n  \"HeaderUpdateAccount\": \"Обновить учетную запись\",\n  \"HeaderUpdateApiKey\": \"Обновить API ключ\",\n  \"HeaderUpdateAuthor\": \"Обновить автора\",\n  \"HeaderUpdateDetails\": \"Обновить детали\",\n  \"HeaderUpdateLibrary\": \"Обновить библиотеку\",\n  \"HeaderUsers\": \"Пользователи\",\n  \"HeaderYearReview\": \"Итоги {0} года\",\n  \"HeaderYourStats\": \"Ваша статистика\",\n  \"LabelAbridged\": \"Сокращенная форма\",\n  \"LabelAbridgedChecked\": \"Сокращено (отмечено)\",\n  \"LabelAbridgedUnchecked\": \"Полное издание (не отмечено)\",\n  \"LabelAccessibleBy\": \"Доступ\",\n  \"LabelAccountType\": \"Тип учетной записи\",\n  \"LabelAccountTypeAdmin\": \"Администратор\",\n  \"LabelAccountTypeGuest\": \"Гость\",\n  \"LabelAccountTypeUser\": \"Пользователь\",\n  \"LabelActivities\": \"События\",\n  \"LabelActivity\": \"Активность\",\n  \"LabelAddToCollection\": \"Добавить в коллекцию\",\n  \"LabelAddToCollectionBatch\": \"Добавить {0} книг в коллекцию\",\n  \"LabelAddToPlaylist\": \"Добавить в плейлист\",\n  \"LabelAddToPlaylistBatch\": \"Добавить {0} элементов в плейлист\",\n  \"LabelAddedAt\": \"Дата добавления\",\n  \"LabelAddedDate\": \"Добавлено {0}\",\n  \"LabelAdminUsersOnly\": \"Только для пользователей с правами администратора\",\n  \"LabelAll\": \"Все\",\n  \"LabelAllEpisodesDownloaded\": \"Все выпуски скачаны\",\n  \"LabelAllUsers\": \"Все пользователи\",\n  \"LabelAllUsersExcludingGuests\": \"Все пользователи, кроме гостей\",\n  \"LabelAllUsersIncludingGuests\": \"Все пользователи, включая гостей\",\n  \"LabelAlreadyInYourLibrary\": \"Уже в Вашей библиотеке\",\n  \"LabelApiKeyCreated\": \"API ключ \\\"{0}\\\" успешно создан.\",\n  \"LabelApiKeyCreatedDescription\": \"Обязательно скопируйте API-ключ сейчас, так как вы больше не сможете его увидеть.\",\n  \"LabelApiKeyUser\": \"Управление от пользователя\",\n  \"LabelApiKeyUserDescription\": \"Этот API-ключ будет иметь те же права доступа, что и пользователь, от имени которого он действует. В логах это будет отображаться так же, как если бы пользователь отправлял запрос.\",\n  \"LabelApiToken\": \"Токен API\",\n  \"LabelAppend\": \"Добавить\",\n  \"LabelAudioBitrate\": \"Битрейт (напр. 128k)\",\n  \"LabelAudioChannels\": \"Аудиоканалы (1 или 2)\",\n  \"LabelAudioCodec\": \"Аудиокодек\",\n  \"LabelAuthor\": \"Автор\",\n  \"LabelAuthorFirstLast\": \"Автор (Имя Фамилия)\",\n  \"LabelAuthorLastFirst\": \"Автор (Фамилия, Имя)\",\n  \"LabelAuthors\": \"Авторы\",\n  \"LabelAutoDownloadEpisodes\": \"Скачивать выпуски автоматически\",\n  \"LabelAutoFetchMetadata\": \"Автоматическое извлечение метаданных\",\n  \"LabelAutoFetchMetadataHelp\": \"Извлекает метаданные для названия, автора и серии для упрощения загрузки. После загрузки может потребоваться сопоставление дополнительных метаданных.\",\n  \"LabelAutoLaunch\": \"Автозапуск\",\n  \"LabelAutoLaunchDescription\": \"Редирект на провайдера аутентификации автоматически при переходе на страницу входа (путь ручного переопределения <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Автоматическая регистрация\",\n  \"LabelAutoRegisterDescription\": \"Автоматическое создание новых пользователей после входа в систему\",\n  \"LabelBackToUser\": \"Назад к пользователю\",\n  \"LabelBackupAudioFiles\": \"Резервное копирование аудиофайлов\",\n  \"LabelBackupLocation\": \"Путь для бэкапов\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Автоматическое резервное копирование\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Бэкапы сохраняются в /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Максимальный размер бэкапа (в GB) (0 для неограниченного лимита)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.\",\n  \"LabelBackupsNumberToKeep\": \"Сохранять бэкапов\",\n  \"LabelBackupsNumberToKeepHelp\": \"За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.\",\n  \"LabelBitrate\": \"Битрейт\",\n  \"LabelBonus\": \"Бонус\",\n  \"LabelBooks\": \"Книги\",\n  \"LabelButtonText\": \"Текст кнопки\",\n  \"LabelByAuthor\": \"от {0}\",\n  \"LabelChangePassword\": \"Изменить пароль\",\n  \"LabelChannels\": \"Ленты\",\n  \"LabelChapterCount\": \"{0} Главы\",\n  \"LabelChapterTitle\": \"Название главы\",\n  \"LabelChapters\": \"Главы\",\n  \"LabelChaptersFound\": \"глав найдено\",\n  \"LabelClickForMoreInfo\": \"Нажмите, чтобы узнать больше\",\n  \"LabelClickToUseCurrentValue\": \"Нажмите, чтобы использовать текущее значение\",\n  \"LabelClosePlayer\": \"Закрыть проигрыватель\",\n  \"LabelCodec\": \"Кодек\",\n  \"LabelCollapseSeries\": \"Свернуть серии\",\n  \"LabelCollapseSubSeries\": \"Свернуть подсерию\",\n  \"LabelCollection\": \"Коллекция\",\n  \"LabelCollections\": \"Коллекции\",\n  \"LabelComplete\": \"Завершить\",\n  \"LabelConfirmPassword\": \"Подтвердить пароль\",\n  \"LabelContinueListening\": \"Продолжить слушать\",\n  \"LabelContinueReading\": \"Продолжить чтение\",\n  \"LabelContinueSeries\": \"Продолжить серию\",\n  \"LabelCorsAllowed\": \"Разрешённые CORS источники\",\n  \"LabelCover\": \"Обложка\",\n  \"LabelCoverImageURL\": \"URL изображения обложки\",\n  \"LabelCoverProvider\": \"Провайдер обложек\",\n  \"LabelCreatedAt\": \"Создан\",\n  \"LabelCronExpression\": \"Выражение Cron\",\n  \"LabelCurrent\": \"Текущий\",\n  \"LabelCurrently\": \"Текущее:\",\n  \"LabelCustomCronExpression\": \"Пользовательское выражение Cron:\",\n  \"LabelDatetime\": \"Дата и время\",\n  \"LabelDays\": \"Дней\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Удалить из файловой системы (снимите флажок, чтобы удалить только из базы данных)\",\n  \"LabelDescription\": \"Описание\",\n  \"LabelDeselectAll\": \"Снять выделение\",\n  \"LabelDetectedPattern\": \"Обнаруженный образец:\",\n  \"LabelDevice\": \"Устройство\",\n  \"LabelDeviceInfo\": \"Информация об устройстве\",\n  \"LabelDeviceIsAvailableTo\": \"Устройство доступно для...\",\n  \"LabelDirectory\": \"Каталог\",\n  \"LabelDiscFromFilename\": \"Диск из Имени файла\",\n  \"LabelDiscFromMetadata\": \"Диск из Метаданных\",\n  \"LabelDiscover\": \"Не начато\",\n  \"LabelDownload\": \"Скачать\",\n  \"LabelDownloadNEpisodes\": \"Скачать {0} выпусков\",\n  \"LabelDownloadable\": \"Загружаемый\",\n  \"LabelDuration\": \"Длительность\",\n  \"LabelDurationComparisonExactMatch\": \"(точное совпадение)\",\n  \"LabelDurationComparisonLonger\": \"({0} дольше)\",\n  \"LabelDurationComparisonShorter\": \"({0} короче)\",\n  \"LabelDurationFound\": \"Найденная длина:\",\n  \"LabelEbook\": \"E-книга\",\n  \"LabelEbooks\": \"E-книги\",\n  \"LabelEdit\": \"Редактировать\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Адрес От\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Отклонение неавторизованных сертификатов\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Отключение проверки SSL-сертификата может подвергнуть ваше подключение рискам безопасности, таким как атаки типа \\\"man-in-the-middle\\\". Отключайте эту опцию только в том случае, если вы понимаете последствия и доверяете почтовому серверу, к которому подключаетесь.\",\n  \"LabelEmailSettingsSecure\": \"Безопасно\",\n  \"LabelEmailSettingsSecureHelp\": \"Если значение истинно, то соединение будет использовать TLS при подключении к серверу. Если значение ложно, то TLS будет использован, если сервер поддерживает расширение STARTTLS. В большинстве случаев установите это значение в истину, если вы подключаетесь к порту 465. Для порта 587 или 25 оставьте значение ложным. (из nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Тестовый адрес\",\n  \"LabelEmbeddedCover\": \"Встроенная обложка\",\n  \"LabelEnable\": \"Включить\",\n  \"LabelEncodingBackupLocation\": \"Резервная копия ваших оригинальных аудиофайлов будет сохранена в:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Главы не встраиваются в многодорожечные аудиокниги.\",\n  \"LabelEncodingClearItemCache\": \"Обязательно периодически очищайте кэш элементов.\",\n  \"LabelEncodingFinishedM4B\": \"Готовый M4B будет помещен в вашу папку с аудиокнигами по адресу:\",\n  \"LabelEncodingInfoEmbedded\": \"Метаданные будут встроены в звуковые дорожки внутри папки вашей аудиокниги.\",\n  \"LabelEncodingStartedNavigation\": \"Как только задача будет запущена, вы сможете перейти с этой страницы.\",\n  \"LabelEncodingTimeWarning\": \"Кодирование может занять до 30 минут.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Предупреждение: Не обновляйте эти настройки, если вы не знакомы с параметрами кодировки ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Если у вас отключено наблюдение за папкой, вам нужно будет повторно пересканировать эту аудиокнигу.\",\n  \"LabelEnd\": \"Конец\",\n  \"LabelEndOfChapter\": \"Конец главы\",\n  \"LabelEpisode\": \"Выпуск\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Выпуск, не связанный с RSS-лентой\",\n  \"LabelEpisodeNumber\": \"Выпуск #{0}\",\n  \"LabelEpisodeTitle\": \"Название выпуска\",\n  \"LabelEpisodeType\": \"Тип выпуска\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL-адрес выпуска из RSS-ленты\",\n  \"LabelEpisodes\": \"Выпуски\",\n  \"LabelEpisodic\": \"Эпизодический\",\n  \"LabelExample\": \"Пример\",\n  \"LabelExpandSeries\": \"Развернуть серию\",\n  \"LabelExpandSubSeries\": \"Развернуть подсерию\",\n  \"LabelExpired\": \"Истекший\",\n  \"LabelExpiresAt\": \"Истекает в\",\n  \"LabelExpiresInSeconds\": \"Истекает через (секунд)\",\n  \"LabelExpiresNever\": \"Никогда\",\n  \"LabelExplicit\": \"18+\",\n  \"LabelExplicitChecked\": \"18+ (отмечено)\",\n  \"LabelExplicitUnchecked\": \"+18 (не отмечено)\",\n  \"LabelExportOPML\": \"Экспорт OPML\",\n  \"LabelFeedURL\": \"URL-адрес ленты\",\n  \"LabelFetchingMetadata\": \"Извлечение метаданных\",\n  \"LabelFile\": \"Файл\",\n  \"LabelFileBirthtime\": \"Дата создания\",\n  \"LabelFileBornDate\": \"Создан {0}\",\n  \"LabelFileModified\": \"Дата модификации\",\n  \"LabelFileModifiedDate\": \"Изменено {0}\",\n  \"LabelFilename\": \"Имя файла\",\n  \"LabelFilterByUser\": \"Фильтр по пользователю\",\n  \"LabelFindEpisodes\": \"Найти выпуски\",\n  \"LabelFinished\": \"Закончен\",\n  \"LabelFinishedDate\": \"Завершено {0}\",\n  \"LabelFolder\": \"Папка\",\n  \"LabelFolders\": \"Папки\",\n  \"LabelFontBold\": \"Жирный\",\n  \"LabelFontBoldness\": \"Жирность шрифта\",\n  \"LabelFontFamily\": \"Семейство шрифта\",\n  \"LabelFontItalic\": \"Курсив\",\n  \"LabelFontScale\": \"Масштаб шрифта\",\n  \"LabelFontStrikethrough\": \"Зачеркнутый\",\n  \"LabelFormat\": \"Формат\",\n  \"LabelFull\": \"Полный\",\n  \"LabelGenre\": \"Жанр\",\n  \"LabelGenres\": \"Жанры\",\n  \"LabelHardDeleteFile\": \"Жесткое удаление файла\",\n  \"LabelHasEbook\": \"Есть e-книга\",\n  \"LabelHasSupplementaryEbook\": \"Есть дополнительная e-книга\",\n  \"LabelHideSubtitles\": \"Скрыть серии\",\n  \"LabelHighestPriority\": \"Наивысший приоритет\",\n  \"LabelHost\": \"Хост\",\n  \"LabelHour\": \"Часы\",\n  \"LabelHours\": \"Часов\",\n  \"LabelIcon\": \"Иконка\",\n  \"LabelImageURLFromTheWeb\": \"URL-адрес изображения из Интернета\",\n  \"LabelInProgress\": \"В процессе\",\n  \"LabelIncludeInTracklist\": \"Включать в список воспроизведения\",\n  \"LabelIncomplete\": \"Не завершен\",\n  \"LabelInterval\": \"Интервал\",\n  \"LabelIntervalCustomDailyWeekly\": \"Пользовательские ежедневно/еженедельно\",\n  \"LabelIntervalEvery12Hours\": \"Каждые 12 часов\",\n  \"LabelIntervalEvery15Minutes\": \"Каждые 15 минут\",\n  \"LabelIntervalEvery2Hours\": \"Каждые 2 часа\",\n  \"LabelIntervalEvery30Minutes\": \"Каждые 30 минут\",\n  \"LabelIntervalEvery6Hours\": \"Каждые 6 часов\",\n  \"LabelIntervalEveryDay\": \"Каждый день\",\n  \"LabelIntervalEveryHour\": \"Каждый час\",\n  \"LabelIntervalEveryMinute\": \"Каждую минуту\",\n  \"LabelInvert\": \"Инвертировать\",\n  \"LabelItem\": \"Элемент\",\n  \"LabelJumpBackwardAmount\": \"Прыжок назад на величину\",\n  \"LabelJumpForwardAmount\": \"Прыжок вперед на величину\",\n  \"LabelLanguage\": \"Язык\",\n  \"LabelLanguageDefaultServer\": \"Язык сервера по умолчанию\",\n  \"LabelLanguages\": \"Языки\",\n  \"LabelLastBookAdded\": \"Последняя книга добавлена\",\n  \"LabelLastBookUpdated\": \"Последняя книга обновлена\",\n  \"LabelLastProgressDate\": \"Последний прогресс: {0}\",\n  \"LabelLastSeen\": \"Последнее сканирование\",\n  \"LabelLastTime\": \"Последний по времени\",\n  \"LabelLastUpdate\": \"Последний обновленный\",\n  \"LabelLayout\": \"Макет\",\n  \"LabelLayoutSinglePage\": \"Одна страница\",\n  \"LabelLayoutSplitPage\": \"Разделенная страница\",\n  \"LabelLess\": \"Менее\",\n  \"LabelLibrariesAccessibleToUser\": \"Библиотеки доступные для пользователя\",\n  \"LabelLibrary\": \"Библиотека\",\n  \"LabelLibraryFilterSublistEmpty\": \"Нет {0}\",\n  \"LabelLibraryItem\": \"Элемент библиотеки\",\n  \"LabelLibraryName\": \"Имя библиотеки\",\n  \"LabelLibrarySortByProgress\": \"Прогресс: Последнее обновление\",\n  \"LabelLibrarySortByProgressFinished\": \"Прогресс: Завершено\",\n  \"LabelLibrarySortByProgressStarted\": \"Прогресс: Начато\",\n  \"LabelLimit\": \"Лимит\",\n  \"LabelLineSpacing\": \"Межстрочный интервал\",\n  \"LabelListenAgain\": \"Послушать снова\",\n  \"LabelLogLevelDebug\": \"Debug\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Предупреждение\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Искать новые выпуски после этой даты\",\n  \"LabelLowestPriority\": \"Самый низкий приоритет\",\n  \"LabelMatchConfidence\": \"Уверенность\",\n  \"LabelMatchExistingUsersBy\": \"Сопоставление существующих пользователей по\",\n  \"LabelMatchExistingUsersByDescription\": \"Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа\",\n  \"LabelMaxEpisodesToDownload\": \"Максимальное количество выпусков для скачивания. Используйте 0 для неограниченного количества.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Максимальное количество новых выпусков для скачивания за один раз\",\n  \"LabelMaxEpisodesToKeep\": \"Максимальное количество сохраняемых выпусков\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Значение 0 не устанавливает максимального ограничения. После автоматической скачивании нового выпуска самый старый будет удалён, если у вас их уже более X выпусков. При этом будет удалён только 1 выпуск за каждое новое скачивание.\",\n  \"LabelMediaPlayer\": \"Медиа проигрыватель\",\n  \"LabelMediaType\": \"Тип медиа\",\n  \"LabelMetaTag\": \"Мета тег\",\n  \"LabelMetaTags\": \"Мета теги\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Источники метаданных с более высоким приоритетом будут переопределять источники метаданных с более низким приоритетом\",\n  \"LabelMetadataProvider\": \"Провайдер\",\n  \"LabelMinute\": \"Минуты\",\n  \"LabelMinutes\": \"Минуты\",\n  \"LabelMissing\": \"Отсутствует\",\n  \"LabelMissingEbook\": \"Нет e-книги\",\n  \"LabelMissingSupplementaryEbook\": \"Нет дополнительной e-книги\",\n  \"LabelMobileRedirectURIs\": \"Разрешенные URI перенаправления с мобильных устройств\",\n  \"LabelMobileRedirectURIsDescription\": \"Это белый список допустимых URI перенаправления для мобильных приложений. По умолчанию используется <code>audiobookshelf://oauth</code>, который можно удалить или дополнить дополнительными URI для интеграции со сторонними приложениями. Использование звездочки (<code>*</code>) в качестве единственной записи разрешает любой URI.\",\n  \"LabelMore\": \"Еще\",\n  \"LabelMoreInfo\": \"Больше информации\",\n  \"LabelName\": \"Имя\",\n  \"LabelNarrator\": \"Читает\",\n  \"LabelNarrators\": \"Чтецы\",\n  \"LabelNew\": \"Новый\",\n  \"LabelNewPassword\": \"Новый пароль\",\n  \"LabelNewestAuthors\": \"Новые авторы\",\n  \"LabelNewestEpisodes\": \"Новые выпуски\",\n  \"LabelNextBackupDate\": \"Следующая дата бэкапирования\",\n  \"LabelNextChapters\": \"Следующие главы будут:\",\n  \"LabelNextScheduledRun\": \"Следущий запланированный запуск\",\n  \"LabelNoApiKeys\": \"API ключи отсутствуют\",\n  \"LabelNoCustomMetadataProviders\": \"Нет пользовательских поставщиков метаданных\",\n  \"LabelNoEpisodesSelected\": \"Нет выбранных выпусков\",\n  \"LabelNotFinished\": \"Не завершено\",\n  \"LabelNotStarted\": \"Не запущено\",\n  \"LabelNotes\": \"Заметки\",\n  \"LabelNotificationAppriseURL\": \"URL(ы) для извещений\",\n  \"LabelNotificationAvailableVariables\": \"Доступные переменные\",\n  \"LabelNotificationBodyTemplate\": \"Шаблон тела\",\n  \"LabelNotificationEvent\": \"Событие оповещения\",\n  \"LabelNotificationTitleTemplate\": \"Шаблон заголовка\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Макс. попыток\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Уведомления будут выключены если произойдет ошибка отправки данное количество раз\",\n  \"LabelNotificationsMaxQueueSize\": \"Макс. размер очереди для событий уведомлений\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"События ограничены 1 в секунду. События будут игнорированы если в очереди максимальное количество. Это предотвращает спам сообщениями.\",\n  \"LabelNumberOfBooks\": \"Количество книг\",\n  \"LabelNumberOfChapters\": \"Кол-во глав:\",\n  \"LabelNumberOfEpisodes\": \"# из выпусков\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Имя утверждения OpenID, содержащего расширенные разрешения на действия пользователя в приложении, которые будут применяться к ролям, не являющимся администраторами (<b>если они настроены</b>). Если утверждение отсутствует в ответе, в доступе к ABS будет отказано. Если одна опция отсутствует, она будет рассматриваться как <code>false</code>. Убедитесь, что утверждение поставщика удостоверений соответствует ожидаемой структуре:\",\n  \"LabelOpenIDClaims\": \"Оставьте следующие параметры пустыми, чтобы отключить расширенное назначение групп и разрешений, будет автоматически присвоена группа «Пользователь».\",\n  \"LabelOpenIDGroupClaimDescription\": \"Имя утверждения OpenID, содержащего список групп пользователя. Обычно их называют <code>groups</code>. <b>Если эта настройка</b> настроена, приложение будет автоматически назначать роли на основе членства пользователя в группах при условии, что эти группы названы в утверждении без учета регистра \\\"admin\\\", \\\"user\\\" или \\\"guest\\\". Утверждение должно содержать список, и если пользователь принадлежит к нескольким группам, то приложение назначит роль, соответствующую самому высокому уровню доступа. Если ни одна из групп не совпадает, доступ будет запрещен.\",\n  \"LabelOpenRSSFeed\": \"Открыть RSS-ленту\",\n  \"LabelOverwrite\": \"Перезаписать\",\n  \"LabelPaginationPageXOfY\": \"Страница {0} из {1}\",\n  \"LabelPassword\": \"Пароль\",\n  \"LabelPath\": \"Путь\",\n  \"LabelPermanent\": \"Постоянный\",\n  \"LabelPermissionsAccessAllLibraries\": \"Есть доступ ко всем библиотекам\",\n  \"LabelPermissionsAccessAllTags\": \"Есть доступ ко всем тегам\",\n  \"LabelPermissionsAccessExplicitContent\": \"Есть доступ к явному содержимому\",\n  \"LabelPermissionsCreateEreader\": \"Можно создать читалку\",\n  \"LabelPermissionsDelete\": \"Может удалять\",\n  \"LabelPermissionsDownload\": \"Может скачивать\",\n  \"LabelPermissionsUpdate\": \"Может обновлять\",\n  \"LabelPermissionsUpload\": \"Может закачивать\",\n  \"LabelPersonalYearReview\": \"Итоги прошедшего года ({0})\",\n  \"LabelPhotoPathURL\": \"Путь к фото/URL\",\n  \"LabelPlayMethod\": \"Метод воспроизведения\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Величина увеличения/уменьшения скорости воспроизведения\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} из {1}\",\n  \"LabelPlaylists\": \"Плейлисты\",\n  \"LabelPodcast\": \"Подкаст\",\n  \"LabelPodcastSearchRegion\": \"Регион поиска подкастов\",\n  \"LabelPodcastType\": \"Тип подкаста\",\n  \"LabelPodcasts\": \"Подкасты\",\n  \"LabelPort\": \"Порт\",\n  \"LabelPrefixesToIgnore\": \"Игнорируемые префиксы (без учета регистра)\",\n  \"LabelPreventIndexing\": \"Запретить индексацию фида каталогами подкастов iTunes и Google\",\n  \"LabelPrimaryEbook\": \"Основная e-книга\",\n  \"LabelProgress\": \"Прогресс\",\n  \"LabelProvider\": \"Провайдер\",\n  \"LabelProviderAuthorizationValue\": \"Значение заголовка авторизации\",\n  \"LabelPubDate\": \"Дата публикации\",\n  \"LabelPublishYear\": \"Год публикации\",\n  \"LabelPublishedDate\": \"Опубликовано {0}\",\n  \"LabelPublishedDecade\": \"Декада публикации\",\n  \"LabelPublishedDecades\": \"Декады публикации\",\n  \"LabelPublisher\": \"Издатель\",\n  \"LabelPublishers\": \"Издатели\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Пользовательский Email владельца\",\n  \"LabelRSSFeedCustomOwnerName\": \"Пользовательское Имя владельца\",\n  \"LabelRSSFeedOpen\": \"Открыть RSS-ленту\",\n  \"LabelRSSFeedPreventIndexing\": \"Запретить индексирование\",\n  \"LabelRSSFeedSlug\": \"Ключевое слово RSS-ленты\",\n  \"LabelRSSFeedURL\": \"URL-адрес RSS-ленты\",\n  \"LabelRandomly\": \"Случайно\",\n  \"LabelReAddSeriesToContinueListening\": \"Повторно добавить серию в «Продолжить слушать»\",\n  \"LabelRead\": \"Читать\",\n  \"LabelReadAgain\": \"Читать снова\",\n  \"LabelReadEbookWithoutProgress\": \"Читать e-книгу без сохранения прогресса\",\n  \"LabelRecentSeries\": \"Последние серии\",\n  \"LabelRecentlyAdded\": \"Недавно добавленные\",\n  \"LabelRecommended\": \"Рекомендованное\",\n  \"LabelRedo\": \"Повторить\",\n  \"LabelRegion\": \"Регион\",\n  \"LabelReleaseDate\": \"Дата выхода\",\n  \"LabelRemoveAllMetadataAbs\": \"Удалите все файлы metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Удалите все файлы metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Удалить вступление и концовку Audible из глав\",\n  \"LabelRemoveCover\": \"Удалить обложку\",\n  \"LabelRemoveMetadataFile\": \"Удаление файлов метаданных в папках элементов библиотеки\",\n  \"LabelRemoveMetadataFileHelp\": \"Удалите все файлы metadata.json и metadata.abs из ваших папок {0}.\",\n  \"LabelRowsPerPage\": \"Строк на странице\",\n  \"LabelSearchTerm\": \"Поисковый запрос\",\n  \"LabelSearchTitle\": \"Поиск по названию\",\n  \"LabelSearchTitleOrASIN\": \"Поиск по названию или ASIN\",\n  \"LabelSeason\": \"Сезон\",\n  \"LabelSeasonNumber\": \"Сезон #{0}\",\n  \"LabelSelectAll\": \"Выбрать все\",\n  \"LabelSelectAllEpisodes\": \"Выбрать все выпуски\",\n  \"LabelSelectEpisodesShowing\": \"Выберите {0} выпусков для отображения\",\n  \"LabelSelectUser\": \"Выбрать пользователя\",\n  \"LabelSelectUsers\": \"Выбор пользователей\",\n  \"LabelSendEbookToDevice\": \"Отправить e-книгу в...\",\n  \"LabelSequence\": \"Последовательность\",\n  \"LabelSerial\": \"Серийный\",\n  \"LabelSeries\": \"Серия\",\n  \"LabelSeriesName\": \"Имя серии\",\n  \"LabelSeriesProgress\": \"Прогресс серии\",\n  \"LabelServerLogLevel\": \"Уровень журнала сервера\",\n  \"LabelServerYearReview\": \"Итоги года всего сервера ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Установить как основную\",\n  \"LabelSetEbookAsSupplementary\": \"Установить как дополнительную\",\n  \"LabelSettingsAllowIframe\": \"Разрешить встраивание в iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Только аудиокниги\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги\",\n  \"LabelSettingsBookshelfViewHelp\": \"Конструкция с деревянными полками\",\n  \"LabelSettingsChromecastSupport\": \"Поддержка Chromecast\",\n  \"LabelSettingsDateFormat\": \"Формат даты\",\n  \"LabelSettingsEnableWatcher\": \"Автоматически отслеживать изменения в библиотеках\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Автоматический просмотр библиотеки на изменение\",\n  \"LabelSettingsEnableWatcherHelp\": \"Включает автоматическое добавление/обновление элементов при обнаружении изменений файлов. *Требуется перезапуск сервера\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Разрешение содержимого epub с скриптами\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Разрешить файлам epub выполнять скрипты. Рекомендуется отключать этот параметр, если вы не доверяете источнику файлов epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Экспериментальные функции\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.\",\n  \"LabelSettingsFindCovers\": \"Найти обложки\",\n  \"LabelSettingsFindCoversHelp\": \"Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования\",\n  \"LabelSettingsHideSingleBookSeries\": \"Скрыть серии с одной книгой\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Серии, в которых всего одна книга, будут скрыты со страницы серий и полок домашней страницы.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Вид книжной полки на Домашней странице\",\n  \"LabelSettingsLibraryBookshelfView\": \"Вид книжной полки в Библиотеке\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Процент выполнения больше, чем\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Оставшееся время составляет менее (секунд)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Отметьте мультимедийный элемент как законченный, когда\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Пропустить предыдущие книги в \\\"Продолжить серию\\\"\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"На домашней странице \\\"Продолжить серию\\\" отображается первая книга, не начатая в серии, в которой закончена хотя бы одна книга и нет начатых книг. При включении этого параметра серия будет продолжена с самой последней завершенной книги, а не с первой, которая не начата.\",\n  \"LabelSettingsParseSubtitles\": \"Разбор подзаголовков\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Извлечение подзаголовков из имен папок аудиокниг.<br>Подзаголовок должны быть отделен \\\" - \\\"<br>например \\\"Название Книги - Тут Подзаголовок\\\" подзаголовок будет \\\"Тут Подзаголовок\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Предпочитать метаданные поиска\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Данные поиска будут перезаписывать данные книг при использовании Быстрого Поиска. По умолчанию Быстрый Поиск будет использоваться только при отсутствии данных.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Пропускать Поиск книг у которых уже заполнен ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Пропускать Поиск книг у которых уже заполнен ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Игнорировать префиксы при сортировке\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"Например \\\"the\\\", книга с названием \\\"The Book Title\\\" будет сортироваться как \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Использовать квадратные обложки книг\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Использовать квадратные обложки вместо стандартных для книг 1.6:1 обложек\",\n  \"LabelSettingsStoreCoversWithItem\": \"Хранить обложки с элементом\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Хранить метаинформацию с элементом\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента\",\n  \"LabelSettingsTimeFormat\": \"Формат времени\",\n  \"LabelShare\": \"Поделиться\",\n  \"LabelShareDownloadableHelp\": \"Позволяет пользователям с помощью ссылки загрузить zip-файл элемента библиотеки.\",\n  \"LabelShareOpen\": \"Общедоступно\",\n  \"LabelShareURL\": \"Общедоступный URL\",\n  \"LabelShowAll\": \"Показать все\",\n  \"LabelShowSeconds\": \"Отображать секунды\",\n  \"LabelShowSubtitles\": \"Показать серии\",\n  \"LabelSize\": \"Размер\",\n  \"LabelSleepTimer\": \"Таймер сна\",\n  \"LabelSlug\": \"Слизень\",\n  \"LabelSortAscending\": \"По возрастанию\",\n  \"LabelSortDescending\": \"По убыванию\",\n  \"LabelSortPubDate\": \"Отсортировать по дате публикации\",\n  \"LabelStart\": \"Начало\",\n  \"LabelStartTime\": \"Время начала\",\n  \"LabelStarted\": \"Начат\",\n  \"LabelStartedAt\": \"Начато В\",\n  \"LabelStartedDate\": \"Начато {0}\",\n  \"LabelStatsAudioTracks\": \"Аудио треки\",\n  \"LabelStatsAuthors\": \"Авторы\",\n  \"LabelStatsBestDay\": \"Лучший День\",\n  \"LabelStatsDailyAverage\": \"В среднем в день\",\n  \"LabelStatsDays\": \"Дней\",\n  \"LabelStatsDaysListened\": \"Дней прослушано\",\n  \"LabelStatsHours\": \"Часов\",\n  \"LabelStatsInARow\": \"беспрерывно\",\n  \"LabelStatsItemsFinished\": \"Элементов завершено\",\n  \"LabelStatsItemsInLibrary\": \"Элементов в библиотеке\",\n  \"LabelStatsMinutes\": \"минут\",\n  \"LabelStatsMinutesListening\": \"Минут прослушано\",\n  \"LabelStatsOverallDays\": \"Всего дней\",\n  \"LabelStatsOverallHours\": \"Всего часов\",\n  \"LabelStatsWeekListening\": \"Прослушано за неделю\",\n  \"LabelSubtitle\": \"Подзаголовок\",\n  \"LabelSupportedFileTypes\": \"Поддерживаемые типы файлов\",\n  \"LabelTag\": \"Тег\",\n  \"LabelTags\": \"Теги\",\n  \"LabelTagsAccessibleToUser\": \"Теги доступные для пользователя\",\n  \"LabelTagsNotAccessibleToUser\": \"Теги не доступные для пользователя\",\n  \"LabelTasks\": \"Запущенные задачи\",\n  \"LabelTextEditorBulletedList\": \"Маркированный список\",\n  \"LabelTextEditorLink\": \"Связь\",\n  \"LabelTextEditorNumberedList\": \"Нумерованный список\",\n  \"LabelTextEditorUnlink\": \"Отсоединить\",\n  \"LabelTheme\": \"Тема\",\n  \"LabelThemeDark\": \"Темная\",\n  \"LabelThemeLight\": \"Светлая\",\n  \"LabelThemeSepia\": \"Сепия\",\n  \"LabelTimeBase\": \"Основное время\",\n  \"LabelTimeDurationXHours\": \"{0} часов\",\n  \"LabelTimeDurationXMinutes\": \"{0} минут\",\n  \"LabelTimeDurationXSeconds\": \"{0} секунд\",\n  \"LabelTimeInMinutes\": \"Время в минутах\",\n  \"LabelTimeLeft\": \"{0} осталось\",\n  \"LabelTimeListened\": \"Время прослушивания\",\n  \"LabelTimeListenedToday\": \"Время прослушивания сегодня\",\n  \"LabelTimeRemaining\": \"{0} осталось\",\n  \"LabelTimeToShift\": \"Время смещения в секундах\",\n  \"LabelTitle\": \"Название\",\n  \"LabelToolsEmbedMetadata\": \"Встроить метаданные\",\n  \"LabelToolsEmbedMetadataDescription\": \"Встроить метаданные в аудио файлы, включая обложку и главы.\",\n  \"LabelToolsM4bEncoder\": \"Кодировщик M4B\",\n  \"LabelToolsMakeM4b\": \"Создать M4B файл аудиокниги\",\n  \"LabelToolsMakeM4bDescription\": \"Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.\",\n  \"LabelToolsSplitM4b\": \"Разделить M4B на MP3 файлы\",\n  \"LabelToolsSplitM4bDescription\": \"Создает MP3 файла из M4B, разделяет на главы с встроенными метаданными, обложкой и главами.\",\n  \"LabelTotalDuration\": \"Общая продолжительность\",\n  \"LabelTotalTimeListened\": \"Всего прослушано\",\n  \"LabelTrackFromFilename\": \"Трек из Имени файла\",\n  \"LabelTrackFromMetadata\": \"Трек из Метаданных\",\n  \"LabelTracks\": \"Треков\",\n  \"LabelTracksMultiTrack\": \"Мультитрек\",\n  \"LabelTracksNone\": \"Нет треков\",\n  \"LabelTracksSingleTrack\": \"Один трек\",\n  \"LabelTrailer\": \"Трейлер\",\n  \"LabelType\": \"Тип\",\n  \"LabelUnabridged\": \"Полное издание\",\n  \"LabelUndo\": \"Отменить\",\n  \"LabelUnknown\": \"Неизвестно\",\n  \"LabelUnknownPublishDate\": \"Дата публикации неизвестна\",\n  \"LabelUpdateCover\": \"Обновить обложку\",\n  \"LabelUpdateCoverHelp\": \"Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены\",\n  \"LabelUpdateDetails\": \"Обновить подробности\",\n  \"LabelUpdateDetailsHelp\": \"Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены\",\n  \"LabelUpdatedAt\": \"Обновлено в\",\n  \"LabelUploaderDragAndDrop\": \"Перетащите файлы или каталоги\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Перетаскивание файлов\",\n  \"LabelUploaderDropFiles\": \"Перетащите файлы\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Автоматическое извлечение названия, автора и серии\",\n  \"LabelUseAdvancedOptions\": \"Используйте расширенные опции\",\n  \"LabelUseChapterTrack\": \"Показывать время главы\",\n  \"LabelUseFullTrack\": \"Показывать время книги\",\n  \"LabelUseZeroForUnlimited\": \"Используйте 0 для неограниченного количества\",\n  \"LabelUser\": \"Пользователь\",\n  \"LabelUsername\": \"Имя пользователя\",\n  \"LabelValue\": \"Значение\",\n  \"LabelVersion\": \"Версия\",\n  \"LabelViewBookmarks\": \"Закладки\",\n  \"LabelViewChapters\": \"Главы\",\n  \"LabelViewPlayerSettings\": \"Просмотр настроек плеера\",\n  \"LabelViewQueue\": \"Очередь воспроизведения\",\n  \"LabelVolume\": \"Громкость\",\n  \"LabelWebRedirectURLsDescription\": \"Авторизуйте эти URL в провайдере OAuth, чтобы разрешить перенаправление обратно в веб-приложение после входа:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Вложенная папка для URL-адресов перенаправления\",\n  \"LabelWeekdaysToRun\": \"Дни недели для запуска\",\n  \"LabelXBooks\": \"{0} книг\",\n  \"LabelXItems\": \"{0} элементов\",\n  \"LabelYearReviewHide\": \"Скрыть итоги года\",\n  \"LabelYearReviewShow\": \"Итоги года\",\n  \"LabelYourAudiobookDuration\": \"Продолжительность Вашей книги\",\n  \"LabelYourBookmarks\": \"Ваши закладки\",\n  \"LabelYourPlaylists\": \"Ваши плейлисты\",\n  \"LabelYourProgress\": \"Ваш прогресс\",\n  \"MessageAddToPlayerQueue\": \"Добавить в очередь проигрывателя\",\n  \"MessageAppriseDescription\": \"Для использования этой функции необходимо иметь запущенный экземпляр <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> или api которое обрабатывает те же самые запросы. <br />URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу <code>http://192.168.1.1:8337</code> тогда нужно указать <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Убедитесь, что вы используете ASIN из правильной региональной зоны Audible, а не из Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Устаревшие токены API в будущем будут удалены. Вместо них используйте <a href=\\\"/config/api-keys\\\">API-ключи</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Перезапустите ваш сервер после сохранения для применения изменений в OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"В целях безопасности была улучшена аутентификация. Всем пользователям необходимо повторно войти в систему.\",\n  \"MessageBackupsDescription\": \"Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в <code>/metadata/items</code> и <code>/metadata/authors</code>. Бэкапы <strong>НЕ</strong> сохраняют файлы из папок библиотек.\",\n  \"MessageBackupsLocationEditNote\": \"Примечание: Обновление местоположения резервной копии не приведет к перемещению или изменению существующих резервных копий\",\n  \"MessageBackupsLocationNoEditNote\": \"Примечание: Местоположение резервного копирования задается с помощью переменной среды и не может быть изменено здесь.\",\n  \"MessageBackupsLocationPathEmpty\": \"Путь к расположению резервной копии не может быть пустым\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Заполнить включенные поля данными из всех элементов. Поля с несколькими значениями будут объединены\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Заполнить активированные поля сведений о карте данными из этого элемента\",\n  \"MessageBatchQuickMatchDescription\": \"Быстрый Поиск попытается добавить отсутствующие обложки и метаданные для выбранных элементов. Включите параметры ниже, чтобы разрешить Быстрому Поиску перезаписывать существующие обложки и/или метаданные.\",\n  \"MessageBookshelfNoCollections\": \"Вы еще не создали ни одной коллекции\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Коллекции являются общедоступными. Все пользователи, имеющие доступ к библиотеке, могут их просматривать.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Нет открытых RSS-лент\",\n  \"MessageBookshelfNoResultsForFilter\": \"Нет Результатов для фильтра \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Нет результатов для запроса\",\n  \"MessageBookshelfNoSeries\": \"У вас нет серий\",\n  \"MessageBulkChapterPattern\": \"Сколько глав вы хотели бы добавить, используя эту схему нумерации?\",\n  \"MessageChapterEndIsAfter\": \"Конец главы после окончания вашей аудиокниги\",\n  \"MessageChapterErrorFirstNotZero\": \"Первая глава должна начинаться с 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Неверное время начала, должно быть меньше продолжительности аудиокниги\",\n  \"MessageChapterErrorStartLtPrev\": \"Неверное время начала, должно быть больше или равно времени начала предыдущей главы\",\n  \"MessageChapterStartIsAfter\": \"Глава начинается после окончания аудиокниги\",\n  \"MessageChaptersNotFound\": \"Главы не найденны\",\n  \"MessageCheckingCron\": \"Проверка cron...\",\n  \"MessageConfirmCloseFeed\": \"Вы уверены, что хотите закрыть эту ленту?\",\n  \"MessageConfirmDeleteApiKey\": \"Вы уверены, что хотите удалить API ключ \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Вы уверены, что хотите удалить бэкап для {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Вы уверены, что хотите удалить устройство для чтения электронных книг \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Это удалит файл из Вашей файловой системы. Вы уверены?\",\n  \"MessageConfirmDeleteLibrary\": \"Вы уверены, что хотите навсегда удалить библиотеку \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Это приведет к удалению элемента библиотеки из базы данных и файловой системы. Вы уверены?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Это приведет к удалению {0} элементов библиотеки из базы данных и файловой системы. Вы уверены?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Вы уверены, что хотите удалить пользовательский поставщик метаданных \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Вы уверены, что хотите удалить это уведомление?\",\n  \"MessageConfirmDeleteSession\": \"Вы уверены, что хотите удалить этот сеанс?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Вы уверены, что хотите вставить метаданные в {0} аудиофайлов?\",\n  \"MessageConfirmForceReScan\": \"Вы уверены, что хотите принудительно выполнить повторное сканирование?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Вы уверены, что хотите отметить все выпуски как прослушанные?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Вы уверены, что хотите отметить все выпуски как непрослушанные?\",\n  \"MessageConfirmMarkItemFinished\": \"Вы уверены, что хотите отметить «{0}» как завершенную?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Вы уверены, что хотите отметить «{0}» как не завершенную?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Вы уверены, что хотите отметить все книги этой серии как завершенные?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Вы уверены, что хотите отметить все книги этой серии как не завершенные?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Активировать это уведомление с тестовыми данными?\",\n  \"MessageConfirmPurgeCache\": \"Очистка кэша удалит весь каталог в <code>/metadata/cache</code>. <br /><br />Вы уверены, что хотите удалить каталог кэша?\",\n  \"MessageConfirmPurgeItemsCache\": \"Очистка кэша элементов удалит весь каталог в <code>/metadata/cache/items</code>.<br />Вы уверены?\",\n  \"MessageConfirmQuickEmbed\": \"Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов. <br><br>Хотите продолжить?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"При обнаружении совпадений информация о выпусках быстрого поиска будет перезаписана. Будут обновлены только несопоставимые выпуски. Вы уверены?\",\n  \"MessageConfirmReScanLibraryItems\": \"Вы уверены, что хотите пересканировать {0} элементов?\",\n  \"MessageConfirmRemoveAllChapters\": \"Вы уверены, что хотите удалить все главы?\",\n  \"MessageConfirmRemoveAuthor\": \"Вы уверены, что хотите удалить автора \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Вы уверены, что хотите удалить коллекцию \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Вы уверены, что хотите удалить выпуск «{0}»?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Примечание: Это не приведет к удалению аудиофайла, если не включить опцию \\\"Жесткое удаление файла\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Вы уверены, что хотите удалить {0} выпусков?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Вы уверены, что хотите удалить {0} сеансов прослушивания?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?\",\n  \"MessageConfirmRemoveNarrator\": \"Вы уверены, что хотите удалить чтеца \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Вы уверены, что хотите удалить плейлист \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Вы уверены, что хотите переименовать жанр \\\"{0}\\\" в \\\"{1}\\\" для всех элементов?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Примечание: Этот жанр уже существует, поэтому они будут объединены.\",\n  \"MessageConfirmRenameGenreWarning\": \"Предупреждение! Похожий жанр с другими начальными буквами уже существует \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Вы уверены, что хотите переименовать тег \\\"{0}\\\" в \\\"{1}\\\" для всех элементов?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Примечание: Этот тег уже существует, поэтому они будут объединены.\",\n  \"MessageConfirmRenameTagWarning\": \"Предупреждение! Похожий тег с другими начальными буквами уже существует \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Вы уверены, что хотите сбросить свой прогресс?\",\n  \"MessageConfirmSendEbookToDevice\": \"Вы уверены, что хотите отправить {0} e-книгу \\\"{1}\\\" на устройство \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Вы уверены, что хотите отвязать этого пользователя от OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} дней прослушивания за последний год\",\n  \"MessageDownloadingEpisode\": \"Скачивание выпуска\",\n  \"MessageDragFilesIntoTrackOrder\": \"Перетащите файлы для исправления порядка треков\",\n  \"MessageEmbedFailed\": \"Вставка не удалась!\",\n  \"MessageEmbedFinished\": \"Встраивание завершено!\",\n  \"MessageEmbedQueue\": \"Поставлен в очередь для внедрения метаданных ({0} в очереди)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} выпуск(ов) запланировано для скачивания\",\n  \"MessageEreaderDevices\": \"Чтобы обеспечить доставку электронных книг, вам может потребоваться добавить указанный выше адрес электронной почты в качестве действительного отправителя для каждого устройства, перечисленного ниже.\",\n  \"MessageFeedURLWillBe\": \"URL-адрес ленты будет {0}\",\n  \"MessageFetching\": \"Завершается...\",\n  \"MessageForceReScanDescription\": \"будет сканировать все файлы снова, как свежее сканирование. Теги ID3 аудиофайлов, OPF-файлы и текстовые файлы будут сканироваться как новые.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} прослушивание</strong> на {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Нет сессий прослушивания на {0}\",\n  \"MessageImportantNotice\": \"Важное замечание!\",\n  \"MessageInsertChapterBelow\": \"Вставить главу ниже\",\n  \"MessageInvalidAsin\": \"Неправильный ASIN\",\n  \"MessageItemsSelected\": \"{0} элементов выделено\",\n  \"MessageItemsUpdated\": \"{0} элементов обновлено\",\n  \"MessageJoinUsOn\": \"Присоединяйтесь к нам в\",\n  \"MessageLoading\": \"Загрузка...\",\n  \"MessageLoadingFolders\": \"Загрузка каталогов...\",\n  \"MessageLogsDescription\": \"Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B Ошибка!\",\n  \"MessageM4BFinished\": \"M4B Завершено!\",\n  \"MessageMapChapterTitles\": \"Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток\",\n  \"MessageMarkAllEpisodesFinished\": \"Отметить все выпуски как прослушанные\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Отметить все выпуски как непрослушанные\",\n  \"MessageMarkAsFinished\": \"Отметить, как завершенную\",\n  \"MessageMarkAsNotFinished\": \"Отметить, как не завершенную\",\n  \"MessageMatchBooksDescription\": \"попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.\",\n  \"MessageNoAudioTracks\": \"Нет аудио треков\",\n  \"MessageNoAuthors\": \"Нет авторов\",\n  \"MessageNoBackups\": \"Нет бэкапов\",\n  \"MessageNoBookmarks\": \"Нет закладок\",\n  \"MessageNoChapters\": \"Нет глав\",\n  \"MessageNoCollections\": \"Нет коллекций\",\n  \"MessageNoCoversFound\": \"Обложек не найдено\",\n  \"MessageNoDescription\": \"Нет описания\",\n  \"MessageNoDevices\": \"Нет устройств\",\n  \"MessageNoDownloadsInProgress\": \"В настоящее время загрузка не выполняется\",\n  \"MessageNoDownloadsQueued\": \"Нет загрузок в очереди\",\n  \"MessageNoEpisodeMatchesFound\": \"Совпадения выпусков не найдены\",\n  \"MessageNoEpisodes\": \"Нету выпусков\",\n  \"MessageNoFoldersAvailable\": \"Нет доступных папок\",\n  \"MessageNoGenres\": \"Нет жанров\",\n  \"MessageNoIssues\": \"Нет проблем\",\n  \"MessageNoItems\": \"Нет элементов\",\n  \"MessageNoItemsFound\": \"Элементы не найдены\",\n  \"MessageNoListeningSessions\": \"Нет сеансов прослушивания\",\n  \"MessageNoLogs\": \"Нет записей\",\n  \"MessageNoMediaProgress\": \"Нет прогресса медиа\",\n  \"MessageNoNotifications\": \"Нет уведомлений\",\n  \"MessageNoPodcastFeed\": \"Недопустимый подкаст: Нет ленты\",\n  \"MessageNoPodcastsFound\": \"Подкасты не найдены\",\n  \"MessageNoResults\": \"Нет результатов\",\n  \"MessageNoSearchResultsFor\": \"Нет результатов поиска для \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Нет серий\",\n  \"MessageNoTags\": \"Нет тегов\",\n  \"MessageNoTasksRunning\": \"Нет выполняемых задач\",\n  \"MessageNoUpdatesWereNecessary\": \"Обновления не требовались\",\n  \"MessageNoUserPlaylists\": \"У вас нет плейлистов\",\n  \"MessageNoUserPlaylistsHelp\": \"Списки воспроизведения являются конфиденциальными. Только пользователь, который их создает, может их видеть.\",\n  \"MessageNotYetImplemented\": \"Пока не реализовано\",\n  \"MessageOpmlPreviewNote\": \"Примечание: Это предварительный просмотр разобранного файла OPML. Фактическое название подкаста будет взято из RSS-ленты.\",\n  \"MessageOr\": \"или\",\n  \"MessagePauseChapter\": \"Пауза воспроизведения главы\",\n  \"MessagePlayChapter\": \"Прослушать начало главы\",\n  \"MessagePlaylistCreateFromCollection\": \"Создать плейлист из коллекции\",\n  \"MessagePleaseWait\": \"Пожалуйста подождите...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Подкаст не имеет URL-адреса RSS-ленты, который можно использовать для поиска\",\n  \"MessagePodcastSearchField\": \"Введите поисковый запрос или URL-адрес RSS-ленты\",\n  \"MessageQuickEmbedInProgress\": \"Быстрое внедрение в процессе выполнения\",\n  \"MessageQuickEmbedQueue\": \"Поставлен в очередь для быстрого внедрения ({0} в очереди)\",\n  \"MessageQuickMatchAllEpisodes\": \"Быстрое сопоставление всех выпусков\",\n  \"MessageQuickMatchDescription\": \"Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.\",\n  \"MessageRemoveChapter\": \"Удалить главу\",\n  \"MessageRemoveEpisodes\": \"Удалить {0} выпуск(ов)\",\n  \"MessageRemoveFromPlayerQueue\": \"Удалить из очереди воспроизведения\",\n  \"MessageRemoveUserWarning\": \"Вы уверены, что хотите навсегда удалить пользователя \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Сообщайте об ошибках, запрашивайте функции и вносите свой вклад на\",\n  \"MessageResetChaptersConfirm\": \"Вы уверены, что хотите сбросить главы и отменить внесенные изменения?\",\n  \"MessageRestoreBackupConfirm\": \"Вы уверены, что хотите восстановить резервную копию, созданную\",\n  \"MessageRestoreBackupWarning\": \"Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.\",\n  \"MessageScheduleLibraryScanNote\": \"Большинству пользователей рекомендуется отключить эту функцию и оставить включённой функцию \\\"Автоматически отслеживать изменения в библиотеках\\\" - она будет автоматически обнаруживать изменения в папках вашей библиотеки. Включите эту функцию если \\\"Автоматически отслеживать изменения в библиотеках\\\" не работает для вашей файловой системы (например, NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Запуск каждые {0} по {1}\",\n  \"MessageSearchResultsFor\": \"Результаты поиска для\",\n  \"MessageSelected\": \"{0} выбрано\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Последовательность серии должна быть без пропусков\",\n  \"MessageServerCouldNotBeReached\": \"Не удалось связаться с сервером\",\n  \"MessageSetChaptersFromTracksDescription\": \"Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла\",\n  \"MessageShareExpirationWillBe\": \"Срок действия истекает <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Срок действия истекает через {0}\",\n  \"MessageShareURLWillBe\": \"URL-адрес общего доступа будет <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Начать воспроизведение для \\\"{0}\\\" с {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Аудиофайл \\\"{0}\\\" недоступен для записи\",\n  \"MessageTaskCanceledByUser\": \"Задание отменено пользователем\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Скачивание выпуска «{0}»\",\n  \"MessageTaskEmbeddingMetadata\": \"Внедрение метаданных\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Встраивание метаданных в аудиокнигу \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Кодировка M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Кодирование аудиокниги \\\"{0}\\\" в один файл формата m4b\",\n  \"MessageTaskFailed\": \"Неудачный\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Не удалось создать резервную копию аудиофайла \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Не удалось создать каталог кэша\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Не удалось вставить метаданные в файл \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Не удалось объединить аудиофайлы\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Не удалось переместить файл m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Не удалось записать файл метаданных\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Сопоставление книг в библиотеке \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Нет файлов для сканирования\",\n  \"MessageTaskOpmlImport\": \"Импорт OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Создание подкастов из {0} RSS-ленты\",\n  \"MessageTaskOpmlImportFeed\": \"Канал импорта OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Импорт RSS-ленты «{0}»\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Не удалось получить ленту подкаста\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Создание подкаста \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Подкаст уже существует по адресу\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Не удалось создать подкаст\",\n  \"MessageTaskOpmlImportFinished\": \"Добавлено {0} подкастов\",\n  \"MessageTaskOpmlParseFailed\": \"Не удалось разобрать OPML-файл\",\n  \"MessageTaskOpmlParseFastFail\": \"Недопустимый тег <opml> файла OPML не найден ИЛИ тег <outline> не найден\",\n  \"MessageTaskOpmlParseNoneFound\": \"В OPML-файле не найдено ни одной ленты\",\n  \"MessageTaskScanItemsAdded\": \"{0} добавлено\",\n  \"MessageTaskScanItemsMissing\": \"{0} отсутствует\",\n  \"MessageTaskScanItemsUpdated\": \"{0} обновлено\",\n  \"MessageTaskScanNoChangesNeeded\": \"Никаких изменений не требуется\",\n  \"MessageTaskScanningFileChanges\": \"Проверка изменений файлов в \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Сканирование библиотеки \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Целевой каталог недоступен для записи\",\n  \"MessageThinking\": \"Думаю...\",\n  \"MessageUploaderItemFailed\": \"Не удалось загрузить\",\n  \"MessageUploaderItemSuccess\": \"Успешно загружено!\",\n  \"MessageUploading\": \"Загрузка...\",\n  \"MessageValidCronExpression\": \"Верное cron выражение\",\n  \"MessageWatcherIsDisabledGlobally\": \"Наблюдатель отключен глобально в настройках сервера\",\n  \"MessageXLibraryIsEmpty\": \"{0} Библиотека пуста!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Продолжительность аудиокниги больше найденной продолжительности\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Продолжительность аудиокниги короче найденной продолжительности\",\n  \"NoteChangeRootPassword\": \"Пользователь root — единственный пользователь, который может иметь пустой пароль\",\n  \"NoteChapterEditorTimes\": \"Примечание: Время начала первой главы должно оставаться в 0:00, а время начала последней главы не может превышать продолжительность этой аудиокниги.\",\n  \"NoteFolderPicker\": \"Примечание: папки, уже сопоставленные, не будут отображаться\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-ленты использовал HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Предупреждение: 1 или более выпусков не имеют даты публикации. Некоторые приложения для подкастов требуют этого.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Если загружать только аудиофайлы, то каждый аудиофайл будет обрабатываться как отдельная аудиокнига.\",\n  \"NoteUploaderUnsupportedFiles\": \"Неподдерживаемые файлы игнорируются. При выборе или удалении папки другие файлы, не находящиеся в папке элемента, игнорируются.\",\n  \"NotificationOnBackupCompletedDescription\": \"Запускается при завершении резервного копирования\",\n  \"NotificationOnBackupFailedDescription\": \"Срабатывает при сбое резервного копирования\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Запускается при автоматической скачивании выпуска подкаста\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Срабатывает, когда автоматическая скачка выпусков отключена из-за слишком большого количества неудачных попыток\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Срабатывает при сбое запроса RSS-ленты на автоматическую скачивание выпуска\",\n  \"NotificationOnTestDescription\": \"Событие для тестирования системы оповещения\",\n  \"PlaceholderBulkChapterInput\": \"Введите название главы или используйте нумерацию (например, «Выпуск 1», «Глава 10», «1.»)\",\n  \"PlaceholderNewCollection\": \"Новое имя коллекции\",\n  \"PlaceholderNewFolderPath\": \"Путь к новой папке\",\n  \"PlaceholderNewPlaylist\": \"Новое название плейлиста\",\n  \"PlaceholderSearch\": \"Поиск...\",\n  \"PlaceholderSearchEpisode\": \"Поиск выпуска...\",\n  \"StatsAuthorsAdded\": \"авторов добавлено\",\n  \"StatsBooksAdded\": \"книг добавлено\",\n  \"StatsBooksAdditional\": \"Некоторые дополнения включают в себя…\",\n  \"StatsBooksFinished\": \"книг завершено\",\n  \"StatsBooksFinishedThisYear\": \"Некоторые книги закончены в этом году…\",\n  \"StatsBooksListenedTo\": \"книг прослушано\",\n  \"StatsCollectionGrewTo\": \"Ваша коллекция книг пополнилась…\",\n  \"StatsSessions\": \"сессий\",\n  \"StatsSpentListening\": \"потрачено на прослушивание\",\n  \"StatsTopAuthor\": \"ТОП АВТОР\",\n  \"StatsTopAuthors\": \"ТОП АВТОРОВ\",\n  \"StatsTopGenre\": \"ТОП ЖАНР\",\n  \"StatsTopGenres\": \"ТОП ЖАНРЫ\",\n  \"StatsTopMonth\": \"ЛУЧШИЙ МЕСЯЦ\",\n  \"StatsTopNarrator\": \"ТОП ЧТЕЦ\",\n  \"StatsTopNarrators\": \"ТОП ЧТЕЦЫ\",\n  \"StatsTotalDuration\": \"С общей продолжительностью…\",\n  \"StatsYearInReview\": \"ИТОГИ ГОДА\",\n  \"ToastAccountUpdateSuccess\": \"Учетная запись обновлена\",\n  \"ToastAppriseUrlRequired\": \"Необходимо ввести URL-адрес Apprise\",\n  \"ToastAsinRequired\": \"Требуется ASIN\",\n  \"ToastAuthorImageRemoveSuccess\": \"Изображение автора удалено\",\n  \"ToastAuthorNotFound\": \"Автор \\\"{0}\\\" не найден\",\n  \"ToastAuthorRemoveSuccess\": \"Автор удален\",\n  \"ToastAuthorSearchNotFound\": \"Автор не найден\",\n  \"ToastAuthorUpdateMerged\": \"Автор объединен\",\n  \"ToastAuthorUpdateSuccess\": \"Автор обновлен\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Автор обновлен (изображение не найдено)\",\n  \"ToastBackupAppliedSuccess\": \"Применена резервная копия\",\n  \"ToastBackupCreateFailed\": \"Не удалось создать бэкап\",\n  \"ToastBackupCreateSuccess\": \"Бэкап создан\",\n  \"ToastBackupDeleteFailed\": \"Не удалось удалить бэкап\",\n  \"ToastBackupDeleteSuccess\": \"Бэкап удален\",\n  \"ToastBackupInvalidMaxKeep\": \"Недопустимое количество резервных копий для хранения\",\n  \"ToastBackupInvalidMaxSize\": \"Недопустимый максимальный размер резервной копии\",\n  \"ToastBackupRestoreFailed\": \"Не удалось восстановить из бэкапа\",\n  \"ToastBackupUploadFailed\": \"Не удалось загрузить бэкап\",\n  \"ToastBackupUploadSuccess\": \"Бэкап загружен\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Подробности, применяемые к элементам\",\n  \"ToastBatchDeleteFailed\": \"Не удалось выполнить пакетное удаление\",\n  \"ToastBatchDeleteSuccess\": \"Успешное пакетное удаление\",\n  \"ToastBatchQuickMatchFailed\": \"Не удалось выполнить пакетное быстрое сопоставление!\",\n  \"ToastBatchQuickMatchStarted\": \"Начато пакетное быстрое сопоставление {0} книг!\",\n  \"ToastBatchUpdateFailed\": \"Сбой пакетного обновления\",\n  \"ToastBatchUpdateSuccess\": \"Успешное пакетное обновление\",\n  \"ToastBookmarkCreateFailed\": \"Не удалось создать закладку\",\n  \"ToastBookmarkCreateSuccess\": \"Добавлена закладка\",\n  \"ToastBookmarkRemoveSuccess\": \"Закладка удалена\",\n  \"ToastBulkChapterInvalidCount\": \"Введите число от 1 до 150\",\n  \"ToastCachePurgeFailed\": \"Не удалось очистить кэш\",\n  \"ToastCachePurgeSuccess\": \"Кэш успешно очищен\",\n  \"ToastChapterLocked\": \"Глава заблокирована.\",\n  \"ToastChapterStartTimeAdjusted\": \"Время начала главы скорректировано на {0} секунд\",\n  \"ToastChaptersAllLocked\": \"Все главы заблокированы. Разблокируйте некоторые главы, чтобы сдвинуть их время.\",\n  \"ToastChaptersHaveErrors\": \"Главы имеют ошибки\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Некорректное значение сдвига. Начало последней главы будет превышать продолжительность этой аудиокниги.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Некорректное значение сдвига. Первая глава будет иметь нулевую или отрицательную длину и будет перезаписана второй главой. Увеличьте начальную продолжительность второй главы.\",\n  \"ToastChaptersMustHaveTitles\": \"Главы должны содержать названия\",\n  \"ToastChaptersRemoved\": \"Удалены главы\",\n  \"ToastChaptersUpdated\": \"Обновленные главы\",\n  \"ToastCollectionItemsAddFailed\": \"Не удалось добавить элемент(ы) в коллекцию\",\n  \"ToastCollectionRemoveSuccess\": \"Коллекция удалена\",\n  \"ToastCollectionUpdateSuccess\": \"Коллекция обновлена\",\n  \"ToastConnectionNotAvailable\": \"Подключение недоступно. Пожалуйста попробуйте позже\",\n  \"ToastCoverSearchFailed\": \"Ошибка поиска обложки\",\n  \"ToastCoverUpdateFailed\": \"Не удалось обновить обложку\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Дата и время указаны неверно или не до конца\",\n  \"ToastDeleteFileFailed\": \"Не удалось удалить файл\",\n  \"ToastDeleteFileSuccess\": \"Файл удален\",\n  \"ToastDeviceAddFailed\": \"Не удалось добавить устройство\",\n  \"ToastDeviceNameAlreadyExists\": \"Устройство для чтения электронных книг с таким именем уже существует\",\n  \"ToastDeviceTestEmailFailed\": \"Не удалось отправить тестовое электронное письмо\",\n  \"ToastDeviceTestEmailSuccess\": \"Тестовое письмо отправлено\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Обновлены настройки электронной почты\",\n  \"ToastEncodeCancelFailed\": \"Не удалось отменить кодирование\",\n  \"ToastEncodeCancelSucces\": \"Кодирование отменено\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Не удалось очистить очередь\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Очищена очередь скачивания выпусков\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} выпусков обновлено\",\n  \"ToastErrorCannotShare\": \"Невозможно предоставить общий доступ на этом устройстве\",\n  \"ToastFailedToCreate\": \"Не удалось создать\",\n  \"ToastFailedToDelete\": \"Не удалось удалить\",\n  \"ToastFailedToLoadData\": \"Не удалось загрузить данные\",\n  \"ToastFailedToMatch\": \"Не удалось найти совпадения\",\n  \"ToastFailedToShare\": \"Не удалось поделиться\",\n  \"ToastFailedToUpdate\": \"Не удалось обновить\",\n  \"ToastInvalidImageUrl\": \"Неверный URL изображения\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Недопустимое максимальное количество скачиваемых выпусков\",\n  \"ToastInvalidUrl\": \"Неверный URL\",\n  \"ToastInvalidUrls\": \"Один или несколько URL неверны\",\n  \"ToastItemCoverUpdateSuccess\": \"Обложка элемента обновлена\",\n  \"ToastItemDeletedFailed\": \"Не удалось удалить элемент\",\n  \"ToastItemDeletedSuccess\": \"Удаленный элемент\",\n  \"ToastItemDetailsUpdateSuccess\": \"Обновлены сведения об элементе\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Не удалось пометить как Завершенный\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Элемент помечен как Завершенный\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Не удалось пометить как Незавершенный\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Элемент помечен как Незавершенный\",\n  \"ToastItemUpdateSuccess\": \"Элемент обновлен\",\n  \"ToastLibraryCreateFailed\": \"Не удалось создать библиотеку\",\n  \"ToastLibraryCreateSuccess\": \"Библиотека \\\"{0}\\\" создана\",\n  \"ToastLibraryDeleteFailed\": \"Не удалось удалить библиотеку\",\n  \"ToastLibraryDeleteSuccess\": \"Библиотека удалена\",\n  \"ToastLibraryScanFailedToStart\": \"Не удалось запустить сканирование\",\n  \"ToastLibraryScanStarted\": \"Запущено сканирование библиотеки\",\n  \"ToastLibraryUpdateSuccess\": \"Библиотека \\\"{0}\\\" обновлена\",\n  \"ToastMatchAllAuthorsFailed\": \"Не удалось найти совпадения со всеми авторами\",\n  \"ToastMetadataFilesRemovedError\": \"Ошибка при удалении файлов metadata.{0}\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"В библиотеке не найдено файлов metadata.{0}\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Нет удаленных файлов metadata.{0}\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} файлов удалено\",\n  \"ToastMustHaveAtLeastOnePath\": \"Должен быть хотя бы один путь\",\n  \"ToastNameEmailRequired\": \"Имя и адрес электронной почты обязательны\",\n  \"ToastNameRequired\": \"Имя обязательно для заполнения\",\n  \"ToastNewApiKeyUserError\": \"Необходимо выбрать пользователя\",\n  \"ToastNewEpisodesFound\": \"Найдено {0} новых выпусков\",\n  \"ToastNewUserCreatedFailed\": \"Не удалось создать учетную запись: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Новая учетная запись создана\",\n  \"ToastNewUserLibraryError\": \"Необходимо выбрать хотя бы одну библиотеку\",\n  \"ToastNewUserPasswordError\": \"Должен иметь пароль, только пользователь root может иметь пустой пароль\",\n  \"ToastNewUserTagError\": \"Необходимо выбрать хотя бы один тег\",\n  \"ToastNewUserUsernameError\": \"Введите имя пользователя\",\n  \"ToastNoNewEpisodesFound\": \"Новых выпусков нету\",\n  \"ToastNoRSSFeed\": \"У подкаста нет RSS-ленты\",\n  \"ToastNoUpdatesNecessary\": \"Обновления не требуются\",\n  \"ToastNotificationCreateFailed\": \"Не удалось создать уведомление\",\n  \"ToastNotificationDeleteFailed\": \"Не удалось удалить уведомление\",\n  \"ToastNotificationFailedMaximum\": \"Максимальное количество неудачных попыток должно быть >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Максимальная очередь уведомлений должна быть >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Обновлены настройки уведомлений\",\n  \"ToastNotificationTestTriggerFailed\": \"Не удалось активировать тестовое уведомление\",\n  \"ToastNotificationTestTriggerSuccess\": \"Сработавшее уведомление о тестировании\",\n  \"ToastNotificationUpdateSuccess\": \"Уведомление обновлено\",\n  \"ToastPlaylistCreateFailed\": \"Не удалось создать плейлист\",\n  \"ToastPlaylistCreateSuccess\": \"Плейлист создан\",\n  \"ToastPlaylistRemoveSuccess\": \"Плейлист удален\",\n  \"ToastPlaylistUpdateSuccess\": \"Плейлист обновлен\",\n  \"ToastPodcastCreateFailed\": \"Не удалось создать подкаст\",\n  \"ToastPodcastCreateSuccess\": \"Подкаст успешно создан\",\n  \"ToastPodcastEpisodeUpdated\": \"Выпуск обновлён\",\n  \"ToastPodcastGetFeedFailed\": \"Не удалось получить ленту подкастов\",\n  \"ToastPodcastNoEpisodesInFeed\": \"В RSS-ленте выпусков не найдено\",\n  \"ToastPodcastNoRssFeed\": \"В подкасте нет RSS-ленты\",\n  \"ToastProgressIsNotBeingSynced\": \"Прогресс не синхронизируется, перезапустите воспроизведение\",\n  \"ToastProviderCreatedFailed\": \"Не удалось добавить провайдера\",\n  \"ToastProviderCreatedSuccess\": \"Добавлен новый провайдер\",\n  \"ToastProviderNameAndUrlRequired\": \"Имя и URL обязательные\",\n  \"ToastProviderRemoveSuccess\": \"Провайдер удален\",\n  \"ToastRSSFeedCloseFailed\": \"Не удалось закрыть RSS-ленту\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-лента закрыта\",\n  \"ToastRemoveFailed\": \"Не удалось удалить\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Не удалось удалить элемент из коллекции\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Элемент удален из коллекции\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Не удалось удалить элементы библиотеки с проблемами\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Удалены элементы библиотеки с проблемами\",\n  \"ToastRenameFailed\": \"Не удалось переименовать\",\n  \"ToastRescanFailed\": \"Ошибка повторного сканирования для {0}\",\n  \"ToastRescanRemoved\": \"Повторное сканирование завершено, элемент был удален\",\n  \"ToastRescanUpToDate\": \"Повторное сканирование завершено, элемент был актуализирован\",\n  \"ToastRescanUpdated\": \"Повторное сканирование завершено, элемент был обновлен\",\n  \"ToastScanFailed\": \"Не удалось просканировать элемент библиотеки\",\n  \"ToastSelectAtLeastOneUser\": \"Выберите хотя бы одного пользователя\",\n  \"ToastSendEbookToDeviceFailed\": \"Не удалось отправить e-книгу на устройство\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-книга отправлена на устройство \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Невозможно добавить две серии с одинаковым названием\",\n  \"ToastSeriesUpdateFailed\": \"Не удалось обновить серию\",\n  \"ToastSeriesUpdateSuccess\": \"Успешное обновление серии\",\n  \"ToastServerSettingsUpdateSuccess\": \"Обновлены настройки сервера\",\n  \"ToastSessionCloseFailed\": \"Не удалось закрыть сеанс\",\n  \"ToastSessionDeleteFailed\": \"Не удалось удалить сеанс\",\n  \"ToastSessionDeleteSuccess\": \"Сеанс удален\",\n  \"ToastSleepTimerDone\": \"Выполнен таймер сна... Хр-р-р-р\",\n  \"ToastSlugMustChange\": \"Slug содержит недопустимые символы\",\n  \"ToastSlugRequired\": \"Требуется Slug\",\n  \"ToastSocketConnected\": \"Сокет подключен\",\n  \"ToastSocketDisconnected\": \"Сокет отключен\",\n  \"ToastSocketFailedToConnect\": \"Не удалось подключить сокет\",\n  \"ToastSortingPrefixesEmptyError\": \"Должен быть хотя бы 1 префикс сортировки\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Обновлены префиксы сортировки ({0} элементов)\",\n  \"ToastTitleRequired\": \"Название обязательно\",\n  \"ToastUnknownError\": \"Неизвестная ошибка\",\n  \"ToastUnlinkOpenIdFailed\": \"Не удалось отвязать пользователя от OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Пользователь отвязан от OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Путь к файлу \\\"{0}\\\" уже существует на сервере\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Элемент «{0}» использует подкаталог пути загрузки.\",\n  \"ToastUserDeleteFailed\": \"Не удалось удалить пользователя\",\n  \"ToastUserDeleteSuccess\": \"Пользователь удален\",\n  \"ToastUserPasswordChangeSuccess\": \"Пароль успешно изменен\",\n  \"ToastUserPasswordMismatch\": \"Пароли не совпадают\",\n  \"ToastUserPasswordMustChange\": \"Новый пароль не может совпадать со старым паролем\",\n  \"ToastUserRootRequireName\": \"Необходимо ввести имя пользователя root\",\n  \"TooltipAddChapters\": \"Добавить главу(ы)\",\n  \"TooltipAddOneSecond\": \"Добавить 1 секунду\",\n  \"TooltipAdjustChapterStart\": \"Нажмите, чтобы настроить время начала\",\n  \"TooltipLockAllChapters\": \"Заблокировать все главы\",\n  \"TooltipLockChapter\": \"Заблокировать главу (Shift+клик для диапазона)\",\n  \"TooltipSubtractOneSecond\": \"Вычтите 1 секунду\",\n  \"TooltipUnlockAllChapters\": \"Разблокируйте все главы\",\n  \"TooltipUnlockChapter\": \"Разблокируйте главу (Shift+клик для диапазона)\"\n}\n"
  },
  {
    "path": "client/strings/sk.json",
    "content": "{\n  \"ButtonAdd\": \"Pridať\",\n  \"ButtonAddApiKey\": \"Pridať kľúč API\",\n  \"ButtonAddChapters\": \"Pridať kapitoly\",\n  \"ButtonAddDevice\": \"Pridať zariadenie\",\n  \"ButtonAddLibrary\": \"Pridať knižnicu\",\n  \"ButtonAddPodcasts\": \"Pridať podcasty\",\n  \"ButtonAddUser\": \"Pridať používateľa\",\n  \"ButtonAddYourFirstLibrary\": \"Pridajte vašu prvú knižnicu\",\n  \"ButtonApply\": \"Použiť\",\n  \"ButtonApplyChapters\": \"Použiť kapitoly\",\n  \"ButtonAuthors\": \"Autori\",\n  \"ButtonBack\": \"Späť\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Vytvoriť z existujúcej\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Vyplniť detaily na mape\",\n  \"ButtonBrowseForFolder\": \"Prehľadávať priečinky\",\n  \"ButtonCancel\": \"Zrušiť\",\n  \"ButtonCancelEncode\": \"Zrušiť kódovanie\",\n  \"ButtonChangeRootPassword\": \"Zmeniť Root heslo\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Skontrolovať a stiahnuť nové epizódy\",\n  \"ButtonChooseAFolder\": \"Vyberte priečinok\",\n  \"ButtonChooseFiles\": \"Vyberte súbory\",\n  \"ButtonClearFilter\": \"Zrušiť filter\",\n  \"ButtonClose\": \"Uzavrieť\",\n  \"ButtonCloseFeed\": \"Zatvoriť zdroj\",\n  \"ButtonCloseSession\": \"Ukončiť aktívne relácie\",\n  \"ButtonCollections\": \"Kolekcie\",\n  \"ButtonConfigureScanner\": \"Nastaviť skener\",\n  \"ButtonCreate\": \"Vytvoriť\",\n  \"ButtonCreateBackup\": \"Vytvoriť zálohu\",\n  \"ButtonDelete\": \"Zmazať\",\n  \"ButtonDownloadQueue\": \"Poradie\",\n  \"ButtonEdit\": \"Upraviť\",\n  \"ButtonEditChapters\": \"Upraviť kapitoly\",\n  \"ButtonEditPodcast\": \"Upraviť podcast\",\n  \"ButtonEnable\": \"Povoliť\",\n  \"ButtonFireAndFail\": \"Spustiť a zlyhať\",\n  \"ButtonFireOnTest\": \"Fire onTest udalosť\",\n  \"ButtonForceReScan\": \"Vynútiť preskenovanie\",\n  \"ButtonFullPath\": \"Zobraziť cestu\",\n  \"ButtonHide\": \"Skryť\",\n  \"ButtonHome\": \"Domov\",\n  \"ButtonIssues\": \"Problémy\",\n  \"ButtonJumpBackward\": \"Posun späť\",\n  \"ButtonJumpForward\": \"Posun vpred\",\n  \"ButtonLatest\": \"Najnovšie\",\n  \"ButtonLibrary\": \"Knižnica\",\n  \"ButtonLogout\": \"Odhlásenie\",\n  \"ButtonLookup\": \"Vyhľadať\",\n  \"ButtonManageTracks\": \"Spravovať stopy\",\n  \"ButtonMapChapterTitles\": \"Mapovať názvy kapitol\",\n  \"ButtonMatchAllAuthors\": \"Vyhľadať všetkých autorov\",\n  \"ButtonMatchBooks\": \"Vyhľadať knihy\",\n  \"ButtonNevermind\": \"Nevadí\",\n  \"ButtonNext\": \"Ďalšie\",\n  \"ButtonNextChapter\": \"Ďalšia kapitola\",\n  \"ButtonNextItemInQueue\": \"Ďalšia položka v poradí\",\n  \"ButtonOk\": \"OK\",\n  \"ButtonOpenFeed\": \"Otvoriť zdroj\",\n  \"ButtonOpenManager\": \"Otvoriť správcu\",\n  \"ButtonPause\": \"Pozastaviť\",\n  \"ButtonPlay\": \"Prehrať\",\n  \"ButtonPlayAll\": \"Prehrať všetko\",\n  \"ButtonPlaying\": \"Prehráva sa\",\n  \"ButtonPlaylists\": \"Playlisty\",\n  \"ButtonPrevious\": \"Predchádzajúci\",\n  \"ButtonPreviousChapter\": \"Predchádzajúca kapitola\",\n  \"ButtonProbeAudioFile\": \"Preskúmaj zvukový súbor\",\n  \"ButtonPurgeAllCache\": \"Vymaž celú medzipamäť\",\n  \"ButtonPurgeItemsCache\": \"Vymaž medzipamäť položiek\",\n  \"ButtonQueueAddItem\": \"Pridať do poradia\",\n  \"ButtonQueueRemoveItem\": \"Vymazať z poradia\",\n  \"ButtonQuickEmbed\": \"Rýchle vloženie\",\n  \"ButtonQuickEmbedMetadata\": \"Rýchle vloženie metadát\",\n  \"ButtonQuickMatch\": \"Rýchle vyhľadanie\",\n  \"ButtonReScan\": \"Preskenovať\",\n  \"ButtonRead\": \"Načítať\",\n  \"ButtonReadLess\": \"Načítať menej\",\n  \"ButtonReadMore\": \"Načítať viac\",\n  \"ButtonRefresh\": \"Obnoviť\",\n  \"ButtonRemove\": \"Odstrániť\",\n  \"ButtonRemoveAll\": \"Odstrániť všetko\",\n  \"ButtonRemoveAllLibraryItems\": \"Odstrániť všetky položky knižnice\",\n  \"ButtonRemoveFromContinueListening\": \"Odstrániť z nedokončených podcastov\",\n  \"ButtonRemoveFromContinueReading\": \"Odtrániť z nedokončených audiokníh\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Odstrániť z nedokončených sérií\",\n  \"ButtonReset\": \"Resetovať\",\n  \"ButtonResetToDefault\": \"Resetovať do predvolené\",\n  \"ButtonRestore\": \"Obnoviť zo zálohy\",\n  \"ButtonSave\": \"Uložiť\",\n  \"ButtonSaveAndClose\": \"Uložiť a zavrieť\",\n  \"ButtonSaveTracklist\": \"Uložiť tracklist\",\n  \"ButtonScan\": \"Skenovať\",\n  \"ButtonScanLibrary\": \"Skenovať knižnicu\",\n  \"ButtonScrollLeft\": \"Doľava\",\n  \"ButtonScrollRight\": \"Doprava\",\n  \"ButtonSearch\": \"Vyhľadať\",\n  \"ButtonSelectFolderPath\": \"Vybrať umiestnenie priečinku\",\n  \"ButtonSeries\": \"Série\",\n  \"ButtonSetChaptersFromTracks\": \"Nastaviť kapitoly podľa stôp\",\n  \"ButtonShare\": \"Zdieľať\",\n  \"ButtonShiftTimes\": \"Posunúť začiatky kapitol\",\n  \"ButtonShow\": \"Zobraziť\",\n  \"ButtonStartM4BEncode\": \"Spustiť prekódovanie na M4B\",\n  \"ButtonStartMetadataEmbed\": \"Vložiť metadáta\",\n  \"ButtonStats\": \"Štatistiky\",\n  \"ButtonSubmit\": \"Odoslať\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Zrušiť prepojenie OpenID\",\n  \"ButtonUpload\": \"Nahrať\",\n  \"ButtonUploadBackup\": \"Nahrať zálohu\",\n  \"ButtonUploadCover\": \"Nahrať prebal\",\n  \"ButtonUploadOPMLFile\": \"Nahrať súbor OPML\",\n  \"ButtonUserDelete\": \"Odstrániť užívateľa {0}\",\n  \"ButtonUserEdit\": \"Upraviť užívateľa {0}\",\n  \"ButtonViewAll\": \"Zobraziť všetko\",\n  \"ButtonYes\": \"Áno\",\n  \"ErrorUploadFetchMetadataAPI\": \"Chyba pri sťahovaní metadát\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Metadáta sa nenašli - skúste upraviť názov a/alebo autora\",\n  \"ErrorUploadLacksTitle\": \"Musí mať názov\",\n  \"HeaderAccount\": \"Účet\",\n  \"HeaderAddCustomMetadataProvider\": \"Pridať vastný zdroj metadát\",\n  \"HeaderAdvanced\": \"Pokročilé\",\n  \"HeaderApiKeys\": \"Kľúče API\",\n  \"HeaderAppriseNotificationSettings\": \"Nastavenie Apprise notifikácií\",\n  \"HeaderAudioTracks\": \"Zvukové stopy\",\n  \"HeaderAudiobookTools\": \"Nástroje na správu súborov audiokníh\",\n  \"HeaderAuthentication\": \"Overenie\",\n  \"HeaderBackups\": \"Zálohy\",\n  \"HeaderBulkChapterModal\": \"Pridať viaceré kapitoly\",\n  \"HeaderChangePassword\": \"Zmeniť heslo\",\n  \"HeaderChapters\": \"Kapitoly\",\n  \"HeaderChooseAFolder\": \"Vybrať priečinok\",\n  \"HeaderCollection\": \"Zbierky\",\n  \"HeaderCollectionItems\": \"Položky zbierky\",\n  \"HeaderCover\": \"Prebal\",\n  \"HeaderCurrentDownloads\": \"Aktuálne sťahovanie\",\n  \"HeaderCustomMessageOnLogin\": \"Vlastné privítanie pri prihlásení\",\n  \"HeaderCustomMetadataProviders\": \"Vlastné zdroje metadát\",\n  \"HeaderDetails\": \"Podrobnosti\",\n  \"HeaderDownloadQueue\": \"Poradie sťahovania\",\n  \"HeaderEbookFiles\": \"Súbory e-kníh\",\n  \"HeaderEmail\": \"E-mail\",\n  \"HeaderEmailSettings\": \"Nastavenie e-mailu\",\n  \"HeaderEpisodes\": \"Epizódy\",\n  \"HeaderEreaderDevices\": \"Čítačky e-kníh\",\n  \"HeaderEreaderSettings\": \"Nastavenia čítačky\",\n  \"HeaderFiles\": \"Súbory\",\n  \"HeaderFindChapters\": \"Nájsť kapitoly\",\n  \"HeaderIgnoredFiles\": \"Ignorované súbory\",\n  \"HeaderItemFiles\": \"Položka Súbory\",\n  \"HeaderItemMetadataUtils\": \"Položka Nástroje metadát\",\n  \"HeaderLastListeningSession\": \"Posledná relácia\",\n  \"HeaderLatestEpisodes\": \"Posledné epizódy\",\n  \"HeaderLibraries\": \"Knižnice\",\n  \"HeaderLibraryFiles\": \"Súbory knižnice\",\n  \"HeaderLibraryStats\": \"Štatistiky knižnice\",\n  \"HeaderListeningSessions\": \"Relácie\",\n  \"HeaderListeningStats\": \"Štatistiky počúvania\",\n  \"HeaderLogin\": \"Prihlásenie\",\n  \"HeaderLogs\": \"Záznamy udalostí\",\n  \"HeaderManageGenres\": \"Spravovať žánre\",\n  \"HeaderManageTags\": \"Spravovať štítky\",\n  \"HeaderMapDetails\": \"Podrobnosti mapovania\",\n  \"HeaderMatch\": \"Spárovať\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Metadáta pravidiel poradia\",\n  \"HeaderMetadataToEmbed\": \"Metadáta na vloženie\",\n  \"HeaderNewAccount\": \"Nový účet\",\n  \"HeaderNewApiKey\": \"Nový kľúč API\",\n  \"HeaderNewLibrary\": \"Nová knižnica\",\n  \"HeaderNotificationCreate\": \"Vytvoriť notifikáciu\",\n  \"HeaderNotificationUpdate\": \"Aktualizovať notifikáciu\",\n  \"HeaderNotifications\": \"Notifikácie\",\n  \"HeaderOpenIDConnectAuthentication\": \"Overenie pripojenia OpenID\",\n  \"HeaderOpenListeningSessions\": \"Aktívne relácie\",\n  \"HeaderOpenRSSFeed\": \"Otvoriť RSS zdroj\",\n  \"HeaderOtherFiles\": \"Ostatné súbory\",\n  \"HeaderPasswordAuthentication\": \"Overenie heslom\",\n  \"HeaderPermissions\": \"Povolenia\",\n  \"HeaderPlayerQueue\": \"Poradie prehrávania\",\n  \"HeaderPlayerSettings\": \"Nastavenia prehrávania\",\n  \"HeaderPlaylist\": \"Playlist\",\n  \"HeaderPlaylistItems\": \"Položky playlistu\",\n  \"HeaderPodcastsToAdd\": \"Podcasty na pridanie\",\n  \"HeaderPresets\": \"Predvolené\",\n  \"HeaderPreviewCover\": \"Ukážka prebalu\",\n  \"HeaderRSSFeedGeneral\": \"Detaily RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS zdroj je otvorený\",\n  \"HeaderRSSFeeds\": \"RSS zdroje\",\n  \"HeaderRemoveEpisode\": \"Odstrániť epizódu\",\n  \"HeaderRemoveEpisodes\": \"Odstrániť {0} epizód\",\n  \"HeaderSavedMediaProgress\": \"Stav uložených médií\",\n  \"HeaderSchedule\": \"Plán\",\n  \"HeaderScheduleEpisodeDownloads\": \"Naplánovať automatické sťahovanie epizód\",\n  \"HeaderScheduleLibraryScans\": \"Naplánovanovať automatické skenovanie knižnice\",\n  \"HeaderSession\": \"Relácia\",\n  \"HeaderSetBackupSchedule\": \"Naplánovať zálohovanie\",\n  \"HeaderSettings\": \"Nastavenia\",\n  \"HeaderSettingsDisplay\": \"Zobraziť\",\n  \"HeaderSettingsExperimental\": \"Experimentálne funkcie\",\n  \"HeaderSettingsGeneral\": \"Hlavné\",\n  \"HeaderSettingsScanner\": \"Skener\",\n  \"HeaderSettingsSecurity\": \"Zabezpečenie\",\n  \"HeaderSettingsWebClient\": \"Webový klient\",\n  \"HeaderSleepTimer\": \"Časovač spánku\",\n  \"HeaderStatsLargestItems\": \"Najväčšie položky\",\n  \"HeaderStatsLongestItems\": \"Najdlhšie položky (v hodinách)\",\n  \"HeaderStatsMinutesListeningChart\": \"Vypočutých minút (za posledných 7 dní)\",\n  \"HeaderStatsRecentSessions\": \"Nedávne relácie\",\n  \"HeaderStatsTop10Authors\": \"Top 10 autorov\",\n  \"HeaderStatsTop5Genres\": \"Top 5 žánrov\",\n  \"HeaderTableOfContents\": \"Obsah\",\n  \"HeaderTools\": \"Nástroje\",\n  \"HeaderUpdateAccount\": \"Aktualizovať účet\",\n  \"HeaderUpdateApiKey\": \"Aktualizovať kľúč API\",\n  \"HeaderUpdateAuthor\": \"Aktualizovať autora\",\n  \"HeaderUpdateDetails\": \"Aktualizovať detaily\",\n  \"HeaderUpdateLibrary\": \"Aktualizovať knižnicu\",\n  \"HeaderUsers\": \"Užívatelia\",\n  \"HeaderYearReview\": \"Prehľad roka {0}\",\n  \"HeaderYourStats\": \"Vaše štatistiky\",\n  \"LabelAbridged\": \"Skrátená verzia\",\n  \"LabelAbridgedChecked\": \"Skrátená verzia (zaškrtnuté)\",\n  \"LabelAbridgedUnchecked\": \"Neskrátená verzia (nezaškrtnuté)\",\n  \"LabelAccessibleBy\": \"Dostupné pre\",\n  \"LabelAccountType\": \"Typ účtu\",\n  \"LabelAccountTypeAdmin\": \"Administrátor\",\n  \"LabelAccountTypeGuest\": \"Hosť\",\n  \"LabelAccountTypeUser\": \"Užívateľ\",\n  \"LabelActivities\": \"Aktivity\",\n  \"LabelActivity\": \"Aktivita\",\n  \"LabelAddToCollection\": \"Pridať do zbierky\",\n  \"LabelAddToCollectionBatch\": \"Pridať {0} kníh do zbierky\",\n  \"LabelAddToPlaylist\": \"Pridať do playlistu\",\n  \"LabelAddToPlaylistBatch\": \"Pridať {0} položie do playlistu\",\n  \"LabelAddedAt\": \"Pridané\",\n  \"LabelAddedDate\": \"Pridané {0}\",\n  \"LabelAdminUsersOnly\": \"Iba administrátory\",\n  \"LabelAll\": \"Všetko\",\n  \"LabelAllEpisodesDownloaded\": \"Všetky epizódy stiahnuté\",\n  \"LabelAllUsers\": \"Všetci užívatelia\",\n  \"LabelAllUsersExcludingGuests\": \"Všetci užívatelia okrem hostí\",\n  \"LabelAllUsersIncludingGuests\": \"Všetci užívatelia vrátane hostí\",\n  \"LabelAlreadyInYourLibrary\": \"Už v tvojej knižnici\",\n  \"LabelApiKeyCreated\": \"Kľúč API \\\"{0}\\\" bol úspešne vytvorený.\",\n  \"LabelApiKeyCreatedDescription\": \"Skopírujte si kľúč API teraz, neskôr ho už neuvidíte.\",\n  \"LabelApiKeyUser\": \"Vykonáva v mene používateľa\",\n  \"LabelApiKeyUserDescription\": \"Uvedená API bude mať rovnaké práva ako používateľ, v mene ktorého koná. Rovnako v záznamoch budú jednotlivé krky uvedené, ako keby ich vykonal samotný používateľ.\",\n  \"LabelApiToken\": \"API Token\",\n  \"LabelAppend\": \"Pridať\",\n  \"LabelAudioBitrate\": \"Bitrate audio stopy (napr. 128k)\",\n  \"LabelAudioChannels\": \"Počet kanálov audio stopy (1 alebo 2)\",\n  \"LabelAudioCodec\": \"Kodek audio stopy\",\n  \"LabelAuthor\": \"Autor\",\n  \"LabelAuthorFirstLast\": \"Autor (Meno Priezvisko)\",\n  \"LabelAuthorLastFirst\": \"Autor (Priezvisko, Meno)\",\n  \"LabelAuthors\": \"Autori\",\n  \"LabelAutoDownloadEpisodes\": \"Automaticky sťahovať epizódy\",\n  \"LabelAutoFetchMetadata\": \"Automaticky načítať metadáta\",\n  \"LabelAutoFetchMetadataHelp\": \"Načíta metadáta pre názov, autra a sériu pre optimalizované nahranie. Dodatočné metadáta môžu byť priradené po nahraní.\",\n  \"LabelAutoLaunch\": \"Automaticky spustiť\",\n  \"LabelAutoLaunchDescription\": \"Presmerovať na poskytovateľa authentifikácie pri otvorení prihlasovacej stránky (manuálny prepis cesty <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Automatická registrácia\",\n  \"LabelAutoRegisterDescription\": \"Automaticky vytvoriť nových užívateľov po prihlásení\",\n  \"LabelBackToUser\": \"Späť na užívateľa\",\n  \"LabelBackupAudioFiles\": \"Zálohovať audio súbory\",\n  \"LabelBackupLocation\": \"Zálohovať lokáciu\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatické zálohy\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Zálohy uložené v /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Maximálna veľkosť zálohy (v GB) (0 pre neobmedzenú)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Ako poistka proti miskonfigurácii, zálohy zlyhajú ak prekročia konfigurovanú veľkosť.\",\n  \"LabelBackupsNumberToKeep\": \"Počet uložených záloh\",\n  \"LabelBackupsNumberToKeepHelp\": \"Týmto spôsobom odstránite vždy iba jednu zálohu. V prípade, ak chcete odtrániť viacero záloh, mali by ste ich odstrániť manuálne.\",\n  \"LabelBitrate\": \"Bitrate\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Knihy\",\n  \"LabelButtonText\": \"Text tlačidla\",\n  \"LabelByAuthor\": \"od\",\n  \"LabelChangePassword\": \"Zmeniť heslo\",\n  \"LabelChannels\": \"Kanály\",\n  \"LabelChapterCount\": \"{0} kapitol\",\n  \"LabelChapterTitle\": \"Názov kapitoly\",\n  \"LabelChapters\": \"Kapitoly\",\n  \"LabelChaptersFound\": \"nájdených kapitol\",\n  \"LabelClickForMoreInfo\": \"Klikni pre viac informácií\",\n  \"LabelClickToUseCurrentValue\": \"Klikni pre použitie aktuálnej hodnoty\",\n  \"LabelClosePlayer\": \"Zavrieť prehrávač\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Zbaliť série\",\n  \"LabelCollapseSubSeries\": \"Zbaliť podsérie\",\n  \"LabelCollection\": \"Zbierka\",\n  \"LabelCollections\": \"Zbierky\",\n  \"LabelComplete\": \"Hotovo\",\n  \"LabelConfirmPassword\": \"Potvrdiť heslo\",\n  \"LabelContinueListening\": \"Pokračovať v počúvaní\",\n  \"LabelContinueReading\": \"Pokračovať v čítaní\",\n  \"LabelContinueSeries\": \"Pokračovať v sérii\",\n  \"LabelCorsAllowed\": \"CORS Origins povolené\",\n  \"LabelCover\": \"Prebal\",\n  \"LabelCoverImageURL\": \"URL obrázku prebalu\",\n  \"LabelCoverProvider\": \"Poskytovateľ prebalu\",\n  \"LabelCreatedAt\": \"Vytvorené\",\n  \"LabelCronExpression\": \"Cron príkaz\",\n  \"LabelCurrent\": \"Aktuálny\",\n  \"LabelCurrently\": \"Aktuálne:\",\n  \"LabelCustomCronExpression\": \"Vlastný Cron príkaz:\",\n  \"LabelDatetime\": \"Dátum a čas\",\n  \"LabelDays\": \"Dni\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Zmazať zo systému (odškrtni len pre odstránenie z databázy)\",\n  \"LabelDescription\": \"Popis\",\n  \"LabelDeselectAll\": \"Odznačiť všetko\",\n  \"LabelDetectedPattern\": \"Identifikovaný vzor:\",\n  \"LabelDevice\": \"Zariadenie\",\n  \"LabelDeviceInfo\": \"Informácie o zariadení\",\n  \"LabelDeviceIsAvailableTo\": \"Zariadenie je k dispozícii...\",\n  \"LabelDirectory\": \"Priečinok\",\n  \"LabelDiscFromFilename\": \"Disk z názvu súboru\",\n  \"LabelDiscFromMetadata\": \"Disk z metadát\",\n  \"LabelDiscover\": \"Objaviť\",\n  \"LabelDownload\": \"Stiahnuť\",\n  \"LabelDownloadNEpisodes\": \"Stiahnuť {0} epizód\",\n  \"LabelDownloadable\": \"Dostupné na stiahnutie\",\n  \"LabelDuration\": \"Dĺžka\",\n  \"LabelDurationComparisonExactMatch\": \"(presná zhoda)\",\n  \"LabelDurationComparisonLonger\": \"({0} dlhšie)\",\n  \"LabelDurationComparisonShorter\": \"({0} kratšie)\",\n  \"LabelDurationFound\": \"Nájdená dlžka:\",\n  \"LabelEbook\": \"E-kniha\",\n  \"LabelEbooks\": \"E-knihy\",\n  \"LabelEdit\": \"Upraviť\",\n  \"LabelEmail\": \"E-mail\",\n  \"LabelEmailSettingsFromAddress\": \"Z e-mailu\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Odmietnuť neautorizované certifikáty\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Vypnutie validácie SSL certifikátu môže tvoje pripojenie vystaviť bezpečnostným rizikám, ako napríklad MitM útokom. Vypni túto možnosť len v prípade, že rozumieš dôsledkom a dôveruješ e-mailovému serveru, ku ktorému sa pripájaš.\",\n  \"LabelEmailSettingsSecure\": \"Bezpečné\",\n  \"LabelEmailSettingsSecureHelp\": \"Pri povolení bude na pripojenie k serveru použité TLS. V opačnom prípade je TLS použité iba v prípade, ak server podporuje rozšírenie STARTTLS. Vo väčšine prípadov povoľte túto možnosť, ak sa pripájate cez port 465. V prípadoch, ak používate port 587 alebo 25, túto voľbu nepovoľujte. (prevzaté z nodemailer.com/smtp/#authentification)\",\n  \"LabelEmailSettingsTestAddress\": \"Testovacia adresa\",\n  \"LabelEmbeddedCover\": \"Vložený prebal\",\n  \"LabelEnable\": \"Povoliť\",\n  \"LabelEncodingBackupLocation\": \"Záloha vašich pôvodných zvukových súborov bude uložená v:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Kapitoly nie sú vkladané do viacstopových audiokníh.\",\n  \"LabelEncodingClearItemCache\": \"Nezabudnite pravidelne vyčistiť vyrovnávaciu pamäť jednotlivých položiek.\",\n  \"LabelEncodingFinishedM4B\": \"Dokončený súbor M4B bude uložený do priečinka audioknihy v:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadáta budú vložené do zvukových stôp v priečinku audioknihy.\",\n  \"LabelEncodingStartedNavigation\": \"Po spustení úlohy môžete túto stránku opustiť.\",\n  \"LabelEncodingTimeWarning\": \"Prekódovanie môže trvať aj 30 minút.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Pozor: Nemeňte uvedené nastavenia, pokiaľ nie ste dostatočne oboznámený s nastaveniami ffmpeg prekódovania.\",\n  \"LabelEncodingWatcherDisabled\": \"V prípade, ak nemáte povolené automatické sledovanie zmien, bude na konci potrebné audioknihu opätovne preskenovať.\",\n  \"LabelEnd\": \"Ukončiť\",\n  \"LabelEndOfChapter\": \"Koniec kapitoly\",\n  \"LabelEpisode\": \"Epizóda\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Epizóda bez RSS zdroja\",\n  \"LabelEpisodeNumber\": \"Epizóda #{0}\",\n  \"LabelEpisodeTitle\": \"Názov epizódy\",\n  \"LabelEpisodeType\": \"Typ epizódy\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL epizódy z RSS\",\n  \"LabelEpisodes\": \"Epizódy\",\n  \"LabelEpisodic\": \"Epizódny\",\n  \"LabelExample\": \"Príklad\",\n  \"LabelExpandSeries\": \"Rozbaliť série\",\n  \"LabelExpandSubSeries\": \"Rozbaliť podsérie\",\n  \"LabelExpired\": \"Vypršal\",\n  \"LabelExpiresAt\": \"Vyprší\",\n  \"LabelExpiresInSeconds\": \"Vyprší za (sekúnd)\",\n  \"LabelExpiresNever\": \"Nikdy\",\n  \"LabelExplicit\": \"Explicitný obsah\",\n  \"LabelExplicitChecked\": \"Explicitné (zaškrtnuté)\",\n  \"LabelExplicitUnchecked\": \"Ne-explicitné (nezaškrtnuté)\",\n  \"LabelExportOPML\": \"Exportovať OPML\",\n  \"LabelFeedURL\": \"URL zdroja\",\n  \"LabelFetchingMetadata\": \"Sťahovanie metadát\",\n  \"LabelFile\": \"Súbor\",\n  \"LabelFileBirthtime\": \"Čas vytvorenia súboru\",\n  \"LabelFileBornDate\": \"Vytvorené {0}\",\n  \"LabelFileModified\": \"Súbor zmenený\",\n  \"LabelFileModifiedDate\": \"Zmenený {0}\",\n  \"LabelFilename\": \"Názov súboru\",\n  \"LabelFilterByUser\": \"Užívateľský filter\",\n  \"LabelFindEpisodes\": \"Nájsť epizódy\",\n  \"LabelFinished\": \"Ukončené\",\n  \"LabelFinishedDate\": \"Dokončené {0}\",\n  \"LabelFolder\": \"Priečinok\",\n  \"LabelFolders\": \"Priečinky\",\n  \"LabelFontBold\": \"Tučné\",\n  \"LabelFontBoldness\": \"Hrúbka písma\",\n  \"LabelFontFamily\": \"písmo\",\n  \"LabelFontItalic\": \"Kurzíva\",\n  \"LabelFontScale\": \"Veľkosť písma\",\n  \"LabelFontStrikethrough\": \"Preškrtnuté\",\n  \"LabelFormat\": \"Formát\",\n  \"LabelFull\": \"Plné\",\n  \"LabelGenre\": \"Žáner\",\n  \"LabelGenres\": \"Žánre\",\n  \"LabelHardDeleteFile\": \"Nezvratné zmazanie súborov\",\n  \"LabelHasEbook\": \"Má e-knihu\",\n  \"LabelHasSupplementaryEbook\": \"Má doplnkovú e-knihu\",\n  \"LabelHideSubtitles\": \"Skryť titulky\",\n  \"LabelHighestPriority\": \"Najvyššia priorita\",\n  \"LabelHost\": \"Host\",\n  \"LabelHour\": \"Hodina\",\n  \"LabelHours\": \"Hodiny\",\n  \"LabelIcon\": \"Ikona\",\n  \"LabelImageURLFromTheWeb\": \"URL obrázku\",\n  \"LabelInProgress\": \"Prebieha\",\n  \"LabelIncludeInTracklist\": \"Vložiť do tracklistu\",\n  \"LabelIncomplete\": \"Nekompletné\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Vlastný\",\n  \"LabelIntervalEvery12Hours\": \"Každých 12 hodín\",\n  \"LabelIntervalEvery15Minutes\": \"Každých 15 minút\",\n  \"LabelIntervalEvery2Hours\": \"Každé 2 hodiny\",\n  \"LabelIntervalEvery30Minutes\": \"Každých 30 minút\",\n  \"LabelIntervalEvery6Hours\": \"Každých 6 hodín\",\n  \"LabelIntervalEveryDay\": \"Denne\",\n  \"LabelIntervalEveryHour\": \"Každú hodinu\",\n  \"LabelIntervalEveryMinute\": \"Každú minútu\",\n  \"LabelInvert\": \"Invertne\",\n  \"LabelItem\": \"Položka\",\n  \"LabelJumpBackwardAmount\": \"Posunúť vpred o\",\n  \"LabelJumpForwardAmount\": \"Posunúť dozadu o\",\n  \"LabelLanguage\": \"Jazyk\",\n  \"LabelLanguageDefaultServer\": \"Prednastavený jazyk servera\",\n  \"LabelLanguages\": \"Jazyky\",\n  \"LabelLastBookAdded\": \"Posledná pridaná kniha\",\n  \"LabelLastBookUpdated\": \"Posledná aktualizovaná kniha\",\n  \"LabelLastProgressDate\": \"Posledný pokrok: {0}\",\n  \"LabelLastSeen\": \"Posledne videné\",\n  \"LabelLastTime\": \"Posledný čas\",\n  \"LabelLastUpdate\": \"Posledná aktualizácia\",\n  \"LabelLayout\": \"Rozloženie\",\n  \"LabelLayoutSinglePage\": \"Jedna stránka\",\n  \"LabelLayoutSplitPage\": \"Rozdelená stránka\",\n  \"LabelLess\": \"Menej\",\n  \"LabelLibrariesAccessibleToUser\": \"Knižnice dostupné užívateľovi\",\n  \"LabelLibrary\": \"Knižnica\",\n  \"LabelLibraryFilterSublistEmpty\": \"Žiadne {0}\",\n  \"LabelLibraryItem\": \"Položka knižnice\",\n  \"LabelLibraryName\": \"Názov knižnice\",\n  \"LabelLibrarySortByProgress\": \"Stav: Naposledy aktualizované\",\n  \"LabelLibrarySortByProgressFinished\": \"Stav: Dokončené\",\n  \"LabelLibrarySortByProgressStarted\": \"Stav: Začal\",\n  \"LabelLimit\": \"Limit\",\n  \"LabelLineSpacing\": \"Riadkovanie\",\n  \"LabelListenAgain\": \"Počúvať znova\",\n  \"LabelLogLevelDebug\": \"Ladenie\",\n  \"LabelLogLevelInfo\": \"Informácia\",\n  \"LabelLogLevelWarn\": \"Varovanie\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Hľadať nové epizódy od uvedeného dátumu\",\n  \"LabelLowestPriority\": \"Najnižšia priorita\",\n  \"LabelMatchConfidence\": \"Istota\",\n  \"LabelMatchExistingUsersBy\": \"Vyhľadaj vytvorených užívateľov podľa\",\n  \"LabelMatchExistingUsersByDescription\": \"Používané na pripájanie vytvorených užívateľov. Po pripojení budú užívatelia vyhľadaní na základe jedinečného id poskytnutého Vaším poskytovateľom SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Stiahnuť maximálne # epizód. Pre neobmedzené sťahovanie zadajte 0.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Pri kontrole stiahnuť maximálne # epizód\",\n  \"LabelMaxEpisodesToKeep\": \"Uchovávať maximálne # epizód\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Hodnota 0 znamená bez limitu. Po automatickom stiahnutí novej epizódy bude najstaršia epizóda zmazaná a ponechaných zostane X epizód. Pri každom stiahnutí 1 epizódy bude vždy zmazaná iba 1 najstaršia epizóda.\",\n  \"LabelMediaPlayer\": \"Prehrávač\",\n  \"LabelMediaType\": \"Typ média\",\n  \"LabelMetaTag\": \"Meta štítok\",\n  \"LabelMetaTags\": \"Meta štítky\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Zdroje metadát s vyššou prioritou prepíšu zdroje metadát s nižšou prioritou\",\n  \"LabelMetadataProvider\": \"Poskytovateľ metadát\",\n  \"LabelMinute\": \"Minúta\",\n  \"LabelMinutes\": \"Minúty\",\n  \"LabelMissing\": \"Chýbajúce\",\n  \"LabelMissingEbook\": \"Nemá e-knihu\",\n  \"LabelMissingSupplementaryEbook\": \"Nemá doplnkovú e-knihu\",\n  \"LabelMobileRedirectURIs\": \"Povolené Mobile Redirect URI\",\n  \"LabelMobileRedirectURIsDescription\": \"Toto je zoznam povolených URI pre mobilné aplikácie. Prednastavená je <code>audiobookshelf://oauth</code>, ktorú však môžete odstrániť alebo nahradiť inou URI pre integráciu aplikácií tretích strán. Použitím hviezdičky (<code>*</code>) povolíte všetky URI.\",\n  \"LabelMore\": \"Viac\",\n  \"LabelMoreInfo\": \"Viac informácií\",\n  \"LabelName\": \"Meno\",\n  \"LabelNarrator\": \"Interpret\",\n  \"LabelNarrators\": \"Interpreti\",\n  \"LabelNew\": \"Nový\",\n  \"LabelNewPassword\": \"Nové heslo\",\n  \"LabelNewestAuthors\": \"Najnovší autori\",\n  \"LabelNewestEpisodes\": \"Najnovšie epizódy\",\n  \"LabelNextBackupDate\": \"Ďalší dátum zálohovania\",\n  \"LabelNextChapters\": \"Nasledujúce kapitoly:\",\n  \"LabelNextScheduledRun\": \"Ďalší plánovaný beh\",\n  \"LabelNoApiKeys\": \"Žiadne API kľúče\",\n  \"LabelNoCustomMetadataProviders\": \"Žiadne vlastné zdroje metadát\",\n  \"LabelNoEpisodesSelected\": \"Neboli vybrané žiadne epizódy\",\n  \"LabelNotFinished\": \"Nedokončené\",\n  \"LabelNotStarted\": \"Nezačaté\",\n  \"LabelNotes\": \"Poznámky\",\n  \"LabelNotificationAppriseURL\": \"URL odkaz(-y) Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Dostupné premenné\",\n  \"LabelNotificationBodyTemplate\": \"Šablóna obsahu\",\n  \"LabelNotificationEvent\": \"Udalosť oznámení\",\n  \"LabelNotificationTitleTemplate\": \"Šablóna názvu\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maximálny počet neúspešných pokusov\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Notifikácie sa automaticky vypnú, ak ich odoslanie zlyhá nasledovný počet krát\",\n  \"LabelNotificationsMaxQueueSize\": \"Maximálna dĺžka fronty oznámení\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Odosielanie udalostí je ohraničené na jedno oznámenie za sekundu. Novovzniknuté udalosti budú ignorované, ak bude fronta oznámení naplnená. Toto nastavenie zabraňuje nevyžiadanému zahlteniu oznámeniami.\",\n  \"LabelNumberOfBooks\": \"Počet kníh\",\n  \"LabelNumberOfChapters\": \"Počet kapitol:\",\n  \"LabelNumberOfEpisodes\": \"# z epizód\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Názov OpenID predpokladá prítomnosť pokročilých povolení pre užívateľské akcie v rámci aplikácie, ktoré sú aplikovateľné na ne-administrátorské role (<b>ak sú nakonfigurované</b>). Ak potvrdenie takýchto pokročilých povolení nie je v odozve prítomné, prístup do ABS bude automaticky zamietnutý. Ak v odozve chýba len niektoré z očakávaných nastavení, tak bude jeho hodnota automaticky nastavená na <code>false</code>. Uistite sa prosím, že forma odozvy poskytovateľa identity má nasledovnú štruktúru:\",\n  \"LabelOpenIDClaims\": \"Ak ponecháte nasledujúce nastavenia prázdne, pokročilé nastavenia skupín a povolení nebudú aktivované a automaticky bude nastavená skupina 'Užívateľ'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Pri názve požiadavky OpenID sa predpokladá, že obsahuje zoznam užívateľských skupín. Bežne označovaný ako <code>groups</code>. <b>Ak je správne nakonfigurovaný</b>, aplikácia automaticky pridelí role podľa príslušnosti k užívateľským skupinám pod podmienkou, že sú tieto skupiny v požiadavke nazvané (bez ohľadu na veľkosť písmen) ako 'admin', 'user' alebo 'guest'. Požiadavka musí obsahovať zoznam skupín a ak užívateľ patrí do viacerých skupín, aplikácia mu priradí rolu, ktorá zodpovedá skupine s najvyššími prístupovými právami. Ak sa žiadna z poskytnutých skupín nezhoduje, prístup bude zamietnutý.\",\n  \"LabelOpenRSSFeed\": \"Otvor RSS zdroj\",\n  \"LabelOverwrite\": \"Prepísať\",\n  \"LabelPaginationPageXOfY\": \"Stránka {0} z {1}\",\n  \"LabelPassword\": \"Heslo\",\n  \"LabelPath\": \"Cesta\",\n  \"LabelPermanent\": \"Trvalé\",\n  \"LabelPermissionsAccessAllLibraries\": \"Má prístup do všetkých knižníc\",\n  \"LabelPermissionsAccessAllTags\": \"Má prístup ku všetkým štítkom\",\n  \"LabelPermissionsAccessExplicitContent\": \"Má prístup k explicitnému obsahu\",\n  \"LabelPermissionsCreateEreader\": \"Môže vytvoriť čítačku e-kníh\",\n  \"LabelPermissionsDelete\": \"Môže mazať\",\n  \"LabelPermissionsDownload\": \"Môže sťahovať\",\n  \"LabelPermissionsUpdate\": \"Môže aktualizovať\",\n  \"LabelPermissionsUpload\": \"Môže nahrávať\",\n  \"LabelPersonalYearReview\": \"Váš rok v prehľade ({0})\",\n  \"LabelPhotoPathURL\": \"Cesta/URL fotky\",\n  \"LabelPlayMethod\": \"Metóda prehrávania\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Veľkosť kroku zrýchlenia/spomalenia rýchlosti prehávania\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} z {1}\",\n  \"LabelPlaylists\": \"Playlisty\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Región vyhľadávania podcastu\",\n  \"LabelPodcastType\": \"Typ podcastu\",\n  \"LabelPodcasts\": \"Podcasty\",\n  \"LabelPort\": \"Prístav\",\n  \"LabelPrefixesToIgnore\": \"Ignorované predpony (bez ohľadu na veľkosť písmen)\",\n  \"LabelPreventIndexing\": \"Zabráni indexácii vašich zdrojov službami iTunes a Google podcast directories\",\n  \"LabelPrimaryEbook\": \"Primárny e-book\",\n  \"LabelProgress\": \"Aktuálny stav\",\n  \"LabelProvider\": \"Poskytovateľ\",\n  \"LabelProviderAuthorizationValue\": \"Obsah hlavičky autorizácie\",\n  \"LabelPubDate\": \"Dátum publikovania\",\n  \"LabelPublishYear\": \"Rok vydania\",\n  \"LabelPublishedDate\": \"Vydané {0}\",\n  \"LabelPublishedDecade\": \"Dekáda vydania\",\n  \"LabelPublishedDecades\": \"Dekády vydania\",\n  \"LabelPublisher\": \"Vydavateľ\",\n  \"LabelPublishers\": \"Vydavatelia\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Vlastný e-mail vlastníka\",\n  \"LabelRSSFeedCustomOwnerName\": \"Vlastné meno vlastníka\",\n  \"LabelRSSFeedOpen\": \"RSS zdroj otvorený\",\n  \"LabelRSSFeedPreventIndexing\": \"Zakázať indexovanie\",\n  \"LabelRSSFeedSlug\": \"Slug RSS zdroja\",\n  \"LabelRSSFeedURL\": \"URL RSS zdroja\",\n  \"LabelRandomly\": \"Náhodne\",\n  \"LabelReAddSeriesToContinueListening\": \"Znova pridať série do pokračujúceho počúvania\",\n  \"LabelRead\": \"Načítať\",\n  \"LabelReadAgain\": \"Čítať znova\",\n  \"LabelReadEbookWithoutProgress\": \"Čítať e-knihu bez zmeny stavu\",\n  \"LabelRecentSeries\": \"Posledné série\",\n  \"LabelRecentlyAdded\": \"Posledné pridané\",\n  \"LabelRecommended\": \"Odporúčané\",\n  \"LabelRedo\": \"Zopakovať\",\n  \"LabelRegion\": \"Región\",\n  \"LabelReleaseDate\": \"Dátum vydania\",\n  \"LabelRemoveAllMetadataAbs\": \"Odstrániť všetky súbory metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Odstrániť všetky súbory metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Odstrániť z kapitol Audible intro a outro\",\n  \"LabelRemoveCover\": \"Odstrániť prebal\",\n  \"LabelRemoveMetadataFile\": \"Odstrániť súbory metadát z priečinkov položiek v knižnici\",\n  \"LabelRemoveMetadataFileHelp\": \"Odstrániť všetky súbory metadata.json a metadata.abs vo Vašich {0} priečinkoch.\",\n  \"LabelRowsPerPage\": \"Počet riadkov na stránku\",\n  \"LabelSearchTerm\": \"Hľadaj výraz\",\n  \"LabelSearchTitle\": \"Hľadaj názov\",\n  \"LabelSearchTitleOrASIN\": \"Hľadaj názov alebo ASIN\",\n  \"LabelSeason\": \"Sezóna\",\n  \"LabelSeasonNumber\": \"Sezóna #{0}\",\n  \"LabelSelectAll\": \"Vybrať všetko\",\n  \"LabelSelectAllEpisodes\": \"Vybrať všetky epizódy\",\n  \"LabelSelectEpisodesShowing\": \"Vybrať {0} zobrazených epizód\",\n  \"LabelSelectUser\": \"Vyberte používateľa\",\n  \"LabelSelectUsers\": \"Vybrať užívateľov\",\n  \"LabelSendEbookToDevice\": \"Poslať e-knihu do...\",\n  \"LabelSequence\": \"Postupnosť\",\n  \"LabelSerial\": \"Na pokračovanie\",\n  \"LabelSeries\": \"Série\",\n  \"LabelSeriesName\": \"Názov série\",\n  \"LabelSeriesProgress\": \"Aktuálny stav série\",\n  \"LabelServerLogLevel\": \"Úroveň logovania servera\",\n  \"LabelServerYearReview\": \"Rok servera v prehľade ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Nastaviť ako primárny\",\n  \"LabelSetEbookAsSupplementary\": \"Nastaviť ako doplnkový\",\n  \"LabelSettingsAllowIframe\": \"Povoliť vkladanie do iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Len audioknihy\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Pri povolení tejto voľby budú všetky e-knihy ignorované okrem tých, ktoré sa nachádzajú v priečinkoch audiokníh. Tie budú zároveň automaticky nastavené ako doplnkové e-knihy\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeuomorfný dizajn s drevenými poličkami\",\n  \"LabelSettingsChromecastSupport\": \"Podpora chromecastu\",\n  \"LabelSettingsDateFormat\": \"Formát dátumu\",\n  \"LabelSettingsEnableWatcher\": \"Automatické sledovanie zmien v knižniciach\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Automatické sledovanie zmien v knižnici\",\n  \"LabelSettingsEnableWatcherHelp\": \"Povoliť automatické pridávanie/aktualizácie položiek pri zmene súborov. *Vyžaduje reštart servera\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Povoliť v e-knihách skriptovaný obsah\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Povoliť e-knihám spúšťanie skriptov. Odporúča sa túto voľbu nepovolovať, pokiaľ plne nedôverujete zdrojom súborov e-kníh.\",\n  \"LabelSettingsExperimentalFeatures\": \"Experimentálne funkcie\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funkcie vo vývoji, ktoré ocenia Vašu spätnú väzbu a pomoc s testovaním. Kliknite pre otvorenie diskusie na GitHub-e.\",\n  \"LabelSettingsFindCovers\": \"Nájdi prebaly\",\n  \"LabelSettingsFindCoversHelp\": \"Ak Vaša audiokniha neobsahuje vložený prebal alebo jeho obrázok v priečinku audioknihy, skener sa ho pokúsi automaticky vyhľadať.<br>Poznámka: Táto voľba predĺži čas skenovania\",\n  \"LabelSettingsHideSingleBookSeries\": \"Skryť série obsahujúce len jednu knihu\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Série obsahujúce len jednu knihu budú skryté na stránke sérií a na poličkách domácej stránky.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Domáca stránka používa poličkový náhľad\",\n  \"LabelSettingsLibraryBookshelfView\": \"Knižnica používa poličkový náhľad\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Percento dokončenia je väčšie ako\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Zostávajúci čas je menší ako (sekúnd)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Označiť položku média ako dokončenú\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Preskočiť predchádzajúce knihy v pokračujúcej sérii\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Polička pokračujúcich sérií na domácej stránke zobrazuje prvú nezačatú knihu série, ktorá má dokončenú aspoň jednu z kníh série a žiadne započaté knihy. Povolením tohto nastavenia bude pokračujúca séria začínať poslednou ukončenou knihou miesto prvej nepočúvanej.\",\n  \"LabelSettingsParseSubtitles\": \"Parsovať podtituly\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Extrahovať podtituly z názvov priečinkov audiokníh.<br>Podtitul musí byť oddelený \\\" - \\\"<br>tj. \\\"Názov knihy - Podtitul knihy\\\" má podtitul \\\"Podtitul knihy\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Preferovať vyhľadané metadáta\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Pri použití funkcie Rýchle vyhľadávanie, vyhľadané údaje prepíšu príslušné údaje položky. Defaultne funkcia Rýchle vyhľadávanie vyplní iba chýbajúce údaje.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Preskočiť vyhľadané knihy, ktoré už majú vyplnené ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Preskočiť vyhľadané knihy, ktoré už majú vyplnené ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Pri triedení ignorovať prefixy\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"tj. v prípade prefixu \\\"the\\\" bude názov knihy \\\"The Book Title\\\" usporiadaný podľa \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Použiť štvorcové prebaly kníh\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Preferovať štvorcové prebaly pred štandardnými prebalmi kníh formátu 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Okladať prebaly k položkám\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Defaultne sú prebaly uložené v priečinku /metadata/items, povolením tejto voľby budú prebaly umiestnené do priečinkov jednotlivých položiek Vašej knižnice, pričom bude vždy použitý len jeden súbor s názvom \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Uložiť metadáta spolu s položkou\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Defaultne sú súbory metadát ukladané do priečinka /metadata/items, pri povolení tejto voľby budú súbory metadát uložené do priečinkov položiek Vašej knižnice\",\n  \"LabelSettingsTimeFormat\": \"Formát času\",\n  \"LabelShare\": \"Zdieľať\",\n  \"LabelShareDownloadableHelp\": \"Umožňuje užívateľom s linkom na zdieľanie sťahovať zip súbor položky knižnice.\",\n  \"LabelShareOpen\": \"Zdieľať Otvoriť\",\n  \"LabelShareURL\": \"Zdieľať URL\",\n  \"LabelShowAll\": \"Zobraziť všetko\",\n  \"LabelShowSeconds\": \"Zobraziť sekundy\",\n  \"LabelShowSubtitles\": \"Zobraziť podnázvy\",\n  \"LabelSize\": \"Veľkosť\",\n  \"LabelSleepTimer\": \"Časovač spánku\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Vzostupne\",\n  \"LabelSortDescending\": \"Zostupne\",\n  \"LabelSortPubDate\": \"Zoradiť podľa dátumu vydania\",\n  \"LabelStart\": \"Spustiť\",\n  \"LabelStartTime\": \"Čas spustenia\",\n  \"LabelStarted\": \"Začaté\",\n  \"LabelStartedAt\": \"Začaté v\",\n  \"LabelStartedDate\": \"Začaté {0}\",\n  \"LabelStatsAudioTracks\": \"Zvukové stopy\",\n  \"LabelStatsAuthors\": \"Autori\",\n  \"LabelStatsBestDay\": \"Najlepší deň\",\n  \"LabelStatsDailyAverage\": \"Denný priemer\",\n  \"LabelStatsDays\": \"Dní\",\n  \"LabelStatsDaysListened\": \"Dní počúvania\",\n  \"LabelStatsHours\": \"Hodiny\",\n  \"LabelStatsInARow\": \"v rade\",\n  \"LabelStatsItemsFinished\": \"Dokončených položiek\",\n  \"LabelStatsItemsInLibrary\": \"Položky v knižnici\",\n  \"LabelStatsMinutes\": \"minút\",\n  \"LabelStatsMinutesListening\": \"Minút počúvania\",\n  \"LabelStatsOverallDays\": \"Celkovo dní\",\n  \"LabelStatsOverallHours\": \"Celkovo hodín\",\n  \"LabelStatsWeekListening\": \"Týždňov počúvania\",\n  \"LabelSubtitle\": \"Podnázov\",\n  \"LabelSupportedFileTypes\": \"Podporované typy súborov\",\n  \"LabelTag\": \"Štítok\",\n  \"LabelTags\": \"Štítky\",\n  \"LabelTagsAccessibleToUser\": \"Štítky dostupné užívateľovi\",\n  \"LabelTagsNotAccessibleToUser\": \"Štítky nedostupné užívateľovi\",\n  \"LabelTasks\": \"Bežiace úlohy\",\n  \"LabelTextEditorBulletedList\": \"Zoznam s odrážkami\",\n  \"LabelTextEditorLink\": \"Prepojiť\",\n  \"LabelTextEditorNumberedList\": \"Očíslovaný zoznam\",\n  \"LabelTextEditorUnlink\": \"Zrušiť prepojenie\",\n  \"LabelTheme\": \"Téma\",\n  \"LabelThemeDark\": \"Tmavá\",\n  \"LabelThemeLight\": \"Svetlá\",\n  \"LabelThemeSepia\": \"Sépia\",\n  \"LabelTimeBase\": \"Časová základňa\",\n  \"LabelTimeDurationXHours\": \"{0} hodín\",\n  \"LabelTimeDurationXMinutes\": \"{0} minút\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekúnd\",\n  \"LabelTimeInMinutes\": \"Čas v minútach\",\n  \"LabelTimeLeft\": \"{0} ponechaných\",\n  \"LabelTimeListened\": \"Čas počúvania\",\n  \"LabelTimeListenedToday\": \"Dnešný čas počúvania\",\n  \"LabelTimeRemaining\": \"Zostáva {0}\",\n  \"LabelTimeToShift\": \"Čas posunutia v sekundách\",\n  \"LabelTitle\": \"Názov\",\n  \"LabelToolsEmbedMetadata\": \"Vlož metadáta\",\n  \"LabelToolsEmbedMetadataDescription\": \"Vloždo zvukových súborov metadáta obsahujúce obrázok prebalu a kapitoly.\",\n  \"LabelToolsM4bEncoder\": \"M4B Encoder\",\n  \"LabelToolsMakeM4b\": \"Vytvoriť M4B súbor audioknihy\",\n  \"LabelToolsMakeM4bDescription\": \"Generovať .M4B súbor audioknihy obsahujúci vložené metadáta, obrázok prebalu a kapitoly.\",\n  \"LabelToolsSplitM4b\": \"Rozdeliť M4B do MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Vytvoriť MP3 súbory rozdelením M4B podľa kapitol a vložiť do nich metadáta, obrázok prebalu a kapitoly.\",\n  \"LabelTotalDuration\": \"Celkové trvanie\",\n  \"LabelTotalTimeListened\": \"Celkový čas počúvania\",\n  \"LabelTrackFromFilename\": \"Stopa podľa názvu súboru\",\n  \"LabelTrackFromMetadata\": \"Stopa podľ metadát\",\n  \"LabelTracks\": \"Stopy\",\n  \"LabelTracksMultiTrack\": \"Viacstopová\",\n  \"LabelTracksNone\": \"Žiadne stopy\",\n  \"LabelTracksSingleTrack\": \"Jednostopová\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Typ\",\n  \"LabelUnabridged\": \"Neskrátená\",\n  \"LabelUndo\": \"Naspäť\",\n  \"LabelUnknown\": \"Neznámy\",\n  \"LabelUnknownPublishDate\": \"Neznámy dátum vydania\",\n  \"LabelUpdateCover\": \"Aktualizácia prebalu\",\n  \"LabelUpdateCoverHelp\": \"Povoliť nahradenie existujúcich prebalov pre vybrané knihy, ak bolo vyhľadanie úspešné\",\n  \"LabelUpdateDetails\": \"Aktualizovať detaily\",\n  \"LabelUpdateDetailsHelp\": \"Povoliť nahradenie existujúcich detailov pre vybrané knihy, ak bolo vyhľadanie úspešné\",\n  \"LabelUpdatedAt\": \"Aktualizované\",\n  \"LabelUploaderDragAndDrop\": \"Potiahni a vlož súbory alebo priečinky\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Potiahni a vlož súbory\",\n  \"LabelUploaderDropFiles\": \"Vlož súbory\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Automaticky vyhľadá názov, autora a sériu\",\n  \"LabelUseAdvancedOptions\": \"Použiť pokročilé nastavenia\",\n  \"LabelUseChapterTrack\": \"Použiť stopu kapitoly\",\n  \"LabelUseFullTrack\": \"Použiť celú stopu\",\n  \"LabelUseZeroForUnlimited\": \"Použito 0 pre neobmedzené\",\n  \"LabelUser\": \"Používateľ\",\n  \"LabelUsername\": \"Prihlasovacie meno\",\n  \"LabelValue\": \"Hodnota\",\n  \"LabelVersion\": \"Verzia\",\n  \"LabelViewBookmarks\": \"Zobraziť záložky\",\n  \"LabelViewChapters\": \"Zobraziť kapitoly\",\n  \"LabelViewPlayerSettings\": \"Zobraziť nastavenie prehrávania\",\n  \"LabelViewQueue\": \"Zobraziť zoznam na prehratie\",\n  \"LabelVolume\": \"Hlasitosť\",\n  \"LabelWebRedirectURLsDescription\": \"Autorizovať nasledovné URL linky pomocou Vášho OAuth poskytovateľa a povoliť presmerovanie späť na webovú aplikáciu po prihlásení:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Podpriečinok pre presmerované URL\",\n  \"LabelWeekdaysToRun\": \"Povolené dni v týždni\",\n  \"LabelXBooks\": \"{0} kníh\",\n  \"LabelXItems\": \"{0} položiek\",\n  \"LabelYearReviewHide\": \"Skryť rok v prehľade\",\n  \"LabelYearReviewShow\": \"Zobraziť rok v prehľade\",\n  \"LabelYourAudiobookDuration\": \"Dĺžka Vašej audioknihy\",\n  \"LabelYourBookmarks\": \"Vaše záložky\",\n  \"LabelYourPlaylists\": \"Vaše playlisty\",\n  \"LabelYourProgress\": \"Váš aktuálny stav\",\n  \"MessageAddToPlayerQueue\": \"Pridať do zoznamu prehrávania\",\n  \"MessageAppriseDescription\": \"Aby ste mohli používať túto funkciumusíte mať k dispozícii inštanciu <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> alebo inú, ktorá dokáže spracovávať rovnaké požiadavky/requesty.<br/>Apprise URL musí byť úplná URL určená na zasielanie notifikácií, tj. ak napr. vaša APi beží na <code>http://192.168.1.1:8337</code>, vložte do daného poľa <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Uistite sa, že používate ASIN zo správneho regiónu Audible, nie Amazonu.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Zastaralé API toleny budú v budúcnosti odstránené. Použite miesto nich <a href=\\\"/config/api-keys\\\">API kľúče</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Reštartujte svoj server po uložení, aby mohli byť použité zmeny OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"Overovanie bolo kvôli bezpečnosti vylepšené. Všetci používatelia sa musia znova prihlásiť.\",\n  \"MessageBackupsDescription\": \"Zálohy pokrývajú používateľov, ich aktuálne stavy počúvania, detaily položiek knižnice, nastavenia servera a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>neobsahujú</strong> súbory v priečinkoch vašich knižníc.\",\n  \"MessageBackupsLocationEditNote\": \"Poznámka: Zmena umiestnenia záloh nepresunie ani nezmení existujúce zálohy\",\n  \"MessageBackupsLocationNoEditNote\": \"Poznámka: Umietnenie záloh je nastavené prostredníctvom premennej prostredia a nie je ho možné zmeniť z tohto miesta.\",\n  \"MessageBackupsLocationPathEmpty\": \"Cesta umiestnenia záloh nemôže byť prázdna\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Vyplniť povolené polia dátami zo všetkých položiek. Viacpočetné hodnoty v poliach budú zlúčené\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Vyplniť polia povolených detailov z údajov tejto položky\",\n  \"MessageBatchQuickMatchDescription\": \"Rýchle vyhľadanie sa pokúsi vložiť chýbajúci prebal a metadáta pre vybrané položky. Ak povolíte voľbu nižšie Rýchle vyhľadanie sa pokúsi vyhľadať a prepísať aj existujúce prebaly a/alebo metadáta.\",\n  \"MessageBookshelfNoCollections\": \"Zatiaľ ste nevytvorili žiadnu zbierku\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Zbierky sú verejné. Všetci používatelia s prístupom do knižnice ich môžu vidieť.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Žiadne RSS zdroje nie sú otvorené\",\n  \"MessageBookshelfNoResultsForFilter\": \"Žiadny výsledok filtrovania \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Žiadne výsledky dopytu\",\n  \"MessageBookshelfNoSeries\": \"Nemáte žiadne série\",\n  \"MessageBulkChapterPattern\": \"Koľko ďalších kapitol si želáte pridať s týmto spôsobom číslovania?\",\n  \"MessageChapterEndIsAfter\": \"Koniec kapitoly je až za koncom vašej audioknihy\",\n  \"MessageChapterErrorFirstNotZero\": \"Prvá kapitola musí začínať na 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Neplatný čas začiatku musí byť menší ako celkové trvanie audioknihy\",\n  \"MessageChapterErrorStartLtPrev\": \"Neplatný čas začiatku musí byť väčší alebo rovný času začiatku predchádzajúcej kapitoly\",\n  \"MessageChapterStartIsAfter\": \"Začiatok kapitoly je až za koncom vašej audioknihy\",\n  \"MessageChaptersNotFound\": \"Kapitoly nenájdené\",\n  \"MessageCheckingCron\": \"Kontrola cron-u...\",\n  \"MessageConfirmCloseFeed\": \"Ste si istý, že chcete zavrieť tento zdroj?\",\n  \"MessageConfirmDeleteApiKey\": \"Ste si istý, že chcete zmazať API kľúč \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Ste si istý, že chcete zmazať zálohu {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Ste si istý, že chcete zmazať zariadenie čítačky e-kníh \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Týmto odstránite súbor z vášho súborového systému. Ste si istý?\",\n  \"MessageConfirmDeleteLibrary\": \"Ste si istý, že chcete natrvalo odstrániť knižnicu \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Týmto odstránite položku knižnice z databázy aj vášho súborového systému. Ste si istý?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Týmto odstránite {0} položiek knižnice z databázy a vášho súborového systému. Ste si istý?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Ste si istý, že chcete odstrániť poskytovateľa metadát \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Ste si istý, že chcete odstrániť túto notifikáciu?\",\n  \"MessageConfirmDeleteSession\": \"Ste si istý, že chcete zmazať túto reláciu?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Ste si istý, že chcete vložiť metadáta do {0} zvukových súborov?\",\n  \"MessageConfirmForceReScan\": \"Ste si istý, že chcete spustiť vynútený re-sken?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Chcete označiť všetky epizódy ako dokončené?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Checte označiť všetky epizódy ako nedokončené?\",\n  \"MessageConfirmMarkItemFinished\": \"Chcete označiť \\\"{0}\\\" ako dokončené?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Chcete označiť \\\"{0}\\\" ako nedokončené?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Ste si istý, že chcete označiť všetky knihy v tejto sérii ako dokončené?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Ste si istý, že chcete označiť všetky knihy v tejto sérii ako nedokončené?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Zapnúť túto notifikáciu pre testovacie dáta?\",\n  \"MessageConfirmPurgeCache\": \"Vymazanie vyrovnávacej pamäte kompletne odstráni priečinok <code>/metadata/cache</code>.<br /><br />Ste si istý, že chcete vymazať priečinok vyrovnávacej pamäte?\",\n  \"MessageConfirmPurgeItemsCache\": \"Vymazanie položiek vyrovnávacej pamäte kompletne odstráni priečinok <code>/metadata/cache/items</code>.<br />Ste si istý?\",\n  \"MessageConfirmQuickEmbed\": \"Varovanie! Pri rýchlom vložení metadát nie sú vaše zvukové súbory zálohované. Uistite sa, že máte vaše zvukové súbory zálohované. <br><br>Chcete pokračovať?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Pri rýchlom vyhľadávaní epizód sú v prípade zhody prepísané podrobnosti nájdených epizód. Nespárované epizódy budú len aktualizované. Ste si istý?\",\n  \"MessageConfirmReScanLibraryItems\": \"Ste si istý, že chcete spustiť opakovaný sken {0} položiek?\",\n  \"MessageConfirmRemoveAllChapters\": \"Ste si istý, že chcete odstrániť všetky kapitoly?\",\n  \"MessageConfirmRemoveAuthor\": \"Ste si istý, že chcete odstrániť autora \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Ste si istý, že chcete odstrániť zbierku \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Ste si istý, že chcete odstrániť epizódu \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Poznámka: Tento krok neodstráni zvukový súbor, pokiaľ nezaškrtnete voľbu \\\"Nezvratné zmazanie súborov\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Ste si istý, že chcete odstrániť {0} epizód?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Ste si istý, že chcete odstrániť týchto {0} relácií?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?\",\n  \"MessageConfirmRemoveNarrator\": \"Ste si istý, že chcete odstrániť interpreta \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Ste si istý, že chcete odstrániť váš playlist \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Ste si istý, že chcete nahradiť žáner \\\"{0}\\\" vo všetkých položkách žánrom \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Poznámka: Tento žáner už existuje a preto bude zlúčený.\",\n  \"MessageConfirmRenameGenreWarning\": \"Varovanie! Podobný žáner len s odlišným zápisom už existuje \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Ste si istý, že chcete zmeniť štítok \\\"{0}\\\" za \\\"{1}\\\" vo všetkých položkách?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Poznámka: Tento štítok už existuje a preto bude zlúčený.\",\n  \"MessageConfirmRenameTagWarning\": \"Varovanie! Štítok s podobným zápisom už existuje \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Ste si istý, že chcete resetnúť váš aktuálny stav počúvania?\",\n  \"MessageConfirmSendEbookToDevice\": \"Ste si istý, že chcete poslať {0} e-knihu \\\"{1}\\\" do zariadenia \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Ste si istý, že chcete odstrániť prepojenie tohto používateľa na OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dní počúvania počas posledného roku\",\n  \"MessageDownloadingEpisode\": \"Sťahovanie epizódy\",\n  \"MessageDragFilesIntoTrackOrder\": \"Presuňte súbory do správneho poradia prehrávania\",\n  \"MessageEmbedFailed\": \"Vloženie zlyhalo!\",\n  \"MessageEmbedFinished\": \"Vloženie skončené!\",\n  \"MessageEmbedQueue\": \"Zaradené do fronty na vloženie metadát ({0} v zozname)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} epizód(a) v zozname na sťahovanie\",\n  \"MessageEreaderDevices\": \"Na zaistenie dodania e-kníh môže byť nutné zadanie vyššie uvedenej e-mailovej adresy ako overeného odosielateľa v každom z nižšie vypísaných zariadení.\",\n  \"MessageFeedURLWillBe\": \"URL zdroja bude {0}\",\n  \"MessageFetching\": \"Získavam...\",\n  \"MessageForceReScanDescription\": \"preskenuje všetky súbory ako pri prvom skenovaní. ID3 štítky zvukových súborov, OPF súbory a textové súbory budú nanovo naskenované.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} počúvajúcich</strong> na {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Žiadne relácie počúvania na {0}\",\n  \"MessageImportantNotice\": \"Dôležité upozornenie!\",\n  \"MessageInsertChapterBelow\": \"Vložte kapitolu nižšie\",\n  \"MessageInvalidAsin\": \"Neplatné ASIN\",\n  \"MessageItemsSelected\": \"{0} vybraných položiek\",\n  \"MessageItemsUpdated\": \"{0} aktualizovaných položiek\",\n  \"MessageJoinUsOn\": \"Pridajte sa k nám\",\n  \"MessageLoading\": \"Načítavam...\",\n  \"MessageLoadingFolders\": \"Načítanie priečinkov...\",\n  \"MessageLogsDescription\": \"Záznamy logovania sú uložené v <code>/metadata/logs</code> vo forme JSON súborov. Záznamy kritických chýb sú uložené v <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B zlyhalo!\",\n  \"MessageM4BFinished\": \"M4B skončené!\",\n  \"MessageMapChapterTitles\": \"Prepojte názvy kapitol s vašimi už existujúcimi kapitolami audioknihy bez vplyvu na časové značky\",\n  \"MessageMarkAllEpisodesFinished\": \"Označiť všetky epizódy ako dokončené\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Označiť všetky epizódy ako nedokončené\",\n  \"MessageMarkAsFinished\": \"Označiť ako dokončené\",\n  \"MessageMarkAsNotFinished\": \"Označiť ako nedokončené\",\n  \"MessageMatchBooksDescription\": \"sa pokúsi spárovať knihy vo vašej knižnici s knihami nájdenými zvoleným poskytovateľom a doplniť chýbajúce údaje a prebal. aktuálne vyplnené údaje nebudú prepísané.\",\n  \"MessageNoAudioTracks\": \"Žiadne zvukové stopy\",\n  \"MessageNoAuthors\": \"Žiadni autori\",\n  \"MessageNoBackups\": \"Žiadne zálohy\",\n  \"MessageNoBookmarks\": \"Žiadne záložky\",\n  \"MessageNoChapters\": \"Žiadne kapitoly\",\n  \"MessageNoCollections\": \"Žiadne zbierky\",\n  \"MessageNoCoversFound\": \"Neboli nájdené žiadne prebaly\",\n  \"MessageNoDescription\": \"Žiadny popis\",\n  \"MessageNoDevices\": \"Žiadne zariadenia\",\n  \"MessageNoDownloadsInProgress\": \"Aktuálne neprebieha žiadne sťahovanie\",\n  \"MessageNoDownloadsQueued\": \"Žiadne sťahovania v poradí\",\n  \"MessageNoEpisodeMatchesFound\": \"Neboli spárované žiadne epizódy\",\n  \"MessageNoEpisodes\": \"Žiadne eizódy\",\n  \"MessageNoFoldersAvailable\": \"Žiadne priečinky nie sú dostupné\",\n  \"MessageNoGenres\": \"Žiadne žánre\",\n  \"MessageNoIssues\": \"Žiadne problémy\",\n  \"MessageNoItems\": \"Žiadne položky\",\n  \"MessageNoItemsFound\": \"Žiadne položky neboli nájdené\",\n  \"MessageNoListeningSessions\": \"Žiadne relácie\",\n  \"MessageNoLogs\": \"Žiadne záznamy udalostí\",\n  \"MessageNoMediaProgress\": \"Žiadny stav médií\",\n  \"MessageNoNotifications\": \"Žiadne upozornenia\",\n  \"MessageNoPodcastFeed\": \"Chybný podcast: Žiadny zdroj\",\n  \"MessageNoPodcastsFound\": \"Žiadne podcasty neboli nájdené\",\n  \"MessageNoResults\": \"Žiadne výsledky\",\n  \"MessageNoSearchResultsFor\": \"Žiadne výsledky vyhľadávania \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Žiadne série\",\n  \"MessageNoTags\": \"Žiadne štítky\",\n  \"MessageNoTasksRunning\": \"Žiadne prebiehajúce úlohy\",\n  \"MessageNoUpdatesWereNecessary\": \"Neboli potrebné žiadne aktualizácie\",\n  \"MessageNoUserPlaylists\": \"Nemáte žiadny playlist\",\n  \"MessageNoUserPlaylistsHelp\": \"Playlisty sú súkromné. Každý playlist môže vidieť iba používateľ, ktorý ho vytvoril.\",\n  \"MessageNotYetImplemented\": \"Ešte neimplementované\",\n  \"MessageOpmlPreviewNote\": \"Poznámka: Toto je náhľad pársovaného OPML súboru. Skutočný názov podcastu bude prevzatý zo RSS zdroja.\",\n  \"MessageOr\": \"alebo\",\n  \"MessagePauseChapter\": \"Pozastaviť prehrávanie kapitoly\",\n  \"MessagePlayChapter\": \"Počúvať začiatok kapitoly\",\n  \"MessagePlaylistCreateFromCollection\": \"Vytvoriť playlist zo zbierky\",\n  \"MessagePleaseWait\": \"Prosím, počkajte...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast nemá žiadnu URL RSS zdroja na spárovanie\",\n  \"MessagePodcastSearchField\": \"Zadajte hľadaný výraz alebo URL RSS zdroja\",\n  \"MessageQuickEmbedInProgress\": \"Prebieha rýchle vkladanie\",\n  \"MessageQuickEmbedQueue\": \"Zadané na rýchle vloženie ({0} v rade)\",\n  \"MessageQuickMatchAllEpisodes\": \"Rýchle vyhľadanie všetkých epizód\",\n  \"MessageQuickMatchDescription\": \"Doplní všetky nevyplnené údaje a chýbajúci prebal z prvého výsledku vyhľdávania '{0}'. Neprepíše už existujúce údaje, vykoná tak iba v prípade, ak je zaškrtnutá voľba 'Preferovať vyhľadané metadáta'.\",\n  \"MessageRemoveChapter\": \"Odstrániť kapitolu\",\n  \"MessageRemoveEpisodes\": \"Odstrániť {0} epizódu(-y)\",\n  \"MessageRemoveFromPlayerQueue\": \"Odstrániť zo zoznamu prehrávania\",\n  \"MessageRemoveUserWarning\": \"Ste si istí, že chcete trvalo odstrániť užívateľa \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Nahlásiť chyby, požiadavky na funkcie, a prispievať na\",\n  \"MessageResetChaptersConfirm\": \"Ste si istý, že chcete resetnúť kapitoly a zahodiť zmeny, ktoré ste vykonali?\",\n  \"MessageRestoreBackupConfirm\": \"Ste si istí, že chcete obnoviť zálohu vytvorenú\",\n  \"MessageRestoreBackupWarning\": \"Obnovenie zálohy spôsobí kompletný prepis databázy umiestnenej v /config a obrázkov prebalov a autorov v /metadata/items a /metadata/authors.<br /><br />Zálohy nemenia žiadne súbory v priečinkoch vašej knižnice. Ak ste povolili v nastaveniach servera ukladanie obrázkov prebalov a metadát v priečinkoch knižnice, tieto nie sú zálohované a teda ani prepisované.<br /><br />Všetky klienti používajúci váš server budú automaticky obnovené.\",\n  \"MessageScheduleLibraryScanNote\": \"Pre väčšinu používateľov sa odporúča nechať túto funkciu vypnutú a ponechať zapnuté nastavenie „Automatické sledovanie zmien v knižnici“ – táto funkcia automaticky zistí zmeny vo vašich priečinkoch knižnice. Túto funkciu zapnite, ak „Automatické sledovanie zmien v knižnici“ nefunguje vo vašom súborovom systéme (napr. NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Spustiť každú {0} o {1}\",\n  \"MessageSearchResultsFor\": \"Výsledky vyhľadávania pre\",\n  \"MessageSelected\": \"{0} vybrané\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Poradie série nemôže obsahovať medzery\",\n  \"MessageServerCouldNotBeReached\": \"Nepodarilo sa pripojiť na server\",\n  \"MessageSetChaptersFromTracksDescription\": \"Nastaviť jednotlivé zvukové súbory ako kapitoly a názvy zvukových súborov ako názvy týchto kapitol\",\n  \"MessageShareExpirationWillBe\": \"Expiruje <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Uplynie za {0}\",\n  \"MessageShareURLWillBe\": \"URL na zdielanie bude <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Spustiť prehrávanie \\\"{0}\\\" o {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Do súboru \\\"{0}\\\" sa nedá zapisovať\",\n  \"MessageTaskCanceledByUser\": \"Úloha zrušená používateľom\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Sťahuje sa diel \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Vkladanie metadát\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Vkladanie metadát v audioknihe \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Kódovanie M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Konverzia audioknihy \\\"{0}\\\" do jedného m4b súboru\",\n  \"MessageTaskFailed\": \"Zlyhané\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Nepodarilo sa zazálohovať audio súbor \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Nepodarilo sa vytvoriť adresár na cache\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Vloženie metadát do \\\"{0}\\\" zlyhalo\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Nepodarilo sa pospájať audio súbory\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Nepodarilo sa presunúť m4b súbor\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Nepodarilo sa zapísať súbor s metadátami\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Spárované knihy v knižnici \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Žiadne súbory k skenu\",\n  \"MessageTaskOpmlImport\": \"OMPL import\",\n  \"MessageTaskOpmlImportDescription\": \"Vytvorenie podcastov z {0} RSS zdrojov\",\n  \"MessageTaskOpmlImportFeed\": \"Importný zdroj OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Import RSS zdroja \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Získanie zdroja podcastu zlyhalo\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Vytvorenie podcastu \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Na zadanom umiestnení už podcast existuje\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Vytvorenie podcastu zlyhalo\",\n  \"MessageTaskOpmlImportFinished\": \"Pridaných {0} podcastov\",\n  \"MessageTaskOpmlParseFailed\": \"Pársovanie OPML súboru zlyhalo\",\n  \"MessageTaskOpmlParseFastFail\": \"Neplatný OPML súbor. <opml> tag ALEBO <outline> tag nebol nájdený\",\n  \"MessageTaskOpmlParseNoneFound\": \"V OPML súbore neboli nájdené žiadne zdroje\",\n  \"MessageTaskScanItemsAdded\": \"{0} pridaných\",\n  \"MessageTaskScanItemsMissing\": \"{0} chýbajúcich\",\n  \"MessageTaskScanItemsUpdated\": \"{0} aktualizovaných\",\n  \"MessageTaskScanNoChangesNeeded\": \"Neboli potrebné žiadne zmeny\",\n  \"MessageTaskScanningFileChanges\": \"Skenovanie zmien súborov v \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Skenovanie knižnice \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Cieľový priečinok nemá oprávnenie na zápis\",\n  \"MessageThinking\": \"Premýšľam...\",\n  \"MessageUploaderItemFailed\": \"Nahratie zlyhalo\",\n  \"MessageUploaderItemSuccess\": \"Úspešne nahrané!\",\n  \"MessageUploading\": \"Nahrávanie zmien...\",\n  \"MessageValidCronExpression\": \"Platný cron výraz\",\n  \"MessageWatcherIsDisabledGlobally\": \"Funkcia sledovania zmien je globálne vypnutá v nastaveniach servera\",\n  \"MessageXLibraryIsEmpty\": \"Knižnica {0} je prázdna!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Dĺžka vašej audioknihy je väčšia ako dĺžka nájdených súborov\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Dĺžka vašej audioknihy je menšia ako dĺžka nájdených súborov\",\n  \"NoteChangeRootPassword\": \"Root používateľ je jediný používateľ, ktorý môže mať prázdne heslo\",\n  \"NoteChapterEditorTimes\": \"Poznámka: Prvá kapitola musí vždy začínať v 0:00 a začiatok poslednej kapitoly nemôže prekročiť trvanie tejto audioknihy.\",\n  \"NoteFolderPicker\": \"Poznámka: Priečinky, ktoré už boli priradené, sa ďalej nezobrazujú\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Varovanie: Väčšina podcastových aplikácií požaduje URL RSS zdroja s HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Varovanie: 1 alebo viac vašich epizód neobsahuje infomáciu o dátum vydania. Niektoré podcastové ju vyžadujú.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Priečinky obsahujúce súbory médií budú považované za samostatné položky knižnice.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Ak budú nahraté iba zvukové súbory, každý zvukový súbor bude považovaný za samostatnú audioknihu.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nepodporované súbory budú ignorované. Pri výbere alebo prenesení priečinka, budú všetky súbory, ktoré nie sú v priečinku niektorej z položiek, ignorované.\",\n  \"NotificationOnBackupCompletedDescription\": \"Spustené po dokončení zálohovania\",\n  \"NotificationOnBackupFailedDescription\": \"Spustené pri zlyhaní zálohovania\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Spustené po automatickom stiahnutí epizódy podcastu\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Spustí sa, keď je automatické sťahovanie epizód pozastavené z dôvodu veľkého počtu zlyhaní\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Spustí sa v prípade, keď zlyhá požiadavka RSS zdroja na automatické stiahnutie epizódy\",\n  \"NotificationOnTestDescription\": \"Udalosť určená na testovanie systému notifikácií\",\n  \"PlaceholderBulkChapterInput\": \"Zadajte názov kapitoly alebo použite číslovanie (napr., 'Epizóda 1', 'Kapitola 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Názov novej zbierky\",\n  \"PlaceholderNewFolderPath\": \"Umiestnenie nového priečinka\",\n  \"PlaceholderNewPlaylist\": \"Názov nového playlistu\",\n  \"PlaceholderSearch\": \"Vyhľadávanie..\",\n  \"PlaceholderSearchEpisode\": \"Vyhľadať epizódu..\",\n  \"StatsAuthorsAdded\": \"autorov pridaných\",\n  \"StatsBooksAdded\": \"kníh pridaných\",\n  \"StatsBooksAdditional\": \"Niektoré prílohy zahŕňajú…\",\n  \"StatsBooksFinished\": \"dokončených kníh\",\n  \"StatsBooksFinishedThisYear\": \"Niektoré knihy dokončené tento rok…\",\n  \"StatsBooksListenedTo\": \"vypočutých kníh\",\n  \"StatsCollectionGrewTo\": \"Vaša knižná zbierka sa blíži k…\",\n  \"StatsSessions\": \"relácie\",\n  \"StatsSpentListening\": \"strávených počúvaním\",\n  \"StatsTopAuthor\": \"TOP AUTOR\",\n  \"StatsTopAuthors\": \"TOP AUTORI\",\n  \"StatsTopGenre\": \"TOP ŽÁNER\",\n  \"StatsTopGenres\": \"TOP ŽÁNRE\",\n  \"StatsTopMonth\": \"TOP MESIAC\",\n  \"StatsTopNarrator\": \"TOP INTERPRET\",\n  \"StatsTopNarrators\": \"TOP INTERPRETI\",\n  \"StatsTotalDuration\": \"S celkovou dĺžkou…\",\n  \"StatsYearInReview\": \"ROK V PREHĽADE\",\n  \"ToastAccountUpdateSuccess\": \"Účet bol aktualizovaný\",\n  \"ToastAppriseUrlRequired\": \"Musíte zadať Apprise URL\",\n  \"ToastAsinRequired\": \"Vyžaduje sa ASIN\",\n  \"ToastAuthorImageRemoveSuccess\": \"Obrázok autora bol odstránený\",\n  \"ToastAuthorNotFound\": \"Autor \\\"{0}\\\" nenájdený\",\n  \"ToastAuthorRemoveSuccess\": \"Autor bol odstránený\",\n  \"ToastAuthorSearchNotFound\": \"Autor nebol nájdený\",\n  \"ToastAuthorUpdateMerged\": \"Autor bol zlúčený\",\n  \"ToastAuthorUpdateSuccess\": \"Autor bol aktualizovaný\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Autor bol aktualizovaný (obrázok nebol nájdený)\",\n  \"ToastBackupAppliedSuccess\": \"Záloha bola aplikovaná\",\n  \"ToastBackupCreateFailed\": \"Vytvorenie zálohy zlyhalo\",\n  \"ToastBackupCreateSuccess\": \"Záloha bola vytvorená\",\n  \"ToastBackupDeleteFailed\": \"Odstránenie zálohy zlyhalo\",\n  \"ToastBackupDeleteSuccess\": \"Záloha bola odstránená\",\n  \"ToastBackupInvalidMaxKeep\": \"Neplatný počet sledovaných záloh\",\n  \"ToastBackupInvalidMaxSize\": \"Neplatná maximálna veľkosť zálohy\",\n  \"ToastBackupRestoreFailed\": \"Obnovenie zo zálohy zlyhalo\",\n  \"ToastBackupUploadFailed\": \"Nahratie zálohy zlyhalo\",\n  \"ToastBackupUploadSuccess\": \"Záloha bola nahratá\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Dotknuté údaje položiek\",\n  \"ToastBatchDeleteFailed\": \"Hromadné odstránenie zlyhalo\",\n  \"ToastBatchDeleteSuccess\": \"Hromadné odstránenie bolo úspešné\",\n  \"ToastBatchQuickMatchFailed\": \"Hromadné rýchle vyhľadanie zlyhalo!\",\n  \"ToastBatchQuickMatchStarted\": \"Hromadné rýchle vyhľadanie {0} kníh začalo!\",\n  \"ToastBatchUpdateFailed\": \"Hromadná aktualizácia zlyhala\",\n  \"ToastBatchUpdateSuccess\": \"Hromadná aktualizácia prebehla úspešne\",\n  \"ToastBookmarkCreateFailed\": \"Vytvorenie záložky zlyhalo\",\n  \"ToastBookmarkCreateSuccess\": \"Záložka pridaná\",\n  \"ToastBookmarkRemoveSuccess\": \"Záložka odstránená\",\n  \"ToastBulkChapterInvalidCount\": \"Zadajte číslo medzi 1 a 150\",\n  \"ToastCachePurgeFailed\": \"Vyčistenie vyrovnávacej pamäte zlyhalo\",\n  \"ToastCachePurgeSuccess\": \"Vyrovnávacia pamäť vyčistená\",\n  \"ToastChapterLocked\": \"Kapitola je zamknutá.\",\n  \"ToastChapterStartTimeAdjusted\": \"Čas začiatku kapitoly upravený o {0} sekúnd\",\n  \"ToastChaptersAllLocked\": \"Všetky kapitoly sú zamknuté. Odomknite niektoré kapitoly, aby ste posunuli ich časy.\",\n  \"ToastChaptersHaveErrors\": \"Kapitoly obsahujú chyby\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Neplatná hodnota veľkosti posunutia. Začiatok poslednej kapitoly by ležal za koncom audioknihy.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Nesprávna hodnota posunutia. Prvá kapitola by mala nulovú alebo zápornú dĺžku a bola by nahradená nasledujúcou kapitolou. Navýšte čas začiatku druhej kapitoly.\",\n  \"ToastChaptersMustHaveTitles\": \"Kapitoly musia mať názvy\",\n  \"ToastChaptersRemoved\": \"Kapitoly boli odstránené\",\n  \"ToastChaptersUpdated\": \"Kapitoly boli aktualizované\",\n  \"ToastCollectionItemsAddFailed\": \"Pridanie položky/-iek do zbierky zlyhalo\",\n  \"ToastCollectionRemoveSuccess\": \"Zbierka odstránená\",\n  \"ToastCollectionUpdateSuccess\": \"Zbierka aktualizovaná\",\n  \"ToastConnectionNotAvailable\": \"Pripojenie je nedostupné. Skúste to neskôr\",\n  \"ToastCoverSearchFailed\": \"Vyhľadanie obalu sa nepodarilo\",\n  \"ToastCoverUpdateFailed\": \"Aktualizácia prebalu zlyhala\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Dátum a čas sú neplatné alebo neúplné\",\n  \"ToastDeleteFileFailed\": \"Odstránenie súboru zlyhalo\",\n  \"ToastDeleteFileSuccess\": \"Súbor bol odstránený\",\n  \"ToastDeviceAddFailed\": \"Pridanie zariadenia zlyhalo\",\n  \"ToastDeviceNameAlreadyExists\": \"Čítačka e-kníh s rovnakým názvom už existuje\",\n  \"ToastDeviceTestEmailFailed\": \"Odoslanie testovacieho e-mailu zlyhalo\",\n  \"ToastDeviceTestEmailSuccess\": \"Testovací e-mail bol odoslaný\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Nastavenia e-mailu boli aktualizované\",\n  \"ToastEncodeCancelFailed\": \"Zrušenie znakového kódovania zlyhalo\",\n  \"ToastEncodeCancelSucces\": \"Znakové kódovanie bolo zrušené\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Vyčistenie poradia zlyhalo\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Poradie sťahovania bolo vyčistené\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} epizód bolo aktualizovaných\",\n  \"ToastErrorCannotShare\": \"Na tomto zariadení nie je možné zdielať vybraným spôsobom\",\n  \"ToastFailedToCreate\": \"Vytvorenie zlyhalo\",\n  \"ToastFailedToDelete\": \"Zmazanie zlyhalo\",\n  \"ToastFailedToLoadData\": \"Načítanie údajov zlyhalo\",\n  \"ToastFailedToMatch\": \"Spárovanie zlyhalo\",\n  \"ToastFailedToShare\": \"Zdieľanie zlyhalo\",\n  \"ToastFailedToUpdate\": \"Aktualizácia zlyhala\",\n  \"ToastInvalidImageUrl\": \"Neplatná URL obrázku\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Neplatný maximálny počet epizód na stiahnutie\",\n  \"ToastInvalidUrl\": \"Neplatná URL\",\n  \"ToastInvalidUrls\": \"Jedna alebo viac URL liniek sú neplatné\",\n  \"ToastItemCoverUpdateSuccess\": \"Prebal položky bol aktualizovaný\",\n  \"ToastItemDeletedFailed\": \"Odstránenie položky zlyhalo\",\n  \"ToastItemDeletedSuccess\": \"Položka bola odstránená\",\n  \"ToastItemDetailsUpdateSuccess\": \"Údaje položky boli aktualizované\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Označenie za Dokončené zlyhalo\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Položka bola označená ako Dokončená\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Označenie za Nedokončené zlyhalo\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Položka bola označená ako Nedokončená\",\n  \"ToastItemUpdateSuccess\": \"Položka bola aktualizovaná\",\n  \"ToastLibraryCreateFailed\": \"Vytvorenie knižnice zlyhalo\",\n  \"ToastLibraryCreateSuccess\": \"Knižnica \\\"{0}\\\" bola vytvorená\",\n  \"ToastLibraryDeleteFailed\": \"Odstránenie knižnice zlyhalo\",\n  \"ToastLibraryDeleteSuccess\": \"Knižnica bola odstránená\",\n  \"ToastLibraryScanFailedToStart\": \"Spustenie skenovania zlyhalo\",\n  \"ToastLibraryScanStarted\": \"Skenovanie knižnice sa začalo\",\n  \"ToastLibraryUpdateSuccess\": \"Knižnica \\\"{0}\\\" bola aktualizovaná\",\n  \"ToastMatchAllAuthorsFailed\": \"Spárovanie všetkých autorov zlyhalo\",\n  \"ToastMetadataFilesRemovedError\": \"Chyba pri odstraňovaní súborov metadata.{0}\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Žiadne súbory metadata.{0} neboli v knižnici nájdené\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Žiadne súbory metadata.{0} neboli odstránené\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} súborov metadata.{1} bolo odstránených\",\n  \"ToastMustHaveAtLeastOnePath\": \"Musí mať aspoň jednu cestu umiestnenia\",\n  \"ToastNameEmailRequired\": \"Meno a e-mail sú povinné\",\n  \"ToastNameRequired\": \"Meno je povinné\",\n  \"ToastNewApiKeyUserError\": \"Musíte vybrať používateľa\",\n  \"ToastNewEpisodesFound\": \"Bolo nájdených {0} nových epizód\",\n  \"ToastNewUserCreatedFailed\": \"Vytvorenie účtu zlyhalo: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Nový účet bol vytvorený\",\n  \"ToastNewUserLibraryError\": \"Musíte vybrať aspoň jednu knižnicu\",\n  \"ToastNewUserPasswordError\": \"Musí mať heslo, len root používateľ môže mať prázdne heslo\",\n  \"ToastNewUserTagError\": \"Musíte vybrať aspoň jeden štítok\",\n  \"ToastNewUserUsernameError\": \"Zadajte používateľské meno\",\n  \"ToastNoNewEpisodesFound\": \"Žiadne nové epizódy neboli nájdené\",\n  \"ToastNoRSSFeed\": \"Podcast nemá RSS zdroj\",\n  \"ToastNoUpdatesNecessary\": \"Žiadne aktualizácie nie nutné\",\n  \"ToastNotificationCreateFailed\": \"Vytvorenie notifikácie zlyhalo\",\n  \"ToastNotificationDeleteFailed\": \"Odstránenie notifikácie zlyhalo\",\n  \"ToastNotificationFailedMaximum\": \"Maximálny počet chybných pokusov musí byť >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Maximálny počet notifikácií v poradí musí byť >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Nastavenia notifikácií boli aktualizované\",\n  \"ToastNotificationTestTriggerFailed\": \"Spustenie testovacej notifikácie zlyhalo\",\n  \"ToastNotificationTestTriggerSuccess\": \"Testovacia notifikácia bola spustená\",\n  \"ToastNotificationUpdateSuccess\": \"Notifikácia bola aktualizovaná\",\n  \"ToastPlaylistCreateFailed\": \"Vytvorenie playlistu zlyhalo\",\n  \"ToastPlaylistCreateSuccess\": \"Playlist bol vytvorený\",\n  \"ToastPlaylistRemoveSuccess\": \"Playlist bol odstránený\",\n  \"ToastPlaylistUpdateSuccess\": \"Playlist bol aktualizovaný\",\n  \"ToastPodcastCreateFailed\": \"Vytvorenie podcastu zlyhalo\",\n  \"ToastPodcastCreateSuccess\": \"Podcast bol vytvorený\",\n  \"ToastPodcastEpisodeUpdated\": \"Epizóda bola aktualizovaná\",\n  \"ToastPodcastGetFeedFailed\": \"Získanie zdroja podcastu zlyhalo\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Na RSS zdroji neboli nájdené žiadne epizódy\",\n  \"ToastPodcastNoRssFeed\": \"Podcast nemá RSS zdroj\",\n  \"ToastProgressIsNotBeingSynced\": \"Stav počúvania nie je synchronizovaný, reštartujte prehrávanie\",\n  \"ToastProviderCreatedFailed\": \"Pridanie poskytovateľa zlyhalo\",\n  \"ToastProviderCreatedSuccess\": \"Nový poskytovateľ bol pridaný\",\n  \"ToastProviderNameAndUrlRequired\": \"Meno a URL sú povinné\",\n  \"ToastProviderRemoveSuccess\": \"Poskytovateľ bol odstránený\",\n  \"ToastRSSFeedCloseFailed\": \"Odstránenie RSS zdroja zlyhalo\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS zdroj bol odstránený\",\n  \"ToastRemoveFailed\": \"Odstránenie zlyhalo\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Odstránenie položky zo zbierky zlyhalo\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Položka bola zo zbierky odstránená\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Odstránenie položiek s problémami zlyhalo\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Položky knižnice s problémami boli odstránené\",\n  \"ToastRenameFailed\": \"Premenovanie zlyhalo\",\n  \"ToastRescanFailed\": \"Opakované skenovanie {0} zlyhalo\",\n  \"ToastRescanRemoved\": \"Opakovane skenovaná položka bola odstránená\",\n  \"ToastRescanUpToDate\": \"Opakovane skenovaná položka bola aktuálna\",\n  \"ToastRescanUpdated\": \"Opakovane skenovaná položka bola aktualizovaná\",\n  \"ToastScanFailed\": \"Skenovanie položky knižnice zlyhalo\",\n  \"ToastSelectAtLeastOneUser\": \"Vyberte aspoň jedného používateľa\",\n  \"ToastSendEbookToDeviceFailed\": \"Odoslanie e-knihy do zariadenia zlyhalo\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-kniha bola odoslaná do zariadenia \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Nie je možné pridať dve série s rovnakým názvom\",\n  \"ToastSeriesUpdateFailed\": \"Aktualizácia série zlyhala\",\n  \"ToastSeriesUpdateSuccess\": \"Séria bola úspešne aktualizovaná\",\n  \"ToastServerSettingsUpdateSuccess\": \"Nastavenia servera boli aktualizované\",\n  \"ToastSessionCloseFailed\": \"Ukončenie relácie zlyhalo\",\n  \"ToastSessionDeleteFailed\": \"Odstránenie relácie zlyhalo\",\n  \"ToastSessionDeleteSuccess\": \"Relácia odstránená\",\n  \"ToastSleepTimerDone\": \"Časovač spánku bol spustený... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug obsahuje naplatné znaky\",\n  \"ToastSlugRequired\": \"Slug je povinný\",\n  \"ToastSocketConnected\": \"Socket je pripojený\",\n  \"ToastSocketDisconnected\": \"Socket je odpojený\",\n  \"ToastSocketFailedToConnect\": \"Pripojenie socketu zlyhalo\",\n  \"ToastSortingPrefixesEmptyError\": \"Musí mať aspoň jednu triediacu predponu\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Triediace predpony aktualizované ({0} položiek)\",\n  \"ToastTitleRequired\": \"Názov je povinný\",\n  \"ToastUnknownError\": \"Neznáma chyba\",\n  \"ToastUnlinkOpenIdFailed\": \"Odstránenie prepojenia na OpenID zlyhalo\",\n  \"ToastUnlinkOpenIdSuccess\": \"Prepojenie používateľa na OpenID bolo odstránené\",\n  \"ToastUploaderFilepathExistsError\": \"Umiestnenie \\\"{0}\\\" na serveri už existuje\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Položka \\\"{0}\\\" používa podpriečinok umiestnenia pre nahrávanie.\",\n  \"ToastUserDeleteFailed\": \"Odstránenie používateľa zlyhalo\",\n  \"ToastUserDeleteSuccess\": \"Používateľ bol odstránený\",\n  \"ToastUserPasswordChangeSuccess\": \"Zmena hesla prebehla úspešne\",\n  \"ToastUserPasswordMismatch\": \"Heslá sa nezhodujú\",\n  \"ToastUserPasswordMustChange\": \"Nové heslo sa nesmie zhodovať so starým\",\n  \"ToastUserRootRequireName\": \"Musíte zadať používateľské meno root používateľa\",\n  \"TooltipAddChapters\": \"Pridať kapitolu(-y)\",\n  \"TooltipAddOneSecond\": \"Pridať 1 sekundu\",\n  \"TooltipAdjustChapterStart\": \"Kliknite, ak chcete zmeniť začiatočný čas\",\n  \"TooltipLockAllChapters\": \"Zamknúť všetky kapitoly\",\n  \"TooltipLockChapter\": \"Zamknúť kapitolu (Shift+klik pre skupinu)\",\n  \"TooltipSubtractOneSecond\": \"Odobrať 1 sekundu\",\n  \"TooltipUnlockAllChapters\": \"Odomknúť všetky kapitoly\",\n  \"TooltipUnlockChapter\": \"Odomknúť kapitolu (Shift+klik pre skupinu)\"\n}\n"
  },
  {
    "path": "client/strings/sl.json",
    "content": "{\n  \"ButtonAdd\": \"Dodaj\",\n  \"ButtonAddApiKey\": \"Dodaj API ključ\",\n  \"ButtonAddChapters\": \"Dodaj poglavja\",\n  \"ButtonAddDevice\": \"Dodaj napravo\",\n  \"ButtonAddLibrary\": \"Dodaj knjižnico\",\n  \"ButtonAddPodcasts\": \"Dodaj podcast\",\n  \"ButtonAddUser\": \"Dodaj uporabnika\",\n  \"ButtonAddYourFirstLibrary\": \"Dodajte svojo prvo knjižnico\",\n  \"ButtonApply\": \"Uveljavi\",\n  \"ButtonApplyChapters\": \"Uveljavi poglavja\",\n  \"ButtonAuthors\": \"Avtorji\",\n  \"ButtonBack\": \"Nazaj\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Napolni iz obstoječega\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Izpolnite podrobnosti zemljevida\",\n  \"ButtonBrowseForFolder\": \"Prebrskaj pot do mape\",\n  \"ButtonCancel\": \"Prekliči\",\n  \"ButtonCancelEncode\": \"Prekliči prekodiranje\",\n  \"ButtonChangeRootPassword\": \"Zamenjaj korensko geslo\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Preveri in prenesi nove epizode\",\n  \"ButtonChooseAFolder\": \"Izberite mapo\",\n  \"ButtonChooseFiles\": \"Izberite datoteke\",\n  \"ButtonClearFilter\": \"Počisti filter\",\n  \"ButtonClose\": \"Zapri\",\n  \"ButtonCloseFeed\": \"Zapri vir\",\n  \"ButtonCloseSession\": \"Zapri odprto sejo\",\n  \"ButtonCollections\": \"Zbirke\",\n  \"ButtonConfigureScanner\": \"Nastavi pregledovalnik\",\n  \"ButtonCreate\": \"Ustvari\",\n  \"ButtonCreateBackup\": \"Ustvari varnostno kopijo\",\n  \"ButtonDelete\": \"Izbriši\",\n  \"ButtonDownloadQueue\": \"Čakalna vrsta\",\n  \"ButtonEdit\": \"Uredi\",\n  \"ButtonEditChapters\": \"Uredi poglavja\",\n  \"ButtonEditPodcast\": \"Uredi podcast\",\n  \"ButtonEnable\": \"Omogoči\",\n  \"ButtonFireAndFail\": \"Zaženi in je bilo neuspešno\",\n  \"ButtonFireOnTest\": \"Zaženi samo na dogodku onTest\",\n  \"ButtonForceReScan\": \"Prisilno ponovno pregledovanje\",\n  \"ButtonFullPath\": \"Polna pot\",\n  \"ButtonHide\": \"Skrij\",\n  \"ButtonHome\": \"Domov\",\n  \"ButtonIssues\": \"Težave\",\n  \"ButtonJumpBackward\": \"Skoči nazaj\",\n  \"ButtonJumpForward\": \"Skoči naprej\",\n  \"ButtonLatest\": \"Najnovejše\",\n  \"ButtonLibrary\": \"Knjižnica\",\n  \"ButtonLogout\": \"Odjava\",\n  \"ButtonLookup\": \"Iskanje\",\n  \"ButtonManageTracks\": \"Upravljaj s posnetki\",\n  \"ButtonMapChapterTitles\": \"Poveži naslove poglavij\",\n  \"ButtonMatchAllAuthors\": \"Ujemanje vseh avtorjev\",\n  \"ButtonMatchBooks\": \"Ujemanje knjig\",\n  \"ButtonNevermind\": \"Ni važno\",\n  \"ButtonNext\": \"Naslednje\",\n  \"ButtonNextChapter\": \"Naslednje poglavje\",\n  \"ButtonNextItemInQueue\": \"Naslednji element v čakalni vrsti\",\n  \"ButtonOk\": \"V redu\",\n  \"ButtonOpenFeed\": \"Odpri vir\",\n  \"ButtonOpenManager\": \"Odpri upravljanje\",\n  \"ButtonPause\": \"Premor\",\n  \"ButtonPlay\": \"Predvajaj\",\n  \"ButtonPlayAll\": \"Predvajaj vse\",\n  \"ButtonPlaying\": \"Predvajam\",\n  \"ButtonPlaylists\": \"Seznami predvajanj\",\n  \"ButtonPrevious\": \"Prejšnje\",\n  \"ButtonPreviousChapter\": \"Prejšnje poglavje\",\n  \"ButtonProbeAudioFile\": \"Analiziraj zvočno datoteko\",\n  \"ButtonPurgeAllCache\": \"Počisti ves predpomnilnik\",\n  \"ButtonPurgeItemsCache\": \"Počisti predpomnilnik elementov\",\n  \"ButtonQueueAddItem\": \"Dodaj v čakalno vrsto\",\n  \"ButtonQueueRemoveItem\": \"Odstrani iz čakalne vrste\",\n  \"ButtonQuickEmbed\": \"Hitra vdelava\",\n  \"ButtonQuickEmbedMetadata\": \"Hitra vdelava metapodatkov\",\n  \"ButtonQuickMatch\": \"Hitro ujemanje\",\n  \"ButtonReScan\": \"Ponovno pregledovanje\",\n  \"ButtonRead\": \"Preberi\",\n  \"ButtonReadLess\": \"Preberi manj\",\n  \"ButtonReadMore\": \"Preberi več\",\n  \"ButtonRefresh\": \"Osveži\",\n  \"ButtonRemove\": \"Odstrani\",\n  \"ButtonRemoveAll\": \"Odstrani vse\",\n  \"ButtonRemoveAllLibraryItems\": \"Odstrani vse elemente v knjižnici\",\n  \"ButtonRemoveFromContinueListening\": \"Odstrani iz nadaljuj poslušanje\",\n  \"ButtonRemoveFromContinueReading\": \"Odstrani iz nadaljuj branje\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Odstrani serijo iz nadaljuj serijo\",\n  \"ButtonReset\": \"Ponastavi\",\n  \"ButtonResetToDefault\": \"Ponastavi na privzeto\",\n  \"ButtonRestore\": \"Obnovi\",\n  \"ButtonSave\": \"Shrani\",\n  \"ButtonSaveAndClose\": \"Shrani iz zapri\",\n  \"ButtonSaveTracklist\": \"Shrani seznam skladb\",\n  \"ButtonScan\": \"Pregledovanje\",\n  \"ButtonScanLibrary\": \"Preglej knjižnico\",\n  \"ButtonScrollLeft\": \"Premik levo\",\n  \"ButtonScrollRight\": \"Premik desno\",\n  \"ButtonSearch\": \"Poišči\",\n  \"ButtonSelectFolderPath\": \"Izberite pot do mape\",\n  \"ButtonSeries\": \"Serije\",\n  \"ButtonSetChaptersFromTracks\": \"Nastavi poglavja za posnetke\",\n  \"ButtonShare\": \"Deli\",\n  \"ButtonShiftTimes\": \"Zamakni čase\",\n  \"ButtonShow\": \"Prikaži\",\n  \"ButtonStartM4BEncode\": \"Zaženi M4B prekodiranje\",\n  \"ButtonStartMetadataEmbed\": \"Začni vdelavo metapodatkov\",\n  \"ButtonStats\": \"Statistika\",\n  \"ButtonSubmit\": \"Pošlji\",\n  \"ButtonTest\": \"Test\",\n  \"ButtonUnlinkOpenId\": \"Prekini povezavo OpenID\",\n  \"ButtonUpload\": \"Naloži\",\n  \"ButtonUploadBackup\": \"Naloži varnostno kopijo\",\n  \"ButtonUploadCover\": \"Naloži naslovnico\",\n  \"ButtonUploadOPMLFile\": \"Naloži OPML datoteko\",\n  \"ButtonUserDelete\": \"Izbriši uporabnika {0}\",\n  \"ButtonUserEdit\": \"Uredi uporabnika {0}\",\n  \"ButtonViewAll\": \"Poglej vse\",\n  \"ButtonYes\": \"Da\",\n  \"ErrorUploadFetchMetadataAPI\": \"Napaka pri pridobivanju metapodatkov\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Ni bilo mogoče pridobiti metapodatkov - poskusi posodobi naslov in/ali avtorja\",\n  \"ErrorUploadLacksTitle\": \"Imeti mora naslov\",\n  \"HeaderAccount\": \"Račun\",\n  \"HeaderAddCustomMetadataProvider\": \"Dodaj ponudnika metapodatkov po meri\",\n  \"HeaderAdvanced\": \"Napredno\",\n  \"HeaderApiKeys\": \"API ključi\",\n  \"HeaderAppriseNotificationSettings\": \"Nastavitve obvestil Apprise\",\n  \"HeaderAudioTracks\": \"Zvočni posnetki\",\n  \"HeaderAudiobookTools\": \"Orodja za upravljanje datotek zvočnih knjig\",\n  \"HeaderAuthentication\": \"Avtentikacija\",\n  \"HeaderBackups\": \"Varnostne kopije\",\n  \"HeaderBulkChapterModal\": \"Dodaj več poglavij\",\n  \"HeaderChangePassword\": \"Zamenjaj geslo\",\n  \"HeaderChapters\": \"Poglavja\",\n  \"HeaderChooseAFolder\": \"Izberite mapo\",\n  \"HeaderCollection\": \"Zbirka\",\n  \"HeaderCollectionItems\": \"Elementi zbirke\",\n  \"HeaderCover\": \"Naslovnica\",\n  \"HeaderCurrentDownloads\": \"Trenutni prenosi\",\n  \"HeaderCustomMessageOnLogin\": \"Sporočilo po meri ob prijavi\",\n  \"HeaderCustomMetadataProviders\": \"Ponudniki metapodatkov po meri\",\n  \"HeaderDetails\": \"Podrobnosti\",\n  \"HeaderDownloadQueue\": \"Čakalna vrsta prenosa\",\n  \"HeaderEbookFiles\": \"Datoteke e-knjig\",\n  \"HeaderEmail\": \"E-pošta\",\n  \"HeaderEmailSettings\": \"Nastavitve e-pošte\",\n  \"HeaderEpisodes\": \"Epizode\",\n  \"HeaderEreaderDevices\": \"E-bralniki\",\n  \"HeaderEreaderSettings\": \"Nastavitve e-bralnika\",\n  \"HeaderFiles\": \"Datoteke\",\n  \"HeaderFindChapters\": \"Najdi poglavja\",\n  \"HeaderIgnoredFiles\": \"Prezrte datoteke\",\n  \"HeaderItemFiles\": \"Datoteke elementa\",\n  \"HeaderItemMetadataUtils\": \"Pripomočki za metapodatke elementa\",\n  \"HeaderLastListeningSession\": \"Zadnja seja poslušanja\",\n  \"HeaderLatestEpisodes\": \"Zadnje epizode\",\n  \"HeaderLibraries\": \"Knjižnice\",\n  \"HeaderLibraryFiles\": \"Datoteke knjižnice\",\n  \"HeaderLibraryStats\": \"Statistika knjižnice\",\n  \"HeaderListeningSessions\": \"Sej poslušanja\",\n  \"HeaderListeningStats\": \"Statistika poslušanja\",\n  \"HeaderLogin\": \"Prijava\",\n  \"HeaderLogs\": \"Dnevniki\",\n  \"HeaderManageGenres\": \"Upravljajne žanrov\",\n  \"HeaderManageTags\": \"Upravljanje oznak\",\n  \"HeaderMapDetails\": \"Podrobnosti povezave\",\n  \"HeaderMatch\": \"Ujemanje\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Vrstni red metapodatkov\",\n  \"HeaderMetadataToEmbed\": \"Metapodatki za vdelavo\",\n  \"HeaderNewAccount\": \"Nov račun\",\n  \"HeaderNewApiKey\": \"Nov API ključ\",\n  \"HeaderNewLibrary\": \"Nova knjižnica\",\n  \"HeaderNotificationCreate\": \"Ustvari obvestilo\",\n  \"HeaderNotificationUpdate\": \"Posodobi obvestilo\",\n  \"HeaderNotifications\": \"Obvestila\",\n  \"HeaderOpenIDConnectAuthentication\": \"Prijava z OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Odprte seje poslušanja\",\n  \"HeaderOpenRSSFeed\": \"Odpri vir RSS\",\n  \"HeaderOtherFiles\": \"Ostale datoteke\",\n  \"HeaderPasswordAuthentication\": \"Preverjanje pristnosti z geslom\",\n  \"HeaderPermissions\": \"Dovoljenja\",\n  \"HeaderPlayerQueue\": \"Čakalna vrsta predvajalnika\",\n  \"HeaderPlayerSettings\": \"Nastavitve predvajalnika\",\n  \"HeaderPlaylist\": \"Seznam predvajanja\",\n  \"HeaderPlaylistItems\": \"Elementi seznama predvajanja\",\n  \"HeaderPodcastsToAdd\": \"Podcasti za dodajanje\",\n  \"HeaderPresets\": \"Prednastavitve\",\n  \"HeaderPreviewCover\": \"Naslovnica za predogled\",\n  \"HeaderRSSFeedGeneral\": \"RSS podrobnosti\",\n  \"HeaderRSSFeedIsOpen\": \"Vir RSS je odprt\",\n  \"HeaderRSSFeeds\": \"RSS viri\",\n  \"HeaderRemoveEpisode\": \"Odstrani epizodo\",\n  \"HeaderRemoveEpisodes\": \"Odstrani {0} epizod\",\n  \"HeaderSavedMediaProgress\": \"Shranjen napredek predstavnosti\",\n  \"HeaderSchedule\": \"Načrtovanje\",\n  \"HeaderScheduleEpisodeDownloads\": \"Načrtovanje samodejnega prenosa epizod\",\n  \"HeaderScheduleLibraryScans\": \"Načrtuj samodejno pregledovanje knjižnice\",\n  \"HeaderSession\": \"Seja\",\n  \"HeaderSetBackupSchedule\": \"Nastavi urnik varnostnega kopiranja\",\n  \"HeaderSettings\": \"Nastavitve\",\n  \"HeaderSettingsDisplay\": \"Zaslon\",\n  \"HeaderSettingsExperimental\": \"Eksperimentalne funkcije\",\n  \"HeaderSettingsGeneral\": \"Splošno\",\n  \"HeaderSettingsScanner\": \"Pregledovalnik\",\n  \"HeaderSettingsSecurity\": \"Varnost\",\n  \"HeaderSettingsWebClient\": \"Spletni odjemalec\",\n  \"HeaderSleepTimer\": \"Časovnik za izklop\",\n  \"HeaderStatsLargestItems\": \"Največji elementi\",\n  \"HeaderStatsLongestItems\": \"Najdaljši elementi (ure)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minut poslušanja (zadnjih 7 dni)\",\n  \"HeaderStatsRecentSessions\": \"Nedavne seje\",\n  \"HeaderStatsTop10Authors\": \"Najboljših 10 avtorjev\",\n  \"HeaderStatsTop5Genres\": \"Najboljših 5 žanrov\",\n  \"HeaderTableOfContents\": \"Kazalo\",\n  \"HeaderTools\": \"Orodja\",\n  \"HeaderUpdateAccount\": \"Posodobi račun\",\n  \"HeaderUpdateApiKey\": \"Posodobi API ključ\",\n  \"HeaderUpdateAuthor\": \"Posodobi avtorja\",\n  \"HeaderUpdateDetails\": \"Posodobi podrobnosti\",\n  \"HeaderUpdateLibrary\": \"Posodobi knjižnico\",\n  \"HeaderUsers\": \"Uporabniki\",\n  \"HeaderYearReview\": \"Leto {0} v pregledu\",\n  \"HeaderYourStats\": \"Tvoja statistika\",\n  \"LabelAbridged\": \"Skrajšano\",\n  \"LabelAbridgedChecked\": \"Skrajšano (omogočeno)\",\n  \"LabelAbridgedUnchecked\": \"Neskrajšano (onemogočeno)\",\n  \"LabelAccessibleBy\": \"Dostopno iz\",\n  \"LabelAccountType\": \"Vrsta računa\",\n  \"LabelAccountTypeAdmin\": \"Administrator\",\n  \"LabelAccountTypeGuest\": \"Gost\",\n  \"LabelAccountTypeUser\": \"Uporabnik\",\n  \"LabelActivities\": \"Aktivnosti\",\n  \"LabelActivity\": \"Aktivnost\",\n  \"LabelAddToCollection\": \"Dodaj v zbirko\",\n  \"LabelAddToCollectionBatch\": \"Dodaj {0} knjig v zbirko\",\n  \"LabelAddToPlaylist\": \"Dodaj na seznam predvajanja\",\n  \"LabelAddToPlaylistBatch\": \"Dodaj {0} elementov v seznam predvajanja\",\n  \"LabelAddedAt\": \"Dodano ob\",\n  \"LabelAddedDate\": \"Dodano {0}\",\n  \"LabelAdminUsersOnly\": \"Samo administratorji\",\n  \"LabelAll\": \"Vse\",\n  \"LabelAllEpisodesDownloaded\": \"Vse epizode so prenešene\",\n  \"LabelAllUsers\": \"Vsi uporabniki\",\n  \"LabelAllUsersExcludingGuests\": \"Vsi uporabniki razen gosti\",\n  \"LabelAllUsersIncludingGuests\": \"Vsi uporabniki vključno z gosti\",\n  \"LabelAlreadyInYourLibrary\": \"Že v tvoji knjižnici\",\n  \"LabelApiKeyCreated\": \"API ključ \\\"{0}\\\" je uspešno ustvarjen.\",\n  \"LabelApiKeyCreatedDescription\": \"Ne pozabite takoj kopirati API ključ, saj ga kasneje ne boste mogli več videti.\",\n  \"LabelApiKeyUser\": \"Izvedi v imenu uporabnika\",\n  \"LabelApiKeyUserDescription\": \"Ta API ključ bo imel enaka dovoljenja kot uporabnik, v imenu katerega deluje. V dnevnikih bo to prikazano enako, kot če bi zahtevo oddal uporabnik.\",\n  \"LabelApiToken\": \"API žeton\",\n  \"LabelAppend\": \"Priloži\",\n  \"LabelAudioBitrate\": \"Avdio bitna hitrost (npr. 128k)\",\n  \"LabelAudioChannels\": \"Avdio kanali (1 ali 2)\",\n  \"LabelAudioCodec\": \"Avdio kodek\",\n  \"LabelAuthor\": \"Avtor\",\n  \"LabelAuthorFirstLast\": \"Avtor (ime priimek)\",\n  \"LabelAuthorLastFirst\": \"Avtor (priimek, ime)\",\n  \"LabelAuthors\": \"Avtorji\",\n  \"LabelAutoDownloadEpisodes\": \"Samodejni prenos epizod\",\n  \"LabelAutoFetchMetadata\": \"Samodejno pridobivanje metapodatkov\",\n  \"LabelAutoFetchMetadataHelp\": \"Pridobi metapodatke za naslov, avtorja in serijo za poenostavitev nalaganja. Po nalaganju bo morda treba ujemanje dodatnih metapodatkov.\",\n  \"LabelAutoLaunch\": \"Samodejni zagon\",\n  \"LabelAutoLaunchDescription\": \"Samodejna preusmeritev na ponudnika avtentikacije ob navigaciji na prijavno stran (ročna preglasitev poti <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Samodejna registracija\",\n  \"LabelAutoRegisterDescription\": \"Po prijavi samodejno ustvari nove uporabnike\",\n  \"LabelBackToUser\": \"Nazaj na uporabnika\",\n  \"LabelBackupAudioFiles\": \"Varnostno kopiranje zvočnih datotek\",\n  \"LabelBackupLocation\": \"Lokacija varnostnih kopij\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Omogoči samodejno varnostno kopiranje\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Varnostne kopije shranjene v /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Največja velikost varnostne kopije (v GB) (0 za neomejeno)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Kot zaščita pred napačno konfiguracijo, varnostne kopije ne bodo uspele, če presežejo konfigurirano velikost.\",\n  \"LabelBackupsNumberToKeep\": \"Število varnostnih kopij, ki jih je treba hraniti\",\n  \"LabelBackupsNumberToKeepHelp\": \"Naenkrat bo odstranjena samo ena varnostna kopija, če že imate več varnostnih kopij, jih odstranite ročno.\",\n  \"LabelBitrate\": \"Bitna hitrost\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"knjig\",\n  \"LabelButtonText\": \"Besedilo gumba\",\n  \"LabelByAuthor\": \"od {0}\",\n  \"LabelChangePassword\": \"Spremeni geslo\",\n  \"LabelChannels\": \"Kanali\",\n  \"LabelChapterCount\": \"{0} poglavij\",\n  \"LabelChapterTitle\": \"Naslov poglavja\",\n  \"LabelChapters\": \"Poglavja\",\n  \"LabelChaptersFound\": \"najdenih poglavij\",\n  \"LabelClickForMoreInfo\": \"Klikni za več informacij\",\n  \"LabelClickToUseCurrentValue\": \"Klikni za uporabo trenutne vrednosti\",\n  \"LabelClosePlayer\": \"Zapri predvajalnik\",\n  \"LabelCodec\": \"Kodek\",\n  \"LabelCollapseSeries\": \"Strni serije\",\n  \"LabelCollapseSubSeries\": \"Strni podserije\",\n  \"LabelCollection\": \"Zbirka\",\n  \"LabelCollections\": \"Zbirke\",\n  \"LabelComplete\": \"Končano\",\n  \"LabelConfirmPassword\": \"Potrdi geslo\",\n  \"LabelContinueListening\": \"Nadaljuj poslušanje\",\n  \"LabelContinueReading\": \"Nadaljuj branje\",\n  \"LabelContinueSeries\": \"Nadaljuj s serijo\",\n  \"LabelCorsAllowed\": \"Dovoljeni CORS viri\",\n  \"LabelCover\": \"Naslovnica\",\n  \"LabelCoverImageURL\": \"URL naslovne slike\",\n  \"LabelCoverProvider\": \"Ponudnik naslovnic\",\n  \"LabelCreatedAt\": \"Ustvarjeno ob\",\n  \"LabelCronExpression\": \"Cron izraz\",\n  \"LabelCurrent\": \"Trenutno\",\n  \"LabelCurrently\": \"Trenutno:\",\n  \"LabelCustomCronExpression\": \"Cron izraz po meri:\",\n  \"LabelDatetime\": \"Datum in ura\",\n  \"LabelDays\": \"Dnevi\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Izbriši iz datotečnega sistema (počisti polje, če želiš odstraniti samo iz zbirke podatkov)\",\n  \"LabelDescription\": \"Opis\",\n  \"LabelDeselectAll\": \"Odznači vse\",\n  \"LabelDetectedPattern\": \"Zaznan vzorec:\",\n  \"LabelDevice\": \"Naprava\",\n  \"LabelDeviceInfo\": \"Podatki o napravi\",\n  \"LabelDeviceIsAvailableTo\": \"Naprava je na voljo za...\",\n  \"LabelDirectory\": \"Imenik\",\n  \"LabelDiscFromFilename\": \"Disk iz imena datoteke\",\n  \"LabelDiscFromMetadata\": \"Disk iz metapodatkov\",\n  \"LabelDiscover\": \"Odkrij\",\n  \"LabelDownload\": \"Prenos\",\n  \"LabelDownloadNEpisodes\": \"Prenesi {0} epizod\",\n  \"LabelDownloadable\": \"Možen prenos\",\n  \"LabelDuration\": \"Trajanje\",\n  \"LabelDurationComparisonExactMatch\": \"(natančno ujemanje)\",\n  \"LabelDurationComparisonLonger\": \"({0} dlje)\",\n  \"LabelDurationComparisonShorter\": \"({0} krajše)\",\n  \"LabelDurationFound\": \"Najdeno trajanje:\",\n  \"LabelEbook\": \"E-knjiga\",\n  \"LabelEbooks\": \"E-knjige\",\n  \"LabelEdit\": \"Uredi\",\n  \"LabelEmail\": \"E-pošta\",\n  \"LabelEmailSettingsFromAddress\": \"Iz naslova\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Zavrni nepooblaščena potrdila\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Če onemogočite preverjanje veljavnosti potrdila SSL, lahko izpostavite svojo povezavo varnostnim tveganjem, kot so napadi človek v sredini. To možnost onemogočite le, če razumete posledice in zaupate poštnemu strežniku, s katerim se povezujete.\",\n  \"LabelEmailSettingsSecure\": \"Varno\",\n  \"LabelEmailSettingsSecureHelp\": \"Če je omogočeno, bo povezava pri povezovanju s strežnikom uporabljala TLS. Če je onemogočeno, se TLS uporablja, če strežnik podpira razširitev STARTTLS. V večini primerov nastavite to vrednost na omogočeno, če se povezujete z vrati 465. Za vrata 587 ali 25 naj ostane onemogočeno. (iz nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Testiraj naslov\",\n  \"LabelEmbeddedCover\": \"Vdelana naslovnica\",\n  \"LabelEnable\": \"Omogoči\",\n  \"LabelEncodingBackupLocation\": \"Varnostna kopija vaših izvirnih zvočnih datotek bo shranjena v:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Poglavja niso vdelana v zvočne knjige z večimi sledmi.\",\n  \"LabelEncodingClearItemCache\": \"Občasno počistite predpomnilnik elementov.\",\n  \"LabelEncodingFinishedM4B\": \"Končana M4B datoteka bo shranjena v vaši mapi z zvočnimi knjigami:\",\n  \"LabelEncodingInfoEmbedded\": \"Metapodatki bodo vdelani v zvočne posnetke znotraj vaše mape zvočne knjige.\",\n  \"LabelEncodingStartedNavigation\": \"Ko se opravilo začne, lahko zapustite to stran.\",\n  \"LabelEncodingTimeWarning\": \"Enkodiranje lahko traja tudi do 30 minut.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Opozorilo: Ne posodabljajte teh nastavitev, razen če poznate možnosti ekodiranja s programom ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Če ste spremljanje datotečnega sistema onemogočili, boste morali pozneje ponovno pregledati to zvočno knjigo.\",\n  \"LabelEnd\": \"Konec\",\n  \"LabelEndOfChapter\": \"Konec poglavja\",\n  \"LabelEpisode\": \"Epizoda\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Epizoda ni povezana z virom RSS\",\n  \"LabelEpisodeNumber\": \"{0}. epizoda\",\n  \"LabelEpisodeTitle\": \"Naslov epizode\",\n  \"LabelEpisodeType\": \"Tip epizode\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL epizode iz vira RSS\",\n  \"LabelEpisodes\": \"Epizode\",\n  \"LabelEpisodic\": \"Epizodično\",\n  \"LabelExample\": \"Primer\",\n  \"LabelExpandSeries\": \"Razširi serije\",\n  \"LabelExpandSubSeries\": \"Razširi podserije\",\n  \"LabelExpired\": \"Potekel\",\n  \"LabelExpiresAt\": \"Peteče ob\",\n  \"LabelExpiresInSeconds\": \"Poteče čez (sekunde)\",\n  \"LabelExpiresNever\": \"Nikoli\",\n  \"LabelExplicit\": \"Eksplicitno\",\n  \"LabelExplicitChecked\": \"Eksplicitno (omogočeno)\",\n  \"LabelExplicitUnchecked\": \"Ne eksplicitno (onemogočeno)\",\n  \"LabelExportOPML\": \"Izvozi OPML\",\n  \"LabelFeedURL\": \"URL vir\",\n  \"LabelFetchingMetadata\": \"Pridobivam metapodatke\",\n  \"LabelFile\": \"Datoteka\",\n  \"LabelFileBirthtime\": \"Čas ustvarjanja datoteke\",\n  \"LabelFileBornDate\": \"Ustvarjena {0}\",\n  \"LabelFileModified\": \"Datoteke spremenjena\",\n  \"LabelFileModifiedDate\": \"Spremenjena {0}\",\n  \"LabelFilename\": \"Ime datoteke\",\n  \"LabelFilterByUser\": \"Filtriraj po uporabniku\",\n  \"LabelFindEpisodes\": \"Poišči epizode\",\n  \"LabelFinished\": \"Zaključeno\",\n  \"LabelFinishedDate\": \"Končano {0}\",\n  \"LabelFolder\": \"Mapa\",\n  \"LabelFolders\": \"Mape\",\n  \"LabelFontBold\": \"Krepko\",\n  \"LabelFontBoldness\": \"Krepkost pisave\",\n  \"LabelFontFamily\": \"Družina pisav\",\n  \"LabelFontItalic\": \"Ležeče\",\n  \"LabelFontScale\": \"Merilo pisave\",\n  \"LabelFontStrikethrough\": \"Prečrtano\",\n  \"LabelFormat\": \"Oblika\",\n  \"LabelFull\": \"Polno\",\n  \"LabelGenre\": \"Žanr\",\n  \"LabelGenres\": \"Žanri\",\n  \"LabelHardDeleteFile\": \"Trdo brisanje datoteke\",\n  \"LabelHasEbook\": \"Ima e-knjigo\",\n  \"LabelHasSupplementaryEbook\": \"Ima dodatno e-knjigo\",\n  \"LabelHideSubtitles\": \"Skrij podnaslove\",\n  \"LabelHighestPriority\": \"Najvišja prioriteta\",\n  \"LabelHost\": \"Gostitelj\",\n  \"LabelHour\": \"Ura\",\n  \"LabelHours\": \"Ure\",\n  \"LabelIcon\": \"Ikona\",\n  \"LabelImageURLFromTheWeb\": \"URL slike iz spleta\",\n  \"LabelInProgress\": \"V teku\",\n  \"LabelIncludeInTracklist\": \"Vključi v seznam skladb\",\n  \"LabelIncomplete\": \"Nedokončano\",\n  \"LabelInterval\": \"Interval\",\n  \"LabelIntervalCustomDailyWeekly\": \"Dnevno/tedensko po meri\",\n  \"LabelIntervalEvery12Hours\": \"Vsakih 12 ur\",\n  \"LabelIntervalEvery15Minutes\": \"Vsakih 15 minut\",\n  \"LabelIntervalEvery2Hours\": \"Vsake 2 uri\",\n  \"LabelIntervalEvery30Minutes\": \"Vsakih 30 minut\",\n  \"LabelIntervalEvery6Hours\": \"Vsakih 6 ur\",\n  \"LabelIntervalEveryDay\": \"Vsak dan\",\n  \"LabelIntervalEveryHour\": \"Vsako uro\",\n  \"LabelIntervalEveryMinute\": \"Vsako minuto\",\n  \"LabelInvert\": \"Obrni izbor\",\n  \"LabelItem\": \"Element\",\n  \"LabelJumpBackwardAmount\": \"Količina skoka nazaj\",\n  \"LabelJumpForwardAmount\": \"Količina skoka naprej\",\n  \"LabelLanguage\": \"Jezik\",\n  \"LabelLanguageDefaultServer\": \"Privzeti jezik strežnika\",\n  \"LabelLanguages\": \"Jeziki\",\n  \"LabelLastBookAdded\": \"Zadnja dodana knjiga\",\n  \"LabelLastBookUpdated\": \"Zadnja posodobljena knjiga\",\n  \"LabelLastProgressDate\": \"Zadnji napredek: {0}\",\n  \"LabelLastSeen\": \"Nazadnje viden\",\n  \"LabelLastTime\": \"Nazadnje\",\n  \"LabelLastUpdate\": \"Zadnja posodobitev\",\n  \"LabelLayout\": \"Postavitev\",\n  \"LabelLayoutSinglePage\": \"Ena stran\",\n  \"LabelLayoutSplitPage\": \"Razdeli stran\",\n  \"LabelLess\": \"Manj\",\n  \"LabelLibrariesAccessibleToUser\": \"Knjižnice, dostopne uporabniku\",\n  \"LabelLibrary\": \"Knjižnica\",\n  \"LabelLibraryFilterSublistEmpty\": \"Ne {0}\",\n  \"LabelLibraryItem\": \"Element knjižnice\",\n  \"LabelLibraryName\": \"Ime knjižnice\",\n  \"LabelLibrarySortByProgress\": \"Napredek: Zadnja posodobitev\",\n  \"LabelLibrarySortByProgressFinished\": \"Napredek: Končano\",\n  \"LabelLibrarySortByProgressStarted\": \"Napredek: Začelo se je\",\n  \"LabelLimit\": \"Omejitev\",\n  \"LabelLineSpacing\": \"Vrstični razmak\",\n  \"LabelListenAgain\": \"Poslušaj znova\",\n  \"LabelLogLevelDebug\": \"Odpravljanje napak\",\n  \"LabelLogLevelInfo\": \"Info\",\n  \"LabelLogLevelWarn\": \"Opozoritve\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Poiščite nove epizode po tem datumu\",\n  \"LabelLowestPriority\": \"Najnižja prioriteta\",\n  \"LabelMatchConfidence\": \"Zaupanje\",\n  \"LabelMatchExistingUsersBy\": \"Poveži obstoječe uporabnike po\",\n  \"LabelMatchExistingUsersByDescription\": \"Uporablja se za povezovanje obstoječih uporabnikov. Ko se vzpostavi povezava, se bodo uporabniki ujemali z enoličnim ID-jem vašega ponudnika SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Največje število epizod za prenos. Uporabite 0 za neomejeno.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Največje število novih epizod za prenos ob preverjanju\",\n  \"LabelMaxEpisodesToKeep\": \"Največje število epizod, ki jih lahko obdržite\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Vrednost 0 ne omejuje navišjega števila. Ko se nova epizoda samodejno prenese, se bo izbrisala najstarejša epizoda, če imate več kot X epizod. S tem boste izbrisali samo 1 epizodo na nov prenos.\",\n  \"LabelMediaPlayer\": \"Medijski predvajalnik\",\n  \"LabelMediaType\": \"Vrsta medija\",\n  \"LabelMetaTag\": \"Meta oznaka\",\n  \"LabelMetaTags\": \"Meta oznake\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Viri metapodatkov višje prioritete bodo preglasili vire metapodatkov nižje prioritete\",\n  \"LabelMetadataProvider\": \"Ponudnik metapodatkov\",\n  \"LabelMinute\": \"Minuta\",\n  \"LabelMinutes\": \"Minute\",\n  \"LabelMissing\": \"Manjka\",\n  \"LabelMissingEbook\": \"Nima nobene e-knjige\",\n  \"LabelMissingSupplementaryEbook\": \"Nima nobene dodatne e-knjige\",\n  \"LabelMobileRedirectURIs\": \"Dovoljeni mobilni preusmeritveni URI-ji\",\n  \"LabelMobileRedirectURIsDescription\": \"To je seznam dovoljenih veljavnih preusmeritvenih URI-jev za mobilne aplikacije. Privzeti je <code>audiobookshelf://oauth</code>, ki ga lahko odstranite ali dopolnite z dodatnimi URI-ji za integracijo aplikacij tretjih oseb. Uporaba zvezdice (<code>*</code>) kot edinega vnosa dovoljuje kateri koli URI.\",\n  \"LabelMore\": \"Več\",\n  \"LabelMoreInfo\": \"Več informacij\",\n  \"LabelName\": \"Naziv\",\n  \"LabelNarrator\": \"Bralec\",\n  \"LabelNarrators\": \"Bralci\",\n  \"LabelNew\": \"Novo\",\n  \"LabelNewPassword\": \"Novo geslo\",\n  \"LabelNewestAuthors\": \"Najnovejši avtorji\",\n  \"LabelNewestEpisodes\": \"Najnovejše epizode\",\n  \"LabelNextBackupDate\": \"Naslednji datum varnostnega kopiranja\",\n  \"LabelNextChapters\": \"Naslednja poglavja bodo:\",\n  \"LabelNextScheduledRun\": \"Naslednji načrtovani zagon\",\n  \"LabelNoApiKeys\": \"Ni API ključev\",\n  \"LabelNoCustomMetadataProviders\": \"Ni ponudnikov metapodatkov po meri\",\n  \"LabelNoEpisodesSelected\": \"Izbrana ni nobena epizoda\",\n  \"LabelNotFinished\": \"Ni dokončano\",\n  \"LabelNotStarted\": \"Ni zagnano\",\n  \"LabelNotes\": \"Opombe\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(ji)\",\n  \"LabelNotificationAvailableVariables\": \"Razpoložljive spremenljivke\",\n  \"LabelNotificationBodyTemplate\": \"Predloga vsebime\",\n  \"LabelNotificationEvent\": \"Dogodek obvestila\",\n  \"LabelNotificationTitleTemplate\": \"Predloga naslova\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Najvišje število neuspelih poskusov\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Obvestila so onemogočena, ko se tolikokrat neuspelo pošljejo\",\n  \"LabelNotificationsMaxQueueSize\": \"Največja velikost čakalne vrste za dogodke obvestil\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.\",\n  \"LabelNumberOfBooks\": \"Število knjig\",\n  \"LabelNumberOfChapters\": \"Število poglavij:\",\n  \"LabelNumberOfEpisodes\": \"# epizod\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (<b>če je konfigurirano</b>). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot <code>false</code>. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:\",\n  \"LabelOpenIDClaims\": \"Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane <code>skupine</code>. <b>Če je konfigurirana</b>, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.\",\n  \"LabelOpenRSSFeed\": \"Odpri vir RSS\",\n  \"LabelOverwrite\": \"Prepiši\",\n  \"LabelPaginationPageXOfY\": \"Stran {0} od {1}\",\n  \"LabelPassword\": \"Geslo\",\n  \"LabelPath\": \"Pot\",\n  \"LabelPermanent\": \"Trajno\",\n  \"LabelPermissionsAccessAllLibraries\": \"Lahko dostopa do vseh knjižnic\",\n  \"LabelPermissionsAccessAllTags\": \"Lahko dostopa do vseh oznak\",\n  \"LabelPermissionsAccessExplicitContent\": \"Lahko dostopa do eksplicitne vsebine\",\n  \"LabelPermissionsCreateEreader\": \"Lahko ustvari e-bralnik\",\n  \"LabelPermissionsDelete\": \"Lahko briše\",\n  \"LabelPermissionsDownload\": \"Lahko prenaša\",\n  \"LabelPermissionsUpdate\": \"Lahko posodablja\",\n  \"LabelPermissionsUpload\": \"Lahko nalaga\",\n  \"LabelPersonalYearReview\": \"Pregled tvojega leta ({0})\",\n  \"LabelPhotoPathURL\": \"Slika pot/URL\",\n  \"LabelPlayMethod\": \"Metoda predvajanja\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Korak povečanja/zmanjšanja hitrosti predvajanja\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} od {1}\",\n  \"LabelPlaylists\": \"Seznami predvajanja\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Regija iskanja podcastov\",\n  \"LabelPodcastType\": \"Vrsta podcasta\",\n  \"LabelPodcasts\": \"Podcasti\",\n  \"LabelPort\": \"Vrata\",\n  \"LabelPrefixesToIgnore\": \"Predpone, ki jih je treba prezreti (neobčutljivo na velike in male črke)\",\n  \"LabelPreventIndexing\": \"Preprečite, da bi vaš vir indeksirali imeniki podcastov iTunes in Google\",\n  \"LabelPrimaryEbook\": \"Primarna e-knjiga\",\n  \"LabelProgress\": \"Napredek\",\n  \"LabelProvider\": \"Ponudnik\",\n  \"LabelProviderAuthorizationValue\": \"Vrednost glave avtorizacije\",\n  \"LabelPubDate\": \"Datum objave\",\n  \"LabelPublishYear\": \"Leto izdaje\",\n  \"LabelPublishedDate\": \"Objavljeno {0}\",\n  \"LabelPublishedDecade\": \"Desetletje izdaje\",\n  \"LabelPublishedDecades\": \"Desetletja izdaje\",\n  \"LabelPublisher\": \"Izdajatelj\",\n  \"LabelPublishers\": \"Izdajatelji\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"E-pošta lastnika po meri\",\n  \"LabelRSSFeedCustomOwnerName\": \"Ime lastnika po meri\",\n  \"LabelRSSFeedOpen\": \"RSS vir je odprt\",\n  \"LabelRSSFeedPreventIndexing\": \"Prepreči indeksiranje\",\n  \"LabelRSSFeedSlug\": \"Slug RSS vira\",\n  \"LabelRSSFeedURL\": \"URL vira RSS\",\n  \"LabelRandomly\": \"Naključno\",\n  \"LabelReAddSeriesToContinueListening\": \"Znova dodaj serijo za nadaljevanje poslušanja\",\n  \"LabelRead\": \"Preberi\",\n  \"LabelReadAgain\": \"Ponovno preberi\",\n  \"LabelReadEbookWithoutProgress\": \"Preberi eknjigo brez ohranjanja napredka\",\n  \"LabelRecentSeries\": \"Nedavne serije\",\n  \"LabelRecentlyAdded\": \"Nedavno dodano\",\n  \"LabelRecommended\": \"Priporočeno\",\n  \"LabelRedo\": \"Ponovi\",\n  \"LabelRegion\": \"Regija\",\n  \"LabelReleaseDate\": \"Datum izdaje\",\n  \"LabelRemoveAllMetadataAbs\": \"Odstrani vse datoteke metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Odstrani vse datoteke metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Odstrani Audible uvod in zaključek iz poglavij\",\n  \"LabelRemoveCover\": \"Odstrani naslovnico\",\n  \"LabelRemoveMetadataFile\": \"Odstrani datoteke z metapodatki v mapah elementov knjižnice\",\n  \"LabelRemoveMetadataFileHelp\": \"Odstrani vse datoteke metadata.json in metadata.abs v svojih mapah {0}.\",\n  \"LabelRowsPerPage\": \"Vrstic na stran\",\n  \"LabelSearchTerm\": \"Iskalni pojem\",\n  \"LabelSearchTitle\": \"Naslov iskanja\",\n  \"LabelSearchTitleOrASIN\": \"Naslov iskanja ali ASIN\",\n  \"LabelSeason\": \"Sezona\",\n  \"LabelSeasonNumber\": \"Sezona #{0}\",\n  \"LabelSelectAll\": \"Izberite vse\",\n  \"LabelSelectAllEpisodes\": \"Izberite vse epizode\",\n  \"LabelSelectEpisodesShowing\": \"Izberi {0} prikazanih epizod\",\n  \"LabelSelectUser\": \"Izberi uporabnika\",\n  \"LabelSelectUsers\": \"Izberite uporabnike\",\n  \"LabelSendEbookToDevice\": \"Pošlji eknjigo k...\",\n  \"LabelSequence\": \"Zaporedje\",\n  \"LabelSerial\": \"Serija\",\n  \"LabelSeries\": \"Serije\",\n  \"LabelSeriesName\": \"Ime serije\",\n  \"LabelSeriesProgress\": \"Napredek serije\",\n  \"LabelServerLogLevel\": \"Raven dnevnika strežnika\",\n  \"LabelServerYearReview\": \"Pregled leta strežnika ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Nastavi kot primarno\",\n  \"LabelSetEbookAsSupplementary\": \"Nastavi kot dodatno\",\n  \"LabelSettingsAllowIframe\": \"Dovoli vdelavo v iframu\",\n  \"LabelSettingsAudiobooksOnly\": \"Samo zvočne knjige\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige\",\n  \"LabelSettingsBookshelfViewHelp\": \"Skeuomorfna oblika z lesenimi policami\",\n  \"LabelSettingsChromecastSupport\": \"Podpora za Chromecast\",\n  \"LabelSettingsDateFormat\": \"Oblika datuma\",\n  \"LabelSettingsEnableWatcher\": \"Samodejno preišči knjižnice za spremembe\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Samodejno preišči knjižnico za spremembe\",\n  \"LabelSettingsEnableWatcherHelp\": \"Omogoča samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Dovoli skriptirano vsebino v epubih\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Dovoli datotekam epub izvajanje skript. Priporočljivo je, da to nastavitev pustite onemogočeno, razen če zaupate viru datotek epub.\",\n  \"LabelSettingsExperimentalFeatures\": \"Eksperimentalne funkcije\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funkcije v razvoju, ki bi lahko uporabile vaše povratne informacije in pomoč pri testiranju. Kliknite, da odprete razpravo na githubu.\",\n  \"LabelSettingsFindCovers\": \"Poišči naslovnice\",\n  \"LabelSettingsFindCoversHelp\": \"Če vaša zvočna knjiga nima vdelane naslovnice ali slike naslovnice v mapi, bo pregledovalnik poskušal najti naslovnico.<br>Opomba: To bo podaljšalo čas pregledovanja\",\n  \"LabelSettingsHideSingleBookSeries\": \"Skrij serije s samo eno knjigo\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serije, ki imajo eno knjigo, bodo skrite na strani serije in policah domače strani.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Domača stran bo imela pogled knjižne police\",\n  \"LabelSettingsLibraryBookshelfView\": \"Knjižnična uporaba pogleda knjižne police\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Odstotek dokončanega je večji od\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Preostali čas je manj kot (sekund)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Označi medijski element kot končan, ko\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Preskoči prejšnje knjige v nadaljevanju serije\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.\",\n  \"LabelSettingsParseSubtitles\": \"Razčleni podnaslove\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Izvleci padnaslove iz imen map zvočnih knjig.<br>Podnaslov mora biti ločen z \\\" - \\\"<br>npr. \\\"Naslov knjige – tu podnaslove\\\" ima podnaslov \\\"tu podnaslov\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Prednost imajo ujemajoči se metapodatki\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Preskoči ujemajoče se knjige, ki že imajo ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Preskoči ujemajoče se knjige, ki že imajo oznako ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Pri razvrščanju ne upoštevaj predpon\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"npr. za naslov knjige s predpono \\\"the\\\" bi se \\\"The Book Title\\\" razvrstil kot \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Uporabi kvadratne platnice knjig\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Raje uporabi kvadratne platnice kot standardne knjižne platnice 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Shrani naslovnice skupaj z elementom\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Naslovnice so privzeto shranjene v /metadata/items, če omogočite to nastavitev, bodo platnice shranjene v mapi elementov knjižnice. Shranjena bo samo ena datoteka z imenom \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Shrani metapodatke skupaj z elementom\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice\",\n  \"LabelSettingsTimeFormat\": \"Oblika časa\",\n  \"LabelShare\": \"Deli\",\n  \"LabelShareDownloadableHelp\": \"Omogoča uporabnikom s povezavo skupne rabe, da prenesejo zip datoteko elementa knjižnice.\",\n  \"LabelShareOpen\": \"Deli odprto\",\n  \"LabelShareURL\": \"Deli URL\",\n  \"LabelShowAll\": \"Prikaži vse\",\n  \"LabelShowSeconds\": \"Prikaži sekunde\",\n  \"LabelShowSubtitles\": \"Prikaži podnaslove\",\n  \"LabelSize\": \"Velikost\",\n  \"LabelSleepTimer\": \"Časovnik za spanje\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"Naraščajoče\",\n  \"LabelSortDescending\": \"Padajoče\",\n  \"LabelSortPubDate\": \"Razvrsti po datumu objave\",\n  \"LabelStart\": \"Začetek\",\n  \"LabelStartTime\": \"Čas začetka\",\n  \"LabelStarted\": \"Začeto\",\n  \"LabelStartedAt\": \"Začeto ob\",\n  \"LabelStartedDate\": \"Začeto {0}\",\n  \"LabelStatsAudioTracks\": \"Zvočni posnetki\",\n  \"LabelStatsAuthors\": \"Avtorji\",\n  \"LabelStatsBestDay\": \"Najboljši dan\",\n  \"LabelStatsDailyAverage\": \"Dnevno povprečje\",\n  \"LabelStatsDays\": \"Dnevi\",\n  \"LabelStatsDaysListened\": \"Dnevi poslušanja\",\n  \"LabelStatsHours\": \"Ure\",\n  \"LabelStatsInARow\": \"v vrsti\",\n  \"LabelStatsItemsFinished\": \"Končani elementi\",\n  \"LabelStatsItemsInLibrary\": \"Elementi v knjižnici\",\n  \"LabelStatsMinutes\": \"minute\",\n  \"LabelStatsMinutesListening\": \"Minut poslušanja\",\n  \"LabelStatsOverallDays\": \"Skupaj dnevi\",\n  \"LabelStatsOverallHours\": \"Skupaj ur\",\n  \"LabelStatsWeekListening\": \"Tednov poslušanja\",\n  \"LabelSubtitle\": \"Podnaslov\",\n  \"LabelSupportedFileTypes\": \"Podprte vrste datotek\",\n  \"LabelTag\": \"Oznaka\",\n  \"LabelTags\": \"Oznake\",\n  \"LabelTagsAccessibleToUser\": \"Oznake, dostopne uporabniku\",\n  \"LabelTagsNotAccessibleToUser\": \"Oznake, ki niso dostopne uporabniku\",\n  \"LabelTasks\": \"Tekoče naloge\",\n  \"LabelTextEditorBulletedList\": \"Seznam z oznakami\",\n  \"LabelTextEditorLink\": \"Povezava\",\n  \"LabelTextEditorNumberedList\": \"Številčni seznam\",\n  \"LabelTextEditorUnlink\": \"Odveži\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Temna\",\n  \"LabelThemeLight\": \"Svetla\",\n  \"LabelThemeSepia\": \"Sepija\",\n  \"LabelTimeBase\": \"Osnovni čas\",\n  \"LabelTimeDurationXHours\": \"{0} ur\",\n  \"LabelTimeDurationXMinutes\": \"{0} minut\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekund\",\n  \"LabelTimeInMinutes\": \"Čas v minutah\",\n  \"LabelTimeLeft\": \"{0} še preostane\",\n  \"LabelTimeListened\": \"Čas poslušanja\",\n  \"LabelTimeListenedToday\": \"Čas poslušanja danes\",\n  \"LabelTimeRemaining\": \"Še {0}\",\n  \"LabelTimeToShift\": \"Čas prestavljanja v sekundah\",\n  \"LabelTitle\": \"Naslov\",\n  \"LabelToolsEmbedMetadata\": \"Vdelaj metapodatke\",\n  \"LabelToolsEmbedMetadataDescription\": \"Vdelajte metapodatke v zvočne datoteke, vključno s sliko naslovnice in poglavji.\",\n  \"LabelToolsM4bEncoder\": \"M4B enkoder\",\n  \"LabelToolsMakeM4b\": \"Ustvari M4B datoteko zvočne knjige\",\n  \"LabelToolsMakeM4bDescription\": \"Ustvari zvočno knjigo v .M4B obliki z vdelanimi metapodatki, sliko naslovnice in poglavji.\",\n  \"LabelToolsSplitM4b\": \"Razdeli M4B v MP3 datoteke\",\n  \"LabelToolsSplitM4bDescription\": \"Ustvarite MP3 datoteke iz datoteke M4B, razdeljene po poglavjih z vdelanimi metapodatki, naslovno sliko in poglavji.\",\n  \"LabelTotalDuration\": \"Skupno trajanje\",\n  \"LabelTotalTimeListened\": \"Skupni čas poslušanja\",\n  \"LabelTrackFromFilename\": \"Posnetek iz datoteke\",\n  \"LabelTrackFromMetadata\": \"Posnetek iz metapodatkov\",\n  \"LabelTracks\": \"Posnetki\",\n  \"LabelTracksMultiTrack\": \"Več posnetkov\",\n  \"LabelTracksNone\": \"Brez posnetka\",\n  \"LabelTracksSingleTrack\": \"Enojni posnetek\",\n  \"LabelTrailer\": \"Napovednik\",\n  \"LabelType\": \"Vrsta\",\n  \"LabelUnabridged\": \"Neskrajšano\",\n  \"LabelUndo\": \"Razveljavi\",\n  \"LabelUnknown\": \"Neznano\",\n  \"LabelUnknownPublishDate\": \"Neznan datum izdaje\",\n  \"LabelUpdateCover\": \"Posodobi naslovnico\",\n  \"LabelUpdateCoverHelp\": \"Dovoli prepisovanje obstoječih naslovnic za izbrane knjige, ko se najde ujemanje\",\n  \"LabelUpdateDetails\": \"Posodobi podrobnosti\",\n  \"LabelUpdateDetailsHelp\": \"Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje\",\n  \"LabelUpdatedAt\": \"Posodobljeno ob\",\n  \"LabelUploaderDragAndDrop\": \"Povleci in spusti datoteke ali mape\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Povleci in spusti datoteke\",\n  \"LabelUploaderDropFiles\": \"Spusti datoteke\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Samodejno pridobi naslov, avtorja in serijo\",\n  \"LabelUseAdvancedOptions\": \"Uporabi napredne možnosti\",\n  \"LabelUseChapterTrack\": \"Uporabi posnetek poglavij\",\n  \"LabelUseFullTrack\": \"Uporabi celoten posnetek\",\n  \"LabelUseZeroForUnlimited\": \"Uporabi 0 za neomejeno\",\n  \"LabelUser\": \"Uporabnik\",\n  \"LabelUsername\": \"Uporabniško ime\",\n  \"LabelValue\": \"Vrednost\",\n  \"LabelVersion\": \"Verzija\",\n  \"LabelViewBookmarks\": \"Ogled zaznamkov\",\n  \"LabelViewChapters\": \"Ogled poglavij\",\n  \"LabelViewPlayerSettings\": \"Ogled nastavitev predvajalnika\",\n  \"LabelViewQueue\": \"Ogled čakalno vrsto predvajalnika\",\n  \"LabelVolume\": \"Glasnost\",\n  \"LabelWebRedirectURLsDescription\": \"Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Podmapa za URL-je preusmeritve\",\n  \"LabelWeekdaysToRun\": \"Delovni dnevi predvajanja\",\n  \"LabelXBooks\": \"{0} knjig\",\n  \"LabelXItems\": \"{0} elementov\",\n  \"LabelYearReviewHide\": \"Skrij pregled leta\",\n  \"LabelYearReviewShow\": \"Poglej si pregled leta\",\n  \"LabelYourAudiobookDuration\": \"Trajanje tvojih zvočnih knjig\",\n  \"LabelYourBookmarks\": \"Tvoji zaznamki\",\n  \"LabelYourPlaylists\": \"Tvoje seznami predvajanj\",\n  \"LabelYourProgress\": \"Tvoj napredek\",\n  \"MessageAddToPlayerQueue\": \"Dodaj v čakalno vrsto predvajalnika\",\n  \"MessageAppriseDescription\": \"Če želite uporabljati to funkcijo, morate imeti zagnano namestitev <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">API Apprise</a> ali API, ki bo obravnavala te iste zahteve. <br />Url API-ja Apprise mora biti celotna pot URL-ja za pošiljanje obvestila, npr. če je vaša namestitev API-ja postrežena na <code>http://192.168.1.1:8337</code>, bi morali vnesti <code >http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Prepričajte se, da uporabljate ASIN iz pravilne zvočne regije, ne iz Amazona.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Zastareli API žetoni bodo v prihodnosti odstranjeni. Namesto tega uporabite <a href=\\\"/config/api-keys\\\">API ključe</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Za uveljavitev OIDC sprememb, po shranjevanju znova zaženite strežnik.\",\n  \"MessageAuthenticationSecurityMessage\": \"Zaradi varnosti je bila izboljšana avtentikacija. Vsi uporabniki se morajo ponovno prijaviti.\",\n  \"MessageBackupsDescription\": \"Varnostne kopije vključujejo uporabnike, napredek uporabnikov, podrobnosti elementov knjižnice, nastavitve strežnika in slike, shranjene v <code>/metadata/items</code> & <code>/metadata/authors</code>. Varnostne kopije <strong>ne</strong> vključujejo datotek, shranjenih v mapah vaše knjižnice.\",\n  \"MessageBackupsLocationEditNote\": \"Opomba: Posodabljanje lokacije varnostne kopije ne bo premaknilo ali spremenilo obstoječih varnostnih kopij\",\n  \"MessageBackupsLocationNoEditNote\": \"Opomba: Lokacija varnostne kopije je nastavljena s spremenljivko okolja in je tu ni mogoče spremeniti.\",\n  \"MessageBackupsLocationPathEmpty\": \"Pot do lokacije varnostne kopije ne sme biti prazna\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Napolni omogočena polja s podatki iz vseh elementov. Polja z več vrednostmi bodo združena\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Napolni omogočena polja s podrobnostmi zemljevida s podatki iz tega elementa\",\n  \"MessageBatchQuickMatchDescription\": \"Hitro ujemanje bo poskušal dodati manjkajoče naslovnice in metapodatke za izbrane elemente. Omogočite spodnje možnosti, da omogočite hitremu ujemanju, da prepiše obstoječe naslovnice in/ali metapodatke.\",\n  \"MessageBookshelfNoCollections\": \"Ustvaril nisi še nobene zbirke\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Zbirke so javne. Vsi uporabniki z dostopom do knjižnice jih lahko vidijo.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Noben vir RSS ni odprt\",\n  \"MessageBookshelfNoResultsForFilter\": \"Ni rezultatov za filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Ni rezultatov za poizvedbo\",\n  \"MessageBookshelfNoSeries\": \"Nimate serij\",\n  \"MessageBulkChapterPattern\": \"Koliko poglavij želite dodati s tem vzorcem oštevilčenja?\",\n  \"MessageChapterEndIsAfter\": \"Konec poglavja je po koncu zvočne knjige\",\n  \"MessageChapterErrorFirstNotZero\": \"Prvo poglavje se mora začeti pri 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Neveljaven začetni čas, mora biti krajši od trajanja zvočne knjige\",\n  \"MessageChapterErrorStartLtPrev\": \"Neveljaven začetni čas mora biti večji od ali enak začetnemu času prejšnjega poglavja\",\n  \"MessageChapterStartIsAfter\": \"Začetek poglavja je po koncu vaše zvočne knjige\",\n  \"MessageChaptersNotFound\": \"Poglavij ni bilo najdenih\",\n  \"MessageCheckingCron\": \"Preverjam cron...\",\n  \"MessageConfirmCloseFeed\": \"Ali ste prepričani, da želite zapreti ta vir?\",\n  \"MessageConfirmDeleteApiKey\": \"Ali ste prepričani, da želite izbrisati API ključ \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Ali ste prepričani, da želite izbrisati varnostno kopijo za {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Ali ste prepričani, da želite izbrisati e-bralnik \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"To bo izbrisalo datoteko iz vašega datotečnega sistema. Ali ste prepričani?\",\n  \"MessageConfirmDeleteLibrary\": \"Ali ste prepričani, da želite trajno izbrisati knjižnico \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"S tem boste element knjižnice izbrisali iz baze podatkov in vašega datotečnega sistema. Ste prepričani?\",\n  \"MessageConfirmDeleteLibraryItems\": \"To bo izbrisalo {0} elementov knjižnice iz baze podatkov in vašega datotečnega sistema. Ste prepričani?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Ali ste prepričani, da želite izbrisati ponudnika metapodatkov po meri \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Ali ste prepričani, da želite izbrisati to obvestilo?\",\n  \"MessageConfirmDeleteSession\": \"Ali ste prepričani, da želite izbrisati to sejo?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Ali ste prepričani, da želite vdelati metapodatke v {0} zvočnih datotek?\",\n  \"MessageConfirmForceReScan\": \"Ali ste prepričani, da želite vsiliti ponovno pregledovanje?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Ali ste prepričani, da želite označiti vse epizode kot dokončane?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Ali ste prepričani, da želite vse epizode označiti kot nedokončane?\",\n  \"MessageConfirmMarkItemFinished\": \"Ali ste prepričani, da želite \\\"{0}\\\" označiti kot dokončanega?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Ali ste prepričani, da želite \\\"{0}\\\" označiti kot nedokončanega?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Ali ste prepričani, da želite vse knjige v tej seriji označiti kot dokončane?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Ali ste prepričani, da želite vse knjige v tej seriji označiti kot nedokončane?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Želite sprožiti to obvestilo s testnimi podatki?\",\n  \"MessageConfirmPurgeCache\": \"Čiščenje predpomnilnika bo izbrisalo celoten imenik v <code>/metadata/cache</code>. <br /><br />Ali ste prepričani, da želite odstraniti imenik predpomnilnika?\",\n  \"MessageConfirmPurgeItemsCache\": \"Čiščenje predpomnilnika elementov bo izbrisalo celoten imenik na <code>/metadata/cache/items</code>.<br />Ste prepričani?\",\n  \"MessageConfirmQuickEmbed\": \"Opozorilo! Hitra vdelava ne bo varnostno kopirala vaših zvočnih datotek. Prepričajte se, da imate varnostno kopijo zvočnih datotek. <br><br>Ali želite nadaljevati?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Hitro ujemanja epizod bo prepisalo podrobnosti, če se najde ujemanje. Posodobljene bodo samo epizode, ki se ne ujemajo. Ste prepričani?\",\n  \"MessageConfirmReScanLibraryItems\": \"Ali ste prepričani, da želite ponovno pregledati {0} elementov?\",\n  \"MessageConfirmRemoveAllChapters\": \"Ali ste prepričani, da želite odstraniti vsa poglavja?\",\n  \"MessageConfirmRemoveAuthor\": \"Ali ste prepričani, da želite odstraniti avtorja \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Ali ste prepričani, da želite odstraniti zbirko \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Ali ste prepričani, da želite odstraniti epizodo \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \\\"Trdo brisanje datoteke\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Ali ste prepričani, da želite odstraniti {0} epizod?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Ali ste prepričani, da želite odstraniti {0} sej poslušanja?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?\",\n  \"MessageConfirmRemoveNarrator\": \"Ali ste prepričani, da želite odstraniti bralca \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Ali ste prepričani, da želite odstraniti svoj seznam predvajanja \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Ali ste prepričani, da želite preimenovati žanr \\\"{0}\\\" v \\\"{1}\\\" za vse elemente?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Opomba: Ta žanr že obstaja, zato bosta združeni.\",\n  \"MessageConfirmRenameGenreWarning\": \"Opozorilo! Podoben žanr z različnimi velikosti črk že obstaja \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Ali ste prepričani, da želite preimenovati oznako \\\"{0}\\\" v \\\"{1}\\\" za vse elemente?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Opomba: Ta oznaka že obstaja, zato bosta združeni.\",\n  \"MessageConfirmRenameTagWarning\": \"Opozorilo! Podobna oznaka z različnimi velikosti črk že obstaja \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Ali ste prepričani, da želite ponastaviti svoj napredek?\",\n  \"MessageConfirmSendEbookToDevice\": \"Ali ste prepričani, da želite poslati {0} e-knjigo \\\"{1}\\\" v napravo \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Ali ste prepričani, da želite prekiniti povezavo tega uporabnika z OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dni poslušanja v zadnjem letu\",\n  \"MessageDownloadingEpisode\": \"Prenašam epizodo\",\n  \"MessageDragFilesIntoTrackOrder\": \"Povlecite datoteke v pravilen vrstni red posnetkov\",\n  \"MessageEmbedFailed\": \"Vdelava ni uspela!\",\n  \"MessageEmbedFinished\": \"Vdelava končana!\",\n  \"MessageEmbedQueue\": \"V čakalni vrsta za vdelavo metapodatkov ({0} v čakalni vrsti)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} epizod v čakalni vrsti za prenos\",\n  \"MessageEreaderDevices\": \"Da zagotovite dostavo e-knjig, boste morda morali dodati zgornji e-poštni naslov kot veljavnega pošiljatelja za vsako spodaj navedeno napravo.\",\n  \"MessageFeedURLWillBe\": \"URL vira bo {0}\",\n  \"MessageFetching\": \"Pridobivam...\",\n  \"MessageForceReScanDescription\": \"bo znova pregledal vse datoteke kot pregled od začetka. Oznake ID3 zvočnih datotek, datoteke OPF in besedilne datoteke bodo pregledane kot nove.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} posluša</strong> na {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Ni sej poslušanj na {0}\",\n  \"MessageImportantNotice\": \"Pomembno obvestilo!\",\n  \"MessageInsertChapterBelow\": \"Spodaj vstavite poglavje\",\n  \"MessageInvalidAsin\": \"Neveljaven ASIN\",\n  \"MessageItemsSelected\": \"{0} izbranih elementov\",\n  \"MessageItemsUpdated\": \"Št. posodobljenih elementov: {0}\",\n  \"MessageJoinUsOn\": \"Pridružite se nam\",\n  \"MessageLoading\": \"Nalagam...\",\n  \"MessageLoadingFolders\": \"Nalagam mape...\",\n  \"MessageLogsDescription\": \"Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"M4B ni uspel!\",\n  \"MessageM4BFinished\": \"M4B končan!\",\n  \"MessageMapChapterTitles\": \"Preslikaj naslove poglavij v obstoječa poglavja zvočne knjige brez prilagajanja časovnih indentifikatorjev\",\n  \"MessageMarkAllEpisodesFinished\": \"Označi vse epizode kot končane\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Označi vse epizode kot nedokončane\",\n  \"MessageMarkAsFinished\": \"Označi kot dokončano\",\n  \"MessageMarkAsNotFinished\": \"Označi kot nedokončano\",\n  \"MessageMatchBooksDescription\": \"bo poskušalo povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.\",\n  \"MessageNoAudioTracks\": \"Ni zvočnih posnetkov\",\n  \"MessageNoAuthors\": \"Brez avtorjev\",\n  \"MessageNoBackups\": \"Brez varnostnih kopij\",\n  \"MessageNoBookmarks\": \"Brez zaznamkov\",\n  \"MessageNoChapters\": \"Brez poglavij\",\n  \"MessageNoCollections\": \"Brez zbirk\",\n  \"MessageNoCoversFound\": \"Ni naslovnic\",\n  \"MessageNoDescription\": \"Ni opisa\",\n  \"MessageNoDevices\": \"Ni naprav\",\n  \"MessageNoDownloadsInProgress\": \"Trenutno ni prenosov v teku\",\n  \"MessageNoDownloadsQueued\": \"Ni prenosov v čakalni vrsti\",\n  \"MessageNoEpisodeMatchesFound\": \"Ni zadetkov za epizodo\",\n  \"MessageNoEpisodes\": \"Ni epizod\",\n  \"MessageNoFoldersAvailable\": \"Ni na voljo nobene mape\",\n  \"MessageNoGenres\": \"Ni žanrov\",\n  \"MessageNoIssues\": \"Ni težav\",\n  \"MessageNoItems\": \"Ni elementov\",\n  \"MessageNoItemsFound\": \"Ni najdenih elementov\",\n  \"MessageNoListeningSessions\": \"Ni sej poslušanja\",\n  \"MessageNoLogs\": \"Ni dnevnikov\",\n  \"MessageNoMediaProgress\": \"Ni medijskega napredka\",\n  \"MessageNoNotifications\": \"Ni obvestil\",\n  \"MessageNoPodcastFeed\": \"Neveljaven podcast: Ni vira\",\n  \"MessageNoPodcastsFound\": \"Ni podcastov\",\n  \"MessageNoResults\": \"Ni rezultatov\",\n  \"MessageNoSearchResultsFor\": \"Ni rezultatov iskanja za \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Ni serij\",\n  \"MessageNoTags\": \"Ni oznak\",\n  \"MessageNoTasksRunning\": \"Nobeno opravili ne teče\",\n  \"MessageNoUpdatesWereNecessary\": \"Posodobitve niso bile potrebne\",\n  \"MessageNoUserPlaylists\": \"Nimate seznamov predvajanja\",\n  \"MessageNoUserPlaylistsHelp\": \"Seznami predvajanj so zasebni. Samo uporabniki, ki jih ustvarijo, jih lahko vidijo.\",\n  \"MessageNotYetImplemented\": \"Še ni implementirano\",\n  \"MessageOpmlPreviewNote\": \"Opomba: To je predogled razčlenjene datoteke OPML. Dejanski naslov podcasta bo vzet iz vira RSS.\",\n  \"MessageOr\": \"ali\",\n  \"MessagePauseChapter\": \"Začasno ustavite predvajanje poglavja\",\n  \"MessagePlayChapter\": \"Poslušajte začetek poglavja\",\n  \"MessagePlaylistCreateFromCollection\": \"Ustvari seznam predvajanja iz zbirke\",\n  \"MessagePleaseWait\": \"Prosim počakajte...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast nima URL-ja vira RSS, ki bi ga lahko uporabil za ujemanje\",\n  \"MessagePodcastSearchField\": \"Vnesite iskalni izraz ali URL vira RSS\",\n  \"MessageQuickEmbedInProgress\": \"Hitra vdelava je v teku\",\n  \"MessageQuickEmbedQueue\": \"V čakalni vrsti za hitro vdelavo ({0} v čakalni vrsti)\",\n  \"MessageQuickMatchAllEpisodes\": \"Hitro ujemanje vseh epizod\",\n  \"MessageQuickMatchDescription\": \"Izpolni prazne podrobnosti elementa in naslovnico s prvim rezultatom ujemanja iz '{0}'. Ne prepiše podrobnosti, razen če je omogočena nastavitev strežnika 'Prednostno ujemajoči se metapodatki'.\",\n  \"MessageRemoveChapter\": \"Odstrani poglavje\",\n  \"MessageRemoveEpisodes\": \"Odstrani toliko epizod: {0}\",\n  \"MessageRemoveFromPlayerQueue\": \"Odstrani iz čakalne vrste predvajalnika\",\n  \"MessageRemoveUserWarning\": \"Ali ste prepričani, da želite trajno izbrisati uporabnika \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Prijavite hrošče, zahtevajte nove funkcije in prispevajte še naprej\",\n  \"MessageResetChaptersConfirm\": \"Ali ste prepričani, da želite ponastaviti poglavja in razveljaviti spremembe, ki ste jih naredili?\",\n  \"MessageRestoreBackupConfirm\": \"Ali ste prepričani, da želite obnoviti varnostno kopijo, ustvarjeno ob\",\n  \"MessageRestoreBackupWarning\": \"Obnovitev varnostne kopije bo prepisala celotno zbirko podatkov, ki se nahaja v /config, in zajema slike v /metadata/items in /metadata/authors.<br /><br />Varnostne kopije ne spreminjajo nobenih datotek v mapah vaše knjižnice. Če ste omogočili nastavitve strežnika za shranjevanje naslovnic in metapodatkov v mapah vaše knjižnice, potem ti niso varnostno kopirani ali prepisani.<br /><br />Vsi odjemalci, ki uporabljajo vaš strežnik, bodo samodejno osveženi.\",\n  \"MessageScheduleLibraryScanNote\": \"Za večino uporabnikov je priporočljivo, da to funkcijo pustijo onemogočeno in ohranijo nastavitev »Samodejno spremljaj knjižnico za spremembe« omogočeno – samodejno bo zaznala spremembe v mapah vaše knjižnice. Omogočite to funkcijo, če »Samodejno spremljaj knjižnico za spremembe« ne deluje za vaš datotečni sistem (kot je NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Zaženi vsakih {0} ob {1}\",\n  \"MessageSearchResultsFor\": \"Rezultati iskanja za\",\n  \"MessageSelected\": \"{0} izbrano\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Zaporedje serij ne sme vsebovati presledkov\",\n  \"MessageServerCouldNotBeReached\": \"Strežnika ni bilo mogoče doseči\",\n  \"MessageSetChaptersFromTracksDescription\": \"Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke\",\n  \"MessageShareExpirationWillBe\": \"Potečeno bo <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Poteče čez {0}\",\n  \"MessageShareURLWillBe\": \"URL za skupno rabo bo <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Začni predvajanje za \\\"{0}\\\" ob {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Zvočna datoteka \\\"{0}\\\" ni zapisljiva\",\n  \"MessageTaskCanceledByUser\": \"Nalogo je preklical uporabnik\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Prenašanje epizode \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Vdelujem metapodatke\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Vdelujem metapodatke v zvočno knjigo \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Enkodiranje M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Enkodiranje zvočne knjige \\\"{0}\\\" v samo eno datoteko m4b\",\n  \"MessageTaskFailed\": \"Neuspešno\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Varnostno kopiranje zvočne datoteke \\\"{0}\\\" ni uspelo\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Imenika predpomnilnika ni bilo mogoče ustvariti\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Metapodatkov ni bilo mogoče vdelati v datoteko \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Zvočnih datotek ni bilo mogoče združiti\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Datoteke m4b ni bilo mogoče premakniti\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Metapodatke ni bilo mogoče zapisati v datoteke\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Prepoznavam knjige v knjižnici \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Ni datotek za pregledovanje\",\n  \"MessageTaskOpmlImport\": \"Uvoz OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Ustvarjanje podcastov iz {0} virov RSS\",\n  \"MessageTaskOpmlImportFeed\": \"Vir za uvoz OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Uvažanje vira RSS \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Vira podcasta ni bilo mogoče pridobiti\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Ustvarjanje podcasta \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast že obstaja na tej poti\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Podcasta ni bilo mogoče ustvariti\",\n  \"MessageTaskOpmlImportFinished\": \"Dodanih {0} podcastov\",\n  \"MessageTaskOpmlParseFailed\": \"Datoteke OPML ni bilo mogoče razčleniti\",\n  \"MessageTaskOpmlParseFastFail\": \"Neveljavna OPMPL datoteka, oznake <opml> ni bilo mogoče najti ALI oznake <outline> ni bilo mogoče najti\",\n  \"MessageTaskOpmlParseNoneFound\": \"V datoteki OPML ni virov\",\n  \"MessageTaskScanItemsAdded\": \"{0} dodano\",\n  \"MessageTaskScanItemsMissing\": \"{0} manjka\",\n  \"MessageTaskScanItemsUpdated\": \"{0} posodobljeno\",\n  \"MessageTaskScanNoChangesNeeded\": \"Spremembe niso potrebne\",\n  \"MessageTaskScanningFileChanges\": \"Pregledovanje sprememb v datoteki \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Pregledujem knjižnico \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Ciljni imenik ni zapisljiv\",\n  \"MessageThinking\": \"Razmišljam...\",\n  \"MessageUploaderItemFailed\": \"Nalaganje ni uspelo\",\n  \"MessageUploaderItemSuccess\": \"Uspešno naloženo!\",\n  \"MessageUploading\": \"Nalaganje...\",\n  \"MessageValidCronExpression\": \"Veljaven cron izraz\",\n  \"MessageWatcherIsDisabledGlobally\": \"Spremljanje sprememb datotek je globalno onemogočeno v nastavitvah strežnika\",\n  \"MessageXLibraryIsEmpty\": \"{0} Knjižnica je prazna!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Trajanje vaše zvočne knjige je daljše od ugotovljenega trajanja\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Trajanje vaše zvočne knjige je krajše od ugotovljenega trajanja\",\n  \"NoteChangeRootPassword\": \"Korenski uporabnik je edini uporabnik, ki ima lahko prazno geslo\",\n  \"NoteChapterEditorTimes\": \"Opomba: Začetni čas prvega poglavja mora ostati pri 0:00 in zadnji čas začetka poglavja ne sme preseči tega trajanja zvočne knjige.\",\n  \"NoteFolderPicker\": \"Opomba: že preslikane mape ne bodo prikazane\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Opozorilo: večina aplikacij za podcaste bo zahtevala, da URL vira RSS uporablja HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Opozorilo: 1 ali več vaših epizod nima datuma objave. Nekatere aplikacije za podcaste to zahtevajo.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mape z predstavnostnimi datotekami bodo obravnavane kot ločene postavke knjižnice.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Če nalagate samo zvočne datoteke, bo vsaka zvočna datoteka obravnavana kot ločena zvočna knjiga.\",\n  \"NoteUploaderUnsupportedFiles\": \"Nepodprte datoteke so prezrte. Ko izberete ali spustite mapo, se druge datoteke, ki niso v mapi elementov, prezrejo.\",\n  \"NotificationOnBackupCompletedDescription\": \"Sproži se, ko je varnostno kopiranje končano\",\n  \"NotificationOnBackupFailedDescription\": \"Sproži se, ko varnostno kopiranje ne uspe\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Sproži se, ko se epizoda podcasta samodejno prenese\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Sproži se, ko so samodejni prenosi epizod onemogočeni zaradi preveč neuspelih poskusov\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Sproži se, ko zahteva za vir RSS za samodejni prenos epizode ne uspe\",\n  \"NotificationOnTestDescription\": \"Dogodek za testiranje sistema obveščanja\",\n  \"PlaceholderBulkChapterInput\": \"Vnesite naslov poglavja ali uporabite oštevilčenje (npr. 'Epizoda 1', 'Poglavje 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Novo ime zbirke\",\n  \"PlaceholderNewFolderPath\": \"Pot nove mape\",\n  \"PlaceholderNewPlaylist\": \"Novo ime seznama predvajanja\",\n  \"PlaceholderSearch\": \"Poišči..\",\n  \"PlaceholderSearchEpisode\": \"Poišči epizodo...\",\n  \"StatsAuthorsAdded\": \"dodanih avtorjev\",\n  \"StatsBooksAdded\": \"dodanih knjig\",\n  \"StatsBooksAdditional\": \"Nekateri dodatki vključujejo…\",\n  \"StatsBooksFinished\": \"končanih knjig\",\n  \"StatsBooksFinishedThisYear\": \"Nekaj knjig, ki so bile dokončane letos…\",\n  \"StatsBooksListenedTo\": \"poslušanih knjig\",\n  \"StatsCollectionGrewTo\": \"Vaša zbirka knjig se je povečala na …\",\n  \"StatsSessions\": \"seje\",\n  \"StatsSpentListening\": \"porabil za poslušanje\",\n  \"StatsTopAuthor\": \"TOP AVTOR\",\n  \"StatsTopAuthors\": \"TOP AVTORJI\",\n  \"StatsTopGenre\": \"TOP ŽANR\",\n  \"StatsTopGenres\": \"TOP ŽANRI\",\n  \"StatsTopMonth\": \"TOP MESEC\",\n  \"StatsTopNarrator\": \"TOP BRALEC\",\n  \"StatsTopNarrators\": \"TOP BRALCI\",\n  \"StatsTotalDuration\": \"S skupnim trajanjem…\",\n  \"StatsYearInReview\": \"PREGLED LETA\",\n  \"ToastAccountUpdateSuccess\": \"Račun posodobljen\",\n  \"ToastAppriseUrlRequired\": \"Vnesti morate Apprise URL\",\n  \"ToastAsinRequired\": \"ASIN koda je obvezen podatek\",\n  \"ToastAuthorImageRemoveSuccess\": \"Slika avtorja je odstranjena\",\n  \"ToastAuthorNotFound\": \"Avtor \\\"{0}\\\" ni bil najden\",\n  \"ToastAuthorRemoveSuccess\": \"Avtor odstranjen\",\n  \"ToastAuthorSearchNotFound\": \"Ne najdem avtorja\",\n  \"ToastAuthorUpdateMerged\": \"Avtor združen\",\n  \"ToastAuthorUpdateSuccess\": \"Avtor posodobljen\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Avtor posodobljen (ne najdem slike)\",\n  \"ToastBackupAppliedSuccess\": \"Uporabljena varnostna kopija\",\n  \"ToastBackupCreateFailed\": \"Varnostne kopije ni bilo mogoče ustvariti\",\n  \"ToastBackupCreateSuccess\": \"Varnostna kopija ustvarjena\",\n  \"ToastBackupDeleteFailed\": \"Varnostne kopije ni bilo mogoče izbrisati\",\n  \"ToastBackupDeleteSuccess\": \"Varnostna kopija izbrisana\",\n  \"ToastBackupInvalidMaxKeep\": \"Neveljavno število varnostnih kopij za ohranjanje\",\n  \"ToastBackupInvalidMaxSize\": \"Neveljavna največja velikost varnostne kopije\",\n  \"ToastBackupRestoreFailed\": \"Varnostne kopije ni bilo mogoče obnoviti\",\n  \"ToastBackupUploadFailed\": \"Nalaganje varnostne kopije ni uspelo\",\n  \"ToastBackupUploadSuccess\": \"Varnostna kopija je naložena\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Podrobnosti, uporabljene za elemente\",\n  \"ToastBatchDeleteFailed\": \"Paketno brisanje ni uspelo\",\n  \"ToastBatchDeleteSuccess\": \"Paketno brisanje je bilo uspešno\",\n  \"ToastBatchQuickMatchFailed\": \"Paketno hitro ujemanje ni uspelo!\",\n  \"ToastBatchQuickMatchStarted\": \"Paketno hitro ujemanje {0} knjig se je začelo!\",\n  \"ToastBatchUpdateFailed\": \"Paketna posodobitev ni uspela\",\n  \"ToastBatchUpdateSuccess\": \"Paketna posodobitev je uspela\",\n  \"ToastBookmarkCreateFailed\": \"Zaznamka ni bilo mogoče ustvariti\",\n  \"ToastBookmarkCreateSuccess\": \"Zaznamek dodan\",\n  \"ToastBookmarkRemoveSuccess\": \"Zaznamek odstranjen\",\n  \"ToastBulkChapterInvalidCount\": \"Vnesite število med 1 in 150\",\n  \"ToastCachePurgeFailed\": \"Čiščenje predpomnilnika ni uspelo\",\n  \"ToastCachePurgeSuccess\": \"Predpomnilnik je bil uspešno očiščen\",\n  \"ToastChapterLocked\": \"Poglavje je zaklenjeno.\",\n  \"ToastChapterStartTimeAdjusted\": \"Začetni čas poglavja je bil prilagojen za {0} sekund\",\n  \"ToastChaptersAllLocked\": \"Vsa poglavja so zaklenjena. Odklenite nekatera poglavja, da premaknete njihove čase.\",\n  \"ToastChaptersHaveErrors\": \"Poglavja imajo napake\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Neveljavna vrednost zamika. Začetni čas zadnjega poglavja bi presegel trajanje te zvočne knjige.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Neveljavna vrednost zamika. Prvo poglavje bi imelo ničelno ali negativno dolžino in bi ga prepisalo drugo poglavje. Povečajte začetno trajanje drugega poglavja.\",\n  \"ToastChaptersMustHaveTitles\": \"Poglavja morajo imeti naslove\",\n  \"ToastChaptersRemoved\": \"Poglavja so odstranjena\",\n  \"ToastChaptersUpdated\": \"Poglavja so posodobljena\",\n  \"ToastCollectionItemsAddFailed\": \"Dodajanje elementov v zbirko ni uspelo\",\n  \"ToastCollectionRemoveSuccess\": \"Zbirka je bila odstranjena\",\n  \"ToastCollectionUpdateSuccess\": \"Zbirka je bila posodobljena\",\n  \"ToastConnectionNotAvailable\": \"Povezava ni na voljo. Poskusite znova pozneje\",\n  \"ToastCoverSearchFailed\": \"Iskanje naslovnice ni uspelo\",\n  \"ToastCoverUpdateFailed\": \"Posodobitev naslovnice ni uspela\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Datum in čas sta neveljavna ali nepopolna\",\n  \"ToastDeleteFileFailed\": \"Brisanje datoteke ni uspelo\",\n  \"ToastDeleteFileSuccess\": \"Datoteka je bila izbrisana\",\n  \"ToastDeviceAddFailed\": \"Naprave ni bilo mogoče dodati\",\n  \"ToastDeviceNameAlreadyExists\": \"Elektronska naprava s tem imenom že obstaja\",\n  \"ToastDeviceTestEmailFailed\": \"Pošiljanje testnega e-poštnega sporočila ni uspelo\",\n  \"ToastDeviceTestEmailSuccess\": \"Testno e-poštno sporočilo je poslano\",\n  \"ToastEmailSettingsUpdateSuccess\": \"E-poštne nastavitve so bile posodobljene\",\n  \"ToastEncodeCancelFailed\": \"Napaka pri preklicu prekodiranja\",\n  \"ToastEncodeCancelSucces\": \"Prekodiranje prekinjeno\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Čiščenje čakalne vrste ni uspelo\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Čakalna vrsta za prenos epizod je počiščena\",\n  \"ToastEpisodeUpdateSuccess\": \"Število posodobljenih epizod: {0}\",\n  \"ToastErrorCannotShare\": \"V tej napravi ni mogoče dati v skupno rabo\",\n  \"ToastFailedToCreate\": \"Ustvarjanje ni bilo uspešno\",\n  \"ToastFailedToDelete\": \"Brisanje ni bilo uspešno\",\n  \"ToastFailedToLoadData\": \"Podatkov ni bilo mogoče naložiti\",\n  \"ToastFailedToMatch\": \"Ujemanje ni uspelo\",\n  \"ToastFailedToShare\": \"Skupna raba ni uspela\",\n  \"ToastFailedToUpdate\": \"Napaka pri posodobitvi\",\n  \"ToastInvalidImageUrl\": \"Neveljaven URL slike\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Neveljavno največje število epizod za prenos\",\n  \"ToastInvalidUrl\": \"Neveljaven URL\",\n  \"ToastInvalidUrls\": \"Eden ali več URL-jev je neveljavnih\",\n  \"ToastItemCoverUpdateSuccess\": \"Naslovnica elementa je bila posodobljena\",\n  \"ToastItemDeletedFailed\": \"Elementa ni bilo mogoče izbrisati\",\n  \"ToastItemDeletedSuccess\": \"Element je bil izbrisan\",\n  \"ToastItemDetailsUpdateSuccess\": \"Podrobnosti elementa so bile posodobjene\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Označevanje kot dokončano ni uspelo\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Element je označen kot dokončan\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Ni bilo mogoče označiti kot nedokončano\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Element označen kot nedokončan\",\n  \"ToastItemUpdateSuccess\": \"Element je bil posodobljen\",\n  \"ToastLibraryCreateFailed\": \"Knjižnice ni bilo mogoče ustvariti\",\n  \"ToastLibraryCreateSuccess\": \"Knjižnica \\\"{0}\\\" je bila ustvarjena\",\n  \"ToastLibraryDeleteFailed\": \"Knjižnice ni bilo mogoče izbrisati\",\n  \"ToastLibraryDeleteSuccess\": \"Knjižnica je bila izbrisana\",\n  \"ToastLibraryScanFailedToStart\": \"Pregleda ni bilo mogoče začeti\",\n  \"ToastLibraryScanStarted\": \"Pregled knjižnice se je začel\",\n  \"ToastLibraryUpdateSuccess\": \"Knjižnica \\\"{0}\\\" je bila posodobljena\",\n  \"ToastMatchAllAuthorsFailed\": \"Ujemanje vseh avtorjev ni bilo uspešno\",\n  \"ToastMetadataFilesRemovedError\": \"Napaka pri odstranjevanju metapodatkov.{0} datotek\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Ni metapodatkov.{0} datotek, najdenih v knjižnici\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Ni metapodatkov.{0} datotek odstranjenih\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metapodatki.{1} datotek odstranjenih\",\n  \"ToastMustHaveAtLeastOnePath\": \"Imeti mora vsaj eno pot\",\n  \"ToastNameEmailRequired\": \"Ime in e-pošta sta obvezna\",\n  \"ToastNameRequired\": \"Ime je obvezno\",\n  \"ToastNewApiKeyUserError\": \"Morate izbrati uporabnika\",\n  \"ToastNewEpisodesFound\": \"Število najdenih novih epizod: {0}\",\n  \"ToastNewUserCreatedFailed\": \"Računa ni bilo mogoče ustvariti: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Nov račun je bil ustvarjen\",\n  \"ToastNewUserLibraryError\": \"Izbrati morate vsaj eno knjižnico\",\n  \"ToastNewUserPasswordError\": \"Mora imeti geslo, samo korenski uporabnik ima lahko prazno geslo\",\n  \"ToastNewUserTagError\": \"Izbrati morate vsaj eno oznako\",\n  \"ToastNewUserUsernameError\": \"Vnesite uporabniško ime\",\n  \"ToastNoNewEpisodesFound\": \"Ni novih epizod\",\n  \"ToastNoRSSFeed\": \"Podcast nima RSS vira\",\n  \"ToastNoUpdatesNecessary\": \"Posodobitve niso potrebne\",\n  \"ToastNotificationCreateFailed\": \"Obvestila ni bilo mogoče ustvariti\",\n  \"ToastNotificationDeleteFailed\": \"Brisanje obvestila ni uspelo\",\n  \"ToastNotificationFailedMaximum\": \"Največje število neuspelih poskusov mora biti >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Največja čakalna vrsta obvestil mora biti >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Nastavitve obvestil so bile posodobljene\",\n  \"ToastNotificationTestTriggerFailed\": \"Sprožitev testnega obvestila ni uspela\",\n  \"ToastNotificationTestTriggerSuccess\": \"Sproženo testno obvestilo\",\n  \"ToastNotificationUpdateSuccess\": \"Obvestilo posodobljeno\",\n  \"ToastPlaylistCreateFailed\": \"Seznama predvajanja ni bilo mogoče ustvariti\",\n  \"ToastPlaylistCreateSuccess\": \"Seznam predvajanja je bil ustvarjen\",\n  \"ToastPlaylistRemoveSuccess\": \"Seznam predvajanja odstranjen\",\n  \"ToastPlaylistUpdateSuccess\": \"Seznam predvajanja je bil posodobljen\",\n  \"ToastPodcastCreateFailed\": \"Podcasta ni bilo mogoče ustvariti\",\n  \"ToastPodcastCreateSuccess\": \"Podcast je bil uspešno ustvarjen\",\n  \"ToastPodcastEpisodeUpdated\": \"Epizoda je bila posodobljena\",\n  \"ToastPodcastGetFeedFailed\": \"Vira podcasta ni bilo mogoče pridobiti\",\n  \"ToastPodcastNoEpisodesInFeed\": \"V viru RSS ni bilo mogoče najti nobene epizode\",\n  \"ToastPodcastNoRssFeed\": \"Podcast nima vira RSS\",\n  \"ToastProgressIsNotBeingSynced\": \"Napredek se ne sinhronizira, znova zaženite predvajanje\",\n  \"ToastProviderCreatedFailed\": \"Ponudnika ni bilo mogoče dodati\",\n  \"ToastProviderCreatedSuccess\": \"Dodan je bil nov ponudnik\",\n  \"ToastProviderNameAndUrlRequired\": \"Obvezen podatek sta ime in URL\",\n  \"ToastProviderRemoveSuccess\": \"Ponudnik je bil odstranjen\",\n  \"ToastRSSFeedCloseFailed\": \"Vira RSS ni bilo mogoče zapreti\",\n  \"ToastRSSFeedCloseSuccess\": \"Vir RSS je bil zaprt\",\n  \"ToastRemoveFailed\": \"Odstranitev ni uspela\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Elementa ni bilo mogoče odstraniti iz zbirke\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Element je bil odstranjen iz zbirke\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Elementov knjižnice s težavami ni bilo mogoče odstraniti\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Odstranjeni so bili elementi knjižnice s težavami\",\n  \"ToastRenameFailed\": \"Preimenovanje ni uspelo\",\n  \"ToastRescanFailed\": \"Ponovni pregled ni uspel za {0}\",\n  \"ToastRescanRemoved\": \"Ponovni pregled celotnega elementa je bil odstranjen\",\n  \"ToastRescanUpToDate\": \"Ponovni pregled celotnega elementa je bil ažuren\",\n  \"ToastRescanUpdated\": \"Ponovni pregled celotnega elementa je bil posodobljen\",\n  \"ToastScanFailed\": \"Pregled elementa knjižnice ni uspel\",\n  \"ToastSelectAtLeastOneUser\": \"Izberite vsaj enega uporabnika\",\n  \"ToastSendEbookToDeviceFailed\": \"E-knjige ni bilo mogoče poslati v napravo\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-knjiga je bila poslana v napravo \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Dveh serij z istim imenom ni mogoče dodati\",\n  \"ToastSeriesUpdateFailed\": \"Posodobitev serije ni uspela\",\n  \"ToastSeriesUpdateSuccess\": \"Uspešna posodobitev serije\",\n  \"ToastServerSettingsUpdateSuccess\": \"Nastavitve strežnika so bile posodobljene\",\n  \"ToastSessionCloseFailed\": \"Seje ni bilo mogoče zapreti\",\n  \"ToastSessionDeleteFailed\": \"Brisanje seje ni uspelo\",\n  \"ToastSessionDeleteSuccess\": \"Seja je bila izbrisana\",\n  \"ToastSleepTimerDone\": \"Časovnik za spanje se je končal... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug vsebuje neveljavne znake\",\n  \"ToastSlugRequired\": \"Slug je obvezen podatek\",\n  \"ToastSocketConnected\": \"Omrežna povezava je priklopljena\",\n  \"ToastSocketDisconnected\": \"Omrežna povezava je odklopljena\",\n  \"ToastSocketFailedToConnect\": \"Omrežna povezava ni uspela vzpostaviti priklopa\",\n  \"ToastSortingPrefixesEmptyError\": \"Imeti mora vsaj 1 predpono za razvrščanje\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Predpone za razvrščanje so bile posodobljene ({0} elementov)\",\n  \"ToastTitleRequired\": \"Naslov je obvezen\",\n  \"ToastUnknownError\": \"Neznana napaka\",\n  \"ToastUnlinkOpenIdFailed\": \"Prekinitev povezave uporabnika z OpenID ni uspela\",\n  \"ToastUnlinkOpenIdSuccess\": \"Uporabnik je prekinil povezavo z OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Datoteka s potjo \\\"{0}\\\" že obstaja na strežniku\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Element \\\"{0}\\\" uporablja podmapo v poti za nalaganje.\",\n  \"ToastUserDeleteFailed\": \"Brisanje uporabnika ni uspelo\",\n  \"ToastUserDeleteSuccess\": \"Uporabnik je bil izbrisan\",\n  \"ToastUserPasswordChangeSuccess\": \"Geslo je bilo uspešno spremenjeno\",\n  \"ToastUserPasswordMismatch\": \"Gesli se ne ujemata\",\n  \"ToastUserPasswordMustChange\": \"Novo geslo se ne sme ujemati s starim geslom\",\n  \"ToastUserRootRequireName\": \"Vnesti morate korensko uporabniško ime\",\n  \"TooltipAddChapters\": \"Dodaj poglavje(-a)\",\n  \"TooltipAddOneSecond\": \"Dodaj 1 sekundo\",\n  \"TooltipAdjustChapterStart\": \"Kliknite za prilagoditev začetnega časa\",\n  \"TooltipLockAllChapters\": \"Zakleni vsa poglavja\",\n  \"TooltipLockChapter\": \"Zakleni poglavje (Shift+klik za obseg)\",\n  \"TooltipSubtractOneSecond\": \"Odštej 1 sekundo\",\n  \"TooltipUnlockAllChapters\": \"Odkleni vsa poglavja\",\n  \"TooltipUnlockChapter\": \"Odkleni poglavje (Shift+klik za obseg)\"\n}\n"
  },
  {
    "path": "client/strings/sv.json",
    "content": "{\n  \"ButtonAdd\": \"Lägg till\",\n  \"ButtonAddApiKey\": \"Lägg till API-nyckel\",\n  \"ButtonAddChapters\": \"Lägg till kapitel\",\n  \"ButtonAddDevice\": \"Lägg till enhet\",\n  \"ButtonAddLibrary\": \"Lägg till bibliotek\",\n  \"ButtonAddPodcasts\": \"Lägg till podcasts\",\n  \"ButtonAddUser\": \"Lägg till användare\",\n  \"ButtonAddYourFirstLibrary\": \"Lägg till ditt första bibliotek\",\n  \"ButtonApply\": \"Tillämpa\",\n  \"ButtonApplyChapters\": \"Tillämpa kapitel\",\n  \"ButtonAuthors\": \"Författare\",\n  \"ButtonBack\": \"Tillbaka\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Hämta befintlig information\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Addera befintliga information\",\n  \"ButtonBrowseForFolder\": \"Bläddra efter mapp\",\n  \"ButtonCancel\": \"Avbryt\",\n  \"ButtonCancelEncode\": \"Avbryt omkodning\",\n  \"ButtonChangeRootPassword\": \"Ändra lösenordet för root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Sök & Hämta nya avsnitt\",\n  \"ButtonChooseAFolder\": \"Välj en mapp\",\n  \"ButtonChooseFiles\": \"Välj filer\",\n  \"ButtonClearFilter\": \"Rensa filter\",\n  \"ButtonClose\": \"Stäng\",\n  \"ButtonCloseFeed\": \"Stäng flöde\",\n  \"ButtonCloseSession\": \"Stäng öppen session\",\n  \"ButtonCollections\": \"Samlingar\",\n  \"ButtonConfigureScanner\": \"Konfigurera skanner\",\n  \"ButtonCreate\": \"Skapa\",\n  \"ButtonCreateBackup\": \"Skapa säkerhetskopia\",\n  \"ButtonDelete\": \"Radera\",\n  \"ButtonDownloadQueue\": \"Kö\",\n  \"ButtonEdit\": \"Redigera\",\n  \"ButtonEditChapters\": \"Redigera kapitel\",\n  \"ButtonEditPodcast\": \"Redigera podcast\",\n  \"ButtonEnable\": \"Aktivera\",\n  \"ButtonFireAndFail\": \"Starta och Misslyckas\",\n  \"ButtonFireOnTest\": \"Starta onTest händelse\",\n  \"ButtonForceReScan\": \"Starta ny skanning\",\n  \"ButtonFullPath\": \"Fullständig sökväg\",\n  \"ButtonHide\": \"Dölj\",\n  \"ButtonHome\": \"Hem\",\n  \"ButtonIssues\": \"Objekt med problem\",\n  \"ButtonJumpBackward\": \"Hoppa bakåt\",\n  \"ButtonJumpForward\": \"Hoppa framåt\",\n  \"ButtonLatest\": \"Senaste\",\n  \"ButtonLibrary\": \"Bibliotek\",\n  \"ButtonLogout\": \"Logga ut\",\n  \"ButtonLookup\": \"Sök\",\n  \"ButtonManageTracks\": \"Hantera spår\",\n  \"ButtonMapChapterTitles\": \"Mappa kapitelrubriker\",\n  \"ButtonMatchAllAuthors\": \"Matcha alla författare\",\n  \"ButtonMatchBooks\": \"Matcha böcker\",\n  \"ButtonNevermind\": \"Glöm det\",\n  \"ButtonNext\": \"Nästa\",\n  \"ButtonNextChapter\": \"Nästa kapitel\",\n  \"ButtonNextItemInQueue\": \"Nästa objekt i Kö\",\n  \"ButtonOk\": \"OK\",\n  \"ButtonOpenFeed\": \"Öppna flöde\",\n  \"ButtonOpenManager\": \"Öppna Manager\",\n  \"ButtonPause\": \"Pausa\",\n  \"ButtonPlay\": \"Spela\",\n  \"ButtonPlayAll\": \"Spela alla\",\n  \"ButtonPlaying\": \"Spelar\",\n  \"ButtonPlaylists\": \"Spellistor\",\n  \"ButtonPrevious\": \"Föregående\",\n  \"ButtonPreviousChapter\": \"Föregående kapitel\",\n  \"ButtonProbeAudioFile\": \"Analysera ljudfil\",\n  \"ButtonPurgeAllCache\": \"Rensa all cache\",\n  \"ButtonPurgeItemsCache\": \"Rensa cache för föremål\",\n  \"ButtonQueueAddItem\": \"Lägg till i kön\",\n  \"ButtonQueueRemoveItem\": \"Ta bort från kön\",\n  \"ButtonQuickEmbed\": \"Infoga metadata\",\n  \"ButtonQuickEmbedMetadata\": \"Infoga metadata\",\n  \"ButtonQuickMatch\": \"Snabbmatchning\",\n  \"ButtonReScan\": \"Ny skanning\",\n  \"ButtonRead\": \"Läs\",\n  \"ButtonReadLess\": \"Visa mindre\",\n  \"ButtonReadMore\": \"Visa mer\",\n  \"ButtonRefresh\": \"Uppdatera\",\n  \"ButtonRemove\": \"Ta bort\",\n  \"ButtonRemoveAll\": \"Ta bort alla\",\n  \"ButtonRemoveAllLibraryItems\": \"Ta bort alla objekt i biblioteket\",\n  \"ButtonRemoveFromContinueListening\": \"Radera från 'Fortsätt att lyssna'\",\n  \"ButtonRemoveFromContinueReading\": \"Radera från 'Fortsätt att läsa'\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Radera från 'Fortsätt med serien'\",\n  \"ButtonReset\": \"Tillbaka\",\n  \"ButtonResetToDefault\": \"Återställ till standard\",\n  \"ButtonRestore\": \"Återställ\",\n  \"ButtonSave\": \"Spara\",\n  \"ButtonSaveAndClose\": \"Spara och stäng\",\n  \"ButtonSaveTracklist\": \"Spara ordningsföljden\",\n  \"ButtonScan\": \"Skanna\",\n  \"ButtonScanLibrary\": \"Skanna bibliotek\",\n  \"ButtonScrollLeft\": \"Scroll vänster\",\n  \"ButtonScrollRight\": \"Scrolla höger\",\n  \"ButtonSearch\": \"Sök\",\n  \"ButtonSelectFolderPath\": \"Välj mappens sökväg\",\n  \"ButtonSeries\": \"Serier\",\n  \"ButtonSetChaptersFromTracks\": \"Ställ in kapitel från spår\",\n  \"ButtonShare\": \"Dela\",\n  \"ButtonShiftTimes\": \"Förskjut tider\",\n  \"ButtonShow\": \"Visa\",\n  \"ButtonStartM4BEncode\": \"Starta M4B-omkodning\",\n  \"ButtonStartMetadataEmbed\": \"Infoga metadata\",\n  \"ButtonStats\": \"Statistik\",\n  \"ButtonSubmit\": \"Skicka\",\n  \"ButtonTest\": \"Testa\",\n  \"ButtonUnlinkOpenId\": \"Koppla ifrån OpenID\",\n  \"ButtonUpload\": \"Ladda upp\",\n  \"ButtonUploadBackup\": \"Läs in säkerhetskopia\",\n  \"ButtonUploadCover\": \"Ladda upp omslag\",\n  \"ButtonUploadOPMLFile\": \"Ladda upp OPML-fil\",\n  \"ButtonUserDelete\": \"Radera användare {0}\",\n  \"ButtonUserEdit\": \"Redigera användare {0}\",\n  \"ButtonViewAll\": \"Visa alla\",\n  \"ButtonYes\": \"Ja\",\n  \"ErrorUploadFetchMetadataAPI\": \"Fel vid hämtning av metadata\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Metadata kunde inte hämtas - försök att ändra titel och/eller författare\",\n  \"ErrorUploadLacksTitle\": \"En titel måste anges\",\n  \"HeaderAccount\": \"Konto\",\n  \"HeaderAddCustomMetadataProvider\": \"Addera egen källa för metadata\",\n  \"HeaderAdvanced\": \"Avancerad\",\n  \"HeaderApiKeys\": \"API-nyckel\",\n  \"HeaderAppriseNotificationSettings\": \"Inställningar av meddelanden med Apprise\",\n  \"HeaderAudioTracks\": \"Ljudspår\",\n  \"HeaderAudiobookTools\": \"Hantering av ljudboksfiler\",\n  \"HeaderAuthentication\": \"Autentisering\",\n  \"HeaderBackups\": \"Säkerhetskopior\",\n  \"HeaderBulkChapterModal\": \"Addera flera kapitel\",\n  \"HeaderChangePassword\": \"Ändra lösenord\",\n  \"HeaderChapters\": \"Kapitel\",\n  \"HeaderChooseAFolder\": \"Välj en mapp\",\n  \"HeaderCollection\": \"Samling\",\n  \"HeaderCollectionItems\": \"Böcker i samlingen\",\n  \"HeaderCover\": \"Omslag\",\n  \"HeaderCurrentDownloads\": \"Aktuella nedladdningar\",\n  \"HeaderCustomMessageOnLogin\": \"Meddelande att visa på sidan för inloggning\",\n  \"HeaderCustomMetadataProviders\": \"Egen källa för metadata\",\n  \"HeaderDetails\": \"Detaljer\",\n  \"HeaderDownloadQueue\": \"Nedladdningskö\",\n  \"HeaderEbookFiles\": \"E-boksfiler\",\n  \"HeaderEmail\": \"E-post\",\n  \"HeaderEmailSettings\": \"Inställningar för e-post\",\n  \"HeaderEpisodes\": \"Avsnitt\",\n  \"HeaderEreaderDevices\": \"Enheter för att läsa e-böcker\",\n  \"HeaderEreaderSettings\": \"E-boksinställningar\",\n  \"HeaderFiles\": \"Filer\",\n  \"HeaderFindChapters\": \"Hitta kapitel\",\n  \"HeaderIgnoredFiles\": \"Ignorerade filer\",\n  \"HeaderItemFiles\": \"Filer\",\n  \"HeaderItemMetadataUtils\": \"Metadataverktyg\",\n  \"HeaderLastListeningSession\": \"Senaste lyssningstillfället\",\n  \"HeaderLatestEpisodes\": \"Senaste avsnitten\",\n  \"HeaderLibraries\": \"Bibliotek\",\n  \"HeaderLibraryFiles\": \"Filer i biblioteket\",\n  \"HeaderLibraryStats\": \"Biblioteksstatistik\",\n  \"HeaderListeningSessions\": \"Lyssningstillfällen\",\n  \"HeaderListeningStats\": \"Lyssningsstatistik\",\n  \"HeaderLogin\": \"Logga in\",\n  \"HeaderLogs\": \"Loggning\",\n  \"HeaderManageGenres\": \"Hantera kategorier\",\n  \"HeaderManageTags\": \"Hantera taggar\",\n  \"HeaderMapDetails\": \"Gemensam information för samtliga objekt\",\n  \"HeaderMatch\": \"Matcha\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Prioriteringsordning vid inläsning av metadata\",\n  \"HeaderMetadataToEmbed\": \"Metadata som kommer att adderas\",\n  \"HeaderNewAccount\": \"Nytt konto\",\n  \"HeaderNewApiKey\": \"Ny API-nyckel\",\n  \"HeaderNewLibrary\": \"Nytt bibliotek\",\n  \"HeaderNotificationCreate\": \"Addera ett meddelande\",\n  \"HeaderNotificationUpdate\": \"Uppdateringsnotis\",\n  \"HeaderNotifications\": \"Meddelanden\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect Autentisering\",\n  \"HeaderOpenListeningSessions\": \"Öppna lyssningstillfällen\",\n  \"HeaderOpenRSSFeed\": \"Öppna RSS-flöde\",\n  \"HeaderOtherFiles\": \"Andra filer\",\n  \"HeaderPasswordAuthentication\": \"Lösenordsautentisering\",\n  \"HeaderPermissions\": \"Behörigheter\",\n  \"HeaderPlayerQueue\": \"Spellista\",\n  \"HeaderPlayerSettings\": \"Inställningar för uppspelning\",\n  \"HeaderPlaylist\": \"Spellista\",\n  \"HeaderPlaylistItems\": \"Böcker i spellistan\",\n  \"HeaderPodcastsToAdd\": \"Podcaster att lägga till\",\n  \"HeaderPresets\": \"Förinställningar\",\n  \"HeaderPreviewCover\": \"Förhandsgranska omslag\",\n  \"HeaderRSSFeedGeneral\": \"RSS-information\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-flödet är öppet\",\n  \"HeaderRSSFeeds\": \"RSS-flöden\",\n  \"HeaderRemoveEpisode\": \"Radera avsnitt\",\n  \"HeaderRemoveEpisodes\": \"Radera {0} avsnitt\",\n  \"HeaderSavedMediaProgress\": \"Sparad historik\",\n  \"HeaderSchedule\": \"Schema\",\n  \"HeaderScheduleEpisodeDownloads\": \"Schemalägg automatiska nedladdning av avsnitt\",\n  \"HeaderScheduleLibraryScans\": \"Schema för skanning av biblioteket\",\n  \"HeaderSession\": \"Tillfälle\",\n  \"HeaderSetBackupSchedule\": \"Ange schemaläggning för säkerhetskopia\",\n  \"HeaderSettings\": \"Inställningar\",\n  \"HeaderSettingsDisplay\": \"Visning\",\n  \"HeaderSettingsExperimental\": \"Experimentella funktioner\",\n  \"HeaderSettingsGeneral\": \"Allmänt\",\n  \"HeaderSettingsScanner\": \"Skanner\",\n  \"HeaderSettingsSecurity\": \"Säkerhet\",\n  \"HeaderSettingsWebClient\": \"Webklient\",\n  \"HeaderSleepTimer\": \"Insomningstimer\",\n  \"HeaderStatsLargestItems\": \"Största objekten\",\n  \"HeaderStatsLongestItems\": \"Längsta objekten (timmar)\",\n  \"HeaderStatsMinutesListeningChart\": \"Minuters lyssning (senaste 7 dagarna)\",\n  \"HeaderStatsRecentSessions\": \"Senaste tillfällena\",\n  \"HeaderStatsTop10Authors\": \"10 populäraste författarna\",\n  \"HeaderStatsTop5Genres\": \"5 populäraste kategorierna\",\n  \"HeaderTableOfContents\": \"Innehållsförteckning\",\n  \"HeaderTools\": \"Verktyg\",\n  \"HeaderUpdateAccount\": \"Uppdatera konto\",\n  \"HeaderUpdateApiKey\": \"Uppdatera API-nyckel\",\n  \"HeaderUpdateAuthor\": \"Uppdatera författare\",\n  \"HeaderUpdateDetails\": \"Uppdatera detaljer om boken\",\n  \"HeaderUpdateLibrary\": \"Uppdatera bibliotek\",\n  \"HeaderUsers\": \"Användare\",\n  \"HeaderYearReview\": \"Sammanställning av {0}\",\n  \"HeaderYourStats\": \"Din statistik\",\n  \"LabelAbridged\": \"Förkortad version\",\n  \"LabelAbridgedChecked\": \"Förkortad (kontrollerad)\",\n  \"LabelAbridgedUnchecked\": \"Oavkortad (okontrollerad)\",\n  \"LabelAccessibleBy\": \"Tillgänglig för\",\n  \"LabelAccountType\": \"Kontotyp\",\n  \"LabelAccountTypeAdmin\": \"Administratör\",\n  \"LabelAccountTypeGuest\": \"Gäst\",\n  \"LabelAccountTypeUser\": \"Användare\",\n  \"LabelActivities\": \"Aktiviteter\",\n  \"LabelActivity\": \"Aktivitet\",\n  \"LabelAddToCollection\": \"Lägg till i en samling\",\n  \"LabelAddToCollectionBatch\": \"Lägg till {0} böcker i samlingen\",\n  \"LabelAddToPlaylist\": \"Lägg till i spellista\",\n  \"LabelAddToPlaylistBatch\": \"Lägg till {0} objekt i Spellistan\",\n  \"LabelAddedAt\": \"Datum adderad\",\n  \"LabelAddedDate\": \"Tillagd {0}\",\n  \"LabelAdminUsersOnly\": \"Endast administratörer\",\n  \"LabelAll\": \"Alla\",\n  \"LabelAllEpisodesDownloaded\": \"Alla avsnitt är nedladdade\",\n  \"LabelAllUsers\": \"Alla användare\",\n  \"LabelAllUsersExcludingGuests\": \"Alla användare utom gäster\",\n  \"LabelAllUsersIncludingGuests\": \"Alla användare inklusive gäster\",\n  \"LabelAlreadyInYourLibrary\": \"Finns redan i samlingen\",\n  \"LabelApiKeyCreated\": \"API-nyckel \\\"{0}\\\" har adderats.\",\n  \"LabelApiKeyCreatedDescription\": \"Se till att kopiera API-nyckeln omedelbart eftersom du inte kommer att kunna se den igen.\",\n  \"LabelApiKeyUser\": \"Utför på uppdrag av användare\",\n  \"LabelApiKeyUserDescription\": \"Denna API-nyckel kommer att ha samma behörigheter som användaren den agerar på uppdrag av. Detta kommer att visas på samma sätt i loggarna som om användaren gjorde begäran.\",\n  \"LabelApiToken\": \"API-token\",\n  \"LabelAppend\": \"Lägg till\",\n  \"LabelAudioBitrate\": \"Bitrate (t.ex. 128k)\",\n  \"LabelAudioChannels\": \"Ljudkanaler (1 eller 2)\",\n  \"LabelAudioCodec\": \"Codec för ljud\",\n  \"LabelAuthor\": \"Författare\",\n  \"LabelAuthorFirstLast\": \"Författare (För-, Efternamn)\",\n  \"LabelAuthorLastFirst\": \"Författare (Efter-, Förnamn)\",\n  \"LabelAuthors\": \"Författare\",\n  \"LabelAutoDownloadEpisodes\": \"Automatisk nedladdning av avsnitt\",\n  \"LabelAutoFetchMetadata\": \"Automatisk nedladdning av metadata\",\n  \"LabelAutoFetchMetadataHelp\": \"Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.\",\n  \"LabelAutoLaunch\": \"Automatisk start\",\n  \"LabelAutoLaunchDescription\": \"Omdirigera till auth-leverantören automatiskt när du navigerar till inloggningssidan (manual override path <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Auto Register\",\n  \"LabelAutoRegisterDescription\": \"Skapa automatiskt nya användare efter inloggning\",\n  \"LabelBackToUser\": \"Tillbaka till användaren\",\n  \"LabelBackupAudioFiles\": \"Säkerhetskopiera ljudfiler\",\n  \"LabelBackupLocation\": \"Plats för säkerhetskopia\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Automatisk säkerhetskopiering\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Säkerhetskopior sparas i \\\"/metadata/backups\\\"\",\n  \"LabelBackupsMaxBackupSize\": \"Maximal storlek på säkerhetskopia i GigaByte (0 = obegränsad)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.\",\n  \"LabelBackupsNumberToKeep\": \"Antal säkerhetskopior att behålla\",\n  \"LabelBackupsNumberToKeepHelp\": \"Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna värdet bör du ta bort dem manuellt.\",\n  \"LabelBitrate\": \"Bitfrekvens\",\n  \"LabelBonus\": \"Bonusavsnitt\",\n  \"LabelBooks\": \"Böcker\",\n  \"LabelButtonText\": \"Knapptext\",\n  \"LabelByAuthor\": \"av {0}\",\n  \"LabelChangePassword\": \"Ändra lösenord\",\n  \"LabelChannels\": \"Kanaler\",\n  \"LabelChapterCount\": \"{0} kapitel\",\n  \"LabelChapterTitle\": \"Titel på kapitel\",\n  \"LabelChapters\": \"Kapitel\",\n  \"LabelChaptersFound\": \"hittade kapitel\",\n  \"LabelClickForMoreInfo\": \"Klicka för mer information\",\n  \"LabelClickToUseCurrentValue\": \"Klicka för att använda aktuellt värde\",\n  \"LabelClosePlayer\": \"Stäng spelaren\",\n  \"LabelCodec\": \"Codec\",\n  \"LabelCollapseSeries\": \"Komprimera serier\",\n  \"LabelCollapseSubSeries\": \"Komprimera underserier\",\n  \"LabelCollection\": \"Samling\",\n  \"LabelCollections\": \"Samlingar\",\n  \"LabelComplete\": \"Komplett\",\n  \"LabelConfirmPassword\": \"Bekräfta lösenord\",\n  \"LabelContinueListening\": \"Fortsätt att lyssna\",\n  \"LabelContinueReading\": \"Fortsätt att läsa\",\n  \"LabelContinueSeries\": \"Fortsätt med serien\",\n  \"LabelCorsAllowed\": \"Godkänd CORS Origins\",\n  \"LabelCover\": \"Omslag\",\n  \"LabelCoverImageURL\": \"URL till omslagsbild\",\n  \"LabelCoverProvider\": \"Källa för omslag\",\n  \"LabelCreatedAt\": \"Skapad\",\n  \"LabelCronExpression\": \"Schemaläggning med hjälp av Cron (Cron Expression)\",\n  \"LabelCurrent\": \"Nuvarande\",\n  \"LabelCurrently\": \"För närvarande:\",\n  \"LabelCustomCronExpression\": \"Anpassat Cron-uttryck:\",\n  \"LabelDatetime\": \"Datum och klockslag\",\n  \"LabelDays\": \"Dagar\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Ta även bort från filsystem (avmarkera = raderar endast från databasen)\",\n  \"LabelDescription\": \"Beskrivning\",\n  \"LabelDeselectAll\": \"Avmarkera alla\",\n  \"LabelDetectedPattern\": \"Identifierat mönster:\",\n  \"LabelDevice\": \"Enhet\",\n  \"LabelDeviceInfo\": \"Enhetsinformation\",\n  \"LabelDeviceIsAvailableTo\": \"Enhet är tillgänglig för...\",\n  \"LabelDirectory\": \"Katalog\",\n  \"LabelDiscFromFilename\": \"Skiva från filnamnet\",\n  \"LabelDiscFromMetadata\": \"Skiva från metadata\",\n  \"LabelDiscover\": \"Några förslag\",\n  \"LabelDownload\": \"Ladda ner\",\n  \"LabelDownloadNEpisodes\": \"Ladda ner {0} avsnitt\",\n  \"LabelDownloadable\": \"Nedladdningsbar\",\n  \"LabelDuration\": \"Längd\",\n  \"LabelDurationComparisonExactMatch\": \"(exakt matchning)\",\n  \"LabelDurationComparisonLonger\": \"({0} längre)\",\n  \"LabelDurationComparisonShorter\": \"({0} kortare)\",\n  \"LabelDurationFound\": \"Längd hittad:\",\n  \"LabelEbook\": \"E-bok\",\n  \"LabelEbooks\": \"E-böcker\",\n  \"LabelEdit\": \"Redigera\",\n  \"LabelEmail\": \"E-post\",\n  \"LabelEmailSettingsFromAddress\": \"Från e-postadress\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Avvisa icke-autentiserade certifikat\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Om valideringen av SSL-certifikat stängs av kan det exponera din anslutning för säkerhetsrisker, som attacker av typen 'man-in-the-middle'. Stäng endast av denna inställning om du förstår konsekvenserna och litar på den epostserver du är ansluten till.\",\n  \"LabelEmailSettingsSecure\": \"Säker\",\n  \"LabelEmailSettingsSecureHelp\": \"Om aktiverad kommer anslutningen att använda TLS vid anslutning till servern. Annars används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör detta alternativ vara aktiverat. För port 587 eller 25, bör det vara avstängt. (från nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"E-postadress för test\",\n  \"LabelEmbeddedCover\": \"Infogat omslag\",\n  \"LabelEnable\": \"Aktivera\",\n  \"LabelEncodingBackupLocation\": \"En säkerhetskopia av ljudfilerna kommer att placeras i katalogen:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Information om kapitel kommer inte att inkluderas i ljudböcker med flera ljudfiler.\",\n  \"LabelEncodingClearItemCache\": \"Kom ihåg att regelbundet rensa cachen för föremål. Du hittar funktionen längst ner på sidan 'Inställningar'.\",\n  \"LabelEncodingFinishedM4B\": \"Den färdiga M4B-filen kommer att placeras i katalogen:\",\n  \"LabelEncodingInfoEmbedded\": \"Metadata kommer att adderas i ljudfilerna i mappen med ljudboken.\",\n  \"LabelEncodingStartedNavigation\": \"När du startat uppgiften kan du lämna denna sida. Arbetet fortsätter i bakgrunden.\",\n  \"LabelEncodingTimeWarning\": \"Omkodningen kan ta upp till 30 minuter eller ännu längre för riktigt stora filer.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"VARNING: Ändra inte inställningarna om du inte är bekant med inställningarna för omkodning med 'ffmpeg'.\",\n  \"LabelEncodingWatcherDisabled\": \"Om den automatiska bevakningen av förändringar är avstängd behöver du göra en ny skanning av ljudboken efteråt.\",\n  \"LabelEnd\": \"Slut\",\n  \"LabelEndOfChapter\": \"Slut av kapitel\",\n  \"LabelEpisode\": \"Avsnitt\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Avsnittet är inte knutet till ett RSS-flöde\",\n  \"LabelEpisodeNumber\": \"Avsnitt #{0}\",\n  \"LabelEpisodeTitle\": \"Titel på avsnittet\",\n  \"LabelEpisodeType\": \"Typ av avsnitt\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL-adress till avsnittet i RSS-flödet\",\n  \"LabelEpisodes\": \"Avsnitt\",\n  \"LabelEpisodic\": \"Uppdelad i avsnitt\",\n  \"LabelExample\": \"Exempel\",\n  \"LabelExpandSeries\": \"Expandera serier\",\n  \"LabelExpandSubSeries\": \"Expandera Underserier\",\n  \"LabelExpired\": \"Upphört\",\n  \"LabelExpiresAt\": \"Gäller till och med\",\n  \"LabelExpiresInSeconds\": \"Upphör om (sekunder)\",\n  \"LabelExpiresNever\": \"Aldrig\",\n  \"LabelExplicit\": \"Vuxeninnehåll\",\n  \"LabelExplicitChecked\": \"Explicit version (markerad)\",\n  \"LabelExplicitUnchecked\": \"Ej Explicit version (ej markerad)\",\n  \"LabelExportOPML\": \"Exportera OPML-information\",\n  \"LabelFeedURL\": \"URL-adress för flödet\",\n  \"LabelFetchingMetadata\": \"Hämtar metadata\",\n  \"LabelFile\": \"Fil\",\n  \"LabelFileBirthtime\": \"Tidpunkt, tillagd\",\n  \"LabelFileBornDate\": \"Skapad {0}\",\n  \"LabelFileModified\": \"Tidpunkt, ändrad\",\n  \"LabelFileModifiedDate\": \"Ändrad {0}\",\n  \"LabelFilename\": \"Filnamn\",\n  \"LabelFilterByUser\": \"Välj användare\",\n  \"LabelFindEpisodes\": \"Sök avsnitt\",\n  \"LabelFinished\": \"Avslutad\",\n  \"LabelFinishedDate\": \"Avslutad {0}\",\n  \"LabelFolder\": \"Mapp\",\n  \"LabelFolders\": \"Mappar\",\n  \"LabelFontBold\": \"Fetstil\",\n  \"LabelFontBoldness\": \"Fetstil\",\n  \"LabelFontFamily\": \"Typsnittsfamilj\",\n  \"LabelFontItalic\": \"Kursiv\",\n  \"LabelFontScale\": \"Storlek på typsnitt\",\n  \"LabelFontStrikethrough\": \"Genomstruken\",\n  \"LabelFormat\": \"Format\",\n  \"LabelFull\": \"Komplett\",\n  \"LabelGenre\": \"Kategori\",\n  \"LabelGenres\": \"Kategorier\",\n  \"LabelHardDeleteFile\": \"Permanent radering av fil\",\n  \"LabelHasEbook\": \"Har e-bok\",\n  \"LabelHasSupplementaryEbook\": \"Har kompletterande e-bok\",\n  \"LabelHideSubtitles\": \"Dölj underrubriker\",\n  \"LabelHighestPriority\": \"Högst prioritet\",\n  \"LabelHost\": \"Värd\",\n  \"LabelHour\": \"Timme\",\n  \"LabelHours\": \"Timmar\",\n  \"LabelIcon\": \"Ikon\",\n  \"LabelImageURLFromTheWeb\": \"Skriv URL-adressen till bilden på webben\",\n  \"LabelInProgress\": \"Pågående\",\n  \"LabelIncludeInTracklist\": \"Inkludera i spårlista\",\n  \"LabelIncomplete\": \"Ofullständigt\",\n  \"LabelInterval\": \"Intervall\",\n  \"LabelIntervalCustomDailyWeekly\": \"Anpassad daglig/veckovis\",\n  \"LabelIntervalEvery12Hours\": \"Var 12:e timme\",\n  \"LabelIntervalEvery15Minutes\": \"Var 15:e minut\",\n  \"LabelIntervalEvery2Hours\": \"Varannan timme\",\n  \"LabelIntervalEvery30Minutes\": \"Var 30:e minut\",\n  \"LabelIntervalEvery6Hours\": \"Var 6:e timme\",\n  \"LabelIntervalEveryDay\": \"Varje dag\",\n  \"LabelIntervalEveryHour\": \"Varje timme\",\n  \"LabelIntervalEveryMinute\": \"Varje minut\",\n  \"LabelInvert\": \"Invertera\",\n  \"LabelItem\": \"Objekt\",\n  \"LabelJumpBackwardAmount\": \"Inställning för \\\"hopp bakåt\\\"\",\n  \"LabelJumpForwardAmount\": \"Inställning för \\\"hopp framåt\\\"\",\n  \"LabelLanguage\": \"Språk\",\n  \"LabelLanguageDefaultServer\": \"Standardspråk för server\",\n  \"LabelLanguages\": \"Språk\",\n  \"LabelLastBookAdded\": \"Bok senast adderad\",\n  \"LabelLastBookUpdated\": \"Bok senast uppdaterad\",\n  \"LabelLastProgressDate\": \"Senaste framsteg: {0}\",\n  \"LabelLastSeen\": \"Senast inloggad\",\n  \"LabelLastTime\": \"Senaste tillfället\",\n  \"LabelLastUpdate\": \"Senast uppdaterad\",\n  \"LabelLayout\": \"Layout\",\n  \"LabelLayoutSinglePage\": \"En sida\",\n  \"LabelLayoutSplitPage\": \"Uppslag\",\n  \"LabelLess\": \"Mindre\",\n  \"LabelLibrariesAccessibleToUser\": \"Bibliotek användaren har tillgång till\",\n  \"LabelLibrary\": \"Bibliotek\",\n  \"LabelLibraryFilterSublistEmpty\": \"Ingen {0}\",\n  \"LabelLibraryItem\": \"Objekt\",\n  \"LabelLibraryName\": \"Biblioteksnamn\",\n  \"LabelLibrarySortByProgress\": \"Status: Senast uppdaterad\",\n  \"LabelLibrarySortByProgressFinished\": \"Status: Avslutad\",\n  \"LabelLibrarySortByProgressStarted\": \"Status: Startad\",\n  \"LabelLimit\": \"Begränsning\",\n  \"LabelLineSpacing\": \"Radavstånd\",\n  \"LabelListenAgain\": \"Lyssna igen\",\n  \"LabelLogLevelDebug\": \"Felsökning\",\n  \"LabelLogLevelInfo\": \"Information\",\n  \"LabelLogLevelWarn\": \"Varningar\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Sök efter nya avsnitt efter detta datum\",\n  \"LabelLowestPriority\": \"Lägst prioritet\",\n  \"LabelMatchConfidence\": \"Förtroende\",\n  \"LabelMatchExistingUsersBy\": \"Matcha befintliga användare med\",\n  \"LabelMatchExistingUsersByDescription\": \"Används för att ansluta befintlig användare. När anslutningen sker kommer användaren att matchas med ett unikt ID från SSO-leverantören.\",\n  \"LabelMaxEpisodesToDownload\": \"Maximalt antal avsnitt att ladda ner (0 = obegränsat).\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Maximalt antal nya avsnitt att ladda ner per tillfälle\",\n  \"LabelMaxEpisodesToKeep\": \"Maximalt antal avsnitt att behålla\",\n  \"LabelMaxEpisodesToKeepHelp\": \"'0' innebär obegränsat antal avsnitt. Efter att nya avsnitt laddats ner raderas det äldsta avsnittet om du har mer än maximalt antal avsnitt. Endast ett avsnitt kommer att raderas per tillfälle.\",\n  \"LabelMediaPlayer\": \"Mediaspelare\",\n  \"LabelMediaType\": \"Mediatyp\",\n  \"LabelMetaTag\": \"Metadata\",\n  \"LabelMetaTags\": \"Metadata\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Källor för metadata med högre prioritet har företräde före källor med lägre prioritet\",\n  \"LabelMetadataProvider\": \"Källa för metadata\",\n  \"LabelMinute\": \"Minut\",\n  \"LabelMinutes\": \"Minuter\",\n  \"LabelMissing\": \"Saknar\",\n  \"LabelMissingEbook\": \"Saknar e-bok\",\n  \"LabelMissingSupplementaryEbook\": \"Saknar kompletterande e-bok\",\n  \"LabelMobileRedirectURIs\": \"Tillåtna mobila omdirigerings-URI:er\",\n  \"LabelMobileRedirectURIsDescription\": \"Detta är en vitlista över giltiga omdirigerings-URI:er för mobila appar. Standard är <code>audiobookshelf://oauth</code>, som du kan radera eller komplettera med ytterligare URI:er för integrering av tredje-parts appar. Används ett asterisk (<code>*</code>) som enda inmatning tillåts alla URI:er.\",\n  \"LabelMore\": \"Mer\",\n  \"LabelMoreInfo\": \"Mer information\",\n  \"LabelName\": \"Namn\",\n  \"LabelNarrator\": \"Uppläsare\",\n  \"LabelNarrators\": \"Uppläsare\",\n  \"LabelNew\": \"Nytt\",\n  \"LabelNewPassword\": \"Nytt lösenord\",\n  \"LabelNewestAuthors\": \"Senaste författarna\",\n  \"LabelNewestEpisodes\": \"Senaste avsnitten\",\n  \"LabelNextBackupDate\": \"Nästa tillfälle för säkerhetskopiering\",\n  \"LabelNextChapters\": \"Nästa kapitel kommer att vara:\",\n  \"LabelNextScheduledRun\": \"Nästa schemalagda körning\",\n  \"LabelNoApiKeys\": \"Ingen API-nyckel\",\n  \"LabelNoCustomMetadataProviders\": \"Ingen egen källa för metadata\",\n  \"LabelNoEpisodesSelected\": \"Inga avsnitt har valts\",\n  \"LabelNotFinished\": \"Ej avslutad\",\n  \"LabelNotStarted\": \"Ej påbörjad\",\n  \"LabelNotes\": \"Anteckningar\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL-adress(er)\",\n  \"LabelNotificationAvailableVariables\": \"Tillgängliga variabler\",\n  \"LabelNotificationBodyTemplate\": \"Kroppsmall\",\n  \"LabelNotificationEvent\": \"Händelser som skickar ett meddelande\",\n  \"LabelNotificationTitleTemplate\": \"Titelsmall\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Max antal misslyckade försök\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Aviseringar inaktiveras när de misslyckas med att skickas så många gånger\",\n  \"LabelNotificationsMaxQueueSize\": \"Max köstorlek för aviseringsevenemang\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.\",\n  \"LabelNumberOfBooks\": \"Antal böcker\",\n  \"LabelNumberOfChapters\": \"Antal kapitel:\",\n  \"LabelNumberOfEpisodes\": \"# av Avsnitt\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Namn på OpenID-anspråket som innehåller avancerade behörigheter för användaråtgärder i applikationen, vilka gäller för icke-administratörsroller (<b>om konfigurerat</b>). Om anspråket saknas i svaret kommer åtkomst till ABS att nekas. Om ett enskilt alternativ saknas kommer det att behandlas som <code>falskt</code>. Se till att identitetsleverantörens anspråk matchar den förväntade strukturen:\",\n  \"LabelOpenIDClaims\": \"Lämna följande alternativ tomma för att inaktivera avancerad grupp- och behörighetstilldelning, och tilldela då automatiskt gruppen 'Användare'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Namn på OpenID-anspråket som innehåller en lista över användarens grupper. Vanligtvis kallat <code>groups</code>. <b>Om det är konfigurerat</b> kommer programmet automatiskt att tilldela roller baserat på användarens gruppmedlemskap, förutsatt att dessa grupper namnges utan att skiftlägeskänsligt tolkas som 'admin', 'user' eller 'guest' i anspråket. Anspråket ska innehålla en lista, och om en användare tillhör flera grupper kommer programmet att tilldela den roll som motsvarar den högsta åtkomstnivån. Om ingen grupp matchar kommer åtkomst att nekas.\",\n  \"LabelOpenRSSFeed\": \"Öppna RSS-flöde\",\n  \"LabelOverwrite\": \"Skriv över\",\n  \"LabelPaginationPageXOfY\": \"Sida {0} av {1}\",\n  \"LabelPassword\": \"Lösenord\",\n  \"LabelPath\": \"Sökväg\",\n  \"LabelPermanent\": \"Permanent\",\n  \"LabelPermissionsAccessAllLibraries\": \"Kan komma åt alla bibliotek\",\n  \"LabelPermissionsAccessAllTags\": \"Kan komma åt alla taggar\",\n  \"LabelPermissionsAccessExplicitContent\": \"Kan komma åt explicit version\",\n  \"LabelPermissionsCreateEreader\": \"Kan addera e-läsarenhet\",\n  \"LabelPermissionsDelete\": \"Kan radera\",\n  \"LabelPermissionsDownload\": \"Kan ladda ner\",\n  \"LabelPermissionsUpdate\": \"Kan uppdatera\",\n  \"LabelPermissionsUpload\": \"Kan ladda upp\",\n  \"LabelPersonalYearReview\": \"En sammanställning av ditt år, sidan {0}\",\n  \"LabelPhotoPathURL\": \"Bildsökväg/URL\",\n  \"LabelPlayMethod\": \"Spelläge\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Uppspelningshastighetsökning/minskning\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} av {1}\",\n  \"LabelPlaylists\": \"Spellistor\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast-sökområde\",\n  \"LabelPodcastType\": \"Typ av postcast\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Prefix att ignorera (skiftlägesokänsligt)\",\n  \"LabelPreventIndexing\": \"Förhindra att ditt flöde indexeras av sökmotorer från iTunes och Google\",\n  \"LabelPrimaryEbook\": \"Primär e-bok\",\n  \"LabelProgress\": \"Framsteg\",\n  \"LabelProvider\": \"Källa\",\n  \"LabelProviderAuthorizationValue\": \"Värde för auktoriseringsheader\",\n  \"LabelPubDate\": \"Publiceringsdatum\",\n  \"LabelPublishYear\": \"Utgivningsår\",\n  \"LabelPublishedDate\": \"Publicerad {0}\",\n  \"LabelPublishedDecade\": \"Årtionde för publicering\",\n  \"LabelPublishedDecades\": \"Årtionde för publicering\",\n  \"LabelPublisher\": \"Utgivare\",\n  \"LabelPublishers\": \"Utgivare\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Anpassad ägarens e-post\",\n  \"LabelRSSFeedCustomOwnerName\": \"Anpassat ägarnamn\",\n  \"LabelRSSFeedOpen\": \"RSS-flöde öppet\",\n  \"LabelRSSFeedPreventIndexing\": \"Förhindra indexering\",\n  \"LabelRSSFeedSlug\": \"RSS-flödesslag\",\n  \"LabelRSSFeedURL\": \"URL-adress för RSS-flödet\",\n  \"LabelRandomly\": \"Slumpartat\",\n  \"LabelReAddSeriesToContinueListening\": \"Addera serien på nytt till 'Fortsätt att lyssna'\",\n  \"LabelRead\": \"Läs\",\n  \"LabelReadAgain\": \"Läs igen\",\n  \"LabelReadEbookWithoutProgress\": \"Läs e-bok utan att behålla framsteg\",\n  \"LabelRecentSeries\": \"Senaste serierna\",\n  \"LabelRecentlyAdded\": \"Nyligen adderade\",\n  \"LabelRecommended\": \"Rekommenderad\",\n  \"LabelRedo\": \"Gör om\",\n  \"LabelRegion\": \"Region\",\n  \"LabelReleaseDate\": \"Utgivningsdatum\",\n  \"LabelRemoveAllMetadataAbs\": \"Radera alla 'metadata.abs' filer\",\n  \"LabelRemoveAllMetadataJson\": \"Radera alla 'metadata.json' filer\",\n  \"LabelRemoveAudibleBranding\": \"Ta bort Audible intro och outro från kapitel\",\n  \"LabelRemoveCover\": \"Ta bort omslag\",\n  \"LabelRemoveMetadataFile\": \"Radera metadata-filer i alla mappar i biblioteket\",\n  \"LabelRemoveMetadataFileHelp\": \"Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.\",\n  \"LabelRowsPerPage\": \"Antal rader per sida\",\n  \"LabelSearchTerm\": \"Sökbegrepp\",\n  \"LabelSearchTitle\": \"Titel\",\n  \"LabelSearchTitleOrASIN\": \"Sök titel eller ASIN-kod\",\n  \"LabelSeason\": \"Säsong\",\n  \"LabelSeasonNumber\": \"Säsong #{0}\",\n  \"LabelSelectAll\": \"Välj alla\",\n  \"LabelSelectAllEpisodes\": \"Välj alla avsnitt\",\n  \"LabelSelectEpisodesShowing\": \"Välj {0} avsnitt som visas\",\n  \"LabelSelectUser\": \"Välj användare\",\n  \"LabelSelectUsers\": \"Välj användare\",\n  \"LabelSendEbookToDevice\": \"Skicka e-bok till...\",\n  \"LabelSequence\": \"Ordningsnummer\",\n  \"LabelSerial\": \"Seriell\",\n  \"LabelSeries\": \"Serier\",\n  \"LabelSeriesName\": \"Serienamn\",\n  \"LabelSeriesProgress\": \"Status för serier\",\n  \"LabelServerLogLevel\": \"Nivå på loggning\",\n  \"LabelServerYearReview\": \"En sammanställning av ditt bibliotek, sidan {0}\",\n  \"LabelSetEbookAsPrimary\": \"Ange som primär fil\",\n  \"LabelSetEbookAsSupplementary\": \"Ange som kompletterande\",\n  \"LabelSettingsAllowIframe\": \"Tillåt att Audiobookshelf får visas i en iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Endast ljudböcker\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"När detta alternativ aktiveras kommer filer med e-böcker<br>att ignoreras om de inte lagras i en mapp med en ljudbok.<br>I det fallet kommer de att anges som en kompletterande e-bok\",\n  \"LabelSettingsBookshelfViewHelp\": \"Bakgrund med ett utseende liknande en bokhylla i trä\",\n  \"LabelSettingsChromecastSupport\": \"Stöd för Chromecast\",\n  \"LabelSettingsDateFormat\": \"Datumformat\",\n  \"LabelSettingsEnableWatcher\": \"Bevaka automatiskt förändringar i biblioteken\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Bevaka biblioteket automatiskt efter ändringar\",\n  \"LabelSettingsEnableWatcherHelp\": \"Aktiverar automatik att upptäcka när objekt<br>adderas, uppdateras eller raderas.<br>OBS: Kräver en omstart av servern\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Tillåt e-böcker i epubs-format som innehåller script\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Tillåt att epub-filer får innehålla script.<br>Det rekommenderas att denna inställning är<br>avstängd när du inte litar på källan för epub-filerna.\",\n  \"LabelSettingsExperimentalFeatures\": \"Experimentella funktioner\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.\",\n  \"LabelSettingsFindCovers\": \"Hitta ett omslag\",\n  \"LabelSettingsFindCoversHelp\": \"Om din bok INTE har ett omslag inkluderat i filen eller en fil med omslaget i mappen kommer skannern att försöka hitta ett omslag.<br>OBS: Detta kommer att förlänga inläsningstiden\",\n  \"LabelSettingsHideSingleBookSeries\": \"Dölj serier som endast innehåller en bok\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Serier som endast har en bok kommer att<br>döljas från sidan 'Serier' och hyllorna på startsidan.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Använd vy liknande en bokhylla på startsidan\",\n  \"LabelSettingsLibraryBookshelfView\": \"Använd vy liknande en bokhylla i biblioteket\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Procent genomfört är större än\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Återstående tid är mindre än (sekunder)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Markera objekt som avslutade när\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Hoppa över tidigare böcker i en serie\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Sektionen 'Fortsätt med serien' på startsidan visar <br>\\\"nästa bok\\\" i serien, där åtminstone en bok avslutats,<br>och ingen bok i serien har påbörjats.<br><br>Om detta alternativ aktiveras kommer efterföljande bok<br>till den avslutade att föreslås - istället<br>för den första ej avslutade boken i serien.\",\n  \"LabelSettingsParseSubtitles\": \"Hämta undertitel från bokens mapp\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Hämtar undertiteln från namnet<br> på mappen där boken lagras.<br>Undertiteln måste vara åtskilda med ett bindestreck ' - '.<br>En mapp med namnet 'Boktitel - Bokens undertitel'<br> får undertiteln \\\"Bokens undertitel\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Prioritera matchad metadata\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Matchad data kommer att ersätta befintliga uppgifter vid en snabbmatchning. Som standard kommer en snabbmatchning endast att fylla i saknade detaljer.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Hoppa över matchande böcker med ASIN-kod\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Hoppa över matchande böcker med ISBN-kod\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ignorera prefix vid sortering\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"För prefix som t.ex. \\\"the\\\" kommer boktiteln \\\"The Book Title\\\" att sorteras som \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Använd kvadratiska omslag\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Föredrar att använda kvadratiska omslag<br>före standardformatet 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Lagra omslag med objektet\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Som standard lagras omslag i mappen '/metadata/items'.<br>Genom att aktivera detta alternativ kommer<br>omslagen att lagra i din biblioteksmapp.<br>Endast en fil med namnet 'cover' kommer att behållas\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Lagra metadata med objektet\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp\",\n  \"LabelSettingsTimeFormat\": \"Tidsformat\",\n  \"LabelShare\": \"Dela\",\n  \"LabelShareDownloadableHelp\": \"Tillåt att användare som fått en delad länk att ladda ner ett komprimerat objekt från biblioteket.\",\n  \"LabelShareOpen\": \"Delning öppet\",\n  \"LabelShareURL\": \"Dela URL-länk\",\n  \"LabelShowAll\": \"Visa alla\",\n  \"LabelShowSeconds\": \"Visa i sekunder\",\n  \"LabelShowSubtitles\": \"Visa underrubriker\",\n  \"LabelSize\": \"Storlek\",\n  \"LabelSleepTimer\": \"Insomningstimer\",\n  \"LabelSlug\": \"Kortnamn\",\n  \"LabelSortAscending\": \"Stigande\",\n  \"LabelSortDescending\": \"Fallande\",\n  \"LabelSortPubDate\": \"Sortera efter publiceringsdatum\",\n  \"LabelStart\": \"Start\",\n  \"LabelStartTime\": \"Starttid\",\n  \"LabelStarted\": \"Startad\",\n  \"LabelStartedAt\": \"Startades\",\n  \"LabelStartedDate\": \"Påbörjad {0}\",\n  \"LabelStatsAudioTracks\": \"Ljudfiler\",\n  \"LabelStatsAuthors\": \"Författare\",\n  \"LabelStatsBestDay\": \"Bästa dag\",\n  \"LabelStatsDailyAverage\": \"Dagligt genomsnitt\",\n  \"LabelStatsDays\": \"Dagar\",\n  \"LabelStatsDaysListened\": \"dagars lyssnande\",\n  \"LabelStatsHours\": \"Timmar\",\n  \"LabelStatsInARow\": \"i rad\",\n  \"LabelStatsItemsFinished\": \"böcker avslutade\",\n  \"LabelStatsItemsInLibrary\": \"Objekt i biblioteket\",\n  \"LabelStatsMinutes\": \"minuter\",\n  \"LabelStatsMinutesListening\": \"minuters lyssnande\",\n  \"LabelStatsOverallDays\": \"Totalt antal dagar\",\n  \"LabelStatsOverallHours\": \"Totalt antal timmar\",\n  \"LabelStatsWeekListening\": \"Veckans lyssnande\",\n  \"LabelSubtitle\": \"Underrubrik\",\n  \"LabelSupportedFileTypes\": \"Filtyper som accepteras\",\n  \"LabelTag\": \"Tagg\",\n  \"LabelTags\": \"Taggar\",\n  \"LabelTagsAccessibleToUser\": \"Taggar användaren har tillgång till\",\n  \"LabelTagsNotAccessibleToUser\": \"Taggar inte tillgängliga för användaren\",\n  \"LabelTasks\": \"Pågående aktivitet\",\n  \"LabelTextEditorBulletedList\": \"Punktlista\",\n  \"LabelTextEditorLink\": \"Länk\",\n  \"LabelTextEditorNumberedList\": \"Numrerad lista\",\n  \"LabelTextEditorUnlink\": \"Radera länk\",\n  \"LabelTheme\": \"Utseende\",\n  \"LabelThemeDark\": \"Mörkt\",\n  \"LabelThemeLight\": \"Ljust\",\n  \"LabelThemeSepia\": \"Sepia\",\n  \"LabelTimeBase\": \"Tidsbas\",\n  \"LabelTimeDurationXHours\": \"{0} timmar\",\n  \"LabelTimeDurationXMinutes\": \"{0} minuter\",\n  \"LabelTimeDurationXSeconds\": \"{0} sekunder\",\n  \"LabelTimeInMinutes\": \"Tid i minuter\",\n  \"LabelTimeLeft\": \"{0} återstår\",\n  \"LabelTimeListened\": \"Tid lyssnad\",\n  \"LabelTimeListenedToday\": \"Tid lyssnad idag\",\n  \"LabelTimeRemaining\": \"{0} återstår\",\n  \"LabelTimeToShift\": \"Tid att skifta i sekunder\",\n  \"LabelTitle\": \"Titel\",\n  \"LabelToolsEmbedMetadata\": \"Infoga metadata\",\n  \"LabelToolsEmbedMetadataDescription\": \"Infoga metadata i ljudfiler, inklusive omslagsbild och kapitel.\",\n  \"LabelToolsM4bEncoder\": \"Omkodning av M4B-fil\",\n  \"LabelToolsMakeM4b\": \"Skapa ljudboksfil i M4B-format\",\n  \"LabelToolsMakeM4bDescription\": \"Skapa en ljudboksfil i M4B-format som inkluderar metadata, omslagsbild och kapitel.\",\n  \"LabelToolsSplitM4b\": \"Dela upp M4B-fil i MP3-filer\",\n  \"LabelToolsSplitM4bDescription\": \"Skapa MP3-filer från en M4B-fil uppdelad i kapitel som inkluderar metadata, omslagsbild och kapitel.\",\n  \"LabelTotalDuration\": \"Total längd\",\n  \"LabelTotalTimeListened\": \"Total tid lyssnad\",\n  \"LabelTrackFromFilename\": \"Plats från filnamnet\",\n  \"LabelTrackFromMetadata\": \"Plats från metadata\",\n  \"LabelTracks\": \"Ljudspår\",\n  \"LabelTracksMultiTrack\": \"Flera ljudspår\",\n  \"LabelTracksNone\": \"Inga ljudspår\",\n  \"LabelTracksSingleTrack\": \"Ett ljudspår\",\n  \"LabelTrailer\": \"Trailer\",\n  \"LabelType\": \"Typ\",\n  \"LabelUnabridged\": \"Oavkortad\",\n  \"LabelUndo\": \"Ångra\",\n  \"LabelUnknown\": \"Okänd\",\n  \"LabelUnknownPublishDate\": \"Okänt publiceringsdatum\",\n  \"LabelUpdateCover\": \"Uppdatera omslag\",\n  \"LabelUpdateCoverHelp\": \"Tillåt att befintliga omslag för de valda böckerna ersätts när en matchning hittas\",\n  \"LabelUpdateDetails\": \"Uppdatera detaljer\",\n  \"LabelUpdateDetailsHelp\": \"Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas\",\n  \"LabelUpdatedAt\": \"Uppdaterades\",\n  \"LabelUploaderDragAndDrop\": \"Dra och släpp filer eller mappar\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Dra & släpp filer\",\n  \"LabelUploaderDropFiles\": \"Släpp filer\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Hämtar automatiskt titel, författare och serier\",\n  \"LabelUseAdvancedOptions\": \"Använd avancerade inställningar\",\n  \"LabelUseChapterTrack\": \"Använd kapitelspår\",\n  \"LabelUseFullTrack\": \"Använd hela spåret\",\n  \"LabelUseZeroForUnlimited\": \"0 = Obegränsad\",\n  \"LabelUser\": \"Användare\",\n  \"LabelUsername\": \"Användarnamn\",\n  \"LabelValue\": \"Värde\",\n  \"LabelVersion\": \"Version\",\n  \"LabelViewBookmarks\": \"Bokmärken\",\n  \"LabelViewChapters\": \"Visa kapitel\",\n  \"LabelViewPlayerSettings\": \"Visa inställningar för uppspelning\",\n  \"LabelViewQueue\": \"Visa spellista\",\n  \"LabelVolume\": \"Volym\",\n  \"LabelWebRedirectURLsDescription\": \"Auktorisera dessa URLer i din OAuth-leverantör för att tillåta omdirigering tillbaka till webbappen efter inloggning:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Undermappar för omdirigeringslänkar\",\n  \"LabelWeekdaysToRun\": \"Veckodagar att köra skanning\",\n  \"LabelXBooks\": \"{0} böcker\",\n  \"LabelXItems\": \"{0} objekt\",\n  \"LabelYearReviewHide\": \"Dölj årets sammanställning\",\n  \"LabelYearReviewShow\": \"Visa årets sammanställning\",\n  \"LabelYourAudiobookDuration\": \"Din ljudboks längd\",\n  \"LabelYourBookmarks\": \"Dina bokmärken\",\n  \"LabelYourPlaylists\": \"Dina spellistor\",\n  \"LabelYourProgress\": \"Framsteg\",\n  \"MessageAddToPlayerQueue\": \"Lägg till i spellistan\",\n  \"MessageAppriseDescription\": \"För att använda den här funktionen behöver du ha en instans av <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> startad eller ett API som hanterar dessa förfrågningar. <br />URL-adressen till Apprise API bör vara hela sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Säkerställ att du använder ASIN-kod för rätt region/område.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Legacy API-koder kommer att raderas i framtiden. Använd denna istället: <a href=\\\"/config/api-keys\\\">API Keys</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Du måste starta om servern efter att du ändrat eller adderat OIDC (OpenID Connect).\",\n  \"MessageAuthenticationSecurityMessage\": \"Identifieringen av användare har förbättrats av säkerhetsskäl. Alla användare måste därför logga in på nytt.\",\n  \"MessageBackupsDescription\": \"Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,<br>serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.\",\n  \"MessageBackupsLocationEditNote\": \"OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit\",\n  \"MessageBackupsLocationNoEditNote\": \"OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.\",\n  \"MessageBackupsLocationPathEmpty\": \"Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Adderar information från alla objekt nedan i de fält som aktiverats. Om fälten innehåller olika uppgifter kommer informationen att slås samman.\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Addera information från detta objekt i aktiva fält ovan\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.\",\n  \"MessageBookshelfNoCollections\": \"Du har ännu inte skapat några samlingar\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Samlingar är privata. Endast den användare som skapat en samling kan se den.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Inga RSS-flöden är öppna\",\n  \"MessageBookshelfNoResultsForFilter\": \"Inga resultat för filter \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Sökningen gav inget resultat\",\n  \"MessageBookshelfNoSeries\": \"Du har inga serier\",\n  \"MessageBulkChapterPattern\": \"Hur många kapitel vill du lägga till med detta numreringsmönster?\",\n  \"MessageChapterEndIsAfter\": \"Kapitelns slut är efter din ljudboks slut\",\n  \"MessageChapterErrorFirstNotZero\": \"Första kapitlet måste börja vid 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Ogiltig starttid, måste vara mindre än ljudbokens längd\",\n  \"MessageChapterErrorStartLtPrev\": \"Ogiltig starttid, måste vara större än eller lika med föregående kapitlets starttid\",\n  \"MessageChapterStartIsAfter\": \"Kapitlets start är efter din ljudboks slut\",\n  \"MessageChaptersNotFound\": \"Inga kapitel kunde hittas\",\n  \"MessageCheckingCron\": \"Kontrollerar cron...\",\n  \"MessageConfirmCloseFeed\": \"Är du säker på att du vill stänga detta flöde?\",\n  \"MessageConfirmDeleteApiKey\": \"Är du säker på att du vill radera API-nyckel \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Är du säker på att du vill radera säkerhetskopian för {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Är du säkert på att du vill radera enheten för e-böcker \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Detta kommer att radera filen från ditt filsystem. Är du säker?\",\n  \"MessageConfirmDeleteLibrary\": \"Är du säker på att du vill radera biblioteket '{0}'?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Detta kommer att radera objektet från databasen och ditt filsystem. Är du säker?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Detta kommer att radera {0} biblioteksobjekt från databasen och ditt filsystem. Är du säker?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Är du säker på att du vill radera din egen källa för metadata \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Är du säker på att du vill radera detta meddelande?\",\n  \"MessageConfirmDeleteSession\": \"Är du säker på att du vill radera detta lyssningstillfälle?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Är du säker på att du vill infoga metadata i {0} ljudfiler?\",\n  \"MessageConfirmForceReScan\": \"Är du säker på att du vill starta en ny skanning?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Är du säker på att du vill markera alla avsnitt som avslutade?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Är du säker på att du vill markera alla avsnitt som ej avslutade?\",\n  \"MessageConfirmMarkItemFinished\": \"Är du säker på att du vill markera \\\"{0}\\\" som avslutad?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Är du säker på att du vill markera \\\"{0}\\\" som ej avslutad?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Är du säker på att du vill markera alla böcker i denna serie som avslutade?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Trigga denna avisering med testdata?\",\n  \"MessageConfirmPurgeCache\": \"När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?\",\n  \"MessageConfirmPurgeItemsCache\": \"När du rensar cashen för objekten kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?\",\n  \"MessageConfirmQuickEmbed\": \"VARNING! När du infogar metadata i dina ljudfiler kommer INGEN SÄKERHETSKOPIA av filerna att göras. Se därför till att först säkerhetskopiera ljudfilerna. <br><br>Vill du fortsätta?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Snabbmatchning av avsnitt kommer att ersätta befintlig information vid en träff. Endast omatchade avsnitt kommer att uppdateras. Vill du fortsätta?\",\n  \"MessageConfirmReScanLibraryItems\": \"Är du säker på att du vill göra en ny skanning för {0} objekt?\",\n  \"MessageConfirmRemoveAllChapters\": \"Är du säker på att du vill ta bort alla kapitel?\",\n  \"MessageConfirmRemoveAuthor\": \"Är du säker på att du vill ta bort författaren \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Är du säker på att du vill ta bort samlingen \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Är du säker på att du vill radera avsnittet \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"OBSERERA: Detta tar inte bort ljudfilen om inte \\\"Permanent radering av fil\\\" är aktiverad\",\n  \"MessageConfirmRemoveEpisodes\": \"Är du säker på att du vill radera {0} avsnitt?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Är du säker på att du vill radera {0} lyssningstillfällen?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Är du säker på att du vill radera filerna 'metadata.{0}' i alla mappar i ditt bibliotek?\",\n  \"MessageConfirmRemoveNarrator\": \"Är du säker på att du vill ta bort uppläsaren \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Är du säker på att du vill ta bort din spellista \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Är du säker på att du vill byta namn på kategorin \\\"{0}\\\" till \\\"{1}\\\" för alla objekt?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"OBS: Den här kategorin finns redan, så de kommer att slås samman.\",\n  \"MessageConfirmRenameGenreWarning\": \"VARNING! En liknande kategori med annat skrivsätt finns redan \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Är du säker på att du vill byta namn på taggen \\\"{0}\\\" till \\\"{1}\\\" för alla objekt?\",\n  \"MessageConfirmRenameTagMergeNote\": \"OBS: Den här taggen finns redan, så de kommer att slås samman.\",\n  \"MessageConfirmRenameTagWarning\": \"VARNING! En liknande tagg med annat skrivsätt finns redan \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Är du säker på att du vill nollställa ditt framsteg?\",\n  \"MessageConfirmSendEbookToDevice\": \"Är du säker på att du vill skicka {0} e-bok \\\"{1}\\\" till enheten \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Är du säker på att du vill koppla bort denna användare från OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} dagars lyssnande det senaste året\",\n  \"MessageDownloadingEpisode\": \"Laddar ner avsnitt\",\n  \"MessageDragFilesIntoTrackOrder\": \"Ändra ordningen genom att klicka och dra filerna till rätt plats\",\n  \"MessageEmbedFailed\": \"Inbäddning misslyckades!\",\n  \"MessageEmbedFinished\": \"Inbäddning genomförd!\",\n  \"MessageEmbedQueue\": \"Köad för inbäddning av metadata plats ({0} i kön)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} avsnitt i kö för nedladdning\",\n  \"MessageEreaderDevices\": \"För att säkerställa överföring av e-böcker kan du bli tvungen<br>att addera ovanstående e-postadress som godkänd avsändare<br>för varje enhet angiven nedan.\",\n  \"MessageFeedURLWillBe\": \"Flödes-URL kommer att vara {0}\",\n  \"MessageFetching\": \"Hämtar...\",\n  \"MessageForceReScanDescription\": \"kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} lyssnar</strong> på {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Inga lyssningssessioner på {0}\",\n  \"MessageImportantNotice\": \"Viktig meddelande!\",\n  \"MessageInsertChapterBelow\": \"Infoga kapitel nedanför\",\n  \"MessageInvalidAsin\": \"Felaktig ASIN-kod\",\n  \"MessageItemsSelected\": \"{0} objekt valda\",\n  \"MessageItemsUpdated\": \"{0} objekt uppdaterade\",\n  \"MessageJoinUsOn\": \"Anslut dig till oss på\",\n  \"MessageLoading\": \"Laddar...\",\n  \"MessageLoadingFolders\": \"Laddar mappar...\",\n  \"MessageLogsDescription\": \"Filer med loggningsinformation sparas i mappen <code>/metadata/logs</code> som JSON-filer.<br>Filer med information om krascher sparas i <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"Skapandet av en M4B-fil misslyckades!\",\n  \"MessageM4BFinished\": \"Omkodningen till M4B är genomförd!\",\n  \"MessageMapChapterTitles\": \"Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar\",\n  \"MessageMarkAllEpisodesFinished\": \"Markera alla avsnitt som avslutade\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Markera alla avsnitt som ej avslutade\",\n  \"MessageMarkAsFinished\": \"Markera som avslutad\",\n  \"MessageMarkAsNotFinished\": \"Markera som ej avslutad\",\n  \"MessageMatchBooksDescription\": \"kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i de uppgifter som saknas och addera omslag som saknas. Inga befintliga uppgifter kommer att ersättas.\",\n  \"MessageNoAudioTracks\": \"Inga ljudfiler har hittats\",\n  \"MessageNoAuthors\": \"Inga författare\",\n  \"MessageNoBackups\": \"Inga säkerhetskopior\",\n  \"MessageNoBookmarks\": \"Inga bokmärken\",\n  \"MessageNoChapters\": \"Inga kapitel\",\n  \"MessageNoCollections\": \"Inga Samlingar\",\n  \"MessageNoCoversFound\": \"Inga omslag hittades\",\n  \"MessageNoDescription\": \"Ingen beskrivning\",\n  \"MessageNoDevices\": \"Inga enheter angivna\",\n  \"MessageNoDownloadsInProgress\": \"Inga nedladdningar pågår för närvarande\",\n  \"MessageNoDownloadsQueued\": \"Inga nedladdningar i kö\",\n  \"MessageNoEpisodeMatchesFound\": \"Inga matchande avsnitt kunde hittas\",\n  \"MessageNoEpisodes\": \"Inga avsnitt\",\n  \"MessageNoFoldersAvailable\": \"Inga mappar tillgängliga\",\n  \"MessageNoGenres\": \"Inga kategorier\",\n  \"MessageNoIssues\": \"Inga objekt med problem hittades\",\n  \"MessageNoItems\": \"Inga objekt\",\n  \"MessageNoItemsFound\": \"Inga objekt hittades\",\n  \"MessageNoListeningSessions\": \"Inga lyssningstillfällen\",\n  \"MessageNoLogs\": \"Inga loggningsinformation finns\",\n  \"MessageNoMediaProgress\": \"Ingen medieförlopp\",\n  \"MessageNoNotifications\": \"Inga aviseringar\",\n  \"MessageNoPodcastFeed\": \"Felaktig podcast: ingen ström\",\n  \"MessageNoPodcastsFound\": \"Inga podcasts hittade\",\n  \"MessageNoResults\": \"Inga resultat\",\n  \"MessageNoSearchResultsFor\": \"Inga sökresultat för \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Inga serier\",\n  \"MessageNoTags\": \"Inga taggar\",\n  \"MessageNoTasksRunning\": \"Inga pågående uppgifter\",\n  \"MessageNoUpdatesWereNecessary\": \"Inga uppdateringar var nödvändiga\",\n  \"MessageNoUserPlaylists\": \"Du har inga spellistor\",\n  \"MessageNoUserPlaylistsHelp\": \"Spellistor är privata. Endast den användare som skapat listan kan se den.\",\n  \"MessageNotYetImplemented\": \"Ännu inte implementerad\",\n  \"MessageOpmlPreviewNote\": \"Obs: Detta är en förhandsvisning av den analyserade OPML-filen. Den faktiska podcasttiteln kommer att hämtas från RSS-flödet.\",\n  \"MessageOr\": \"eller\",\n  \"MessagePauseChapter\": \"Pausa kapiteluppspelning\",\n  \"MessagePlayChapter\": \"Lyssna på kapitlets början\",\n  \"MessagePlaylistCreateFromCollection\": \"Skapa en spellista från samlingen\",\n  \"MessagePleaseWait\": \"Vänta ett ögonblick...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcasten har ingen RSS-flödes-URL att använda för matchning\",\n  \"MessagePodcastSearchField\": \"Skriv sökfrågan eller URL-adressen för RSS-flödet\",\n  \"MessageQuickEmbedInProgress\": \"Infogande av metadata pågår\",\n  \"MessageQuickEmbedQueue\": \"Kö för infogaden av metadata ({0} objekt i kön)\",\n  \"MessageQuickMatchAllEpisodes\": \"Snabbmatchning av alla avsnitt\",\n  \"MessageQuickMatchDescription\": \"Adderar uppgifter som saknas samt en omslagsbild från<br>första träffen i resultatet vid sökningen från '{0}'.<br>Skriver inte över befintliga uppgifter om inte<br>inställningen 'Prioritera matchad metadata' är aktiverad.\",\n  \"MessageRemoveChapter\": \"Ta bort kapitel\",\n  \"MessageRemoveEpisodes\": \"Radera {0} avsnitt\",\n  \"MessageRemoveFromPlayerQueue\": \"Ta bort från spellistan\",\n  \"MessageRemoveUserWarning\": \"Är du säker på att du vill radera användaren \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Rapportera buggar, begär funktioner och bidra på\",\n  \"MessageResetChaptersConfirm\": \"Är du säker på att du vill återställa alla kapitel och ångra de ändringarna du gjort?\",\n  \"MessageRestoreBackupConfirm\": \"Är du säker på att du vill läsa in säkerhetskopian som skapades den\",\n  \"MessageRestoreBackupWarning\": \"Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.\",\n  \"MessageScheduleLibraryScanNote\": \"För de flesta användare rekommenderas det att låta den här funktionen vara inaktiverad och att inställningen \\\"Bevaka biblioteket automatiskt efter ändringar\\\" är aktiverad – den kommer automatiskt att upptäcka ändringar i dina biblioteksmappar. Aktivera den här funktionen om \\\"Bevaka biblioteket automatiskt efter ändringar\\\" inte fungerar för ditt filsystem (som NFS).\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Startar varje {0} klockan {1}\",\n  \"MessageSearchResultsFor\": \"Sökresultat för\",\n  \"MessageSelected\": \"{0} valda\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Ordningsnumret i en serie får inte innehålla ett mellanslag\",\n  \"MessageServerCouldNotBeReached\": \"Servern kunde inte nås\",\n  \"MessageSetChaptersFromTracksDescription\": \"Använd varje ljudfil som ett kapitel och ljudfilens namn som kapitlets rubrik\",\n  \"MessageShareExpirationWillBe\": \"Giltig till kommer att bli <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Upphör om {0}\",\n  \"MessageShareURLWillBe\": \"Delningslänken kommer att vara <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Starta uppspelning av \\\"{0}\\\" från tidpunkt {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Det går inte att skriva till ljudfilen \\\"{0}\\\"\",\n  \"MessageTaskCanceledByUser\": \"Uppgiften avslutades av användaren\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Laddar ner avsnitt \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Infogar metadata\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Infogar metadata i ljudboken \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Kodar M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Omkodning av ljudbok \\\"{0}\\\" till en M4B-fil\",\n  \"MessageTaskFailed\": \"Misslyckades\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Misslyckades med att göra backup på ljudfil \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Misslyckades med att skapa bibliotek för cachen\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Misslyckades med att infoga metadata i \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Misslyckades med att sammanfoga ljudfilerna\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Misslyckades med att flytta M4B-filen\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Misslyckades med att skapa filen med metadata\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Matchar böcker i biblioteket \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Inga filer finns tillgängliga för skanning\",\n  \"MessageTaskOpmlImport\": \"OPML-import\",\n  \"MessageTaskOpmlImportDescription\": \"Skapar podcasts från {0} RSS-flöden\",\n  \"MessageTaskOpmlImportFeed\": \"OPML importflöde\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Importerar RSS-flödet \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Det gick inte att hämta poddflödet\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Skapar podcast \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"En podcast finns redan med den adressen\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Misslyckades med att skapa podcast\",\n  \"MessageTaskOpmlImportFinished\": \"Adderade {0} podcasts\",\n  \"MessageTaskOpmlParseFailed\": \"Misslyckades att tolka OPML-filen\",\n  \"MessageTaskOpmlParseFastFail\": \"Felaktig OPML-fil. Ingen <opml> tag eller <outline> tag finns i filen\",\n  \"MessageTaskOpmlParseNoneFound\": \"Inget flöde finns angivet i OPML-filen\",\n  \"MessageTaskScanItemsAdded\": \"{0} adderades\",\n  \"MessageTaskScanItemsMissing\": \"{0} saknades\",\n  \"MessageTaskScanItemsUpdated\": \"{0} uppdaterades\",\n  \"MessageTaskScanNoChangesNeeded\": \"Inget adderades eller uppdaterades\",\n  \"MessageTaskScanningFileChanges\": \"Söker efter ändrade filer i \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Biblioteket \\\"{0}\\\" har skannats\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Det är inte tillåtet att skriva i den angivna katalogen\",\n  \"MessageThinking\": \"Tänker...\",\n  \"MessageUploaderItemFailed\": \"Misslyckades med att ladda upp\",\n  \"MessageUploaderItemSuccess\": \"har blivit uppladdad!\",\n  \"MessageUploading\": \"Laddar upp...\",\n  \"MessageValidCronExpression\": \"Giltigt cron-uttryck\",\n  \"MessageWatcherIsDisabledGlobally\": \"Automatisk bevakning av förändringar är inaktiverad under rubriken 'Inställningar'\",\n  \"MessageXLibraryIsEmpty\": \"Biblioteket {0} är tomt!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Längden på din ljudbok är längre än den hittade längden\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Längden på din ljudbok är kortare än den hittade längden\",\n  \"NoteChangeRootPassword\": \"Användaren 'root' är den enda användaren som kan vara utan lösenord\",\n  \"NoteChapterEditorTimes\": \"OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala längd.\",\n  \"NoteFolderPicker\": \"OBS: Mappar som redan är kopplade kommer inte att visas\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"VARNING: Ett eller flera av dina avsnitt saknar publiceringsdatum. Vissa applikationer för podcasts kräver detta.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.\",\n  \"NoteUploaderUnsupportedFiles\": \"Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.\",\n  \"NotificationOnBackupCompletedDescription\": \"Aktiveras när en backup är genomförd\",\n  \"NotificationOnBackupFailedDescription\": \"Aktiveras när en backup misslyckas\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Aktiveras när avsnitt i en podcast automatiskt har hämtats\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Aktiveras när den automatiska nedladdningen av avsnitt i en podcast stängts av pga för många misslyckade försök\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Aktiveras när den automatiska nedladdningen av avsnitt i en podcast misslyckats\",\n  \"NotificationOnTestDescription\": \"Händelse för att testa meddelandesystemet\",\n  \"PlaceholderBulkChapterInput\": \"Addera kapitlets titel eller numrera kapitlen (t.ex. 'Avsnitt 1', 'Kapitel 10', '1.)\",\n  \"PlaceholderNewCollection\": \"Nytt namn på samlingen\",\n  \"PlaceholderNewFolderPath\": \"Ny sökväg till mappen\",\n  \"PlaceholderNewPlaylist\": \"Nytt namn på spellistan\",\n  \"PlaceholderSearch\": \"Sök...\",\n  \"PlaceholderSearchEpisode\": \"Sök avsnitt...\",\n  \"StatsAuthorsAdded\": \"författare har adderats\",\n  \"StatsBooksAdded\": \"böcker har adderats\",\n  \"StatsBooksAdditional\": \"Några exempel på det som adderats…\",\n  \"StatsBooksFinished\": \"avslutade böcker\",\n  \"StatsBooksFinishedThisYear\": \"Några böcker som avslutats under året…\",\n  \"StatsBooksListenedTo\": \"böcker, lyssnat på\",\n  \"StatsCollectionGrewTo\": \"Ditt biblioteks storlek ökade till…\",\n  \"StatsSessions\": \"lyssningstillfällen\",\n  \"StatsSpentListening\": \"tid, som lyssnats\",\n  \"StatsTopAuthor\": \"Populäraste författare\",\n  \"StatsTopAuthors\": \"Populäraste författarna\",\n  \"StatsTopGenre\": \"Populäraste kategori\",\n  \"StatsTopGenres\": \"Populäraste kategorierna\",\n  \"StatsTopMonth\": \"Bästa månad\",\n  \"StatsTopNarrator\": \"Populäraste uppläsare\",\n  \"StatsTopNarrators\": \"Populäraste uppläsarna\",\n  \"StatsTotalDuration\": \"Med den totala längden…\",\n  \"StatsYearInReview\": \"- SAMMANSTÄLLNING AV ÅRET\",\n  \"ToastAccountUpdateSuccess\": \"Kontot har uppdaterats\",\n  \"ToastAppriseUrlRequired\": \"En URL-adress till Apprise API måste anges\",\n  \"ToastAsinRequired\": \"En ASIN-kod krävs\",\n  \"ToastAuthorImageRemoveSuccess\": \"Författarens bild borttagen\",\n  \"ToastAuthorNotFound\": \"Författaren \\\"{0}\\\" kunde inte identifieras\",\n  \"ToastAuthorRemoveSuccess\": \"Författaren har raderats\",\n  \"ToastAuthorSearchNotFound\": \"Författaren kunde inte identifieras\",\n  \"ToastAuthorUpdateMerged\": \"Författaren sammanslagen\",\n  \"ToastAuthorUpdateSuccess\": \"Författaren uppdaterad\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Författaren uppdaterad (ingen bild hittad)\",\n  \"ToastBackupAppliedSuccess\": \"Säkerhetskopian är importerad\",\n  \"ToastBackupCreateFailed\": \"Det gick inte att skapa en säkerhetskopia\",\n  \"ToastBackupCreateSuccess\": \"Säkerhetskopian har skapats\",\n  \"ToastBackupDeleteFailed\": \"Det gick inte att radera säkerhetskopian\",\n  \"ToastBackupDeleteSuccess\": \"Säkerhetskopian har raderats\",\n  \"ToastBackupInvalidMaxKeep\": \"Felaktigt antal kopior av backup har angivits\",\n  \"ToastBackupInvalidMaxSize\": \"Felaktig storlek på backup har angivits\",\n  \"ToastBackupRestoreFailed\": \"Det gick inte att återställa säkerhetskopian\",\n  \"ToastBackupUploadFailed\": \"Det gick inte att ladda upp säkerhetskopian\",\n  \"ToastBackupUploadSuccess\": \"Säkerhetskopian har laddats upp\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Informationen har adderats till alla objekt\",\n  \"ToastBatchDeleteFailed\": \"Det gick inte att radera batch\",\n  \"ToastBatchDeleteSuccess\": \"Batch borttagning lyckades\",\n  \"ToastBatchQuickMatchFailed\": \"Snabbmatchning av batch misslyckades!\",\n  \"ToastBatchQuickMatchStarted\": \"Snabbmatchning av {0} böcker har påbörjats!\",\n  \"ToastBatchUpdateFailed\": \"Batchuppdateringen misslyckades\",\n  \"ToastBatchUpdateSuccess\": \"Batchuppdateringen lyckades\",\n  \"ToastBookmarkCreateFailed\": \"Det gick inte att skapa bokmärket\",\n  \"ToastBookmarkCreateSuccess\": \"Bokmärket har adderats\",\n  \"ToastBookmarkRemoveSuccess\": \"Bokmärket har raderats\",\n  \"ToastBulkChapterInvalidCount\": \"Ange ett nummer mellan 1 och 150\",\n  \"ToastCachePurgeFailed\": \"Misslyckades med att rensa cachen\",\n  \"ToastCachePurgeSuccess\": \"Rensning av cachen har genomförts\",\n  \"ToastChapterLocked\": \"Kapitlet är låst.\",\n  \"ToastChapterStartTimeAdjusted\": \"Kapitlets starttid justerades med {0} sekunder\",\n  \"ToastChaptersAllLocked\": \"Alla kapitel är låsta. Lås upp några av dem för att kunna ändra deras tider.\",\n  \"ToastChaptersHaveErrors\": \"Kapitlen har fel\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Felaktig ändring. Det sista kapitlets starttid kommer att hamna efter den totala längden på ljudboken.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Felaktig ändring. Det första kapitlets längd kommer att vara 0 eller ha ett negativt värde. Det kommer därför att skrivas över av det andra kapitlet. Öka starttiden för det andra kapitlet.\",\n  \"ToastChaptersMustHaveTitles\": \"Kapitel måste ha titlar\",\n  \"ToastChaptersRemoved\": \"Kapitlen har raderats\",\n  \"ToastChaptersUpdated\": \"Kapitlen har uppdaterats\",\n  \"ToastCollectionItemsAddFailed\": \"Misslyckades med att addera böcker till samlingen\",\n  \"ToastCollectionRemoveSuccess\": \"Samlingen har raderats\",\n  \"ToastCollectionUpdateSuccess\": \"Samlingen har uppdaterats\",\n  \"ToastConnectionNotAvailable\": \"Uppkopplingen är inte tillgänglig. Var vänlig försök senare.\",\n  \"ToastCoverSearchFailed\": \"Sökningen efter omslag misslyckades\",\n  \"ToastCoverUpdateFailed\": \"Uppdatering av omslag misslyckades\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Datum och klockslag är felaktigt eller ej komplett\",\n  \"ToastDeleteFileFailed\": \"Misslyckades att radera filen\",\n  \"ToastDeleteFileSuccess\": \"Filen har raderats\",\n  \"ToastDeviceAddFailed\": \"Misslyckades med att addera enheten\",\n  \"ToastDeviceNameAlreadyExists\": \"En enhet för att läsa e-böcker med det namnet finns redan\",\n  \"ToastDeviceTestEmailFailed\": \"Misslyckades med att skicka ett testmail\",\n  \"ToastDeviceTestEmailSuccess\": \"Ett testmail har skickats\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Inställningarna av e-post har uppdaterats\",\n  \"ToastEncodeCancelFailed\": \"Misslyckades med att avbryta omkodningen\",\n  \"ToastEncodeCancelSucces\": \"Omkodningen avbruten\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Misslyckades med att tömma kön\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Kö för nedladdning av avsnitt har tömts\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} avsnitt uppdaterades\",\n  \"ToastErrorCannotShare\": \"Kan inte dela direkt på den här enheten\",\n  \"ToastFailedToCreate\": \"Misslyckades med att addera\",\n  \"ToastFailedToDelete\": \"Misslyckades med att radera\",\n  \"ToastFailedToLoadData\": \"Misslyckades med att ladda data\",\n  \"ToastFailedToMatch\": \"Misslyckades med att matcha\",\n  \"ToastFailedToShare\": \"Misslyckades med att dela\",\n  \"ToastFailedToUpdate\": \"Misslyckades med att uppdatera\",\n  \"ToastInvalidImageUrl\": \"Felaktig URL-adress till omslagsbilden\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Felaktigt värde angivet för maximalt antal avsnitt att ladda ner\",\n  \"ToastInvalidUrl\": \"Felaktig URL-adress\",\n  \"ToastInvalidUrls\": \"En eller flera URL-adresser är felaktig\",\n  \"ToastItemCoverUpdateSuccess\": \"Objektets omslag har uppdaterats\",\n  \"ToastItemDeletedFailed\": \"Misslyckades med att radera objektet\",\n  \"ToastItemDeletedSuccess\": \"Objektet har raderats\",\n  \"ToastItemDetailsUpdateSuccess\": \"Informationen om objektet har uppdaterats\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Misslyckades med att markera den som avslutad\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Den har markerat som avslutad\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Misslyckades med att markera den som ej avslutad\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Den har markerats som ej avslutad\",\n  \"ToastItemUpdateSuccess\": \"Objektet har uppdaterats\",\n  \"ToastLibraryCreateFailed\": \"Det gick inte att skapa biblioteket\",\n  \"ToastLibraryCreateSuccess\": \"Biblioteket \\\"{0}\\\" har skapats\",\n  \"ToastLibraryDeleteFailed\": \"Det gick inte att ta bort biblioteket\",\n  \"ToastLibraryDeleteSuccess\": \"Biblioteket borttaget\",\n  \"ToastLibraryScanFailedToStart\": \"Misslyckades med att starta skanningen\",\n  \"ToastLibraryScanStarted\": \"Skanning av biblioteket påbörjad\",\n  \"ToastLibraryUpdateSuccess\": \"Biblioteket \\\"{0}\\\" har uppdaterats\",\n  \"ToastMatchAllAuthorsFailed\": \"Misslyckades med att matcha alla författare\",\n  \"ToastMetadataFilesRemovedError\": \"Misslyckades med att radera 'metadata.{0}' filerna\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Inga 'metadata.{0}' filer hittades i biblioteket\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Inga 'metadata.{0}' filer raderades\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} 'metadata.{1}' raderades\",\n  \"ToastMustHaveAtLeastOnePath\": \"Måste ha minst en sökväg\",\n  \"ToastNameEmailRequired\": \"Ett namn och en e-postadress måste anges\",\n  \"ToastNameRequired\": \"Ett namn måste anges\",\n  \"ToastNewApiKeyUserError\": \"En användare måste väljas\",\n  \"ToastNewEpisodesFound\": \"Hittade {0} nya avsnitt\",\n  \"ToastNewUserCreatedFailed\": \"Misslyckades med att skapa kontot \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Ett nytt konto har skapats\",\n  \"ToastNewUserLibraryError\": \"Minst ett bibliotek måste anges\",\n  \"ToastNewUserPasswordError\": \"Ett lösenord måste anges. Endast användaren 'root' tillåts sakna lösenord.\",\n  \"ToastNewUserTagError\": \"Minst en tagg måste läggas till\",\n  \"ToastNewUserUsernameError\": \"Ange ett användarnamn\",\n  \"ToastNoNewEpisodesFound\": \"Inga nya avsnitt kunde hittas\",\n  \"ToastNoRSSFeed\": \"Denna podcast har ingen RSS-flöde\",\n  \"ToastNoUpdatesNecessary\": \"Inga uppdateringar var nödvändiga\",\n  \"ToastNotificationCreateFailed\": \"Misslyckades med att skapa meddelandet\",\n  \"ToastNotificationDeleteFailed\": \"Misslyckades med att radera meddelandet\",\n  \"ToastNotificationFailedMaximum\": \"Maximalt antal misslyckade försök måste vara större än eller lika med 0\",\n  \"ToastNotificationQueueMaximum\": \"Maximala antalet aviseringsköer måste vara >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Inställningarna för meddelanden har ändrats\",\n  \"ToastNotificationTestTriggerFailed\": \"Misslyckades med att skicka testmeddelandet\",\n  \"ToastNotificationTestTriggerSuccess\": \"Triggade testavisering\",\n  \"ToastNotificationUpdateSuccess\": \"Meddelandet har uppdaterats\",\n  \"ToastPlaylistCreateFailed\": \"Det gick inte att skapa spellistan\",\n  \"ToastPlaylistCreateSuccess\": \"Spellistan skapad\",\n  \"ToastPlaylistRemoveSuccess\": \"Spellistan har tagits bort\",\n  \"ToastPlaylistUpdateSuccess\": \"Spellistan har uppdaterats\",\n  \"ToastPodcastCreateFailed\": \"Misslyckades med att skapa podcasten\",\n  \"ToastPodcastCreateSuccess\": \"Podcasten skapades framgångsrikt\",\n  \"ToastPodcastEpisodeUpdated\": \"Avsnittet har uppdaterats\",\n  \"ToastPodcastGetFeedFailed\": \"Det gick inte att hämta poddflödet\",\n  \"ToastPodcastNoEpisodesInFeed\": \"Inga avsnitt finns i RSS-flödet\",\n  \"ToastPodcastNoRssFeed\": \"Denna podcast har ingen RSS-flöde\",\n  \"ToastProgressIsNotBeingSynced\": \"Förloppet synkroniseras inte, starta om uppspelningen\",\n  \"ToastProviderCreatedFailed\": \"Misslyckades med att addera en källa\",\n  \"ToastProviderCreatedSuccess\": \"En ny källa har adderats\",\n  \"ToastProviderNameAndUrlRequired\": \"Ett namn och en URL-adress krävs\",\n  \"ToastProviderRemoveSuccess\": \"Källan har tagits bort\",\n  \"ToastRSSFeedCloseFailed\": \"Misslyckades med att stänga RSS-flödet\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-flödet stängt\",\n  \"ToastRemoveFailed\": \"Misslyckades med att radera\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Misslyckades med att ta bort objektet från samlingen\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Objektet borttaget från samlingen\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Misslyckades med att radera objekt med problem\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Raderade objekt med problem\",\n  \"ToastRenameFailed\": \"Misslyckades med att ändra namn\",\n  \"ToastRescanFailed\": \"Skanningen misslyckades för {0}\",\n  \"ToastRescanRemoved\": \"Skanningen har genomförts - objektet har raderats\",\n  \"ToastRescanUpToDate\": \"Skanningen har genomförts - objektet behövde inte uppdateras\",\n  \"ToastRescanUpdated\": \"Skanningen har genomförts - objektet har uppdaterats\",\n  \"ToastScanFailed\": \"Misslyckades med att skanna biblioteket\",\n  \"ToastSelectAtLeastOneUser\": \"Åtminstone en användare måste väljas\",\n  \"ToastSendEbookToDeviceFailed\": \"Misslyckades med att skicka e-boken till enheten\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-boken skickad till enheten \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Det är inte möjligt att addera två serier med samma namn\",\n  \"ToastSeriesUpdateFailed\": \"Misslyckades med att uppdatera serien\",\n  \"ToastSeriesUpdateSuccess\": \"Uppdateringen av serierna lyckades\",\n  \"ToastServerSettingsUpdateSuccess\": \"Inställningarna för servern har uppdaterats\",\n  \"ToastSessionCloseFailed\": \"Misslyckades med att avsluta sessionen\",\n  \"ToastSessionDeleteFailed\": \"Misslyckades med att ta bort sessionen\",\n  \"ToastSessionDeleteSuccess\": \"Sessionen borttagen\",\n  \"ToastSleepTimerDone\": \"Timer har stängt av lyssning. Sov gott... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug innehåller ogiltiga tecken\",\n  \"ToastSlugRequired\": \"Slug krävs\",\n  \"ToastSocketConnected\": \"Socket ansluten\",\n  \"ToastSocketDisconnected\": \"Socket frånkopplad\",\n  \"ToastSocketFailedToConnect\": \"Socket misslyckades med att ansluta\",\n  \"ToastSortingPrefixesEmptyError\": \"Åtminstone ett sorteringsbegrepp måste anges\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"{0} begrepp för sortering har uppdateras\",\n  \"ToastTitleRequired\": \"En titel måste anges\",\n  \"ToastUnknownError\": \"Ett okänt fel inträffade\",\n  \"ToastUnlinkOpenIdFailed\": \"Misslyckades med att koppla bort användaren från OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Användaren har kopplats bort från OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"En fil med namnet \\\"{0}\\\" finns redan på servern\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Objektet \\\"{0}\\\" använder en underkatalog till uppladdningssökvägen.\",\n  \"ToastUserDeleteFailed\": \"Misslyckades med att ta bort användaren\",\n  \"ToastUserDeleteSuccess\": \"Användaren borttagen\",\n  \"ToastUserPasswordChangeSuccess\": \"Lösenordet har ändrats\",\n  \"ToastUserPasswordMismatch\": \"Lösenorden är inte identiska\",\n  \"ToastUserPasswordMustChange\": \"Det nya lösenordet kan inte vara samma som det gamla\",\n  \"ToastUserRootRequireName\": \"Ett användarnamn för 'root' måste anges\",\n  \"TooltipAddChapters\": \"Addera kapitel\",\n  \"TooltipAddOneSecond\": \"Öka med 1 sekund\",\n  \"TooltipAdjustChapterStart\": \"Klicka för att ändra starttiden\",\n  \"TooltipLockAllChapters\": \"Lås alla kapitel\",\n  \"TooltipLockChapter\": \"Lås kapitel (Tryck på Shift + Klick för att markera flera)\",\n  \"TooltipSubtractOneSecond\": \"Minska med 1 sekund\",\n  \"TooltipUnlockAllChapters\": \"Lås upp alla kapitel\",\n  \"TooltipUnlockChapter\": \"Lås upp kapitel (Tryck på Shift + Klick för att markera flera)\"\n}\n"
  },
  {
    "path": "client/strings/tr.json",
    "content": "{\n  \"ButtonAdd\": \"Ekle\",\n  \"ButtonAddApiKey\": \"API Anahtarı Ekle\",\n  \"ButtonAddChapters\": \"Bölüm Ekle\",\n  \"ButtonAddDevice\": \"Cihaz Ekle\",\n  \"ButtonAddLibrary\": \"Kütüphane Ekle\",\n  \"ButtonAddPodcasts\": \"Podcast Ekle\",\n  \"ButtonAddUser\": \"Kullanıcı Ekle\",\n  \"ButtonAddYourFirstLibrary\": \"İlk kütüphaneni ekle\",\n  \"ButtonApply\": \"Uygula\",\n  \"ButtonApplyChapters\": \"Bölümleri Uygula\",\n  \"ButtonAuthors\": \"Yazarlar\",\n  \"ButtonBack\": \"Geri\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Mevcuttan Doldur\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Harita Detaylarını Doldur\",\n  \"ButtonBrowseForFolder\": \"Klasöre Göz At\",\n  \"ButtonCancel\": \"İptal\",\n  \"ButtonCancelEncode\": \"Kodlamayı İptal Et\",\n  \"ButtonChangeRootPassword\": \"Root Şifresini Değiştir\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Yeni Bölümleri Kontrol Et ve İndir\",\n  \"ButtonChooseAFolder\": \"Bir Klasör Seç\",\n  \"ButtonChooseFiles\": \"Dosyaları Seç\",\n  \"ButtonClearFilter\": \"Filtreyi Temizle\",\n  \"ButtonClose\": \"Kapat\",\n  \"ButtonCloseFeed\": \"Akışı Kapat\",\n  \"ButtonCloseSession\": \"Oturumu Kapat\",\n  \"ButtonCollections\": \"Koleksiyonlar\",\n  \"ButtonConfigureScanner\": \"Tarayıcıyı Yapılandır\",\n  \"ButtonCreate\": \"Oluştur\",\n  \"ButtonCreateBackup\": \"Yedek Oluştur\",\n  \"ButtonDelete\": \"Sil\",\n  \"ButtonDownloadQueue\": \"İndirme Sırası\",\n  \"ButtonEdit\": \"Düzenle\",\n  \"ButtonEditChapters\": \"Bölümleri Düzenle\",\n  \"ButtonEditPodcast\": \"Podcast'i Düzenle\",\n  \"ButtonEnable\": \"Etkinleştir\",\n  \"ButtonFireAndFail\": \"Tetikle ve Hata Ver\",\n  \"ButtonFireOnTest\": \"onTest Olayını Tetikle\",\n  \"ButtonForceReScan\": \"Yeniden Taramaya Zorla\",\n  \"ButtonFullPath\": \"Tam Yol\",\n  \"ButtonHide\": \"Gizle\",\n  \"ButtonHome\": \"Ana Sayfa\",\n  \"ButtonIssues\": \"Sorunlar\",\n  \"ButtonJumpBackward\": \"Geri Sar\",\n  \"ButtonJumpForward\": \"İleri Sar\",\n  \"ButtonLatest\": \"En Yeniler\",\n  \"ButtonLibrary\": \"Kütüphane\",\n  \"ButtonLogout\": \"Çıkış Yap\",\n  \"ButtonLookup\": \"Sorgula\",\n  \"ButtonManageTracks\": \"Parçaları Yönet\",\n  \"ButtonMapChapterTitles\": \"Bölüm Başlıklarını Eşle\",\n  \"ButtonMatchAllAuthors\": \"Tüm Yazarları Eşleştir\",\n  \"ButtonMatchBooks\": \"Kitapları Eşleştir\",\n  \"ButtonNevermind\": \"Vazgeç\",\n  \"ButtonNext\": \"İleri\",\n  \"ButtonNextChapter\": \"Sonraki Bölüm\",\n  \"ButtonNextItemInQueue\": \"Sıradaki Öğe\",\n  \"ButtonOk\": \"Tamam\",\n  \"ButtonOpenFeed\": \"Akışı Aç\",\n  \"ButtonOpenManager\": \"Yöneticiyi Aç\",\n  \"ButtonPause\": \"Duraklat\",\n  \"ButtonPlay\": \"Oynat\",\n  \"ButtonPlayAll\": \"Tümünü Oynat\",\n  \"ButtonPlaying\": \"Oynatılıyor\",\n  \"ButtonPlaylists\": \"Oynatma Listeleri\",\n  \"ButtonPrevious\": \"Önceki\",\n  \"ButtonPreviousChapter\": \"Önceki Bölüm\",\n  \"ButtonProbeAudioFile\": \"Ses Dosyasını Analiz Et\",\n  \"ButtonPurgeAllCache\": \"Tüm Önbelleği Temizle\",\n  \"ButtonPurgeItemsCache\": \"Öğe Önbelleğini Temizle\",\n  \"ButtonQueueAddItem\": \"Sıraya Ekle\",\n  \"ButtonQueueRemoveItem\": \"Sıradan Kaldır\",\n  \"ButtonQuickEmbed\": \"Hızlı Gömme\",\n  \"ButtonQuickEmbedMetadata\": \"Üst Veriyi Hızlıca Göm\",\n  \"ButtonQuickMatch\": \"Hızlı Eşleştirme\",\n  \"ButtonReScan\": \"Yeniden Tara\",\n  \"ButtonRead\": \"Oku\",\n  \"ButtonReadLess\": \"Daha az göster\",\n  \"ButtonReadMore\": \"Devamını oku\",\n  \"ButtonRefresh\": \"Yenile\",\n  \"ButtonRemove\": \"Kaldır\",\n  \"ButtonRemoveAll\": \"Tümünü Kaldır\",\n  \"ButtonRemoveAllLibraryItems\": \"Tüm Kütüphane Öğelerini Kaldır\",\n  \"ButtonRemoveFromContinueListening\": \"Dinlemeye Devam Ettiklerimden Kaldır\",\n  \"ButtonRemoveFromContinueReading\": \"Okumaya Devam Ettiklerimden Kaldır\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Seriyi 'Seriye Devam Et'ten Kaldır\",\n  \"ButtonReset\": \"Sıfırla\",\n  \"ButtonResetToDefault\": \"Varsayılana Sıfırla\",\n  \"ButtonRestore\": \"Geri Yükle\",\n  \"ButtonSave\": \"Kaydet\",\n  \"ButtonSaveAndClose\": \"Kaydet ve Kapat\",\n  \"ButtonSaveTracklist\": \"Parça Listesini Kaydet\",\n  \"ButtonScan\": \"Tara\",\n  \"ButtonScanLibrary\": \"Kütüphaneyi Tara\",\n  \"ButtonScrollLeft\": \"Sola Kaydır\",\n  \"ButtonScrollRight\": \"Sağa Kaydır\",\n  \"ButtonSearch\": \"Ara\",\n  \"ButtonSelectFolderPath\": \"Klasör Yolunu Seç\",\n  \"ButtonSeries\": \"Seriler\",\n  \"ButtonSetChaptersFromTracks\": \"Bölümleri Parçalardan Ayarla\",\n  \"ButtonShare\": \"Paylaş\",\n  \"ButtonShiftTimes\": \"Zamanlamayı Kaydır\",\n  \"ButtonShow\": \"Göster\",\n  \"ButtonStartM4BEncode\": \"M4B Kodlamasını Başlat\",\n  \"ButtonStartMetadataEmbed\": \"Üst Veri Gömme İşlemini Başlat\",\n  \"ButtonStats\": \"İstatistikler\",\n  \"ButtonSubmit\": \"Gönder\",\n  \"ButtonTest\": \"Test Et\",\n  \"ButtonUnlinkOpenId\": \"OpenID Bağlantısını Kaldır\",\n  \"ButtonUpload\": \"Yükle\",\n  \"ButtonUploadBackup\": \"Yedeği Yükle\",\n  \"ButtonUploadCover\": \"Kapağı Yükle\",\n  \"ButtonUploadOPMLFile\": \"OPML Dosyası Yükle\",\n  \"ButtonUserDelete\": \"{0} kullanıcısını sil\",\n  \"ButtonUserEdit\": \"{0} kullanıcısını düzenle\",\n  \"ButtonViewAll\": \"Tümünü Görüntüle\",\n  \"ButtonYes\": \"Evet\",\n  \"ErrorUploadFetchMetadataAPI\": \"Üst veri alınırken hata oluştu\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Üst veri bulunamadı. Başlığı veya yazarı güncellemeyi deneyin\",\n  \"ErrorUploadLacksTitle\": \"Başlık girilmesi zorunludur\",\n  \"HeaderAccount\": \"Hesap\",\n  \"HeaderAddCustomMetadataProvider\": \"Özel Üst Veri Sağlayıcısı Ekle\",\n  \"HeaderAdvanced\": \"Gelişmiş\",\n  \"HeaderApiKeys\": \"API Anahtarları\",\n  \"HeaderAppriseNotificationSettings\": \"Apprise Bildirim Ayarları\",\n  \"HeaderAudioTracks\": \"Ses Parçaları\",\n  \"HeaderAudiobookTools\": \"Sesli Kitap Dosya Yönetim Araçları\",\n  \"HeaderAuthentication\": \"Kimlik Doğrulama\",\n  \"HeaderBackups\": \"Yedekler\",\n  \"HeaderBulkChapterModal\": \"Toplu Bölüm Ekle\",\n  \"HeaderChangePassword\": \"Şifre Değiştir\",\n  \"HeaderChapters\": \"Bölümler\",\n  \"HeaderChooseAFolder\": \"Bir Klasör Seç\",\n  \"HeaderCollection\": \"Koleksiyon\",\n  \"HeaderCollectionItems\": \"Koleksiyon Öğeleri\",\n  \"HeaderCover\": \"Kapak\",\n  \"HeaderCurrentDownloads\": \"Mevcut İndirmeler\",\n  \"HeaderCustomMessageOnLogin\": \"Giriş Ekranı Özel Mesajı\",\n  \"HeaderCustomMetadataProviders\": \"Özel Üst Veri Sağlayıcıları\",\n  \"HeaderDetails\": \"Detaylar\",\n  \"HeaderDownloadQueue\": \"İndirme Sırası\",\n  \"HeaderEbookFiles\": \"E-kitap Dosyaları\",\n  \"HeaderEmail\": \"E-posta\",\n  \"HeaderEmailSettings\": \"E-posta Ayarları\",\n  \"HeaderEpisodes\": \"Bölümler\",\n  \"HeaderEreaderDevices\": \"E-kitap Okuyucu Cihazlar\",\n  \"HeaderEreaderSettings\": \"E-kitap Okuyucu Ayarları\",\n  \"HeaderFiles\": \"Dosyalar\",\n  \"HeaderFindChapters\": \"Bölüm Bul\",\n  \"HeaderIgnoredFiles\": \"Yoksayılan Dosyalar\",\n  \"HeaderItemFiles\": \"Öğe Dosyaları\",\n  \"HeaderItemMetadataUtils\": \"Öğe Üst Veri Araçları\",\n  \"HeaderLastListeningSession\": \"Son Dinleme Oturumu\",\n  \"HeaderLatestEpisodes\": \"En Son Bölümler\",\n  \"HeaderLibraries\": \"Kütüphaneler\",\n  \"HeaderLibraryFiles\": \"Kütüphane Dosyaları\",\n  \"HeaderLibraryStats\": \"Kütüphane İstatistikleri\",\n  \"HeaderListeningSessions\": \"Dinleme Oturumları\",\n  \"HeaderListeningStats\": \"Dinleme İstatistikleri\",\n  \"HeaderLogin\": \"Giriş Yap\",\n  \"HeaderLogs\": \"Kayıtlar\",\n  \"HeaderManageGenres\": \"Türleri Yönet\",\n  \"HeaderManageTags\": \"Etiketleri Yönet\",\n  \"HeaderMapDetails\": \"Detayları Eşle\",\n  \"HeaderMatch\": \"Eşleştir\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Üst Veri Öncelik Sırası\",\n  \"HeaderMetadataToEmbed\": \"Gömülecek Üst Veri\",\n  \"HeaderNewAccount\": \"Yeni Hesap\",\n  \"HeaderNewApiKey\": \"Yeni API Anahtarı\",\n  \"HeaderNewLibrary\": \"Yeni Kütüphane\",\n  \"HeaderNotificationCreate\": \"Bildirim Oluştur\",\n  \"HeaderNotificationUpdate\": \"Bildirimi Güncelle\",\n  \"HeaderNotifications\": \"Bildirimler\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID Connect ile Kimlik Doğrulama\",\n  \"HeaderOpenListeningSessions\": \"Açık Dinleme Oturumları\",\n  \"HeaderOpenRSSFeed\": \"RSS Akışını Aç\",\n  \"HeaderOtherFiles\": \"Diğer Dosyalar\",\n  \"HeaderPasswordAuthentication\": \"Şifre ile Kimlik Doğrulama\",\n  \"HeaderPermissions\": \"İzinler\",\n  \"HeaderPlayerQueue\": \"Oynatma Sırası\",\n  \"HeaderPlayerSettings\": \"Oynatıcı Ayarları\",\n  \"HeaderPlaylist\": \"Oynatma Listesi\",\n  \"HeaderPlaylistItems\": \"Oynatma Listesi Öğeleri\",\n  \"HeaderPodcastsToAdd\": \"Eklenecek Podcast'ler\",\n  \"HeaderPresets\": \"Hazır Ayarlar\",\n  \"HeaderPreviewCover\": \"Kapak Önizlemesi\",\n  \"HeaderRSSFeedGeneral\": \"RSS Detayları\",\n  \"HeaderRSSFeedIsOpen\": \"RSS Akışı Açık\",\n  \"HeaderRSSFeeds\": \"RSS Akışları\",\n  \"HeaderRemoveEpisode\": \"Bölümü Kaldır\",\n  \"HeaderRemoveEpisodes\": \"{0} Bölümü Kaldır\",\n  \"HeaderSavedMediaProgress\": \"Kaydedilmiş Medya İlerlemesi\",\n  \"HeaderSchedule\": \"Zamanlama\",\n  \"HeaderScheduleEpisodeDownloads\": \"Otomatik Bölüm İndirmeyi Zamanla\",\n  \"HeaderScheduleLibraryScans\": \"Otomatik Kütüphane Taramalarını Zamanla\",\n  \"HeaderSession\": \"Oturum\",\n  \"HeaderSetBackupSchedule\": \"Yedekleme Zamanlaması Ayarla\",\n  \"HeaderSettings\": \"Ayarlar\",\n  \"HeaderSettingsDisplay\": \"Görünüm\",\n  \"HeaderSettingsExperimental\": \"Deneysel Özellikler\",\n  \"HeaderSettingsGeneral\": \"Genel\",\n  \"HeaderSettingsScanner\": \"Tarayıcı\",\n  \"HeaderSettingsSecurity\": \"Güvenlik\",\n  \"HeaderSettingsWebClient\": \"Web İstemcisi\",\n  \"HeaderSleepTimer\": \"Uyku Zamanlayıcısı\",\n  \"HeaderStatsLargestItems\": \"En Büyük Öğeler\",\n  \"HeaderStatsLongestItems\": \"En Uzun Öğeler (saat)\",\n  \"HeaderStatsMinutesListeningChart\": \"Dinlenen Dakika (son 7 gün)\",\n  \"HeaderStatsRecentSessions\": \"Son Oturumlar\",\n  \"HeaderStatsTop10Authors\": \"En İyi 10 Yazar\",\n  \"HeaderStatsTop5Genres\": \"En İyi 5 Tür\",\n  \"HeaderTableOfContents\": \"İçindekiler\",\n  \"HeaderTools\": \"Araçlar\",\n  \"HeaderUpdateAccount\": \"Hesabı Güncelle\",\n  \"HeaderUpdateApiKey\": \"API Anahtarını Güncelle\",\n  \"HeaderUpdateAuthor\": \"Yazarı Güncelle\",\n  \"HeaderUpdateDetails\": \"Detayları Güncelle\",\n  \"HeaderUpdateLibrary\": \"Kütüphaneyi Güncelle\",\n  \"HeaderUsers\": \"Kullanıcılar\",\n  \"HeaderYearReview\": \"{0} Yılına Genel Bakış\",\n  \"HeaderYourStats\": \"İstatistiklerin\",\n  \"LabelAbridged\": \"Kısaltılmış\",\n  \"LabelAbridgedChecked\": \"Kısaltılmış (işaretli)\",\n  \"LabelAbridgedUnchecked\": \"Kısaltılmamış (işaretli değil)\",\n  \"LabelAccessibleBy\": \"Erişebilenler\",\n  \"LabelAccountType\": \"Hesap Türü\",\n  \"LabelAccountTypeAdmin\": \"Yönetici\",\n  \"LabelAccountTypeGuest\": \"Misafir\",\n  \"LabelAccountTypeUser\": \"Kullanıcı\",\n  \"LabelActivities\": \"Aktiviteler\",\n  \"LabelActivity\": \"Aktivite\",\n  \"LabelAddToCollection\": \"Koleksiyona Ekle\",\n  \"LabelAddToCollectionBatch\": \"{0} Kitabı Koleksiyona Ekle\",\n  \"LabelAddToPlaylist\": \"Oynatma Listesine Ekle\",\n  \"LabelAddToPlaylistBatch\": \"{0} Öğeyi Oynatma Listesine Ekle\",\n  \"LabelAddedAt\": \"Eklenme Zamanı\",\n  \"LabelAddedDate\": \"Eklendi: {0}\",\n  \"LabelAdminUsersOnly\": \"Sadece Yöneticiler\",\n  \"LabelAll\": \"Tümü\",\n  \"LabelAllEpisodesDownloaded\": \"Tüm bölümler indirildi\",\n  \"LabelAllUsers\": \"Tüm Kullanıcılar\",\n  \"LabelAllUsersExcludingGuests\": \"Misafirler hariç tüm kullanıcılar\",\n  \"LabelAllUsersIncludingGuests\": \"Misafirler dahil tüm kullanıcılar\",\n  \"LabelAlreadyInYourLibrary\": \"Zaten kütüphanende mevcut\",\n  \"LabelApiKeyCreated\": \"API Anahtarı \\\"{0}\\\" başarıyla oluşturuldu.\",\n  \"LabelApiKeyCreatedDescription\": \"API anahtarını şimdi kopyaladığından emin ol, çünkü bir daha göremeyeceksin.\",\n  \"LabelApiKeyUser\": \"Kullanıcı adına hareket et\",\n  \"LabelApiKeyUserDescription\": \"Bu API anahtarı, adına hareket ettiği kullanıcıyla aynı izinlere sahip olacaktır. Kayıtlarda, isteği doğrudan kullanıcının kendisi yapmış gibi görünecektir.\",\n  \"LabelApiToken\": \"API Jetonu (Token)\",\n  \"LabelAppend\": \"Sona Ekle\",\n  \"LabelAudioBitrate\": \"Ses Bit Hızı (ör. 128k)\",\n  \"LabelAudioChannels\": \"Ses Kanalları (1 veya 2)\",\n  \"LabelAudioCodec\": \"Ses Kodlayıcı (Codec)\",\n  \"LabelAuthor\": \"Yazar\",\n  \"LabelAuthorFirstLast\": \"Yazar (Ad Soyad)\",\n  \"LabelAuthorLastFirst\": \"Yazar (Soyad, Ad)\",\n  \"LabelAuthors\": \"Yazarlar\",\n  \"LabelAutoDownloadEpisodes\": \"Bölümleri Otomatik İndir\",\n  \"LabelAutoFetchMetadata\": \"Üst Veriyi Otomatik Getir\",\n  \"LabelAutoFetchMetadataHelp\": \"Yüklemeyi kolaylaştırmak için başlık, yazar ve seri üst verilerini getirir. Ek üst verilerin yüklemeden sonra eşleştirilmesi gerekebilir.\",\n  \"LabelAutoLaunch\": \"Otomatik Başlat\",\n  \"LabelAutoLaunchDescription\": \"Giriş sayfasına gidildiğinde otomatik olarak kimlik doğrulama sağlayıcısına yönlendir (manuel geçersiz kılma yolu: <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Otomatik Kayıt\",\n  \"LabelAutoRegisterDescription\": \"Giriş yaptıktan sonra yeni kullanıcıları otomatik olarak oluştur\",\n  \"LabelBackToUser\": \"Kullanıcıya Geri Dön\",\n  \"LabelBackupAudioFiles\": \"Ses Dosyalarını Yedekle\",\n  \"LabelBackupLocation\": \"Yedekleme Konumu\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Otomatik yedeklemeler\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Yedekler /metadata/backups klasörüne kaydedilir\",\n  \"LabelBackupsMaxBackupSize\": \"Maksimum yedek boyutu (GB) (sınırsız için 0)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"Yanlış yapılandırmaya karşı bir önlem olarak, yedekler yapılandırılan boyutu aşarsa başarısız olur.\",\n  \"LabelBackupsNumberToKeep\": \"Saklanacak yedek sayısı\",\n  \"LabelBackupsNumberToKeepHelp\": \"Her seferinde sadece 1 yedek kaldırılır, bu yüzden zaten bundan daha fazla yedeğiniz varsa bunları manuel olarak kaldırmalısınız.\",\n  \"LabelBitrate\": \"Bit Hızı\",\n  \"LabelBonus\": \"Bonus\",\n  \"LabelBooks\": \"Kitaplar\",\n  \"LabelButtonText\": \"Buton Metni\",\n  \"LabelByAuthor\": \"{0} tarafından\",\n  \"LabelChangePassword\": \"Şifreyi Değiştir\",\n  \"LabelChannels\": \"Kanallar\",\n  \"LabelChapterCount\": \"{0} Bölüm\",\n  \"LabelChapterTitle\": \"Bölüm Başlığı\",\n  \"LabelChapters\": \"Bölümler\",\n  \"LabelChaptersFound\": \"bölüm bulundu\",\n  \"LabelClickForMoreInfo\": \"Daha fazla bilgi için tıkla\",\n  \"LabelClickToUseCurrentValue\": \"Mevcut değeri kullanmak için tıkla\",\n  \"LabelClosePlayer\": \"Oynatıcıyı kapat\",\n  \"LabelCodec\": \"Kodlayıcı (Codec)\",\n  \"LabelCollapseSeries\": \"Seriyi Daralt\",\n  \"LabelCollapseSubSeries\": \"Alt Seriyi Daralt\",\n  \"LabelCollection\": \"Koleksiyon\",\n  \"LabelCollections\": \"Koleksiyonlar\",\n  \"LabelComplete\": \"Tamamlandı\",\n  \"LabelConfirmPassword\": \"Şifreyi Onayla\",\n  \"LabelContinueListening\": \"Dinlemeye Devam Et\",\n  \"LabelContinueReading\": \"Okumaya Devam Et\",\n  \"LabelContinueSeries\": \"Seriye Devam Et\",\n  \"LabelCorsAllowed\": \"İzin Verilen CORS Kökenleri\",\n  \"LabelCover\": \"Kapak\",\n  \"LabelCoverImageURL\": \"Kapak Görseli URL'si\",\n  \"LabelCoverProvider\": \"Kapak Sağlayıcı\",\n  \"LabelCreatedAt\": \"Oluşturulma Tarihi\",\n  \"LabelCronExpression\": \"Cron İfadesi\",\n  \"LabelCurrent\": \"Mevcut\",\n  \"LabelCurrently\": \"Şu anda:\",\n  \"LabelCustomCronExpression\": \"Özel Cron İfadesi:\",\n  \"LabelDatetime\": \"Tarih ve Saat\",\n  \"LabelDays\": \"Gün\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Dosya sisteminden sil (sadece veritabanından kaldırmak için işareti kaldır)\",\n  \"LabelDescription\": \"Açıklama\",\n  \"LabelDeselectAll\": \"Tüm Seçimi Kaldır\",\n  \"LabelDetectedPattern\": \"Algılanan desen:\",\n  \"LabelDevice\": \"Cihaz\",\n  \"LabelDeviceInfo\": \"Cihaz Bilgisi\",\n  \"LabelDeviceIsAvailableTo\": \"Cihaz şunlar için mevcut...\",\n  \"LabelDirectory\": \"Dizin\",\n  \"LabelDiscFromFilename\": \"Dosya Adından Disk\",\n  \"LabelDiscFromMetadata\": \"Üst Veriden Disk\",\n  \"LabelDiscover\": \"Keşfet\",\n  \"LabelDownload\": \"İndir\",\n  \"LabelDownloadNEpisodes\": \"{0} bölümü indir\",\n  \"LabelDownloadable\": \"İndirilebilir\",\n  \"LabelDuration\": \"Süre\",\n  \"LabelDurationComparisonExactMatch\": \"(tam eşleşme)\",\n  \"LabelDurationComparisonLonger\": \"({0} daha uzun)\",\n  \"LabelDurationComparisonShorter\": \"({0} daha kısa)\",\n  \"LabelDurationFound\": \"Bulunan süre:\",\n  \"LabelEbook\": \"E-kitap\",\n  \"LabelEbooks\": \"E-kitaplar\",\n  \"LabelEdit\": \"Düzenle\",\n  \"LabelEmail\": \"E-posta\",\n  \"LabelEmailSettingsFromAddress\": \"Gönderen Adresi\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Yetkisiz sertifikaları reddet\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"SSL sertifika doğrulamasını devre dışı bırakmak, bağlantınızı ortadaki adam saldırıları gibi güvenlik risklerine maruz bırakabilir. Bu seçeneği yalnızca sonuçlarını anlıyorsanız ve bağlandığınız posta sunucusuna güveniyorsanız devre dışı bırakın.\",\n  \"LabelEmailSettingsSecure\": \"Güvenli\",\n  \"LabelEmailSettingsSecureHelp\": \"Eğer doğruysa, sunucuya bağlanırken bağlantı TLS kullanacaktır. Eğer yanlışsa, sunucu STARTTLS uzantısını destekliyorsa TLS kullanılır. Çoğu durumda, 465 numaralı bağlantı noktasına bağlanıyorsanız bu değeri doğru olarak ayarlayın. 587 veya 25 numaralı bağlantı noktası için yanlış olarak bırakın. (nodemailer.com/smtp/#authentication adresinden)\",\n  \"LabelEmailSettingsTestAddress\": \"Test Adresi\",\n  \"LabelEmbeddedCover\": \"Gömülü Kapak\",\n  \"LabelEnable\": \"Etkinleştir\",\n  \"LabelEncodingBackupLocation\": \"Orijinal ses dosyalarınızın bir yedeği şu konumda saklanacaktır:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Bölümler, çok parçalı sesli kitaplara gömülmez.\",\n  \"LabelEncodingClearItemCache\": \"Öğe önbelleğini periyodik olarak temizlediğinizden emin olun.\",\n  \"LabelEncodingFinishedM4B\": \"Bitmiş M4B, sesli kitap klasörünüze şu konuma konulacaktır:\",\n  \"LabelEncodingInfoEmbedded\": \"Üst veri, sesli kitap klasörünüzdeki ses parçalarının içine gömülecektir.\",\n  \"LabelEncodingStartedNavigation\": \"Görev başlatıldığında bu sayfadan ayrılabilirsiniz.\",\n  \"LabelEncodingTimeWarning\": \"Kodlama 30 dakikaya kadar sürebilir.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Uyarı: ffmpeg kodlama seçeneklerine aşina değilseniz bu ayarları güncellemeyin.\",\n  \"LabelEncodingWatcherDisabled\": \"Eğer izleyiciyi devre dışı bıraktıysanız, daha sonra bu sesli kitabı yeniden taramanız gerekecektir.\",\n  \"LabelEnd\": \"Bitiş\",\n  \"LabelEndOfChapter\": \"Bölüm Sonu\",\n  \"LabelEpisode\": \"Bölüm\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Bölüm RSS akışına bağlı değil\",\n  \"LabelEpisodeNumber\": \"Bölüm #{0}\",\n  \"LabelEpisodeTitle\": \"Bölüm Başlığı\",\n  \"LabelEpisodeType\": \"Bölüm Türü\",\n  \"LabelEpisodeUrlFromRssFeed\": \"RSS akışından bölüm URL'si\",\n  \"LabelEpisodes\": \"Bölümler\",\n  \"LabelEpisodic\": \"Bölümlü\",\n  \"LabelExample\": \"Örnek\",\n  \"LabelExpandSeries\": \"Seriyi Genişlet\",\n  \"LabelExpandSubSeries\": \"Alt Seriyi Genişlet\",\n  \"LabelExpired\": \"Süresi Doldu\",\n  \"LabelExpiresAt\": \"Bitiş Tarihi\",\n  \"LabelExpiresInSeconds\": \"Bitiş Süresi (saniye)\",\n  \"LabelExpiresNever\": \"Asla\",\n  \"LabelExplicit\": \"Açık İçerik\",\n  \"LabelExplicitChecked\": \"Açık İçerik (işaretli)\",\n  \"LabelExplicitUnchecked\": \"Açık İçerik Değil (işaretli değil)\",\n  \"LabelExportOPML\": \"OPML Olarak Dışa Aktar\",\n  \"LabelFeedURL\": \"Akış URL'si\",\n  \"LabelFetchingMetadata\": \"Üst Veri Alınıyor\",\n  \"LabelFile\": \"Dosya\",\n  \"LabelFileBirthtime\": \"Dosya Oluşturulma Zamanı\",\n  \"LabelFileBornDate\": \"Oluşturulma: {0}\",\n  \"LabelFileModified\": \"Dosya Değiştirilme Zamanı\",\n  \"LabelFileModifiedDate\": \"Değiştirilme: {0}\",\n  \"LabelFilename\": \"Dosya Adı\",\n  \"LabelFilterByUser\": \"Kullanıcıya Göre Filtrele\",\n  \"LabelFindEpisodes\": \"Bölüm Bul\",\n  \"LabelFinished\": \"Bitirildi\",\n  \"LabelFinishedDate\": \"Bitirildi: {0}\",\n  \"LabelFolder\": \"Klasör\",\n  \"LabelFolders\": \"Klasörler\",\n  \"LabelFontBold\": \"Kalın\",\n  \"LabelFontBoldness\": \"Yazı Tipi Kalınlığı\",\n  \"LabelFontFamily\": \"Yazı tipi ailesi\",\n  \"LabelFontItalic\": \"İtalik\",\n  \"LabelFontScale\": \"Yazı Tipi Ölçeği\",\n  \"LabelFontStrikethrough\": \"Üstü Çizili\",\n  \"LabelFormat\": \"Biçim\",\n  \"LabelFull\": \"Tam\",\n  \"LabelGenre\": \"Tür\",\n  \"LabelGenres\": \"Türler\",\n  \"LabelHardDeleteFile\": \"Dosyayı kalıcı olarak sil\",\n  \"LabelHasEbook\": \"E-kitabı var\",\n  \"LabelHasSupplementaryEbook\": \"Ek e-kitabı var\",\n  \"LabelHideSubtitles\": \"Altyazıları Gizle\",\n  \"LabelHighestPriority\": \"En Yüksek Öncelik\",\n  \"LabelHost\": \"Sunucu\",\n  \"LabelHour\": \"Saat\",\n  \"LabelHours\": \"Saat\",\n  \"LabelIcon\": \"Simge\",\n  \"LabelImageURLFromTheWeb\": \"Web'den resim URL'si\",\n  \"LabelInProgress\": \"Devam Ediyor\",\n  \"LabelIncludeInTracklist\": \"Parça Listesine Dahil Et\",\n  \"LabelIncomplete\": \"Tamamlanmamış\",\n  \"LabelInterval\": \"Aralık\",\n  \"LabelIntervalCustomDailyWeekly\": \"Özel günlük/haftalık\",\n  \"LabelIntervalEvery12Hours\": \"Her 12 saatte bir\",\n  \"LabelIntervalEvery15Minutes\": \"Her 15 dakikada bir\",\n  \"LabelIntervalEvery2Hours\": \"Her 2 saatte bir\",\n  \"LabelIntervalEvery30Minutes\": \"Her 30 dakikada bir\",\n  \"LabelIntervalEvery6Hours\": \"Her 6 saatte bir\",\n  \"LabelIntervalEveryDay\": \"Her gün\",\n  \"LabelIntervalEveryHour\": \"Her saat\",\n  \"LabelIntervalEveryMinute\": \"Her dakika\",\n  \"LabelInvert\": \"Ters Çevir\",\n  \"LabelItem\": \"Öğe\",\n  \"LabelJumpBackwardAmount\": \"Geri sarma miktarı\",\n  \"LabelJumpForwardAmount\": \"İleri sarma miktarı\",\n  \"LabelLanguage\": \"Dil\",\n  \"LabelLanguageDefaultServer\": \"Varsayılan Sunucu Dili\",\n  \"LabelLanguages\": \"Diller\",\n  \"LabelLastBookAdded\": \"Son Eklenen Kitap\",\n  \"LabelLastBookUpdated\": \"Son Güncellenen Kitap\",\n  \"LabelLastProgressDate\": \"Son ilerleme: {0}\",\n  \"LabelLastSeen\": \"Son Görülme\",\n  \"LabelLastTime\": \"Son Kez\",\n  \"LabelLastUpdate\": \"Son Güncelleme\",\n  \"LabelLayout\": \"Düzen\",\n  \"LabelLayoutSinglePage\": \"Tek sayfa\",\n  \"LabelLayoutSplitPage\": \"Bölünmüş sayfa\",\n  \"LabelLess\": \"Daha Az\",\n  \"LabelLibrariesAccessibleToUser\": \"Kullanıcının Erişebildiği Kütüphaneler\",\n  \"LabelLibrary\": \"Kütüphane\",\n  \"LabelLibraryFilterSublistEmpty\": \"{0} yok\",\n  \"LabelLibraryItem\": \"Kütüphane Öğesi\",\n  \"LabelLibraryName\": \"Kütüphane Adı\",\n  \"LabelLibrarySortByProgress\": \"İlerleme: Son Güncellenme\",\n  \"LabelLibrarySortByProgressFinished\": \"İlerleme: Bitirildi\",\n  \"LabelLibrarySortByProgressStarted\": \"İlerleme: Başlandı\",\n  \"LabelLimit\": \"Sınır\",\n  \"LabelLineSpacing\": \"Satır aralığı\",\n  \"LabelListenAgain\": \"Tekrar Dinle\",\n  \"LabelLogLevelDebug\": \"Hata Ayıklama (Debug)\",\n  \"LabelLogLevelInfo\": \"Bilgi (Info)\",\n  \"LabelLogLevelWarn\": \"Uyarı (Warn)\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Bu tarihten sonra yeni bölümleri ara\",\n  \"LabelLowestPriority\": \"En Düşük Öncelik\",\n  \"LabelMatchConfidence\": \"Güvenilirlik\",\n  \"LabelMatchExistingUsersBy\": \"Mevcut kullanıcıları şuna göre eşleştir\",\n  \"LabelMatchExistingUsersByDescription\": \"Mevcut kullanıcıları bağlamak için kullanılır. Bağlandıktan sonra, kullanıcılar SSO sağlayıcınızdan gelen benzersiz bir kimlikle eşleştirilir\",\n  \"LabelMaxEpisodesToDownload\": \"İndirilecek maks. bölüm sayısı. Sınırsız için 0 kullanın.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Kontrol başına indirilecek maks. yeni bölüm sayısı\",\n  \"LabelMaxEpisodesToKeep\": \"Saklanacak maks. bölüm sayısı\",\n  \"LabelMaxEpisodesToKeepHelp\": \"0 değeri maks. sınır belirlemez. Yeni bir bölüm otomatik indirildikten sonra, X'ten fazla bölümünüz varsa en eski bölümü siler. Bu, her yeni indirme için yalnızca 1 bölüm siler.\",\n  \"LabelMediaPlayer\": \"Medya Oynatıcı\",\n  \"LabelMediaType\": \"Medya Türü\",\n  \"LabelMetaTag\": \"Meta Etiketi\",\n  \"LabelMetaTags\": \"Meta Etiketleri\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Daha yüksek öncelikli üst veri kaynakları, daha düşük öncelikli üst veri kaynaklarının üzerine yazacaktır\",\n  \"LabelMetadataProvider\": \"Üst Veri Sağlayıcısı\",\n  \"LabelMinute\": \"Dakika\",\n  \"LabelMinutes\": \"Dakika\",\n  \"LabelMissing\": \"Eksik\",\n  \"LabelMissingEbook\": \"E-kitabı yok\",\n  \"LabelMissingSupplementaryEbook\": \"Ek e-kitabı yok\",\n  \"LabelMobileRedirectURIs\": \"İzin Verilen Mobil Yönlendirme URI'leri\",\n  \"LabelMobileRedirectURIsDescription\": \"Bu, mobil uygulamalar için geçerli yönlendirme URL'lerinin beyaz listesidir. Varsayılan olan <code>audiobookshelf://oauth</code>'dır, bunu kaldırabilir veya üçüncü taraf uygulama entegrasyonu için ek URL'lerle tamamlayabilirsiniz. Tek giriş olarak bir yıldız işareti (<code>*</code>) kullanmak herhangi bir URL'ye izin verir.\",\n  \"LabelMore\": \"Daha Fazla\",\n  \"LabelMoreInfo\": \"Daha Fazla Bilgi\",\n  \"LabelName\": \"İsim\",\n  \"LabelNarrator\": \"Anlatıcı\",\n  \"LabelNarrators\": \"Anlatıcılar\",\n  \"LabelNew\": \"Yeni\",\n  \"LabelNewPassword\": \"Yeni Şifre\",\n  \"LabelNewestAuthors\": \"En Yeni Yazarlar\",\n  \"LabelNewestEpisodes\": \"En Yeni Bölümler\",\n  \"LabelNextBackupDate\": \"Sonraki yedekleme tarihi\",\n  \"LabelNextChapters\": \"Sonraki bölümler şunlar olacak:\",\n  \"LabelNextScheduledRun\": \"Sonraki zamanlanmış çalışma\",\n  \"LabelNoApiKeys\": \"API anahtarı yok\",\n  \"LabelNoCustomMetadataProviders\": \"Özel üst veri sağlayıcısı yok\",\n  \"LabelNoEpisodesSelected\": \"Seçili bölüm yok\",\n  \"LabelNotFinished\": \"Bitirilmedi\",\n  \"LabelNotStarted\": \"Başlanmadı\",\n  \"LabelNotes\": \"Notlar\",\n  \"LabelNotificationAppriseURL\": \"Apprise URL(leri)\",\n  \"LabelNotificationAvailableVariables\": \"Mevcut değişkenler\",\n  \"LabelNotificationBodyTemplate\": \"Gövde Şablonu\",\n  \"LabelNotificationEvent\": \"Bildirim Olayı\",\n  \"LabelNotificationTitleTemplate\": \"Başlık Şablonu\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Maks. başarısız deneme sayısı\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Bildirimler bu kadar çok kez gönderilemediğinde devre dışı bırakılır\",\n  \"LabelNotificationsMaxQueueSize\": \"Bildirim olayları için maks. sıra boyutu\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Olaylar saniyede 1 kez tetiklenmekle sınırlıdır. Sıra maksimum boyuttaysa olaylar yoksayılır. Bu, bildirim spam'ini önler.\",\n  \"LabelNumberOfBooks\": \"Kitap Sayısı\",\n  \"LabelNumberOfChapters\": \"Bölüm sayısı:\",\n  \"LabelNumberOfEpisodes\": \"Bölüm Sayısı\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Uygulama içindeki kullanıcı eylemleri için yönetici olmayan rollere uygulanacak gelişmiş izinleri içeren OpenID talebinin adı (<b>yapılandırılmışsa</b>). Talep yanıtta eksikse, ABS'ye erişim reddedilir. Tek bir seçenek eksikse, '<code>false</code>' olarak kabul edilir. Kimlik sağlayıcısının talebinin beklenen yapıyla eşleştiğinden emin olun:\",\n  \"LabelOpenIDClaims\": \"Gelişmiş grup ve izin atamasını devre dışı bırakmak için aşağıdaki seçenekleri boş bırakın, bu durumda otomatik olarak 'Kullanıcı' grubu atanır.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Kullanıcının gruplarının bir listesini içeren OpenID talebinin adı. Genellikle '<code>groups</code>' olarak adlandırılır. (<b>Yapılandırılmışsa</b>), uygulama, bu grupların talepte büyük/küçük harfe duyarsız olarak 'admin', 'user' veya 'guest' olarak adlandırılması koşuluyla, kullanıcının grup üyeliklerine göre rolleri otomatik olarak atayacaktır. Talep bir liste içermelidir ve bir kullanıcı birden fazla gruba aitse, uygulama en yüksek erişim düzeyine karşılık gelen rolü atayacaktır. Hiçbir grup eşleşmezse, erişim reddedilir.\",\n  \"LabelOpenRSSFeed\": \"RSS Akışını Aç\",\n  \"LabelOverwrite\": \"Üzerine Yaz\",\n  \"LabelPaginationPageXOfY\": \"Sayfa {0} / {1}\",\n  \"LabelPassword\": \"Şifre\",\n  \"LabelPath\": \"Yol\",\n  \"LabelPermanent\": \"Kalıcı\",\n  \"LabelPermissionsAccessAllLibraries\": \"Tüm Kütüphanelere Erişebilir\",\n  \"LabelPermissionsAccessAllTags\": \"Tüm Etiketlere Erişebilir\",\n  \"LabelPermissionsAccessExplicitContent\": \"Açık İçeriğe Erişebilir\",\n  \"LabelPermissionsCreateEreader\": \"E-kitap Okuyucu Oluşturabilir\",\n  \"LabelPermissionsDelete\": \"Silebilir\",\n  \"LabelPermissionsDownload\": \"İndirebilir\",\n  \"LabelPermissionsUpdate\": \"Güncelleyebilir\",\n  \"LabelPermissionsUpload\": \"Yükleyebilir\",\n  \"LabelPersonalYearReview\": \"Yıl Değerlendirmen ({0})\",\n  \"LabelPhotoPathURL\": \"Fotoğraf Yolu/URL'si\",\n  \"LabelPlayMethod\": \"Oynatma Yöntemi\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Oynatma Hızı Artırma/Azaltma Miktarı\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} / {1}\",\n  \"LabelPlaylists\": \"Oynatma Listeleri\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Podcast arama bölgesi\",\n  \"LabelPodcastType\": \"Podcast Türü\",\n  \"LabelPodcasts\": \"Podcast'ler\",\n  \"LabelPort\": \"Port\",\n  \"LabelPrefixesToIgnore\": \"Yoksayılacak Önekler (büyük/küçük harf duyarsız)\",\n  \"LabelPreventIndexing\": \"Akışınızın iTunes ve Google tarafından dizine eklenmesini önleyin\",\n  \"LabelPrimaryEbook\": \"Birincil e-kitap\",\n  \"LabelProgress\": \"İlerleme\",\n  \"LabelProvider\": \"Sağlayıcı\",\n  \"LabelProviderAuthorizationValue\": \"Yetkilendirme Başlık Değeri\",\n  \"LabelPubDate\": \"Yayın Tarihi\",\n  \"LabelPublishYear\": \"Yayın Yılı\",\n  \"LabelPublishedDate\": \"Yayınlandı: {0}\",\n  \"LabelPublishedDecade\": \"Yayınlandığı On Yıl\",\n  \"LabelPublishedDecades\": \"Yayınlandığı On Yıllar\",\n  \"LabelPublisher\": \"Yayıncı\",\n  \"LabelPublishers\": \"Yayıncılar\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Özel Sahip E-postası\",\n  \"LabelRSSFeedCustomOwnerName\": \"Özel Sahip Adı\",\n  \"LabelRSSFeedOpen\": \"RSS Akışı Açık\",\n  \"LabelRSSFeedPreventIndexing\": \"Dizine Eklemeyi Önle\",\n  \"LabelRSSFeedSlug\": \"RSS Akışı Kısa Adı\",\n  \"LabelRSSFeedURL\": \"RSS Akışı URL'si\",\n  \"LabelRandomly\": \"Rastgele\",\n  \"LabelReAddSeriesToContinueListening\": \"Seriyi 'Dinlemeye Devam Et'e yeniden ekle\",\n  \"LabelRead\": \"Oku\",\n  \"LabelReadAgain\": \"Tekrar Oku\",\n  \"LabelReadEbookWithoutProgress\": \"İlerlemeyi kaydetmeden e-kitap oku\",\n  \"LabelRecentSeries\": \"Son Seriler\",\n  \"LabelRecentlyAdded\": \"Son Eklenenler\",\n  \"LabelRecommended\": \"Önerilenler\",\n  \"LabelRedo\": \"Yinele\",\n  \"LabelRegion\": \"Bölge\",\n  \"LabelReleaseDate\": \"Çıkış Tarihi\",\n  \"LabelRemoveAllMetadataAbs\": \"Tüm metadata.abs dosyalarını kaldır\",\n  \"LabelRemoveAllMetadataJson\": \"Tüm metadata.json dosyalarını kaldır\",\n  \"LabelRemoveAudibleBranding\": \"Bölümlerden Audible giriş ve çıkışını kaldır\",\n  \"LabelRemoveCover\": \"Kapağı kaldır\",\n  \"LabelRemoveMetadataFile\": \"Kütüphane öğesi klasörlerindeki üst veri dosyalarını kaldır\",\n  \"LabelRemoveMetadataFileHelp\": \"{0} klasörlerinizdeki tüm metadata.json ve metadata.abs dosyalarını kaldırın.\",\n  \"LabelRowsPerPage\": \"Sayfa başına satır\",\n  \"LabelSearchTerm\": \"Arama Terimi\",\n  \"LabelSearchTitle\": \"Başlık Ara\",\n  \"LabelSearchTitleOrASIN\": \"Başlık veya ASIN Ara\",\n  \"LabelSeason\": \"Sezon\",\n  \"LabelSeasonNumber\": \"Sezon #{0}\",\n  \"LabelSelectAll\": \"Tümünü Seç\",\n  \"LabelSelectAllEpisodes\": \"Tüm bölümleri seç\",\n  \"LabelSelectEpisodesShowing\": \"Gösterilen {0} bölümü seç\",\n  \"LabelSelectUser\": \"Kullanıcı seç\",\n  \"LabelSelectUsers\": \"Kullanıcıları seç\",\n  \"LabelSendEbookToDevice\": \"E-kitabı şuraya gönder...\",\n  \"LabelSequence\": \"Sıra\",\n  \"LabelSerial\": \"Seri\",\n  \"LabelSeries\": \"Seriler\",\n  \"LabelSeriesName\": \"Seri Adı\",\n  \"LabelSeriesProgress\": \"Seri İlerlemesi\",\n  \"LabelServerLogLevel\": \"Sunucu Kayıt Seviyesi\",\n  \"LabelServerYearReview\": \"Sunucu Yıl Değerlendirmesi ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Birincil olarak ayarla\",\n  \"LabelSetEbookAsSupplementary\": \"Ek olarak ayarla\",\n  \"LabelSettingsAllowIframe\": \"Iframe içine gömülmesine izin ver\",\n  \"LabelSettingsAudiobooksOnly\": \"Sadece sesli kitaplar\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Bu ayarın etkinleştirilmesi, bir sesli kitap klasörünün içinde olmadıkları sürece e-kitap dosyalarını yoksayar, bu durumda ek e-kitaplar olarak ayarlanırlar\",\n  \"LabelSettingsBookshelfViewHelp\": \"Ahşap raflı skeuomorfik tasarım\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast desteği\",\n  \"LabelSettingsDateFormat\": \"Tarih Formatı\",\n  \"LabelSettingsEnableWatcher\": \"Kütüphanelerdeki değişiklikleri otomatik olarak izle\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Kütüphanedeki değişiklikleri otomatik olarak izle\",\n  \"LabelSettingsEnableWatcherHelp\": \"Dosya değişiklikleri algılandığında öğelerin otomatik olarak eklenmesini/güncellenmesini sağlar. *Sunucunun yeniden başlatılmasını gerektirir\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Epub'larda betiklenmiş içeriğe izin ver\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Epub dosyalarının betik çalıştırmasına izin verin. Epub dosyalarının kaynağına güvenmiyorsanız bu ayarı devre dışı bırakmanız önerilir.\",\n  \"LabelSettingsExperimentalFeatures\": \"Deneysel özellikler\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Geri bildiriminize ve test yardımınıza ihtiyaç duyabilecek geliştirme aşamasındaki özellikler. Github tartışmasını açmak için tıklayın.\",\n  \"LabelSettingsFindCovers\": \"Kapak bul\",\n  \"LabelSettingsFindCoversHelp\": \"Sesli kitabınızın gömülü bir kapağı veya klasör içinde bir kapak resmi yoksa, tarayıcı bir kapak bulmaya çalışır.<br>Not: Bu, tarama süresini uzatacaktır\",\n  \"LabelSettingsHideSingleBookSeries\": \"Tek kitaplık serileri gizle\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Tek bir kitaba sahip seriler, seriler sayfasından ve ana sayfa raflarından gizlenir.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Ana sayfa kitaplık görünümünü kullan\",\n  \"LabelSettingsLibraryBookshelfView\": \"Kütüphane kitaplık görünümünü kullan\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Tamamlanma yüzdesi şundan büyükse\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Kalan süre şundan azsa (saniye)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Medya öğesini bitti olarak işaretle\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Seriye Devam Et'te önceki kitapları atla\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Seriye Devam Et ana sayfa rafı, en az bir kitabı bitmiş ve devam eden kitabı olmayan serilerde başlanmamış ilk kitabı gösterir. Bu ayarı etkinleştirmek, seriyi başlanmamış ilk kitaptan değil, en son tamamlanan kitaptan devam ettirir.\",\n  \"LabelSettingsParseSubtitles\": \"Altyazıları ayrıştır\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Sesli kitap klasör adlarından altyazıları çıkarın.<br>Altyazı \\\" - \\\" ile ayrılmalıdır<br>örn. \\\"Kitap Başlığı - Bir Altyazı\\\" altyazısı \\\"Bir Altyazı\\\"dır\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Eşleşen üst veriyi tercih et\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Hızlı Eşleştirme kullanıldığında eşleşen veriler öğe ayrıntılarının üzerine yazılır. Varsayılan olarak Hızlı Eşleştirme yalnızca eksik ayrıntıları doldurur.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Zaten bir ASIN'e sahip kitapları eşleştirmeyi atla\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Zaten bir ISBN'e sahip kitapları eşleştirmeyi atla\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Sıralarken önekleri yoksay\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"örn. \\\"the\\\" öneki için \\\"The Book Title\\\" kitap başlığı \\\"Book Title, The\\\" olarak sıralanır\",\n  \"LabelSettingsSquareBookCovers\": \"Kare kitap kapakları kullan\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Standart 1.6:1 kitap kapakları yerine kare kapakları tercih et\",\n  \"LabelSettingsStoreCoversWithItem\": \"Kapakları öğeyle birlikte sakla\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Varsayılan olarak kapaklar /metadata/items içinde saklanır, bu ayarı etkinleştirmek kapakları kütüphane öğesi klasörünüzde saklar. Yalnızca \\\"cover\\\" adlı bir dosya tutulur\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Üst veriyi öğeyle birlikte sakla\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Varsayılan olarak üst veri dosyaları /metadata/items içinde saklanır, bu ayarı etkinleştirmek üst veri dosyalarını kütüphane öğesi klasörlerinizde saklar\",\n  \"LabelSettingsTimeFormat\": \"Saat Formatı\",\n  \"LabelShare\": \"Paylaş\",\n  \"LabelShareDownloadableHelp\": \"Paylaşım bağlantısına sahip kullanıcıların kütüphane öğesinin bir zip dosyasını indirmesine olanak tanır.\",\n  \"LabelShareOpen\": \"Paylaşım Açık\",\n  \"LabelShareURL\": \"Paylaşım URL'si\",\n  \"LabelShowAll\": \"Tümünü Göster\",\n  \"LabelShowSeconds\": \"Saniyeleri göster\",\n  \"LabelShowSubtitles\": \"Altyazıları Göster\",\n  \"LabelSize\": \"Boyut\",\n  \"LabelSleepTimer\": \"Uyku Zamanlayıcısı\",\n  \"LabelSlug\": \"Kısa Ad (Slug)\",\n  \"LabelSortAscending\": \"Artan\",\n  \"LabelSortDescending\": \"Azalan\",\n  \"LabelSortPubDate\": \"Yayın Tarihine Göre Sırala\",\n  \"LabelStart\": \"Başlangıç\",\n  \"LabelStartTime\": \"Başlangıç Zamanı\",\n  \"LabelStarted\": \"Başlandı\",\n  \"LabelStartedAt\": \"Başlama Tarihi\",\n  \"LabelStartedDate\": \"Başlandı: {0}\",\n  \"LabelStatsAudioTracks\": \"Ses Parçaları\",\n  \"LabelStatsAuthors\": \"Yazarlar\",\n  \"LabelStatsBestDay\": \"En İyi Gün\",\n  \"LabelStatsDailyAverage\": \"Günlük Ortalama\",\n  \"LabelStatsDays\": \"Gün\",\n  \"LabelStatsDaysListened\": \"Dinlenen Gün Sayısı\",\n  \"LabelStatsHours\": \"Saat\",\n  \"LabelStatsInARow\": \"üst üste\",\n  \"LabelStatsItemsFinished\": \"Bitirilen Öğe Sayısı\",\n  \"LabelStatsItemsInLibrary\": \"Kütüphanedeki Öğe Sayısı\",\n  \"LabelStatsMinutes\": \"dakika\",\n  \"LabelStatsMinutesListening\": \"Dinlenen Dakika\",\n  \"LabelStatsOverallDays\": \"Toplam Gün\",\n  \"LabelStatsOverallHours\": \"Toplam Saat\",\n  \"LabelStatsWeekListening\": \"Haftalık Dinleme\",\n  \"LabelSubtitle\": \"Altyazı\",\n  \"LabelSupportedFileTypes\": \"Desteklenen Dosya Türleri\",\n  \"LabelTag\": \"Etiket\",\n  \"LabelTags\": \"Etiketler\",\n  \"LabelTagsAccessibleToUser\": \"Kullanıcının Erişebildiği Etiketler\",\n  \"LabelTagsNotAccessibleToUser\": \"Kullanıcının Erişemediği Etiketler\",\n  \"LabelTasks\": \"Çalışan Görevler\",\n  \"LabelTextEditorBulletedList\": \"Madde imli liste\",\n  \"LabelTextEditorLink\": \"Bağlantı\",\n  \"LabelTextEditorNumberedList\": \"Numaralı liste\",\n  \"LabelTextEditorUnlink\": \"Bağlantıyı kaldır\",\n  \"LabelTheme\": \"Tema\",\n  \"LabelThemeDark\": \"Koyu\",\n  \"LabelThemeLight\": \"Açık\",\n  \"LabelThemeSepia\": \"Sepya\",\n  \"LabelTimeBase\": \"Zaman Temeli\",\n  \"LabelTimeDurationXHours\": \"{0} saat\",\n  \"LabelTimeDurationXMinutes\": \"{0} dakika\",\n  \"LabelTimeDurationXSeconds\": \"{0} saniye\",\n  \"LabelTimeInMinutes\": \"Süre (dakika)\",\n  \"LabelTimeLeft\": \"{0} kaldı\",\n  \"LabelTimeListened\": \"Dinlenen Süre\",\n  \"LabelTimeListenedToday\": \"Bugün Dinlenen Süre\",\n  \"LabelTimeRemaining\": \"{0} kaldı\",\n  \"LabelTimeToShift\": \"Kaydırılacak süre (saniye)\",\n  \"LabelTitle\": \"Başlık\",\n  \"LabelToolsEmbedMetadata\": \"Üst Veri Göm\",\n  \"LabelToolsEmbedMetadataDescription\": \"Kapak resmi ve bölümler dahil olmak üzere üst verileri ses dosyalarına gömün.\",\n  \"LabelToolsM4bEncoder\": \"M4B Kodlayıcı\",\n  \"LabelToolsMakeM4b\": \"M4B Sesli Kitap Dosyası Oluştur\",\n  \"LabelToolsMakeM4bDescription\": \"Gömülü üst veri, kapak resmi ve bölümler içeren bir .M4B sesli kitap dosyası oluşturun.\",\n  \"LabelToolsSplitM4b\": \"M4B'yi MP3'lere Böl\",\n  \"LabelToolsSplitM4bDescription\": \"Gömülü üst veri, kapak resmi ve bölümlerle bir M4B'den bölümlere göre bölünmüş MP3'ler oluşturun.\",\n  \"LabelTotalDuration\": \"Toplam Süre\",\n  \"LabelTotalTimeListened\": \"Toplam Dinlenen Süre\",\n  \"LabelTrackFromFilename\": \"Dosya Adından Parça\",\n  \"LabelTrackFromMetadata\": \"Üst Veriden Parça\",\n  \"LabelTracks\": \"Parçalar\",\n  \"LabelTracksMultiTrack\": \"Çoklu parça\",\n  \"LabelTracksNone\": \"Parça yok\",\n  \"LabelTracksSingleTrack\": \"Tek parça\",\n  \"LabelTrailer\": \"Fragman\",\n  \"LabelType\": \"Tür\",\n  \"LabelUnabridged\": \"Kısaltılmamış\",\n  \"LabelUndo\": \"Geri Al\",\n  \"LabelUnknown\": \"Bilinmeyen\",\n  \"LabelUnknownPublishDate\": \"Bilinmeyen yayın tarihi\",\n  \"LabelUpdateCover\": \"Kapağı Güncelle\",\n  \"LabelUpdateCoverHelp\": \"Bir eşleşme bulunduğunda seçilen kitaplar için mevcut kapakların üzerine yazılmasına izin ver\",\n  \"LabelUpdateDetails\": \"Detayları Güncelle\",\n  \"LabelUpdateDetailsHelp\": \"Bir eşleşme bulunduğunda seçilen kitaplar için mevcut ayrıntıların üzerine yazılmasına izin ver\",\n  \"LabelUpdatedAt\": \"Güncellenme Tarihi\",\n  \"LabelUploaderDragAndDrop\": \"Dosyaları veya klasörleri sürükleyip bırakın\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Dosyaları sürükleyip bırakın\",\n  \"LabelUploaderDropFiles\": \"Dosyaları bırakın\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Başlık, yazar ve seriyi otomatik olarak getir\",\n  \"LabelUseAdvancedOptions\": \"Gelişmiş Seçenekleri Kullan\",\n  \"LabelUseChapterTrack\": \"Bölüm parçasını kullan\",\n  \"LabelUseFullTrack\": \"Tam parçayı kullan\",\n  \"LabelUseZeroForUnlimited\": \"Sınırsız için 0 kullanın\",\n  \"LabelUser\": \"Kullanıcı\",\n  \"LabelUsername\": \"Kullanıcı Adı\",\n  \"LabelValue\": \"Değer\",\n  \"LabelVersion\": \"Sürüm\",\n  \"LabelViewBookmarks\": \"Yer imlerini görüntüle\",\n  \"LabelViewChapters\": \"Bölümleri görüntüle\",\n  \"LabelViewPlayerSettings\": \"Oynatıcı ayarlarını görüntüle\",\n  \"LabelViewQueue\": \"Oynatma sırasını görüntüle\",\n  \"LabelVolume\": \"Ses Seviyesi\",\n  \"LabelWebRedirectURLsDescription\": \"Giriş yaptıktan sonra web uygulamasına geri yönlendirmeye izin vermek için OAuth sağlayıcınızda bu URL'leri yetkilendirin:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Yönlendirme URL'leri için Alt Klasör\",\n  \"LabelWeekdaysToRun\": \"Çalıştırılacak hafta günleri\",\n  \"LabelXBooks\": \"{0} kitap\",\n  \"LabelXItems\": \"{0} öğe\",\n  \"LabelYearReviewHide\": \"Yıl Değerlendirmesini Gizle\",\n  \"LabelYearReviewShow\": \"Yıl Değerlendirmesini Gör\",\n  \"LabelYourAudiobookDuration\": \"Sesli kitabınızın süresi\",\n  \"LabelYourBookmarks\": \"Yer İmlerin\",\n  \"LabelYourPlaylists\": \"Oynatma Listelerin\",\n  \"LabelYourProgress\": \"İlerlemen\",\n  \"MessageAddToPlayerQueue\": \"Oynatma sırasına ekle\",\n  \"MessageAppriseDescription\": \"Bu özelliği kullanmak için <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a>'nin bir örneğinin çalışıyor olması veya aynı istekleri işleyecek bir api'nizin olması gerekir. <br />Apprise API Url'si, bildirimi göndermek için tam URL yolu olmalıdır, örn. API örneğiniz <code>http://192.168.1.1:8337</code> adresinde sunuluyorsa <code>http://192.168.1.1:8337/notify</code> girmelisiniz.\",\n  \"MessageAsinCheck\": \"Amazon'dan değil, doğru Audible bölgesinden ASIN kullandığınızdan emin olun.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Eski API jetonları gelecekte kaldırılacaktır. Bunun yerine <a href=\\\"/config/api-keys\\\">API Anahtarları</a> kullanın.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"OIDC değişikliklerini uygulamak için kaydettikten sonra sunucunuzu yeniden başlatın.\",\n  \"MessageAuthenticationSecurityMessage\": \"Güvenlik için kimlik doğrulama iyileştirildi. Tüm kullanıcıların yeniden giriş yapması gerekmektedir.\",\n  \"MessageBackupsDescription\": \"Yedekler; kullanıcıları, kullanıcı ilerlemesini, kütüphane öğesi ayrıntılarını, sunucu ayarlarını ve <code>/metadata/items</code> & <code>/metadata/authors</code> içinde depolanan görüntüleri içerir. Yedekler, kütüphane klasörlerinizde depolanan herhangi bir dosyayı <strong>içermez</strong>.\",\n  \"MessageBackupsLocationEditNote\": \"Not: Yedekleme konumunu güncellemek, mevcut yedekleri taşımaz veya değiştirmez\",\n  \"MessageBackupsLocationNoEditNote\": \"Not: Yedekleme konumu bir ortam değişkeni aracılığıyla ayarlanır ve buradan değiştirilemez.\",\n  \"MessageBackupsLocationPathEmpty\": \"Yedekleme konumu yolu boş olamaz\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Etkin alanları tüm öğelerden gelen verilerle doldurun. Birden çok değere sahip alanlar birleştirilecektir\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Etkin harita ayrıntıları alanlarını bu öğeden gelen verilerle doldurun\",\n  \"MessageBatchQuickMatchDescription\": \"Hızlı Eşleştirme, seçilen öğeler için eksik kapakları ve üst verileri eklemeye çalışacaktır. Hızlı Eşleştirme'nin mevcut kapakların ve/veya üst verilerin üzerine yazmasına izin vermek için aşağıdaki seçenekleri etkinleştirin.\",\n  \"MessageBookshelfNoCollections\": \"Henüz hiç koleksiyon oluşturmadın\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Koleksiyonlar herkese açıktır. Kütüphaneye erişimi olan tüm kullanıcılar bunları görebilir.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Açık RSS akışı yok\",\n  \"MessageBookshelfNoResultsForFilter\": \"\\\"{0}: {1}\\\" filtresi için sonuç bulunamadı\",\n  \"MessageBookshelfNoResultsForQuery\": \"Sorgu için sonuç bulunamadı\",\n  \"MessageBookshelfNoSeries\": \"Hiç serin yok\",\n  \"MessageBulkChapterPattern\": \"Bu numaralandırma deseniyle kaç bölüm eklemek istersiniz?\",\n  \"MessageChapterEndIsAfter\": \"Bölüm sonu, sesli kitabınızın sonundan sonra\",\n  \"MessageChapterErrorFirstNotZero\": \"İlk bölüm 0'da başlamalıdır\",\n  \"MessageChapterErrorStartGteDuration\": \"Geçersiz başlangıç zamanı, sesli kitap süresinden az olmalıdır\",\n  \"MessageChapterErrorStartLtPrev\": \"Geçersiz başlangıç zamanı, önceki bölüm başlangıç zamanından büyük veya ona eşit olmalıdır\",\n  \"MessageChapterStartIsAfter\": \"Bölüm başlangıcı, sesli kitabınızın sonundan sonra\",\n  \"MessageChaptersNotFound\": \"Bölüm bulunamadı\",\n  \"MessageCheckingCron\": \"Cron kontrol ediliyor...\",\n  \"MessageConfirmCloseFeed\": \"Bu akışı kapatmak istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteApiKey\": \"\\\"{0}\\\" adlı API anahtarını silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteBackup\": \"{0} tarihli yedeği silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteDevice\": \"\\\"{0}\\\" adlı e-kitap okuyucu cihazını silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteFile\": \"Bu, dosyayı dosya sisteminizden silecektir. Emin misiniz?\",\n  \"MessageConfirmDeleteLibrary\": \"\\\"{0}\\\" adlı kütüphaneyi kalıcı olarak silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Bu, kütüphane öğesini veritabanından ve dosya sisteminizden silecektir. Emin misiniz?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Bu, {0} kütüphane öğesini veritabanından ve dosya sisteminizden silecektir. Emin misiniz?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"\\\"{0}\\\" adlı özel üst veri sağlayıcısını silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteNotification\": \"Bu bildirimi silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmDeleteSession\": \"Bu oturumu silmek istediğinizden emin misiniz?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"{0} ses dosyasına üst veri gömmek istediğinizden emin misiniz?\",\n  \"MessageConfirmForceReScan\": \"Yeniden taramaya zorlamak istediğinizden emin misiniz?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Tüm bölümleri bitirildi olarak işaretlemek istediğinizden emin misiniz?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Tüm bölümleri bitirilmedi olarak işaretlemek istediğinizden emin misiniz?\",\n  \"MessageConfirmMarkItemFinished\": \"\\\"{0}\\\" öğesini bitirildi olarak işaretlemek istediğinizden emin misiniz?\",\n  \"MessageConfirmMarkItemNotFinished\": \"\\\"{0}\\\" öğesini bitirilmedi olarak işaretlemek istediğinizden emin misiniz?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Bu serideki tüm kitapları bitirildi olarak işaretlemek istediğinizden emin misiniz?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Bu serideki tüm kitapları bitirilmedi olarak işaretlemek istediğinizden emin misiniz?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Bu bildirimi test verileriyle tetiklemek istiyor musunuz?\",\n  \"MessageConfirmPurgeCache\": \"Önbelleği temizlemek, <code>/metadata/cache</code> konumundaki tüm dizini silecektir. <br /><br />Önbellek dizinini kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmPurgeItemsCache\": \"Öğe önbelleğini temizlemek, <code>/metadata/cache/items</code> konumundaki tüm dizini silecektir.<br />Emin misiniz?\",\n  \"MessageConfirmQuickEmbed\": \"Uyarı! Hızlı gömme, ses dosyalarınızı yedeklemez. Ses dosyalarınızın bir yedeğine sahip olduğunuzdan emin olun. <br><br>Devam etmek ister misiniz?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Bölümleri hızlı eşleştirmek, bir eşleşme bulunursa ayrıntıların üzerine yazacaktır. Yalnızca eşleşmeyen bölümler güncellenecektir. Emin misiniz?\",\n  \"MessageConfirmReScanLibraryItems\": \"{0} öğeyi yeniden taramak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveAllChapters\": \"Tüm bölümleri kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveAuthor\": \"\\\"{0}\\\" yazarını kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveCollection\": \"\\\"{0}\\\" koleksiyonunu kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveEpisode\": \"\\\"{0}\\\" bölümünü kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Not: Bu, \\\"Dosyayı kalıcı olarak sil\\\" seçeneği işaretlenmedikçe ses dosyasını silmez\",\n  \"MessageConfirmRemoveEpisodes\": \"{0} bölümü kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveListeningSessions\": \"{0} dinleme oturumunu kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Kütüphane öğesi klasörlerinizdeki tüm metadata.{0} dosyalarını kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemoveNarrator\": \"\\\"{0}\\\" anlatıcısını kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRemovePlaylist\": \"\\\"{0}\\\" adlı oynatma listenizi kaldırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRenameGenre\": \"\\\"{0}\\\" türünü tüm öğeler için \\\"{1}\\\" olarak yeniden adlandırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Not: Bu tür zaten mevcut, bu yüzden birleştirilecekler.\",\n  \"MessageConfirmRenameGenreWarning\": \"Uyarı! Farklı bir büyük/küçük harf kullanımına sahip benzer bir tür zaten mevcut: \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"\\\"{0}\\\" etiketini tüm öğeler için \\\"{1}\\\" olarak yeniden adlandırmak istediğinizden emin misiniz?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Not: Bu etiket zaten mevcut, bu yüzden birleştirilecekler.\",\n  \"MessageConfirmRenameTagWarning\": \"Uyarı! Farklı bir büyük/küçük harf kullanımına sahip benzer bir etiket zaten mevcut: \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"İlerlemenizi sıfırlamak istediğinizden emin misiniz?\",\n  \"MessageConfirmSendEbookToDevice\": \"{0} türündeki \\\"{1}\\\" adlı e-kitabı \\\"{2}\\\" cihazına göndermek istediğinizden emin misiniz?\",\n  \"MessageConfirmUnlinkOpenId\": \"Bu kullanıcının OpenID bağlantısını kaldırmak istediğinizden emin misiniz?\",\n  \"MessageDaysListenedInTheLastYear\": \"Son bir yılda {0} gün dinlendi\",\n  \"MessageDownloadingEpisode\": \"Bölüm indiriliyor\",\n  \"MessageDragFilesIntoTrackOrder\": \"Dosyaları doğru parça sırasına sürükleyin\",\n  \"MessageEmbedFailed\": \"Gömme Başarısız Oldu!\",\n  \"MessageEmbedFinished\": \"Gömme Tamamlandı!\",\n  \"MessageEmbedQueue\": \"Üst veri gömme için sıraya alındı (sırada {0} var)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} bölüm indirme için sıraya alındı\",\n  \"MessageEreaderDevices\": \"E-kitapların teslimatını sağlamak için, yukarıdaki e-posta adresini aşağıda listelenen her cihaz için geçerli bir gönderen olarak eklemeniz gerekebilir.\",\n  \"MessageFeedURLWillBe\": \"Akış URL'si {0} olacak\",\n  \"MessageFetching\": \"Alınıyor...\",\n  \"MessageForceReScanDescription\": \"tüm dosyaları yeni bir tarama gibi yeniden tarayacaktır. Ses dosyası ID3 etiketleri, OPF dosyaları ve metin dosyaları yeni olarak taranacaktır.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"{1} tarihinde <strong>{0} dinleme</strong>\",\n  \"MessageHeatmapNoListeningSessions\": \"{0} tarihinde dinleme oturumu yok\",\n  \"MessageImportantNotice\": \"Önemli Duyuru!\",\n  \"MessageInsertChapterBelow\": \"Aşağıya bölüm ekle\",\n  \"MessageInvalidAsin\": \"Geçersiz ASIN\",\n  \"MessageItemsSelected\": \"{0} öğe seçildi\",\n  \"MessageItemsUpdated\": \"{0} öğe güncellendi\",\n  \"MessageJoinUsOn\": \"Bize katılın\",\n  \"MessageLoading\": \"Yükleniyor...\",\n  \"MessageLoadingFolders\": \"Klasörler yükleniyor...\",\n  \"MessageLogsDescription\": \"Kayıtlar <code>/metadata/logs</code> içinde JSON dosyaları olarak saklanır. Çökme kayıtları <code>/metadata/logs/crash_logs.txt</code> içinde saklanır.\",\n  \"MessageM4BFailed\": \"M4B Başarısız Oldu!\",\n  \"MessageM4BFinished\": \"M4B Tamamlandı!\",\n  \"MessageMapChapterTitles\": \"Zaman damgalarını ayarlamadan bölüm başlıklarını mevcut sesli kitap bölümlerinizle eşleyin\",\n  \"MessageMarkAllEpisodesFinished\": \"Tüm bölümleri bitirildi olarak işaretle\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Tüm bölümleri bitirilmedi olarak işaretle\",\n  \"MessageMarkAsFinished\": \"Bitirildi Olarak İşaretle\",\n  \"MessageMarkAsNotFinished\": \"Bitirilmedi Olarak İşaretle\",\n  \"MessageMatchBooksDescription\": \"kütüphanedeki kitapları seçilen arama sağlayıcısından bir kitapla eşleştirmeye ve boş ayrıntıları ve kapak resmini doldurmaya çalışacaktır. Ayrıntıların üzerine yazmaz.\",\n  \"MessageNoAudioTracks\": \"Ses parçası yok\",\n  \"MessageNoAuthors\": \"Yazar yok\",\n  \"MessageNoBackups\": \"Yedek yok\",\n  \"MessageNoBookmarks\": \"Yer İmi Yok\",\n  \"MessageNoChapters\": \"Bölüm Yok\",\n  \"MessageNoCollections\": \"Koleksiyon Yok\",\n  \"MessageNoCoversFound\": \"Kapak bulunamadı\",\n  \"MessageNoDescription\": \"Açıklama yok\",\n  \"MessageNoDevices\": \"Cihaz yok\",\n  \"MessageNoDownloadsInProgress\": \"Şu anda devam eden indirme yok\",\n  \"MessageNoDownloadsQueued\": \"Sırada bekleyen indirme yok\",\n  \"MessageNoEpisodeMatchesFound\": \"Bölüm eşleşmesi bulunamadı\",\n  \"MessageNoEpisodes\": \"Bölüm yok\",\n  \"MessageNoFoldersAvailable\": \"Kullanılabilir klasör yok\",\n  \"MessageNoGenres\": \"Tür yok\",\n  \"MessageNoIssues\": \"Sorun yok\",\n  \"MessageNoItems\": \"Öğe Yok\",\n  \"MessageNoItemsFound\": \"Öğe bulunamadı\",\n  \"MessageNoListeningSessions\": \"Dinleme Oturumu Yok\",\n  \"MessageNoLogs\": \"Kayıt yok\",\n  \"MessageNoMediaProgress\": \"Medya ilerlemesi yok\",\n  \"MessageNoNotifications\": \"Bildirim yok\",\n  \"MessageNoPodcastFeed\": \"Geçersiz podcast: Akış Yok\",\n  \"MessageNoPodcastsFound\": \"Podcast bulunamadı\",\n  \"MessageNoResults\": \"Sonuç yok\",\n  \"MessageNoSearchResultsFor\": \"\\\"{0}\\\" için arama sonucu bulunamadı\",\n  \"MessageNoSeries\": \"Seri yok\",\n  \"MessageNoTags\": \"Etiket yok\",\n  \"MessageNoTasksRunning\": \"Çalışan görev yok\",\n  \"MessageNoUpdatesWereNecessary\": \"Güncelleme gerekmedi\",\n  \"MessageNoUserPlaylists\": \"Hiç oynatma listeniz yok\",\n  \"MessageNoUserPlaylistsHelp\": \"Oynatma listeleri özeldir. Yalnızca oluşturan kullanıcı bunları görebilir.\",\n  \"MessageNotYetImplemented\": \"Henüz uygulanmadı\",\n  \"MessageOpmlPreviewNote\": \"Not: Bu, ayrıştırılmış OPML dosyasının bir önizlemesidir. Gerçek podcast başlığı RSS akışından alınacaktır.\",\n  \"MessageOr\": \"veya\",\n  \"MessagePauseChapter\": \"Bölüm oynatımını duraklat\",\n  \"MessagePlayChapter\": \"Bölümün başlangıcını dinle\",\n  \"MessagePlaylistCreateFromCollection\": \"Koleksiyondan oynatma listesi oluştur\",\n  \"MessagePleaseWait\": \"Lütfen bekleyin...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast'in eşleştirme için kullanılabilecek bir RSS akış URL'si yok\",\n  \"MessagePodcastSearchField\": \"Arama terimi veya RSS akış URL'si girin\",\n  \"MessageQuickEmbedInProgress\": \"Hızlı gömme devam ediyor\",\n  \"MessageQuickEmbedQueue\": \"Hızlı gömme için sıraya alındı (sırada {0} var)\",\n  \"MessageQuickMatchAllEpisodes\": \"Tüm Bölümleri Hızlı Eşleştir\",\n  \"MessageQuickMatchDescription\": \"Boş öğe ayrıntılarını ve kapağı '{0}'dan gelen ilk eşleşme sonucuyla doldurun. 'Eşleşen üst veriyi tercih et' sunucu ayarı etkinleştirilmedikçe ayrıntıların üzerine yazmaz.\",\n  \"MessageRemoveChapter\": \"Bölümü kaldır\",\n  \"MessageRemoveEpisodes\": \"{0} bölümü kaldır\",\n  \"MessageRemoveFromPlayerQueue\": \"Oynatma sırasından kaldır\",\n  \"MessageRemoveUserWarning\": \"\\\"{0}\\\" kullanıcısını kalıcı olarak silmek istediğinizden emin misiniz?\",\n  \"MessageReportBugsAndContribute\": \"Hataları bildirin, özellikler isteyin ve katkıda bulunun\",\n  \"MessageResetChaptersConfirm\": \"Bölümleri sıfırlamak ve yaptığınız değişiklikleri geri almak istediğinizden emin misiniz?\",\n  \"MessageRestoreBackupConfirm\": \"Şu tarihte oluşturulan yedeği geri yüklemek istediğinizden emin misiniz\",\n  \"MessageRestoreBackupWarning\": \"Bir yedeği geri yüklemek, /config konumundaki tüm veritabanının ve /metadata/items & /metadata/authors içindeki kapak resimlerinin üzerine yazacaktır.<br /><br />Yedekler, kütüphane klasörlerinizdeki hiçbir dosyayı değiştirmez. Sunucu ayarlarını kütüphane klasörlerinizde kapak resmi ve üst veri saklamak için etkinleştirdiyseniz, bunlar yedeklenmez veya üzerine yazılmaz.<br /><br />Sunucunuzu kullanan tüm istemciler otomatik olarak yenilenecektir.\",\n  \"MessageScheduleLibraryScanNote\": \"Çoğu kullanıcı için bu ayarı pasif bırakması ve \\\"Kütüphanedeki değişiklikleri otomatik olarak izle\\\" seçeneğini aktif etmesi önerilir. O seçenek kütüphane dizinlerindeki herhangi bir değişikliği otomatik olarak tespit edecektir. Eğer dosya sisteminiz \\\"Kütüphanedeki değişiklikleri otomatik olarak izle\\\" yöntemini desteklemiyorsa (örn; NFS dosya sistemi) bu özelliği aktif edebilirsiniz.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Her {0} günü saat {1}'de çalıştır\",\n  \"MessageSearchResultsFor\": \"Arama sonuçları\",\n  \"MessageSelected\": \"{0} seçildi\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Seri sırası boşluk içeremez\",\n  \"MessageServerCouldNotBeReached\": \"Sunucuya ulaşılamadı\",\n  \"MessageSetChaptersFromTracksDescription\": \"Her ses dosyasını bir bölüm ve bölüm başlığını ses dosyası adı olarak kullanarak bölümleri ayarlayın\",\n  \"MessageShareExpirationWillBe\": \"Son kullanma tarihi <strong>{0}</strong> olacak\",\n  \"MessageShareExpiresIn\": \"{0} içinde sona eriyor\",\n  \"MessageShareURLWillBe\": \"Paylaşım URL'si <strong>{0}</strong> olacak\",\n  \"MessageStartPlaybackAtTime\": \"\\\"{0}\\\" için oynatmayı {1}'de başlatmak istiyor musunuz?\",\n  \"MessageTaskAudioFileNotWritable\": \"\\\"{0}\\\" ses dosyası yazılabilir değil\",\n  \"MessageTaskCanceledByUser\": \"Görev kullanıcı tarafından iptal edildi\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"\\\"{0}\\\" bölümü indiriliyor\",\n  \"MessageTaskEmbeddingMetadata\": \"Üst veri gömülüyor\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"\\\"{0}\\\" sesli kitabına üst veri gömülüyor\",\n  \"MessageTaskEncodingM4b\": \"M4B kodlanıyor\",\n  \"MessageTaskEncodingM4bDescription\": \"\\\"{0}\\\" sesli kitabı tek bir m4b dosyasına kodlanıyor\",\n  \"MessageTaskFailed\": \"Başarısız oldu\",\n  \"MessageTaskFailedToBackupAudioFile\": \"\\\"{0}\\\" ses dosyasını yedekleme başarısız oldu\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Önbellek dizini oluşturma başarısız oldu\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"\\\"{0}\\\" dosyasına üst veri gömme başarısız oldu\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Ses dosyalarını birleştirme başarısız oldu\",\n  \"MessageTaskFailedToMoveM4bFile\": \"m4b dosyasını taşıma başarısız oldu\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Üst veri dosyasını yazma başarısız oldu\",\n  \"MessageTaskMatchingBooksInLibrary\": \"\\\"{0}\\\" kütüphanesindeki kitaplar eşleştiriliyor\",\n  \"MessageTaskNoFilesToScan\": \"Taranacak dosya yok\",\n  \"MessageTaskOpmlImport\": \"OPML içe aktarma\",\n  \"MessageTaskOpmlImportDescription\": \"{0} RSS akışından podcast'ler oluşturuluyor\",\n  \"MessageTaskOpmlImportFeed\": \"OPML içe aktarma akışı\",\n  \"MessageTaskOpmlImportFeedDescription\": \"\\\"{0}\\\" RSS akışı içe aktarılıyor\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Podcast akışı alınamadı\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"\\\"{0}\\\" podcast'i oluşturuluyor\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Podcast zaten bu yolda mevcut\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Podcast oluşturulamadı\",\n  \"MessageTaskOpmlImportFinished\": \"{0} podcast eklendi\",\n  \"MessageTaskOpmlParseFailed\": \"OPML dosyası ayrıştırılamadı\",\n  \"MessageTaskOpmlParseFastFail\": \"Geçersiz OPML dosyası, <opml> etiketi bulunamadı VEYA bir <outline> etiketi bulunamadı\",\n  \"MessageTaskOpmlParseNoneFound\": \"OPML dosyasında akış bulunamadı\",\n  \"MessageTaskScanItemsAdded\": \"{0} eklendi\",\n  \"MessageTaskScanItemsMissing\": \"{0} eksik\",\n  \"MessageTaskScanItemsUpdated\": \"{0} güncellendi\",\n  \"MessageTaskScanNoChangesNeeded\": \"Değişiklik gerekmiyor\",\n  \"MessageTaskScanningFileChanges\": \"\\\"{0}\\\" içindeki dosya değişiklikleri taranıyor\",\n  \"MessageTaskScanningLibrary\": \"\\\"{0}\\\" kütüphanesi taranıyor\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Hedef dizin yazılabilir değil\",\n  \"MessageThinking\": \"Düşünüyor...\",\n  \"MessageUploaderItemFailed\": \"Yükleme başarısız oldu\",\n  \"MessageUploaderItemSuccess\": \"Başarıyla Yüklendi!\",\n  \"MessageUploading\": \"Yükleniyor...\",\n  \"MessageValidCronExpression\": \"Geçerli cron ifadesi\",\n  \"MessageWatcherIsDisabledGlobally\": \"İzleyici genel olarak sunucu ayarlarında devre dışı bırakıldı\",\n  \"MessageXLibraryIsEmpty\": \"{0} Kütüphanesi boş!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Sesli kitabınızın süresi bulunan süreden daha uzun\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Sesli kitabınızın süresi bulunan süreden daha kısa\",\n  \"NoteChangeRootPassword\": \"Root kullanıcısı, boş şifreye sahip olabilen tek kullanıcıdır\",\n  \"NoteChapterEditorTimes\": \"Not: İlk bölüm başlangıç zamanı 0:00'da kalmalı ve son bölüm başlangıç zamanı bu sesli kitabın süresini aşamaz.\",\n  \"NoteFolderPicker\": \"Not: Zaten eşlenmiş klasörler gösterilmeyecektir\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Uyarı: Çoğu podcast uygulaması, RSS akış URL'sinin HTTPS kullanmasını gerektirir\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Uyarı: Bölümlerinizden bir veya daha fazlasının Yayın Tarihi yok. Bazı podcast uygulamaları bunu zorunlu kılar.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Medya dosyaları içeren klasörler ayrı kütüphane öğeleri olarak ele alınacaktır.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Yalnızca ses dosyaları yükleniyorsa, her ses dosyası ayrı bir sesli kitap olarak ele alınacaktır.\",\n  \"NoteUploaderUnsupportedFiles\": \"Desteklenmeyen dosyalar yoksayılır. Bir klasör seçerken veya bırakırken, bir öğe klasöründe olmayan diğer dosyalar yoksayılır.\",\n  \"NotificationOnBackupCompletedDescription\": \"Bir yedekleme tamamlandığında tetiklenir\",\n  \"NotificationOnBackupFailedDescription\": \"Bir yedekleme başarısız olduğunda tetiklenir\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Bir podcast bölümü otomatik indirildiğinde tetiklenir\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Çok fazla başarısız deneme nedeniyle otomatik bölüm indirmeleri devre dışı bırakıldığında tetiklenir\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Bir otomatik bölüm indirme için RSS akışı isteği başarısız olduğunda tetiklenir\",\n  \"NotificationOnTestDescription\": \"Bildirim sistemini test etmek için olay\",\n  \"PlaceholderBulkChapterInput\": \"Bölüm başlığı girin veya numaralandırma kullanın (ör. 'Bölüm 1', 'Kısım 10', '1.')\",\n  \"PlaceholderNewCollection\": \"Yeni koleksiyon adı\",\n  \"PlaceholderNewFolderPath\": \"Yeni klasör yolu\",\n  \"PlaceholderNewPlaylist\": \"Yeni oynatma listesi adı\",\n  \"PlaceholderSearch\": \"Ara...\",\n  \"PlaceholderSearchEpisode\": \"Bölüm ara...\",\n  \"StatsAuthorsAdded\": \"yazar eklendi\",\n  \"StatsBooksAdded\": \"kitap eklendi\",\n  \"StatsBooksAdditional\": \"Eklenenlerden bazıları…\",\n  \"StatsBooksFinished\": \"kitap bitirildi\",\n  \"StatsBooksFinishedThisYear\": \"Bu yıl bitirilen bazı kitaplar…\",\n  \"StatsBooksListenedTo\": \"kitap dinlendi\",\n  \"StatsCollectionGrewTo\": \"Kitap koleksiyonun şuna ulaştı…\",\n  \"StatsSessions\": \"oturum\",\n  \"StatsSpentListening\": \"dinleyerek geçirildi\",\n  \"StatsTopAuthor\": \"EN İYİ YAZAR\",\n  \"StatsTopAuthors\": \"EN İYİ YAZARLAR\",\n  \"StatsTopGenre\": \"EN İYİ TÜR\",\n  \"StatsTopGenres\": \"EN İYİ TÜRLER\",\n  \"StatsTopMonth\": \"EN İYİ AY\",\n  \"StatsTopNarrator\": \"EN İYİ ANLATICI\",\n  \"StatsTopNarrators\": \"EN İYİ ANLATICILAR\",\n  \"StatsTotalDuration\": \"Toplam süresi…\",\n  \"StatsYearInReview\": \"YILA GENEL BAKIŞ\",\n  \"ToastAccountUpdateSuccess\": \"Hesap güncellendi\",\n  \"ToastAppriseUrlRequired\": \"Bir Apprise URL'si girilmelidir\",\n  \"ToastAsinRequired\": \"ASIN gereklidir\",\n  \"ToastAuthorImageRemoveSuccess\": \"Yazar resmi kaldırıldı\",\n  \"ToastAuthorNotFound\": \"\\\"{0}\\\" yazarı bulunamadı\",\n  \"ToastAuthorRemoveSuccess\": \"Yazar kaldırıldı\",\n  \"ToastAuthorSearchNotFound\": \"Yazar bulunamadı\",\n  \"ToastAuthorUpdateMerged\": \"Yazar birleştirildi\",\n  \"ToastAuthorUpdateSuccess\": \"Yazar güncellendi\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Yazar güncellendi (resim bulunamadı)\",\n  \"ToastBackupAppliedSuccess\": \"Yedek uygulandı\",\n  \"ToastBackupCreateFailed\": \"Yedek oluşturulamadı\",\n  \"ToastBackupCreateSuccess\": \"Yedek oluşturuldu\",\n  \"ToastBackupDeleteFailed\": \"Yedek silinemedi\",\n  \"ToastBackupDeleteSuccess\": \"Yedek silindi\",\n  \"ToastBackupInvalidMaxKeep\": \"Geçersiz saklanacak yedek sayısı\",\n  \"ToastBackupInvalidMaxSize\": \"Geçersiz maksimum yedek boyutu\",\n  \"ToastBackupRestoreFailed\": \"Yedek geri yüklenemedi\",\n  \"ToastBackupUploadFailed\": \"Yedek yüklenemedi\",\n  \"ToastBackupUploadSuccess\": \"Yedek yüklendi\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Ayrıntılar öğelere uygulandı\",\n  \"ToastBatchDeleteFailed\": \"Toplu silme başarısız oldu\",\n  \"ToastBatchDeleteSuccess\": \"Toplu silme başarılı oldu\",\n  \"ToastBatchQuickMatchFailed\": \"Toplu Hızlı Eşleştirme başarısız oldu!\",\n  \"ToastBatchQuickMatchStarted\": \"{0} kitabın Toplu Hızlı Eşleştirmesi başladı!\",\n  \"ToastBatchUpdateFailed\": \"Toplu güncelleme başarısız oldu\",\n  \"ToastBatchUpdateSuccess\": \"Toplu güncelleme başarılı oldu\",\n  \"ToastBookmarkCreateFailed\": \"Yer işareti oluşturulamadı\",\n  \"ToastBookmarkCreateSuccess\": \"Yer imi eklendi\",\n  \"ToastBookmarkRemoveSuccess\": \"Yer imi kaldırıldı\",\n  \"ToastBulkChapterInvalidCount\": \"1 ile 150 arasında bir sayı girin\",\n  \"ToastCachePurgeFailed\": \"Önbellek temizlenemedi\",\n  \"ToastCachePurgeSuccess\": \"Önbellek başarıyla temizlendi\",\n  \"ToastChapterLocked\": \"Bölüm kilitli.\",\n  \"ToastChapterStartTimeAdjusted\": \"Bölüm başlangıç zamanı {0} saniye ayarlandı\",\n  \"ToastChaptersAllLocked\": \"Tüm bölümler kilitli. Zamanlarını kaydırmak için bazı bölümlerin kilidini açın.\",\n  \"ToastChaptersHaveErrors\": \"Bölümlerde hatalar var\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Geçersiz kaydırma miktarı. Son bölüm başlangıç zamanı bu sesli kitabın süresini aşacaktır.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Geçersiz kaydırma miktarı. İlk bölüm sıfır veya negatif uzunlukta olacak ve ikinci bölüm tarafından üzerine yazılacaktır. İkinci bölümün başlangıç süresini artırın.\",\n  \"ToastChaptersMustHaveTitles\": \"Bölümlerin başlıkları olmalıdır\",\n  \"ToastChaptersRemoved\": \"Bölümler kaldırıldı\",\n  \"ToastChaptersUpdated\": \"Bölümler güncellendi\",\n  \"ToastCollectionItemsAddFailed\": \"Öğe(ler) koleksiyona eklenemedi\",\n  \"ToastCollectionRemoveSuccess\": \"Koleksiyon kaldırıldı\",\n  \"ToastCollectionUpdateSuccess\": \"Koleksiyon güncellendi\",\n  \"ToastConnectionNotAvailable\": \"Bağlantı kurulamıyor. Lütfen daha sonra tekrar deneyin\",\n  \"ToastCoverSearchFailed\": \"Kapak arama başarısız\",\n  \"ToastCoverUpdateFailed\": \"Kapak güncellemesi başarısız oldu\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Tarih ve saat geçersiz veya eksik\",\n  \"ToastDeleteFileFailed\": \"Dosya silinemedi\",\n  \"ToastDeleteFileSuccess\": \"Dosya silindi\",\n  \"ToastDeviceAddFailed\": \"Cihaz eklenemedi\",\n  \"ToastDeviceNameAlreadyExists\": \"Bu ada sahip bir e-kitap okuyucu cihazı zaten mevcut\",\n  \"ToastDeviceTestEmailFailed\": \"Test e-postası gönderilemedi\",\n  \"ToastDeviceTestEmailSuccess\": \"Test e-postası gönderildi\",\n  \"ToastEmailSettingsUpdateSuccess\": \"E-posta ayarları güncellendi\",\n  \"ToastEncodeCancelFailed\": \"Kodlama iptal edilemedi\",\n  \"ToastEncodeCancelSucces\": \"Kodlama iptal edildi\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Sıra temizlenemedi\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Bölüm indirme sırası temizlendi\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} bölüm güncellendi\",\n  \"ToastErrorCannotShare\": \"Bu cihazda yerel olarak paylaşılamıyor\",\n  \"ToastFailedToCreate\": \"Oluşturulamadı\",\n  \"ToastFailedToDelete\": \"Silinemedi\",\n  \"ToastFailedToLoadData\": \"Veri yüklenemedi\",\n  \"ToastFailedToMatch\": \"Eşleştirilemedi\",\n  \"ToastFailedToShare\": \"Paylaşılamadı\",\n  \"ToastFailedToUpdate\": \"Güncellenemedi\",\n  \"ToastInvalidImageUrl\": \"Geçersiz resim URL'si\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Geçersiz maks. indirilecek bölüm sayısı\",\n  \"ToastInvalidUrl\": \"Geçersiz URL\",\n  \"ToastInvalidUrls\": \"Bir veya daha fazla URL geçersiz\",\n  \"ToastItemCoverUpdateSuccess\": \"Öğe kapağı güncellendi\",\n  \"ToastItemDeletedFailed\": \"Öğe silinemedi\",\n  \"ToastItemDeletedSuccess\": \"Öğe silindi\",\n  \"ToastItemDetailsUpdateSuccess\": \"Öğe ayrıntıları güncellendi\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Bitirildi olarak işaretlenemedi\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Öğe bitirildi olarak işaretlendi\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Bitirilmedi olarak işaretlenemedi\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Öğe bitirilmedi olarak işaretlendi\",\n  \"ToastItemUpdateSuccess\": \"Öğe güncellendi\",\n  \"ToastLibraryCreateFailed\": \"Kütüphane oluşturulamadı\",\n  \"ToastLibraryCreateSuccess\": \"\\\"{0}\\\" kütüphanesi oluşturuldu\",\n  \"ToastLibraryDeleteFailed\": \"Kütüphane silinemedi\",\n  \"ToastLibraryDeleteSuccess\": \"Kütüphane silindi\",\n  \"ToastLibraryScanFailedToStart\": \"Tarama başlatılamadı\",\n  \"ToastLibraryScanStarted\": \"Kütüphane taraması başladı\",\n  \"ToastLibraryUpdateSuccess\": \"\\\"{0}\\\" kütüphanesi güncellendi\",\n  \"ToastMatchAllAuthorsFailed\": \"Tüm yazarlar eşleştirilemedi\",\n  \"ToastMetadataFilesRemovedError\": \"metadata.{0} dosyaları kaldırılırken hata oluştu\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"Kütüphanede metadata.{0} dosyası bulunamadı\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Hiçbir metadata.{0} dosyası kaldırılmadı\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} dosyası kaldırıldı\",\n  \"ToastMustHaveAtLeastOnePath\": \"En az bir yol olmalıdır\",\n  \"ToastNameEmailRequired\": \"İsim ve e-posta gereklidir\",\n  \"ToastNameRequired\": \"İsim gereklidir\",\n  \"ToastNewApiKeyUserError\": \"Bir kullanıcı seçilmelidir\",\n  \"ToastNewEpisodesFound\": \"{0} yeni bölüm bulundu\",\n  \"ToastNewUserCreatedFailed\": \"Hesap oluşturulamadı: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Yeni hesap oluşturuldu\",\n  \"ToastNewUserLibraryError\": \"En az bir kütüphane seçilmelidir\",\n  \"ToastNewUserPasswordError\": \"Bir şifre olmalıdır, yalnızca root kullanıcısının boş şifresi olabilir\",\n  \"ToastNewUserTagError\": \"En az bir etiket seçilmelidir\",\n  \"ToastNewUserUsernameError\": \"Bir kullanıcı adı girin\",\n  \"ToastNoNewEpisodesFound\": \"Yeni bölüm bulunamadı\",\n  \"ToastNoRSSFeed\": \"Podcast'in bir RSS Akışı yok\",\n  \"ToastNoUpdatesNecessary\": \"Güncelleme gerekmiyor\",\n  \"ToastNotificationCreateFailed\": \"Bildirim oluşturulamadı\",\n  \"ToastNotificationDeleteFailed\": \"Bildirim silinemedi\",\n  \"ToastNotificationFailedMaximum\": \"Maks. başarısız deneme sayısı >= 0 olmalıdır\",\n  \"ToastNotificationQueueMaximum\": \"Maks. bildirim sırası >= 0 olmalıdır\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Bildirim ayarları güncellendi\",\n  \"ToastNotificationTestTriggerFailed\": \"Test bildirimi tetiklenemedi\",\n  \"ToastNotificationTestTriggerSuccess\": \"Test bildirimi tetiklendi\",\n  \"ToastNotificationUpdateSuccess\": \"Bildirim güncellendi\",\n  \"ToastPlaylistCreateFailed\": \"Oynatma listesi oluşturulamadı\",\n  \"ToastPlaylistCreateSuccess\": \"Oynatma listesi oluşturuldu\",\n  \"ToastPlaylistRemoveSuccess\": \"Oynatma listesi kaldırıldı\",\n  \"ToastPlaylistUpdateSuccess\": \"Oynatma listesi güncellendi\",\n  \"ToastPodcastCreateFailed\": \"Podcast oluşturulamadı\",\n  \"ToastPodcastCreateSuccess\": \"Podcast başarıyla oluşturuldu\",\n  \"ToastPodcastEpisodeUpdated\": \"Bölüm güncellendi\",\n  \"ToastPodcastGetFeedFailed\": \"Podcast akışı alınamadı\",\n  \"ToastPodcastNoEpisodesInFeed\": \"RSS akışında bölüm bulunamadı\",\n  \"ToastPodcastNoRssFeed\": \"Podcast'in bir RSS akışı yok\",\n  \"ToastProgressIsNotBeingSynced\": \"İlerleme senkronize edilmiyor, oynatmayı yeniden başlatın\",\n  \"ToastProviderCreatedFailed\": \"Sağlayıcı eklenemedi\",\n  \"ToastProviderCreatedSuccess\": \"Yeni sağlayıcı eklendi\",\n  \"ToastProviderNameAndUrlRequired\": \"İsim ve Url gereklidir\",\n  \"ToastProviderRemoveSuccess\": \"Sağlayıcı kaldırıldı\",\n  \"ToastRSSFeedCloseFailed\": \"RSS akışı kapatılamadı\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS akışı kapatıldı\",\n  \"ToastRemoveFailed\": \"Kaldırılamadı\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Öğe koleksiyondan kaldırılamadı\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Öğe koleksiyondan kaldırıldı\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Sorunlu kütüphane öğeleri kaldırılamadı\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Sorunlu kütüphane öğeleri kaldırıldı\",\n  \"ToastRenameFailed\": \"Yeniden adlandırılamadı\",\n  \"ToastRescanFailed\": \"{0} için Yeniden Tarama Başarısız Oldu\",\n  \"ToastRescanRemoved\": \"Yeniden Tarama tamamlandı, öğe kaldırıldı\",\n  \"ToastRescanUpToDate\": \"Yeniden Tarama tamamlandı, öğe günceldi\",\n  \"ToastRescanUpdated\": \"Yeniden Tarama tamamlandı, öğe güncellendi\",\n  \"ToastScanFailed\": \"Kütüphane öğesi taranamadı\",\n  \"ToastSelectAtLeastOneUser\": \"En az bir kullanıcı seçin\",\n  \"ToastSendEbookToDeviceFailed\": \"E-kitap cihaza gönderilemedi\",\n  \"ToastSendEbookToDeviceSuccess\": \"E-kitap \\\"{0}\\\" cihazına gönderildi\",\n  \"ToastSeriesSubmitFailedSameName\": \"Aynı ada sahip iki seri eklenemez\",\n  \"ToastSeriesUpdateFailed\": \"Seri güncellemesi başarısız oldu\",\n  \"ToastSeriesUpdateSuccess\": \"Seri güncellemesi başarılı oldu\",\n  \"ToastServerSettingsUpdateSuccess\": \"Sunucu ayarları güncellendi\",\n  \"ToastSessionCloseFailed\": \"Oturum kapatılamadı\",\n  \"ToastSessionDeleteFailed\": \"Oturum silinemedi\",\n  \"ToastSessionDeleteSuccess\": \"Oturum silindi\",\n  \"ToastSleepTimerDone\": \"Uyku zamanlayıcısı bitti... zZzzZz\",\n  \"ToastSlugMustChange\": \"Kısa ad (slug) geçersiz karakterler içeriyor\",\n  \"ToastSlugRequired\": \"Kısa ad (slug) gereklidir\",\n  \"ToastSocketConnected\": \"Soket bağlandı\",\n  \"ToastSocketDisconnected\": \"Soket bağlantısı kesildi\",\n  \"ToastSocketFailedToConnect\": \"Soket bağlanamadı\",\n  \"ToastSortingPrefixesEmptyError\": \"En az 1 sıralama öneki olmalıdır\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Sıralama önekleri güncellendi ({0} öğe)\",\n  \"ToastTitleRequired\": \"Başlık gereklidir\",\n  \"ToastUnknownError\": \"Bilinmeyen hata\",\n  \"ToastUnlinkOpenIdFailed\": \"Kullanıcının OpenID bağlantısı kaldırılamadı\",\n  \"ToastUnlinkOpenIdSuccess\": \"Kullanıcının OpenID bağlantısı kaldırıldı\",\n  \"ToastUploaderFilepathExistsError\": \"\\\"{0}\\\" dosya yolu sunucuda zaten mevcut\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"\\\"{0}\\\" öğesi, yükleme yolunun bir alt dizinini kullanıyor.\",\n  \"ToastUserDeleteFailed\": \"Kullanıcı silinemedi\",\n  \"ToastUserDeleteSuccess\": \"Kullanıcı silindi\",\n  \"ToastUserPasswordChangeSuccess\": \"Şifre başarıyla değiştirildi\",\n  \"ToastUserPasswordMismatch\": \"Şifreler eşleşmiyor\",\n  \"ToastUserPasswordMustChange\": \"Yeni şifre eski şifreyle aynı olamaz\",\n  \"ToastUserRootRequireName\": \"Bir root kullanıcı adı girilmelidir\",\n  \"TooltipAddChapters\": \"Bölüm(ler) ekle\",\n  \"TooltipAddOneSecond\": \"1 saniye ekle\",\n  \"TooltipAdjustChapterStart\": \"Başlangıç zamanını ayarlamak için tıkla\",\n  \"TooltipLockAllChapters\": \"Tüm bölümleri kilitle\",\n  \"TooltipLockChapter\": \"Bölümü kilitle (aralık için Shift+tıkla)\",\n  \"TooltipSubtractOneSecond\": \"1 saniye çıkar\",\n  \"TooltipUnlockAllChapters\": \"Tüm bölümlerin kilidini aç\",\n  \"TooltipUnlockChapter\": \"Bölüm kilidini aç (aralık için Shift+tıkla)\"\n}\n"
  },
  {
    "path": "client/strings/uk.json",
    "content": "{\n  \"ButtonAdd\": \"Додати\",\n  \"ButtonAddApiKey\": \"Додати ключ API\",\n  \"ButtonAddChapters\": \"Додати глави\",\n  \"ButtonAddDevice\": \"Додати пристрій\",\n  \"ButtonAddLibrary\": \"Додати бібліотеку\",\n  \"ButtonAddPodcasts\": \"Додати подкасти\",\n  \"ButtonAddUser\": \"Додати користувача\",\n  \"ButtonAddYourFirstLibrary\": \"Додайте вашу першу бібліотеку\",\n  \"ButtonApply\": \"Застосувати\",\n  \"ButtonApplyChapters\": \"Зберегти глави\",\n  \"ButtonAuthors\": \"Автори\",\n  \"ButtonBack\": \"Назад\",\n  \"ButtonBatchEditPopulateFromExisting\": \"Заповнити з наявних\",\n  \"ButtonBatchEditPopulateMapDetails\": \"Заповнити деталі карти\",\n  \"ButtonBrowseForFolder\": \"Огляд тек\",\n  \"ButtonCancel\": \"Скасувати\",\n  \"ButtonCancelEncode\": \"Скасувати кодування\",\n  \"ButtonChangeRootPassword\": \"Змінити кореневий пароль\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Перевірити та скачати нові епізоди\",\n  \"ButtonChooseAFolder\": \"Обрати теку\",\n  \"ButtonChooseFiles\": \"Обрати файли\",\n  \"ButtonClearFilter\": \"Очистити фільтр\",\n  \"ButtonClose\": \"Закрити\",\n  \"ButtonCloseFeed\": \"Закрити стрічку\",\n  \"ButtonCloseSession\": \"Закрити відкритий сеанс\",\n  \"ButtonCollections\": \"Добірки\",\n  \"ButtonConfigureScanner\": \"Налаштувати сканер\",\n  \"ButtonCreate\": \"Створити\",\n  \"ButtonCreateBackup\": \"Створити резервну копію\",\n  \"ButtonDelete\": \"Видалити\",\n  \"ButtonDownloadQueue\": \"Черга\",\n  \"ButtonEdit\": \"Редагувати\",\n  \"ButtonEditChapters\": \"Редагувати глави\",\n  \"ButtonEditPodcast\": \"Редагувати подкаст\",\n  \"ButtonEnable\": \"Увімкнути\",\n  \"ButtonFireAndFail\": \"Виконати і завершити з помилкою\",\n  \"ButtonFireOnTest\": \"Виконати подію onTest\",\n  \"ButtonForceReScan\": \"Примусово сканувати\",\n  \"ButtonFullPath\": \"Повний шлях\",\n  \"ButtonHide\": \"Приховати\",\n  \"ButtonHome\": \"Головна\",\n  \"ButtonIssues\": \"Проблеми\",\n  \"ButtonJumpBackward\": \"Перейти назад\",\n  \"ButtonJumpForward\": \"Перейти вперед\",\n  \"ButtonLatest\": \"Останні\",\n  \"ButtonLibrary\": \"Бібліотека\",\n  \"ButtonLogout\": \"Вийти\",\n  \"ButtonLookup\": \"Пошуки\",\n  \"ButtonManageTracks\": \"Керувати доріжками\",\n  \"ButtonMapChapterTitles\": \"Призначити назви глав\",\n  \"ButtonMatchAllAuthors\": \"Віднайти усіх авторів\",\n  \"ButtonMatchBooks\": \"Віднайти книги\",\n  \"ButtonNevermind\": \"Скасувати\",\n  \"ButtonNext\": \"Наступний\",\n  \"ButtonNextChapter\": \"Наступна глава\",\n  \"ButtonNextItemInQueue\": \"Наступний елемент у черзі\",\n  \"ButtonOk\": \"Добре\",\n  \"ButtonOpenFeed\": \"Відкрити стрічку\",\n  \"ButtonOpenManager\": \"Відкрити менеджер\",\n  \"ButtonPause\": \"Пауза\",\n  \"ButtonPlay\": \"Відтворити\",\n  \"ButtonPlayAll\": \"Відтворити все\",\n  \"ButtonPlaying\": \"Відтворюється\",\n  \"ButtonPlaylists\": \"Списки відтворення\",\n  \"ButtonPrevious\": \"Попередній\",\n  \"ButtonPreviousChapter\": \"Попередня глава\",\n  \"ButtonProbeAudioFile\": \"Перевірити аудіофайл\",\n  \"ButtonPurgeAllCache\": \"Очистити весь кеш\",\n  \"ButtonPurgeItemsCache\": \"Очистити кеш елементів\",\n  \"ButtonQueueAddItem\": \"Додати до черги\",\n  \"ButtonQueueRemoveItem\": \"Вилучити з черги\",\n  \"ButtonQuickEmbed\": \"Швидке вбудовування\",\n  \"ButtonQuickEmbedMetadata\": \"Швидко вбудувати метадані\",\n  \"ButtonQuickMatch\": \"Швидкий пошук\",\n  \"ButtonReScan\": \"Пересканувати\",\n  \"ButtonRead\": \"Читати\",\n  \"ButtonReadLess\": \"Згорнути\",\n  \"ButtonReadMore\": \"Читати більше\",\n  \"ButtonRefresh\": \"Оновити\",\n  \"ButtonRemove\": \"Видалити\",\n  \"ButtonRemoveAll\": \"Видалити все\",\n  \"ButtonRemoveAllLibraryItems\": \"Видалити всі елементи бібліотеки\",\n  \"ButtonRemoveFromContinueListening\": \"Видалити з Продовжити слухати\",\n  \"ButtonRemoveFromContinueReading\": \"Видалити з Продовжити читання\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Видалити серію з Продовжити серії\",\n  \"ButtonReset\": \"Скинути\",\n  \"ButtonResetToDefault\": \"Скинути до стандартних\",\n  \"ButtonRestore\": \"Відновити\",\n  \"ButtonSave\": \"Зберегти\",\n  \"ButtonSaveAndClose\": \"Зберегти і закрити\",\n  \"ButtonSaveTracklist\": \"Зберегти порядок\",\n  \"ButtonScan\": \"Сканувати\",\n  \"ButtonScanLibrary\": \"Сканувати бібліотеку\",\n  \"ButtonScrollLeft\": \"Прокрутити ліворуч\",\n  \"ButtonScrollRight\": \"Прокрутити праворуч\",\n  \"ButtonSearch\": \"Пошук\",\n  \"ButtonSelectFolderPath\": \"Обрати шлях до теки\",\n  \"ButtonSeries\": \"Серії\",\n  \"ButtonSetChaptersFromTracks\": \"Встановити глави за доріжками\",\n  \"ButtonShare\": \"Поширити\",\n  \"ButtonShiftTimes\": \"Зсунути час\",\n  \"ButtonShow\": \"Показати\",\n  \"ButtonStartM4BEncode\": \"Почати кодування у M4B\",\n  \"ButtonStartMetadataEmbed\": \"Почати вбудування метаданих\",\n  \"ButtonStats\": \"Статистика\",\n  \"ButtonSubmit\": \"Надіслати\",\n  \"ButtonTest\": \"Тест\",\n  \"ButtonUnlinkOpenId\": \"Вимкнути OpenID\",\n  \"ButtonUpload\": \"Завантажити\",\n  \"ButtonUploadBackup\": \"Завантажити резервну копію\",\n  \"ButtonUploadCover\": \"Завантажити обкладинку\",\n  \"ButtonUploadOPMLFile\": \"Завантажити OPML-файл\",\n  \"ButtonUserDelete\": \"Видалити користувача {0}\",\n  \"ButtonUserEdit\": \"Редагувати користувача {0}\",\n  \"ButtonViewAll\": \"Переглянути все\",\n  \"ButtonYes\": \"Так\",\n  \"ErrorUploadFetchMetadataAPI\": \"Помилка при отриманні метаданих\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора\",\n  \"ErrorUploadLacksTitle\": \"Потрібна назва\",\n  \"HeaderAccount\": \"Профіль\",\n  \"HeaderAddCustomMetadataProvider\": \"Додати користувацький постачальник метаданих\",\n  \"HeaderAdvanced\": \"Розширені\",\n  \"HeaderApiKeys\": \"Ключі API\",\n  \"HeaderAppriseNotificationSettings\": \"Налаштування сповіщень Apprise\",\n  \"HeaderAudioTracks\": \"Аудіодоріжки\",\n  \"HeaderAudiobookTools\": \"Інструменти керування файлами книг\",\n  \"HeaderAuthentication\": \"Автентифікація\",\n  \"HeaderBackups\": \"Резервні копії\",\n  \"HeaderBulkChapterModal\": \"Додати кілька розділів\",\n  \"HeaderChangePassword\": \"Змінити пароль\",\n  \"HeaderChapters\": \"Глави\",\n  \"HeaderChooseAFolder\": \"Обрати теку\",\n  \"HeaderCollection\": \"Добірка\",\n  \"HeaderCollectionItems\": \"Елементи добірки\",\n  \"HeaderCover\": \"Обкладинка\",\n  \"HeaderCurrentDownloads\": \"Поточні скачування\",\n  \"HeaderCustomMessageOnLogin\": \"Повідомлення при вході\",\n  \"HeaderCustomMetadataProviders\": \"Постачальники метаданих\",\n  \"HeaderDetails\": \"Подробиці\",\n  \"HeaderDownloadQueue\": \"Черга скачувань\",\n  \"HeaderEbookFiles\": \"Файли електронних книг\",\n  \"HeaderEmail\": \"Електронна пошта\",\n  \"HeaderEmailSettings\": \"Налаштування електронної пошти\",\n  \"HeaderEpisodes\": \"Епізоди\",\n  \"HeaderEreaderDevices\": \"Пристрої для читання\",\n  \"HeaderEreaderSettings\": \"Налаштування пристрою для читання\",\n  \"HeaderFiles\": \"Файли\",\n  \"HeaderFindChapters\": \"Пошук глав\",\n  \"HeaderIgnoredFiles\": \"Ігноровані файли\",\n  \"HeaderItemFiles\": \"Файли елементів\",\n  \"HeaderItemMetadataUtils\": \"Інструменти для метаданих\",\n  \"HeaderLastListeningSession\": \"Останній сеанс прослуховування\",\n  \"HeaderLatestEpisodes\": \"Останні епізоди\",\n  \"HeaderLibraries\": \"Бібліотеки\",\n  \"HeaderLibraryFiles\": \"Файли бібліотеки\",\n  \"HeaderLibraryStats\": \"Статистика бібліотеки\",\n  \"HeaderListeningSessions\": \"Сеанси прослуховування\",\n  \"HeaderListeningStats\": \"Статистика прослуховування\",\n  \"HeaderLogin\": \"Вхід\",\n  \"HeaderLogs\": \"Журнал\",\n  \"HeaderManageGenres\": \"Керувати жанрами\",\n  \"HeaderManageTags\": \"Керувати мітками\",\n  \"HeaderMapDetails\": \"Призначити подробиці\",\n  \"HeaderMatch\": \"Допасуй\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Порядок метаданих\",\n  \"HeaderMetadataToEmbed\": \"Вбудувати метадані\",\n  \"HeaderNewAccount\": \"Новий профіль\",\n  \"HeaderNewApiKey\": \"Новий ключ API\",\n  \"HeaderNewLibrary\": \"Нова бібліотека\",\n  \"HeaderNotificationCreate\": \"Створити сповіщення\",\n  \"HeaderNotificationUpdate\": \"Оновити сповіщення\",\n  \"HeaderNotifications\": \"Сповіщення\",\n  \"HeaderOpenIDConnectAuthentication\": \"Автентифікація OpenID Connect\",\n  \"HeaderOpenListeningSessions\": \"Відкриті сеанси прослуховування\",\n  \"HeaderOpenRSSFeed\": \"Відкрити RSS-канал\",\n  \"HeaderOtherFiles\": \"Інші файли\",\n  \"HeaderPasswordAuthentication\": \"Автентифікація за паролем\",\n  \"HeaderPermissions\": \"Дозволи\",\n  \"HeaderPlayerQueue\": \"Черга відтворення\",\n  \"HeaderPlayerSettings\": \"Налаштування програвача\",\n  \"HeaderPlaylist\": \"Список відтворення\",\n  \"HeaderPlaylistItems\": \"Елементи списку відтворення\",\n  \"HeaderPodcastsToAdd\": \"Подкасти для додання\",\n  \"HeaderPresets\": \"Пресети\",\n  \"HeaderPreviewCover\": \"Попередній перегляд\",\n  \"HeaderRSSFeedGeneral\": \"Подробиці RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS-канал відкрито\",\n  \"HeaderRSSFeeds\": \"RSS-канали\",\n  \"HeaderRemoveEpisode\": \"Видалити епізод\",\n  \"HeaderRemoveEpisodes\": \"Видалити епізодів: {0}\",\n  \"HeaderSavedMediaProgress\": \"Збережений прогрес медіа\",\n  \"HeaderSchedule\": \"Розклад\",\n  \"HeaderScheduleEpisodeDownloads\": \"Запланувати автоматичне скачування епізодів\",\n  \"HeaderScheduleLibraryScans\": \"Розклад автосканування бібліотеки\",\n  \"HeaderSession\": \"Сеанс\",\n  \"HeaderSetBackupSchedule\": \"Встановити розклад резервного копіювання\",\n  \"HeaderSettings\": \"Налаштування\",\n  \"HeaderSettingsDisplay\": \"Відображення\",\n  \"HeaderSettingsExperimental\": \"Експериментальні функції\",\n  \"HeaderSettingsGeneral\": \"Основне\",\n  \"HeaderSettingsScanner\": \"Сканер\",\n  \"HeaderSettingsSecurity\": \"Безпека\",\n  \"HeaderSettingsWebClient\": \"Вебклієнт\",\n  \"HeaderSleepTimer\": \"Таймер вимкнення\",\n  \"HeaderStatsLargestItems\": \"Найбільші елементи\",\n  \"HeaderStatsLongestItems\": \"Найдовші елементи (год)\",\n  \"HeaderStatsMinutesListeningChart\": \"Хвилин прослухано (останні 7 днів)\",\n  \"HeaderStatsRecentSessions\": \"Останні сеанси\",\n  \"HeaderStatsTop10Authors\": \"10 улюблених авторів\",\n  \"HeaderStatsTop5Genres\": \"5 улюблених жанрів\",\n  \"HeaderTableOfContents\": \"Зміст\",\n  \"HeaderTools\": \"Інструменти\",\n  \"HeaderUpdateAccount\": \"Оновити профіль\",\n  \"HeaderUpdateApiKey\": \"Оновити ключ API\",\n  \"HeaderUpdateAuthor\": \"Оновити автора\",\n  \"HeaderUpdateDetails\": \"Оновити подробиці\",\n  \"HeaderUpdateLibrary\": \"Оновити бібліотеку\",\n  \"HeaderUsers\": \"Користувачі\",\n  \"HeaderYearReview\": \"Підсумки {0} року\",\n  \"HeaderYourStats\": \"Ваша статистика\",\n  \"LabelAbridged\": \"Скорочена\",\n  \"LabelAbridgedChecked\": \"Скорочена (з прапорцем)\",\n  \"LabelAbridgedUnchecked\": \"Нескорочена (без прапорця)\",\n  \"LabelAccessibleBy\": \"Доступно\",\n  \"LabelAccountType\": \"Тип профілю\",\n  \"LabelAccountTypeAdmin\": \"Адміністратор\",\n  \"LabelAccountTypeGuest\": \"Гість\",\n  \"LabelAccountTypeUser\": \"Користувач\",\n  \"LabelActivities\": \"Діяльність\",\n  \"LabelActivity\": \"Активність\",\n  \"LabelAddToCollection\": \"Додати у добірку\",\n  \"LabelAddToCollectionBatch\": \"Додати {0} книг до добірки\",\n  \"LabelAddToPlaylist\": \"Додати до списку відтворення\",\n  \"LabelAddToPlaylistBatch\": \"Додати {0} елементів до списку відтворення\",\n  \"LabelAddedAt\": \"Дата додавання\",\n  \"LabelAddedDate\": \"Додано {0}\",\n  \"LabelAdminUsersOnly\": \"Тільки для адміністраторів\",\n  \"LabelAll\": \"Усе\",\n  \"LabelAllEpisodesDownloaded\": \"Усі епізоди скачано\",\n  \"LabelAllUsers\": \"Усі користувачі\",\n  \"LabelAllUsersExcludingGuests\": \"Усі, крім гостей\",\n  \"LabelAllUsersIncludingGuests\": \"Усі, включно з гостями\",\n  \"LabelAlreadyInYourLibrary\": \"Вже у вашій бібліотеці\",\n  \"LabelApiKeyCreated\": \"Ключ API \\\"{0}\\\" успішно створено.\",\n  \"LabelApiKeyCreatedDescription\": \"Обов’язково скопіюйте ключ API зараз, оскільки ви більше не зможете його побачити.\",\n  \"LabelApiKeyUser\": \"Діяти від імені користувача\",\n  \"LabelApiKeyUserDescription\": \"Цей ключ API матиме ті самі дозволи, що й користувач, від імені якого він діє. Це відображатиметься в журналах так само, як і в разі надсилання запиту користувачем.\",\n  \"LabelApiToken\": \"Токен API\",\n  \"LabelAppend\": \"Додати\",\n  \"LabelAudioBitrate\": \"Бітрейт аудіо (наприклад, 128k)\",\n  \"LabelAudioChannels\": \"Канали аудіо (1 або 2)\",\n  \"LabelAudioCodec\": \"Аудіокодек\",\n  \"LabelAuthor\": \"Автор\",\n  \"LabelAuthorFirstLast\": \"Автор (за ім'ям)\",\n  \"LabelAuthorLastFirst\": \"Автор (за прізвищем)\",\n  \"LabelAuthors\": \"Автори\",\n  \"LabelAutoDownloadEpisodes\": \"Автозавантаження епізодів\",\n  \"LabelAutoFetchMetadata\": \"Автозавантаження метаданих\",\n  \"LabelAutoFetchMetadataHelp\": \"Отримує метадані про назву, автора та серію під час послідового завантаження. Після завантаження може знадобитися пошук додаткових метаданих.\",\n  \"LabelAutoLaunch\": \"Автозапуск\",\n  \"LabelAutoLaunchDescription\": \"Автоматично перенаправляти зі сторінки входу до сервісу автентифікації (ручний перезапис шляху <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"Автореєстрація\",\n  \"LabelAutoRegisterDescription\": \"Автоматично створювати нових користувачів після входу\",\n  \"LabelBackToUser\": \"Повернутися до користувача\",\n  \"LabelBackupAudioFiles\": \"Резервне копіювання аудіофайлів\",\n  \"LabelBackupLocation\": \"Розташування резервних копій\",\n  \"LabelBackupsEnableAutomaticBackups\": \"Автоматичне резервне копіювання\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"Резервні копії збережено у /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"Максимальний розмір резервної копії (у ГБ) (0 — без обмежень)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"У якості захисту від неправильного налаштування, резервну копію не буде збережено, якщо її розмір перевищуватиме вказаний.\",\n  \"LabelBackupsNumberToKeep\": \"Кількість резервних копій\",\n  \"LabelBackupsNumberToKeepHelp\": \"Видаляється лише 1 резервна копія за раз, тому якщо у вас більше копій, видаліть їх вручну.\",\n  \"LabelBitrate\": \"Бітрейт\",\n  \"LabelBonus\": \"Бонус\",\n  \"LabelBooks\": \"Книг\",\n  \"LabelButtonText\": \"Текст кнопки\",\n  \"LabelByAuthor\": \"від {0}\",\n  \"LabelChangePassword\": \"Змінити пароль\",\n  \"LabelChannels\": \"Канали\",\n  \"LabelChapterCount\": \"{0} глав\",\n  \"LabelChapterTitle\": \"Назва глави\",\n  \"LabelChapters\": \"Глави\",\n  \"LabelChaptersFound\": \"глав знайдено\",\n  \"LabelClickForMoreInfo\": \"Натисніть, щоб дізнатися більше\",\n  \"LabelClickToUseCurrentValue\": \"Натисніть, щоб використати поточне значення\",\n  \"LabelClosePlayer\": \"Закрити програвач\",\n  \"LabelCodec\": \"Кодек\",\n  \"LabelCollapseSeries\": \"Згорнути серії\",\n  \"LabelCollapseSubSeries\": \"Згорнути підсерії\",\n  \"LabelCollection\": \"Добірка\",\n  \"LabelCollections\": \"Добірки\",\n  \"LabelComplete\": \"Завершити\",\n  \"LabelConfirmPassword\": \"Підтвердити пароль\",\n  \"LabelContinueListening\": \"Слухати далі\",\n  \"LabelContinueReading\": \"Продовжити читання\",\n  \"LabelContinueSeries\": \"Продовжити серії\",\n  \"LabelCorsAllowed\": \"Дозволені джерела CORS\",\n  \"LabelCover\": \"Обкладинка\",\n  \"LabelCoverImageURL\": \"URL-адреса обкладинки\",\n  \"LabelCoverProvider\": \"Постачальник покриття\",\n  \"LabelCreatedAt\": \"Дата створення\",\n  \"LabelCronExpression\": \"Команда cron\",\n  \"LabelCurrent\": \"Поточне\",\n  \"LabelCurrently\": \"Поточний:\",\n  \"LabelCustomCronExpression\": \"Спеціальна команда cron:\",\n  \"LabelDatetime\": \"Дата й час\",\n  \"LabelDays\": \"Днів\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Видалити з файлової системи (зніміть прапорець, щоб видалити лише з бази даних)\",\n  \"LabelDescription\": \"Опис\",\n  \"LabelDeselectAll\": \"Скасувати вибір\",\n  \"LabelDetectedPattern\": \"Виявлений візерунок:\",\n  \"LabelDevice\": \"Пристрій\",\n  \"LabelDeviceInfo\": \"Про пристрій\",\n  \"LabelDeviceIsAvailableTo\": \"Пристрій доступний для...\",\n  \"LabelDirectory\": \"Каталог\",\n  \"LabelDiscFromFilename\": \"Диск за назвою файлу\",\n  \"LabelDiscFromMetadata\": \"Диск за метаданими\",\n  \"LabelDiscover\": \"Огляд\",\n  \"LabelDownload\": \"Скачати\",\n  \"LabelDownloadNEpisodes\": \"Скачати {0} епізодів\",\n  \"LabelDownloadable\": \"Можна скачати\",\n  \"LabelDuration\": \"Тривалість\",\n  \"LabelDurationComparisonExactMatch\": \"(повний збіг)\",\n  \"LabelDurationComparisonLonger\": \"(на {0} довше)\",\n  \"LabelDurationComparisonShorter\": \"(на {0} коротше)\",\n  \"LabelDurationFound\": \"Виявлена тривалість:\",\n  \"LabelEbook\": \"Електронна книга\",\n  \"LabelEbooks\": \"Електронні книги\",\n  \"LabelEdit\": \"Редагувати\",\n  \"LabelEmail\": \"Електронна пошта\",\n  \"LabelEmailSettingsFromAddress\": \"Адреса відправника\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"Відхиляти неавторизовані сертифікати\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"Вимкнення перевірки SSL-сертифікату може наражати ваше з’єднання на ризики безпеки, наприклад атаки типу «людина посередині». Вимкніть цей параметр, лише якщо ви розумієте наслідки та довіряєте поштовому серверу, до якого підключаєтеся.\",\n  \"LabelEmailSettingsSecure\": \"Безпечне\",\n  \"LabelEmailSettingsSecureHelp\": \"Увімкніть, аби використовувати TLS при підключенні до сервера. Якщо вимкнути, то TLS буде використано, якщо сервер підтримує STARTTLS. Увімкніть, якщо ви підключаєтеся до порту 465. Вимкніть для портів 587 або 25. (з nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Тестова адреса\",\n  \"LabelEmbeddedCover\": \"Вбудована обкладинка\",\n  \"LabelEnable\": \"Увімкнути\",\n  \"LabelEncodingBackupLocation\": \"Резервна копія ваших оригінальних аудіофайлів буде збережена в:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"Глави не вбудовуються в багатодоріжкові аудіокниги.\",\n  \"LabelEncodingClearItemCache\": \"Переконайтесь, що періодично очищуєте кеш елементів.\",\n  \"LabelEncodingFinishedM4B\": \"Готовий M4B буде поміщений у вашу папку з аудіокнигами за адресою:\",\n  \"LabelEncodingInfoEmbedded\": \"Метадані будуть вбудовані в звукові доріжки всередині папки вашої аудіокниги.\",\n  \"LabelEncodingStartedNavigation\": \"Як тільки завдання розпочнеться, ви можете покинути цю сторінку.\",\n  \"LabelEncodingTimeWarning\": \"Кодування може зайняти до 30 хвилин.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"Увага: не змінюйте ці налаштування, якщо ви не знайомі з параметрами кодування ffmpeg.\",\n  \"LabelEncodingWatcherDisabled\": \"Якщо у вас вимкнено спостереження за папкою, вам потрібно буде повторно відсканувати цю аудіокнигу.\",\n  \"LabelEnd\": \"Кінець\",\n  \"LabelEndOfChapter\": \"Кінець глави\",\n  \"LabelEpisode\": \"Епізод\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"Епізод не прив'язаний до RSS-каналу\",\n  \"LabelEpisodeNumber\": \"Епізод #{0}\",\n  \"LabelEpisodeTitle\": \"Назва епізоду\",\n  \"LabelEpisodeType\": \"Тип епізоду\",\n  \"LabelEpisodeUrlFromRssFeed\": \"URL епізоду з RSS-каналу\",\n  \"LabelEpisodes\": \"Епізодов\",\n  \"LabelEpisodic\": \"Епізодичний\",\n  \"LabelExample\": \"Приклад\",\n  \"LabelExpandSeries\": \"Розгорнути серії\",\n  \"LabelExpandSubSeries\": \"Розгорнути підсерії\",\n  \"LabelExpired\": \"Термін дії минув\",\n  \"LabelExpiresAt\": \"Термін дії закінчується о\",\n  \"LabelExpiresInSeconds\": \"Термін дії закінчується через (секунди)\",\n  \"LabelExpiresNever\": \"Ніколи\",\n  \"LabelExplicit\": \"Відвертий\",\n  \"LabelExplicitChecked\": \"Відверта (з прапорцем)\",\n  \"LabelExplicitUnchecked\": \"Не відверта (без прапорця)\",\n  \"LabelExportOPML\": \"Експорт OPML\",\n  \"LabelFeedURL\": \"Адреса стрічки\",\n  \"LabelFetchingMetadata\": \"Отримання метаданих\",\n  \"LabelFile\": \"Файл\",\n  \"LabelFileBirthtime\": \"Дата створення файлу\",\n  \"LabelFileBornDate\": \"Народився {0}\",\n  \"LabelFileModified\": \"Дата зміни файлу\",\n  \"LabelFileModifiedDate\": \"Змінено {0}\",\n  \"LabelFilename\": \"Ім'я файлу\",\n  \"LabelFilterByUser\": \"Фільтрувати за користувачем\",\n  \"LabelFindEpisodes\": \"Знайти епізоди\",\n  \"LabelFinished\": \"Завершено\",\n  \"LabelFinishedDate\": \"Завершено {0}\",\n  \"LabelFolder\": \"Тека\",\n  \"LabelFolders\": \"Теки\",\n  \"LabelFontBold\": \"Жирний\",\n  \"LabelFontBoldness\": \"Товщина шрифту\",\n  \"LabelFontFamily\": \"Сімейство шрифтів\",\n  \"LabelFontItalic\": \"Курсив\",\n  \"LabelFontScale\": \"Розмір шрифту\",\n  \"LabelFontStrikethrough\": \"Закреслений\",\n  \"LabelFormat\": \"Формат\",\n  \"LabelFull\": \"Повний\",\n  \"LabelGenre\": \"Жанр\",\n  \"LabelGenres\": \"Жанри\",\n  \"LabelHardDeleteFile\": \"Остаточно видалити файл\",\n  \"LabelHasEbook\": \"Має електронну книгу\",\n  \"LabelHasSupplementaryEbook\": \"Має додаткову електронну книгу\",\n  \"LabelHideSubtitles\": \"Приховати субтитри\",\n  \"LabelHighestPriority\": \"Найвищий пріоритет\",\n  \"LabelHost\": \"Гост\",\n  \"LabelHour\": \"Година\",\n  \"LabelHours\": \"Години\",\n  \"LabelIcon\": \"Іконка\",\n  \"LabelImageURLFromTheWeb\": \"URL зображення з мережі\",\n  \"LabelInProgress\": \"У процесі\",\n  \"LabelIncludeInTracklist\": \"Включити у список\",\n  \"LabelIncomplete\": \"Не завершено\",\n  \"LabelInterval\": \"Частота\",\n  \"LabelIntervalCustomDailyWeekly\": \"Налаштувати щодня/щотижня\",\n  \"LabelIntervalEvery12Hours\": \"Кожні 12 годин\",\n  \"LabelIntervalEvery15Minutes\": \"Кожні 15 хвилин\",\n  \"LabelIntervalEvery2Hours\": \"Кожні 2 години\",\n  \"LabelIntervalEvery30Minutes\": \"Кожні 30 хвилин\",\n  \"LabelIntervalEvery6Hours\": \"Кожні 6 годин\",\n  \"LabelIntervalEveryDay\": \"Щодня\",\n  \"LabelIntervalEveryHour\": \"Щогодини\",\n  \"LabelIntervalEveryMinute\": \"Щохвилини\",\n  \"LabelInvert\": \"Інвертувати\",\n  \"LabelItem\": \"Елемент\",\n  \"LabelJumpBackwardAmount\": \"Час переходу назад\",\n  \"LabelJumpForwardAmount\": \"Час переходу вперед\",\n  \"LabelLanguage\": \"Мова\",\n  \"LabelLanguageDefaultServer\": \"Типова мова сервера\",\n  \"LabelLanguages\": \"Мови\",\n  \"LabelLastBookAdded\": \"Останню книгу додано\",\n  \"LabelLastBookUpdated\": \"Останню книгу оновлено\",\n  \"LabelLastProgressDate\": \"Останній прогрес: {0}\",\n  \"LabelLastSeen\": \"Активність\",\n  \"LabelLastTime\": \"Останній час\",\n  \"LabelLastUpdate\": \"Останнє оновлення\",\n  \"LabelLayout\": \"Вигляд\",\n  \"LabelLayoutSinglePage\": \"Одна сторінка\",\n  \"LabelLayoutSplitPage\": \"Розділити сторінку\",\n  \"LabelLess\": \"Менше\",\n  \"LabelLibrariesAccessibleToUser\": \"Бібліотеки, доступні користувачу\",\n  \"LabelLibrary\": \"Бібліотека\",\n  \"LabelLibraryFilterSublistEmpty\": \"Ні {0}\",\n  \"LabelLibraryItem\": \"Елемент бібліотеки\",\n  \"LabelLibraryName\": \"Назва бібліотеки\",\n  \"LabelLibrarySortByProgress\": \"Прогрес: Останнє оновлення\",\n  \"LabelLibrarySortByProgressFinished\": \"Прогрес: Завершено\",\n  \"LabelLibrarySortByProgressStarted\": \"Прогрес: Розпочато\",\n  \"LabelLimit\": \"Обмеження\",\n  \"LabelLineSpacing\": \"Відстань між рядками\",\n  \"LabelListenAgain\": \"Слухати знову\",\n  \"LabelLogLevelDebug\": \"Зневадження\",\n  \"LabelLogLevelInfo\": \"Відомості\",\n  \"LabelLogLevelWarn\": \"Увага\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Шукати нові епізоди після вказаної дати\",\n  \"LabelLowestPriority\": \"Найнижчий пріоритет\",\n  \"LabelMatchConfidence\": \"Впевненість\",\n  \"LabelMatchExistingUsersBy\": \"Шукати наявних користувачів за\",\n  \"LabelMatchExistingUsersByDescription\": \"Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO\",\n  \"LabelMaxEpisodesToDownload\": \"Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"Максимальна кількість нових епізодів для скачування за перевірку\",\n  \"LabelMaxEpisodesToKeep\": \"Максимальна кількість епізодів для зберігання\",\n  \"LabelMaxEpisodesToKeepHelp\": \"Значення 0 — без обмежень. Після автоматичного завантаження нового епізоду буде видалено найстаріший, якщо їх більше X. Видаляється лише 1 епізод за одне нове завантаження.\",\n  \"LabelMediaPlayer\": \"Програвач медіа\",\n  \"LabelMediaType\": \"Тип медіа\",\n  \"LabelMetaTag\": \"Метатег\",\n  \"LabelMetaTags\": \"Метатеги\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Пріоритетніші джерела метаданих перезапишуть менш пріоритетні метадані\",\n  \"LabelMetadataProvider\": \"Джерело метаданих\",\n  \"LabelMinute\": \"Хвилина\",\n  \"LabelMinutes\": \"Хвилини\",\n  \"LabelMissing\": \"Відсутня\",\n  \"LabelMissingEbook\": \"Без електронної книги\",\n  \"LabelMissingSupplementaryEbook\": \"Без додаткової електронної книги\",\n  \"LabelMobileRedirectURIs\": \"Дозволені адреси перенаправлення\",\n  \"LabelMobileRedirectURIsDescription\": \"Це білий список наявних URI, що перенаправляють у мобільний додаток. За замовчуванням це <code>audiobookshelf://oauth</code>, який ви можете видалити або ж додати інші адреси для сторонніх інтеграцій. Використайте зірочку (<code>*</code>), аби дозволити будь-яке URI.\",\n  \"LabelMore\": \"Більше\",\n  \"LabelMoreInfo\": \"Докладніше\",\n  \"LabelName\": \"Назва\",\n  \"LabelNarrator\": \"Читець\",\n  \"LabelNarrators\": \"Читці\",\n  \"LabelNew\": \"Нове\",\n  \"LabelNewPassword\": \"Новий пароль\",\n  \"LabelNewestAuthors\": \"Нові автори\",\n  \"LabelNewestEpisodes\": \"Нові епізоди\",\n  \"LabelNextBackupDate\": \"Дата наступного резервного копіювання\",\n  \"LabelNextChapters\": \"Наступні розділи будуть:\",\n  \"LabelNextScheduledRun\": \"Наступний запланований запуск\",\n  \"LabelNoApiKeys\": \"Без ключів API\",\n  \"LabelNoCustomMetadataProviders\": \"Без постачальників метаданих\",\n  \"LabelNoEpisodesSelected\": \"Не вибрано жодного епізоду\",\n  \"LabelNotFinished\": \"Незавершені\",\n  \"LabelNotStarted\": \"Не розпочато\",\n  \"LabelNotes\": \"Примітки\",\n  \"LabelNotificationAppriseURL\": \"URL Apprise\",\n  \"LabelNotificationAvailableVariables\": \"Доступні змінні\",\n  \"LabelNotificationBodyTemplate\": \"Шаблон сповіщення\",\n  \"LabelNotificationEvent\": \"Сповіщення про події\",\n  \"LabelNotificationTitleTemplate\": \"Шаблон заголовку\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Ліміт невдалих спроб\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Сповіщення буде вимкнено після багатьох невдалих надсилань\",\n  \"LabelNotificationsMaxQueueSize\": \"Ліміт розміру черги сповіщень\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Події обмежені до 1 на секунду. Події буде проігноровано, якщо ліміт черги досягнуто. Це запобігає спаму сповіщеннями.\",\n  \"LabelNumberOfBooks\": \"Кількість книг\",\n  \"LabelNumberOfChapters\": \"Кількість розділів:\",\n  \"LabelNumberOfEpisodes\": \"Кількість серій\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"Назва OpenID claim, що містить розширені дозволи на дії користувачів у додатку, які будуть застосовуватися до ролей, що не є адміністраторами (<b>якщо налаштовано</b>). Якщо у відповіді нема claim, у доступі до Audiobookshelf буде відмовлено. Якщо відсутня хоча б одна опція, відповідь буде вважатися <code>хибною</code>. Переконайтеся, що запит постачальника ідентифікаційних даних відповідає очікуваній структурі:\",\n  \"LabelOpenIDClaims\": \"Не змінюйте наступні параметри, аби вимкнути розширене призначення груп і дозволів, автоматично призначаючи групу 'Користувач'.\",\n  \"LabelOpenIDGroupClaimDescription\": \"Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають <code>групами</code>. <b>Якщо налаштовано</b>, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.\",\n  \"LabelOpenRSSFeed\": \"Відкрити RSS-канал\",\n  \"LabelOverwrite\": \"Перезаписати\",\n  \"LabelPaginationPageXOfY\": \"Сторінка {0} з {1}\",\n  \"LabelPassword\": \"Пароль\",\n  \"LabelPath\": \"Шлях\",\n  \"LabelPermanent\": \"Постійний\",\n  \"LabelPermissionsAccessAllLibraries\": \"Доступ до усіх бібліотек\",\n  \"LabelPermissionsAccessAllTags\": \"Доступ до усіх міток\",\n  \"LabelPermissionsAccessExplicitContent\": \"Доступ до відвертого вмісту\",\n  \"LabelPermissionsCreateEreader\": \"Можна створити читалку\",\n  \"LabelPermissionsDelete\": \"Може видаляти\",\n  \"LabelPermissionsDownload\": \"Може скачувати\",\n  \"LabelPermissionsUpdate\": \"Може оновлювати\",\n  \"LabelPermissionsUpload\": \"Може завантажувати\",\n  \"LabelPersonalYearReview\": \"Ваші підсумки року ({0})\",\n  \"LabelPhotoPathURL\": \"Шлях/URL фото\",\n  \"LabelPlayMethod\": \"Метод відтворення\",\n  \"LabelPlaybackRateIncrementDecrement\": \"Величина збільшення/зменшення швидкості відтворення\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} з {1}\",\n  \"LabelPlaylists\": \"Списки відтворення\",\n  \"LabelPodcast\": \"Подкаст\",\n  \"LabelPodcastSearchRegion\": \"Регіон пошуку подкасту\",\n  \"LabelPodcastType\": \"Тип подкасту\",\n  \"LabelPodcasts\": \"Подкасти\",\n  \"LabelPort\": \"Порт\",\n  \"LabelPrefixesToIgnore\": \"Ігнорувати префікси (з урахуванням регістру)\",\n  \"LabelPreventIndexing\": \"Заборонити індексування вашого каналу каталогами подкастів iTunes та Google\",\n  \"LabelPrimaryEbook\": \"Основна електронна книга\",\n  \"LabelProgress\": \"Прогрес\",\n  \"LabelProvider\": \"Джерело\",\n  \"LabelProviderAuthorizationValue\": \"Значення заголовка авторизації\",\n  \"LabelPubDate\": \"Дата публікації\",\n  \"LabelPublishYear\": \"Рік публікації\",\n  \"LabelPublishedDate\": \"Опубліковано {0}\",\n  \"LabelPublishedDecade\": \"Десятиліття публікації\",\n  \"LabelPublishedDecades\": \"Опубліковані десятиліття\",\n  \"LabelPublisher\": \"Видавець\",\n  \"LabelPublishers\": \"Видавці\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Користувацька електронна адреса власника\",\n  \"LabelRSSFeedCustomOwnerName\": \"Користувацьке ім'я власника\",\n  \"LabelRSSFeedOpen\": \"RSS-канал відкритий\",\n  \"LabelRSSFeedPreventIndexing\": \"Запобігати індексації\",\n  \"LabelRSSFeedSlug\": \"Назва RSS-каналу\",\n  \"LabelRSSFeedURL\": \"Адреса RSS-каналу\",\n  \"LabelRandomly\": \"Випадково\",\n  \"LabelReAddSeriesToContinueListening\": \"Заново додати серії до Продовжити слухати\",\n  \"LabelRead\": \"Читати\",\n  \"LabelReadAgain\": \"Читати знову\",\n  \"LabelReadEbookWithoutProgress\": \"Читати книгу без збереження прогресу\",\n  \"LabelRecentSeries\": \"Останні серії\",\n  \"LabelRecentlyAdded\": \"Нещодавно додані\",\n  \"LabelRecommended\": \"Рекомендовані\",\n  \"LabelRedo\": \"Повторити\",\n  \"LabelRegion\": \"Регіон\",\n  \"LabelReleaseDate\": \"Дата публікації\",\n  \"LabelRemoveAllMetadataAbs\": \"Видалити всі файли metadata.abs\",\n  \"LabelRemoveAllMetadataJson\": \"Видалити всі файли metadata.json\",\n  \"LabelRemoveAudibleBranding\": \"Видалити звуковий вступ та завершення з розділів\",\n  \"LabelRemoveCover\": \"Видалити обкладинку\",\n  \"LabelRemoveMetadataFile\": \"Видалити файли метаданих у папках елементів бібліотеки\",\n  \"LabelRemoveMetadataFileHelp\": \"Видалити всі файли metadata.json та metadata.abs у ваших папках {0}.\",\n  \"LabelRowsPerPage\": \"Рядків на сторінку\",\n  \"LabelSearchTerm\": \"Пошуковий запит\",\n  \"LabelSearchTitle\": \"Пошук за назвою\",\n  \"LabelSearchTitleOrASIN\": \"Пошук назви або ASIN\",\n  \"LabelSeason\": \"Сезон\",\n  \"LabelSeasonNumber\": \"Сезон #{0}\",\n  \"LabelSelectAll\": \"Вибрати все\",\n  \"LabelSelectAllEpisodes\": \"Вибрати всі епізоди\",\n  \"LabelSelectEpisodesShowing\": \"Вибрати {0} показаних епізодів\",\n  \"LabelSelectUser\": \"Виберіть користувача\",\n  \"LabelSelectUsers\": \"Вибрати користувачів\",\n  \"LabelSendEbookToDevice\": \"Надіслати електронну книгу на...\",\n  \"LabelSequence\": \"Послідовність\",\n  \"LabelSerial\": \"Серійний\",\n  \"LabelSeries\": \"Серії\",\n  \"LabelSeriesName\": \"Назва серії\",\n  \"LabelSeriesProgress\": \"Прогрес серії\",\n  \"LabelServerLogLevel\": \"Рівень журналу сервера\",\n  \"LabelServerYearReview\": \"Підсумки року сервера ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Зробити основною\",\n  \"LabelSetEbookAsSupplementary\": \"Зробити додатковою\",\n  \"LabelSettingsAllowIframe\": \"Дозволити вбудовування у iframe\",\n  \"LabelSettingsAudiobooksOnly\": \"Лише аудіокниги\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги\",\n  \"LabelSettingsBookshelfViewHelp\": \"Імітує вигляд дерев'яних полиць\",\n  \"LabelSettingsChromecastSupport\": \"Підтримка Chromecast\",\n  \"LabelSettingsDateFormat\": \"Формат дати\",\n  \"LabelSettingsEnableWatcher\": \"Автоматично відстежувати зміни в бібліотеках\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"Автоматично відстежувати зміни в бібліотеці\",\n  \"LabelSettingsEnableWatcherHelp\": \"Вмикає автоматичне додавання/оновлення елементів, коли спостерігаються зміни файлів. *Потребує перезавантаження сервера\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"Дозволити JavaScript-вміст у epub\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"Дозволяти epub-файлам виконувати код. Вмикайте цей параметр лише якщо ви довіряєте джерелу epub-файлів.\",\n  \"LabelSettingsExperimentalFeatures\": \"Експериментальні функції\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Функції в розробці, які потребують вашого відгуку та допомоги в тестуванні. Натисніть, щоб відкрити обговорення на Github.\",\n  \"LabelSettingsFindCovers\": \"Пошук обкладинок\",\n  \"LabelSettingsFindCoversHelp\": \"Якщо ваша аудіокнига не містить вбудованої обкладинки або зображення у теці, сканувальник спробує знайти обкладинку.<br>Примітка: Це збільшить час сканування\",\n  \"LabelSettingsHideSingleBookSeries\": \"Сховати серії з однією книгою\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Серії, що містять одну книгу, будуть приховані зі сторінки серій та полиць головної сторінки.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Полиці на головній сторінці\",\n  \"LabelSettingsLibraryBookshelfView\": \"Показувати полиці у бібліотеці\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"Відсоток виконання більше ніж\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"Час, що залишився, менше ніж (секунди)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"Позначити медіа-елемент як завершений, коли\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"Пропускати попередні книги у Продовжити серії\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.\",\n  \"LabelSettingsParseSubtitles\": \"Дістати підзаголовки\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Витягти субтитри з імен папок аудіокниг.<br>Підзаголовки мають бути розділені символом \\\" - \\\"<br>тобто. «Назва книги – тут підзаголовок» має підзаголовок «Тут підзаголовок»\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Надавати перевагу віднайденим метаданим\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Подробиці буде перезаписано віднайденими даними Швидкого пошуку. Без цього Швидкий пошук заповнить лише подробиці, яких бракує.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Не шукати книги, що мають ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Не шукати книги, що мають ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Ігнорувати префікси при сортуванні\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"Наприклад, для префіксу \\\"1.\\\" назва книги \\\"1. Назва книги\\\" буде визначена як \\\"Назва книги, 1.\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Квадратні обкладинки\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Надавати перевагу квадратним обкладинкам замість формату 1,6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Зберігати обкладинки з елементом\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"За замовчуванням обкладинки зберігаються у /metadata/items. Цей параметр увімкне збереження обкладинок у теці елемента бібліотеки. Буде збережено лише один файл \\\"cover\\\"\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Зберігати метадані з елементом\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки\",\n  \"LabelSettingsTimeFormat\": \"Формат часу\",\n  \"LabelShare\": \"Поділитися\",\n  \"LabelShareDownloadableHelp\": \"Дозволяє користувачам із посиланням для спільного доступу скачування zip-файлу елемента бібліотеки.\",\n  \"LabelShareOpen\": \"Поділитися відкрито\",\n  \"LabelShareURL\": \"Поділитися URL\",\n  \"LabelShowAll\": \"Показати все\",\n  \"LabelShowSeconds\": \"Показувати секунди\",\n  \"LabelShowSubtitles\": \"Показати субтитри\",\n  \"LabelSize\": \"Розмір\",\n  \"LabelSleepTimer\": \"Таймер вимкнення\",\n  \"LabelSlug\": \"Назва\",\n  \"LabelSortAscending\": \"По зростанню\",\n  \"LabelSortDescending\": \"По спаданню\",\n  \"LabelSortPubDate\": \"Сортувати дату публікації\",\n  \"LabelStart\": \"Початок\",\n  \"LabelStartTime\": \"Час початку\",\n  \"LabelStarted\": \"Стартував\",\n  \"LabelStartedAt\": \"Почато з\",\n  \"LabelStartedDate\": \"Розпочато {0}\",\n  \"LabelStatsAudioTracks\": \"Аудіодоріжки\",\n  \"LabelStatsAuthors\": \"Автори\",\n  \"LabelStatsBestDay\": \"Найкращий день\",\n  \"LabelStatsDailyAverage\": \"В середньому за добу\",\n  \"LabelStatsDays\": \"Днів\",\n  \"LabelStatsDaysListened\": \"Днів прослухано\",\n  \"LabelStatsHours\": \"Годин\",\n  \"LabelStatsInARow\": \"поспіль\",\n  \"LabelStatsItemsFinished\": \"Елементів завершено\",\n  \"LabelStatsItemsInLibrary\": \"Елементів у бібліотеці\",\n  \"LabelStatsMinutes\": \"хвилин\",\n  \"LabelStatsMinutesListening\": \"Хвилин прослухано\",\n  \"LabelStatsOverallDays\": \"Днів загалом\",\n  \"LabelStatsOverallHours\": \"Годин загалом\",\n  \"LabelStatsWeekListening\": \"Прослухано за тиждень\",\n  \"LabelSubtitle\": \"Підзаголовок\",\n  \"LabelSupportedFileTypes\": \"Підтримувані типи файлів\",\n  \"LabelTag\": \"Мітка\",\n  \"LabelTags\": \"Мітки\",\n  \"LabelTagsAccessibleToUser\": \"Мітки, доступні користувачу\",\n  \"LabelTagsNotAccessibleToUser\": \"Мітки, недоступні користувачу\",\n  \"LabelTasks\": \"Запущені завдання\",\n  \"LabelTextEditorBulletedList\": \"Маркований список\",\n  \"LabelTextEditorLink\": \"Посилання\",\n  \"LabelTextEditorNumberedList\": \"Нумерований список\",\n  \"LabelTextEditorUnlink\": \"Прибрати посилання\",\n  \"LabelTheme\": \"Тема\",\n  \"LabelThemeDark\": \"Темна\",\n  \"LabelThemeLight\": \"Світла\",\n  \"LabelThemeSepia\": \"Сепія\",\n  \"LabelTimeBase\": \"Шкала часу\",\n  \"LabelTimeDurationXHours\": \"{0} години\",\n  \"LabelTimeDurationXMinutes\": \"{0} хвилини\",\n  \"LabelTimeDurationXSeconds\": \"{0} секунди\",\n  \"LabelTimeInMinutes\": \"Час у хвилинах\",\n  \"LabelTimeLeft\": \"{0} залишилось\",\n  \"LabelTimeListened\": \"Часу прослухано\",\n  \"LabelTimeListenedToday\": \"Сьогодні прослухано\",\n  \"LabelTimeRemaining\": \"Лишилося: {0}\",\n  \"LabelTimeToShift\": \"На скільки секунд зсунути\",\n  \"LabelTitle\": \"Назва\",\n  \"LabelToolsEmbedMetadata\": \"Вбудувати метадані\",\n  \"LabelToolsEmbedMetadataDescription\": \"Вбудувати метадані в аудіофайли, включно з обкладинками та главами.\",\n  \"LabelToolsM4bEncoder\": \"Кодувальник M4B\",\n  \"LabelToolsMakeM4b\": \"Створити M4B-файл аудіокниги\",\n  \"LabelToolsMakeM4bDescription\": \"Створити .M4B-аудіокнигу з вбудованими метаданими, обкладинкою та главами.\",\n  \"LabelToolsSplitM4b\": \"Розділити M4B на MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Створення MP3 з розділеного за главами M4B з вбудованими метаданими, обкладинкою та главами.\",\n  \"LabelTotalDuration\": \"Загальна тривалість\",\n  \"LabelTotalTimeListened\": \"Усього прослухано\",\n  \"LabelTrackFromFilename\": \"Доріжка за назвою файлу\",\n  \"LabelTrackFromMetadata\": \"Доріжка за метаданими\",\n  \"LabelTracks\": \"Доріжки\",\n  \"LabelTracksMultiTrack\": \"Декілька доріжок\",\n  \"LabelTracksNone\": \"Доріжки відсутні\",\n  \"LabelTracksSingleTrack\": \"Одна доріжка\",\n  \"LabelTrailer\": \"Трейлер\",\n  \"LabelType\": \"Тип\",\n  \"LabelUnabridged\": \"Повна\",\n  \"LabelUndo\": \"Скасувати\",\n  \"LabelUnknown\": \"Невідомо\",\n  \"LabelUnknownPublishDate\": \"Невідома дата публікації\",\n  \"LabelUpdateCover\": \"Оновити обкладинку\",\n  \"LabelUpdateCoverHelp\": \"Дозволити перезапис наявних обкладинок обраних книг після віднайдення\",\n  \"LabelUpdateDetails\": \"Оновити подробиці\",\n  \"LabelUpdateDetailsHelp\": \"Дозволити перезапис наявних подробиць обраних книг після віднайдення\",\n  \"LabelUpdatedAt\": \"Оновлення\",\n  \"LabelUploaderDragAndDrop\": \"Перетягніть файли або теки\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"Перетягніть і скиньте файли\",\n  \"LabelUploaderDropFiles\": \"Перетягніть файли\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Автоматично шукати назву, автора та серію\",\n  \"LabelUseAdvancedOptions\": \"Використовувати розширені налаштування\",\n  \"LabelUseChapterTrack\": \"Прогрес глави\",\n  \"LabelUseFullTrack\": \"Використовувати доріжку повністю\",\n  \"LabelUseZeroForUnlimited\": \"Використовуйте 0 для необмеженої кількості\",\n  \"LabelUser\": \"Користувач\",\n  \"LabelUsername\": \"Ім’я користувача\",\n  \"LabelValue\": \"Значення\",\n  \"LabelVersion\": \"Версія\",\n  \"LabelViewBookmarks\": \"Переглянути закладки\",\n  \"LabelViewChapters\": \"Переглянути глави\",\n  \"LabelViewPlayerSettings\": \"Переглянути налаштування програвача\",\n  \"LabelViewQueue\": \"Переглянути чергу відтворення\",\n  \"LabelVolume\": \"Гучність\",\n  \"LabelWebRedirectURLsDescription\": \"Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:\",\n  \"LabelWebRedirectURLsSubfolder\": \"Підпапка для Redirect URL\",\n  \"LabelWeekdaysToRun\": \"Виконувати у дні\",\n  \"LabelXBooks\": \"{0} книг\",\n  \"LabelXItems\": \"{0} елементів\",\n  \"LabelYearReviewHide\": \"Сховати підсумки року\",\n  \"LabelYearReviewShow\": \"Переглянути підсумки року\",\n  \"LabelYourAudiobookDuration\": \"Тривалість вашої аудіокниги\",\n  \"LabelYourBookmarks\": \"Ваші закладки\",\n  \"LabelYourPlaylists\": \"Ваші списки відтворення\",\n  \"LabelYourProgress\": \"Ваш прогрес\",\n  \"MessageAddToPlayerQueue\": \"Додати до черги відтворення\",\n  \"MessageAppriseDescription\": \"Щоб скористатися цією функцією, вам потрібно мати запущену <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> або API, що оброблятиме ті ж запити. <br />Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою <code>http://192.168.1.1:8337</code>, то необхідно вказати адресу <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"Застарілі токени API будуть видалені в майбутньому. Натомість використовуйте <a href=\\\"/config/api-keys\\\">Ключі API</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.\",\n  \"MessageAuthenticationSecurityMessage\": \"Автентифікацію покращено для безпеки. Усім користувачам потрібно повторно увійти в систему.\",\n  \"MessageBackupsDescription\": \"Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з <code>/metadata/items</code> та <code>/metadata/authors</code>. Резервні копії <strong>не</strong> містять жодних файлів з тек бібліотеки.\",\n  \"MessageBackupsLocationEditNote\": \"Примітка: оновлення розташування резервної копії не переносить та не змінює існуючих копій\",\n  \"MessageBackupsLocationNoEditNote\": \"Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.\",\n  \"MessageBackupsLocationPathEmpty\": \"Шлях розташування резервної копії не може бути порожнім\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"Заповнити увімкнені поля даними з усіх елементів. Поля з кількома значеннями буде об’єднано\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"Заповнити увімкнені поля деталізації даними з цього елемента\",\n  \"MessageBatchQuickMatchDescription\": \"Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.\",\n  \"MessageBookshelfNoCollections\": \"Ви ще не створили жодної добірки\",\n  \"MessageBookshelfNoCollectionsHelp\": \"Колекції публічні. Їх можуть бачити всі користувачі, які мають доступ до бібліотеки.\",\n  \"MessageBookshelfNoRSSFeeds\": \"Немає відкритих RSS-каналів\",\n  \"MessageBookshelfNoResultsForFilter\": \"Немає результатів з фільтром \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"Немає результатів за запитом\",\n  \"MessageBookshelfNoSeries\": \"У вас немає серій\",\n  \"MessageBulkChapterPattern\": \"Скільки розділів ви хочете додати за допомогою цієї схеми нумерації?\",\n  \"MessageChapterEndIsAfter\": \"Кінець глави після завершення аудіокниги\",\n  \"MessageChapterErrorFirstNotZero\": \"Перша глава повинна починатися з 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Час початку має бути меншим за тривалість аудіокниги\",\n  \"MessageChapterErrorStartLtPrev\": \"Час початку має бути більшим або рівним часу початку попередньої глави\",\n  \"MessageChapterStartIsAfter\": \"Початок глави після завершення аудіокниги\",\n  \"MessageChaptersNotFound\": \"Розділи не знайдені\",\n  \"MessageCheckingCron\": \"Перевірка планувальника...\",\n  \"MessageConfirmCloseFeed\": \"Ви дійсно бажаєте закрити цей канал?\",\n  \"MessageConfirmDeleteApiKey\": \"Ви впевнені, що хочете видалити ключ API? \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteBackup\": \"Ви дійсно бажаєте видалити резервну копію за {0}?\",\n  \"MessageConfirmDeleteDevice\": \"Ви впевнені, що хочете видалити пристрій для читання \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteFile\": \"Файл буде видалено з вашої файлової системи. Ви впевнені?\",\n  \"MessageConfirmDeleteLibrary\": \"Ви дійсно бажаєте назавжди видалити бібліотеку \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Елемент бібліотеки буде видалено з бази даних і файлової системи. Ви впевнені?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Буде видалено {0} елементів бібліотеки з бази даних і файлової системи. Ви впевнені?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"Ви впевнені, що хочете видалити користувацького постачальника метаданих \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteNotification\": \"Ви впевнені, що хочете видалити це сповіщення?\",\n  \"MessageConfirmDeleteSession\": \"Ви дійсно бажаєте видалити цей сеанс?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"Ви впевнені, що хочете вбудувати метадані у {0} аудіофайлів?\",\n  \"MessageConfirmForceReScan\": \"Ви дійсно бажаєте примусово пересканувати?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Ви впевнені, що хочете позначити всі епізоди завершеними?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Ви впевнені, що хочете позначити всі епізоди незавершеними?\",\n  \"MessageConfirmMarkItemFinished\": \"Ви впевнені, що хочете позначити \\\"{0}\\\" як завершене?\",\n  \"MessageConfirmMarkItemNotFinished\": \"Ви впевнені, що хочете позначити \\\"{0}\\\" як незавершене?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Ви дійсно бажаєте позначити усі книги серії завершеними?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Ви дійсно бажаєте позначити всі книги серії незавершеними?\",\n  \"MessageConfirmNotificationTestTrigger\": \"Активувати це сповіщення з тестовими даними?\",\n  \"MessageConfirmPurgeCache\": \"Очищення кешу видалить всю теку <code>/metadata/cache</code>. <br /><br />Ви впевнені, що хочете видалити теку кешу?\",\n  \"MessageConfirmPurgeItemsCache\": \"Очищення кешу елементів видалить всю теку <code>/metadata/cache/items</code>.<br />Ви впевнені?\",\n  \"MessageConfirmQuickEmbed\": \"Увага! Швидке вбудовування не створює резервних копій ваших аудіофайлів. Переконайтеся, що маєте резервну копію. <br><br>Продовжити?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"Швидке співставлення епізодів перезапише подробиці, якщо знайдено відповідність. Оновлюються лише невідповідні епізоди. Ви впевнені?\",\n  \"MessageConfirmReScanLibraryItems\": \"Ви впевнені, що хочете пересканувати {0} елементів?\",\n  \"MessageConfirmRemoveAllChapters\": \"Ви дійсно бажаєте видалити усі глави?\",\n  \"MessageConfirmRemoveAuthor\": \"Ви дійсно бажаєте видалити автора \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"Ви дійсно бажаєте видалити добірку \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"Ви дійсно бажаєте видалити епізод \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"Примітка: Це не видаляє аудіофайл, якщо не перемикає \\\"файл жорсткого видалення\\\"\",\n  \"MessageConfirmRemoveEpisodes\": \"Ви дійсно бажаєте видалити епізодів: {0}?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Ви дійсно бажаєте видалити сеанси прослуховування: {0}?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?\",\n  \"MessageConfirmRemoveNarrator\": \"Ви дійсно бажаєте видалити читця \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"Ви дійсно бажаєте видалити ваш список відтворення \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"Ви впевнені, що хочете перейменувати жанр \\\"{0}\\\" на \\\"{1}\\\" для всіх елементів?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Примітка: Такий жанр вже існує, тому вони будуть об'єднані.\",\n  \"MessageConfirmRenameGenreWarning\": \"Увага! Схожий жанр з іншом регістром вже існує \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Ви впевнені, що хочете перейменувати мітку \\\"{0}\\\" на \\\"{1}\\\" для всіх елементів?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Примітка: Така мітка вже існує, тому вони будуть об'єднані.\",\n  \"MessageConfirmRenameTagWarning\": \"Увага! Схожа мітка з іншою регістром вже існує \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"Ви впевнені, що хочете скинути свій прогрес?\",\n  \"MessageConfirmSendEbookToDevice\": \"Ви дійсно хочете відправити на пристрій \\\"{2}\\\" електроні книги: {0}, \\\"{1}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"Ви впевнені, що хочете відв'язати цього користувача від OpenID?\",\n  \"MessageDaysListenedInTheLastYear\": \"{0} днів, прослуханих за останній рік\",\n  \"MessageDownloadingEpisode\": \"Скачування епізоду\",\n  \"MessageDragFilesIntoTrackOrder\": \"Перетягніть файли до правильного порядку\",\n  \"MessageEmbedFailed\": \"Не вдалося вбудувати!\",\n  \"MessageEmbedFinished\": \"Вбудовування завершено!\",\n  \"MessageEmbedQueue\": \"У черзі на вбудовування метаданих ({0} у черзі)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} епізод(ів) у черзі на завантаження\",\n  \"MessageEreaderDevices\": \"Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.\",\n  \"MessageFeedURLWillBe\": \"URL-адреса каналу буде {0}\",\n  \"MessageFetching\": \"Отримання...\",\n  \"MessageForceReScanDescription\": \"Просканує всі файли заново, як при першому скануванні. ID3-мітки, OPF-файли та текстові файли будуть проскановані як нові.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"<strong>{0} прослуховування</strong> на {1}\",\n  \"MessageHeatmapNoListeningSessions\": \"Немає сеансів прослуховування на {0}\",\n  \"MessageImportantNotice\": \"Важливе повідомлення!\",\n  \"MessageInsertChapterBelow\": \"Введіть главу нижче\",\n  \"MessageInvalidAsin\": \"Невірний ASIN\",\n  \"MessageItemsSelected\": \"Вибрано {0} елементів\",\n  \"MessageItemsUpdated\": \"Оновлено {0} елементів\",\n  \"MessageJoinUsOn\": \"Приєднуйтесь до\",\n  \"MessageLoading\": \"Завантаження...\",\n  \"MessageLoadingFolders\": \"Завантаження папок...\",\n  \"MessageLogsDescription\": \"Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.\",\n  \"MessageM4BFailed\": \"Помилка M4B!\",\n  \"MessageM4BFinished\": \"M4B створено!\",\n  \"MessageMapChapterTitles\": \"Встановіть назви глав вашої аудіокниги без зміни часових міток\",\n  \"MessageMarkAllEpisodesFinished\": \"Позначити всі епізоди завершеними\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Позначити всі епізоди незавершеними\",\n  \"MessageMarkAsFinished\": \"Позначити як завершене\",\n  \"MessageMarkAsNotFinished\": \"Позначити як незавершене\",\n  \"MessageMatchBooksDescription\": \"Спробує знайти книги у бібліотеці у вибраному джерелі пошуку та заповнити порожні подробиці й обкладинку. Не перезаписує подробиці.\",\n  \"MessageNoAudioTracks\": \"Аудіодоріжки відсутні\",\n  \"MessageNoAuthors\": \"Автори відсутні\",\n  \"MessageNoBackups\": \"Резервні копії відсутні\",\n  \"MessageNoBookmarks\": \"Немає закладок\",\n  \"MessageNoChapters\": \"Глави відсутні\",\n  \"MessageNoCollections\": \"Добірки відсутні\",\n  \"MessageNoCoversFound\": \"Обкладинок не знайдено\",\n  \"MessageNoDescription\": \"Без опису\",\n  \"MessageNoDevices\": \"Немає пристроїв\",\n  \"MessageNoDownloadsInProgress\": \"Немає активних скачувань\",\n  \"MessageNoDownloadsQueued\": \"Немає скачувань у черзі\",\n  \"MessageNoEpisodeMatchesFound\": \"Відповідних епізодів не знайдено\",\n  \"MessageNoEpisodes\": \"Епізоди відсутні\",\n  \"MessageNoFoldersAvailable\": \"Немає доступних тек\",\n  \"MessageNoGenres\": \"Без жанру\",\n  \"MessageNoIssues\": \"Немає проблем\",\n  \"MessageNoItems\": \"Елементи відсутні\",\n  \"MessageNoItemsFound\": \"Елементів не знайдено\",\n  \"MessageNoListeningSessions\": \"Сеанси прослуховування відсутні\",\n  \"MessageNoLogs\": \"Немає Журнали\",\n  \"MessageNoMediaProgress\": \"Прогрес відсутній\",\n  \"MessageNoNotifications\": \"Сповіщення відсутні\",\n  \"MessageNoPodcastFeed\": \"Некоректний подкаст: немає каналу\",\n  \"MessageNoPodcastsFound\": \"Подкастів не знайдено\",\n  \"MessageNoResults\": \"Немає результатів\",\n  \"MessageNoSearchResultsFor\": \"Немає результатів пошуку для \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Немає серій\",\n  \"MessageNoTags\": \"Немає міток\",\n  \"MessageNoTasksRunning\": \"Немає активних завдань\",\n  \"MessageNoUpdatesWereNecessary\": \"Оновлення не потрібні\",\n  \"MessageNoUserPlaylists\": \"У вас немає списків відтворення\",\n  \"MessageNoUserPlaylistsHelp\": \"Списки відтворення приватні. Лише користувач, який їх створив, може їх бачити.\",\n  \"MessageNotYetImplemented\": \"Ще не реалізовано\",\n  \"MessageOpmlPreviewNote\": \"Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде взята з RSS-каналу.\",\n  \"MessageOr\": \"або\",\n  \"MessagePauseChapter\": \"Призупинити відтворення глави\",\n  \"MessagePlayChapter\": \"Слухати початок глави\",\n  \"MessagePlaylistCreateFromCollection\": \"Створити список відтворення з добірки\",\n  \"MessagePleaseWait\": \"Будь ласка, зачекайте...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Подкаст не має RSS-каналу для пошуку\",\n  \"MessagePodcastSearchField\": \"Введіть пошуковий запит або URL RSS-стрічки\",\n  \"MessageQuickEmbedInProgress\": \"Швидке вбудовування в процесі\",\n  \"MessageQuickEmbedQueue\": \"У черзі на швидке вбудовування ({0} в черзі)\",\n  \"MessageQuickMatchAllEpisodes\": \"Швидке співставлення всіх епізодів\",\n  \"MessageQuickMatchDescription\": \"Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \\\"Надавати перевагу віднайденим метаданим\\\".\",\n  \"MessageRemoveChapter\": \"Видалити главу\",\n  \"MessageRemoveEpisodes\": \"Видалити епізодів: {0}\",\n  \"MessageRemoveFromPlayerQueue\": \"Вилучити з черги відтворення\",\n  \"MessageRemoveUserWarning\": \"Ви дійсно бажаєте назавжди видалити користувача \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"Повідомляйте про помилки, пропонуйте функції та долучайтеся на\",\n  \"MessageResetChaptersConfirm\": \"Ви впевнені, що хочете скинути глави та скасувати внесені зміни?\",\n  \"MessageRestoreBackupConfirm\": \"Ви впевнені, що хочете відновити резервну копію, створену\",\n  \"MessageRestoreBackupWarning\": \"Відновлення резервної копії перезапише всю базу даних у /config і зображення обкладинок у /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють файли у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються.<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.\",\n  \"MessageScheduleLibraryScanNote\": \"Для більшості користувачів рекомендується відключити цю функцію та підтримувати налаштування \\\"Автоматично переглядати бібліотеку для змін\\\" - вона автоматично виявить зміни в ваших папках бібліотеки. Звісніть цю функцію, якщо для вашої файлової системи (наприклад, NFS) не працює \\\"Автоматично переглядати бібліотеку.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"Запуск кожні {0} о {1}\",\n  \"MessageSearchResultsFor\": \"Результати пошуку для\",\n  \"MessageSelected\": \"Вибрано: {0}\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"Послідовність серій не може містити пробілів\",\n  \"MessageServerCouldNotBeReached\": \"Не вдалося підключитися до сервера\",\n  \"MessageSetChaptersFromTracksDescription\": \"Створити глави з аудіодоріжок, встановивши назви файлів за заголовки\",\n  \"MessageShareExpirationWillBe\": \"Термін сплине за <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"Спливає через {0}\",\n  \"MessageShareURLWillBe\": \"URL для спільного доступу — <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"Почати відтворення \\\"{0}\\\" з {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"Аудіофайл \\\"{0}\\\" недоступний для запису\",\n  \"MessageTaskCanceledByUser\": \"Завдання скасовано користувачем\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"Скачування епізоду \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"Вбудовування метаданих\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"Вбудовування метаданих у аудіокнигу \\\"{0}\\\"\",\n  \"MessageTaskEncodingM4b\": \"Кодування M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"Кодування аудіокниги \\\"{0}\\\" в один файл m4b\",\n  \"MessageTaskFailed\": \"Неуспішно\",\n  \"MessageTaskFailedToBackupAudioFile\": \"Не вдалося створити резервну копію аудіофайлу \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"Не вдалося створити каталог кешу\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"Не вдалося вбудувати метадані у файл \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"Не вдалося об’єднати аудіофайли\",\n  \"MessageTaskFailedToMoveM4bFile\": \"Не вдалося перемістити файл m4b\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"Не вдалося записати файл метаданих\",\n  \"MessageTaskMatchingBooksInLibrary\": \"Відповідність книг у бібліотеці \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"Немає файлів для сканування\",\n  \"MessageTaskOpmlImport\": \"Імпорт OPML\",\n  \"MessageTaskOpmlImportDescription\": \"Створення подкастів з {0} RSS-каналів\",\n  \"MessageTaskOpmlImportFeed\": \"Імпорт RSS-каналу OPML\",\n  \"MessageTaskOpmlImportFeedDescription\": \"Імпорт RSS-каналу \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"Не вдалося отримати подкаст-канал\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"Створення подкасту \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"Подкаст вже існує за цим шляхом\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"Не вдалося створити подкаст\",\n  \"MessageTaskOpmlImportFinished\": \"Додано {0} подкастів\",\n  \"MessageTaskOpmlParseFailed\": \"Не вдалося розібрати OPML-файл\",\n  \"MessageTaskOpmlParseFastFail\": \"Некоректний OPML-файл: не знайдено тег <opml> або <outline>\",\n  \"MessageTaskOpmlParseNoneFound\": \"У OPML-файлі не знайдено жодного каналу\",\n  \"MessageTaskScanItemsAdded\": \"{0} додано\",\n  \"MessageTaskScanItemsMissing\": \"{0} відсутні\",\n  \"MessageTaskScanItemsUpdated\": \"{0} оновлено\",\n  \"MessageTaskScanNoChangesNeeded\": \"Змін не потрібно\",\n  \"MessageTaskScanningFileChanges\": \"Сканування змін файлів у \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"Сканування бібліотеки \\\"{0}\\\"\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"Цільовий каталог недоступний для запису\",\n  \"MessageThinking\": \"Думаю…\",\n  \"MessageUploaderItemFailed\": \"Не вдалося завантажити\",\n  \"MessageUploaderItemSuccess\": \"Успішно завантажено!\",\n  \"MessageUploading\": \"Завантаження...\",\n  \"MessageValidCronExpression\": \"Коректний cron-вираз\",\n  \"MessageWatcherIsDisabledGlobally\": \"Спостерігача вимкнено у глобальних налаштуваннях сервера\",\n  \"MessageXLibraryIsEmpty\": \"Бібліотека {0} порожня!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Тривалість вашої аудіокниги більша за знайдену\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Тривалість вашої аудіокниги менша за знайдену\",\n  \"NoteChangeRootPassword\": \"Тільки користувач root може мати порожній пароль\",\n  \"NoteChapterEditorTimes\": \"Примітка: Перша глава повинна починатися з 0:00, а час початку останньої глави не може перевищувати тривалість цієї аудіокниги.\",\n  \"NoteFolderPicker\": \"Примітка: вже додані папки не відображаються\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Попередження: більшість додатків подкастів вимагають використання HTTPS для RSS-каналу\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Попередження: один або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів цього вимагають.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Теки з медіафайлами обробляються як окремі елементи бібліотеки.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Якщо завантажувати лише аудіофайли, кожен файл буде окремою аудіокнигою.\",\n  \"NoteUploaderUnsupportedFiles\": \"Непідтримувані файли ігноруються. При виборі або перетягуванні теки, файли поза теками елементів ігноруються.\",\n  \"NotificationOnBackupCompletedDescription\": \"Виконується після завершення резервного копіювання\",\n  \"NotificationOnBackupFailedDescription\": \"Виконується при помилці резервного копіювання\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"Виконується при автоматичному завантаженні епізоду подкасту\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"Виконується, коли автоматичне завантаження епізодів вимкнено через забагато невдалих спроб\",\n  \"NotificationOnRSSFeedFailedDescription\": \"Виконується, коли запит RSS-каналу не вдається для автоматичного завантаження епізоду\",\n  \"NotificationOnTestDescription\": \"Подія для тестування системи сповіщень\",\n  \"PlaceholderBulkChapterInput\": \"Введіть назву розділу або використовуйте нумерацію (наприклад, «Епізод 1», «Розділ 10», «1.»)\",\n  \"PlaceholderNewCollection\": \"Нова назва добірки\",\n  \"PlaceholderNewFolderPath\": \"Новий шлях до теки\",\n  \"PlaceholderNewPlaylist\": \"Нова назва списку\",\n  \"PlaceholderSearch\": \"Пошук...\",\n  \"PlaceholderSearchEpisode\": \"Шукати епізод...\",\n  \"StatsAuthorsAdded\": \"авторів додано\",\n  \"StatsBooksAdded\": \"книг додано\",\n  \"StatsBooksAdditional\": \"Було додано…\",\n  \"StatsBooksFinished\": \"книг завершено\",\n  \"StatsBooksFinishedThisYear\": \"Дещо з завершеного цьогоріч…\",\n  \"StatsBooksListenedTo\": \"книг, які слухали\",\n  \"StatsCollectionGrewTo\": \"Ваша колекція книг зросла до…\",\n  \"StatsSessions\": \"сесій\",\n  \"StatsSpentListening\": \"слухали\",\n  \"StatsTopAuthor\": \"УЛЮБЛЕНИЙ АВТОР\",\n  \"StatsTopAuthors\": \"УЛЮБЛЕНІ АВТОРИ\",\n  \"StatsTopGenre\": \"УЛЮБЛЕНИЙ ЖАНР\",\n  \"StatsTopGenres\": \"УЛЮБЛЕНІ ЖАНРИ\",\n  \"StatsTopMonth\": \"НАЙКРАЩИЙ МІСЯЦЬ\",\n  \"StatsTopNarrator\": \"УЛЮБЛЕНИЙ ЧИТЕЦЬ\",\n  \"StatsTopNarrators\": \"УЛЮБЛЕНІ ЧИТЦІ\",\n  \"StatsTotalDuration\": \"Загальною довжиною…\",\n  \"StatsYearInReview\": \"ОГЛЯД РОКУ\",\n  \"ToastAccountUpdateSuccess\": \"Профіль оновлено\",\n  \"ToastAppriseUrlRequired\": \"Необхідно ввести URL для Apprise\",\n  \"ToastAsinRequired\": \"ASIN є обов'язковим\",\n  \"ToastAuthorImageRemoveSuccess\": \"Фото автора видалено\",\n  \"ToastAuthorNotFound\": \"Автор \\\"{0}\\\" не знайдений\",\n  \"ToastAuthorRemoveSuccess\": \"Автор видалений\",\n  \"ToastAuthorSearchNotFound\": \"Автор не знайдений\",\n  \"ToastAuthorUpdateMerged\": \"Автора об'єднано\",\n  \"ToastAuthorUpdateSuccess\": \"Автора оновлено\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Автора оновлено (фото не знайдено)\",\n  \"ToastBackupAppliedSuccess\": \"Резервна копія застосована\",\n  \"ToastBackupCreateFailed\": \"Не вдалося створити резервну копію\",\n  \"ToastBackupCreateSuccess\": \"Резервну копію створено\",\n  \"ToastBackupDeleteFailed\": \"Не вдалося видалити резервну копію\",\n  \"ToastBackupDeleteSuccess\": \"Резервну копію видалено\",\n  \"ToastBackupInvalidMaxKeep\": \"Профіль оновленоПрофіль оновлено\",\n  \"ToastBackupInvalidMaxSize\": \"Невірний максимальний розмір резервної копії\",\n  \"ToastBackupRestoreFailed\": \"Не вдалося відновити резервну копію\",\n  \"ToastBackupUploadFailed\": \"Не вдалося завантажити резервну копію\",\n  \"ToastBackupUploadSuccess\": \"Резервну копію завантажено\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"Деталі застосовано до елементів\",\n  \"ToastBatchDeleteFailed\": \"Помилка при пакетному видаленні\",\n  \"ToastBatchDeleteSuccess\": \"Пакетне видалення успішне\",\n  \"ToastBatchQuickMatchFailed\": \"Не вдалося виконати пакетне швидке співпадіння!\",\n  \"ToastBatchQuickMatchStarted\": \"Пакетне швидке співпадіння {0} книг розпочато!\",\n  \"ToastBatchUpdateFailed\": \"Не вдалося оновити обрані\",\n  \"ToastBatchUpdateSuccess\": \"Обрані успішно оновлено\",\n  \"ToastBookmarkCreateFailed\": \"Не вдалося створити закладку\",\n  \"ToastBookmarkCreateSuccess\": \"Закладку додано\",\n  \"ToastBookmarkRemoveSuccess\": \"Закладку видалено\",\n  \"ToastBulkChapterInvalidCount\": \"Введіть число від 1 до 150\",\n  \"ToastCachePurgeFailed\": \"Не вдалося очистити кеш\",\n  \"ToastCachePurgeSuccess\": \"Кеш очищено\",\n  \"ToastChapterLocked\": \"Розділ заблоковано.\",\n  \"ToastChapterStartTimeAdjusted\": \"Час початку розділу змінено на {0} секунд\",\n  \"ToastChaptersAllLocked\": \"Усі розділи заблоковано. Розблокуйте деякі розділи, щоб змістити їхній час.\",\n  \"ToastChaptersHaveErrors\": \"Глави містять помилки\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"Недійсна тривалість зсуву. Час початку останнього розділу перевищує тривалість цієї аудіокниги.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"Недійсна величина зсуву. Перший розділ матиме нульову або від’ємну тривалість і буде перезаписаний другим розділом. Збільште початкову тривалість другого розділу.\",\n  \"ToastChaptersMustHaveTitles\": \"Глави повинні мати назви\",\n  \"ToastChaptersRemoved\": \"Розділи видалені\",\n  \"ToastChaptersUpdated\": \"Розділи оновлені\",\n  \"ToastCollectionItemsAddFailed\": \"Не вдалося додати елемент(и) до колекції\",\n  \"ToastCollectionRemoveSuccess\": \"Добірку видалено\",\n  \"ToastCollectionUpdateSuccess\": \"Добірку оновлено\",\n  \"ToastConnectionNotAvailable\": \"З’єднання недоступне. Спробуйте пізніше\",\n  \"ToastCoverSearchFailed\": \"Пошук обкладинки не вдався\",\n  \"ToastCoverUpdateFailed\": \"Не вдалося оновити обкладинку\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"Дата й час недійсні або неповні\",\n  \"ToastDeleteFileFailed\": \"Не вдалося видалити файл\",\n  \"ToastDeleteFileSuccess\": \"Файл видалено\",\n  \"ToastDeviceAddFailed\": \"Не вдалося додати пристрій\",\n  \"ToastDeviceNameAlreadyExists\": \"Пристрій для електронних книг з таким ім'ям вже існує\",\n  \"ToastDeviceTestEmailFailed\": \"Не вдалося надіслати тестовий електронний лист\",\n  \"ToastDeviceTestEmailSuccess\": \"Тестовий електронний лист надіслано\",\n  \"ToastEmailSettingsUpdateSuccess\": \"Налаштування електронної пошти оновлено\",\n  \"ToastEncodeCancelFailed\": \"Не вдалося скасувати кодування\",\n  \"ToastEncodeCancelSucces\": \"Кодування скасовано\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"Не вдалося очистити чергу\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"Чергу на скачування епізодів очищено\",\n  \"ToastEpisodeUpdateSuccess\": \"{0} епізодів оновлено\",\n  \"ToastErrorCannotShare\": \"Не можна типово поширити на цей пристрій\",\n  \"ToastFailedToCreate\": \"Не вдалося створити\",\n  \"ToastFailedToDelete\": \"Не вдалося видалити\",\n  \"ToastFailedToLoadData\": \"Не вдалося завантажити дані\",\n  \"ToastFailedToMatch\": \"Не вдалося знайти відповідність\",\n  \"ToastFailedToShare\": \"Не вдалося поділитися\",\n  \"ToastFailedToUpdate\": \"Не вдалося оновити\",\n  \"ToastInvalidImageUrl\": \"Невірний URL зображення\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"Невірна кількість епізодів для скачування\",\n  \"ToastInvalidUrl\": \"Невірний URL\",\n  \"ToastInvalidUrls\": \"Одна або декілька URL-адрес недійсні\",\n  \"ToastItemCoverUpdateSuccess\": \"Обкладинку елемента оновлено\",\n  \"ToastItemDeletedFailed\": \"Не вдалося видалити елемент\",\n  \"ToastItemDeletedSuccess\": \"Видалений елемент\",\n  \"ToastItemDetailsUpdateSuccess\": \"Подробиці про елемент оновлено\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Не вдалося позначити як завершене\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Елемент позначено як завершений\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Не вдалося позначити незавершеним\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Елемент позначено незавершеним\",\n  \"ToastItemUpdateSuccess\": \"Елемент оновлено\",\n  \"ToastLibraryCreateFailed\": \"Не вдалося створити бібліотеку\",\n  \"ToastLibraryCreateSuccess\": \"Бібліотеку \\\"{0}\\\" створено\",\n  \"ToastLibraryDeleteFailed\": \"Не вдалося видалити бібліотеку\",\n  \"ToastLibraryDeleteSuccess\": \"Бібліотеку видалено\",\n  \"ToastLibraryScanFailedToStart\": \"Не вдалося розпочати сканування\",\n  \"ToastLibraryScanStarted\": \"Почалося сканування бібліотеки\",\n  \"ToastLibraryUpdateSuccess\": \"Бібліотеку \\\"{0}\\\" оновлено\",\n  \"ToastMatchAllAuthorsFailed\": \"Не вдалось знайти відповідності з усіма авторами\",\n  \"ToastMetadataFilesRemovedError\": \"Помилка при видаленні metadata.{0} файли\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"У бібліотеці не знайдено metadata.{0} файлів\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"Не видалено metadata.{0} файлів\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} metadata.{1} файлів видалено\",\n  \"ToastMustHaveAtLeastOnePath\": \"Повинен бути хоча б один шлях\",\n  \"ToastNameEmailRequired\": \"Ім'я та електронна пошта обов'язкові\",\n  \"ToastNameRequired\": \"Ім'я обов'язкове\",\n  \"ToastNewApiKeyUserError\": \"Потрібно вибрати користувача\",\n  \"ToastNewEpisodesFound\": \"{0} нових епізодів знайдено\",\n  \"ToastNewUserCreatedFailed\": \"Не вдалося створити акаунт: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"Новий акаунт створено\",\n  \"ToastNewUserLibraryError\": \"Потрібно вибрати хоча б одну бібліотеку\",\n  \"ToastNewUserPasswordError\": \"Пароль обов'язковий, лише користувач з правами root може мати порожній пароль\",\n  \"ToastNewUserTagError\": \"Потрібно вибрати хоча б один тег\",\n  \"ToastNewUserUsernameError\": \"Введіть ім'я користувача\",\n  \"ToastNoNewEpisodesFound\": \"Нових епізодів не знайдено\",\n  \"ToastNoRSSFeed\": \"Подкаст не має RSS-канал\",\n  \"ToastNoUpdatesNecessary\": \"Оновлення не потрібні\",\n  \"ToastNotificationCreateFailed\": \"Не вдалося створити сповіщення\",\n  \"ToastNotificationDeleteFailed\": \"Не вдалося видалити сповіщення\",\n  \"ToastNotificationFailedMaximum\": \"Максимальна кількість невдалих спроб повинна бути >= 0\",\n  \"ToastNotificationQueueMaximum\": \"Максимальна кількість сповіщень у черзі повинна бути >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"Налаштування сповіщень оновлено\",\n  \"ToastNotificationTestTriggerFailed\": \"Не вдалося ініціювати тестове сповіщення\",\n  \"ToastNotificationTestTriggerSuccess\": \"Спрацьовувало сповіщення про тестування\",\n  \"ToastNotificationUpdateSuccess\": \"Сповіщення оновлено\",\n  \"ToastPlaylistCreateFailed\": \"Не вдалося створити список\",\n  \"ToastPlaylistCreateSuccess\": \"Список відтворення створено\",\n  \"ToastPlaylistRemoveSuccess\": \"Список відтворення видалено\",\n  \"ToastPlaylistUpdateSuccess\": \"Список відтворення оновлено\",\n  \"ToastPodcastCreateFailed\": \"Не вдалося створити подкаст\",\n  \"ToastPodcastCreateSuccess\": \"Подкаст успішно створено\",\n  \"ToastPodcastEpisodeUpdated\": \"Епізод оновлено\",\n  \"ToastPodcastGetFeedFailed\": \"Не вдалося отримати фід подкасту\",\n  \"ToastPodcastNoEpisodesInFeed\": \"У RSS-каналі не знайдено епізодів\",\n  \"ToastPodcastNoRssFeed\": \"Подкаст не має RSS-каналу\",\n  \"ToastProgressIsNotBeingSynced\": \"Прогрес не синхронізується, перезапустіть відтворення\",\n  \"ToastProviderCreatedFailed\": \"Не вдалося додати постачальника\",\n  \"ToastProviderCreatedSuccess\": \"Новий постачальник доданий\",\n  \"ToastProviderNameAndUrlRequired\": \"Ім'я та URL обов'язкові\",\n  \"ToastProviderRemoveSuccess\": \"Постачальник видалений\",\n  \"ToastRSSFeedCloseFailed\": \"Не вдалося закрити RSS-канал\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS-канал закрито\",\n  \"ToastRemoveFailed\": \"Не вдалося вилучити\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Не вдалося видалити елемент із добірки\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Елемент видалено з добірки\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"Не вдалося видалити елементи бібліотеки з проблемами\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"Видалено елементи бібліотеки з проблемами\",\n  \"ToastRenameFailed\": \"Не вдалося перейменувати\",\n  \"ToastRescanFailed\": \"Не вдалося повторно сканувати для {0}\",\n  \"ToastRescanRemoved\": \"Повторне сканування завершено, елемент був видалений\",\n  \"ToastRescanUpToDate\": \"Повторне сканування завершено, елемент актуальний\",\n  \"ToastRescanUpdated\": \"Повторне сканування завершено, елемент оновлено\",\n  \"ToastScanFailed\": \"Не вдалося сканувати елемент бібліотеки\",\n  \"ToastSelectAtLeastOneUser\": \"Виберіть хоча б одного користувача\",\n  \"ToastSendEbookToDeviceFailed\": \"Не вдалося надіслати електронну книгу на пристрій\",\n  \"ToastSendEbookToDeviceSuccess\": \"Електронну книгу надіслано на пристрій \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"Неможливо додати дві серії з однаковою назвою\",\n  \"ToastSeriesUpdateFailed\": \"Не вдалося оновити серію\",\n  \"ToastSeriesUpdateSuccess\": \"Серію успішно оновлено\",\n  \"ToastServerSettingsUpdateSuccess\": \"Налаштування сервера оновлено\",\n  \"ToastSessionCloseFailed\": \"Не вдалося закрити сесію\",\n  \"ToastSessionDeleteFailed\": \"Не вдалося видалити сесію\",\n  \"ToastSessionDeleteSuccess\": \"Сесію видалено\",\n  \"ToastSleepTimerDone\": \"Час сну завершено... зЗзЗз\",\n  \"ToastSlugMustChange\": \"Slug містить недопустимі символи\",\n  \"ToastSlugRequired\": \"Slug обов'язковий\",\n  \"ToastSocketConnected\": \"Сокет під'єднано\",\n  \"ToastSocketDisconnected\": \"Сокет від'єднано\",\n  \"ToastSocketFailedToConnect\": \"Не вдалося під'єднатися до сокета\",\n  \"ToastSortingPrefixesEmptyError\": \"Мусить мати хоча б 1 префікс сортування\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"Префікси сортування оновлено ({0})\",\n  \"ToastTitleRequired\": \"Заголовок обов'язковий\",\n  \"ToastUnknownError\": \"Невідома помилка\",\n  \"ToastUnlinkOpenIdFailed\": \"Не вдалося відв'язати користувача від OpenID\",\n  \"ToastUnlinkOpenIdSuccess\": \"Користувача відв'язано від OpenID\",\n  \"ToastUploaderFilepathExistsError\": \"Шлях до файлу \\\"{0}\\\" уже існує на сервері\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"Елемент \\\"{0}\\\" використовує підкаталог шляху завантаження.\",\n  \"ToastUserDeleteFailed\": \"Не вдалося видалити користувача\",\n  \"ToastUserDeleteSuccess\": \"Користувача видалено\",\n  \"ToastUserPasswordChangeSuccess\": \"Пароль успішно змінено\",\n  \"ToastUserPasswordMismatch\": \"Паролі не збігаються\",\n  \"ToastUserPasswordMustChange\": \"Новий пароль не може співпадати з попереднім\",\n  \"ToastUserRootRequireName\": \"Потрібно ввести ім'я користувача root\",\n  \"TooltipAddChapters\": \"Додати розділ(и)\",\n  \"TooltipAddOneSecond\": \"Додати 1 секунду\",\n  \"TooltipAdjustChapterStart\": \"Натисніть, щоб налаштувати час початку\",\n  \"TooltipLockAllChapters\": \"Заблокувати всі розділи\",\n  \"TooltipLockChapter\": \"Заблокувати розділ (Shift+клацання для діапазону)\",\n  \"TooltipSubtractOneSecond\": \"Відніміть 1 секунду\",\n  \"TooltipUnlockAllChapters\": \"Розблокувати всі розділи\",\n  \"TooltipUnlockChapter\": \"Розблокувати розділ (Shift+клацання для діапазону)\"\n}\n"
  },
  {
    "path": "client/strings/vi-vn.json",
    "content": "{\n  \"ButtonAdd\": \"Thêm\",\n  \"ButtonAddApiKey\": \"Thêm API Key\",\n  \"ButtonAddChapters\": \"Thêm Chương\",\n  \"ButtonAddDevice\": \"Thêm Thiết Bị\",\n  \"ButtonAddLibrary\": \"Thêm Thư Viện\",\n  \"ButtonAddPodcasts\": \"Thêm Podcasts\",\n  \"ButtonAddUser\": \"Thêm Người Dùng\",\n  \"ButtonAddYourFirstLibrary\": \"Thêm thư viện đầu tiên của bạn\",\n  \"ButtonApply\": \"Áp Dụng\",\n  \"ButtonApplyChapters\": \"Áp Dụng Chương\",\n  \"ButtonAuthors\": \"Tác Giả\",\n  \"ButtonBack\": \"Quay lại\",\n  \"ButtonBrowseForFolder\": \"Duyệt Thư Mục\",\n  \"ButtonCancel\": \"Hủy\",\n  \"ButtonCancelEncode\": \"Hủy Mã Hóa\",\n  \"ButtonChangeRootPassword\": \"Thay Đổi Mật Khẩu Root\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"Kiểm Tra và Tải Xuống Các Tập Phim Mới\",\n  \"ButtonChooseAFolder\": \"Chọn một thư mục\",\n  \"ButtonChooseFiles\": \"Chọn tập tin\",\n  \"ButtonClearFilter\": \"Xóa Bộ Lọc\",\n  \"ButtonClose\": \"Đóng\",\n  \"ButtonCloseFeed\": \"Đóng Feed\",\n  \"ButtonCloseSession\": \"Đóng phiên hiện tại\",\n  \"ButtonCollections\": \"Bộ Sưu Tập\",\n  \"ButtonConfigureScanner\": \"Cấu Hình Bộ Quét\",\n  \"ButtonCreate\": \"Tạo\",\n  \"ButtonCreateBackup\": \"Tạo Bản Sao Lưu\",\n  \"ButtonDelete\": \"Xóa\",\n  \"ButtonDownloadQueue\": \"Hàng Chờ\",\n  \"ButtonEdit\": \"Chỉnh Sửa\",\n  \"ButtonEditChapters\": \"Chỉnh Sửa Chương\",\n  \"ButtonEditPodcast\": \"Chỉnh Sửa Podcast\",\n  \"ButtonEnable\": \"Kích hoạt\",\n  \"ButtonFireAndFail\": \"Chạy và báo lỗi\",\n  \"ButtonFireOnTest\": \"Chạy thử\",\n  \"ButtonForceReScan\": \"Quét lại\",\n  \"ButtonFullPath\": \"Đường Dẫn Đầy Đủ\",\n  \"ButtonHide\": \"Ẩn\",\n  \"ButtonHome\": \"Trang Chủ\",\n  \"ButtonIssues\": \"Vấn Đề\",\n  \"ButtonJumpBackward\": \"Bước Lùi\",\n  \"ButtonJumpForward\": \"Bước Tiến\",\n  \"ButtonLatest\": \"Mới Nhất\",\n  \"ButtonLibrary\": \"Thư Viện\",\n  \"ButtonLogout\": \"Đăng Xuất\",\n  \"ButtonLookup\": \"Tra Cứu\",\n  \"ButtonManageTracks\": \"Quản Lý Tracks\",\n  \"ButtonMapChapterTitles\": \"Ánh Xạ Tiêu Đề Chương\",\n  \"ButtonMatchAllAuthors\": \"Khớp Tất Cả Tác Giả\",\n  \"ButtonMatchBooks\": \"Khớp Sách\",\n  \"ButtonNevermind\": \"Không Sao\",\n  \"ButtonNext\": \"Tiếp Theo\",\n  \"ButtonNextChapter\": \"Chương Tiếp Theo\",\n  \"ButtonNextItemInQueue\": \"Mục tiếp theo trong hàng đợi\",\n  \"ButtonOk\": \"Chấp nhận\",\n  \"ButtonOpenFeed\": \"Mở Feed\",\n  \"ButtonOpenManager\": \"Mở Quản Lý\",\n  \"ButtonPause\": \"Tạm Dừng\",\n  \"ButtonPlay\": \"Phát\",\n  \"ButtonPlayAll\": \"Phát tất cả\",\n  \"ButtonPlaying\": \"Đang Phát\",\n  \"ButtonPlaylists\": \"Danh Sách Phát\",\n  \"ButtonPrevious\": \"Trước\",\n  \"ButtonPreviousChapter\": \"Chương Trước\",\n  \"ButtonProbeAudioFile\": \"Kiểm tra tệp âm thanh\",\n  \"ButtonPurgeAllCache\": \"Xóa Sạch Tất Cả Bộ Nhớ Cache\",\n  \"ButtonPurgeItemsCache\": \"Xóa Sạch Bộ Nhớ Cache Các Mục\",\n  \"ButtonQueueAddItem\": \"Thêm vào hàng đợi\",\n  \"ButtonQueueRemoveItem\": \"Xóa khỏi hàng đợi\",\n  \"ButtonQuickEmbed\": \"Nhúng nhanh\",\n  \"ButtonQuickMatch\": \"Khớp Nhanh\",\n  \"ButtonReScan\": \"Quét Lại\",\n  \"ButtonRead\": \"Đọc\",\n  \"ButtonReadLess\": \"Thu gọn\",\n  \"ButtonReadMore\": \"Đọc thêm\",\n  \"ButtonRefresh\": \"Làm Mới\",\n  \"ButtonRemove\": \"Xóa\",\n  \"ButtonRemoveAll\": \"Xóa Tất Cả\",\n  \"ButtonRemoveAllLibraryItems\": \"Xóa Tất Cả Các Mục Thư Viện\",\n  \"ButtonRemoveFromContinueListening\": \"Xóa khỏi Tiếp Tục Nghe\",\n  \"ButtonRemoveFromContinueReading\": \"Xóa khỏi Tiếp Tục Đọc\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"Xóa Series khỏi Tiếp Tục Series\",\n  \"ButtonReset\": \"Đặt Lại\",\n  \"ButtonResetToDefault\": \"Đặt Lại về Mặc Định\",\n  \"ButtonRestore\": \"Khôi Phục\",\n  \"ButtonSave\": \"Lưu\",\n  \"ButtonSaveAndClose\": \"Lưu & Đóng\",\n  \"ButtonSaveTracklist\": \"Lưu Danh Sách Track\",\n  \"ButtonScan\": \"Quét\",\n  \"ButtonScanLibrary\": \"Quét Thư Viện\",\n  \"ButtonScrollLeft\": \"Cuộn trái\",\n  \"ButtonScrollRight\": \"Cuộn phải\",\n  \"ButtonSearch\": \"Tìm Kiếm\",\n  \"ButtonSelectFolderPath\": \"Chọn Đường Dẫn Thư Mục\",\n  \"ButtonSetChaptersFromTracks\": \"Đặt chương từ các track\",\n  \"ButtonShare\": \"Chia Sẻ\",\n  \"ButtonShiftTimes\": \"Dời Thời Gian\",\n  \"ButtonShow\": \"Hiện\",\n  \"ButtonStartM4BEncode\": \"Bắt đầu Mã Hóa M4B\",\n  \"ButtonStartMetadataEmbed\": \"Bắt đầu Nhúng Dữ Liệu\",\n  \"ButtonStats\": \"Dữ liệu\",\n  \"ButtonSubmit\": \"Gửi\",\n  \"ButtonTest\": \"Kiểm Tra\",\n  \"ButtonUnlinkOpenId\": \"Huỷ liên kết OpenID\",\n  \"ButtonUpload\": \"Tải Lên\",\n  \"ButtonUploadBackup\": \"Tải Lên Bản Sao Lưu\",\n  \"ButtonUploadCover\": \"Tải Lên Bìa\",\n  \"ButtonUploadOPMLFile\": \"Tải Lên Tệp OPML\",\n  \"ButtonUserDelete\": \"Xóa người dùng {0}\",\n  \"ButtonUserEdit\": \"Chỉnh Sửa người dùng {0}\",\n  \"ButtonViewAll\": \"Xem Tất Cả\",\n  \"ButtonYes\": \"Có\",\n  \"ErrorUploadFetchMetadataAPI\": \"Lỗi khi lấy dữ liệu metadata\",\n  \"ErrorUploadFetchMetadataNoResults\": \"Không thể lấy dữ liệu metadata - hãy thử cập nhật tiêu đề và/hoặc tác giả\",\n  \"ErrorUploadLacksTitle\": \"Phải có một tiêu đề\",\n  \"HeaderAccount\": \"Tài Khoản\",\n  \"HeaderAdvanced\": \"Nâng Cao\",\n  \"HeaderAppriseNotificationSettings\": \"Cài Đặt Thông Báo Apprise\",\n  \"HeaderAudioTracks\": \"Danh Sách Âm Thanh\",\n  \"HeaderAudiobookTools\": \"Công Cụ Quản Lý Tệp Truyện Nói\",\n  \"HeaderAuthentication\": \"Xác Thực\",\n  \"HeaderBackups\": \"Bản Sao Lưu\",\n  \"HeaderChangePassword\": \"Thay Đổi Mật Khẩu\",\n  \"HeaderChapters\": \"Chương\",\n  \"HeaderChooseAFolder\": \"Chọn Một Thư Mục\",\n  \"HeaderCollection\": \"Bộ Sưu Tập\",\n  \"HeaderCollectionItems\": \"Danh sách Bộ Sưu Tập\",\n  \"HeaderCover\": \"Bìa\",\n  \"HeaderCurrentDownloads\": \"Tải Xuống Hiện Tại\",\n  \"HeaderCustomMessageOnLogin\": \"Tin nhắn tuỳ chọn khi đăng nhập\",\n  \"HeaderCustomMetadataProviders\": \"Các Nhà Cung Cấp Metadata Tùy Chỉnh\",\n  \"HeaderDetails\": \"Chi Tiết\",\n  \"HeaderDownloadQueue\": \"Hàng Đợi Tải Xuống\",\n  \"HeaderEbookFiles\": \"Tệp Ebook\",\n  \"HeaderEmailSettings\": \"Cài Đặt Email\",\n  \"HeaderEpisodes\": \"Tập Phim\",\n  \"HeaderEreaderDevices\": \"Thiết Bị Đọc Sách\",\n  \"HeaderEreaderSettings\": \"Cài Đặt Thiết Bị Đọc Sách\",\n  \"HeaderFiles\": \"Tệp\",\n  \"HeaderFindChapters\": \"Tìm Kiếm Chương\",\n  \"HeaderIgnoredFiles\": \"Tệp Bị Bỏ Qua\",\n  \"HeaderItemFiles\": \"Tệp Mục\",\n  \"HeaderItemMetadataUtils\": \"Công Cụ Metadata Mục\",\n  \"HeaderLastListeningSession\": \"Phiên Nghe Gần Nhất\",\n  \"HeaderLatestEpisodes\": \"Tập Mới Nhất\",\n  \"HeaderLibraries\": \"Thư Viện\",\n  \"HeaderLibraryFiles\": \"Tệp Thư Viện\",\n  \"HeaderLibraryStats\": \"Thống Kê Thư Viện\",\n  \"HeaderListeningSessions\": \"Phiên Nghe\",\n  \"HeaderListeningStats\": \"Thống Kê Nghe\",\n  \"HeaderLogin\": \"Đăng Nhập\",\n  \"HeaderLogs\": \"Nhật Ký\",\n  \"HeaderManageGenres\": \"Quản Lý Thể Loại\",\n  \"HeaderManageTags\": \"Quản Lý Thẻ\",\n  \"HeaderMapDetails\": \"Bản Đồ Chi Tiết\",\n  \"HeaderMatch\": \"Kết Hợp\",\n  \"HeaderMetadataOrderOfPrecedence\": \"Thứ Tự Ưu Tiên Metadata\",\n  \"HeaderMetadataToEmbed\": \"Metadata để nhúng\",\n  \"HeaderNewAccount\": \"Tài Khoản Mới\",\n  \"HeaderNewLibrary\": \"Thư Viện Mới\",\n  \"HeaderNotificationCreate\": \"Tạo thông báo\",\n  \"HeaderNotificationUpdate\": \"Cập nhập thông báo\",\n  \"HeaderNotifications\": \"Thông Báo\",\n  \"HeaderOpenIDConnectAuthentication\": \"Xác Thực Mở ID Connect\",\n  \"HeaderOpenRSSFeed\": \"Mở RSS Feed\",\n  \"HeaderOtherFiles\": \"Các Tệp Khác\",\n  \"HeaderPasswordAuthentication\": \"Xác Thực Mật Khẩu\",\n  \"HeaderPermissions\": \"Quyền Hạn\",\n  \"HeaderPlayerQueue\": \"Hàng Đợi Người Chơi\",\n  \"HeaderPlayerSettings\": \"Cài đặt trình phát\",\n  \"HeaderPlaylist\": \"Danh Sách Phát\",\n  \"HeaderPlaylistItems\": \"Các Mục Danh Sách Phát\",\n  \"HeaderPodcastsToAdd\": \"Podcasts để Thêm\",\n  \"HeaderPreviewCover\": \"Xem Trước Bìa\",\n  \"HeaderRSSFeedGeneral\": \"Chi Tiết RSS\",\n  \"HeaderRSSFeedIsOpen\": \"RSS Feed Đã Mở\",\n  \"HeaderRemoveEpisode\": \"Xóa Tập\",\n  \"HeaderRemoveEpisodes\": \"Xóa {0} Tập\",\n  \"HeaderSavedMediaProgress\": \"Tiến Trình Phương Tiện Đã Lưu\",\n  \"HeaderSchedule\": \"Lịch Trình\",\n  \"HeaderScheduleEpisodeDownloads\": \"Đặt lịch tự động tải tập\",\n  \"HeaderScheduleLibraryScans\": \"Lên Lịch Quét Tự Động Thư Viện\",\n  \"HeaderSession\": \"Phiên\",\n  \"HeaderSetBackupSchedule\": \"Đặt Lịch Sao Lưu\",\n  \"HeaderSettings\": \"Cài Đặt\",\n  \"HeaderSettingsDisplay\": \"Hiển Thị\",\n  \"HeaderSettingsExperimental\": \"Tính Năng Thử Nghiệm\",\n  \"HeaderSettingsGeneral\": \"Chung\",\n  \"HeaderSettingsScanner\": \"Máy Quét\",\n  \"HeaderSettingsSecurity\": \"Bảo mật\",\n  \"HeaderSleepTimer\": \"Hẹn Giờ Tắt\",\n  \"HeaderStatsLargestItems\": \"Các Mục Lớn Nhất\",\n  \"HeaderStatsLongestItems\": \"Các Mục Dài Nhất (giờ)\",\n  \"HeaderStatsMinutesListeningChart\": \"Thống Kê Thời Gian Nghe (7 ngày gần nhất)\",\n  \"HeaderStatsRecentSessions\": \"Các Phiên Gần Đây\",\n  \"HeaderStatsTop10Authors\": \"10 Tác Giả Hàng Đầu\",\n  \"HeaderStatsTop5Genres\": \"5 Thể Loại Hàng Đầu\",\n  \"HeaderTableOfContents\": \"Mục Lục\",\n  \"HeaderTools\": \"Công Cụ\",\n  \"HeaderUpdateAccount\": \"Cập Nhật Tài Khoản\",\n  \"HeaderUpdateApiKey\": \"Cập nhập API Key\",\n  \"HeaderUpdateAuthor\": \"Cập Nhật Tác Giả\",\n  \"HeaderUpdateDetails\": \"Cập Nhật Chi Tiết\",\n  \"HeaderUpdateLibrary\": \"Cập Nhật Thư Viện\",\n  \"HeaderUsers\": \"Người Dùng\",\n  \"HeaderYearReview\": \"Năm {0} trong Xem Xét\",\n  \"HeaderYourStats\": \"Thống Kê Của Bạn\",\n  \"LabelAbridged\": \"Rút Gọn\",\n  \"LabelAccountType\": \"Loại Tài Khoản\",\n  \"LabelAccountTypeAdmin\": \"Quản Trị Viên\",\n  \"LabelAccountTypeGuest\": \"Khách\",\n  \"LabelAccountTypeUser\": \"Người Dùng\",\n  \"LabelActivity\": \"Hoạt Động\",\n  \"LabelAddToCollection\": \"Thêm vào Bộ Sưu Tập\",\n  \"LabelAddToCollectionBatch\": \"Thêm {0} Sách vào Bộ Sưu Tập\",\n  \"LabelAddToPlaylist\": \"Thêm vào Danh Sách Phát\",\n  \"LabelAddedAt\": \"Đã Thêm Lúc\",\n  \"LabelAddedDate\": \"Đã thêm {0}\",\n  \"LabelAutoDownloadEpisodes\": \"Tự Động Tải Xuống Các Tập\",\n  \"LabelBackupsMaxBackupSize\": \"Maximum backup size (in GB)\",\n  \"LabelBooks\": \"Sách\",\n  \"LabelButtonText\": \"Nút Văn Bản\",\n  \"LabelChangePassword\": \"Đổi Mật Khẩu\",\n  \"LabelChannels\": \"Kênh\",\n  \"LabelChapterTitle\": \"Tiêu đề Chương\",\n  \"LabelChapters\": \"Chương\",\n  \"LabelChaptersFound\": \"chương được tìm thấy\",\n  \"LabelClickForMoreInfo\": \"Nhấn để biết thêm thông tin\",\n  \"LabelClosePlayer\": \"Đóng trình phát\",\n  \"LabelCodec\": \"Mã hóa\",\n  \"LabelCollapseSeries\": \"Thu gọn Series\",\n  \"LabelCollection\": \"Bộ Sưu Tập\",\n  \"LabelCollections\": \"Các Bộ Sưu Tập\",\n  \"LabelComplete\": \"Hoàn Thành\",\n  \"LabelConfirmPassword\": \"Xác Nhận Mật Khẩu\",\n  \"LabelContinueListening\": \"Tiếp Tục Nghe\",\n  \"LabelContinueReading\": \"Tiếp Tục Đọc\",\n  \"LabelContinueSeries\": \"Tiếp Tục Series\",\n  \"LabelCover\": \"Bìa\",\n  \"LabelCoverImageURL\": \"URL Ảnh Bìa\",\n  \"LabelCreatedAt\": \"Được Tạo Lúc\",\n  \"LabelCronExpression\": \"Biểu Thức Cron\",\n  \"LabelCurrent\": \"Hiện tại\",\n  \"LabelCurrently\": \"Hiện tại:\",\n  \"LabelCustomCronExpression\": \"Biểu Thức Cron Tùy Chỉnh:\",\n  \"LabelDatetime\": \"Ngày giờ\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"Xóa khỏi hệ thống tệp (bỏ chọn để chỉ xóa khỏi cơ sở dữ liệu)\",\n  \"LabelDescription\": \"Mô Tả\",\n  \"LabelDeselectAll\": \"Bỏ Chọn Tất Cả\",\n  \"LabelDevice\": \"Thiết Bị\",\n  \"LabelDeviceInfo\": \"Thông Tin Thiết Bị\",\n  \"LabelDeviceIsAvailableTo\": \"Thiết Bị Đã Sẵn Sàng Cho...\",\n  \"LabelDirectory\": \"Thư Mục\",\n  \"LabelDiscFromFilename\": \"Đĩa từ Tên Tệp\",\n  \"LabelDiscFromMetadata\": \"Đĩa từ Metadata\",\n  \"LabelDiscover\": \"Khám Phá\",\n  \"LabelDownload\": \"Tải Xuống\",\n  \"LabelDownloadNEpisodes\": \"Tải Xuống {0} Tập\",\n  \"LabelDuration\": \"Thời Lượng\",\n  \"LabelDurationFound\": \"Thời lượng được tìm thấy:\",\n  \"LabelEbooks\": \"\",\n  \"LabelEdit\": \"Chỉnh Sửa\",\n  \"LabelEmailSettingsFromAddress\": \"Địa chỉ Gửi từ\",\n  \"LabelEmailSettingsSecure\": \"Bảo Mật\",\n  \"LabelEmailSettingsSecureHelp\": \"Nếu đúng thì kết nối sẽ sử dụng TLS khi kết nối đến máy chủ. Nếu sai thì TLS sẽ được sử dụng nếu máy chủ hỗ trợ phần mở rộng STARTTLS. Trong hầu hết các trường hợp, hãy đặt giá trị này là đúng nếu bạn kết nối đến cổng 465. Đối với cổng 587 hoặc 25, giữ nó sai. (từ nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"Địa Chỉ Kiểm Tra\",\n  \"LabelEmbeddedCover\": \"Bìa Nội\",\n  \"LabelEnable\": \"Bật\",\n  \"LabelEnd\": \"Kết Thúc\",\n  \"LabelEpisode\": \"Tập\",\n  \"LabelEpisodeTitle\": \"Tiêu Đề Tập\",\n  \"LabelEpisodeType\": \"Loại Tập\",\n  \"LabelExample\": \"Ví Dụ\",\n  \"LabelExplicit\": \"Rõ Ràng\",\n  \"LabelFeedURL\": \"URL Feed\",\n  \"LabelFetchingMetadata\": \"Đang Lấy Metadata\",\n  \"LabelFile\": \"Tệp\",\n  \"LabelFileBirthtime\": \"Thời Gian Tạo Tệp\",\n  \"LabelFileModified\": \"Sửa Đổi Tệp\",\n  \"LabelFilename\": \"Tên Tệp\",\n  \"LabelFilterByUser\": \"Lọc theo Người Dùng\",\n  \"LabelFindEpisodes\": \"Tìm Tập\",\n  \"LabelFinished\": \"Hoàn Thành\",\n  \"LabelFolder\": \"Thư Mục\",\n  \"LabelFolders\": \"Các Thư Mục\",\n  \"LabelFontBold\": \"Đậm\",\n  \"LabelFontBoldness\": \"Chữ đậm\",\n  \"LabelFontFamily\": \"Gia đình font\",\n  \"LabelFontItalic\": \"Nghiêng\",\n  \"LabelFontScale\": \"Tỷ lệ font\",\n  \"LabelFontStrikethrough\": \"Gạch ngang\",\n  \"LabelFormat\": \"Định dạng\",\n  \"LabelGenre\": \"Thể loại\",\n  \"LabelGenres\": \"Các thể loại\",\n  \"LabelHardDeleteFile\": \"Xóa tập tin vĩnh viễn\",\n  \"LabelHasEbook\": \"Có ebook\",\n  \"LabelHasSupplementaryEbook\": \"Có ebook bổ sung\",\n  \"LabelHighestPriority\": \"Ưu tiên cao nhất\",\n  \"LabelHost\": \"Máy Chủ\",\n  \"LabelHour\": \"Giờ\",\n  \"LabelIcon\": \"Biểu tượng\",\n  \"LabelImageURLFromTheWeb\": \"URL hình ảnh từ web\",\n  \"LabelInProgress\": \"Đang tiến hành\",\n  \"LabelIncludeInTracklist\": \"Bao gồm trong danh sách phát\",\n  \"LabelIncomplete\": \"Chưa hoàn thành\",\n  \"LabelInterval\": \"Khoảng cách\",\n  \"LabelIntervalCustomDailyWeekly\": \"Tuỳ chỉnh hàng ngày/hàng tuần\",\n  \"LabelIntervalEvery12Hours\": \"Mỗi 12 giờ\",\n  \"LabelIntervalEvery15Minutes\": \"Mỗi 15 phút\",\n  \"LabelIntervalEvery2Hours\": \"Mỗi 2 giờ\",\n  \"LabelIntervalEvery30Minutes\": \"Mỗi 30 phút\",\n  \"LabelIntervalEvery6Hours\": \"Mỗi 6 giờ\",\n  \"LabelIntervalEveryDay\": \"Mỗi ngày\",\n  \"LabelIntervalEveryHour\": \"Mỗi giờ\",\n  \"LabelInvert\": \"Nghịch đảo\",\n  \"LabelItem\": \"Mục\",\n  \"LabelLanguage\": \"Ngôn ngữ\",\n  \"LabelLanguageDefaultServer\": \"Ngôn ngữ Máy chủ mặc định\",\n  \"LabelLastBookAdded\": \"Sách mới nhất được thêm\",\n  \"LabelLastBookUpdated\": \"Sách mới nhất được cập nhật\",\n  \"LabelLastSeen\": \"Lần cuối nhìn thấy\",\n  \"LabelLastTime\": \"Lần cuối\",\n  \"LabelLastUpdate\": \"Cập nhật cuối cùng\",\n  \"LabelLayout\": \"Bố cục\",\n  \"LabelLayoutSinglePage\": \"Một trang\",\n  \"LabelLayoutSplitPage\": \"Chia trang\",\n  \"LabelLess\": \"Ít hơn\",\n  \"LabelLibrariesAccessibleToUser\": \"Thư viện có thể truy cập cho người dùng\",\n  \"LabelLibrary\": \"Thư viện\",\n  \"LabelLibraryItem\": \"Mục thư viện\",\n  \"LabelLibraryName\": \"Tên thư viện\",\n  \"LabelLimit\": \"Giới hạn\",\n  \"LabelLineSpacing\": \"Khoảng cách dòng\",\n  \"LabelListenAgain\": \"Nghe Lại\",\n  \"LabelLogLevelDebug\": \"Gỡ lỗi\",\n  \"LabelLogLevelInfo\": \"Thông tin\",\n  \"LabelLogLevelWarn\": \"Cảnh báo\",\n  \"LabelLookForNewEpisodesAfterDate\": \"Tìm tập mới sau ngày này\",\n  \"LabelLowestPriority\": \"Ưu tiên thấp nhất\",\n  \"LabelMatchExistingUsersBy\": \"Kết hợp người dùng hiện có theo\",\n  \"LabelMatchExistingUsersByDescription\": \"Sử dụng để kết nối người dùng hiện có. Khi kết nối, người dùng sẽ được kết hợp bằng một ID duy nhất từ nhà cung cấp SSO của bạn\",\n  \"LabelMediaPlayer\": \"Trình phát đa phương tiện\",\n  \"LabelMediaType\": \"Loại Phương Tiện\",\n  \"LabelMetaTag\": \"Thẻ Meta\",\n  \"LabelMetaTags\": \"Các thẻ Meta\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"Nguồn siêu dữ liệu ưu tiên cao hơn sẽ ghi đè lên các nguồn siêu dữ liệu ưu tiên thấp hơn\",\n  \"LabelMetadataProvider\": \"Nhà cung cấp siêu dữ liệu\",\n  \"LabelMinute\": \"Phút\",\n  \"LabelMissing\": \"\",\n  \"LabelMissingEbook\": \"Không có ebook\",\n  \"LabelMissingSupplementaryEbook\": \"Không có ebook bổ sung\",\n  \"LabelMobileRedirectURIs\": \"URI chuyển hướng di động được cho phép\",\n  \"LabelMobileRedirectURIsDescription\": \"Đây là danh sách trắng các URI chuyển hướng hợp lệ cho ứng dụng di động. Mặc định là <code>audiobookshelf://oauth</code>, bạn có thể loại bỏ hoặc bổ sung thêm các URI cho tích hợp ứng dụng bên thứ ba. Sử dụng dấu hoa thị (<code>*</code>) như một mục duy nhất cho phép bất kỳ URI nào.\",\n  \"LabelMore\": \"Thêm\",\n  \"LabelMoreInfo\": \"Thông tin thêm\",\n  \"LabelName\": \"Tên\",\n  \"LabelNarrator\": \"Người kể\",\n  \"LabelNarrators\": \"Các người kể\",\n  \"LabelNew\": \"Mới\",\n  \"LabelNewPassword\": \"Mật khẩu mới\",\n  \"LabelNewestAuthors\": \"Nhà văn mới nhất\",\n  \"LabelNewestEpisodes\": \"Tập mới nhất\",\n  \"LabelNextBackupDate\": \"Ngày sao lưu tiếp theo\",\n  \"LabelNextScheduledRun\": \"Chạy tiếp theo theo lịch trình\",\n  \"LabelNoEpisodesSelected\": \"Không có tập nào được chọn\",\n  \"LabelNotFinished\": \"Chưa Hoàn Thành\",\n  \"LabelNotStarted\": \"Chưa bắt đầu\",\n  \"LabelNotes\": \"Ghi chú\",\n  \"LabelNotificationAppriseURL\": \"URL(s) thông báo\",\n  \"LabelNotificationAvailableVariables\": \"Biến có sẵn\",\n  \"LabelNotificationBodyTemplate\": \"Mẫu Nội dung\",\n  \"LabelNotificationEvent\": \"Sự kiện Thông báo\",\n  \"LabelNotificationTitleTemplate\": \"Mẫu Tiêu đề\",\n  \"LabelNotificationsMaxFailedAttempts\": \"Số lần thất bại tối đa\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"Thông báo sẽ bị vô hiệu hóa sau khi thất bại gửi số lần này\",\n  \"LabelNotificationsMaxQueueSize\": \"Kích thước hàng đợi tối đa cho sự kiện thông báo\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"Các sự kiện bị giới hạn mỗi giây chỉ gửi 1 lần. Các sự kiện sẽ bị bỏ qua nếu hàng đợi đạt kích thước tối đa. Điều này ngăn chặn spam thông báo.\",\n  \"LabelNumberOfBooks\": \"Số lượng Sách\",\n  \"LabelNumberOfEpisodes\": \"Số lượng tập\",\n  \"LabelOpenRSSFeed\": \"Mở RSS Feed\",\n  \"LabelOverwrite\": \"Ghi đè\",\n  \"LabelPassword\": \"Mật khẩu\",\n  \"LabelPath\": \"Đường dẫn\",\n  \"LabelPermissionsAccessAllLibraries\": \"Có Thể Truy Cập Tất Cả Thư Viện\",\n  \"LabelPermissionsAccessAllTags\": \"Có Thể Truy Cập Tất Cả Thẻ\",\n  \"LabelPermissionsAccessExplicitContent\": \"Có Thể Truy Cập Nội Dung Rõ Ràng\",\n  \"LabelPermissionsDelete\": \"Có Thể Xóa\",\n  \"LabelPermissionsDownload\": \"Có Thể Tải Xuống\",\n  \"LabelPermissionsUpdate\": \"Có Thể Cập Nhật\",\n  \"LabelPermissionsUpload\": \"Có Thể Tải Lên\",\n  \"LabelPersonalYearReview\": \"Năm của Bạn trong Bài Đánh Giá ({0})\",\n  \"LabelPhotoPathURL\": \"Đường dẫn/URL ảnh\",\n  \"LabelPlayMethod\": \"Phương pháp phát\",\n  \"LabelPlaylists\": \"Danh sách phát\",\n  \"LabelPodcast\": \"Podcast\",\n  \"LabelPodcastSearchRegion\": \"Vùng tìm kiếm podcast\",\n  \"LabelPodcastType\": \"Loại Podcast\",\n  \"LabelPodcasts\": \"Podcasts\",\n  \"LabelPort\": \"Cổng\",\n  \"LabelPrefixesToIgnore\": \"Tiền tố để bỏ qua (không phân biệt chữ hoa/chữ thường)\",\n  \"LabelPreventIndexing\": \"Ngăn Chặn Feed của bạn bị  truy xuất bởi thư mục iTunes và Google podcast\",\n  \"LabelPrimaryEbook\": \"Ebook chính\",\n  \"LabelProgress\": \"Tiến độ\",\n  \"LabelProvider\": \"Nhà cung cấp\",\n  \"LabelPubDate\": \"Ngày Xuất bản\",\n  \"LabelPublishYear\": \"Năm Xuất Bản\",\n  \"LabelPublishedDate\": \"Xuất bản vào {0}\",\n  \"LabelPublisher\": \"Nhà xuất bản\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"Email chủ sở hữu tùy chỉnh\",\n  \"LabelRSSFeedCustomOwnerName\": \"Tên chủ sở hữu tùy chỉnh\",\n  \"LabelRSSFeedOpen\": \"Mở RSS Feed\",\n  \"LabelRSSFeedPreventIndexing\": \"Ngăn chặn Chỉ mục RSS Feed\",\n  \"LabelRSSFeedSlug\": \"Slug RSS Feed\",\n  \"LabelRSSFeedURL\": \"URL RSS Feed\",\n  \"LabelRead\": \"Đọc\",\n  \"LabelReadAgain\": \"Đọc lại\",\n  \"LabelReadEbookWithoutProgress\": \"Đọc ebook mà không giữ tiến độ\",\n  \"LabelRecentSeries\": \"Loạt phim gần đây\",\n  \"LabelRecentlyAdded\": \"Gần đây thêm vào\",\n  \"LabelRecommended\": \"Được khuyến nghị\",\n  \"LabelRedo\": \"Làm lại\",\n  \"LabelRegion\": \"Khu vực\",\n  \"LabelReleaseDate\": \"Ngày Phát hành\",\n  \"LabelRemoveCover\": \"Xóa ảnh bìa\",\n  \"LabelRowsPerPage\": \"Số dòng mỗi trang\",\n  \"LabelSearchTerm\": \"Thuật ngữ tìm kiếm\",\n  \"LabelSearchTitle\": \"Tìm kiếm Tiêu đề\",\n  \"LabelSearchTitleOrASIN\": \"Tìm kiếm Tiêu đề hoặc ASIN\",\n  \"LabelSeason\": \"Mùa\",\n  \"LabelSelectAllEpisodes\": \"Chọn tất cả các tập\",\n  \"LabelSelectEpisodesShowing\": \"Chọn {0} tập đang hiển thị\",\n  \"LabelSelectUsers\": \"Chọn người dùng\",\n  \"LabelSendEbookToDevice\": \"Gửi Ebook tới...\",\n  \"LabelSequence\": \"Trình tự\",\n  \"LabelSeries\": \"Loạt\",\n  \"LabelSeriesName\": \"Tên loạt\",\n  \"LabelSeriesProgress\": \"Tiến độ loạt\",\n  \"LabelServerYearReview\": \"Năm của Máy chủ trong Bài Đánh Giá ({0})\",\n  \"LabelSetEbookAsPrimary\": \"Đặt làm chính\",\n  \"LabelSetEbookAsSupplementary\": \"Đặt là bổ sung\",\n  \"LabelSettingsAudiobooksOnly\": \"Chỉ sách nói\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"Bật cài đặt này sẽ bỏ qua các tập tin ebook trừ khi chúng ở trong một thư mục sách nói, trong trường hợp đó chúng sẽ được đặt làm ebook bổ sung\",\n  \"LabelSettingsBookshelfViewHelp\": \"Thiết kế giả lập với kệ gỗ\",\n  \"LabelSettingsChromecastSupport\": \"Hỗ trợ Chromecast\",\n  \"LabelSettingsDateFormat\": \"Định dạng Ngày\",\n  \"LabelSettingsEnableWatcherHelp\": \"Bật chức năng tự động thêm/cập nhật các mục khi phát hiện thay đổi tập tin. *Yêu cầu khởi động lại máy chủ\",\n  \"LabelSettingsExperimentalFeatures\": \"Tính năng thử nghiệm\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"Các tính năng đang phát triển có thể cần phản hồi của bạn và sự giúp đỡ trong thử nghiệm. Nhấp để mở thảo luận trên github.\",\n  \"LabelSettingsFindCovers\": \"Tìm ảnh bìa\",\n  \"LabelSettingsFindCoversHelp\": \"Nếu sách nói của bạn không có ảnh bìa nhúng hoặc ảnh bìa trong thư mục, trình quét sẽ cố gắng tìm ảnh bìa.<br>Lưu ý: Điều này sẽ kéo dài thời gian quét\",\n  \"LabelSettingsHideSingleBookSeries\": \"Ẩn loạt sách đơn lẻ\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"Các loạt sách chỉ có một cuốn sách sẽ được ẩn khỏi trang loạt sách và kệ trang chủ.\",\n  \"LabelSettingsHomePageBookshelfView\": \"Trang chủ sử dụng chế độ xem kệ sách\",\n  \"LabelSettingsLibraryBookshelfView\": \"Thư viện sử dụng chế độ xem kệ sách\",\n  \"LabelSettingsParseSubtitles\": \"Phân tích phụ đề\",\n  \"LabelSettingsParseSubtitlesHelp\": \"Trích xuất phụ đề từ tên thư mục sách nói.<br>Phụ đề phải được tách bằng \\\" - \\\"<br>i.e. \\\"Book Title - A Subtitle Here\\\" có phụ đề \\\"A Subtitle Here\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"Ưu tiên siêu dữ liệu phù hợp\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"Dữ liệu phù hợp sẽ ghi đè lên chi tiết mục khi sử dụng Kết hợp Nhanh. Theo mặc định, Kết hợp Nhanh chỉ điền vào các chi tiết bị thiếu.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"Bỏ qua sách khớp có ASIN\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"Bỏ qua sách khớp có ISBN\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"Bỏ qua tiền tố khi sắp xếp\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"ví dụ. với tiền tố \\\"the\\\" tiêu đề sách \\\"The Book Title\\\" sẽ được sắp xếp như \\\"Book Title, The\\\"\",\n  \"LabelSettingsSquareBookCovers\": \"Sử dụng ảnh bìa vuông\",\n  \"LabelSettingsSquareBookCoversHelp\": \"Ưu tiên sử dụng ảnh bìa vuông hơn ảnh bìa tiêu chuẩn 1.6:1\",\n  \"LabelSettingsStoreCoversWithItem\": \"Lưu trữ ảnh bìa với mục\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"Theo mặc định, ảnh bìa được lưu trữ trong /metadata/items, bật cài đặt này sẽ lưu trữ ảnh bìa trong thư mục mục của thư viện bạn. Chỉ một tệp có tên là \\\"cover\\\" sẽ được giữ lại\",\n  \"LabelSettingsStoreMetadataWithItem\": \"Lưu trữ siêu dữ liệu với mục\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"Theo mặc định, các tệp siêu dữ liệu được lưu trữ trong /metadata/items, bật cài đặt này sẽ lưu trữ các tệp siêu dữ liệu trong các thư mục mục của thư viện bạn\",\n  \"LabelSettingsTimeFormat\": \"Định dạng Thời gian\",\n  \"LabelShowAll\": \"Hiển thị Tất cả\",\n  \"LabelSize\": \"Kích thước\",\n  \"LabelSleepTimer\": \"Hẹn giờ tắt\",\n  \"LabelStart\": \"Bắt đầu\",\n  \"LabelStartTime\": \"Thời gian bắt đầu\",\n  \"LabelStarted\": \"Đã bắt đầu\",\n  \"LabelStartedAt\": \"Bắt đầu vào\",\n  \"LabelStatsAuthors\": \"Tác giả\",\n  \"LabelStatsBestDay\": \"Ngày tốt nhất\",\n  \"LabelStatsDailyAverage\": \"Trung bình hàng ngày\",\n  \"LabelStatsDays\": \"Ngày\",\n  \"LabelStatsDaysListened\": \"Ngày đã nghe\",\n  \"LabelStatsHours\": \"Giờ\",\n  \"LabelStatsInARow\": \"liên tiếp\",\n  \"LabelStatsItemsFinished\": \"Mục đã hoàn thành\",\n  \"LabelStatsItemsInLibrary\": \"Mục trong thư viện\",\n  \"LabelStatsMinutes\": \"phút\",\n  \"LabelStatsMinutesListening\": \"Phút Nghe\",\n  \"LabelStatsOverallDays\": \"Tổng số ngày\",\n  \"LabelStatsOverallHours\": \"Tổng số giờ\",\n  \"LabelStatsWeekListening\": \"Tuần nghe\",\n  \"LabelSubtitle\": \"Phụ đề\",\n  \"LabelSupportedFileTypes\": \"Loại tệp được hỗ trợ\",\n  \"LabelTag\": \"Thẻ\",\n  \"LabelTags\": \"Thẻ\",\n  \"LabelTagsAccessibleToUser\": \"Thẻ Có Thể Truy Cập Cho Người Dùng\",\n  \"LabelTagsNotAccessibleToUser\": \"Thẻ Không Thể Truy Cập Cho Người Dùng\",\n  \"LabelTasks\": \"Nhiệm vụ Đang chạy\",\n  \"LabelTextEditorBulletedList\": \"Danh sách có dấu đầu dòng\",\n  \"LabelTextEditorLink\": \"Liên kết\",\n  \"LabelTextEditorNumberedList\": \"Danh sách đánh số\",\n  \"LabelTextEditorUnlink\": \"Gỡ liên kết\",\n  \"LabelTheme\": \"Chủ đề\",\n  \"LabelThemeDark\": \"Tối\",\n  \"LabelThemeLight\": \"Sáng\",\n  \"LabelTimeBase\": \"Thời gian cơ bản\",\n  \"LabelTimeListened\": \"Thời gian đã nghe\",\n  \"LabelTimeListenedToday\": \"Thời gian đã nghe hôm nay\",\n  \"LabelTimeRemaining\": \"{0} còn lại\",\n  \"LabelTimeToShift\": \"Thời gian dời chuyển theo giây\",\n  \"LabelTitle\": \"Tiêu đề\",\n  \"LabelToolsEmbedMetadata\": \"Nhúng siêu dữ liệu\",\n  \"LabelToolsEmbedMetadataDescription\": \"Nhúng siêu dữ liệu vào tệp âm thanh bao gồm ảnh bìa và chương.\",\n  \"LabelToolsMakeM4b\": \"Tạo Tệp Audiobook M4B\",\n  \"LabelToolsMakeM4bDescription\": \"Tạo tệp audiobook .M4B với siêu dữ liệu nhúng, ảnh bìa và chương.\",\n  \"LabelToolsSplitM4b\": \"Chia M4B thành MP3\",\n  \"LabelToolsSplitM4bDescription\": \"Tạo MP3 từ M4B được chia theo chương với siêu dữ liệu nhúng, ảnh bìa và chương.\",\n  \"LabelTotalDuration\": \"Tổng thời lượng\",\n  \"LabelTotalTimeListened\": \"Tổng thời gian đã nghe\",\n  \"LabelTrackFromFilename\": \"Từ tên tệp\",\n  \"LabelTrackFromMetadata\": \"Từ siêu dữ liệu\",\n  \"LabelTracks\": \"Bài hát\",\n  \"LabelTracksMultiTrack\": \"Nhiều track\",\n  \"LabelTracksNone\": \"Không có track\",\n  \"LabelTracksSingleTrack\": \"Một track\",\n  \"LabelType\": \"Loại\",\n  \"LabelUnabridged\": \"Không rút gọn\",\n  \"LabelUndo\": \"Hoàn tác\",\n  \"LabelUnknown\": \"Không xác định\",\n  \"LabelUpdateCover\": \"Cập nhật ảnh bìa\",\n  \"LabelUpdateCoverHelp\": \"Cho phép ghi đè lên các ảnh bìa hiện có cho các cuốn sách được chọn khi tìm thấy một kết hợp\",\n  \"LabelUpdateDetails\": \"Cập nhật chi tiết\",\n  \"LabelUpdateDetailsHelp\": \"Cho phép ghi đè lên các chi tiết hiện có cho các cuốn sách được chọn khi tìm thấy một kết hợp\",\n  \"LabelUpdatedAt\": \"Cập nhật lúc\",\n  \"LabelUploaderDragAndDrop\": \"Kéo và thả tệp hoặc thư mục\",\n  \"LabelUploaderDropFiles\": \"Thả tệp\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"Tự động lấy tiêu đề, tác giả và loạt\",\n  \"LabelUseChapterTrack\": \"Sử dụng track chương\",\n  \"LabelUseFullTrack\": \"Sử dụng toàn bộ track\",\n  \"LabelUser\": \"Người dùng\",\n  \"LabelUsername\": \"Tên người dùng\",\n  \"LabelValue\": \"Giá trị\",\n  \"LabelVersion\": \"Phiên bản\",\n  \"LabelViewBookmarks\": \"Xem các đánh dấu\",\n  \"LabelViewChapters\": \"Xem các chương\",\n  \"LabelViewQueue\": \"Xem hàng đợi phát\",\n  \"LabelVolume\": \"Âm lượng\",\n  \"LabelWeekdaysToRun\": \"Ngày trong tuần để chạy\",\n  \"LabelYearReviewHide\": \"Ẩn Năm trong Bài Đánh Giá\",\n  \"LabelYearReviewShow\": \"Xem Năm trong Bài Đánh Giá\",\n  \"LabelYourAudiobookDuration\": \"Thời lượng sách nói của bạn\",\n  \"LabelYourBookmarks\": \"Đánh dấu của bạn\",\n  \"LabelYourPlaylists\": \"Danh sách phát của bạn\",\n  \"LabelYourProgress\": \"Tiến trình của bạn\",\n  \"MessageAddToPlayerQueue\": \"Thêm vào hàng đợi phát\",\n  \"MessageAppriseDescription\": \"Để sử dụng tính năng này, bạn cần có một phiên bản của <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> đang chạy hoặc một api sẽ xử lý các yêu cầu tương tự. <br /> Địa chỉ URL của Apprise API nên là đường dẫn URL đầy đủ để gửi thông báo, ví dụ, nếu phiên bản API của bạn được phục vụ tại <code>http://192.168.1.1:8337</code> thì bạn sẽ đặt <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageBackupsDescription\": \"Bản sao bao gồm người dùng, tiến độ của người dùng, chi tiết mục thư viện, cài đặt máy chủ và hình ảnh được lưu trữ trong <code>/metadata/items</code> & <code>/metadata/authors</code>. Bản sao <strong>không</strong> bao gồm bất kỳ tệp nào được lưu trữ trong các thư mục thư viện của bạn.\",\n  \"MessageBatchQuickMatchDescription\": \"Quick Match sẽ cố gắng thêm các ảnh bìa và siêu dữ liệu bị thiếu cho các mục đã chọn. Bật các tùy chọn dưới đây để cho phép Quick Match ghi đè lên các ảnh bìa hiện có và / hoặc siêu dữ liệu.\",\n  \"MessageBookshelfNoCollections\": \"Bạn chưa tạo bất kỳ bộ sưu tập nào\",\n  \"MessageBookshelfNoRSSFeeds\": \"Không có nguồn cung cấp RSS nào đang mở\",\n  \"MessageBookshelfNoResultsForFilter\": \"Không có Kết quả cho bộ lọc \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoSeries\": \"Bạn không có bộ sách\",\n  \"MessageChapterEndIsAfter\": \"Kết thúc chương sau khi kết thúc sách nói của bạn\",\n  \"MessageChapterErrorFirstNotZero\": \"Chương đầu tiên phải bắt đầu từ 0\",\n  \"MessageChapterErrorStartGteDuration\": \"Thời gian bắt đầu không hợp lệ phải nhỏ hơn thời lượng sách nói\",\n  \"MessageChapterErrorStartLtPrev\": \"Thời gian bắt đầu không hợp lệ phải lớn hơn hoặc bằng thời gian bắt đầu của chương trước\",\n  \"MessageChapterStartIsAfter\": \"Bắt đầu chương sau khi kết thúc sách nói của bạn\",\n  \"MessageCheckingCron\": \"Kiểm tra cron...\",\n  \"MessageConfirmCloseFeed\": \"Bạn có chắc chắn muốn đóng nguồn cung cấp này không?\",\n  \"MessageConfirmDeleteBackup\": \"Bạn có chắc chắn muốn xóa bản sao lưu cho {0} không?\",\n  \"MessageConfirmDeleteFile\": \"Điều này sẽ xóa tệp khỏi hệ thống tệp của bạn. Bạn có chắc chắn không?\",\n  \"MessageConfirmDeleteLibrary\": \"Bạn có chắc chắn muốn xóa vĩnh viễn thư viện \\\"{0}\\\" không?\",\n  \"MessageConfirmDeleteLibraryItem\": \"Điều này sẽ xóa mục thư viện khỏi cơ sở dữ liệu và hệ thống tệp của bạn. Bạn có chắc chắn không?\",\n  \"MessageConfirmDeleteLibraryItems\": \"Điều này sẽ xóa {0} mục thư viện khỏi cơ sở dữ liệu và hệ thống tệp của bạn. Bạn có chắc chắn không?\",\n  \"MessageConfirmDeleteSession\": \"Bạn có chắc chắn muốn xóa phiên này không?\",\n  \"MessageConfirmForceReScan\": \"Bạn có chắc chắn muốn buộc quét lại không?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"Bạn có chắc chắn muốn đánh dấu tất cả các tập phim đã kết thúc không?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"Bạn có chắc chắn muốn đánh dấu tất cả các tập phim chưa kết thúc không?\",\n  \"MessageConfirmMarkSeriesFinished\": \"Bạn có chắc chắn muốn đánh dấu tất cả các sách trong loạt sách này đã kết thúc không?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"Bạn có chắc chắn muốn đánh dấu tất cả các sách trong loạt sách này chưa kết thúc không?\",\n  \"MessageConfirmQuickEmbed\": \"Cảnh báo! Quick embed sẽ không sao lưu các tệp âm thanh của bạn. Đảm bảo bạn có một bản sao lưu của các tệp âm thanh của bạn. <br><br>Bạn có muốn tiếp tục không?\",\n  \"MessageConfirmReScanLibraryItems\": \"Bạn có chắc chắn muốn quét lại {0} mục không?\",\n  \"MessageConfirmRemoveAllChapters\": \"Bạn có chắc chắn muốn xóa tất cả các chương không?\",\n  \"MessageConfirmRemoveAuthor\": \"Bạn có chắc chắn muốn xóa tác giả \\\"{0}\\\" không?\",\n  \"MessageConfirmRemoveCollection\": \"Bạn có chắc chắn muốn xóa bộ sưu tập \\\"{0}\\\" không?\",\n  \"MessageConfirmRemoveEpisode\": \"Bạn có chắc chắn muốn xóa tập phim \\\"{0}\\\" không?\",\n  \"MessageConfirmRemoveEpisodes\": \"Bạn có chắc chắn muốn xóa {0} tập phim không?\",\n  \"MessageConfirmRemoveListeningSessions\": \"Bạn có chắc chắn muốn xóa {0} phiên nghe không?\",\n  \"MessageConfirmRemoveNarrator\": \"Bạn có chắc chắn muốn xóa người kể chuyện \\\"{0}\\\" không?\",\n  \"MessageConfirmRemovePlaylist\": \"Bạn có chắc chắn muốn xóa danh sách phát của bạn \\\"{0}\\\" không?\",\n  \"MessageConfirmRenameGenre\": \"Bạn có chắc chắn muốn đổi tên thể loại \\\"{0}\\\" thành \\\"{1}\\\" cho tất cả các mục không?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"Lưu ý: Thể loại này đã tồn tại nên chúng sẽ được hợp nhất.\",\n  \"MessageConfirmRenameGenreWarning\": \"Cảnh báo! Một thể loại tương tự với kiểu chữ khác đã tồn tại \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"Bạn có chắc chắn muốn đổi tên tag \\\"{0}\\\" thành \\\"{1}\\\" cho tất cả các mục không?\",\n  \"MessageConfirmRenameTagMergeNote\": \"Lưu ý: Thẻ này đã tồn tại nên chúng sẽ được hợp nhất.\",\n  \"MessageConfirmRenameTagWarning\": \"Cảnh báo! Một thẻ tương tự với kiểu chữ khác đã tồn tại \\\"{0}\\\".\",\n  \"MessageConfirmSendEbookToDevice\": \"Bạn có chắc chắn muốn gửi {0} ebook \\\"{1}\\\" đến thiết bị \\\"{2}\\\" không?\",\n  \"MessageDownloadingEpisode\": \"Đang tải tập phim\",\n  \"MessageDragFilesIntoTrackOrder\": \"Kéo tệp vào thứ tự track đúng\",\n  \"MessageEmbedFinished\": \"Nhúng Hoàn thành!\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} Tập(s) đã được thêm vào hàng đợi để tải xuống\",\n  \"MessageFeedURLWillBe\": \"URL nguồn cấp sẽ là {0}\",\n  \"MessageFetching\": \"Đang tìm...\",\n  \"MessageForceReScanDescription\": \"sẽ quét lại tất cả các tệp như một quét mới. Các thẻ ID3 của tệp âm thanh, tệp OPF và tệp văn bản sẽ được quét làm mới.\",\n  \"MessageImportantNotice\": \"Thông báo quan trọng!\",\n  \"MessageInsertChapterBelow\": \"Chèn chương dưới đây\",\n  \"MessageItemsSelected\": \"{0} Mục Đã Chọn\",\n  \"MessageItemsUpdated\": \"{0} Mục Đã Cập Nhật\",\n  \"MessageJoinUsOn\": \"Tham gia cùng chúng tôi trên\",\n  \"MessageLoading\": \"Đang tải...\",\n  \"MessageLoadingFolders\": \"Đang tải các thư mục...\",\n  \"MessageM4BFailed\": \"M4B thất bại!\",\n  \"MessageM4BFinished\": \"M4B Hoàn thành!\",\n  \"MessageMapChapterTitles\": \"Ánh xạ tiêu đề chương với các chương hiện có của sách audio của bạn mà không điều chỉnh thời gian\",\n  \"MessageMarkAllEpisodesFinished\": \"Đánh dấu tất cả các tập phim đã kết thúc\",\n  \"MessageMarkAllEpisodesNotFinished\": \"Đánh dấu tất cả các tập phim chưa kết thúc\",\n  \"MessageMarkAsFinished\": \"Đánh dấu là Đã Kết Thúc\",\n  \"MessageMarkAsNotFinished\": \"Đánh dấu là Chưa Kết Thúc\",\n  \"MessageMatchBooksDescription\": \"sẽ cố gắng kết hợp các sách trong thư viện với một cuốn sách từ nhà cung cấp tìm kiếm được chọn và điền vào các chi tiết trống và ảnh bìa. Không ghi đè các chi tiết.\",\n  \"MessageNoAudioTracks\": \"Không có track âm thanh\",\n  \"MessageNoAuthors\": \"Không có Tác giả\",\n  \"MessageNoBackups\": \"Không có Bản sao lưu\",\n  \"MessageNoBookmarks\": \"Không có Đánh dấu\",\n  \"MessageNoChapters\": \"Không có Chương\",\n  \"MessageNoCollections\": \"Không có Bộ sưu tập\",\n  \"MessageNoCoversFound\": \"Không tìm thấy Ảnh bìa\",\n  \"MessageNoDescription\": \"Không có mô tả\",\n  \"MessageNoDownloadsInProgress\": \"Không có tải xuống đang tiến hành\",\n  \"MessageNoDownloadsQueued\": \"Không có tải xuống được xếp hàng\",\n  \"MessageNoEpisodeMatchesFound\": \"Không tìm thấy tập phim nào phù hợp\",\n  \"MessageNoEpisodes\": \"Không có Tập phim\",\n  \"MessageNoFoldersAvailable\": \"Không có Thư mục nào có sẵn\",\n  \"MessageNoGenres\": \"Không có Thể loại\",\n  \"MessageNoIssues\": \"Không có Vấn đề\",\n  \"MessageNoItems\": \"Không có Mục\",\n  \"MessageNoItemsFound\": \"Không tìm thấy mục nào\",\n  \"MessageNoListeningSessions\": \"Không có Phiên Nghe\",\n  \"MessageNoLogs\": \"Không có Log\",\n  \"MessageNoMediaProgress\": \"Không có Tiến độ Phương tiện\",\n  \"MessageNoNotifications\": \"Không có Thông báo\",\n  \"MessageNoPodcastsFound\": \"Không tìm thấy podcast nào\",\n  \"MessageNoResults\": \"Không có Kết quả\",\n  \"MessageNoSearchResultsFor\": \"Không có kết quả tìm kiếm cho \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"Không có Bộ\",\n  \"MessageNoTags\": \"Không có Thẻ\",\n  \"MessageNoTasksRunning\": \"Không có Công việc đang chạy\",\n  \"MessageNoUpdatesWereNecessary\": \"Không cần cập nhật\",\n  \"MessageNoUserPlaylists\": \"Bạn chưa có danh sách phát\",\n  \"MessageNotYetImplemented\": \"Chưa được triển khai\",\n  \"MessageOr\": \"hoặc\",\n  \"MessagePauseChapter\": \"Tạm dừng phát chương\",\n  \"MessagePlayChapter\": \"Nghe từ đầu chương\",\n  \"MessagePlaylistCreateFromCollection\": \"Tạo danh sách phát từ bộ sưu tập\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"Podcast không có RSS feed để sử dụng cho việc kết hợp\",\n  \"MessageQuickMatchDescription\": \"Điền chi tiết mục trống và ảnh bìa với kết quả phù hợp đầu tiên từ '{0}'. Không ghi đè chi tiết trừ khi cài đặt máy chủ 'Ưu tiên dữ liệu phù hợp' được bật.\",\n  \"MessageRemoveChapter\": \"Xóa chương\",\n  \"MessageRemoveEpisodes\": \"Xóa {0} tập\",\n  \"MessageRemoveFromPlayerQueue\": \"Xóa khỏi hàng đợi phát\",\n  \"MessageRemoveUserWarning\": \"Bạn có chắc chắn muốn xóa người dùng \\\"{0}\\\" một cách vĩnh viễn không?\",\n  \"MessageReportBugsAndContribute\": \"Báo cáo lỗi, yêu cầu tính năng và đóng góp tại\",\n  \"MessageResetChaptersConfirm\": \"Bạn có chắc chắn muốn đặt lại các chương và hủy những thay đổi bạn đã thực hiện không?\",\n  \"MessageRestoreBackupConfirm\": \"Bạn có chắc chắn muốn khôi phục bản sao lưu được tạo vào\",\n  \"MessageRestoreBackupWarning\": \"Việc khôi phục bản sao lưu sẽ ghi đè lên toàn bộ cơ sở dữ liệu được đặt tại /config và ảnh bìa trong /metadata/items & /metadata/authors.<br /><br />Bản sao lưu không sửa đổi bất kỳ tệp nào trong các thư mục thư viện của bạn. Nếu bạn đã bật các cài đặt máy chủ để lưu ảnh bìa và dữ liệu phần mềm trong các thư mục thư viện của mình thì chúng sẽ không được sao lưu hoặc ghi đè.<br /><br />Tất cả các máy khách sử dụng máy chủ của bạn sẽ được làm mới tự động.\",\n  \"MessageSearchResultsFor\": \"Kết quả tìm kiếm cho\",\n  \"MessageSelected\": \"{0} đã được chọn\",\n  \"MessageServerCouldNotBeReached\": \"Không thể kết nối đến máy chủ\",\n  \"MessageSetChaptersFromTracksDescription\": \"Đặt chương sử dụng mỗi tệp âm thanh là một chương và tiêu đề chương là tên tệp âm thanh\",\n  \"MessageStartPlaybackAtTime\": \"Bắt đầu phát \\\"{0}\\\" tại thời điểm {1}?\",\n  \"MessageThinking\": \"Đang suy nghĩ...\",\n  \"MessageUploaderItemFailed\": \"Không thể tải lên\",\n  \"MessageUploaderItemSuccess\": \"Tải lên thành công!\",\n  \"MessageUploading\": \"Đang tải lên...\",\n  \"MessageValidCronExpression\": \"Biểu thức cron hợp lệ\",\n  \"MessageWatcherIsDisabledGlobally\": \"Watcher đã bị vô hiệu hóa toàn cầu trong cài đặt máy chủ\",\n  \"MessageXLibraryIsEmpty\": \"Thư viện {0} rỗng!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"Thời lượng sách nói của bạn dài hơn so với thời lượng tìm thấy\",\n  \"MessageYourAudiobookDurationIsShorter\": \"Thời lượng sách nói của bạn ngắn hơn so với thời lượng tìm thấy\",\n  \"NoteChangeRootPassword\": \"Người dùng Root là người dùng duy nhất có thể có mật khẩu trống\",\n  \"NoteChapterEditorTimes\": \"Lưu ý: Thời gian bắt đầu của chương đầu tiên phải ở 0:00 và thời gian bắt đầu của chương cuối cùng không thể vượt quá thời lượng của sách nói này.\",\n  \"NoteFolderPicker\": \"Lưu ý: các thư mục đã được ánh xạ trước đó sẽ không được hiển thị\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"Cảnh báo: Hầu hết các ứng dụng podcast sẽ yêu cầu URL của RSS feed sử dụng HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"Cảnh báo: 1 hoặc nhiều tập của bạn không có Pub Date. Một số ứng dụng podcast yêu cầu điều này.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"Các thư mục có tệp phương tiện sẽ được xử lý như các mục thư viện riêng biệt.\",\n  \"NoteUploaderOnlyAudioFiles\": \"Nếu chỉ tải lên các tệp âm thanh thì mỗi tệp âm thanh sẽ được xử lý như một cuốn sách nói riêng biệt.\",\n  \"NoteUploaderUnsupportedFiles\": \"Các tệp không được hỗ trợ sẽ bị bỏ qua. Khi chọn hoặc thả một thư mục, các tệp khác không có trong thư mục mục sẽ bị bỏ qua.\",\n  \"PlaceholderNewCollection\": \"Tên bộ sưu tập mới\",\n  \"PlaceholderNewFolderPath\": \"Đường dẫn thư mục mới\",\n  \"PlaceholderNewPlaylist\": \"Tên danh sách phát mới\",\n  \"PlaceholderSearch\": \"Tìm kiếm..\",\n  \"PlaceholderSearchEpisode\": \"Tìm kiếm tập..\",\n  \"ToastAccountUpdateSuccess\": \"Tài khoản đã được cập nhật\",\n  \"ToastAuthorImageRemoveSuccess\": \"Ảnh tác giả đã được xóa\",\n  \"ToastAuthorUpdateMerged\": \"Tác giả đã được hợp nhất\",\n  \"ToastAuthorUpdateSuccess\": \"Cập nhật tác giả thành công\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"Cập nhật tác giả thành công (không tìm thấy ảnh)\",\n  \"ToastBackupCreateFailed\": \"Tạo bản sao lưu thất bại\",\n  \"ToastBackupCreateSuccess\": \"Bản sao lưu được tạo\",\n  \"ToastBackupDeleteFailed\": \"Xóa bản sao lưu thất bại\",\n  \"ToastBackupDeleteSuccess\": \"Bản sao lưu đã được xóa\",\n  \"ToastBackupRestoreFailed\": \"Khôi phục bản sao lưu thất bại\",\n  \"ToastBackupUploadFailed\": \"Tải lên bản sao lưu thất bại\",\n  \"ToastBackupUploadSuccess\": \"Bản sao lưu đã được tải lên\",\n  \"ToastBatchUpdateFailed\": \"Cập nhật nhóm thất bại\",\n  \"ToastBatchUpdateSuccess\": \"Cập nhật nhóm thành công\",\n  \"ToastBookmarkCreateFailed\": \"Tạo đánh dấu thất bại\",\n  \"ToastBookmarkCreateSuccess\": \"Đã thêm đánh dấu\",\n  \"ToastBookmarkRemoveSuccess\": \"Đánh dấu đã được xóa\",\n  \"ToastChaptersHaveErrors\": \"Các chương có lỗi\",\n  \"ToastChaptersMustHaveTitles\": \"Các chương phải có tiêu đề\",\n  \"ToastCollectionRemoveSuccess\": \"Bộ sưu tập đã được xóa\",\n  \"ToastCollectionUpdateSuccess\": \"Bộ sưu tập đã được cập nhật\",\n  \"ToastItemCoverUpdateSuccess\": \"Ảnh bìa mục đã được cập nhật\",\n  \"ToastItemDetailsUpdateSuccess\": \"Chi tiết mục đã được cập nhật\",\n  \"ToastItemMarkedAsFinishedFailed\": \"Đánh dấu mục là Hoàn thành thất bại\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"Mục đã được đánh dấu là Hoàn thành\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"Đánh dấu mục là Chưa hoàn thành thất bại\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"Mục đã được đánh dấu là Chưa hoàn thành\",\n  \"ToastLibraryCreateFailed\": \"Tạo thư viện thất bại\",\n  \"ToastLibraryCreateSuccess\": \"Thư viện \\\"{0}\\\" đã được tạo\",\n  \"ToastLibraryDeleteFailed\": \"Xóa thư viện thất bại\",\n  \"ToastLibraryDeleteSuccess\": \"Thư viện đã được xóa\",\n  \"ToastLibraryScanFailedToStart\": \"Không thể bắt đầu quét thư viện\",\n  \"ToastLibraryScanStarted\": \"Quét thư viện đã được bắt đầu\",\n  \"ToastLibraryUpdateSuccess\": \"Thư viện \\\"{0}\\\" đã được cập nhật\",\n  \"ToastPlaylistCreateFailed\": \"Tạo danh sách phát thất bại\",\n  \"ToastPlaylistCreateSuccess\": \"Danh sách phát đã được tạo\",\n  \"ToastPlaylistRemoveSuccess\": \"Danh sách phát đã được xóa\",\n  \"ToastPlaylistUpdateSuccess\": \"Danh sách phát đã được cập nhật\",\n  \"ToastPodcastCreateFailed\": \"Tạo podcast thất bại\",\n  \"ToastPodcastCreateSuccess\": \"Podcast đã được tạo thành công\",\n  \"ToastRSSFeedCloseFailed\": \"Đóng nguồn cấp RSS thất bại\",\n  \"ToastRSSFeedCloseSuccess\": \"Nguồn cấp RSS đã được đóng\",\n  \"ToastRemoveItemFromCollectionFailed\": \"Xóa mục khỏi bộ sưu tập thất bại\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"Mục đã được xóa khỏi bộ sưu tập\",\n  \"ToastSendEbookToDeviceFailed\": \"Gửi ebook đến thiết bị thất bại\",\n  \"ToastSendEbookToDeviceSuccess\": \"Ebook đã được gửi đến thiết bị \\\"{0}\\\"\",\n  \"ToastSeriesUpdateFailed\": \"Cập nhật loạt truyện thất bại\",\n  \"ToastSeriesUpdateSuccess\": \"Cập nhật loạt truyện thành công\",\n  \"ToastSessionDeleteFailed\": \"Xóa phiên thất bại\",\n  \"ToastSessionDeleteSuccess\": \"Phiên đã được xóa\",\n  \"ToastSocketConnected\": \"Kết nối socket\",\n  \"ToastSocketDisconnected\": \"Ngắt kết nối socket\",\n  \"ToastSocketFailedToConnect\": \"Không thể kết nối socket\",\n  \"ToastUserDeleteFailed\": \"Xóa người dùng thất bại\",\n  \"ToastUserDeleteSuccess\": \"Người dùng đã được xóa\"\n}\n"
  },
  {
    "path": "client/strings/zh-cn.json",
    "content": "{\n  \"ButtonAdd\": \"添加\",\n  \"ButtonAddApiKey\": \"添加 API 密钥\",\n  \"ButtonAddChapters\": \"添加章节\",\n  \"ButtonAddDevice\": \"添加设备\",\n  \"ButtonAddLibrary\": \"添加库\",\n  \"ButtonAddPodcasts\": \"添加播客\",\n  \"ButtonAddUser\": \"添加用户\",\n  \"ButtonAddYourFirstLibrary\": \"添加第一个媒体库\",\n  \"ButtonApply\": \"应用\",\n  \"ButtonApplyChapters\": \"应用到章节\",\n  \"ButtonAuthors\": \"作者\",\n  \"ButtonBack\": \"返回\",\n  \"ButtonBatchEditPopulateFromExisting\": \"用现有内容填充\",\n  \"ButtonBatchEditPopulateMapDetails\": \"填入此项详情\",\n  \"ButtonBrowseForFolder\": \"浏览文件夹\",\n  \"ButtonCancel\": \"取消\",\n  \"ButtonCancelEncode\": \"取消编码\",\n  \"ButtonChangeRootPassword\": \"更改 Root 密码\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"检查并下载新剧集\",\n  \"ButtonChooseAFolder\": \"选择文件夹\",\n  \"ButtonChooseFiles\": \"选择文件\",\n  \"ButtonClearFilter\": \"清除过滤器\",\n  \"ButtonClose\": \"关闭\",\n  \"ButtonCloseFeed\": \"关闭源\",\n  \"ButtonCloseSession\": \"关闭活动会话\",\n  \"ButtonCollections\": \"收藏\",\n  \"ButtonConfigureScanner\": \"配置扫描\",\n  \"ButtonCreate\": \"创建\",\n  \"ButtonCreateBackup\": \"创建备份\",\n  \"ButtonDelete\": \"删除\",\n  \"ButtonDownloadQueue\": \"下载队列\",\n  \"ButtonEdit\": \"编辑\",\n  \"ButtonEditChapters\": \"编辑章节\",\n  \"ButtonEditPodcast\": \"编辑播客\",\n  \"ButtonEnable\": \"启用\",\n  \"ButtonFireAndFail\": \"故障和失败\",\n  \"ButtonFireOnTest\": \"测试事件触发\",\n  \"ButtonForceReScan\": \"强制重新扫描\",\n  \"ButtonFullPath\": \"完整路径\",\n  \"ButtonHide\": \"隐藏\",\n  \"ButtonHome\": \"首页\",\n  \"ButtonIssues\": \"问题\",\n  \"ButtonJumpBackward\": \"向后跳转\",\n  \"ButtonJumpForward\": \"向前跳转\",\n  \"ButtonLatest\": \"最新\",\n  \"ButtonLibrary\": \"媒体库\",\n  \"ButtonLogout\": \"注销\",\n  \"ButtonLookup\": \"查找\",\n  \"ButtonManageTracks\": \"管理音轨\",\n  \"ButtonMapChapterTitles\": \"章节标题结构\",\n  \"ButtonMatchAllAuthors\": \"匹配所有作者\",\n  \"ButtonMatchBooks\": \"匹配图书\",\n  \"ButtonNevermind\": \"取消\",\n  \"ButtonNext\": \"下一个\",\n  \"ButtonNextChapter\": \"下一章节\",\n  \"ButtonNextItemInQueue\": \"队列中的下一个项目\",\n  \"ButtonOk\": \"确定\",\n  \"ButtonOpenFeed\": \"打开源\",\n  \"ButtonOpenManager\": \"打开管理器\",\n  \"ButtonPause\": \"暂停\",\n  \"ButtonPlay\": \"播放\",\n  \"ButtonPlayAll\": \"播放\",\n  \"ButtonPlaying\": \"正在播放\",\n  \"ButtonPlaylists\": \"播放列表\",\n  \"ButtonPrevious\": \"上一个\",\n  \"ButtonPreviousChapter\": \"上一章节\",\n  \"ButtonProbeAudioFile\": \"探测音频文件\",\n  \"ButtonPurgeAllCache\": \"清理所有缓存\",\n  \"ButtonPurgeItemsCache\": \"清理项目缓存\",\n  \"ButtonQueueAddItem\": \"添加到队列\",\n  \"ButtonQueueRemoveItem\": \"从队列中移除\",\n  \"ButtonQuickEmbed\": \"快速嵌入\",\n  \"ButtonQuickEmbedMetadata\": \"快速嵌入元数据\",\n  \"ButtonQuickMatch\": \"快速匹配\",\n  \"ButtonReScan\": \"重新扫描\",\n  \"ButtonRead\": \"读取\",\n  \"ButtonReadLess\": \"收起\",\n  \"ButtonReadMore\": \"阅读更多\",\n  \"ButtonRefresh\": \"刷新\",\n  \"ButtonRemove\": \"移除\",\n  \"ButtonRemoveAll\": \"移除所有\",\n  \"ButtonRemoveAllLibraryItems\": \"移除所有媒体库项目\",\n  \"ButtonRemoveFromContinueListening\": \"从继续收听中删除\",\n  \"ButtonRemoveFromContinueReading\": \"从继续阅读中删除\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"从继续收听系列中删除\",\n  \"ButtonReset\": \"重置\",\n  \"ButtonResetToDefault\": \"重置为默认\",\n  \"ButtonRestore\": \"恢复\",\n  \"ButtonSave\": \"保存\",\n  \"ButtonSaveAndClose\": \"保存并关闭\",\n  \"ButtonSaveTracklist\": \"保存音轨列表\",\n  \"ButtonScan\": \"扫描\",\n  \"ButtonScanLibrary\": \"扫描库\",\n  \"ButtonScrollLeft\": \"向左滚动\",\n  \"ButtonScrollRight\": \"向右滚动\",\n  \"ButtonSearch\": \"查找\",\n  \"ButtonSelectFolderPath\": \"选择文件夹路径\",\n  \"ButtonSeries\": \"系列\",\n  \"ButtonSetChaptersFromTracks\": \"将音轨设置为章节\",\n  \"ButtonShare\": \"分享\",\n  \"ButtonShiftTimes\": \"快速调整时间\",\n  \"ButtonShow\": \"显示\",\n  \"ButtonStartM4BEncode\": \"开始 M4B 编码\",\n  \"ButtonStartMetadataEmbed\": \"开始嵌入元数据\",\n  \"ButtonStats\": \"统计数据\",\n  \"ButtonSubmit\": \"提交\",\n  \"ButtonTest\": \"测试\",\n  \"ButtonUnlinkOpenId\": \"取消 OpenID 链接\",\n  \"ButtonUpload\": \"上传\",\n  \"ButtonUploadBackup\": \"上传备份\",\n  \"ButtonUploadCover\": \"上传封面\",\n  \"ButtonUploadOPMLFile\": \"上传 OPML 文件\",\n  \"ButtonUserDelete\": \"删除用户 {0}\",\n  \"ButtonUserEdit\": \"编辑用户 {0}\",\n  \"ButtonViewAll\": \"查看全部\",\n  \"ButtonYes\": \"确定\",\n  \"ErrorUploadFetchMetadataAPI\": \"获取元数据时出错\",\n  \"ErrorUploadFetchMetadataNoResults\": \"无法获取元数据 - 尝试更新标题和/或作者\",\n  \"ErrorUploadLacksTitle\": \"必须有标题\",\n  \"HeaderAccount\": \"帐户\",\n  \"HeaderAddCustomMetadataProvider\": \"添加自定义元数据提供商\",\n  \"HeaderAdvanced\": \"高级\",\n  \"HeaderApiKeys\": \"API 密钥\",\n  \"HeaderAppriseNotificationSettings\": \"测试通知设置\",\n  \"HeaderAudioTracks\": \"音轨\",\n  \"HeaderAudiobookTools\": \"有声读物文件管理工具\",\n  \"HeaderAuthentication\": \"身份验证\",\n  \"HeaderBackups\": \"备份\",\n  \"HeaderBulkChapterModal\": \"添加多个章节\",\n  \"HeaderChangePassword\": \"更改密码\",\n  \"HeaderChapters\": \"章节\",\n  \"HeaderChooseAFolder\": \"选择文件夹\",\n  \"HeaderCollection\": \"收藏\",\n  \"HeaderCollectionItems\": \"收藏项目\",\n  \"HeaderCover\": \"封面\",\n  \"HeaderCurrentDownloads\": \"当前下载\",\n  \"HeaderCustomMessageOnLogin\": \"登录时的自定义消息\",\n  \"HeaderCustomMetadataProviders\": \"自定义元数据提供商\",\n  \"HeaderDetails\": \"详情\",\n  \"HeaderDownloadQueue\": \"下载队列\",\n  \"HeaderEbookFiles\": \"电子书文件\",\n  \"HeaderEmail\": \"邮箱\",\n  \"HeaderEmailSettings\": \"邮箱设置\",\n  \"HeaderEpisodes\": \"剧集\",\n  \"HeaderEreaderDevices\": \"Ereader 设备\",\n  \"HeaderEreaderSettings\": \"电子阅读器设置\",\n  \"HeaderFiles\": \"文件\",\n  \"HeaderFindChapters\": \"查找章节\",\n  \"HeaderIgnoredFiles\": \"忽略的文件\",\n  \"HeaderItemFiles\": \"项目文件\",\n  \"HeaderItemMetadataUtils\": \"项目元数据管理\",\n  \"HeaderLastListeningSession\": \"最后一次收听会话\",\n  \"HeaderLatestEpisodes\": \"最新剧集\",\n  \"HeaderLibraries\": \"媒体库\",\n  \"HeaderLibraryFiles\": \"媒体库文件\",\n  \"HeaderLibraryStats\": \"媒体库统计数据\",\n  \"HeaderListeningSessions\": \"收听会话\",\n  \"HeaderListeningStats\": \"收听统计数据\",\n  \"HeaderLogin\": \"登录\",\n  \"HeaderLogs\": \"日志\",\n  \"HeaderManageGenres\": \"管理流派\",\n  \"HeaderManageTags\": \"管理标签\",\n  \"HeaderMapDetails\": \"编辑详情\",\n  \"HeaderMatch\": \"匹配\",\n  \"HeaderMetadataOrderOfPrecedence\": \"元数据优先级\",\n  \"HeaderMetadataToEmbed\": \"嵌入元数据\",\n  \"HeaderNewAccount\": \"新建帐户\",\n  \"HeaderNewApiKey\": \"新建 API 密钥\",\n  \"HeaderNewLibrary\": \"新建媒体库\",\n  \"HeaderNotificationCreate\": \"创建通知\",\n  \"HeaderNotificationUpdate\": \"更新通知\",\n  \"HeaderNotifications\": \"通知\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID 连接身份验证\",\n  \"HeaderOpenListeningSessions\": \"活动中会话\",\n  \"HeaderOpenRSSFeed\": \"打开 RSS 源\",\n  \"HeaderOtherFiles\": \"其他文件\",\n  \"HeaderPasswordAuthentication\": \"密码认证\",\n  \"HeaderPermissions\": \"权限\",\n  \"HeaderPlayerQueue\": \"播放队列\",\n  \"HeaderPlayerSettings\": \"播放器设置\",\n  \"HeaderPlaylist\": \"播放列表\",\n  \"HeaderPlaylistItems\": \"播放列表项目\",\n  \"HeaderPodcastsToAdd\": \"要添加的播客\",\n  \"HeaderPresets\": \"预设\",\n  \"HeaderPreviewCover\": \"预览封面\",\n  \"HeaderRSSFeedGeneral\": \"RSS 详细信息\",\n  \"HeaderRSSFeedIsOpen\": \"RSS 源已打开\",\n  \"HeaderRSSFeeds\": \"RSS 订阅\",\n  \"HeaderRemoveEpisode\": \"移除剧集\",\n  \"HeaderRemoveEpisodes\": \"移除 {0} 剧集\",\n  \"HeaderSavedMediaProgress\": \"保存媒体进度\",\n  \"HeaderSchedule\": \"计划任务\",\n  \"HeaderScheduleEpisodeDownloads\": \"设置自动下载剧集\",\n  \"HeaderScheduleLibraryScans\": \"自动扫描媒体库\",\n  \"HeaderSession\": \"会话\",\n  \"HeaderSetBackupSchedule\": \"设置备份计划任务\",\n  \"HeaderSettings\": \"设置\",\n  \"HeaderSettingsDisplay\": \"显示\",\n  \"HeaderSettingsExperimental\": \"实验功能\",\n  \"HeaderSettingsGeneral\": \"通用\",\n  \"HeaderSettingsScanner\": \"扫描\",\n  \"HeaderSettingsSecurity\": \"安全\",\n  \"HeaderSettingsWebClient\": \"网页客户端\",\n  \"HeaderSleepTimer\": \"睡眠计时\",\n  \"HeaderStatsLargestItems\": \"最大的项目\",\n  \"HeaderStatsLongestItems\": \"项目时长(小时)\",\n  \"HeaderStatsMinutesListeningChart\": \"收听分钟数 (最近7天)\",\n  \"HeaderStatsRecentSessions\": \"历史会话\",\n  \"HeaderStatsTop10Authors\": \"前 10 位作者\",\n  \"HeaderStatsTop5Genres\": \"前 5 种流派\",\n  \"HeaderTableOfContents\": \"目录\",\n  \"HeaderTools\": \"工具\",\n  \"HeaderUpdateAccount\": \"更新帐户\",\n  \"HeaderUpdateApiKey\": \"更新 API 密钥\",\n  \"HeaderUpdateAuthor\": \"更新作者\",\n  \"HeaderUpdateDetails\": \"更新详情\",\n  \"HeaderUpdateLibrary\": \"更新媒体库\",\n  \"HeaderUsers\": \"用户\",\n  \"HeaderYearReview\": \"{0} 年回顾\",\n  \"HeaderYourStats\": \"你的统计数据\",\n  \"LabelAbridged\": \"删节版\",\n  \"LabelAbridgedChecked\": \"删节版 (已勾选)\",\n  \"LabelAbridgedUnchecked\": \"未删节版 (未勾选)\",\n  \"LabelAccessibleBy\": \"可访问\",\n  \"LabelAccountType\": \"帐户类型\",\n  \"LabelAccountTypeAdmin\": \"管理员\",\n  \"LabelAccountTypeGuest\": \"来宾\",\n  \"LabelAccountTypeUser\": \"用户\",\n  \"LabelActivities\": \"活动\",\n  \"LabelActivity\": \"活动\",\n  \"LabelAddToCollection\": \"添加到收藏\",\n  \"LabelAddToCollectionBatch\": \"批量添加 {0} 个媒体到收藏\",\n  \"LabelAddToPlaylist\": \"添加到播放列表\",\n  \"LabelAddToPlaylistBatch\": \"添加 {0} 个项目到播放列表\",\n  \"LabelAddedAt\": \"添加于\",\n  \"LabelAddedDate\": \"已添加 {0}\",\n  \"LabelAdminUsersOnly\": \"仅限管理员用户\",\n  \"LabelAll\": \"全部\",\n  \"LabelAllEpisodesDownloaded\": \"所有剧集已下载\",\n  \"LabelAllUsers\": \"所有用户\",\n  \"LabelAllUsersExcludingGuests\": \"除访客外的所有用户\",\n  \"LabelAllUsersIncludingGuests\": \"包括访客的所有用户\",\n  \"LabelAlreadyInYourLibrary\": \"已存在你的库中\",\n  \"LabelApiKeyCreated\": \"API 密钥 \\\"{0}\\\" 创建成功.\",\n  \"LabelApiKeyCreatedDescription\": \"请确保现在就复制 API 密钥, 之后将无法再次查看.\",\n  \"LabelApiKeyUser\": \"代用户操作\",\n  \"LabelApiKeyUserDescription\": \"此 API 密钥将具有与其代理的用户相同的权限. 在日志中, 其请求将被视为由该用户直接发出.\",\n  \"LabelApiToken\": \"API 令牌\",\n  \"LabelAppend\": \"附加\",\n  \"LabelAudioBitrate\": \"音频比特率 (例如: 128k)\",\n  \"LabelAudioChannels\": \"音频通道 (1 或 2)\",\n  \"LabelAudioCodec\": \"音频编解码器\",\n  \"LabelAuthor\": \"作者\",\n  \"LabelAuthorFirstLast\": \"作者 (姓 名)\",\n  \"LabelAuthorLastFirst\": \"作者 (名, 姓)\",\n  \"LabelAuthors\": \"作者\",\n  \"LabelAutoDownloadEpisodes\": \"自动下载剧集\",\n  \"LabelAutoFetchMetadata\": \"自动获取元数据\",\n  \"LabelAutoFetchMetadataHelp\": \"获取标题, 作者和系列的元数据以简化上传. 上传后可能需要匹配其他元数据.\",\n  \"LabelAutoLaunch\": \"自动启动\",\n  \"LabelAutoLaunchDescription\": \"导航到登录页面时自动重定向到身份验证提供程序 (手动覆盖路径 <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"自动注册\",\n  \"LabelAutoRegisterDescription\": \"登录后自动创建新用户\",\n  \"LabelBackToUser\": \"返回到用户\",\n  \"LabelBackupAudioFiles\": \"备份音频文件\",\n  \"LabelBackupLocation\": \"备份位置\",\n  \"LabelBackupsEnableAutomaticBackups\": \"自动备份\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"备份保存到 /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"最大备份大小 (GB) (0 为无限制)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"为了防止错误配置, 如果备份超过配置的大小, 备份将失败.\",\n  \"LabelBackupsNumberToKeep\": \"要保留的备份个数\",\n  \"LabelBackupsNumberToKeepHelp\": \"一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.\",\n  \"LabelBitrate\": \"比特率\",\n  \"LabelBonus\": \"额外\",\n  \"LabelBooks\": \"图书\",\n  \"LabelButtonText\": \"按钮文本\",\n  \"LabelByAuthor\": \"作者: {0}\",\n  \"LabelChangePassword\": \"修改密码\",\n  \"LabelChannels\": \"声道\",\n  \"LabelChapterCount\": \"{0} 章节\",\n  \"LabelChapterTitle\": \"章节标题\",\n  \"LabelChapters\": \"章节\",\n  \"LabelChaptersFound\": \"找到的章节\",\n  \"LabelClickForMoreInfo\": \"点击了解更多信息\",\n  \"LabelClickToUseCurrentValue\": \"点击使用当前值\",\n  \"LabelClosePlayer\": \"关闭播放器\",\n  \"LabelCodec\": \"编解码\",\n  \"LabelCollapseSeries\": \"折叠系列\",\n  \"LabelCollapseSubSeries\": \"折叠子系列\",\n  \"LabelCollection\": \"收藏\",\n  \"LabelCollections\": \"收藏\",\n  \"LabelComplete\": \"已完成\",\n  \"LabelConfirmPassword\": \"确认密码\",\n  \"LabelContinueListening\": \"继续收听\",\n  \"LabelContinueReading\": \"继续阅读\",\n  \"LabelContinueSeries\": \"继续收听系列\",\n  \"LabelCorsAllowed\": \"允许的跨域来源\",\n  \"LabelCover\": \"封面\",\n  \"LabelCoverImageURL\": \"封面图像 URL\",\n  \"LabelCoverProvider\": \"封面提供者\",\n  \"LabelCreatedAt\": \"创建时间\",\n  \"LabelCronExpression\": \"计划任务表达式\",\n  \"LabelCurrent\": \"当前\",\n  \"LabelCurrently\": \"当前:\",\n  \"LabelCustomCronExpression\": \"自定义计划任务表达式:\",\n  \"LabelDatetime\": \"日期时间\",\n  \"LabelDays\": \"天\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"从文件系统删除 (取消选中仅从数据库中删除)\",\n  \"LabelDescription\": \"描述\",\n  \"LabelDeselectAll\": \"全部取消选择\",\n  \"LabelDetectedPattern\": \"检测到的编号格式:\",\n  \"LabelDevice\": \"设备\",\n  \"LabelDeviceInfo\": \"设备信息\",\n  \"LabelDeviceIsAvailableTo\": \"设备可用于...\",\n  \"LabelDirectory\": \"目录\",\n  \"LabelDiscFromFilename\": \"从文件名获取光盘\",\n  \"LabelDiscFromMetadata\": \"从元数据获取光盘\",\n  \"LabelDiscover\": \"发现\",\n  \"LabelDownload\": \"下载\",\n  \"LabelDownloadNEpisodes\": \"下载 {0} 集\",\n  \"LabelDownloadable\": \"可下载\",\n  \"LabelDuration\": \"持续时间\",\n  \"LabelDurationComparisonExactMatch\": \"(完全匹配)\",\n  \"LabelDurationComparisonLonger\": \"({0} 更长)\",\n  \"LabelDurationComparisonShorter\": \"({0} 更短)\",\n  \"LabelDurationFound\": \"找到持续时间:\",\n  \"LabelEbook\": \"电子书\",\n  \"LabelEbooks\": \"电子书\",\n  \"LabelEdit\": \"编辑\",\n  \"LabelEmail\": \"邮箱\",\n  \"LabelEmailSettingsFromAddress\": \"发件人地址\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"拒绝未经授权的证书\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.\",\n  \"LabelEmailSettingsSecure\": \"安全\",\n  \"LabelEmailSettingsSecureHelp\": \"开启此选项时, 将始终通过TLS连接服务器. 关闭此选项时, 仅在服务器支持STARTTLS扩展时使用TLS. 在大多数情况下, 如果连接到端口465, 请将此项设为开启. 如果连接到端口587或25, 请将此设置保持为关闭. (来自nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"测试地址\",\n  \"LabelEmbeddedCover\": \"嵌入封面\",\n  \"LabelEnable\": \"启用\",\n  \"LabelEncodingBackupLocation\": \"你的原始音频文件的备份将存储在:\",\n  \"LabelEncodingChaptersNotEmbedded\": \"多轨有声读物中未嵌入章节.\",\n  \"LabelEncodingClearItemCache\": \"确保定期清除项目缓存.\",\n  \"LabelEncodingFinishedM4B\": \"完成的 M4B 将被放入你的有声读物文件夹中:\",\n  \"LabelEncodingInfoEmbedded\": \"元数据将嵌入有声读物文件夹内的音轨中.\",\n  \"LabelEncodingStartedNavigation\": \"一旦任务开始, 你就可以离开此页面.\",\n  \"LabelEncodingTimeWarning\": \"编码最多可能需要 30 分钟.\",\n  \"LabelEncodingWarningAdvancedSettings\": \"警告: 除非你熟悉 ffmpeg 编码选项, 否则请不要更新这些设置.\",\n  \"LabelEncodingWatcherDisabled\": \"如果你禁用了监视器, 则随后需要重新扫描此有声读物.\",\n  \"LabelEnd\": \"结束\",\n  \"LabelEndOfChapter\": \"章节结束\",\n  \"LabelEpisode\": \"剧集\",\n  \"LabelEpisodeNotLinkedToRssFeed\": \"剧集没有链接到RSS源\",\n  \"LabelEpisodeNumber\": \"剧集 #{0}\",\n  \"LabelEpisodeTitle\": \"剧集标题\",\n  \"LabelEpisodeType\": \"剧集类型\",\n  \"LabelEpisodeUrlFromRssFeed\": \"来自 RSS 订阅的剧集 URL\",\n  \"LabelEpisodes\": \"剧集\",\n  \"LabelEpisodic\": \"剧集\",\n  \"LabelExample\": \"示例\",\n  \"LabelExpandSeries\": \"展开系列\",\n  \"LabelExpandSubSeries\": \"展开子系列\",\n  \"LabelExpired\": \"已过期\",\n  \"LabelExpiresAt\": \"过期时间\",\n  \"LabelExpiresInSeconds\": \"有效期 (秒)\",\n  \"LabelExpiresNever\": \"从不\",\n  \"LabelExplicit\": \"含成人内容\",\n  \"LabelExplicitChecked\": \"成人内容 (已核实)\",\n  \"LabelExplicitUnchecked\": \"无成人内容 (未核实)\",\n  \"LabelExportOPML\": \"导出 OPML\",\n  \"LabelFeedURL\": \"源 URL\",\n  \"LabelFetchingMetadata\": \"正在获取元数据\",\n  \"LabelFile\": \"文件\",\n  \"LabelFileBirthtime\": \"文件创建时间\",\n  \"LabelFileBornDate\": \"添加于 {0}\",\n  \"LabelFileModified\": \"文件修改时间\",\n  \"LabelFileModifiedDate\": \"已修改 {0}\",\n  \"LabelFilename\": \"文件名\",\n  \"LabelFilterByUser\": \"按用户筛选\",\n  \"LabelFindEpisodes\": \"查找剧集\",\n  \"LabelFinished\": \"已听完\",\n  \"LabelFinishedDate\": \"完成于 {0}\",\n  \"LabelFolder\": \"文件夹\",\n  \"LabelFolders\": \"文件夹\",\n  \"LabelFontBold\": \"加粗\",\n  \"LabelFontBoldness\": \"字体粗细\",\n  \"LabelFontFamily\": \"字体\",\n  \"LabelFontItalic\": \"斜体\",\n  \"LabelFontScale\": \"字体比例\",\n  \"LabelFontStrikethrough\": \"删除线\",\n  \"LabelFormat\": \"编码格式\",\n  \"LabelFull\": \"完整\",\n  \"LabelGenre\": \"流派\",\n  \"LabelGenres\": \"流派\",\n  \"LabelHardDeleteFile\": \"完全删除文件\",\n  \"LabelHasEbook\": \"有电子书\",\n  \"LabelHasSupplementaryEbook\": \"有补充电子书\",\n  \"LabelHideSubtitles\": \"隐藏副标题\",\n  \"LabelHighestPriority\": \"最高优先级\",\n  \"LabelHost\": \"主机\",\n  \"LabelHour\": \"小时\",\n  \"LabelHours\": \"小时\",\n  \"LabelIcon\": \"图标\",\n  \"LabelImageURLFromTheWeb\": \"来自 Web 图像的 URL\",\n  \"LabelInProgress\": \"正在听\",\n  \"LabelIncludeInTracklist\": \"包含在音轨列表中\",\n  \"LabelIncomplete\": \"未听完\",\n  \"LabelInterval\": \"间隔\",\n  \"LabelIntervalCustomDailyWeekly\": \"自定义 每天 / 每周\",\n  \"LabelIntervalEvery12Hours\": \"每 12 小时\",\n  \"LabelIntervalEvery15Minutes\": \"每 15 分钟\",\n  \"LabelIntervalEvery2Hours\": \"每 2 小时\",\n  \"LabelIntervalEvery30Minutes\": \"每 30 分钟\",\n  \"LabelIntervalEvery6Hours\": \"每 6 小时\",\n  \"LabelIntervalEveryDay\": \"每天\",\n  \"LabelIntervalEveryHour\": \"每小时\",\n  \"LabelIntervalEveryMinute\": \"每分钟\",\n  \"LabelInvert\": \"倒转\",\n  \"LabelItem\": \"项目\",\n  \"LabelJumpBackwardAmount\": \"向后跳转时间\",\n  \"LabelJumpForwardAmount\": \"向前跳转时间\",\n  \"LabelLanguage\": \"语言\",\n  \"LabelLanguageDefaultServer\": \"默认服务器语言\",\n  \"LabelLanguages\": \"语言\",\n  \"LabelLastBookAdded\": \"最后添加的书\",\n  \"LabelLastBookUpdated\": \"最后更新的书\",\n  \"LabelLastProgressDate\": \"上次阅读时间: {0}\",\n  \"LabelLastSeen\": \"上次查看时间\",\n  \"LabelLastTime\": \"最近一次\",\n  \"LabelLastUpdate\": \"最近更新\",\n  \"LabelLayout\": \"布局\",\n  \"LabelLayoutSinglePage\": \"单页\",\n  \"LabelLayoutSplitPage\": \"分页\",\n  \"LabelLess\": \"较少\",\n  \"LabelLibrariesAccessibleToUser\": \"用户可访问的媒体库\",\n  \"LabelLibrary\": \"媒体库\",\n  \"LabelLibraryFilterSublistEmpty\": \"没有 {0}\",\n  \"LabelLibraryItem\": \"媒体库项目\",\n  \"LabelLibraryName\": \"媒体库名称\",\n  \"LabelLibrarySortByProgress\": \"进度: 上次更新\",\n  \"LabelLibrarySortByProgressFinished\": \"进度: 已完成\",\n  \"LabelLibrarySortByProgressStarted\": \"进度: 已开始\",\n  \"LabelLimit\": \"限制\",\n  \"LabelLineSpacing\": \"行间距\",\n  \"LabelListenAgain\": \"再次收听\",\n  \"LabelLogLevelDebug\": \"调试\",\n  \"LabelLogLevelInfo\": \"信息\",\n  \"LabelLogLevelWarn\": \"警告\",\n  \"LabelLookForNewEpisodesAfterDate\": \"在此日期后查找新剧集\",\n  \"LabelLowestPriority\": \"最低优先级\",\n  \"LabelMatchConfidence\": \"置信度\",\n  \"LabelMatchExistingUsersBy\": \"匹配现有用户\",\n  \"LabelMatchExistingUsersByDescription\": \"用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配\",\n  \"LabelMaxEpisodesToDownload\": \"可下载的最大集数. 输入 0 表示无限制.\",\n  \"LabelMaxEpisodesToDownloadPerCheck\": \"每次检查最多可下载新剧集数\",\n  \"LabelMaxEpisodesToKeep\": \"要保留的最大剧集数\",\n  \"LabelMaxEpisodesToKeepHelp\": \"值为 0 时, 不设置最大限制. 自动下载新剧集后, 如果您有超过 X 个剧集, 它将删除最旧的剧集. 每次新下载时, 只会删除 1 个剧集.\",\n  \"LabelMediaPlayer\": \"媒体播放器\",\n  \"LabelMediaType\": \"媒体类型\",\n  \"LabelMetaTag\": \"元数据标签\",\n  \"LabelMetaTags\": \"元标签\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"较高优先级的元数据源将覆盖较低优先级的元数据源\",\n  \"LabelMetadataProvider\": \"元数据提供商\",\n  \"LabelMinute\": \"分钟\",\n  \"LabelMinutes\": \"分钟\",\n  \"LabelMissing\": \"丢失的\",\n  \"LabelMissingEbook\": \"没有电子书\",\n  \"LabelMissingSupplementaryEbook\": \"没有补充电子书\",\n  \"LabelMobileRedirectURIs\": \"允许移动应用重定向 URI\",\n  \"LabelMobileRedirectURIsDescription\": \"这是移动应用程序的有效重定向 URI 白名单. 默认值为 <code>audiobookshelf://oauth</code>，你可以删除它或添加其他 URI 以进行第三方应用集成. 使用星号 (<code>*</code>) 作为唯一条目允许任何 URI.\",\n  \"LabelMore\": \"更多\",\n  \"LabelMoreInfo\": \"更多信息\",\n  \"LabelName\": \"名称\",\n  \"LabelNarrator\": \"演播者\",\n  \"LabelNarrators\": \"演播者\",\n  \"LabelNew\": \"新建\",\n  \"LabelNewPassword\": \"新密码\",\n  \"LabelNewestAuthors\": \"最新作者\",\n  \"LabelNewestEpisodes\": \"最新剧集\",\n  \"LabelNextBackupDate\": \"下次备份日期\",\n  \"LabelNextChapters\": \"后续章节示例:\",\n  \"LabelNextScheduledRun\": \"下次任务运行\",\n  \"LabelNoApiKeys\": \"无 API 密钥\",\n  \"LabelNoCustomMetadataProviders\": \"没有自定义元数据提供商\",\n  \"LabelNoEpisodesSelected\": \"未选择任何剧集\",\n  \"LabelNotFinished\": \"未听完\",\n  \"LabelNotStarted\": \"未开始\",\n  \"LabelNotes\": \"注释\",\n  \"LabelNotificationAppriseURL\": \"通知 URL(s)\",\n  \"LabelNotificationAvailableVariables\": \"可用变量\",\n  \"LabelNotificationBodyTemplate\": \"正文模板\",\n  \"LabelNotificationEvent\": \"通知事件\",\n  \"LabelNotificationTitleTemplate\": \"标题模板\",\n  \"LabelNotificationsMaxFailedAttempts\": \"最大失败尝试次数\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"如果多次发送失败，通知将被禁用\",\n  \"LabelNotificationsMaxQueueSize\": \"通知事件的最大队列大小\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"通知事件被限制为每秒触发 1 个. 如果队列处于最大大小, 则将忽略事件. 这可以防止通知垃圾邮件.\",\n  \"LabelNumberOfBooks\": \"图书数量\",\n  \"LabelNumberOfChapters\": \"章节数量:\",\n  \"LabelNumberOfEpisodes\": \"# 集数\",\n  \"LabelOpenIDAdvancedPermsClaimDescription\": \"OpenID 声明的名称, 该声明包含应用程序内用户操作的高级权限, 该权限将应用于非管理员角色(<b>如果已配置</b>). 如果响应中缺少声明, 获取 ABS 的权限将被拒绝. 如果缺少单个选项, 它将被视为 <code>禁用</code>. 确保身份提供商的声明与预期结构匹配:\",\n  \"LabelOpenIDClaims\": \"将以下选项留空以禁用高级组和权限分配, 然后自动分配 'User' 组.\",\n  \"LabelOpenIDGroupClaimDescription\": \"OpenID 声明的名称, 该声明包含用户组的列表. 通常称为<code>组</code><b>如果已配置</b>, 应用程序将根据用户的组成员身份自动分配角色, 前提是这些组在声明中以不区分大小写的方式命名为 'Admin', 'User' 或 'Guest'. 声明应包含一个列表, 如果用户属于多个组, 则应用程序将分配与最高访问级别相对应的角色. 如果没有组匹配, 访问将被拒绝.\",\n  \"LabelOpenRSSFeed\": \"打开 RSS 源\",\n  \"LabelOverwrite\": \"覆盖\",\n  \"LabelPaginationPageXOfY\": \"第 {0} 页 共 {1} 页\",\n  \"LabelPassword\": \"密码\",\n  \"LabelPath\": \"路径\",\n  \"LabelPermanent\": \"永久的\",\n  \"LabelPermissionsAccessAllLibraries\": \"可以访问所有媒体库\",\n  \"LabelPermissionsAccessAllTags\": \"可以访问所有标签\",\n  \"LabelPermissionsAccessExplicitContent\": \"可以访问成人内容\",\n  \"LabelPermissionsCreateEreader\": \"可以创建电子阅读器\",\n  \"LabelPermissionsDelete\": \"可以删除\",\n  \"LabelPermissionsDownload\": \"可以下载\",\n  \"LabelPermissionsUpdate\": \"可以更新\",\n  \"LabelPermissionsUpload\": \"可以上传\",\n  \"LabelPersonalYearReview\": \"你的年度回顾 ({0})\",\n  \"LabelPhotoPathURL\": \"图片路径或 URL\",\n  \"LabelPlayMethod\": \"播放方法\",\n  \"LabelPlaybackRateIncrementDecrement\": \"播放速率增加/减少量\",\n  \"LabelPlayerChapterNumberMarker\": \"{0} 于 {1}\",\n  \"LabelPlaylists\": \"播放列表\",\n  \"LabelPodcast\": \"播客\",\n  \"LabelPodcastSearchRegion\": \"播客搜索地区\",\n  \"LabelPodcastType\": \"播客类型\",\n  \"LabelPodcasts\": \"播客\",\n  \"LabelPort\": \"端口\",\n  \"LabelPrefixesToIgnore\": \"忽略的前缀 (不区分大小写)\",\n  \"LabelPreventIndexing\": \"防止 iTunes 和 Google 播客目录对你的源进行索引\",\n  \"LabelPrimaryEbook\": \"主电子书\",\n  \"LabelProgress\": \"进度\",\n  \"LabelProvider\": \"提供商\",\n  \"LabelProviderAuthorizationValue\": \"授权标头值\",\n  \"LabelPubDate\": \"出版日期\",\n  \"LabelPublishYear\": \"发布年份\",\n  \"LabelPublishedDate\": \"已发布 {0}\",\n  \"LabelPublishedDecade\": \"出版年代\",\n  \"LabelPublishedDecades\": \"出版年代\",\n  \"LabelPublisher\": \"出版商\",\n  \"LabelPublishers\": \"出版商\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"自定义所有者电子邮件\",\n  \"LabelRSSFeedCustomOwnerName\": \"自定义所有者名称\",\n  \"LabelRSSFeedOpen\": \"打开 RSS 源\",\n  \"LabelRSSFeedPreventIndexing\": \"防止索引\",\n  \"LabelRSSFeedSlug\": \"RSS 源段\",\n  \"LabelRSSFeedURL\": \"RSS 源 URL\",\n  \"LabelRandomly\": \"随机\",\n  \"LabelReAddSeriesToContinueListening\": \"重新添加系列以继续收听\",\n  \"LabelRead\": \"阅读\",\n  \"LabelReadAgain\": \"再次阅读\",\n  \"LabelReadEbookWithoutProgress\": \"阅读电子书而不保存进度\",\n  \"LabelRecentSeries\": \"最近添加系列\",\n  \"LabelRecentlyAdded\": \"最近添加\",\n  \"LabelRecommended\": \"推荐内容\",\n  \"LabelRedo\": \"重做\",\n  \"LabelRegion\": \"区域\",\n  \"LabelReleaseDate\": \"发布日期\",\n  \"LabelRemoveAllMetadataAbs\": \"删除所有 metadata.abs 文件\",\n  \"LabelRemoveAllMetadataJson\": \"删除所有 metadata.json 文件\",\n  \"LabelRemoveAudibleBranding\": \"删除章节中的 Audible 简介和结尾\",\n  \"LabelRemoveCover\": \"移除封面\",\n  \"LabelRemoveMetadataFile\": \"删除库项目文件夹中的元数据文件\",\n  \"LabelRemoveMetadataFileHelp\": \"删除 {0} 文件夹中的所有 metadata.json 和 metadata.abs 文件.\",\n  \"LabelRowsPerPage\": \"每页行数\",\n  \"LabelSearchTerm\": \"搜索项\",\n  \"LabelSearchTitle\": \"搜索标题\",\n  \"LabelSearchTitleOrASIN\": \"搜索标题或 ASIN\",\n  \"LabelSeason\": \"季\",\n  \"LabelSeasonNumber\": \"第 {0} 季\",\n  \"LabelSelectAll\": \"全选\",\n  \"LabelSelectAllEpisodes\": \"选择所有剧集\",\n  \"LabelSelectEpisodesShowing\": \"选择正在播放的 {0} 剧集\",\n  \"LabelSelectUser\": \"选择用户\",\n  \"LabelSelectUsers\": \"选择用户\",\n  \"LabelSendEbookToDevice\": \"发送电子书到...\",\n  \"LabelSequence\": \"序列\",\n  \"LabelSerial\": \"系列\",\n  \"LabelSeries\": \"系列\",\n  \"LabelSeriesName\": \"系列名称\",\n  \"LabelSeriesProgress\": \"系列进度\",\n  \"LabelServerLogLevel\": \"服务器日志级别\",\n  \"LabelServerYearReview\": \"服务器年度回顾 ({0})\",\n  \"LabelSetEbookAsPrimary\": \"设置为主\",\n  \"LabelSetEbookAsSupplementary\": \"设置为补充\",\n  \"LabelSettingsAllowIframe\": \"允许嵌入到 iframe 中\",\n  \"LabelSettingsAudiobooksOnly\": \"只有有声读物\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书\",\n  \"LabelSettingsBookshelfViewHelp\": \"带有木架子的拟物化设计\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast 支持\",\n  \"LabelSettingsDateFormat\": \"日期格式\",\n  \"LabelSettingsEnableWatcher\": \"自动检测媒体库变化\",\n  \"LabelSettingsEnableWatcherForLibrary\": \"自动检测媒体库变化\",\n  \"LabelSettingsEnableWatcherHelp\": \"当检测到文件更改时, 启用项目的自动添加/更新. *需要重新启动服务器\",\n  \"LabelSettingsEpubsAllowScriptedContent\": \"允许 epubs 中包含脚本内容\",\n  \"LabelSettingsEpubsAllowScriptedContentHelp\": \"允许 epub 文件执行脚本. 建议将此设置保持禁用, 除非你信任 epub 文件的来源.\",\n  \"LabelSettingsExperimentalFeatures\": \"实验功能\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.\",\n  \"LabelSettingsFindCovers\": \"查找封面\",\n  \"LabelSettingsFindCoversHelp\": \"如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.<br>注意: 这将延长扫描时间\",\n  \"LabelSettingsHideSingleBookSeries\": \"隐藏单书系列\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"只有一本书的系列将从系列页面和主页书架中隐藏.\",\n  \"LabelSettingsHomePageBookshelfView\": \"首页使用书架视图\",\n  \"LabelSettingsLibraryBookshelfView\": \"媒体库使用书架视图\",\n  \"LabelSettingsLibraryMarkAsFinishedPercentComplete\": \"完成百分比大于\",\n  \"LabelSettingsLibraryMarkAsFinishedTimeRemaining\": \"剩余时间少于 (秒)\",\n  \"LabelSettingsLibraryMarkAsFinishedWhen\": \"当发生以下情况时将媒体项目标记为已完成\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeries\": \"跳过继续系列中的早期书籍\",\n  \"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp\": \"继续系列主页书架显示系列中未开始的第一本书, 该系列至少有一本书已完成且没有正在进行的书. 启用此设置将从最远完成的书开始系列, 而不是从第一本书开始.\",\n  \"LabelSettingsParseSubtitles\": \"解析副标题\",\n  \"LabelSettingsParseSubtitlesHelp\": \"从有声读物文件夹中提取副标题.<br>副标题必须用 \\\" - \\\" 分隔.<br>例: \\\"书名 - 这里是副标题\\\" 则显示副标题 \\\"这里是副标题\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"首选匹配的元数据\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"使用快速匹配时, 匹配的数据将覆盖项目详细信息. 默认情况下, 快速匹配将只填充缺少的详细信息.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"跳过匹配已有 ASIN 的图书\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"跳过匹配已有 ISBN 的图书\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"排序时忽略前缀\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"例如: 前缀为 \\\"The\\\" 的图书标题 \\\"The Book Title\\\" 将按 \\\"Book Title, The\\\" 进行排序\",\n  \"LabelSettingsSquareBookCovers\": \"用户方形图书封面\",\n  \"LabelSettingsSquareBookCoversHelp\": \"比起标准的 1.6:1 图书封面，更喜欢使用方形封面\",\n  \"LabelSettingsStoreCoversWithItem\": \"存储项目封面\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \\\"cover\\\" 的文件\",\n  \"LabelSettingsStoreMetadataWithItem\": \"存储项目元数据\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中\",\n  \"LabelSettingsTimeFormat\": \"时间格式\",\n  \"LabelShare\": \"分享\",\n  \"LabelShareDownloadableHelp\": \"允许用户通过共享链接的下载库项目为 zip 文件.\",\n  \"LabelShareOpen\": \"打开分享\",\n  \"LabelShareURL\": \"分享 URL\",\n  \"LabelShowAll\": \"全部显示\",\n  \"LabelShowSeconds\": \"显示秒数\",\n  \"LabelShowSubtitles\": \"显示标题\",\n  \"LabelSize\": \"文件大小\",\n  \"LabelSleepTimer\": \"睡眠定时\",\n  \"LabelSlug\": \"Slug\",\n  \"LabelSortAscending\": \"升序\",\n  \"LabelSortDescending\": \"降序\",\n  \"LabelSortPubDate\": \"按出版日期排序\",\n  \"LabelStart\": \"开始\",\n  \"LabelStartTime\": \"开始时间\",\n  \"LabelStarted\": \"开始于\",\n  \"LabelStartedAt\": \"收听始于\",\n  \"LabelStartedDate\": \"开始于 {0}\",\n  \"LabelStatsAudioTracks\": \"音轨\",\n  \"LabelStatsAuthors\": \"作者\",\n  \"LabelStatsBestDay\": \"单日最高\",\n  \"LabelStatsDailyAverage\": \"每日平均值\",\n  \"LabelStatsDays\": \"连续\",\n  \"LabelStatsDaysListened\": \"收听天数\",\n  \"LabelStatsHours\": \"小时\",\n  \"LabelStatsInARow\": \"天\",\n  \"LabelStatsItemsFinished\": \"已完成的项目\",\n  \"LabelStatsItemsInLibrary\": \"媒体库中的项目\",\n  \"LabelStatsMinutes\": \"分钟\",\n  \"LabelStatsMinutesListening\": \"收听分钟数\",\n  \"LabelStatsOverallDays\": \"总计天数\",\n  \"LabelStatsOverallHours\": \"总计小时\",\n  \"LabelStatsWeekListening\": \"每周收听\",\n  \"LabelSubtitle\": \"副标题\",\n  \"LabelSupportedFileTypes\": \"支持的文件类型\",\n  \"LabelTag\": \"标签\",\n  \"LabelTags\": \"标签\",\n  \"LabelTagsAccessibleToUser\": \"用户可访问的标签\",\n  \"LabelTagsNotAccessibleToUser\": \"用户无法访问标签\",\n  \"LabelTasks\": \"正在运行的任务\",\n  \"LabelTextEditorBulletedList\": \"项目符号列表\",\n  \"LabelTextEditorLink\": \"链接\",\n  \"LabelTextEditorNumberedList\": \"编号列表\",\n  \"LabelTextEditorUnlink\": \"取消链接\",\n  \"LabelTheme\": \"主题\",\n  \"LabelThemeDark\": \"黑暗\",\n  \"LabelThemeLight\": \"明亮\",\n  \"LabelThemeSepia\": \"棕褐色\",\n  \"LabelTimeBase\": \"时间基准\",\n  \"LabelTimeDurationXHours\": \"{0} 小时\",\n  \"LabelTimeDurationXMinutes\": \"{0} 分钟\",\n  \"LabelTimeDurationXSeconds\": \"{0} 秒\",\n  \"LabelTimeInMinutes\": \"时间 (分钟)\",\n  \"LabelTimeLeft\": \"剩余 {0}\",\n  \"LabelTimeListened\": \"收听时间\",\n  \"LabelTimeListenedToday\": \"今日收听的时间\",\n  \"LabelTimeRemaining\": \"剩余 {0}\",\n  \"LabelTimeToShift\": \"快速调整时间以秒为单位\",\n  \"LabelTitle\": \"标题\",\n  \"LabelToolsEmbedMetadata\": \"嵌入元数据\",\n  \"LabelToolsEmbedMetadataDescription\": \"将元数据嵌入音频文件, 包括封面图像和章节.\",\n  \"LabelToolsM4bEncoder\": \"M4B 编码器\",\n  \"LabelToolsMakeM4b\": \"制作 M4B 有声读物文件\",\n  \"LabelToolsMakeM4bDescription\": \"生成带有嵌入元数据, 封面图像和章节的 .M4B 有声读物文件.\",\n  \"LabelToolsSplitM4b\": \"将 M4B 文件拆分为 MP3 文件\",\n  \"LabelToolsSplitM4bDescription\": \"从 M4B 文件创建 MP3 文件, 按章节分割, 并嵌入元数据, 封面图像和章节.\",\n  \"LabelTotalDuration\": \"总持续时间\",\n  \"LabelTotalTimeListened\": \"总收听时间\",\n  \"LabelTrackFromFilename\": \"从文件名获取音轨\",\n  \"LabelTrackFromMetadata\": \"从源数据获取音轨\",\n  \"LabelTracks\": \"音轨\",\n  \"LabelTracksMultiTrack\": \"多轨\",\n  \"LabelTracksNone\": \"没有音轨\",\n  \"LabelTracksSingleTrack\": \"单轨\",\n  \"LabelTrailer\": \"预告\",\n  \"LabelType\": \"类型\",\n  \"LabelUnabridged\": \"未删节\",\n  \"LabelUndo\": \"撤消\",\n  \"LabelUnknown\": \"未知\",\n  \"LabelUnknownPublishDate\": \"未知发布日期\",\n  \"LabelUpdateCover\": \"更新封面\",\n  \"LabelUpdateCoverHelp\": \"找到匹配项时允许覆盖所选书籍存在的封面\",\n  \"LabelUpdateDetails\": \"更新详细信息\",\n  \"LabelUpdateDetailsHelp\": \"找到匹配项时允许覆盖所选书籍存在的详细信息\",\n  \"LabelUpdatedAt\": \"更新时间\",\n  \"LabelUploaderDragAndDrop\": \"拖放文件或文件夹\",\n  \"LabelUploaderDragAndDropFilesOnly\": \"拖放文件\",\n  \"LabelUploaderDropFiles\": \"删除文件\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"自动获取标题, 作者和系列\",\n  \"LabelUseAdvancedOptions\": \"使用高级选项\",\n  \"LabelUseChapterTrack\": \"使用章节音轨\",\n  \"LabelUseFullTrack\": \"使用完整音轨\",\n  \"LabelUseZeroForUnlimited\": \"使用 0 表示无限制\",\n  \"LabelUser\": \"用户\",\n  \"LabelUsername\": \"用户名\",\n  \"LabelValue\": \"值\",\n  \"LabelVersion\": \"版本\",\n  \"LabelViewBookmarks\": \"查看书签\",\n  \"LabelViewChapters\": \"查看章节\",\n  \"LabelViewPlayerSettings\": \"查看播放器设置\",\n  \"LabelViewQueue\": \"查看播放列表\",\n  \"LabelVolume\": \"音量\",\n  \"LabelWebRedirectURLsDescription\": \"在你的 OAuth 提供商中授权这些链接，以允许在登录后重定向回 Web 应用程序:\",\n  \"LabelWebRedirectURLsSubfolder\": \"重定向 URL 的子文件夹\",\n  \"LabelWeekdaysToRun\": \"工作日运行\",\n  \"LabelXBooks\": \"{0} 本书\",\n  \"LabelXItems\": \"{0} 项目\",\n  \"LabelYearReviewHide\": \"隐藏年度回顾\",\n  \"LabelYearReviewShow\": \"查看年度回顾\",\n  \"LabelYourAudiobookDuration\": \"你的有声读物持续时间\",\n  \"LabelYourBookmarks\": \"你的书签\",\n  \"LabelYourPlaylists\": \"你的播放列表\",\n  \"LabelYourProgress\": \"你的进度\",\n  \"MessageAddToPlayerQueue\": \"添加到播放队列\",\n  \"MessageAppriseDescription\": \"要使用此功能，你需要运行一个 <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageAsinCheck\": \"确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.\",\n  \"MessageAuthenticationLegacyTokenWarning\": \"旧版 API 令牌将来会被移除. 请改用 <a href=\\\"/config/api-keys\\\">API 密钥</a>.\",\n  \"MessageAuthenticationOIDCChangesRestart\": \"保存后重新启动服务器以应用 OIDC 更改.\",\n  \"MessageAuthenticationSecurityMessage\": \"身份验证安全性已增强, 所有用户都需要重新登录.\",\n  \"MessageBackupsDescription\": \"备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 备份不包括存储在你的媒体库文件夹中的任何文件.\",\n  \"MessageBackupsLocationEditNote\": \"注意: 更新备份位置不会移动或修改现有备份\",\n  \"MessageBackupsLocationNoEditNote\": \"注意: 备份位置是通过环境变量设置的, 不能在此处更改.\",\n  \"MessageBackupsLocationPathEmpty\": \"备份位置路径不能为空\",\n  \"MessageBatchEditPopulateMapDetailsAllHelp\": \"使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并\",\n  \"MessageBatchEditPopulateMapDetailsItemHelp\": \"提取此项目的信息, 填入上方所有勾选的编辑框中\",\n  \"MessageBatchQuickMatchDescription\": \"快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.\",\n  \"MessageBookshelfNoCollections\": \"你尚未进行任何收藏\",\n  \"MessageBookshelfNoCollectionsHelp\": \"收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.\",\n  \"MessageBookshelfNoRSSFeeds\": \"没有打开的 RSS 源\",\n  \"MessageBookshelfNoResultsForFilter\": \"过滤器无结果 \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoResultsForQuery\": \"没有可查询的结果\",\n  \"MessageBookshelfNoSeries\": \"你没有系列\",\n  \"MessageBulkChapterPattern\": \"您想用此编号模式添加多少个章节?\",\n  \"MessageChapterEndIsAfter\": \"章节结束是在有声读物结束之后\",\n  \"MessageChapterErrorFirstNotZero\": \"第一章节必须从 0 开始\",\n  \"MessageChapterErrorStartGteDuration\": \"无效的开始时间, 必须小于有声读物持续时间\",\n  \"MessageChapterErrorStartLtPrev\": \"无效的开始时间, 必须大于或等于上一章节的开始时间\",\n  \"MessageChapterStartIsAfter\": \"章节开始是在有声读物结束之后\",\n  \"MessageChaptersNotFound\": \"未找到章节\",\n  \"MessageCheckingCron\": \"检查计划任务...\",\n  \"MessageConfirmCloseFeed\": \"你确定要关闭此订阅源吗?\",\n  \"MessageConfirmDeleteApiKey\": \"你确定要删除 API 密钥 \\\"{0}\\\" 吗?\",\n  \"MessageConfirmDeleteBackup\": \"你确定要删除备份 {0}?\",\n  \"MessageConfirmDeleteDevice\": \"你确定要删除电子阅读器设备 \\\"{0}\\\" 吗？\",\n  \"MessageConfirmDeleteFile\": \"这将从文件系统中删除该文件. 你确定吗?\",\n  \"MessageConfirmDeleteLibrary\": \"你确定要永久删除媒体库 \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"这将从数据库和文件系统中删除库项目. 你确定吗?\",\n  \"MessageConfirmDeleteLibraryItems\": \"这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?\",\n  \"MessageConfirmDeleteMetadataProvider\": \"是否确实要删除自定义元数据提供商 \\\"{0}\\\" ?\",\n  \"MessageConfirmDeleteNotification\": \"你确定要删除此通知吗?\",\n  \"MessageConfirmDeleteSession\": \"你确定要删除此会话吗?\",\n  \"MessageConfirmEmbedMetadataInAudioFiles\": \"你确定要将元数据嵌入到 {0} 个音频文件中吗?\",\n  \"MessageConfirmForceReScan\": \"你确定要强制重新扫描吗?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"你确定要将所有剧集都标记为已完成吗?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"你确定要将所有剧集都标记为未完成吗?\",\n  \"MessageConfirmMarkItemFinished\": \"你确定要将 \\\"{0}\\\" 标记为已完成吗?\",\n  \"MessageConfirmMarkItemNotFinished\": \"你确定要将 \\\"{0}\\\" 标记为未完成吗?\",\n  \"MessageConfirmMarkSeriesFinished\": \"你确定要将此系列中的所有书籍都标记为已听完吗?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"你确定要将此系列中的所有书籍都标记为未听完吗?\",\n  \"MessageConfirmNotificationTestTrigger\": \"使用测试数据触发此通知吗?\",\n  \"MessageConfirmPurgeCache\": \"清除缓存将删除 <code>/metadata/cache</code> 整个目录. <br /><br />你确定要删除缓存目录吗?\",\n  \"MessageConfirmPurgeItemsCache\": \"清除项目缓存将删除 <code>/metadata/cache/items</code> 整个目录.<br />你确定吗?\",\n  \"MessageConfirmQuickEmbed\": \"警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份. <br><br>你是否想继续吗?\",\n  \"MessageConfirmQuickMatchEpisodes\": \"如果找到匹配项, 快速匹配的剧集将覆盖详细信息. 只有不匹配的剧集才会更新. 你确定吗?\",\n  \"MessageConfirmReScanLibraryItems\": \"你确定要重新扫描 {0} 个项目吗?\",\n  \"MessageConfirmRemoveAllChapters\": \"你确定要移除所有章节吗?\",\n  \"MessageConfirmRemoveAuthor\": \"你确定要删除作者 \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"你确定要移除收藏 \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"你确定要移除剧集 \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodeNote\": \"注意: 此操作不会删除音频文件, 除非勾选 \\\"完全删除文件\\\" 选项\",\n  \"MessageConfirmRemoveEpisodes\": \"你确定要移除 {0} 剧集?\",\n  \"MessageConfirmRemoveListeningSessions\": \"你确定要移除 {0} 收听会话吗?\",\n  \"MessageConfirmRemoveMetadataFiles\": \"你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?\",\n  \"MessageConfirmRemoveNarrator\": \"你确定要删除演播者 \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"你确定要移除播放列表 \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"你确定要将所有项目流派 \\\"{0}\\\" 重命名到 \\\"{1}\\\"?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"注意: 该流派已经存在, 因此它们将被合并.\",\n  \"MessageConfirmRenameGenreWarning\": \"警告! 已经存在有大小写不同的类似流派 \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"你确定要将所有项目标签 \\\"{0}\\\" 重命名到 \\\"{1}\\\"?\",\n  \"MessageConfirmRenameTagMergeNote\": \"注意: 该标签已经存在, 因此它们将被合并.\",\n  \"MessageConfirmRenameTagWarning\": \"警告! 已经存在有大小写不同的类似标签 \\\"{0}\\\".\",\n  \"MessageConfirmResetProgress\": \"你确定要重置进度吗?\",\n  \"MessageConfirmSendEbookToDevice\": \"你确定要发送 {0} 电子书 \\\"{1}\\\" 到设备 \\\"{2}\\\"?\",\n  \"MessageConfirmUnlinkOpenId\": \"你确定要取消该用户与 OpenID 的链接吗?\",\n  \"MessageDaysListenedInTheLastYear\": \"去年收听了 {0} 天\",\n  \"MessageDownloadingEpisode\": \"正在下载剧集\",\n  \"MessageDragFilesIntoTrackOrder\": \"将文件拖动到正确的音轨顺序\",\n  \"MessageEmbedFailed\": \"嵌入失败!\",\n  \"MessageEmbedFinished\": \"嵌入完成!\",\n  \"MessageEmbedQueue\": \"已排队等待元数据嵌入 (队列中有 {0} 个)\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} 个剧集排队等待下载\",\n  \"MessageEreaderDevices\": \"为了确保电子书的送达, 你可能需要将上述电子邮件地址添加为下列每台设备的有效发件人.\",\n  \"MessageFeedURLWillBe\": \"源 URL 将改为 {0}\",\n  \"MessageFetching\": \"正在获取...\",\n  \"MessageForceReScanDescription\": \"将像重新扫描一样再次扫描所有文件. 音频文件 ID3 标签, OPF 文件和文本文件将被扫描为新文件.\",\n  \"MessageHeatmapListeningTimeTooltip\": \"{1} <strong>收听了 {0}</strong>\",\n  \"MessageHeatmapNoListeningSessions\": \"{0} 没有收听\",\n  \"MessageImportantNotice\": \"重要通知!\",\n  \"MessageInsertChapterBelow\": \"在下面插入章节\",\n  \"MessageInvalidAsin\": \"无效的 ASIN\",\n  \"MessageItemsSelected\": \"已选定 {0} 个项目\",\n  \"MessageItemsUpdated\": \"已更新 {0} 个项目\",\n  \"MessageJoinUsOn\": \"加入我们的\",\n  \"MessageLoading\": \"正在加载...\",\n  \"MessageLoadingFolders\": \"加载文件夹...\",\n  \"MessageLogsDescription\": \"日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.\",\n  \"MessageM4BFailed\": \"M4B 失败!\",\n  \"MessageM4BFinished\": \"M4B 完成!\",\n  \"MessageMapChapterTitles\": \"将章节标题映射到现有的有声读物章节, 无需调整时间戳\",\n  \"MessageMarkAllEpisodesFinished\": \"标记所有剧集为已完成\",\n  \"MessageMarkAllEpisodesNotFinished\": \"标记所有剧集为未完成\",\n  \"MessageMarkAsFinished\": \"标记为已听完\",\n  \"MessageMarkAsNotFinished\": \"标记为未听完\",\n  \"MessageMatchBooksDescription\": \"尝试将媒体库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.\",\n  \"MessageNoAudioTracks\": \"没有音轨\",\n  \"MessageNoAuthors\": \"没有作者\",\n  \"MessageNoBackups\": \"没有备份\",\n  \"MessageNoBookmarks\": \"没有书签\",\n  \"MessageNoChapters\": \"没有章节\",\n  \"MessageNoCollections\": \"没有收藏\",\n  \"MessageNoCoversFound\": \"没有找到封面\",\n  \"MessageNoDescription\": \"没有描述\",\n  \"MessageNoDevices\": \"没有设备\",\n  \"MessageNoDownloadsInProgress\": \"当前没有正在进行的下载\",\n  \"MessageNoDownloadsQueued\": \"下载队列无任务\",\n  \"MessageNoEpisodeMatchesFound\": \"没有找到任何剧集匹配项\",\n  \"MessageNoEpisodes\": \"没有剧集\",\n  \"MessageNoFoldersAvailable\": \"没有可用文件夹\",\n  \"MessageNoGenres\": \"无流派\",\n  \"MessageNoIssues\": \"无问题\",\n  \"MessageNoItems\": \"无项目\",\n  \"MessageNoItemsFound\": \"未找到任何项目\",\n  \"MessageNoListeningSessions\": \"无收听会话\",\n  \"MessageNoLogs\": \"无日志\",\n  \"MessageNoMediaProgress\": \"无媒体进度\",\n  \"MessageNoNotifications\": \"无通知\",\n  \"MessageNoPodcastFeed\": \"无效播客: 无源\",\n  \"MessageNoPodcastsFound\": \"未找到播客\",\n  \"MessageNoResults\": \"无结果\",\n  \"MessageNoSearchResultsFor\": \"没有搜索到结果 \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"无系列\",\n  \"MessageNoTags\": \"无标签\",\n  \"MessageNoTasksRunning\": \"没有正在运行的任务\",\n  \"MessageNoUpdatesWereNecessary\": \"无需更新\",\n  \"MessageNoUserPlaylists\": \"你没有播放列表\",\n  \"MessageNoUserPlaylistsHelp\": \"播放列表是私密的. 只有创建播放列表的用户才能看到.\",\n  \"MessageNotYetImplemented\": \"尚未实施\",\n  \"MessageOpmlPreviewNote\": \"注意: 这是解析的OPML文件的预览. 实际的播客标题将从 RSS 提要中获取.\",\n  \"MessageOr\": \"或\",\n  \"MessagePauseChapter\": \"暂停章节播放\",\n  \"MessagePlayChapter\": \"开始章节播放\",\n  \"MessagePlaylistCreateFromCollection\": \"从收藏中创建播放列表\",\n  \"MessagePleaseWait\": \"请稍等...\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"播客没有可用于匹配 RSS 源的 url\",\n  \"MessagePodcastSearchField\": \"输入搜索词或 RSS 源 URL\",\n  \"MessageQuickEmbedInProgress\": \"正在进行快速嵌入\",\n  \"MessageQuickEmbedQueue\": \"已排队等待快速嵌入 (队列中有 {0} 个)\",\n  \"MessageQuickMatchAllEpisodes\": \"快速匹配所有剧集\",\n  \"MessageQuickMatchDescription\": \"使用来自 '{0}' 的第一个匹配结果填充空白详细信息和封面. 除非启用 '首选匹配元数据' 服务器设置, 否则不会覆盖详细信息.\",\n  \"MessageRemoveChapter\": \"移除章节\",\n  \"MessageRemoveEpisodes\": \"移除 {0} 剧集\",\n  \"MessageRemoveFromPlayerQueue\": \"从播放队列中移除\",\n  \"MessageRemoveUserWarning\": \"是否确实要永久删除用户 \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"反馈问题, 建议功能或参与贡献, 请访问\",\n  \"MessageResetChaptersConfirm\": \"你确定要重置章节并撤消你所做的更改吗?\",\n  \"MessageRestoreBackupConfirm\": \"你确定要恢复创建的这个备份\",\n  \"MessageRestoreBackupWarning\": \"恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中，则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.\",\n  \"MessageScheduleLibraryScanNote\": \"对于大多数用户, 建议保持此功能关闭并启用 \\\"自动检测媒体库变化\\\" 选项, 它会自动检测媒体库文件夹中的更改. 如果 \\\"自动检测媒体库变化\\\" 在你的文件系统 (例如 NFS) 上无法正常工作, 请启用此功能.\",\n  \"MessageScheduleRunEveryWeekdayAtTime\": \"每 {0} 的 {1} 执行\",\n  \"MessageSearchResultsFor\": \"搜索结果\",\n  \"MessageSelected\": \"{0} 已选择\",\n  \"MessageSeriesSequenceCannotContainSpaces\": \"系列序列不能包含空格\",\n  \"MessageServerCouldNotBeReached\": \"无法访问服务器\",\n  \"MessageSetChaptersFromTracksDescription\": \"把每个音频文件设置为章节并将章节标题设置为音频文件名\",\n  \"MessageShareExpirationWillBe\": \"到期日期为 <strong>{0}</strong>\",\n  \"MessageShareExpiresIn\": \"到期时间 {0}\",\n  \"MessageShareURLWillBe\": \"分享网址是 <strong>{0}</strong>\",\n  \"MessageStartPlaybackAtTime\": \"开始播放 \\\"{0}\\\" 在 {1}?\",\n  \"MessageTaskAudioFileNotWritable\": \"音频文件 \\\"{0}\\\" 不可写\",\n  \"MessageTaskCanceledByUser\": \"任务已被用户取消\",\n  \"MessageTaskDownloadingEpisodeDescription\": \"正在下载剧集 \\\"{0}\\\"\",\n  \"MessageTaskEmbeddingMetadata\": \"嵌入元数据\",\n  \"MessageTaskEmbeddingMetadataDescription\": \"在有声读物 \\\"{0}\\\" 中嵌入元数据\",\n  \"MessageTaskEncodingM4b\": \"编码 M4B\",\n  \"MessageTaskEncodingM4bDescription\": \"将有声读物 \\\"{0}\\\" 编码为单个 m4b 文件\",\n  \"MessageTaskFailed\": \"失败\",\n  \"MessageTaskFailedToBackupAudioFile\": \"无法备份音频文件 \\\"{0}\\\"\",\n  \"MessageTaskFailedToCreateCacheDirectory\": \"无法创建缓存目录\",\n  \"MessageTaskFailedToEmbedMetadataInFile\": \"无法将元数据嵌入文件 \\\"{0}\\\"\",\n  \"MessageTaskFailedToMergeAudioFiles\": \"无法合并音频文件\",\n  \"MessageTaskFailedToMoveM4bFile\": \"无法移动 m4b 文件\",\n  \"MessageTaskFailedToWriteMetadataFile\": \"无法写入元数据文件\",\n  \"MessageTaskMatchingBooksInLibrary\": \"在库中匹配图书 \\\"{0}\\\"\",\n  \"MessageTaskNoFilesToScan\": \"没有要扫描的文件\",\n  \"MessageTaskOpmlImport\": \"OPML 导入\",\n  \"MessageTaskOpmlImportDescription\": \"通过 {0} RSS 源创建播客\",\n  \"MessageTaskOpmlImportFeed\": \"OPML 导入源\",\n  \"MessageTaskOpmlImportFeedDescription\": \"正在导入 RSS 源 \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedFailed\": \"无法获取播客信息\",\n  \"MessageTaskOpmlImportFeedPodcastDescription\": \"正在创建播客 \\\"{0}\\\"\",\n  \"MessageTaskOpmlImportFeedPodcastExists\": \"播客已存在于路径中\",\n  \"MessageTaskOpmlImportFeedPodcastFailed\": \"无法创建播客\",\n  \"MessageTaskOpmlImportFinished\": \"已添加 {0} 播客\",\n  \"MessageTaskOpmlParseFailed\": \"无法解析 OPML 文件\",\n  \"MessageTaskOpmlParseFastFail\": \"未找到无效的 OPML 文件 <opml> 标签或未找到 <outline> 标签\",\n  \"MessageTaskOpmlParseNoneFound\": \"OPML 文件中未找到任何信息\",\n  \"MessageTaskScanItemsAdded\": \"{0} 已添加\",\n  \"MessageTaskScanItemsMissing\": \"{0} 已缺失\",\n  \"MessageTaskScanItemsUpdated\": \"{0} 已更新\",\n  \"MessageTaskScanNoChangesNeeded\": \"无需改变\",\n  \"MessageTaskScanningFileChanges\": \"正在扫描文件更改 \\\"{0}\\\"\",\n  \"MessageTaskScanningLibrary\": \"扫描 \\\"{0}\\\" 库\",\n  \"MessageTaskTargetDirectoryNotWritable\": \"目标目录不可写\",\n  \"MessageThinking\": \"正在查找...\",\n  \"MessageUploaderItemFailed\": \"上传失败\",\n  \"MessageUploaderItemSuccess\": \"上传成功!\",\n  \"MessageUploading\": \"正在上传...\",\n  \"MessageValidCronExpression\": \"有效的计划任务表达式\",\n  \"MessageWatcherIsDisabledGlobally\": \"在服务器设置中禁用全局监视程序\",\n  \"MessageXLibraryIsEmpty\": \"{0} 库为空!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"你的有声读物持续时间比找到的持续时间长\",\n  \"MessageYourAudiobookDurationIsShorter\": \"你的有声读物持续时间比找到的持续时间短\",\n  \"NoteChangeRootPassword\": \"Root 是唯一可以拥有空密码的用户\",\n  \"NoteChapterEditorTimes\": \"注意: 第一章开始时间必须保持在 0:00, 最后一章开始时间不能超过有声读物持续时间.\",\n  \"NoteFolderPicker\": \"注意: 将不显示已映射的文件夹\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"警告: 你的一集或多集没有发布日期. 一些播客应用程序要求这样做.\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"包含媒体文件的文件夹将作为单独的媒体库项目处理.\",\n  \"NoteUploaderOnlyAudioFiles\": \"如果只上传音频文件, 则每个音频文件将作为单独的有声读物处理.\",\n  \"NoteUploaderUnsupportedFiles\": \"不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.\",\n  \"NotificationOnBackupCompletedDescription\": \"备份完成时触发\",\n  \"NotificationOnBackupFailedDescription\": \"备份失败时触发\",\n  \"NotificationOnEpisodeDownloadedDescription\": \"当播客节目自动下载完成时触发\",\n  \"NotificationOnRSSFeedDisabledDescription\": \"由于尝试失败次数过多而导致剧集自动下载被禁用时触发\",\n  \"NotificationOnRSSFeedFailedDescription\": \"当用于自动下载剧集的 RSS 源请求失败时触发\",\n  \"NotificationOnTestDescription\": \"测试通知系统的事件\",\n  \"PlaceholderBulkChapterInput\": \"请输入章节标题, 或使用编号格式 (例如: \\\"Episode 1\\\", \\\"Chapter 10\\\", \\\"1.\\\")\",\n  \"PlaceholderNewCollection\": \"输入收藏夹名称\",\n  \"PlaceholderNewFolderPath\": \"输入文件夹路径\",\n  \"PlaceholderNewPlaylist\": \"输入播放列表名称\",\n  \"PlaceholderSearch\": \"查找..\",\n  \"PlaceholderSearchEpisode\": \"搜索剧集..\",\n  \"StatsAuthorsAdded\": \"添加作者\",\n  \"StatsBooksAdded\": \"添加书籍\",\n  \"StatsBooksAdditional\": \"一些新增内容包括…\",\n  \"StatsBooksFinished\": \"已完成书籍\",\n  \"StatsBooksFinishedThisYear\": \"今年完成的一些书…\",\n  \"StatsBooksListenedTo\": \"听过的书\",\n  \"StatsCollectionGrewTo\": \"你的藏书已增长到…\",\n  \"StatsSessions\": \"会话\",\n  \"StatsSpentListening\": \"花时间聆听\",\n  \"StatsTopAuthor\": \"热门作者\",\n  \"StatsTopAuthors\": \"热门作者\",\n  \"StatsTopGenre\": \"热门流派\",\n  \"StatsTopGenres\": \"热门流派\",\n  \"StatsTopMonth\": \"最佳月份\",\n  \"StatsTopNarrator\": \"最佳叙述者\",\n  \"StatsTopNarrators\": \"最佳叙述者\",\n  \"StatsTotalDuration\": \"总时长为…\",\n  \"StatsYearInReview\": \"年度回顾\",\n  \"ToastAccountUpdateSuccess\": \"帐户已更新\",\n  \"ToastAppriseUrlRequired\": \"必须输入 Apprise URL\",\n  \"ToastAsinRequired\": \"需要 ASIN\",\n  \"ToastAuthorImageRemoveSuccess\": \"作者图像已删除\",\n  \"ToastAuthorNotFound\": \"未找到作者 \\\"{0}\\\"\",\n  \"ToastAuthorRemoveSuccess\": \"作者已删除\",\n  \"ToastAuthorSearchNotFound\": \"未找到作者\",\n  \"ToastAuthorUpdateMerged\": \"作者已合并\",\n  \"ToastAuthorUpdateSuccess\": \"作者已更新\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"作者已更新 (未找到图像)\",\n  \"ToastBackupAppliedSuccess\": \"已应用备份\",\n  \"ToastBackupCreateFailed\": \"备份创建失败\",\n  \"ToastBackupCreateSuccess\": \"备份已创建\",\n  \"ToastBackupDeleteFailed\": \"备份删除失败\",\n  \"ToastBackupDeleteSuccess\": \"备份已删除\",\n  \"ToastBackupInvalidMaxKeep\": \"要保留的备份数无效\",\n  \"ToastBackupInvalidMaxSize\": \"最大备份大小无效\",\n  \"ToastBackupRestoreFailed\": \"备份还原失败\",\n  \"ToastBackupUploadFailed\": \"上传备份失败\",\n  \"ToastBackupUploadSuccess\": \"备份已上传\",\n  \"ToastBatchApplyDetailsToItemsSuccess\": \"应用于项目的详细信息\",\n  \"ToastBatchDeleteFailed\": \"批量删除失败\",\n  \"ToastBatchDeleteSuccess\": \"批量删除成功\",\n  \"ToastBatchQuickMatchFailed\": \"批量快速匹配失败!\",\n  \"ToastBatchQuickMatchStarted\": \"批量快速匹配 {0} 图书已开始!\",\n  \"ToastBatchUpdateFailed\": \"批量更新失败\",\n  \"ToastBatchUpdateSuccess\": \"批量更新成功\",\n  \"ToastBookmarkCreateFailed\": \"创建书签失败\",\n  \"ToastBookmarkCreateSuccess\": \"书签已添加\",\n  \"ToastBookmarkRemoveSuccess\": \"书签已删除\",\n  \"ToastBulkChapterInvalidCount\": \"输入一个1到150之间的整数\",\n  \"ToastCachePurgeFailed\": \"清除缓存失败\",\n  \"ToastCachePurgeSuccess\": \"缓存清除成功\",\n  \"ToastChapterLocked\": \"章节已锁定.\",\n  \"ToastChapterStartTimeAdjusted\": \"章节开始时间已调整 {0} 秒\",\n  \"ToastChaptersAllLocked\": \"所有章节均已锁定. 请解锁要调整时间的章节.\",\n  \"ToastChaptersHaveErrors\": \"章节有错误\",\n  \"ToastChaptersInvalidShiftAmountLast\": \"偏移量无效. 最后一章的开始时间将超过这本有声读物的持续时间.\",\n  \"ToastChaptersInvalidShiftAmountStart\": \"偏移量无效. 第一章的长度将为零或负数, 并会被第二章覆盖. 请增加第二章的起始时长.\",\n  \"ToastChaptersMustHaveTitles\": \"章节必须有标题\",\n  \"ToastChaptersRemoved\": \"已删除章节\",\n  \"ToastChaptersUpdated\": \"章节已更新\",\n  \"ToastCollectionItemsAddFailed\": \"项目添加到收藏夹失败\",\n  \"ToastCollectionRemoveSuccess\": \"收藏夹已删除\",\n  \"ToastCollectionUpdateSuccess\": \"收藏夹已更新\",\n  \"ToastConnectionNotAvailable\": \"连接不可用. 请稍后重试\",\n  \"ToastCoverSearchFailed\": \"封面搜索失败\",\n  \"ToastCoverUpdateFailed\": \"封面更新失败\",\n  \"ToastDateTimeInvalidOrIncomplete\": \"日期和时间无效或不完整\",\n  \"ToastDeleteFileFailed\": \"删除文件失败\",\n  \"ToastDeleteFileSuccess\": \"文件已删除\",\n  \"ToastDeviceAddFailed\": \"添加设备失败\",\n  \"ToastDeviceNameAlreadyExists\": \"同名的电子阅读器设备已存在\",\n  \"ToastDeviceTestEmailFailed\": \"无法发送测试电子邮件\",\n  \"ToastDeviceTestEmailSuccess\": \"测试邮件已发送\",\n  \"ToastEmailSettingsUpdateSuccess\": \"电子邮件设置已更新\",\n  \"ToastEncodeCancelFailed\": \"取消编码失败\",\n  \"ToastEncodeCancelSucces\": \"编码已取消\",\n  \"ToastEpisodeDownloadQueueClearFailed\": \"无法清除队列\",\n  \"ToastEpisodeDownloadQueueClearSuccess\": \"剧集下载队列已清空\",\n  \"ToastEpisodeUpdateSuccess\": \"已更新 {0} 剧集\",\n  \"ToastErrorCannotShare\": \"无法在此设备上本地共享\",\n  \"ToastFailedToCreate\": \"创建失败\",\n  \"ToastFailedToDelete\": \"删除失败\",\n  \"ToastFailedToLoadData\": \"加载数据失败\",\n  \"ToastFailedToMatch\": \"匹配失败\",\n  \"ToastFailedToShare\": \"分享失败\",\n  \"ToastFailedToUpdate\": \"更新失败\",\n  \"ToastInvalidImageUrl\": \"图片网址无效\",\n  \"ToastInvalidMaxEpisodesToDownload\": \"可下载的最大集数无效\",\n  \"ToastInvalidUrl\": \"网址无效\",\n  \"ToastInvalidUrls\": \"一个或多个 URL 无效\",\n  \"ToastItemCoverUpdateSuccess\": \"项目封面已更新\",\n  \"ToastItemDeletedFailed\": \"删除项目失败\",\n  \"ToastItemDeletedSuccess\": \"已删除项目\",\n  \"ToastItemDetailsUpdateSuccess\": \"项目详细信息已更新\",\n  \"ToastItemMarkedAsFinishedFailed\": \"无法标记为已听完\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"标记为已听完的项目\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"无法标记为未听完\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"标记为未听完的项目\",\n  \"ToastItemUpdateSuccess\": \"项目已更新\",\n  \"ToastLibraryCreateFailed\": \"创建媒体库失败\",\n  \"ToastLibraryCreateSuccess\": \"媒体库 \\\"{0}\\\" 创建成功\",\n  \"ToastLibraryDeleteFailed\": \"删除媒体库失败\",\n  \"ToastLibraryDeleteSuccess\": \"媒体库已删除\",\n  \"ToastLibraryScanFailedToStart\": \"无法启动扫描\",\n  \"ToastLibraryScanStarted\": \"媒体库扫描已启动\",\n  \"ToastLibraryUpdateSuccess\": \"媒体库 \\\"{0}\\\" 已更新\",\n  \"ToastMatchAllAuthorsFailed\": \"无法匹配所有作者\",\n  \"ToastMetadataFilesRemovedError\": \"删除 metadata.{0} 文件时出错\",\n  \"ToastMetadataFilesRemovedNoneFound\": \"在库中没有找到 metadata.{0} 文件\",\n  \"ToastMetadataFilesRemovedNoneRemoved\": \"没有 metadata.{0} 文件被删除\",\n  \"ToastMetadataFilesRemovedSuccess\": \"{0} 个 metadata.{1} 文件被删除\",\n  \"ToastMustHaveAtLeastOnePath\": \"必须至少有一个路径\",\n  \"ToastNameEmailRequired\": \"姓名和电子邮件为必填项\",\n  \"ToastNameRequired\": \"姓名为必填项\",\n  \"ToastNewApiKeyUserError\": \"必须选择一个用户\",\n  \"ToastNewEpisodesFound\": \"找到 {0} 个新剧集\",\n  \"ToastNewUserCreatedFailed\": \"无法创建帐户: \\\"{0}\\\"\",\n  \"ToastNewUserCreatedSuccess\": \"已创建新帐户\",\n  \"ToastNewUserLibraryError\": \"必须至少选择一个图书馆\",\n  \"ToastNewUserPasswordError\": \"必须有密码, 只有root用户可以有空密码\",\n  \"ToastNewUserTagError\": \"必须至少选择一个标签\",\n  \"ToastNewUserUsernameError\": \"输入用户名\",\n  \"ToastNoNewEpisodesFound\": \"没有找到新剧集\",\n  \"ToastNoRSSFeed\": \"播客没有 RSS 订阅\",\n  \"ToastNoUpdatesNecessary\": \"无需更新\",\n  \"ToastNotificationCreateFailed\": \"无法创建通知\",\n  \"ToastNotificationDeleteFailed\": \"删除通知失败\",\n  \"ToastNotificationFailedMaximum\": \"最大失败尝试次数必须 >= 0\",\n  \"ToastNotificationQueueMaximum\": \"最大通知队列必须 >= 0\",\n  \"ToastNotificationSettingsUpdateSuccess\": \"通知设置已更新\",\n  \"ToastNotificationTestTriggerFailed\": \"无法触发测试通知\",\n  \"ToastNotificationTestTriggerSuccess\": \"触发测试通知\",\n  \"ToastNotificationUpdateSuccess\": \"通知已更新\",\n  \"ToastPlaylistCreateFailed\": \"创建播放列表失败\",\n  \"ToastPlaylistCreateSuccess\": \"已成功创建播放列表\",\n  \"ToastPlaylistRemoveSuccess\": \"播放列表已删除\",\n  \"ToastPlaylistUpdateSuccess\": \"播放列表已更新\",\n  \"ToastPodcastCreateFailed\": \"创建播客失败\",\n  \"ToastPodcastCreateSuccess\": \"已成功创建播客\",\n  \"ToastPodcastEpisodeUpdated\": \"剧集已更新\",\n  \"ToastPodcastGetFeedFailed\": \"无法获取播客信息\",\n  \"ToastPodcastNoEpisodesInFeed\": \"RSS 订阅中未找到任何剧集\",\n  \"ToastPodcastNoRssFeed\": \"播客没有 RSS 源\",\n  \"ToastProgressIsNotBeingSynced\": \"进度未同步, 请重新开始播放\",\n  \"ToastProviderCreatedFailed\": \"无法添加提供商\",\n  \"ToastProviderCreatedSuccess\": \"已添加新提供商\",\n  \"ToastProviderNameAndUrlRequired\": \"名称和网址必需填写\",\n  \"ToastProviderRemoveSuccess\": \"提供商已移除\",\n  \"ToastRSSFeedCloseFailed\": \"关闭 RSS 源失败\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS 源已关闭\",\n  \"ToastRemoveFailed\": \"删除失败\",\n  \"ToastRemoveItemFromCollectionFailed\": \"从收藏中删除项目失败\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"项目已从收藏中删除\",\n  \"ToastRemoveItemsWithIssuesFailed\": \"无法删除有问题的库项目\",\n  \"ToastRemoveItemsWithIssuesSuccess\": \"已删除有问题的库项目\",\n  \"ToastRenameFailed\": \"重命名失败\",\n  \"ToastRescanFailed\": \"{0} 重新扫描失败\",\n  \"ToastRescanRemoved\": \"重新扫描完成项目已删除\",\n  \"ToastRescanUpToDate\": \"重新扫描完成项目已更新\",\n  \"ToastRescanUpdated\": \"重新扫描完成项目已更新\",\n  \"ToastScanFailed\": \"扫描库项目失败\",\n  \"ToastSelectAtLeastOneUser\": \"至少选择一位用户\",\n  \"ToastSendEbookToDeviceFailed\": \"发送电子书到设备失败\",\n  \"ToastSendEbookToDeviceSuccess\": \"电子书已经发送到设备 \\\"{0}\\\"\",\n  \"ToastSeriesSubmitFailedSameName\": \"无法添加两个同名系列\",\n  \"ToastSeriesUpdateFailed\": \"更新系列失败\",\n  \"ToastSeriesUpdateSuccess\": \"系列已更新\",\n  \"ToastServerSettingsUpdateSuccess\": \"服务器设置已更新\",\n  \"ToastSessionCloseFailed\": \"关闭会话失败\",\n  \"ToastSessionDeleteFailed\": \"删除会话失败\",\n  \"ToastSessionDeleteSuccess\": \"会话已删除\",\n  \"ToastSleepTimerDone\": \"睡眠定时完成... zZzzZz\",\n  \"ToastSlugMustChange\": \"Slug 包含无效字符\",\n  \"ToastSlugRequired\": \"Slug 是必填项\",\n  \"ToastSocketConnected\": \"网络已连接\",\n  \"ToastSocketDisconnected\": \"网络已断开\",\n  \"ToastSocketFailedToConnect\": \"网络连接失败\",\n  \"ToastSortingPrefixesEmptyError\": \"必须至少有 1 个排序前缀\",\n  \"ToastSortingPrefixesUpdateSuccess\": \"排序前缀已更新 ({0} 项)\",\n  \"ToastTitleRequired\": \"标题为必填项\",\n  \"ToastUnknownError\": \"未知错误\",\n  \"ToastUnlinkOpenIdFailed\": \"无法取消用户与 OpenID 的关联\",\n  \"ToastUnlinkOpenIdSuccess\": \"用户已取消与 OpenID 的关联\",\n  \"ToastUploaderFilepathExistsError\": \"文件路径 \\\"{0}\\\" 在服务器上已存在\",\n  \"ToastUploaderItemExistsInSubdirectoryError\": \"项目 \\\"{0}\\\" 正在使用上传路径的子目录。\",\n  \"ToastUserDeleteFailed\": \"删除用户失败\",\n  \"ToastUserDeleteSuccess\": \"用户已删除\",\n  \"ToastUserPasswordChangeSuccess\": \"密码修改成功\",\n  \"ToastUserPasswordMismatch\": \"密码不匹配\",\n  \"ToastUserPasswordMustChange\": \"新密码不能与旧密码相同\",\n  \"ToastUserRootRequireName\": \"必须输入 root 用户名\",\n  \"TooltipAddChapters\": \"添加章节\",\n  \"TooltipAddOneSecond\": \"加 1 秒\",\n  \"TooltipAdjustChapterStart\": \"点击调整开始时间\",\n  \"TooltipLockAllChapters\": \"锁定所有章节\",\n  \"TooltipLockChapter\": \"锁定章节 (按住 Shift再点击, 可进行范围选择)\",\n  \"TooltipSubtractOneSecond\": \"减 1 秒\",\n  \"TooltipUnlockAllChapters\": \"解锁所有章节\",\n  \"TooltipUnlockChapter\": \"解锁章节 (按住 Shift再点击, 可进行范围选择)\"\n}\n"
  },
  {
    "path": "client/strings/zh-tw.json",
    "content": "{\n  \"ButtonAdd\": \"添加\",\n  \"ButtonAddChapters\": \"新增章節\",\n  \"ButtonAddDevice\": \"新增設備\",\n  \"ButtonAddLibrary\": \"新增庫\",\n  \"ButtonAddPodcasts\": \"新增播客\",\n  \"ButtonAddUser\": \"新增使用者\",\n  \"ButtonAddYourFirstLibrary\": \"新增第一個媒體庫\",\n  \"ButtonApply\": \"應用\",\n  \"ButtonApplyChapters\": \"應用到章節\",\n  \"ButtonAuthors\": \"作者\",\n  \"ButtonBack\": \"返回\",\n  \"ButtonBrowseForFolder\": \"瀏覽資料夾\",\n  \"ButtonCancel\": \"取消\",\n  \"ButtonCancelEncode\": \"取消編碼\",\n  \"ButtonChangeRootPassword\": \"更改 Root 密碼\",\n  \"ButtonCheckAndDownloadNewEpisodes\": \"檢查並下載新劇集\",\n  \"ButtonChooseAFolder\": \"選擇資料夾\",\n  \"ButtonChooseFiles\": \"選擇檔案\",\n  \"ButtonClearFilter\": \"清楚過濾器\",\n  \"ButtonCloseFeed\": \"關閉源\",\n  \"ButtonCloseSession\": \"關閉開放會話\",\n  \"ButtonCollections\": \"收藏\",\n  \"ButtonConfigureScanner\": \"配置掃描\",\n  \"ButtonCreate\": \"創建\",\n  \"ButtonCreateBackup\": \"創建備份\",\n  \"ButtonDelete\": \"刪除\",\n  \"ButtonDownloadQueue\": \"下載佇列\",\n  \"ButtonEdit\": \"編輯\",\n  \"ButtonEditChapters\": \"編輯章節\",\n  \"ButtonEditPodcast\": \"編輯播客\",\n  \"ButtonEnable\": \"啟用\",\n  \"ButtonForceReScan\": \"強制重新掃描\",\n  \"ButtonFullPath\": \"完整路徑\",\n  \"ButtonHide\": \"隱藏\",\n  \"ButtonHome\": \"首頁\",\n  \"ButtonIssues\": \"問題\",\n  \"ButtonJumpBackward\": \"向後跳轉\",\n  \"ButtonJumpForward\": \"向前跳轉\",\n  \"ButtonLatest\": \"最新\",\n  \"ButtonLibrary\": \"媒體庫\",\n  \"ButtonLogout\": \"登出\",\n  \"ButtonLookup\": \"查找\",\n  \"ButtonManageTracks\": \"管理音軌\",\n  \"ButtonMapChapterTitles\": \"章節標題結構\",\n  \"ButtonMatchAllAuthors\": \"匹配所有作者\",\n  \"ButtonMatchBooks\": \"匹配圖書\",\n  \"ButtonNevermind\": \"沒關係\",\n  \"ButtonNext\": \"下個\",\n  \"ButtonNextChapter\": \"下個章節\",\n  \"ButtonOk\": \"確定\",\n  \"ButtonOpenFeed\": \"打開源\",\n  \"ButtonOpenManager\": \"打開管理器\",\n  \"ButtonPause\": \"暫停\",\n  \"ButtonPlay\": \"播放\",\n  \"ButtonPlaying\": \"正在播放\",\n  \"ButtonPlaylists\": \"播放列表\",\n  \"ButtonPrevious\": \"上一個\",\n  \"ButtonPreviousChapter\": \"過去的章節\",\n  \"ButtonPurgeAllCache\": \"清理所有快取\",\n  \"ButtonPurgeItemsCache\": \"清理項目快取\",\n  \"ButtonQueueAddItem\": \"新增到佇列\",\n  \"ButtonQueueRemoveItem\": \"從佇列中移除\",\n  \"ButtonQuickMatch\": \"快速匹配\",\n  \"ButtonReScan\": \"重新掃描\",\n  \"ButtonRead\": \"讀取\",\n  \"ButtonRefresh\": \"重整\",\n  \"ButtonRemove\": \"移除\",\n  \"ButtonRemoveAll\": \"移除所有\",\n  \"ButtonRemoveAllLibraryItems\": \"移除所有媒體庫項目\",\n  \"ButtonRemoveFromContinueListening\": \"從繼續收聽中刪除\",\n  \"ButtonRemoveFromContinueReading\": \"從繼續閱讀中刪除\",\n  \"ButtonRemoveSeriesFromContinueSeries\": \"從繼續收聽系列中刪除\",\n  \"ButtonReset\": \"重置\",\n  \"ButtonResetToDefault\": \"重置為預設\",\n  \"ButtonRestore\": \"恢復\",\n  \"ButtonSave\": \"保存\",\n  \"ButtonSaveAndClose\": \"保存並關閉\",\n  \"ButtonSaveTracklist\": \"保存音軌列表\",\n  \"ButtonScan\": \"掃描\",\n  \"ButtonScanLibrary\": \"掃描庫\",\n  \"ButtonSearch\": \"搜索\",\n  \"ButtonSelectFolderPath\": \"選擇資料夾路徑\",\n  \"ButtonSeries\": \"系列\",\n  \"ButtonSetChaptersFromTracks\": \"將音軌設定為章節\",\n  \"ButtonShiftTimes\": \"快速調整時間\",\n  \"ButtonShow\": \"顯示\",\n  \"ButtonStartM4BEncode\": \"開始 M4B 編碼\",\n  \"ButtonStartMetadataEmbed\": \"開始嵌入元數據\",\n  \"ButtonSubmit\": \"提交\",\n  \"ButtonTest\": \"測試\",\n  \"ButtonUpload\": \"上傳\",\n  \"ButtonUploadBackup\": \"上傳備份\",\n  \"ButtonUploadCover\": \"上傳封面\",\n  \"ButtonUploadOPMLFile\": \"上傳 OPML 檔\",\n  \"ButtonUserDelete\": \"刪除使用者 {0}\",\n  \"ButtonUserEdit\": \"編輯使用者 {0}\",\n  \"ButtonViewAll\": \"查看全部\",\n  \"ButtonYes\": \"確定\",\n  \"ErrorUploadFetchMetadataAPI\": \"獲取元數據時出錯\",\n  \"ErrorUploadFetchMetadataNoResults\": \"無法獲取元數據 - 嘗試更新標題和/或作者\",\n  \"ErrorUploadLacksTitle\": \"必須有標題\",\n  \"HeaderAccount\": \"賬號\",\n  \"HeaderAdvanced\": \"高級\",\n  \"HeaderAppriseNotificationSettings\": \"測試通知設定\",\n  \"HeaderAudioTracks\": \"音軌\",\n  \"HeaderAudiobookTools\": \"有聲書檔案管理工具\",\n  \"HeaderAuthentication\": \"身份驗證\",\n  \"HeaderBackups\": \"備份\",\n  \"HeaderChangePassword\": \"更改密碼\",\n  \"HeaderChapters\": \"章節\",\n  \"HeaderChooseAFolder\": \"選擇資料夾\",\n  \"HeaderCollection\": \"收藏\",\n  \"HeaderCollectionItems\": \"收藏項目\",\n  \"HeaderCover\": \"封面\",\n  \"HeaderCurrentDownloads\": \"當前下載\",\n  \"HeaderCustomMessageOnLogin\": \"登錄時的自定義信息\",\n  \"HeaderCustomMetadataProviders\": \"自訂 Metadata 提供者\",\n  \"HeaderDetails\": \"詳情\",\n  \"HeaderDownloadQueue\": \"下載佇列\",\n  \"HeaderEbookFiles\": \"電子書檔\",\n  \"HeaderEmail\": \"郵箱\",\n  \"HeaderEmailSettings\": \"郵箱設定\",\n  \"HeaderEpisodes\": \"劇集\",\n  \"HeaderEreaderDevices\": \"Ereader 設備\",\n  \"HeaderEreaderSettings\": \"Ereader 設定\",\n  \"HeaderFiles\": \"檔案\",\n  \"HeaderFindChapters\": \"查找章節\",\n  \"HeaderIgnoredFiles\": \"忽略的檔案\",\n  \"HeaderItemFiles\": \"項目檔案\",\n  \"HeaderItemMetadataUtils\": \"項目元數據管理\",\n  \"HeaderLastListeningSession\": \"最後一次收聽會話\",\n  \"HeaderLatestEpisodes\": \"最新劇集\",\n  \"HeaderLibraries\": \"媒體庫\",\n  \"HeaderLibraryFiles\": \"媒體庫檔案\",\n  \"HeaderLibraryStats\": \"媒體庫統計數據\",\n  \"HeaderListeningSessions\": \"收聽會話\",\n  \"HeaderListeningStats\": \"收聽統計數據\",\n  \"HeaderLogin\": \"登入\",\n  \"HeaderLogs\": \"日誌\",\n  \"HeaderManageGenres\": \"管理流派\",\n  \"HeaderManageTags\": \"管理標籤\",\n  \"HeaderMapDetails\": \"編輯詳情\",\n  \"HeaderMatch\": \"匹配\",\n  \"HeaderMetadataOrderOfPrecedence\": \"元數據優先級\",\n  \"HeaderMetadataToEmbed\": \"嵌入元數據\",\n  \"HeaderNewAccount\": \"新建帳號\",\n  \"HeaderNewLibrary\": \"新建媒體庫\",\n  \"HeaderNotifications\": \"通知\",\n  \"HeaderOpenIDConnectAuthentication\": \"OpenID 連接身份驗證\",\n  \"HeaderOpenRSSFeed\": \"打開 Rss 源\",\n  \"HeaderOtherFiles\": \"其他檔案\",\n  \"HeaderPasswordAuthentication\": \"密碼認證\",\n  \"HeaderPermissions\": \"權限\",\n  \"HeaderPlayerQueue\": \"播放佇列\",\n  \"HeaderPlaylist\": \"播放列表\",\n  \"HeaderPlaylistItems\": \"播放列表項目\",\n  \"HeaderPodcastsToAdd\": \"要新增的播客\",\n  \"HeaderPreviewCover\": \"預覽封面\",\n  \"HeaderRSSFeedGeneral\": \"RSS 詳細信息\",\n  \"HeaderRSSFeedIsOpen\": \"RSS 源已打開\",\n  \"HeaderRSSFeeds\": \"RSS 訂閱\",\n  \"HeaderRemoveEpisode\": \"移除劇集\",\n  \"HeaderRemoveEpisodes\": \"移除 {0} 劇集\",\n  \"HeaderSavedMediaProgress\": \"保存媒體進度\",\n  \"HeaderSchedule\": \"計劃任務\",\n  \"HeaderScheduleLibraryScans\": \"自動掃描媒體庫\",\n  \"HeaderSession\": \"會話\",\n  \"HeaderSetBackupSchedule\": \"設定備份計劃任務\",\n  \"HeaderSettings\": \"設定\",\n  \"HeaderSettingsDisplay\": \"顯示\",\n  \"HeaderSettingsExperimental\": \"實驗功能\",\n  \"HeaderSettingsGeneral\": \"通用\",\n  \"HeaderSettingsScanner\": \"掃描\",\n  \"HeaderSleepTimer\": \"睡眠定時\",\n  \"HeaderStatsLargestItems\": \"最大的項目\",\n  \"HeaderStatsLongestItems\": \"項目時長(小時)\",\n  \"HeaderStatsMinutesListeningChart\": \"收聽分鐘數(最近7天)\",\n  \"HeaderStatsRecentSessions\": \"歷史會話\",\n  \"HeaderStatsTop10Authors\": \"前 10 位作者\",\n  \"HeaderStatsTop5Genres\": \"前 5 種流派\",\n  \"HeaderTableOfContents\": \"目錄\",\n  \"HeaderTools\": \"工具\",\n  \"HeaderUpdateAccount\": \"更新帳號\",\n  \"HeaderUpdateAuthor\": \"更新作者\",\n  \"HeaderUpdateDetails\": \"更新詳情\",\n  \"HeaderUpdateLibrary\": \"更新媒體庫\",\n  \"HeaderUsers\": \"使用者\",\n  \"HeaderYearReview\": \"{0} 年回顧\",\n  \"HeaderYourStats\": \"你的統計數據\",\n  \"LabelAbridged\": \"概要\",\n  \"LabelAbridgedChecked\": \"刪節版（已勾選）\",\n  \"LabelAbridgedUnchecked\": \"未刪節版（未勾選）\",\n  \"LabelAccessibleBy\": \"可訪問\",\n  \"LabelAccountType\": \"帳號類型\",\n  \"LabelAccountTypeAdmin\": \"管理員\",\n  \"LabelAccountTypeGuest\": \"來賓\",\n  \"LabelAccountTypeUser\": \"使用者\",\n  \"LabelActivity\": \"活動\",\n  \"LabelAddToCollection\": \"新增到收藏\",\n  \"LabelAddToCollectionBatch\": \"批量新增 {0} 個媒體到收藏\",\n  \"LabelAddToPlaylist\": \"新增到播放列表\",\n  \"LabelAddToPlaylistBatch\": \"新增 {0} 個項目到播放列表\",\n  \"LabelAddedAt\": \"新增於\",\n  \"LabelAdminUsersOnly\": \"僅限管理員使用者\",\n  \"LabelAll\": \"全部\",\n  \"LabelAllUsers\": \"所有使用者\",\n  \"LabelAllUsersExcludingGuests\": \"除訪客外的所有使用者\",\n  \"LabelAllUsersIncludingGuests\": \"包括訪客的所有使用者\",\n  \"LabelAlreadyInYourLibrary\": \"已存在你的庫中\",\n  \"LabelAppend\": \"附加\",\n  \"LabelAuthor\": \"作者\",\n  \"LabelAuthorFirstLast\": \"作者 (姓 名)\",\n  \"LabelAuthorLastFirst\": \"作者 (名, 姓)\",\n  \"LabelAuthors\": \"作者\",\n  \"LabelAutoDownloadEpisodes\": \"自動下載劇集\",\n  \"LabelAutoFetchMetadata\": \"自動獲取元數據\",\n  \"LabelAutoFetchMetadataHelp\": \"獲取標題, 作者和系列的元數據以簡化上傳. 上傳後可能需要匹配其他元數據.\",\n  \"LabelAutoLaunch\": \"自動啟動\",\n  \"LabelAutoLaunchDescription\": \"導航到登入頁面時自動重定向到身份驗證提供程序 (手動覆蓋路徑 <code>/login?autoLaunch=0</code>)\",\n  \"LabelAutoRegister\": \"自動註冊\",\n  \"LabelAutoRegisterDescription\": \"登入後自動創建新使用者\",\n  \"LabelBackToUser\": \"返回到使用者\",\n  \"LabelBackupLocation\": \"備份位置\",\n  \"LabelBackupsEnableAutomaticBackups\": \"啟用自動備份\",\n  \"LabelBackupsEnableAutomaticBackupsHelp\": \"備份保存到 /metadata/backups\",\n  \"LabelBackupsMaxBackupSize\": \"最大備份大小 (GB)\",\n  \"LabelBackupsMaxBackupSizeHelp\": \"為了防止錯誤配置, 如果備份超過配置的大小, 備份將失敗.\",\n  \"LabelBackupsNumberToKeep\": \"要保留的備份個數\",\n  \"LabelBackupsNumberToKeepHelp\": \"一次只能刪除一個備份, 因此如果你已經有超過此數量的備份, 則應手動刪除它們.\",\n  \"LabelBitrate\": \"位元率\",\n  \"LabelBooks\": \"圖書\",\n  \"LabelButtonText\": \"按鈕文本\",\n  \"LabelChangePassword\": \"修改密碼\",\n  \"LabelChannels\": \"聲道\",\n  \"LabelChapterTitle\": \"章節標題\",\n  \"LabelChapters\": \"章節\",\n  \"LabelChaptersFound\": \"找到的章節\",\n  \"LabelClickForMoreInfo\": \"點擊了解更多資訊\",\n  \"LabelClosePlayer\": \"關閉播放器\",\n  \"LabelCodec\": \"編解碼\",\n  \"LabelCollapseSeries\": \"折疊系列\",\n  \"LabelCollection\": \"收藏\",\n  \"LabelCollections\": \"收藏\",\n  \"LabelComplete\": \"已完成\",\n  \"LabelConfirmPassword\": \"確認密碼\",\n  \"LabelContinueListening\": \"繼續收聽\",\n  \"LabelContinueReading\": \"繼續閱讀\",\n  \"LabelContinueSeries\": \"繼續收聽系列\",\n  \"LabelCover\": \"封面\",\n  \"LabelCoverImageURL\": \"封面圖像 URL\",\n  \"LabelCreatedAt\": \"創建時間\",\n  \"LabelCronExpression\": \"計劃任務表達式\",\n  \"LabelCurrent\": \"當前\",\n  \"LabelCurrently\": \"當前:\",\n  \"LabelCustomCronExpression\": \"自定義計劃任務表達式:\",\n  \"LabelDatetime\": \"日期時間\",\n  \"LabelDeleteFromFileSystemCheckbox\": \"從檔案系統刪除 (取消選中僅從資料庫中刪除)\",\n  \"LabelDescription\": \"描述\",\n  \"LabelDeselectAll\": \"全部取消選擇\",\n  \"LabelDevice\": \"設備\",\n  \"LabelDeviceInfo\": \"設備資訊\",\n  \"LabelDeviceIsAvailableTo\": \"設備可用於...\",\n  \"LabelDirectory\": \"目錄\",\n  \"LabelDiscFromFilename\": \"從檔名獲取光碟\",\n  \"LabelDiscFromMetadata\": \"從元數據獲取光碟\",\n  \"LabelDiscover\": \"發現\",\n  \"LabelDownload\": \"下載\",\n  \"LabelDownloadNEpisodes\": \"下載 {0} 集\",\n  \"LabelDuration\": \"持續時間\",\n  \"LabelDurationComparisonExactMatch\": \"（完全匹配）\",\n  \"LabelDurationComparisonLonger\": \"（{0} 更長）\",\n  \"LabelDurationComparisonShorter\": \"（{0} 更短）\",\n  \"LabelDurationFound\": \"找到持續時間:\",\n  \"LabelEbook\": \"電子書\",\n  \"LabelEbooks\": \"電子書\",\n  \"LabelEdit\": \"編輯\",\n  \"LabelEmail\": \"郵箱\",\n  \"LabelEmailSettingsFromAddress\": \"發件人位址\",\n  \"LabelEmailSettingsRejectUnauthorized\": \"拒絕未經授權的證書\",\n  \"LabelEmailSettingsRejectUnauthorizedHelp\": \"停用 SSL 證書驗證可能會使您的連接暴露於安全風險中，例如中間人攻擊。僅在您了解其含義並信任您所連接的郵件伺服器的情況下才停用此選項。\",\n  \"LabelEmailSettingsSecure\": \"安全\",\n  \"LabelEmailSettingsSecureHelp\": \"如果選是, 則連接將在連接到伺服器時使用TLS. 如果選否, 則若伺服器支援STARTTLS擴展, 則使用TLS. 在大多數情況下, 如果連接到465埠, 請將該值設定為是. 對於587或25埠, 請保持為否. (來自nodemailer.com/smtp/#authentication)\",\n  \"LabelEmailSettingsTestAddress\": \"測試位址\",\n  \"LabelEmbeddedCover\": \"嵌入封面\",\n  \"LabelEnable\": \"啟用\",\n  \"LabelEnd\": \"結束\",\n  \"LabelEndOfChapter\": \"章節結束\",\n  \"LabelEpisode\": \"劇集\",\n  \"LabelEpisodeTitle\": \"劇集標題\",\n  \"LabelEpisodeType\": \"劇集類型\",\n  \"LabelExample\": \"示例\",\n  \"LabelExplicit\": \"信息準確\",\n  \"LabelFeedURL\": \"源鏈接\",\n  \"LabelFetchingMetadata\": \"正在獲取元數據\",\n  \"LabelFile\": \"文件\",\n  \"LabelFileBirthtime\": \"檔案創建時間\",\n  \"LabelFileModified\": \"檔案修改時間\",\n  \"LabelFilename\": \"檔名\",\n  \"LabelFilterByUser\": \"按使用者篩選\",\n  \"LabelFindEpisodes\": \"查找劇集\",\n  \"LabelFinished\": \"已聽完\",\n  \"LabelFolder\": \"資料夾\",\n  \"LabelFolders\": \"資料夾\",\n  \"LabelFontBoldness\": \"字體粗細\",\n  \"LabelFontFamily\": \"字體系列\",\n  \"LabelFontItalic\": \"斜體\",\n  \"LabelFontScale\": \"字體比例\",\n  \"LabelFontStrikethrough\": \"刪除線\",\n  \"LabelFormat\": \"編碼格式\",\n  \"LabelGenre\": \"流派\",\n  \"LabelGenres\": \"流派\",\n  \"LabelHardDeleteFile\": \"完全刪除檔案\",\n  \"LabelHasEbook\": \"有電子書\",\n  \"LabelHasSupplementaryEbook\": \"有補充電子書\",\n  \"LabelHighestPriority\": \"最高優先級\",\n  \"LabelHost\": \"主機\",\n  \"LabelHour\": \"小時\",\n  \"LabelIcon\": \"圖標\",\n  \"LabelImageURLFromTheWeb\": \"來自 Web 圖像的 URL\",\n  \"LabelInProgress\": \"正在聽\",\n  \"LabelIncludeInTracklist\": \"包含在音軌列表中\",\n  \"LabelIncomplete\": \"未聽完\",\n  \"LabelInterval\": \"間隔\",\n  \"LabelIntervalCustomDailyWeekly\": \"自定義 每天 / 每周\",\n  \"LabelIntervalEvery12Hours\": \"每 12 小時\",\n  \"LabelIntervalEvery15Minutes\": \"每 15 分鐘\",\n  \"LabelIntervalEvery2Hours\": \"每 2 小時\",\n  \"LabelIntervalEvery30Minutes\": \"每 30 分鐘\",\n  \"LabelIntervalEvery6Hours\": \"每 6 小時\",\n  \"LabelIntervalEveryDay\": \"每天\",\n  \"LabelIntervalEveryHour\": \"每小時\",\n  \"LabelInvert\": \"倒轉\",\n  \"LabelItem\": \"項目\",\n  \"LabelLanguage\": \"語言\",\n  \"LabelLanguageDefaultServer\": \"預設伺服器語言\",\n  \"LabelLastBookAdded\": \"最後新增的書\",\n  \"LabelLastBookUpdated\": \"最後更新的書\",\n  \"LabelLastSeen\": \"上次查看時間\",\n  \"LabelLastTime\": \"最近一次\",\n  \"LabelLastUpdate\": \"最近更新\",\n  \"LabelLayout\": \"布局\",\n  \"LabelLayoutSinglePage\": \"單頁\",\n  \"LabelLayoutSplitPage\": \"分頁\",\n  \"LabelLess\": \"較少\",\n  \"LabelLibrariesAccessibleToUser\": \"使用者可存取的媒體庫\",\n  \"LabelLibrary\": \"媒體庫\",\n  \"LabelLibraryItem\": \"媒體庫項目\",\n  \"LabelLibraryName\": \"媒體庫名稱\",\n  \"LabelLimit\": \"限制\",\n  \"LabelLineSpacing\": \"行間距\",\n  \"LabelListenAgain\": \"再次收聽\",\n  \"LabelLogLevelDebug\": \"調試\",\n  \"LabelLogLevelInfo\": \"信息\",\n  \"LabelLogLevelWarn\": \"警告\",\n  \"LabelLookForNewEpisodesAfterDate\": \"在此日期後查找新劇集\",\n  \"LabelLowestPriority\": \"最低優先級\",\n  \"LabelMatchExistingUsersBy\": \"匹配現有使用者\",\n  \"LabelMatchExistingUsersByDescription\": \"用於連接現有使用者. 連接後, 使用者將通過SSO提供商提供的唯一 id 進行匹配\",\n  \"LabelMediaPlayer\": \"媒體播放器\",\n  \"LabelMediaType\": \"媒體類型\",\n  \"LabelMetaTag\": \"元數據標籤\",\n  \"LabelMetaTags\": \"元標籤\",\n  \"LabelMetadataOrderOfPrecedenceDescription\": \"較高優先級的元數據源將覆蓋較低優先級的元數據源\",\n  \"LabelMetadataProvider\": \"元數據提供者\",\n  \"LabelMinute\": \"分鐘\",\n  \"LabelMissing\": \"丟失\",\n  \"LabelMobileRedirectURIs\": \"允許移動應用重定向 URI\",\n  \"LabelMobileRedirectURIsDescription\": \"這是移動應用程序的有效重定向 URI 白名單. 預設值為 <code>audiobookshelf://oauth</code>，您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (<code>*</code>) 作為唯一條目允許任何 URI.\",\n  \"LabelMore\": \"更多\",\n  \"LabelMoreInfo\": \"更多信息\",\n  \"LabelName\": \"名稱\",\n  \"LabelNarrator\": \"講述者\",\n  \"LabelNarrators\": \"講述者\",\n  \"LabelNew\": \"新建\",\n  \"LabelNewPassword\": \"新密碼\",\n  \"LabelNewestAuthors\": \"最新作者\",\n  \"LabelNewestEpisodes\": \"最新劇集\",\n  \"LabelNextBackupDate\": \"下次備份日期\",\n  \"LabelNextScheduledRun\": \"下次任務運行\",\n  \"LabelNoEpisodesSelected\": \"未選擇任何劇集\",\n  \"LabelNotFinished\": \"未聽完\",\n  \"LabelNotStarted\": \"未開始\",\n  \"LabelNotes\": \"注釋\",\n  \"LabelNotificationAppriseURL\": \"通知 URL(s)\",\n  \"LabelNotificationAvailableVariables\": \"可用變量\",\n  \"LabelNotificationBodyTemplate\": \"正文模板\",\n  \"LabelNotificationEvent\": \"通知事件\",\n  \"LabelNotificationTitleTemplate\": \"標題模板\",\n  \"LabelNotificationsMaxFailedAttempts\": \"最大失敗嘗試次數\",\n  \"LabelNotificationsMaxFailedAttemptsHelp\": \"如果多次發送失敗，通知將被禁用\",\n  \"LabelNotificationsMaxQueueSize\": \"通知事件的最大佇列大小\",\n  \"LabelNotificationsMaxQueueSizeHelp\": \"通知事件被限制為每秒觸發 1 個. 如果佇列處於最大大小, 則將忽略事件. 這可以防止通知垃圾郵件.\",\n  \"LabelNumberOfBooks\": \"圖書數量\",\n  \"LabelNumberOfEpisodes\": \"# 集\",\n  \"LabelOpenRSSFeed\": \"打開 RSS 源\",\n  \"LabelOverwrite\": \"覆蓋\",\n  \"LabelPassword\": \"密碼\",\n  \"LabelPath\": \"路徑\",\n  \"LabelPermissionsAccessAllLibraries\": \"可以存取所有媒體庫\",\n  \"LabelPermissionsAccessAllTags\": \"可以存取所有標籤\",\n  \"LabelPermissionsAccessExplicitContent\": \"可以存取顯式內容\",\n  \"LabelPermissionsDelete\": \"可以刪除\",\n  \"LabelPermissionsDownload\": \"可以下載\",\n  \"LabelPermissionsUpdate\": \"可以更新\",\n  \"LabelPermissionsUpload\": \"可以上傳\",\n  \"LabelPersonalYearReview\": \"你的年度回顧 ({0})\",\n  \"LabelPhotoPathURL\": \"圖片路徑或 URL\",\n  \"LabelPlayMethod\": \"播放方法\",\n  \"LabelPlaylists\": \"播放列表\",\n  \"LabelPodcast\": \"播客\",\n  \"LabelPodcastSearchRegion\": \"播客搜尋地區\",\n  \"LabelPodcastType\": \"播客類型\",\n  \"LabelPodcasts\": \"播客\",\n  \"LabelPort\": \"埠\",\n  \"LabelPrefixesToIgnore\": \"忽略的前綴 (不區分大小寫)\",\n  \"LabelPreventIndexing\": \"防止您的訂閱源被 iTunes 和 Google 播客目錄索引\",\n  \"LabelPrimaryEbook\": \"主電子書\",\n  \"LabelProgress\": \"進度\",\n  \"LabelProvider\": \"供應商\",\n  \"LabelPubDate\": \"出版日期\",\n  \"LabelPublishYear\": \"發布年份\",\n  \"LabelPublisher\": \"出版商\",\n  \"LabelRSSFeedCustomOwnerEmail\": \"自定義所有者電子郵件\",\n  \"LabelRSSFeedCustomOwnerName\": \"自定義所有者名稱\",\n  \"LabelRSSFeedOpen\": \"打開 RSS 源\",\n  \"LabelRSSFeedPreventIndexing\": \"防止索引\",\n  \"LabelRSSFeedSlug\": \"RSS 源段\",\n  \"LabelRSSFeedURL\": \"RSS 源 URL\",\n  \"LabelRandomly\": \"隨機\",\n  \"LabelRead\": \"閱讀\",\n  \"LabelReadAgain\": \"再次閱讀\",\n  \"LabelReadEbookWithoutProgress\": \"閱讀電子書而不保存進度\",\n  \"LabelRecentSeries\": \"最近新增系列\",\n  \"LabelRecentlyAdded\": \"最近新增\",\n  \"LabelRecommended\": \"推薦內容\",\n  \"LabelRedo\": \"重做\",\n  \"LabelRegion\": \"區域\",\n  \"LabelReleaseDate\": \"發布日期\",\n  \"LabelRemoveCover\": \"移除封面\",\n  \"LabelRowsPerPage\": \"每頁行數\",\n  \"LabelSearchTerm\": \"搜尋項\",\n  \"LabelSearchTitle\": \"搜尋標題\",\n  \"LabelSearchTitleOrASIN\": \"搜尋標題或 ASIN\",\n  \"LabelSeason\": \"季\",\n  \"LabelSelectAllEpisodes\": \"選擇所有劇集\",\n  \"LabelSelectEpisodesShowing\": \"選擇正在播放的 {0} 劇集\",\n  \"LabelSendEbookToDevice\": \"發送電子書到...\",\n  \"LabelSequence\": \"序列\",\n  \"LabelSeries\": \"系列\",\n  \"LabelSeriesName\": \"系列名稱\",\n  \"LabelSeriesProgress\": \"系列進度\",\n  \"LabelServerYearReview\": \"伺服器年度回顧 ({0})\",\n  \"LabelSetEbookAsPrimary\": \"設定為主\",\n  \"LabelSetEbookAsSupplementary\": \"設定為補充\",\n  \"LabelSettingsAudiobooksOnly\": \"僅有聲書\",\n  \"LabelSettingsAudiobooksOnlyHelp\": \"啟用此設定將忽略電子書檔, 除非它們位於有聲書資料夾中, 在這種情況下, 它們將被設定為補充電子書\",\n  \"LabelSettingsBookshelfViewHelp\": \"帶有木架子的擬物化設計\",\n  \"LabelSettingsChromecastSupport\": \"Chromecast 支援\",\n  \"LabelSettingsDateFormat\": \"日期格式\",\n  \"LabelSettingsEnableWatcherHelp\": \"當檢測到檔案更改時, 啟用項目的自動新增/更新. *需要重新啟動伺服器\",\n  \"LabelSettingsExperimentalFeatures\": \"實驗功能\",\n  \"LabelSettingsExperimentalFeaturesHelp\": \"開發中的功能需要你的反饋並幫助測試. 點擊打開 github 討論.\",\n  \"LabelSettingsFindCovers\": \"查找封面\",\n  \"LabelSettingsFindCoversHelp\": \"如果你的有聲書在資料夾中沒有嵌入封面或封面圖像, 掃描將嘗試查找封面.<br>注意: 這將延長掃描時間\",\n  \"LabelSettingsHideSingleBookSeries\": \"隱藏單書系列\",\n  \"LabelSettingsHideSingleBookSeriesHelp\": \"只有一本書的系列將從系列頁面和主頁書架中隱藏.\",\n  \"LabelSettingsHomePageBookshelfView\": \"首頁使用書架視圖\",\n  \"LabelSettingsLibraryBookshelfView\": \"媒體庫使用書架視圖\",\n  \"LabelSettingsParseSubtitles\": \"解析副標題\",\n  \"LabelSettingsParseSubtitlesHelp\": \"從有聲書資料夾中提取副標題.<br>副標題必須用 \\\" - \\\" 分隔.<br>例: \\\"書名 - 這裡是副標題\\\" 則顯示副標題 \\\"這裡是副標題\\\"\",\n  \"LabelSettingsPreferMatchedMetadata\": \"首選匹配的元數據\",\n  \"LabelSettingsPreferMatchedMetadataHelp\": \"使用快速匹配時, 匹配的數據將覆蓋項目詳細信息. 預設情況下, 快速匹配將只填充缺少的詳細信息.\",\n  \"LabelSettingsSkipMatchingBooksWithASIN\": \"跳過匹配已有 ASIN 的圖書\",\n  \"LabelSettingsSkipMatchingBooksWithISBN\": \"跳過匹配已有 ISBN 的圖書\",\n  \"LabelSettingsSortingIgnorePrefixes\": \"排序時忽略前綴\",\n  \"LabelSettingsSortingIgnorePrefixesHelp\": \"例如: 前綴為 \\\"The\\\" 的圖書標題 \\\"The Book Title\\\" 將按 \\\"Book Title, The\\\" 進行排序\",\n  \"LabelSettingsSquareBookCovers\": \"使用者方形圖書封面\",\n  \"LabelSettingsSquareBookCoversHelp\": \"比起標準的 1.6:1 圖書封面，更喜歡使用方形封面\",\n  \"LabelSettingsStoreCoversWithItem\": \"存儲項目封面\",\n  \"LabelSettingsStoreCoversWithItemHelp\": \"預設情況下封面存儲在/metadata/items資料夾中, 啟用此設定將存儲封面在你媒體項目資料夾中. 只保留一個名為 \\\"cover\\\" 的檔案\",\n  \"LabelSettingsStoreMetadataWithItem\": \"存儲項目元數據\",\n  \"LabelSettingsStoreMetadataWithItemHelp\": \"預設情況下元數據檔案存儲在/metadata/items資料夾中, 啟用此設定將存儲元數據在你媒體項目資料夾中\",\n  \"LabelSettingsTimeFormat\": \"時間格式\",\n  \"LabelShowAll\": \"全部顯示\",\n  \"LabelSize\": \"檔案大小\",\n  \"LabelSleepTimer\": \"睡眠定時\",\n  \"LabelStart\": \"開始\",\n  \"LabelStartTime\": \"開始時間\",\n  \"LabelStarted\": \"開始於\",\n  \"LabelStartedAt\": \"從這開始\",\n  \"LabelStatsAudioTracks\": \"音軌\",\n  \"LabelStatsAuthors\": \"作者\",\n  \"LabelStatsBestDay\": \"最好的一天\",\n  \"LabelStatsDailyAverage\": \"每日平均值\",\n  \"LabelStatsDays\": \"天\",\n  \"LabelStatsDaysListened\": \"收聽天數\",\n  \"LabelStatsHours\": \"小時\",\n  \"LabelStatsInARow\": \"在一行\",\n  \"LabelStatsItemsFinished\": \"已完成的項目\",\n  \"LabelStatsItemsInLibrary\": \"媒體庫中的項目\",\n  \"LabelStatsMinutes\": \"分鐘\",\n  \"LabelStatsMinutesListening\": \"收聽分鐘數\",\n  \"LabelStatsOverallDays\": \"總計天數\",\n  \"LabelStatsOverallHours\": \"總計小時\",\n  \"LabelStatsWeekListening\": \"每周收聽\",\n  \"LabelSubtitle\": \"副標題\",\n  \"LabelSupportedFileTypes\": \"支援的檔案類型\",\n  \"LabelTag\": \"標籤\",\n  \"LabelTags\": \"標籤\",\n  \"LabelTagsAccessibleToUser\": \"使用者可存取的標籤\",\n  \"LabelTagsNotAccessibleToUser\": \"使用者無法存取標籤\",\n  \"LabelTasks\": \"正在運行的任務\",\n  \"LabelTextEditorBulletedList\": \"項目符號列表\",\n  \"LabelTextEditorNumberedList\": \"編號列表\",\n  \"LabelTextEditorUnlink\": \"取消連結\",\n  \"LabelTheme\": \"主題\",\n  \"LabelThemeDark\": \"黑暗\",\n  \"LabelThemeLight\": \"明亮\",\n  \"LabelTimeBase\": \"時間基準\",\n  \"LabelTimeListened\": \"收聽時間\",\n  \"LabelTimeListenedToday\": \"今日收聽的時間\",\n  \"LabelTimeRemaining\": \"剩餘 {0}\",\n  \"LabelTimeToShift\": \"快速調整時間以秒為單位\",\n  \"LabelTitle\": \"標題\",\n  \"LabelToolsEmbedMetadata\": \"嵌入元數據\",\n  \"LabelToolsEmbedMetadataDescription\": \"將元數據嵌入音頻檔案, 包括封面圖像和章節.\",\n  \"LabelToolsMakeM4b\": \"制作 M4B 有聲書檔案\",\n  \"LabelToolsMakeM4bDescription\": \"生成帶有嵌入元數據, 封面圖像和章節的 .M4B 有聲書檔.\",\n  \"LabelToolsSplitM4b\": \"將 M4B 檔拆分為 MP3 檔\",\n  \"LabelToolsSplitM4bDescription\": \"從 M4B 檔創建 MP3 檔, 按章節分割, 並嵌入元數據, 封面圖像和章節.\",\n  \"LabelTotalDuration\": \"總持續時間\",\n  \"LabelTotalTimeListened\": \"總收聽時間\",\n  \"LabelTrackFromFilename\": \"從檔案名獲取音軌\",\n  \"LabelTrackFromMetadata\": \"從源數據獲取音軌\",\n  \"LabelTracks\": \"音軌\",\n  \"LabelTracksMultiTrack\": \"多軌\",\n  \"LabelTracksNone\": \"沒有音軌\",\n  \"LabelTracksSingleTrack\": \"單軌\",\n  \"LabelType\": \"類型\",\n  \"LabelUnabridged\": \"未刪節\",\n  \"LabelUnknown\": \"未知\",\n  \"LabelUpdateCover\": \"更新封面\",\n  \"LabelUpdateCoverHelp\": \"找到匹配項時允許覆蓋所選書籍存在的封面\",\n  \"LabelUpdateDetails\": \"更新詳細信息\",\n  \"LabelUpdateDetailsHelp\": \"找到匹配項時允許覆蓋所選書籍存在的詳細信息\",\n  \"LabelUpdatedAt\": \"更新時間\",\n  \"LabelUploaderDragAndDrop\": \"拖放檔案或資料夾\",\n  \"LabelUploaderDropFiles\": \"刪除檔案\",\n  \"LabelUploaderItemFetchMetadataHelp\": \"自動獲取標題, 作者和系列\",\n  \"LabelUseChapterTrack\": \"使用章節音軌\",\n  \"LabelUseFullTrack\": \"使用完整音軌\",\n  \"LabelUser\": \"使用者\",\n  \"LabelUsername\": \"使用者名\",\n  \"LabelValue\": \"值\",\n  \"LabelVersion\": \"版本\",\n  \"LabelViewBookmarks\": \"查看書籤\",\n  \"LabelViewChapters\": \"查看章節\",\n  \"LabelViewQueue\": \"查看播放列表\",\n  \"LabelVolume\": \"音量\",\n  \"LabelWeekdaysToRun\": \"工作日運行\",\n  \"LabelYearReviewHide\": \"隱藏年度回顧\",\n  \"LabelYearReviewShow\": \"顯示年度回顧\",\n  \"LabelYourAudiobookDuration\": \"你的有聲書持續時間\",\n  \"LabelYourBookmarks\": \"你的書籤\",\n  \"LabelYourPlaylists\": \"你的播放列表\",\n  \"LabelYourProgress\": \"你的進度\",\n  \"MessageAddToPlayerQueue\": \"新增到播放佇列\",\n  \"MessageAppriseDescription\": \"要使用此功能，您需要運行一個 <a href=\\\"https://github.com/caronc/apprise-api\\\" target=\\\"_blank\\\">Apprise API</a> 實例或一個可以處理這些相同請求的 API. <br />Apprise API Url 應該是發送通知的完整 URL 路徑, 例如: 如果你的 API 實例運行在 <code>http://192.168.1.1:8337</code>, 那么你可以輸入 <code>http://192.168.1.1:8337/notify</code>.\",\n  \"MessageBackupsDescription\": \"備份包括使用者, 使用者進度, 媒體庫項目詳細信息, 伺服器設定和圖像, 存儲在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 備份不包括存儲在您的媒體庫資料夾中的任何檔案.\",\n  \"MessageBatchQuickMatchDescription\": \"快速匹配將嘗試為所選項目新增缺少的封面和元數據. 啟用以下選項以允許快速匹配覆蓋現有封面和或元數據.\",\n  \"MessageBookshelfNoCollections\": \"你尚未進行任何收藏\",\n  \"MessageBookshelfNoRSSFeeds\": \"沒有打開的 RSS 源\",\n  \"MessageBookshelfNoResultsForFilter\": \"過濾器無結果 \\\"{0}: {1}\\\"\",\n  \"MessageBookshelfNoSeries\": \"你沒有系列\",\n  \"MessageChapterEndIsAfter\": \"章節結束是在有聲書結束之後\",\n  \"MessageChapterErrorFirstNotZero\": \"第一章節必須從 0 開始\",\n  \"MessageChapterErrorStartGteDuration\": \"無效的開始時間, 必須小於有聲書持續時間\",\n  \"MessageChapterErrorStartLtPrev\": \"無效的開始時間, 必須大於或等於上一章節的開始時間\",\n  \"MessageChapterStartIsAfter\": \"章節開始是在有聲書結束之後\",\n  \"MessageCheckingCron\": \"檢查計劃任務...\",\n  \"MessageConfirmCloseFeed\": \"你確定要關閉此訂閱源嗎?\",\n  \"MessageConfirmDeleteBackup\": \"你確定要刪除備份 {0}?\",\n  \"MessageConfirmDeleteFile\": \"這將從檔案系統中刪除該檔案. 你確定嗎?\",\n  \"MessageConfirmDeleteLibrary\": \"你確定要永久刪除媒體庫 \\\"{0}\\\"?\",\n  \"MessageConfirmDeleteLibraryItem\": \"這將從資料庫和檔案系統中刪除庫項目. 你確定嗎?\",\n  \"MessageConfirmDeleteLibraryItems\": \"這將從資料庫和檔案系統中刪除 {0} 個庫項目. 你確定嗎?\",\n  \"MessageConfirmDeleteSession\": \"你確定要刪除此會話嗎?\",\n  \"MessageConfirmForceReScan\": \"你確定要強制重新掃描嗎?\",\n  \"MessageConfirmMarkAllEpisodesFinished\": \"你確定要將所有劇集都標記為已完成嗎?\",\n  \"MessageConfirmMarkAllEpisodesNotFinished\": \"你確定要將所有劇集都標記為未完成嗎?\",\n  \"MessageConfirmMarkSeriesFinished\": \"你確定要將此系列中的所有書籍都標記為已聽完嗎?\",\n  \"MessageConfirmMarkSeriesNotFinished\": \"你確定要將此系列中的所有書籍都標記為未聽完嗎?\",\n  \"MessageConfirmQuickEmbed\": \"警告! 快速嵌入不會備份你的音頻檔案. 確保你有音頻檔案的備份. <br><br>你是否想繼續嗎?\",\n  \"MessageConfirmReScanLibraryItems\": \"你確定要重新掃描 {0} 個項目嗎?\",\n  \"MessageConfirmRemoveAllChapters\": \"你確定要移除所有章節嗎?\",\n  \"MessageConfirmRemoveAuthor\": \"你確定要刪除作者 \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveCollection\": \"你確定要移除收藏 \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisode\": \"你確定要移除劇集 \\\"{0}\\\"?\",\n  \"MessageConfirmRemoveEpisodes\": \"你確定要移除 {0} 劇集?\",\n  \"MessageConfirmRemoveListeningSessions\": \"你確定要移除 {0} 收聽會話嗎?\",\n  \"MessageConfirmRemoveNarrator\": \"你確定要刪除演播者 \\\"{0}\\\"?\",\n  \"MessageConfirmRemovePlaylist\": \"你確定要移除播放列表 \\\"{0}\\\"?\",\n  \"MessageConfirmRenameGenre\": \"你確定要將所有項目流派 \\\"{0}\\\" 重命名到 \\\"{1}\\\"?\",\n  \"MessageConfirmRenameGenreMergeNote\": \"注意: 該流派已經存在, 因此它們將被合併.\",\n  \"MessageConfirmRenameGenreWarning\": \"警告! 已經存在有大小寫不同的類似流派 \\\"{0}\\\".\",\n  \"MessageConfirmRenameTag\": \"你確定要將所有項目標籤 \\\"{0}\\\" 重命名到 \\\"{1}\\\"?\",\n  \"MessageConfirmRenameTagMergeNote\": \"注意: 該標籤已經存在, 因此它們將被合併.\",\n  \"MessageConfirmRenameTagWarning\": \"警告! 已經存在有大小寫不同的類似標籤 \\\"{0}\\\".\",\n  \"MessageConfirmSendEbookToDevice\": \"你確定要發送 {0} 電子書 \\\"{1}\\\" 到設備 \\\"{2}\\\"?\",\n  \"MessageDownloadingEpisode\": \"正在下載劇集\",\n  \"MessageDragFilesIntoTrackOrder\": \"將檔案拖動到正確的音軌順序\",\n  \"MessageEmbedFinished\": \"嵌入完成!\",\n  \"MessageEpisodesQueuedForDownload\": \"{0} 個劇集排隊等待下載\",\n  \"MessageFeedURLWillBe\": \"源 URL 將改為 {0}\",\n  \"MessageFetching\": \"正在獲取...\",\n  \"MessageForceReScanDescription\": \"將像重新掃描一樣再次掃描所有檔案. 音頻檔 ID3 標籤, OPF 檔和文本檔將被掃描為新檔案.\",\n  \"MessageImportantNotice\": \"重要通知!\",\n  \"MessageInsertChapterBelow\": \"在下面插入章節\",\n  \"MessageItemsSelected\": \"已選定 {0} 個項目\",\n  \"MessageItemsUpdated\": \"已更新 {0} 個項目\",\n  \"MessageJoinUsOn\": \"加入我們\",\n  \"MessageLoading\": \"讀取...\",\n  \"MessageLoadingFolders\": \"讀取資料夾...\",\n  \"MessageM4BFailed\": \"M4B 失敗!\",\n  \"MessageM4BFinished\": \"M4B 完成!\",\n  \"MessageMapChapterTitles\": \"將章節標題映射到現有的有聲書章節, 無需調整時間戳\",\n  \"MessageMarkAllEpisodesFinished\": \"標記所有劇集為已完成\",\n  \"MessageMarkAllEpisodesNotFinished\": \"標記所有劇集為未完成\",\n  \"MessageMarkAsFinished\": \"標記為已聽完\",\n  \"MessageMarkAsNotFinished\": \"標記為未聽完\",\n  \"MessageMatchBooksDescription\": \"嘗試將媒體庫中的圖書與所選搜尋提供商的圖書進行匹配, 並填寫空白的詳細信息和封面. 不覆蓋詳細信息.\",\n  \"MessageNoAudioTracks\": \"沒有音軌\",\n  \"MessageNoAuthors\": \"沒有作者\",\n  \"MessageNoBackups\": \"沒有備份\",\n  \"MessageNoBookmarks\": \"沒有書籤\",\n  \"MessageNoChapters\": \"沒有章節\",\n  \"MessageNoCollections\": \"沒有收藏\",\n  \"MessageNoCoversFound\": \"沒有找到封面\",\n  \"MessageNoDescription\": \"沒有描述\",\n  \"MessageNoDownloadsInProgress\": \"當前沒有正在進行的下載\",\n  \"MessageNoDownloadsQueued\": \"下載佇列無任務\",\n  \"MessageNoEpisodeMatchesFound\": \"沒有找到任何劇集匹配項\",\n  \"MessageNoEpisodes\": \"沒有劇集\",\n  \"MessageNoFoldersAvailable\": \"沒有可用資料夾\",\n  \"MessageNoGenres\": \"無流派\",\n  \"MessageNoIssues\": \"無問題\",\n  \"MessageNoItems\": \"沒有項目\",\n  \"MessageNoItemsFound\": \"沒有找到任何項目\",\n  \"MessageNoListeningSessions\": \"沒有收聽會話\",\n  \"MessageNoLogs\": \"無日誌\",\n  \"MessageNoMediaProgress\": \"無媒體進度\",\n  \"MessageNoNotifications\": \"無通知\",\n  \"MessageNoPodcastsFound\": \"沒有找到播客\",\n  \"MessageNoResults\": \"無結果\",\n  \"MessageNoSearchResultsFor\": \"沒有搜尋到結果 \\\"{0}\\\"\",\n  \"MessageNoSeries\": \"無系列\",\n  \"MessageNoTags\": \"無標籤\",\n  \"MessageNoTasksRunning\": \"沒有正在運行的任務\",\n  \"MessageNoUpdatesWereNecessary\": \"無需更新\",\n  \"MessageNoUserPlaylists\": \"您沒有播放列表\",\n  \"MessageNotYetImplemented\": \"尚未實施\",\n  \"MessageOr\": \"或\",\n  \"MessagePauseChapter\": \"暫停章節播放\",\n  \"MessagePlayChapter\": \"開始章節播放\",\n  \"MessagePlaylistCreateFromCollection\": \"從收藏中創建播放列表\",\n  \"MessagePodcastHasNoRSSFeedForMatching\": \"播客沒有可用於匹配 RSS 源的 url\",\n  \"MessageQuickMatchDescription\": \"使用來自 '{0}' 的第一個匹配結果填充空白詳細信息和封面. 除非啟用 '首選匹配元數據' 伺服器設定, 否則不會覆蓋詳細信息.\",\n  \"MessageRemoveChapter\": \"移除章節\",\n  \"MessageRemoveEpisodes\": \"移除 {0} 劇集\",\n  \"MessageRemoveFromPlayerQueue\": \"從播放佇列中移除\",\n  \"MessageRemoveUserWarning\": \"是否確實要永久刪除使用者 \\\"{0}\\\"?\",\n  \"MessageReportBugsAndContribute\": \"報告錯誤、請求功能和做出貢獻\",\n  \"MessageResetChaptersConfirm\": \"你確定要重置章節並撤消你所做的更改嗎?\",\n  \"MessageRestoreBackupConfirm\": \"你確定要恢復創建的這個備份\",\n  \"MessageRestoreBackupWarning\": \"恢復備份將覆蓋位於 /config 的整個資料庫並覆蓋 /metadata/items & /metadata/authors 中的圖像.<br /><br />備份不會修改媒體庫資料夾中的任何檔案. 如果您已啟用伺服器設定將封面和元數據存儲在庫資料夾中，則不會備份或覆蓋這些內容.<br /><br />將自動刷新使用伺服器的所有客戶端.\",\n  \"MessageSearchResultsFor\": \"搜尋結果\",\n  \"MessageSelected\": \"{0} 被選取\",\n  \"MessageServerCouldNotBeReached\": \"無法連接伺服器\",\n  \"MessageSetChaptersFromTracksDescription\": \"把每個音頻檔設定為章節並將章節標題設定為音頻檔名\",\n  \"MessageStartPlaybackAtTime\": \"開始播放 \\\"{0}\\\" 在 {1}?\",\n  \"MessageThinking\": \"正在查找...\",\n  \"MessageUploaderItemFailed\": \"上傳失敗\",\n  \"MessageUploaderItemSuccess\": \"上傳成功!\",\n  \"MessageUploading\": \"正在上傳...\",\n  \"MessageValidCronExpression\": \"有效的計劃任務表達式\",\n  \"MessageWatcherIsDisabledGlobally\": \"在伺服器設定中禁用全域監視程序\",\n  \"MessageXLibraryIsEmpty\": \"{0} 庫為空!\",\n  \"MessageYourAudiobookDurationIsLonger\": \"您的有聲書持續時間比找到的持續時間長\",\n  \"MessageYourAudiobookDurationIsShorter\": \"您的有聲書持續時間比找到的持續時間短\",\n  \"NoteChangeRootPassword\": \"Root 是唯一可以擁有空密碼的使用者\",\n  \"NoteChapterEditorTimes\": \"注意: 第一章開始時間必須保持在 0:00, 最後一章開始時間不能超過有聲書持續時間.\",\n  \"NoteFolderPicker\": \"注意: 將不顯示已映射的資料夾\",\n  \"NoteRSSFeedPodcastAppsHttps\": \"警告：大多數播客應用程式要求 RSS 訂閱源 URL 使用 HTTPS\",\n  \"NoteRSSFeedPodcastAppsPubDate\": \"警告：您的一個或多個劇集沒有發布日期。某些播客應用程式要求提供此資訊。\",\n  \"NoteUploaderFoldersWithMediaFiles\": \"包含媒體檔案的資料夾將作為單獨的媒體庫項目處理.\",\n  \"NoteUploaderOnlyAudioFiles\": \"如果只上傳音頻檔, 則每個音頻檔將作為單獨的有聲書處理.\",\n  \"NoteUploaderUnsupportedFiles\": \"不支援的檔案將被忽略. 選擇或刪除資料夾時, 將忽略不在項目資料夾中的其他檔案.\",\n  \"PlaceholderNewCollection\": \"輸入收藏夾名稱\",\n  \"PlaceholderNewFolderPath\": \"輸入資料夾路徑\",\n  \"PlaceholderNewPlaylist\": \"輸入播放列表名稱\",\n  \"PlaceholderSearch\": \"查找..\",\n  \"PlaceholderSearchEpisode\": \"搜尋劇集..\",\n  \"ToastAccountUpdateSuccess\": \"帳號已更新\",\n  \"ToastAuthorImageRemoveSuccess\": \"作者圖像已刪除\",\n  \"ToastAuthorUpdateMerged\": \"作者已合併\",\n  \"ToastAuthorUpdateSuccess\": \"作者已更新\",\n  \"ToastAuthorUpdateSuccessNoImageFound\": \"作者已更新 (未找到圖像)\",\n  \"ToastBackupCreateFailed\": \"備份創建失敗\",\n  \"ToastBackupCreateSuccess\": \"備份已創建\",\n  \"ToastBackupDeleteFailed\": \"備份刪除失敗\",\n  \"ToastBackupDeleteSuccess\": \"備份已刪除\",\n  \"ToastBackupRestoreFailed\": \"備份還原失敗\",\n  \"ToastBackupUploadFailed\": \"上傳備份失敗\",\n  \"ToastBackupUploadSuccess\": \"備份已上傳\",\n  \"ToastBatchUpdateFailed\": \"批量更新失敗\",\n  \"ToastBatchUpdateSuccess\": \"批量更新成功\",\n  \"ToastBookmarkCreateFailed\": \"創建書簽失敗\",\n  \"ToastBookmarkCreateSuccess\": \"書籤已新增\",\n  \"ToastBookmarkRemoveSuccess\": \"書籤已刪除\",\n  \"ToastChaptersHaveErrors\": \"章節有錯誤\",\n  \"ToastChaptersMustHaveTitles\": \"章節必須有標題\",\n  \"ToastCollectionRemoveSuccess\": \"收藏夾已刪除\",\n  \"ToastCollectionUpdateSuccess\": \"收藏夾已更新\",\n  \"ToastItemCoverUpdateSuccess\": \"項目封面已更新\",\n  \"ToastItemDetailsUpdateSuccess\": \"項目詳細信息已更新\",\n  \"ToastItemMarkedAsFinishedFailed\": \"標記為聽完失敗\",\n  \"ToastItemMarkedAsFinishedSuccess\": \"標記為聽完的項目\",\n  \"ToastItemMarkedAsNotFinishedFailed\": \"標記為未聽完失敗\",\n  \"ToastItemMarkedAsNotFinishedSuccess\": \"標記為未聽完的項目\",\n  \"ToastLibraryCreateFailed\": \"創建媒體庫失敗\",\n  \"ToastLibraryCreateSuccess\": \"媒體庫 \\\"{0}\\\" 創建成功\",\n  \"ToastLibraryDeleteFailed\": \"刪除媒體庫失敗\",\n  \"ToastLibraryDeleteSuccess\": \"媒體庫已刪除\",\n  \"ToastLibraryScanFailedToStart\": \"無法啟動掃描\",\n  \"ToastLibraryScanStarted\": \"媒體庫掃描已啟動\",\n  \"ToastLibraryUpdateSuccess\": \"媒體庫 \\\"{0}\\\" 已更新\",\n  \"ToastPlaylistCreateFailed\": \"創建播放列表失敗\",\n  \"ToastPlaylistCreateSuccess\": \"已成功創建播放列表\",\n  \"ToastPlaylistRemoveSuccess\": \"播放列表已刪除\",\n  \"ToastPlaylistUpdateSuccess\": \"播放列表已更新\",\n  \"ToastPodcastCreateFailed\": \"創建播客失敗\",\n  \"ToastPodcastCreateSuccess\": \"已成功創建播客\",\n  \"ToastRSSFeedCloseFailed\": \"關閉 RSS 源失敗\",\n  \"ToastRSSFeedCloseSuccess\": \"RSS 源已關閉\",\n  \"ToastRemoveItemFromCollectionFailed\": \"從收藏中刪除項目失敗\",\n  \"ToastRemoveItemFromCollectionSuccess\": \"項目已從收藏中刪除\",\n  \"ToastSendEbookToDeviceFailed\": \"發送電子書到設備失敗\",\n  \"ToastSendEbookToDeviceSuccess\": \"電子書已經發送到設備 \\\"{0}\\\"\",\n  \"ToastSeriesUpdateFailed\": \"更新系列失敗\",\n  \"ToastSeriesUpdateSuccess\": \"系列已更新\",\n  \"ToastSessionDeleteFailed\": \"刪除會話失敗\",\n  \"ToastSessionDeleteSuccess\": \"會話已刪除\",\n  \"ToastSocketConnected\": \"網路已連接\",\n  \"ToastSocketDisconnected\": \"網路已斷開\",\n  \"ToastSocketFailedToConnect\": \"網路連接失敗\",\n  \"ToastUserDeleteFailed\": \"刪除使用者失敗\",\n  \"ToastUserDeleteSuccess\": \"使用者已刪除\"\n}\n"
  },
  {
    "path": "custom-metadata-provider-specification.yaml",
    "content": "openapi: 3.0.0\n\nservers:\n  - url: https://example.com\n    description: Local server\n\ninfo:\n  title: Custom Metadata Provider\n  version: 0.1.0\n  license:\n    name: MIT\n    url: https://opensource.org/licenses/MIT\n\nsecurity:\n  - api_key: []\n\npaths:\n  /search:\n    get:\n      description: Search for books\n      operationId: search\n      summary: Search for books\n      security:\n        - api_key: []\n\n      parameters:\n        - name: query\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: author\n          in: query\n          required: false\n          schema:\n            type: string\n\n      responses:\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  matches:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/BookMetadata\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  error:\n                    type: string\n        \"401\":\n          description: Unauthorized\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  error:\n                    type: string\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  error:\n                    type: string\ncomponents:\n  securitySchemes:\n    api_key:\n      type: apiKey\n      name: AUTHORIZATION\n      in: header\n\n  schemas:\n    BookMetadata:\n      type: object\n      required:\n        - title\n      properties:\n        title:\n          type: string\n        subtitle:\n          type: string\n        author:\n          type: string\n        narrator:\n          type: string\n        publisher:\n          type: string\n        publishedYear:\n          type: string\n        description:\n          type: string\n        cover:\n          type: string\n          description: URL to the cover image\n        isbn:\n          type: string\n          format: isbn\n        asin:\n          type: string\n          format: asin\n        genres:\n          type: array\n          items:\n            type: string\n        tags:\n          type: array\n          items:\n            type: string\n        series:\n          type: array\n          items:\n            $ref: \"#/components/schemas/SeriesMetadata\"\n        language:\n          type: string\n        duration:\n          type: integer\n          format: int64\n          description: Duration in minutes\n\n    SeriesMetadata:\n      type: object\n      required:\n        - series\n      properties:\n        series:\n          type: string\n        sequence:\n          type: string\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "### EXAMPLE DOCKER COMPOSE ###\nservices:\n  audiobookshelf:\n    image: ghcr.io/advplyr/audiobookshelf:latest\n    # ABS runs on port 13378 by default. If you want to change\n    # the port, only change the external port, not the internal port\n    ports:\n      - 13378:80\n    volumes:\n      # These volumes are needed to keep your library persistent\n      # and allow media to be accessed by the ABS server.\n      # The path to the left of the colon is the path on your computer,\n      # and the path to the right of the colon is where the data is\n      # available to ABS in Docker.\n      # You can change these media directories or add as many as you want\n      - ./audiobooks:/audiobooks\n      - ./podcasts:/podcasts\n      # The metadata directory can be stored anywhere on your computer\n      - ./metadata:/metadata\n      # The config directory needs to be on the same physical machine\n      # you are running ABS on\n      - ./config:/config\n    restart: unless-stopped\n    # You can use the following user directive to run the ABS\n    # docker container as a specific user. You will need to change\n    # the UID and GID to the correct values for your user.\n    # user: 1000:1000\n"
  },
  {
    "path": "docker-template.xml",
    "content": "<?xml version=\"1.0\"?>\n<Container version=\"2\">\n  <Name>audiobookshelf</Name>\n  <Repository>ghcr.io/advplyr/audiobookshelf</Repository>\n  <Registry>https://hub.docker.com/r/advplyr/audiobookshelf/</Registry>\n  <Network>bridge</Network>\n  <MyIP/>\n  <Shell>sh</Shell>\n  <Privileged>false</Privileged>\n  <Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>\n  <Project>https://github.com/advplyr/audiobookshelf</Project>\n  <Overview>Self-hosted audiobook and podcast server and web app. Supports multi-user w/ permissions and keeps progress in sync across devices. Free &amp; open source mobile apps. Consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>\n  <Category>MediaApp:Books MediaServer:Books MediaApp:Other MediaServer:Other</Category>\n  <WebUI>http://[IP]:[PORT:80]</WebUI>\n  <TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>\n  <Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>\n  <ExtraParams/>\n  <PostArgs/>\n  <CPUset/>\n  <DateInstalled>1629238508</DateInstalled>\n  <DonateText/>\n  <DonateLink/>\n  <Description>Self-hosted audiobook and podcast server and web app. Supports multi-user w/ permissions and keeps progress in sync across devices. Free &amp; open source mobile apps. Consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>\n  <Networking>\n    <Mode>bridge</Mode>\n    <Publish>\n      <Port>\n        <HostPort>13378</HostPort>\n        <ContainerPort>80</ContainerPort>\n        <Protocol>tcp</Protocol>\n      </Port>\n    </Publish>\n  </Networking>\n  <Data>\n    <Volume>\n      <HostDir/>\n      <ContainerDir>/audiobooks</ContainerDir>\n      <Mode>rw</Mode>\n    </Volume>\n    <Volume>\n      <HostDir>/mnt/user/appdata/audiobookshelf/config/</HostDir>\n      <ContainerDir>/config</ContainerDir>\n      <Mode>rw</Mode>\n    </Volume>\n    <Volume>\n      <HostDir>/mnt/user/appdata/audiobookshelf/metadata/</HostDir>\n      <ContainerDir>/metadata</ContainerDir>\n      <Mode>rw</Mode>\n    </Volume>\n  </Data>\n  <Labels/>\n  <Config Name=\"Audiobooks\" Target=\"/audiobooks\" Default=\"\" Mode=\"rw\" Description=\"Container Path: /audiobooks\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\" />\n  <Config Name=\"Config\" Target=\"/config\" Default=\"/mnt/user/appdata/audiobookshelf/config/\" Mode=\"rw\" Description=\"Container Path: /config\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/mnt/user/appdata/audiobookshelf/config/</Config>\n  <Config Name=\"Metadata\" Target=\"/metadata\" Default=\"/mnt/user/appdata/audiobookshelf/metadata/\" Mode=\"rw\" Description=\"Container Path: /metadata\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/mnt/user/appdata/audiobookshelf/metadata/</Config>\n  <Config Name=\"Web UI Port\" Target=\"80\" Default=\"13378\" Mode=\"tcp\" Description=\"Container Port: 80\" Type=\"Port\" Display=\"always\" Required=\"false\" Mask=\"false\">13378</Config>\n</Container>"
  },
  {
    "path": "docs/README.md",
    "content": "# OpenAPI specification\n\nThis directory includes the OpenAPI spec for the ABS server. The spec is made up of a number of individual `yaml` files located here and in the subfolders, with `root.yaml` being the file that references all of the others. The files are organized to have the same hierarchy as the server source files. The full spec is bundled into one file in `openapi.json`.\n\nThe spec is linted and bundled using [`redocly-cli`](https://redocly.com/docs/cli/). This tool also generates HTML docs for the spec.\n\nThe tools created by [`pb33f`](https://pb33f.io/), specifically `vacuum` and `wiretap`, are also useful for linting and verification. These tools check for some other things, such as validating requests to and responses from the server.\n\n### Bundling the spec\n\nThe command used to bundle the spec into a `yaml` file is `redocly bundle root.yaml > bundled.yaml`.\n\nThe `yq` tool is used to convert the `yaml` to a `json` using the `yq -p yaml -o json bundled.yaml > openapi.json`.\n\n### Linting the spec\n\nThe command used to lint the spec is `redocly lint root.yaml`\n\nTo generate an HTML report using `vacuum`, you can use `vacuum html-report [file]` to generate `report.html` and view the report in your browser.\n\n### Generating documentation\n\nRedocly allows for creating a static HTML page to document the API. This is done by using `redocly build-docs [file]` and supports exploded specs.\n\n### Putting it all together\n\nThe full command that I run to bundle the spec and generate the documentation is:\n\n```\nredocly bundle root.yaml > bundled.yaml && \\\nyq -p yaml -o json bundled.yaml > openapi.json && \\\nredocly build-docs openapi.json\n```\n"
  },
  {
    "path": "docs/controllers/AuthorController.yaml",
    "content": "components:\n  schemas:\n    authorUpdated:\n      description: Whether the author was updated without errors. Will not exist if author was merged.\n      type: boolean\n      nullable: true\n    authorMerged:\n      description: Whether the author was merged with another author. Will not exist if author was updated.\n      type: boolean\n      nullable: true\n    authorInclude:\n      description: A comma separated list of what to include with the author. The options are `items` and `series`. `series` will only have an effect if `items` is included. For example, the value `items,series` will include both library items and series.\n      type: string\n      example: 'items'\n    authorLibraryId:\n      $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n    authorSearchName:\n      description: The name of the author to use for searching.\n      type: string\n      example: Terry Goodkind\n    authorName:\n      $ref: '../objects/entities/Author.yaml#/components/schemas/authorName'\n    imageUrl:\n      description: The URL of the image to add to the server\n      type: string\n      format: uri\n      example: https://images-na.ssl-images-amazon.com/images/I/51NoQTm33OL.__01_SX120_CR0,0,120,120__.jpg\n    imageWidth:\n      description: The requested width of image in pixels.\n      type: integer\n      default: 400\n      example: 400\n    imageHeight:\n      description: The requested height of image in pixels. If `null`, the height is scaled to maintain aspect ratio based on the requested width.\n      type: integer\n      nullable: true\n      default: null\n      example: 600\n    imageFormat:\n      description: The requested output format.\n      type: string\n      default: jpeg\n      example: webp\n    imageRaw:\n      description: Return the raw image without scaling if true.\n      type: boolean\n      default: false\n  responses:\n    author200:\n      description: Author found.\n      content:\n        application/json:\n          schema:\n            $ref: '../objects/entities/Author.yaml#/components/schemas/author'\n    author404:\n      description: Author not found.\n      content:\n        text/html:\n          schema:\n            type: string\n            example: Author not found.\npaths:\n  /api/authors/{id}:\n    parameters:\n      - name: id\n        in: path\n        description: Author ID\n        required: true\n        schema:\n          $ref: '../objects/entities/Author.yaml#/components/schemas/authorId'\n    get:\n      operationId: getAuthorById\n      summary: Get an author by ID\n      description: Get an author by ID. The author's books and series can be included in the response.\n      tags:\n        - Authors\n      parameters:\n        - in: query\n          name: include\n          description: A comma separated list of what to include with the author. The options are `items` and `series`. `series` will only have an effect if `items` is included. For example, the value `items,series` will include both library items and series.\n          allowReserved: true\n          schema:\n            type: string\n          example: 'items,series'\n      responses:\n        '200':\n          description: getAuthorById OK\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/entities/Author.yaml#/components/schemas/author'\n        '404':\n          $ref: '#/components/responses/author404'\n    patch:\n      operationId: updateAuthorById\n      summary: Update an author by ID\n      description: Update an author by ID. The author's name and description can be updated. This endpoint will merge two authors if the new author name matches another author name in the database.\n      tags:\n        - Authors\n      requestBody:\n        description: The author object to update.\n        content:\n          application/json:\n            schema:\n              properties:\n                name:\n                  $ref: '#/components/schemas/authorName'\n                description:\n                  $ref: '../objects/entities/Author.yaml#/components/schemas/authorDescription'\n                imagePath:\n                  $ref: '../objects/entities/Author.yaml#/components/schemas/authorImagePath'\n                asin:\n                  $ref: '../objects/entities/Author.yaml#/components/schemas/authorAsin'\n      responses:\n        '200':\n          description: updateAuthorById OK\n          content:\n            application/json:\n              schema:\n                oneOf:\n                  - $ref: '../objects/entities/Author.yaml#/components/schemas/author'\n                  - $ref: '#/components/schemas/authorUpdated'\n                  - $ref: '#/components/schemas/authorMerged'\n        '404':\n          $ref: '#/components/responses/author404'\n    delete:\n      operationId: deleteAuthorById\n      summary: Delete an author by ID\n      description: Delete an author by ID. This will remove the author from all books.\n      tags:\n        - Authors\n      responses:\n        '200':\n          description: deleteAuthorById OK\n          content:\n            text/plain:\n              schema:\n                type: string\n                example: Author deleted.\n        '404':\n          $ref: '#/components/responses/author404'\n  /api/authors/{id}/image:\n    parameters:\n      - name: id\n        in: path\n        description: Author ID\n        required: true\n        schema:\n          $ref: '../objects/entities/Author.yaml#/components/schemas/authorId'\n      - name: token\n        in: query\n        description: API token\n        schema:\n          type: string\n      - name: ts\n        in: query\n        description: Updated at value\n        schema:\n          type: integer\n    get:\n      operationId: getAuthorImageById\n      summary: Get an author image by author ID\n      description: Get an author image by author ID. The image will be returned in the requested format and size.\n      security: [] # No security for getting author image\n      tags:\n        - Authors\n      requestBody:\n        required: false\n        description: The author image to get.\n        content:\n          application/json:\n            schema:\n              properties:\n                width:\n                  $ref: '#/components/schemas/imageWidth'\n                height:\n                  $ref: '#/components/schemas/imageHeight'\n                format:\n                  $ref: '#/components/schemas/imageFormat'\n                raw:\n                  $ref: '#/components/schemas/imageRaw'\n      responses:\n        '200':\n          description: getAuthorImageById OK\n          content:\n            image/webp:\n              schema:\n                type: string\n                format: binary\n            image/jpeg:\n              schema:\n                type: string\n                format: binary\n            image/*:\n              schema:\n                type: string\n                format: binary\n        '404':\n          $ref: '#/components/responses/author404'\n    post:\n      operationId: addAuthorImageById\n      summary: Add an author image to the server\n      description: Add an author image to the server. The image will be downloaded from the provided URL and stored on the server.\n      tags:\n        - Authors\n      requestBody:\n        required: true\n        description: The author image to add by URL.\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/imageUrl'\n      responses:\n        '200':\n          description: addAuthorImageById OK\n          content:\n            image/*:\n              schema:\n                type: string\n                format: binary\n        '404':\n          $ref: '#/components/responses/author404'\n    patch:\n      operationId: updateAuthorImageById\n      summary: Update an author image by author ID\n      description: Update an author image by author ID. The image will be resized if the width, height, or format is provided.\n      tags:\n        - Authors\n      requestBody:\n        description: The author image to update.\n        content:\n          application/json:\n            schema:\n              properties:\n                width:\n                  $ref: '#/components/schemas/imageWidth'\n                height:\n                  $ref: '#/components/schemas/imageHeight'\n                format:\n                  $ref: '#/components/schemas/imageFormat'\n                raw:\n                  $ref: '#/components/schemas/imageRaw'\n      responses:\n        '200':\n          description: updateAuthorImageById OK\n          content:\n            image/*:\n              schema:\n                type: string\n                format: binary\n        '404':\n          $ref: '#/components/responses/author404'\n    delete:\n      operationId: deleteAuthorImageById\n      summary: Delete an author image by author ID\n      description: Delete an author image by author ID. This will remove the image from the server and the database.\n      tags:\n        - Authors\n      responses:\n        '200':\n          description: deleteAuthorImageById OK\n        '404':\n          $ref: '#/components/responses/author404'\n  /api/authors/{id}/match:\n    parameters:\n      - name: id\n        in: path\n        description: Author ID\n        required: true\n        schema:\n          $ref: '../objects/entities/Author.yaml#/components/schemas/authorId'\n    post:\n      operationId: matchAuthorById\n      summary: Match the author against Audible using quick match\n      description: Match the author against Audible using quick match. Quick match updates the author's description and image (if no image already existed) with information from audible. Either `asin` or `q` must be provided, with `asin` taking priority if both are provided.\n      tags:\n        - Authors\n      requestBody:\n        required: true\n        description: The author object to match against an online provider.\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                q:\n                  $ref: '#/components/schemas/authorSearchName'\n                asin:\n                  $ref: '../objects/entities/Author.yaml#/components/schemas/authorAsin'\n                region:\n                  $ref: '../schemas.yaml#/components/schemas/region'\n      responses:\n        '200':\n          description: matchAuthorById OK\n          content:\n            application/json:\n              schema:\n                oneOf:\n                  - $ref: '../objects/entities/Author.yaml#/components/schemas/author'\n                  - $ref: '#/components/schemas/authorUpdated'\n        '404':\n          $ref: '#/components/responses/author404'\n"
  },
  {
    "path": "docs/controllers/EmailController.yaml",
    "content": "components:\n  schemas:\n    emailSettings:\n      type: string\n      description: The field to sort by from the request.\n      example: 'media.metadata.title'\n  responses:\n    email200:\n      description: Successful response - Email\n      content:\n        application/json:\n          schema:\n            $ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EmailSettings'\n    ereader200:\n      description: Successful response - Ereader\n      content:\n        application/json:\n          schema:\n            type: object\n            properties:\n              ereaderDevices:\n                type: array\n                items:\n                  $ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EreaderDeviceObject'\npaths:\n  /api/emails/settings:\n    get:\n      summary: Get email settings\n      description: Get email settings for sending e-books to e-readers.\n      operationId: getEmailSettings\n      tags:\n        - Email\n      responses:\n        200:\n          $ref: '#/components/responses/email200'\n    patch:\n      summary: Update email settings\n      operationId: updateEmailSettings\n      tags:\n        - Email\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EmailSettings'\n      responses:\n        200:\n          $ref: '#/components/responses/email200'\n  /api/emails/test:\n    post:\n      summary: Send test email\n      operationId: sendTestEmail\n      tags:\n        - Email\n      responses:\n        200:\n          description: Successful response\n  /api/emails/ereader-devices:\n    post:\n      summary: Update e-reader devices\n      operationId: updateEReaderDevices\n      tags:\n        - Email\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                ereaderDevices:\n                  type: array\n                  items:\n                    $ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EreaderDeviceObject'\n      responses:\n        200:\n          $ref: '#/components/responses/ereader200'\n        400:\n          description: Invalid payload\n  /api/emails/send-ebook-to-device:\n    post:\n      summary: Send ebook to device\n      operationId: sendEBookToDevice\n      tags:\n        - Email\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                libraryItemId:\n                  $ref: '../objects/LibraryItem.yaml#/components/schemas/libraryItemId'\n                deviceName:\n                  $ref: '../objects/settings/EmailSettings.yaml#/components/schemas/ereaderName'\n      responses:\n        200:\n          description: Successful response\n        400:\n          description: Invalid request\n        403:\n          description: Forbidden\n        404:\n          description: Not found\n"
  },
  {
    "path": "docs/controllers/LibraryController.yaml",
    "content": "components:\n  schemas:\n    sortBy:\n      type: string\n      description: The field to sort by from the request.\n      example: 'media.metadata.title'\n    sortDesc:\n      description: Whether to sort in descending order.\n      type: boolean\n      example: true\n    filterBy:\n      type: string\n      description: The field to filter by from the request. TODO\n      example: 'media.metadata.title'\n    collapseSeries:\n      type: boolean\n      description: Whether collapse series was set in the request.\n      example: true\n    libraryFolders:\n      description: The folders of the library. Only specify the fullPath.\n      type: array\n      items:\n        $ref: '../objects/Folder.yaml#/components/schemas/folder'\n    libraryDisplayOrder:\n      description: The display order of the library. Must be >= 1.\n      type: integer\n      minimum: 1\n      example: 1\n    libraryIcon:\n      description: The icon of the library. See Library Icons for a list of possible icons.\n      type: string\n      example: 'audiobookshelf'\n    libraryMediaType:\n      description: The type of media that the library contains. Must be `book` or `podcast`.\n      type: string\n      example: 'book'\n    libraryProvider:\n      description: Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.\n      type: string\n      example: 'audible'\n    librarySettings:\n      $ref: '../objects/Library.yaml#/components/schemas/librarySettings'\n    librarySort:\n      description: The sort order of the library. For example, to sort by title use 'sort=media.metadata.title'.\n      type: string\n      example: 'media.metadata.title'\n    libraryFilter:\n      description: The filter for the library.\n      type: string\n      example: 'media.metadata.title'\n    libraryCollapseSeries:\n      description: Whether to collapse series.\n      type: boolean\n      example: true\n      default: false\n    libraryInclude:\n      description: The fields to include in the response. The only current option is `rssfeed`.\n      type: string\n      example: 'rssfeed'\n  parameters:\n    limit:\n      in: query\n      name: limit\n      description: The number of items to return. This the size of a single page for the optional `page` query.\n      example: 10\n      schema:\n        type: integer\n        default: 0\n    page:\n      in: query\n      name: page\n      description: The page number (zero indexed) to return. If no limit is specified, then page will have no effect.\n      example: 0\n      schema:\n        type: integer\n        default: 0\n    desc:\n      in: query\n      name: desc\n      description: Return items in reversed order if true.\n      example: 0\n      schema:\n        type: integer\n        default: 0\n\n  responses:\n    library200:\n      description: Library found.\n      content:\n        application/json:\n          schema:\n            $ref: '../objects/Library.yaml#/components/schemas/library'\n    library404:\n      description: Library not found.\n      content:\n        text/html:\n          schema:\n            type: string\n            example: Library not found.\npaths:\n  /api/libraries:\n    get:\n      operationId: getLibraries\n      summary: Get all libraries on server\n      description: Get all libraries on server.\n      tags:\n        - Libraries\n      responses:\n        '200':\n          description: getLibraries OK\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  libraries:\n                    type: array\n                    items:\n                      $ref: '../objects/Library.yaml#/components/schemas/library'\n    post:\n      operationId: createLibrary\n      summary: Create a new library on server\n      description: Create a new library on server.\n      tags:\n        - Libraries\n      requestBody:\n        description: The library object to create.\n        content:\n          application/json:\n            schema:\n              type: object\n              required: [name, folders]\n              properties:\n                name:\n                  $ref: '../objects/Library.yaml#/components/schemas/libraryName'\n                folders:\n                  $ref: '#/components/schemas/libraryFolders'\n                displayOrder:\n                  $ref: '#/components/schemas/libraryDisplayOrder'\n                icon:\n                  $ref: '#/components/schemas/libraryIcon'\n                mediaType:\n                  $ref: '#/components/schemas/libraryMediaType'\n                provider:\n                  $ref: '#/components/schemas/libraryProvider'\n                settings:\n                  $ref: '#/components/schemas/librarySettings'\n      responses:\n        '200':\n          $ref: '#/components/responses/library200'\n        '404':\n          $ref: '#/components/responses/library404'\n  /api/libraries/{id}:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the library.\n        required: true\n        schema:\n          $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n    get:\n      operationId: getLibraryById\n      summary: Get a single library by ID on server\n      description: Get a single library by ID on server.\n      tags:\n        - Libraries\n      parameters:\n        - in: query\n          name: include\n          schema:\n            type: string\n        - $ref: '../schemas.yaml#/components/parameters/minified'\n      responses:\n        '200':\n          $ref: '#/components/responses/library200'\n        '404':\n          $ref: '#/components/responses/library404'\n    patch:\n      operationId: updateLibraryById\n      summary: Update a single library by ID on server\n      description: Update a single library by ID on server.\n      tags:\n        - Libraries\n      requestBody:\n        required: true\n        description: The library object to update.\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                name:\n                  $ref: '../objects/Library.yaml#/components/schemas/libraryName'\n                folders:\n                  $ref: '#/components/schemas/libraryFolders'\n                displayOrder:\n                  $ref: '#/components/schemas/libraryDisplayOrder'\n                icon:\n                  $ref: '#/components/schemas/libraryIcon'\n                mediaType:\n                  $ref: '#/components/schemas/libraryMediaType'\n                provider:\n                  $ref: '#/components/schemas/libraryProvider'\n                settings:\n                  $ref: '#/components/schemas/librarySettings'\n      responses:\n        '200':\n          $ref: '#/components/responses/library200'\n        '404':\n          $ref: '#/components/responses/library404'\n    delete:\n      operationId: deleteLibraryById\n      summary: Delete a single library by ID on server\n      description: Delete a single library by ID on server and return the deleted object.\n      tags:\n        - Libraries\n      responses:\n        '200':\n          $ref: '#/components/responses/library200'\n        '404':\n          $ref: '#/components/responses/library404'\n  /api/libraries/{id}/issues:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the library.\n        required: true\n        schema:\n          $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n    delete:\n      operationId: deleteLibraryIssues\n      summary: Delete items with issues in a library.\n      description: Delete all items with issues in a library by library ID on the server. This only removes the items from the ABS database and does not delete media files.\n      tags:\n        - Libraries\n      responses:\n        '200':\n          description: deleteLibraryIssues OK\n          content:\n            application/json:\n              schema:\n                type: string\n                example: 'Issues deleted.'\n        '404':\n          $ref: '#/components/responses/library404'\n  /api/libraries/{id}/items:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the library.\n        required: true\n        schema:\n          $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n    get:\n      operationId: getLibraryItems\n      summary: Get items in a library\n      description: Get items in a library by ID on server.\n      tags:\n        - Libraries\n      parameters:\n        - $ref: '#/components/parameters/limit'\n        - $ref: '#/components/parameters/page'\n        - in: query\n          name: sort\n          description: The field to sort by from the request.\n          example: 'numBooks'\n          schema:\n            type: string\n            default: 'name'\n        - $ref: '#/components/parameters/desc'\n        - in: query\n          name: filter\n          description: The filter for the library.\n          example: 'media.metadata.title'\n          schema:\n            type: string\n        - in: query\n          name: include\n          description: The fields to include in the response. The only current option is `rssfeed`.\n          allowReserved: true\n          example: 'rssfeed'\n          schema:\n            type: string\n        - $ref: '../schemas.yaml#/components/parameters/minified'\n        - in: query\n          name: collapseSeries\n          description: Whether to collapse series into a single cover\n          schema:\n            type: integer\n            default: 0\n      responses:\n        '200':\n          description: getLibraryItems OK\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  results:\n                    type: array\n                    items:\n                      $ref: '../objects/LibraryItem.yaml#/components/schemas/libraryItemBase'\n                  total:\n                    $ref: '../schemas.yaml#/components/schemas/total'\n                  limit:\n                    $ref: '../schemas.yaml#/components/schemas/limit'\n                  page:\n                    $ref: '../schemas.yaml#/components/schemas/page'\n                  sortBy:\n                    $ref: '#/components/schemas/sortBy'\n                  sortDesc:\n                    $ref: '#/components/schemas/sortDesc'\n                  filterBy:\n                    $ref: '#/components/schemas/filterBy'\n                  mediaType:\n                    $ref: '../objects/mediaTypes/media.yaml#/components/schemas/mediaType'\n                  minified:\n                    $ref: '../schemas.yaml#/components/schemas/minified'\n                  collapseSeries:\n                    $ref: '#/components/schemas/collapseSeries'\n                  include:\n                    $ref: '#/components/schemas/libraryInclude'\n        '404':\n          $ref: '#/components/responses/library404'\n  /api/libraries/{id}/authors:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the library.\n        required: true\n        schema:\n          $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n    get:\n      operationId: getLibraryAuthors\n      summary: Get all authors in a library\n      description: Get all authors in a library by ID on server.\n      tags:\n        - Libraries\n      responses:\n        '200':\n          description: getLibraryAuthors OK\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  authors:\n                    type: array\n                    items:\n                      $ref: '../objects/entities/Author.yaml#/components/schemas/authorExpanded'\n        '404':\n          $ref: '#/components/responses/library404'\n  /api/libraries/{id}/series:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the library.\n        required: true\n        schema:\n          $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n    get:\n      operationId: getLibrarySeries\n      summary: Get library series\n      description: Get series in a library. Filtering and sorting can be applied.\n      tags:\n        - Libraries\n      parameters:\n        - $ref: '#/components/parameters/limit'\n        - $ref: '#/components/parameters/page'\n        - in: query\n          name: sort\n          description: The field to sort by from the request.\n          example: 'numBooks'\n          schema:\n            type: string\n            enum: ['name', 'numBooks', 'totalDuration', 'addedAt', 'lastBookAdded', 'lastBookUpdated']\n            default: 'name'\n        - $ref: '#/components/parameters/desc'\n        - in: query\n          name: filter\n          description: The filter for the library.\n          example: 'media.metadata.title'\n          schema:\n            type: string\n        - in: query\n          name: include\n          description: The fields to include in the response. The only current option is `rssfeed`.\n          allowReserved: true\n          example: 'rssfeed'\n          schema:\n            type: string\n        - $ref: '../schemas.yaml#/components/parameters/minified'\n      responses:\n        '200':\n          description: getLibrarySeries OK\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  results:\n                    type: array\n                    items:\n                      $ref: '../objects/entities/Series.yaml#/components/schemas/seriesBooks'\n                  total:\n                    $ref: '../schemas.yaml#/components/schemas/total'\n                  limit:\n                    $ref: '../schemas.yaml#/components/schemas/limit'\n                  page:\n                    $ref: '../schemas.yaml#/components/schemas/page'\n                  sortBy:\n                    $ref: '#/components/schemas/sortBy'\n                  sortDesc:\n                    $ref: '#/components/schemas/sortDesc'\n                  filterBy:\n                    $ref: '#/components/schemas/filterBy'\n                  minified:\n                    $ref: '../schemas.yaml#/components/schemas/minified'\n                  include:\n                    $ref: '#/components/schemas/libraryInclude'\n\n        '404':\n          $ref: '#/components/responses/library404'\n  /api/libraries/{id}/series/{seriesId}:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the library.\n        required: true\n        schema:\n          $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n      - name: seriesId\n        in: path\n        description: The ID of the series.\n        required: true\n        schema:\n          $ref: '../objects/entities/Series.yaml#/components/schemas/seriesId'\n    get:\n      operationId: getLibrarySeriesById\n      summary: Get single series in library\n      description: Get a single series in a library by ID on server. This endpoint is deprecated and `/api/series/{id}` should be used instead.\n      deprecated: true\n      tags:\n        - Libraries\n      parameters:\n        - $ref: '#/components/parameters/limit'\n        - $ref: '#/components/parameters/page'\n        - in: query\n          name: sort\n          description: The field to sort by from the request.\n          example: 'numBooks'\n          schema:\n            type: string\n            enum: ['name', 'numBooks', 'totalDuration', 'addedAt', 'lastBookAdded', 'lastBookUpdated']\n            default: 'name'\n        - $ref: '#/components/parameters/desc'\n        - in: query\n          name: filter\n          description: The filter for the library.\n          example: 'media.metadata.title'\n          schema:\n            type: string\n        - $ref: '../schemas.yaml#/components/parameters/minified'\n        - in: query\n          name: include\n          description: The fields to include in the response. The only current option is `rssfeed`.\n          allowReserved: true\n          example: 'rssfeed'\n          schema:\n            type: string\n      responses:\n        '200':\n          description: getLibrarySeriesById OK\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/entities/Series.yaml#/components/schemas/seriesWithProgressAndRSS'\n        '404':\n          $ref: '#/components/responses/library404'\n"
  },
  {
    "path": "docs/controllers/NotificationController.yaml",
    "content": "components:\n  responses:\n    notification200:\n      description: Notification endpoint success.\n      content:\n        text/html:\n          schema:\n            type: string\n            example: OK\n    notification404:\n      description: An admin user is required or notification with the given ID not found.\n      content:\n        text/html:\n          schema:\n            type: string\n            example: Series not found.\npaths:\n  /api/notifications:\n    get:\n      operationId: getNotifications\n      summary: Get notification settings\n      description: Get all Apprise notification events and notification settings for server.\n      tags:\n        - Notification\n      responses:\n        '200':\n          description: Success\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  data:\n                    type: object\n                    properties:\n                      events:\n                        type: array\n                        items:\n                          $ref: '../objects/Notification.yaml#/components/schemas/NotificationEvent'\n                  settings:\n                    $ref: '../objects/Notification.yaml#/components/schemas/NotificationSettings'\n        '404':\n          $ref: '#/components/responses/notification404'\n    patch:\n      operationId: configureNotificationSettings\n      summary: Update select notification settings\n      description: Update the URL, max failed attempts, and maximum notifications that can be queued for Apprise.\n      tags:\n        - Notification\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                appriseApiUrl:\n                  $ref: '../objects/Notification.yaml#/components/schemas/appriseApiUrl'\n                maxFailedAttempts:\n                  $ref: '../objects/Notification.yaml#/components/schemas/maxFailedAttempts'\n                maxNotificationQueue:\n                  $ref: '../objects/Notification.yaml#/components/schemas/maxNotificationQueue'\n      responses:\n        '200':\n          $ref: '#/components/responses/notification200'\n        '404':\n          $ref: '#/components/responses/notification404'\n    post:\n      operationId: createNotification\n      summary: Create notification settings\n      description: Create or update Notification settings.\n      tags:\n        - Notification\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                libraryId:\n                  $ref: '../objects/Library.yaml#/components/schemas/libraryIdNullable'\n                eventName:\n                  $ref: '../objects/Notification.yaml#/components/schemas/notificationEventName'\n                urls:\n                  $ref: '../objects/Notification.yaml#/components/schemas/urls'\n                titleTemplate:\n                  $ref: '../objects/Notification.yaml#/components/schemas/titleTemplate'\n                bodyTemplate:\n                  $ref: '../objects/Notification.yaml#/components/schemas/bodyTemplate'\n                enabled:\n                  $ref: '../objects/Notification.yaml#/components/schemas/enabled'\n                type:\n                  $ref: '../objects/Notification.yaml#/components/schemas/notificationType'\n              required:\n                - eventName\n                - urls\n                - titleTemplate\n                - bodyTemplate\n      responses:\n        '200':\n          description: Success\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  settings:\n                    $ref: '../objects/Notification.yaml#/components/schemas/NotificationSettings'\n        '404':\n          $ref: '#/components/responses/notification404'\n  /api/notificationdata:\n    get:\n      operationId: getNotificationEventData\n      summary: Get notification event data\n      description: Get all Apprise notification event data for the server.\n      tags:\n        - Notification\n      responses:\n        '200':\n          description: Success\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  events:\n                    type: array\n                    items:\n                      $ref: '../objects/Notification.yaml#/components/schemas/NotificationEvent'\n        '404':\n          $ref: '#/components/responses/notification404'\n  /api/notifications/test:\n    get:\n      operationId: sendDefaultTestNotification\n      summary: Send general test notification\n      description: Send a test notification.\n      tags:\n        - Notification\n      parameters:\n        - in: query\n          name: fail\n          description: Whether to intentionally cause the notification to fail. `0` for false, `1` for true.\n          schema:\n            type: integer\n      responses:\n        '200':\n          $ref: '#/components/responses/notification200'\n        '404':\n          $ref: '#/components/responses/notification404'\n  /api/notifications/{id}:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the notification.\n        required: true\n        schema:\n          $ref: '../objects/Notification.yaml#/components/schemas/notificationId'\n    delete:\n      operationId: deleteNotification\n      summary: Delete a notification\n      description: Delete the notification by ID and return the notification settings.\n      tags:\n        - Notification\n      responses:\n        '200':\n          description: Success\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  settings:\n                    $ref: '../objects/Notification.yaml#/components/schemas/NotificationSettings'\n        '404':\n          $ref: '#/components/responses/notification404'\n    patch:\n      operationId: updateNotification\n      summary: Update a notification\n      description: Update an individual Notification by ID\n      tags:\n        - Notification\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                libraryId:\n                  $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n                eventName:\n                  $ref: '../objects/Notification.yaml#/components/schemas/notificationEventName'\n                urls:\n                  $ref: '../objects/Notification.yaml#/components/schemas/urls'\n                titleTemplate:\n                  $ref: '../objects/Notification.yaml#/components/schemas/titleTemplate'\n                bodyTemplate:\n                  $ref: '../objects/Notification.yaml#/components/schemas/bodyTemplate'\n                enabled:\n                  $ref: '../objects/Notification.yaml#/components/schemas/enabled'\n                type:\n                  $ref: '../objects/Notification.yaml#/components/schemas/notificationType'\n      responses:\n        '200':\n          description: Success\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  settings:\n                    $ref: '../objects/Notification.yaml#/components/schemas/NotificationSettings'\n        '404':\n          $ref: '#/components/responses/notification404'\n  /api/notifications/{id}/test:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the notification.\n        required: true\n        schema:\n          $ref: '../objects/Notification.yaml#/components/schemas/notificationId'\n    get:\n      operationId: sendTestNotification\n      summary: Send a test notification\n      description: Send a test to the given notification by ID.\n      tags:\n        - Notification\n      responses:\n        '200':\n          $ref: '#/components/responses/notification200'\n        '404':\n          $ref: '#/components/responses/notification404'\n"
  },
  {
    "path": "docs/controllers/PodcastController.yaml",
    "content": "paths:\n  /api/podcasts:\n    post:\n      summary: Create a new podcast\n      operationId: createPodcast\n      tags:\n        - Podcasts\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'\n      responses:\n        200:\n          description: Successfully created a podcast\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'\n        400:\n          description: Bad request\n        403:\n          description: Forbidden\n        404:\n          description: Not found\n\n  /api/podcasts/feed:\n    post:\n      summary: Get podcast feed\n      operationId: getPodcastFeed\n      tags:\n        - Podcasts\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                rssFeed:\n                  type: string\n                  description: The RSS feed URL of the podcast\n      responses:\n        200:\n          description: Successfully retrieved podcast feed\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  podcast:\n                    $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'\n        400:\n          description: Bad request\n        403:\n          description: Forbidden\n        404:\n          description: Not found\n\n  /api/podcasts/opml/parse:\n    post:\n      summary: Get feeds from OPML text\n      description: Parse OPML text and return an array of feeds\n      operationId: getFeedsFromOPMLText\n      tags:\n        - Podcasts\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                opmlText:\n                  type: string\n      responses:\n        '200':\n          description: Successfully parsed OPML text and returned feeds\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  feeds:\n                    type: array\n                    items:\n                      type: object\n                      properties:\n                        title:\n                          type: string\n                        feedUrl:\n                          type: string\n        '400':\n          description: Bad request, OPML text not provided\n        '403':\n          description: Forbidden, user is not admin\n  /api/podcasts/opml/create:\n    post:\n      summary: Bulk create podcasts from OPML feed URLs\n      operationId: bulkCreatePodcastsFromOpmlFeedUrls\n      tags:\n        - Podcasts\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                feeds:\n                  type: array\n                  items:\n                    type: string\n                libraryId:\n                  $ref: '../objects/Library.yaml#/components/schemas/libraryId'\n                folderId:\n                  $ref: '../objects/Folder.yaml#/components/schemas/folderId'\n                autoDownloadEpisodes:\n                  $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/autoDownloadEpisodes'\n      responses:\n        '200':\n          description: Successfully created podcasts from feed URLs\n        '400':\n          description: Bad request, invalid request body\n        '403':\n          description: Forbidden, user is not admin\n        '404':\n          description: Folder not found\n\n  /api/podcasts/{id}/checknew:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    get:\n      summary: Check and download new episodes\n      operationId: checkNewEpisodes\n      tags:\n        - Podcasts\n      parameters:\n        - name: limit\n          in: query\n          description: Maximum number of episodes to download\n          required: false\n          schema:\n            type: integer\n      responses:\n        200:\n          description: Successfully checked and downloaded new episodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  episodes:\n                    type: array\n                    items:\n                      $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'\n        403:\n          description: Forbidden\n        404:\n          description: Not found\n        500:\n          description: Server error\n\n  /api/podcasts/{id}/clear-queue:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    get:\n      summary: Clear episode download queue\n      operationId: clearEpisodeDownloadQueue\n      tags:\n        - Podcasts\n      responses:\n        200:\n          description: Successfully cleared download queue\n        403:\n          description: Forbidden\n\n  /api/podcasts/{id}/downloads:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    get:\n      summary: Get episode downloads\n      operationId: getEpisodeDownloads\n      tags:\n        - Podcasts\n      responses:\n        200:\n          description: Successfully retrieved episode downloads\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  downloads:\n                    type: array\n                    items:\n                      $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'\n        404:\n          description: Not found\n\n  /api/podcasts/{id}/search-episode:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    get:\n      summary: Find episode by title\n      operationId: findEpisode\n      tags:\n        - Podcasts\n      parameters:\n        - name: title\n          in: query\n          description: Title of the episode to search for\n          required: true\n          schema:\n            type: string\n      responses:\n        200:\n          description: Successfully found episodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  episodes:\n                    type: array\n                    items:\n                      $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'\n        404:\n          description: Not found\n        500:\n          description: Server error\n\n  /api/podcasts/{id}/download-episodes:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    post:\n      summary: Download podcast episodes\n      operationId: downloadEpisodes\n      tags:\n        - Podcasts\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: array\n              items:\n                type: string\n      responses:\n        200:\n          description: Successfully started episode download\n        400:\n          description: Bad request\n        403:\n          description: Forbidden\n\n  /api/podcasts/{id}/match-episodes:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    post:\n      summary: Quick match podcast episodes\n      operationId: quickMatchEpisodes\n      tags:\n        - Podcasts\n      parameters:\n        - name: override\n          in: query\n          description: Override existing details if set to 1\n          required: false\n          schema:\n            type: string\n      responses:\n        200:\n          description: Successfully matched episodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  numEpisodesUpdated:\n                    type: integer\n        403:\n          description: Forbidden\n\n  /api/podcasts/{id}/episode/{episodeId}:\n    parameters:\n      - name: id\n        in: path\n        description: Podcast ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n      - name: episodeId\n        in: path\n        description: Episode ID\n        required: true\n        schema:\n          $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n\n    patch:\n      summary: Update a podcast episode\n      operationId: updateEpisode\n      tags:\n        - Podcasts\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n      responses:\n        200:\n          description: Successfully updated episode\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'\n        404:\n          description: Not found\n\n    get:\n      summary: Get a specific podcast episode\n      operationId: getEpisode\n      tags:\n        - Podcasts\n      responses:\n        200:\n          description: Successfully retrieved episode\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'\n        404:\n          description: Not found\n\n    delete:\n      summary: Remove a podcast episode\n      operationId: removeEpisode\n      tags:\n        - Podcasts\n      parameters:\n        - name: hard\n          in: query\n          description: Hard delete the episode if set to 1\n          required: false\n          schema:\n            type: string\n      responses:\n        200:\n          description: Successfully removed episode\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'\n        404:\n          description: Not found\n        500:\n          description: Server error\n"
  },
  {
    "path": "docs/controllers/SeriesController.yaml",
    "content": "components:\n  responses:\n    series404:\n      description: Series not found.\n      content:\n        text/html:\n          schema:\n            type: string\n            example: Series not found.\npaths:\n  /api/series/{id}:\n    parameters:\n      - name: id\n        in: path\n        description: The ID of the series.\n        required: true\n        schema:\n          $ref: '../objects/entities/Series.yaml#/components/schemas/seriesId'\n    get:\n      operationId: getSeries\n      tags:\n        - Series\n      summary: Get series\n      description: Get a series by ID.\n      requestBody:\n        required: false\n        description: A comma separated list of what to include with the series.\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                include:\n                  type: string\n                  description: A comma separated list of what to include with the series.\n                  example: 'progress,rssfeed'\n                  enum: ['progress', 'rssfeed', 'progress,rssfeed', 'rssfeed,progress']\n      responses:\n        '200':\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/entities/Series.yaml#/components/schemas/seriesWithProgressAndRSS'\n        '404':\n          $ref: '#/components/responses/series404'\n    patch:\n      operationId: updateSeries\n      tags:\n        - Series\n      summary: Update series\n      description: Update a series by ID.\n      requestBody:\n        required: true\n        description: The series to update.\n        content:\n          application/json:\n            schema:\n              properties:\n                name:\n                  $ref: '../objects/entities/Series.yaml#/components/schemas/seriesName'\n                description:\n                  $ref: '../objects/entities/Series.yaml#/components/schemas/seriesDescription'\n      responses:\n        '200':\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: '../objects/entities/Series.yaml#/components/schemas/series'\n        '404':\n          $ref: '#/components/responses/series404'\n"
  },
  {
    "path": "docs/objects/Folder.yaml",
    "content": "components:\n  schemas:\n    folderId:\n      type: string\n      description: The ID of the folder.\n      format: uuid\n      example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b\n    folder:\n      type: object\n      description: Folder used in library\n      properties:\n        id:\n          $ref: '#/components/schemas/folderId'\n        fullPath:\n          description: The path on the server for the folder. (Read Only)\n          type: string\n          example: /podcasts\n        libraryId:\n          $ref: './Library.yaml#/components/schemas/libraryId'\n        addedAt:\n          $ref: '../schemas.yaml#/components/schemas/addedAt'\n"
  },
  {
    "path": "docs/objects/Library.yaml",
    "content": "components:\n  schemas:\n    oldLibraryId:\n      type: string\n      description: The ID of the libraries created on server version 2.2.23 and before.\n      format: 'lib_[a-z0-9]{18}'\n      example: 'lib_o78uaoeuh78h6aoeif'\n    libraryId:\n      type: string\n      description: The ID of the library.\n      format: uuid\n      example: 'e4bb1afb-4a4f-4dd6-8be0-e615d233185b'\n    libraryIdNullable:\n      type: string\n      description: The ID of the library. Applies to all libraries if `null`.\n      format: uuid\n      nullable: true\n      example: 'e4bb1afb-4a4f-4dd6-8be0-e615d233185b'\n    libraryName:\n      description: The name of the library.\n      type: string\n      example: My Audiobooks\n    librarySettings:\n      description: The settings for the library.\n      type: object\n      properties:\n        coverAspectRatio:\n          description: Whether the library should use square book covers. Must be 0 (for false) or 1 (for true).\n          type: integer\n          example: 1\n        disableWatcher:\n          description: Whether to disable the folder watcher for the library.\n          type: boolean\n          example: false\n        skipMatchingMediaWithAsin:\n          description: Whether to skip matching books that already have an ASIN.\n          type: boolean\n          example: false\n        skipMatchingMediaWithIsbn:\n          description: Whether to skip matching books that already have an ISBN.\n          type: boolean\n          example: false\n        autoScanCronExpression:\n          description: The cron expression for when to automatically scan the library folders. If null, automatic scanning will be disabled.\n          type: string\n          nullable: true\n          example: '0 0 0 * * *'\n        audiobooksOnly:\n          description: Whether the library should ignore ebook files and only allow ebook files to be supplementary.\n          type: boolean\n          example: false\n        hideSingleBookSeries:\n          description: Whether to hide series with only one book.\n          type: boolean\n          example: false\n        onlyShowLaterBooksInContinueSeries:\n          description: Whether to only show books in a series after the highest series sequence.\n          type: boolean\n          example: false\n        metadataPrecedence:\n          description: The precedence of metadata sources. See Metadata Providers for a list of possible providers.\n          type: array\n          items:\n            type: string\n          example: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']\n        podcastSearchRegion:\n          description: The region to use when searching for podcasts.\n          type: string\n          example: 'us'\n    library:\n      description: A library object which includes either books or podcasts.\n      type: object\n      properties:\n        id:\n          $ref: '#/components/schemas/libraryId'\n        name:\n          $ref: '#/components/schemas/libraryName'\n        folders:\n          description: The folders that belong to the library.\n          type: array\n          items:\n            $ref: './Folder.yaml#/components/schemas/folder'\n        displayOrder:\n          description: Display position of the library in the list of libraries. Must be >= 1.\n          type: integer\n          example: 1\n        icon:\n          description: The selected icon for the library. See Library Icons for a list of possible icons.\n          type: string\n          example: 'audiobookshelf'\n        mediaType:\n          description: The type of media that the library contains. Will be `book` or `podcast`. (Read Only)\n          type: string\n          example: 'book'\n        provider:\n          description: Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.\n          type: string\n          example: 'audible'\n        settings:\n          $ref: '#/components/schemas/librarySettings'\n        createdAt:\n          $ref: '../schemas.yaml#/components/schemas/createdAt'\n        lastUpdate:\n          $ref: '../schemas.yaml#/components/schemas/updatedAt'\n"
  },
  {
    "path": "docs/objects/LibraryItem.yaml",
    "content": "components:\n  schemas:\n    oldLibraryItemId:\n      description: The ID of library items on server version 2.2.23 and before.\n      type: string\n      nullable: true\n      format: 'li_[a-z0-9]{18}'\n      example: li_o78uaoeuh78h6aoeif\n    libraryItemId:\n      type: string\n      description: The ID of library items after 2.3.0.\n      format: uuid\n      example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b\n    libraryItemBase:\n      type: object\n      description: Base library item schema\n      properties:\n        id:\n          $ref: '#/components/schemas/libraryItemId'\n        oldLibraryItemId:\n          $ref: '#/components/schemas/oldLibraryItemId'\n        ino:\n          $ref: '../schemas.yaml#/components/schemas/inode'\n        libraryId:\n          $ref: './Library.yaml#/components/schemas/libraryId'\n        folderId:\n          $ref: './Folder.yaml#/components/schemas/folderId'\n        path:\n          description: The path of the library item on the server.\n          type: string\n        relPath:\n          description: The path, relative to the library folder, of the library item.\n          type: string\n        isFile:\n          description: Whether the library item is a single file in the root of the library folder.\n          type: boolean\n        mtimeMs:\n          description: The time (in ms since POSIX epoch) when the library item was last modified on disk.\n          type: integer\n        ctimeMs:\n          description: The time (in ms since POSIX epoch) when the library item status was changed on disk.\n          type: integer\n        birthtimeMs:\n          description: The time (in ms since POSIX epoch) when the library item was created on disk. Will be 0 if unknown.\n          type: integer\n        addedAt:\n          $ref: '../schemas.yaml#/components/schemas/addedAt'\n        updatedAt:\n          $ref: '../schemas.yaml#/components/schemas/updatedAt'\n        isMissing:\n          description: Whether the library item was scanned and no longer exists.\n          type: boolean\n        isInvalid:\n          description: Whether the library item was scanned and no longer has media files.\n          type: boolean\n        mediaType:\n          $ref: './mediaTypes/media.yaml#/components/schemas/mediaType'\n    libraryItemMinified:\n      type: object\n      description: A single item on the server, like a book or podcast. Minified media format.\n      allOf:\n        - $ref: '#/components/schemas/libraryItemBase'\n        - type: object\n          properties:\n            media:\n              $ref: './mediaTypes/media.yaml#/components/schemas/mediaMinified'\n    libraryItemSequence:\n      type: object\n      description: A single item on the server, like a book or podcast. Includes series sequence information.\n      allOf:\n        - $ref: '#/components/schemas/libraryItemBase'\n        - type: object\n          properties:\n            sequence:\n              $ref: './entities/Series.yaml#/components/schemas/sequence'\n"
  },
  {
    "path": "docs/objects/Notification.yaml",
    "content": "components:\n  schemas:\n    notificationId:\n      type: string\n      description: The ID of the notification.\n      example: notification-settings\n      # This is using a value of `notification-settings`, not a UUID. Need to investigate\n      #format: uuid\n      #example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b\n    appriseApiUrl:\n      type: string\n      nullable: true\n      description: The full URL where the Apprise API to use is located.\n    maxFailedAttempts:\n      type: integer\n      minimum: 0\n      default: 5\n      description: The maximum number of times a notification fails before being disabled.\n    maxNotificationQueue:\n      type: integer\n      description: The maximum number of notifications in the notification queue before events are ignored.\n    notificationEventName:\n      type: string\n      description: The name of the event the notification will fire on.\n      enum: ['onPodcastEpisodeDownloaded', 'onBackupCompleted', 'onBackupFailed', 'onTest']\n    urls:\n      type: array\n      items:\n        type: string\n      description: The Apprise URLs to use for the notification.\n      example: http://192.168.0.3:8000/notify/my-cool-notification\n    titleTemplate:\n      type: string\n      description: The template for the notification title.\n      example: 'New {{podcastTitle}} Episode!'\n    bodyTemplate:\n      type: string\n      description: The template for the notification body.\n      example: '{{episodeTitle}} has been added to {{libraryName}} library.'\n    enabled:\n      type: boolean\n      default: false\n      description: Whether the notification is enabled.\n    notificationType:\n      type: string\n      enum: ['info', 'success', 'warning', 'failure']\n      nullable: true\n      default: 'info'\n      description: The notification's type.\n    Notification:\n      type: object\n      properties:\n        id:\n          $ref: '#/components/schemas/notificationId'\n        libraryId:\n          $ref: './Library.yaml#/components/schemas/libraryIdNullable'\n        eventName:\n          $ref: '#/components/schemas/notificationEventName'\n        urls:\n          $ref: '#/components/schemas/urls'\n        titleTemplate:\n          $ref: '#/components/schemas/titleTemplate'\n        bodyTemplate:\n          $ref: '#/components/schemas/bodyTemplate'\n        enabled:\n          $ref: '#/components/schemas/enabled'\n        type:\n          $ref: '#/components/schemas/notificationType'\n        lastFiredAt:\n          type: integer\n          nullable: true\n          description: The time (in ms since POSIX epoch) when the notification was last fired. Will be null if the notification has not fired.\n        lastAttemptFailed:\n          type: boolean\n          description: Whether the last notification attempt failed.\n        numConsecutiveFailedAttempts:\n          type: integer\n          description: The number of consecutive times the notification has failed.\n          default: 0\n        numTimesFired:\n          type: integer\n          description: The number of times the notification has fired.\n          default: 0\n        createdAt:\n          $ref: '../schemas.yaml#/components/schemas/createdAt'\n    NotificationEvent:\n      type: object\n      properties:\n        name:\n          type: string\n          description: The name of the notification event. The names and allowable values are defined at https://github.com/advplyr/audiobookshelf/blob/master/server/utils/notifications.js\n        requiresLibrary:\n          type: boolean\n          description: Whether the notification event depends on a library existing.\n        libraryMediaType:\n          type: string\n          description: The type of media of the library the notification depends on existing. Will not exist if requiresLibrary is false.\n          nullable: true\n        description:\n          type: string\n          description: The description of the notification event.\n        variables:\n          type: array\n          items:\n            type: string\n          description: The variables of the notification event that can be used in the notification templates.\n        defaults:\n          type: object\n          properties:\n            title:\n              type: string\n              description: The default title template for notifications using the notification event.\n            body:\n              type: string\n              description: The default body template for notifications using the notification event.\n        testData:\n          type: object\n          description: The keys of the testData object will match the list of variables. The values will be the data used when sending a test notification.\n          additionalProperties:\n            type: string\n    NotificationSettings:\n      type: object\n      properties:\n        id:\n          $ref: '#/components/schemas/notificationId'\n        appriseType:\n          type: string\n          description: The type of Apprise that will be used. At the moment, only api is available.\n        appriseApiUrl:\n          $ref: '#/components/schemas/appriseApiUrl'\n        notifications:\n          type: array\n          items:\n            $ref: '#/components/schemas/Notification'\n          description: The set notifications.\n        maxFailedAttempts:\n          $ref: '#/components/schemas/maxFailedAttempts'\n        maxNotificationQueue:\n          $ref: '#/components/schemas/maxNotificationQueue'\n        notificationDelay:\n          type: integer\n          description: The time (in ms) between notification pushes.\n"
  },
  {
    "path": "docs/objects/entities/Author.yaml",
    "content": "components:\n  schemas:\n    authorId:\n      type: string\n      description: The ID of the author.\n      format: uuid\n      example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b\n    authorAsin:\n      description: The Audible identifier (ASIN) of the author. Will be null if unknown. Not the Amazon identifier.\n      type: string\n      nullable: true\n      example: B000APZOQA\n    authorName:\n      description: The name of the author.\n      type: string\n      example: Terry Goodkind\n    authorDescription:\n      description: The new description of the author.\n      type: string\n      nullable: true\n      example: Terry Goodkind is a #1 New York Times Bestselling Author and creator of the critically acclaimed masterwork, ‘The Sword of Truth’. He has written 30+ major, bestselling novels, has been published in more than 20 languages world-wide, and has sold more than 26 Million books. ‘The Sword of Truth’ is a revered literary tour de force, comprised of 17 volumes, borne from over 25 years of dedicated writing.\n    authorImagePath:\n      description: The absolute path for the author image. This will be in the `metadata/` directory. Will be null if there is no image.\n      type: string\n      nullable: true\n      example: /metadata/authors/aut_z3leimgybl7uf3y4ab.jpg\n    authorSeries:\n      type: object\n      description: Series and the included library items that an author has written.\n      properties:\n        id:\n          $ref: './Series.yaml#/components/schemas/seriesId'\n        name:\n          $ref: './Series.yaml#/components/schemas/seriesName'\n        items:\n          description: The items in the series. Each library item's media's metadata will have a `series` attribute, a `Series Sequence`, which is the matching series.\n          type: array\n          items:\n            $ref: '../LibraryItem.yaml#/components/schemas/libraryItemMinified'\n    author:\n      description: An author object which includes a description and image path. The library items and series associated with the author are optionally included.\n      type: object\n      properties:\n        id:\n          $ref: '#/components/schemas/authorId'\n        asin:\n          $ref: '#/components/schemas/authorAsin'\n        name:\n          $ref: '#/components/schemas/authorName'\n        description:\n          $ref: '#/components/schemas/authorDescription'\n        imagePath:\n          $ref: '#/components/schemas/authorImagePath'\n        addedAt:\n          $ref: '../../schemas.yaml#/components/schemas/addedAt'\n        updatedAt:\n          $ref: '../../schemas.yaml#/components/schemas/updatedAt'\n        libraryItems:\n          description: The items associated with the author\n          type: array\n          items:\n            $ref: '../LibraryItem.yaml#/components/schemas/libraryItemMinified'\n        series:\n          description: The series associated with the author\n          type: array\n          items:\n            $ref: '#/components/schemas/authorSeries'\n    authorMinified:\n      type: object\n      description: Minified author object which only contains the author name and ID.\n      properties:\n        id:\n          $ref: '#/components/schemas/authorId'\n        name:\n          $ref: '#/components/schemas/authorName'\n    authorExpanded:\n      type: object\n      description: The author schema with the total number of books in the library.\n      allOf:\n        - $ref: '#/components/schemas/author'\n        - type: object\n          properties:\n            numBooks:\n              description: The number of books associated with the author in the library.\n              type: integer\n              example: 1\n"
  },
  {
    "path": "docs/objects/entities/PodcastEpisode.yaml",
    "content": "components:\n  schemas:\n    PodcastEpisode:\n      type: object\n      description: A single episode of a podcast.\n      properties:\n        libraryItemId:\n          $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'\n        podcastId:\n          $ref: '../mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n        id:\n          $ref: '../mediaTypes/Podcast.yaml#/components/schemas/podcastId'\n        oldEpisodeId:\n          $ref: '../mediaTypes/Podcast.yaml#/components/schemas/oldPodcastId'\n        index:\n          type: integer\n          description: The index of the episode within the podcast.\n          nullable: true\n        season:\n          type: string\n          description: The season number of the episode.\n          nullable: true\n        episode:\n          type: string\n          description: The episode number within the season.\n          nullable: true\n        episodeType:\n          type: string\n          description: The type of episode (e.g., full, trailer).\n          nullable: true\n        title:\n          type: string\n          description: The title of the episode.\n          nullable: true\n        subtitle:\n          type: string\n          description: The subtitle of the episode.\n          nullable: true\n        description:\n          type: string\n          description: The description of the episode.\n          nullable: true\n        enclosure:\n          type: object\n          description: The enclosure object containing additional episode data.\n          nullable: true\n          additionalProperties: true\n        guid:\n          type: string\n          description: The globally unique identifier for the episode.\n          nullable: true\n        pubDate:\n          type: string\n          description: The publication date of the episode.\n          nullable: true\n        chapters:\n          type: array\n          description: The chapters within the episode.\n          items:\n            type: object\n        audioFile:\n          $ref: '../files/AudioFile.yaml#/components/schemas/audioFile'\n        publishedAt:\n          $ref: '../../schemas.yaml#/components/schemas/createdAt'\n        addedAt:\n          $ref: '../../schemas.yaml#/components/schemas/addedAt'\n        updatedAt:\n          $ref: '../../schemas.yaml#/components/schemas/updatedAt'\n        audioTrack:\n          $ref: '../files/AudioTrack.yaml#/components/schemas/AudioTrack'\n        duration:\n          $ref: '../../schemas.yaml#/components/schemas/durationSec'\n        size:\n          $ref: '../../schemas.yaml#/components/schemas/size'\n"
  },
  {
    "path": "docs/objects/entities/Series.yaml",
    "content": "components:\n  schemas:\n    seriesId:\n      type: string\n      description: The ID of the series.\n      format: uuid\n      example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b\n    seriesName:\n      description: The name of the series.\n      type: string\n      example: Sword of Truth\n    seriesDescription:\n      description: A description for the series. Will be null if there is none.\n      type: string\n      nullable: true\n      example: The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind.\n    sequence:\n      description: The position in the series the book is.\n      type: string\n      nullable: true\n    seriesProgress:\n      type: object\n      description: The user's progress of a series.\n      properties:\n        libraryItemIds:\n          description: The IDs of the library items in the series.\n          type: array\n          items:\n            $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'\n        libraryItemIdsFinished:\n          description: The IDs of the library items in the series that are finished.\n          type: array\n          items:\n            $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'\n        isFinished:\n          description: Whether the series is finished.\n          type: boolean\n    series:\n      type: object\n      description: A series object which includes the name and description of the series.\n      properties:\n        id:\n          $ref: '#/components/schemas/seriesId'\n        name:\n          $ref: '#/components/schemas/seriesName'\n        description:\n          $ref: '#/components/schemas/seriesDescription'\n        addedAt:\n          $ref: '../../schemas.yaml#/components/schemas/addedAt'\n        updatedAt:\n          $ref: '../../schemas.yaml#/components/schemas/updatedAt'\n    seriesNumBooks:\n      type: object\n      description: A series object which includes the name and number of books in the series.\n      properties:\n        id:\n          $ref: '#/components/schemas/seriesId'\n        name:\n          $ref: '#/components/schemas/seriesName'\n        numBooks:\n          description: The number of books in the series.\n          type: integer\n        libraryItemIds:\n          description: The IDs of the library items in the series.\n          type: array\n          items:\n            $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'\n    seriesBooks:\n      type: object\n      description: A series object which includes the name and books in the series.\n      properties:\n        id:\n          $ref: '#/components/schemas/seriesId'\n        name:\n          $ref: '#/components/schemas/seriesName'\n        addedAt:\n          $ref: '../../schemas.yaml#/components/schemas/addedAt'\n        nameIgnorePrefix:\n          description: The name of the series with any prefix moved to the end.\n          type: string\n        nameIgnorePrefixSort:\n          description: The name of the series with any prefix removed.\n          type: string\n        type:\n          description: Will always be `series`.\n          type: string\n        books:\n          description: The library items that contain the books in the series. A sequence attribute that denotes the position in the series the book is in, is tacked on.\n          type: array\n          items:\n            $ref: '../LibraryItem.yaml#/components/schemas/libraryItemSequence'\n        totalDuration:\n          description: The combined duration (in seconds) of all books in the series.\n          type: number\n    seriesSequence:\n      type: object\n      description: A series object which includes the name and sequence of the series.\n      properties:\n        id:\n          $ref: '#/components/schemas/seriesId'\n        name:\n          $ref: '#/components/schemas/seriesName'\n        sequence:\n          $ref: '#/components/schemas/sequence'\n    seriesWithProgressAndRSS:\n      type: object\n      description: A series object which includes the name and progress of the series.\n      oneOf:\n        - $ref: '#/components/schemas/series'\n        - type: object\n          properties:\n            progress:\n              $ref: '#/components/schemas/seriesProgress'\n            rssFeed:\n              description: The RSS feed for the series.\n              type: string\n              example: 'TBD'\n"
  },
  {
    "path": "docs/objects/files/AudioFile.yaml",
    "content": "components:\n  schemas:\n    audioFile:\n      type: object\n      description: An audio file for a book. Includes audio metadata and track numbers.\n      properties:\n        index:\n          description: The index of the audio file.\n          type: integer\n          example: 1\n        ino:\n          $ref: '../../schemas.yaml#/components/schemas/inode'\n        metadata:\n          $ref: '../metadata/FileMetadata.yaml#/components/schemas/fileMetadata'\n        addedAt:\n          $ref: '../../schemas.yaml#/components/schemas/addedAt'\n        updatedAt:\n          $ref: '../../schemas.yaml#/components/schemas/updatedAt'\n        trackNumFromMeta:\n          description: The track number of the audio file as pulled from the file's metadata. Will be null if unknown.\n          type: integer\n          nullable: true\n          example: 1\n        discNumFromMeta:\n          description: The disc number of the audio file as pulled from the file's metadata. Will be null if unknown.\n          type: string\n          nullable: true\n        trackNumFromFilename:\n          description: The track number of the audio file as determined from the file's name. Will be null if unknown.\n          type: integer\n          nullable: true\n          example: 1\n        discNumFromFilename:\n          description: The disc number of the audio file as determined from the file's name. Will be null if unknown.\n          type: string\n          nullable: true\n        manuallyVerified:\n          description: Whether the audio file has been manually verified by a user.\n          type: boolean\n        invalid:\n          description: Whether the audio file is missing from the server.\n          type: boolean\n        exclude:\n          description: Whether the audio file has been marked for exclusion.\n          type: boolean\n        error:\n          description: Any error with the audio file. Will be null if there is none.\n          type: string\n          nullable: true\n        format:\n          description: The format of the audio file.\n          type: string\n          example: MP2/3 (MPEG audio layer 2/3)\n        duration:\n          $ref: '../../schemas.yaml#/components/schemas/durationSec'\n        bitRate:\n          description: The bit rate (in bit/s) of the audio file.\n          type: integer\n          example: 64000\n        language:\n          description: The language of the audio file.\n          type: string\n          nullable: true\n        codec:\n          description: The codec of the audio file.\n          type: string\n          example: mp3\n        timeBase:\n          description: The time base of the audio file.\n          type: string\n          example: 1/14112000\n        channels:\n          description: The number of channels the audio file has.\n          type: integer\n          example: 2\n        channelLayout:\n          description: The layout of the audio file's channels.\n          type: string\n          example: stereo\n        chapters:\n          description: If the audio file is part of an audiobook, the chapters the file contains.\n          type: array\n          items:\n            $ref: '../metadata/BookMetadata.yaml#/components/schemas/bookChapter'\n        embeddedCoverArt:\n          description: The type of embedded cover art in the audio file. Will be null if none exists.\n          type: string\n          nullable: true\n        metaTags:\n          $ref: '../metadata/AudioMetaTags.yaml#/components/schemas/audioMetaTags'\n        mimeType:\n          description: The MIME type of the audio file.\n          type: string\n          example: audio/mpeg\n"
  },
  {
    "path": "docs/objects/files/AudioTrack.yaml",
    "content": "components:\n  schemas:\n    AudioTrack:\n      type: object\n      description: Represents an audio track with various properties.\n      properties:\n        index:\n          type: integer\n          nullable: true\n          description: The index of the audio track.\n          example: null\n        startOffset:\n          type: number\n          format: float\n          nullable: true\n          description: The start offset of the audio track in seconds.\n          example: null\n        duration:\n          type: number\n          format: float\n          nullable: true\n          description: The duration of the audio track in seconds.\n          example: null\n        title:\n          type: string\n          nullable: true\n          description: The title of the audio track.\n          example: null\n        contentUrl:\n          type: string\n          nullable: true\n          description: The URL where the audio track content is located.\n          example: '`/api/items/${itemId}/file/${audioFile.ino}`'\n        mimeType:\n          type: string\n          nullable: true\n          description: The MIME type of the audio track.\n          example: null\n        codec:\n          type: string\n          nullable: true\n          description: The codec used for the audio track.\n          example: aac\n        metadata:\n          $ref: '../metadata/FileMetadata.yaml#/components/schemas/fileMetadata'\n"
  },
  {
    "path": "docs/objects/files/EBookFile.yaml",
    "content": "components:\n  schemas:\n    ebookFile:\n      type: object\n      properties:\n        ino:\n          $ref: '../../schemas.yaml#/components/schemas/inode'\n        metadata:\n          $ref: '../metadata/FileMetadata.yaml#/components/schemas/fileMetadata'\n        ebookFormat:\n          description: The ebook format of the ebook file.\n          type: string\n          example: epub\n        addedAt:\n          $ref: '../../schemas.yaml#/components/schemas/addedAt'\n        updatedAt:\n          $ref: '../../schemas.yaml#/components/schemas/updatedAt'\n"
  },
  {
    "path": "docs/objects/mediaTypes/Book.yaml",
    "content": "components:\n  schemas:\n    bookCoverPath:\n      description: The absolute path on the server of the cover file. Will be null if there is no cover.\n      type: string\n      nullable: true\n      example: /audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/cover.jpg\n    bookBase:\n      type: object\n      description: Base book schema\n      properties:\n        libraryItemId:\n          $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'\n        coverPath:\n          $ref: '#/components/schemas/bookCoverPath'\n        tags:\n          $ref: '../../schemas.yaml#/components/schemas/tags'\n        audioFiles:\n          type: array\n          items:\n            $ref: '../files/AudioFile.yaml#/components/schemas/audioFile'\n        chapters:\n          type: array\n          items:\n            $ref: '../metadata/BookMetadata.yaml#/components/schemas/bookChapter'\n        missingParts:\n          description: Any parts missing from the book by track index.\n          type: array\n          items:\n            type: integer\n        ebookFile:\n          $ref: '../files/EBookFile.yaml#/components/schemas/ebookFile'\n    bookMinified:\n      type: object\n      description: Minified book schema. Does not depend on `bookBase` because there's pretty much no overlap.\n      properties:\n        metadata:\n          $ref: '../metadata/BookMetadata.yaml#/components/schemas/bookMetadataMinified'\n        coverPath:\n          $ref: '#/components/schemas/bookCoverPath'\n        tags:\n          $ref: '../../schemas.yaml#/components/schemas/tags'\n        numTracks:\n          description: The number of tracks the book's audio files have.\n          type: integer\n          example: 1\n        numAudioFiles:\n          description: The number of audio files the book has.\n          type: integer\n          example: 1\n        numChapters:\n          description: The number of chapters the book has.\n          type: integer\n          example: 1\n        numMissingParts:\n          description: The total number of missing parts the book has.\n          type: integer\n          example: 0\n        numInvalidAudioFiles:\n          description: The number of invalid audio files the book has.\n          type: integer\n          example: 0\n        duration:\n          $ref: '../../schemas.yaml#/components/schemas/durationSec'\n        size:\n          $ref: '../../schemas.yaml#/components/schemas/size'\n        ebookFormat:\n          description: The format of ebook of the book. Will be null if the book is an audiobook.\n          type: string\n          nullable: true\n"
  },
  {
    "path": "docs/objects/mediaTypes/Podcast.yaml",
    "content": "components:\n  schemas:\n    podcastId:\n      type: string\n      description: The ID of podcasts and podcast episodes after 2.3.0.\n      format: uuid\n      example: e4bb1afb-4a4f-4dd6-8be0-e615d233185b\n    oldPodcastId:\n      description: The ID of podcasts on server version 2.2.23 and before.\n      type: string\n      nullable: true\n      format: 'pod_[a-z0-9]{18}'\n      example: pod_o78uaoeuh78h6aoeif\n    autoDownloadEpisodes:\n      type: boolean\n      description: Whether episodes are automatically downloaded.\n\n    Podcast:\n      type: object\n      description: A podcast containing multiple episodes.\n      properties:\n        id:\n          $ref: '#/components/schemas/podcastId'\n        libraryItemId:\n          $ref: '../LibraryItem.yaml#/components/schemas/libraryItemId'\n        metadata:\n          $ref: '../metadata/PodcastMetadata.yaml#/components/schemas/PodcastMetadata'\n        coverPath:\n          type: string\n          description: The file path to the podcast's cover image.\n          nullable: true\n        tags:\n          type: array\n          description: The tags associated with the podcast.\n          items:\n            type: string\n        episodes:\n          type: array\n          description: The episodes of the podcast.\n          items:\n            $ref: '../entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'\n        autoDownloadEpisodes:\n          $ref: '#/components/schemas/autoDownloadEpisodes'\n        autoDownloadSchedule:\n          type: string\n          description: The schedule for automatic episode downloads, in cron format.\n          nullable: true\n        lastEpisodeCheck:\n          type: integer\n          description: The timestamp of the last episode check.\n        maxEpisodesToKeep:\n          type: integer\n          description: The maximum number of episodes to keep.\n        maxNewEpisodesToDownload:\n          type: integer\n          description: The maximum number of new episodes to download when automatically downloading epsiodes.\n        lastCoverSearch:\n          type: integer\n          description: The timestamp of the last cover search.\n          nullable: true\n        lastCoverSearchQuery:\n          type: string\n          description: The query used for the last cover search.\n          nullable: true\n        size:\n          type: integer\n          description: The total size of all episodes in bytes.\n        duration:\n          type: integer\n          description: The total duration of all episodes in seconds.\n        numTracks:\n          type: integer\n          description: The number of tracks (episodes) in the podcast.\n        latestEpisodePublished:\n          type: integer\n          description: The timestamp of the most recently published episode.\n"
  },
  {
    "path": "docs/objects/mediaTypes/media.yaml",
    "content": "components:\n  schemas:\n    mediaType:\n      type: string\n      description: The type of media, will be book or podcast.\n      enum: [book, podcast]\n    mediaMinified:\n      description: The minified media of the library item.\n      oneOf:\n        - $ref: './Book.yaml#/components/schemas/bookMinified'\n"
  },
  {
    "path": "docs/objects/metadata/AudioMetaTags.yaml",
    "content": "components:\n  schemas:\n    audioMetaTags:\n      description: ID3 metadata tags pulled from the audio file on import. Only non-null tags will be returned in requests.\n      type: object\n      properties:\n        tagAlbum:\n          type: string\n          nullable: true\n          example: SOT Bk01\n        tagArtist:\n          type: string\n          nullable: true\n          example: Terry Goodkind\n        tagGenre:\n          type: string\n          nullable: true\n          example: Audiobook Fantasy\n        tagTitle:\n          type: string\n          nullable: true\n          example: Wizards First Rule 01\n        tagSeries:\n          type: string\n          nullable: true\n        tagSeriesPart:\n          type: string\n          nullable: true\n        tagTrack:\n          type: string\n          nullable: true\n          example: 01/20\n        tagDisc:\n          type: string\n          nullable: true\n        tagSubtitle:\n          type: string\n          nullable: true\n        tagAlbumArtist:\n          type: string\n          nullable: true\n          example: Terry Goodkind\n        tagDate:\n          type: string\n          nullable: true\n        tagComposer:\n          type: string\n          nullable: true\n          example: Terry Goodkind\n        tagPublisher:\n          type: string\n          nullable: true\n        tagComment:\n          type: string\n          nullable: true\n        tagDescription:\n          type: string\n          nullable: true\n        tagEncoder:\n          type: string\n          nullable: true\n        tagEncodedBy:\n          type: string\n          nullable: true\n        tagIsbn:\n          type: string\n          nullable: true\n        tagLanguage:\n          type: string\n          nullable: true\n        tagASIN:\n          type: string\n          nullable: true\n        tagOverdriveMediaMarker:\n          type: string\n          nullable: true\n        tagOriginalYear:\n          type: string\n          nullable: true\n        tagReleaseCountry:\n          type: string\n          nullable: true\n        tagReleaseType:\n          type: string\n          nullable: true\n        tagReleaseStatus:\n          type: string\n          nullable: true\n        tagISRC:\n          type: string\n          nullable: true\n        tagMusicBrainzTrackId:\n          type: string\n          nullable: true\n        tagMusicBrainzAlbumId:\n          type: string\n          nullable: true\n        tagMusicBrainzAlbumArtistId:\n          type: string\n          nullable: true\n        tagMusicBrainzArtistId:\n          type: string\n          nullable: true\n"
  },
  {
    "path": "docs/objects/metadata/BookMetadata.yaml",
    "content": "components:\n  schemas:\n    narrators:\n      description: The narrators of the audiobook.\n      type: array\n      items:\n        type: string\n        example: Sam Tsoutsouvas\n    bookMetadataBase:\n      type: object\n      description: The base book metadata object for minified, normal, and extended schemas to inherit from.\n      properties:\n        title:\n          description: The title of the book. Will be null if unknown.\n          type: string\n          nullable: true\n          example: Wizards First Rule\n        subtitle:\n          description: The subtitle of the book. Will be null if there is no subtitle.\n          type: string\n          nullable: true\n        genres:\n          description: The genres of the book.\n          type: array\n          items:\n            type: string\n          example: [\"Fantasy\", \"Sci-Fi\", \"Nonfiction: History\"]\n        publishedYear:\n          description: The year the book was published. Will be null if unknown.\n          type: string\n          nullable: true\n          example: '2008'\n        publishedDate:\n          description: The date the book was published. Will be null if unknown.\n          type: string\n          nullable: true\n        publisher:\n          description: The publisher of the book. Will be null if unknown.\n          type: string\n          nullable: true\n          example: Brilliance Audio\n        description:\n          description: A description for the book. Will be null if empty.\n          type: string\n          nullable: true\n          example: >-\n              The masterpiece that started Terry Goodkind's New York Times bestselling\n              epic Sword of Truth In the aftermath of the brutal murder of his father,\n              a mysterious woman, Kahlan Amnell, appears in Richard Cypher's forest\n              sanctuary seeking help...and more. His world, his very beliefs, are\n              shattered when ancient debts come due with thundering violence. In a\n              dark age it takes courage to live, and more than mere courage to\n              challenge those who hold dominion, Richard and Kahlan must take up that\n              challenge or become the next victims. Beyond awaits a bewitching land\n              where even the best of their hearts could betray them. Yet, Richard\n              fears nothing so much as what secrets his sword might reveal about his\n              own soul. Falling in love would destroy them - for reasons Richard can't\n              imagine and Kahlan dare not say. In their darkest hour, hunted\n              relentlessly, tormented by treachery and loss, Kahlan calls upon Richard\n              to reach beyond his sword - to invoke within himself something more\n              noble. Neither knows that the rules of battle have just changed...or\n              that their time has run out. Wizard's First Rule is the beginning. One\n              book. One Rule. Witness the birth of a legend.\n        isbn:\n          description: The ISBN of the book. Will be null if unknown.\n          type: string\n          nullable: true\n        asin:\n          description: The ASIN of the book. Will be null if unknown.\n          type: string\n          nullable: true\n          example: B002V0QK4C\n        language:\n          description: The language of the book. Will be null if unknown.\n          type: string\n          nullable: true\n        explicit:\n          description: Whether the book has been marked as explicit.\n          type: boolean\n          example: false\n    bookMetadataMinified:\n      type: object\n      description: The minified metadata for a book in the database.\n      allOf:\n        - $ref : '#/components/schemas/bookMetadataBase'\n        - type: object\n          properties:\n            titleIgnorePrefix:\n              description: The title of the book with any prefix moved to the end.\n              type: string\n            authorName:\n              description: The name of the book's author(s).\n              type: string\n              example: Terry Goodkind\n            authorNameLF:\n              description: The name of the book's author(s) with last names first.\n              type: string\n              example: Goodkind, Terry\n            narratorName:\n              description: The name of the audiobook's narrator(s).\n              type: string\n              example: Sam Tsoutsouvas\n            seriesName:\n              description: The name of the book's series.\n              type: string\n              example: Sword of Truth\n    bookChapter:\n      type: object\n      description: A book chapter. Includes the title and timestamps.\n      properties:\n        id:\n          description: The ID of the book chapter.\n          type: integer\n          example: 0\n        start:\n          description: When in the book (in seconds) the chapter starts.\n          type: integer\n          example: 0\n        end:\n          description: When in the book (in seconds) the chapter ends.\n          type: number\n          example: 6004.6675\n        title:\n          description: The title of the chapter.\n          type: string\n          example: Wizards First Rule 01 Chapter 1\n"
  },
  {
    "path": "docs/objects/metadata/FileMetadata.yaml",
    "content": "components:\n  schemas:\n    fileMetadata:\n      type: object\n      description: The metadata for a file, including the path, size, and unix timestamps of the file.\n      nullable: true\n      properties:\n        filename:\n          description: The filename of the file.\n          type: string\n          example: Wizards First Rule 01.mp3\n        ext:\n          description: The file extension of the file.\n          type: string\n          example: .mp3\n        path:\n          description: The absolute path on the server of the file.\n          type: string\n          example: >-\n              /audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/Terry\n              Goodkind - SOT Bk01 - Wizards First Rule 01.mp3\n        relPath:\n          description: The path of the file, relative to the book's or podcast's folder.\n          type: string\n          example: Wizards First Rule 01.mp3\n        size:\n          $ref: '../../schemas.yaml#/components/schemas/size'\n        mtimeMs:\n          description: The time (in ms since POSIX epoch) when the file was last modified on disk.\n          type: integer\n          example: 1632223180278\n        ctimeMs:\n          description: The time (in ms since POSIX epoch) when the file status was changed on disk.\n          type: integer\n          example: 1645978261001\n        birthtimeMs:\n          description: The time (in ms since POSIX epoch) when the file was created on disk. Will be 0 if unknown.\n          type: integer\n          example: 0\n"
  },
  {
    "path": "docs/objects/metadata/PodcastMetadata.yaml",
    "content": "components:\n  schemas:\n    PodcastMetadata:\n      type: object\n      description: Metadata for a podcast.\n      properties:\n        title:\n          type: string\n          description: The title of the podcast.\n          nullable: true\n        author:\n          type: string\n          description: The author of the podcast.\n          nullable: true\n        description:\n          type: string\n          description: The description of the podcast.\n          nullable: true\n        releaseDate:\n          type: string\n          format: date-time\n          description: The release date of the podcast.\n          nullable: true\n        genres:\n          type: array\n          description: The genres of the podcast.\n          items:\n            type: string\n        feedUrl:\n          type: string\n          description: The URL of the podcast feed.\n          nullable: true\n        imageUrl:\n          type: string\n          description: The URL of the podcast's image.\n          nullable: true\n        itunesPageUrl:\n          type: string\n          description: The URL of the podcast's iTunes page.\n          nullable: true\n        itunesId:\n          type: string\n          description: The iTunes ID of the podcast.\n          nullable: true\n        itunesArtistId:\n          type: string\n          description: The iTunes artist ID of the podcast.\n          nullable: true\n        explicit:\n          type: boolean\n          description: Whether the podcast contains explicit content.\n        language:\n          type: string\n          description: The language of the podcast.\n          nullable: true\n        type:\n          type: string\n          description: The type of podcast (e.g., episodic, serial).\n          nullable: true\n"
  },
  {
    "path": "docs/objects/settings/EmailSettings.yaml",
    "content": "components:\n  schemas:\n    ereaderName:\n      type: string\n      description: The name of the e-reader device.\n    EreaderDeviceObject:\n      type: object\n      description: An e-reader device configured to receive EPUB through e-mail.\n      properties:\n        name:\n          $ref: '#/components/schemas/ereaderName'\n        email:\n          type: string\n          description: The email address associated with the e-reader device.\n        availabilityOption:\n          type: string\n          description: The availability option for the device.\n          enum: ['adminOrUp', 'userOrUp', 'guestOrUp', 'specificUsers']\n        users:\n          type: array\n          description: List of specific users allowed to access the device.\n          items:\n            type: string\n      required:\n        - name\n        - email\n        - availabilityOption\n    EmailSettings:\n      type: object\n      description: The email settings configuration for the server. This includes the credentials to send e-books and an array of e-reader devices.\n      properties:\n        id:\n          type: string\n          description: The unique identifier for the email settings. Currently this is always `email-settings`\n          example: email-settings\n        host:\n          type: string\n          description: The SMTP host address.\n          nullable: true\n        port:\n          type: integer\n          format: int32\n          description: The port number for the SMTP server.\n          example: 465\n        secure:\n          type: boolean\n          description: Indicates if the connection should use SSL/TLS.\n          example: true\n        rejectUnauthorized:\n          type: boolean\n          description: Indicates if unauthorized SSL/TLS certificates should be rejected.\n          example: true\n        user:\n          type: string\n          description: The username for SMTP authentication.\n          nullable: true\n        pass:\n          type: string\n          description: The password for SMTP authentication.\n          nullable: true\n        testAddress:\n          type: string\n          description: The test email address used for sending test emails.\n          nullable: true\n        fromAddress:\n          type: string\n          description: The default \"from\" email address for outgoing emails.\n          nullable: true\n        ereaderDevices:\n          type: array\n          description: List of configured e-reader devices.\n          items:\n            $ref: '#/components/schemas/EreaderDeviceObject'\n      required:\n        - id\n        - port\n        - secure\n        - ereaderDevices\n"
  },
  {
    "path": "docs/openapi.json",
    "content": "{\n  \"openapi\": \"3.0.0\",\n  \"info\": {\n    \"title\": \"Audiobookshelf API\",\n    \"version\": \"0.1.0\",\n    \"description\": \"Audiobookshelf API with autogenerated OpenAPI doc\"\n  },\n  \"servers\": [\n    {\n      \"url\": \"http://localhost:3000\",\n      \"description\": \"Development server\"\n    }\n  ],\n  \"security\": [\n    {\n      \"BearerAuth\": []\n    }\n  ],\n  \"tags\": [\n    {\n      \"name\": \"Authors\",\n      \"description\": \"Author endpoints\"\n    },\n    {\n      \"name\": \"Libraries\",\n      \"description\": \"Library endpoints\"\n    },\n    {\n      \"name\": \"Series\",\n      \"description\": \"Series endpoints\"\n    },\n    {\n      \"name\": \"Email\",\n      \"description\": \"Email endpoints\"\n    },\n    {\n      \"name\": \"Notification\",\n      \"description\": \"Notifications endpoints\"\n    },\n    {\n      \"name\": \"Podcasts\",\n      \"description\": \"Podcast endpoints\"\n    }\n  ],\n  \"paths\": {\n    \"/api/authors/{id}\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Author ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/authorId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getAuthorById\",\n        \"summary\": \"Get an author by ID\",\n        \"description\": \"Get an author by ID. The author's books and series can be included in the response.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"parameters\": [\n          {\n            \"in\": \"query\",\n            \"name\": \"include\",\n            \"description\": \"A comma separated list of what to include with the author. The options are `items` and `series`. `series` will only have an effect if `items` is included. For example, the value `items,series` will include both library items and series.\",\n            \"allowReserved\": true,\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"example\": \"items,series\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getAuthorById OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/author\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      },\n      \"patch\": {\n        \"operationId\": \"updateAuthorById\",\n        \"summary\": \"Update an author by ID\",\n        \"description\": \"Update an author by ID. The author's name and description can be updated. This endpoint will merge two authors if the new author name matches another author name in the database.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"requestBody\": {\n          \"description\": \"The author object to update.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"properties\": {\n                  \"name\": {\n                    \"$ref\": \"#/components/schemas/authorName\"\n                  },\n                  \"description\": {\n                    \"$ref\": \"#/components/schemas/authorDescription\"\n                  },\n                  \"imagePath\": {\n                    \"$ref\": \"#/components/schemas/authorImagePath\"\n                  },\n                  \"asin\": {\n                    \"$ref\": \"#/components/schemas/authorAsin\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"updateAuthorById OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"oneOf\": [\n                    {\n                      \"$ref\": \"#/components/schemas/author\"\n                    },\n                    {\n                      \"$ref\": \"#/components/schemas/authorUpdated\"\n                    },\n                    {\n                      \"$ref\": \"#/components/schemas/authorMerged\"\n                    }\n                  ]\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      },\n      \"delete\": {\n        \"operationId\": \"deleteAuthorById\",\n        \"summary\": \"Delete an author by ID\",\n        \"description\": \"Delete an author by ID. This will remove the author from all books.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"deleteAuthorById OK\",\n            \"content\": {\n              \"text/plain\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"example\": \"Author deleted.\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      }\n    },\n    \"/api/authors/{id}/image\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Author ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/authorId\"\n          }\n        },\n        {\n          \"name\": \"token\",\n          \"in\": \"query\",\n          \"description\": \"API token\",\n          \"schema\": {\n            \"type\": \"string\"\n          }\n        },\n        {\n          \"name\": \"ts\",\n          \"in\": \"query\",\n          \"description\": \"Updated at value\",\n          \"schema\": {\n            \"type\": \"integer\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getAuthorImageById\",\n        \"summary\": \"Get an author image by author ID\",\n        \"description\": \"Get an author image by author ID. The image will be returned in the requested format and size.\",\n        \"security\": [],\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"requestBody\": {\n          \"required\": false,\n          \"description\": \"The author image to get.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"properties\": {\n                  \"width\": {\n                    \"$ref\": \"#/components/schemas/imageWidth\"\n                  },\n                  \"height\": {\n                    \"$ref\": \"#/components/schemas/imageHeight\"\n                  },\n                  \"format\": {\n                    \"$ref\": \"#/components/schemas/imageFormat\"\n                  },\n                  \"raw\": {\n                    \"$ref\": \"#/components/schemas/imageRaw\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getAuthorImageById OK\",\n            \"content\": {\n              \"image/webp\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"format\": \"binary\"\n                }\n              },\n              \"image/jpeg\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"format\": \"binary\"\n                }\n              },\n              \"image/*\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"format\": \"binary\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      },\n      \"post\": {\n        \"operationId\": \"addAuthorImageById\",\n        \"summary\": \"Add an author image to the server\",\n        \"description\": \"Add an author image to the server. The image will be downloaded from the provided URL and stored on the server.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"description\": \"The author image to add by URL.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/imageUrl\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"addAuthorImageById OK\",\n            \"content\": {\n              \"image/*\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"format\": \"binary\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      },\n      \"patch\": {\n        \"operationId\": \"updateAuthorImageById\",\n        \"summary\": \"Update an author image by author ID\",\n        \"description\": \"Update an author image by author ID. The image will be resized if the width, height, or format is provided.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"requestBody\": {\n          \"description\": \"The author image to update.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"properties\": {\n                  \"width\": {\n                    \"$ref\": \"#/components/schemas/imageWidth\"\n                  },\n                  \"height\": {\n                    \"$ref\": \"#/components/schemas/imageHeight\"\n                  },\n                  \"format\": {\n                    \"$ref\": \"#/components/schemas/imageFormat\"\n                  },\n                  \"raw\": {\n                    \"$ref\": \"#/components/schemas/imageRaw\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"updateAuthorImageById OK\",\n            \"content\": {\n              \"image/*\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"format\": \"binary\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      },\n      \"delete\": {\n        \"operationId\": \"deleteAuthorImageById\",\n        \"summary\": \"Delete an author image by author ID\",\n        \"description\": \"Delete an author image by author ID. This will remove the image from the server and the database.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"deleteAuthorImageById OK\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      }\n    },\n    \"/api/authors/{id}/match\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Author ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/authorId\"\n          }\n        }\n      ],\n      \"post\": {\n        \"operationId\": \"matchAuthorById\",\n        \"summary\": \"Match the author against Audible using quick match\",\n        \"description\": \"Match the author against Audible using quick match. Quick match updates the author's description and image (if no image already existed) with information from audible. Either `asin` or `q` must be provided, with `asin` taking priority if both are provided.\",\n        \"tags\": [\n          \"Authors\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"description\": \"The author object to match against an online provider.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"q\": {\n                    \"$ref\": \"#/components/schemas/authorSearchName\"\n                  },\n                  \"asin\": {\n                    \"$ref\": \"#/components/schemas/authorAsin\"\n                  },\n                  \"region\": {\n                    \"$ref\": \"#/components/schemas/region\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"matchAuthorById OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"oneOf\": [\n                    {\n                      \"$ref\": \"#/components/schemas/author\"\n                    },\n                    {\n                      \"$ref\": \"#/components/schemas/authorUpdated\"\n                    }\n                  ]\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/author404\"\n          }\n        }\n      }\n    },\n    \"/api/emails/settings\": {\n      \"get\": {\n        \"summary\": \"Get email settings\",\n        \"description\": \"Get email settings for sending e-books to e-readers.\",\n        \"operationId\": \"getEmailSettings\",\n        \"tags\": [\n          \"Email\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/email200\"\n          }\n        }\n      },\n      \"patch\": {\n        \"summary\": \"Update email settings\",\n        \"operationId\": \"updateEmailSettings\",\n        \"tags\": [\n          \"Email\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/EmailSettings\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/email200\"\n          }\n        }\n      }\n    },\n    \"/api/emails/test\": {\n      \"post\": {\n        \"summary\": \"Send test email\",\n        \"operationId\": \"sendTestEmail\",\n        \"tags\": [\n          \"Email\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\"\n          }\n        }\n      }\n    },\n    \"/api/emails/ereader-devices\": {\n      \"post\": {\n        \"summary\": \"Update e-reader devices\",\n        \"operationId\": \"updateEReaderDevices\",\n        \"tags\": [\n          \"Email\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"ereaderDevices\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"$ref\": \"#/components/schemas/EreaderDeviceObject\"\n                    }\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/ereader200\"\n          },\n          \"400\": {\n            \"description\": \"Invalid payload\"\n          }\n        }\n      }\n    },\n    \"/api/emails/send-ebook-to-device\": {\n      \"post\": {\n        \"summary\": \"Send ebook to device\",\n        \"operationId\": \"sendEBookToDevice\",\n        \"tags\": [\n          \"Email\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"libraryItemId\": {\n                    \"$ref\": \"#/components/schemas/libraryItemId\"\n                  },\n                  \"deviceName\": {\n                    \"$ref\": \"#/components/schemas/ereaderName\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successful response\"\n          },\n          \"400\": {\n            \"description\": \"Invalid request\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          }\n        }\n      }\n    },\n    \"/api/libraries\": {\n      \"get\": {\n        \"operationId\": \"getLibraries\",\n        \"summary\": \"Get all libraries on server\",\n        \"description\": \"Get all libraries on server.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getLibraries OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"libraries\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/library\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n      },\n      \"post\": {\n        \"operationId\": \"createLibrary\",\n        \"summary\": \"Create a new library on server\",\n        \"description\": \"Create a new library on server.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"requestBody\": {\n          \"description\": \"The library object to create.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"required\": [\n                  \"name\",\n                  \"folders\"\n                ],\n                \"properties\": {\n                  \"name\": {\n                    \"$ref\": \"#/components/schemas/libraryName\"\n                  },\n                  \"folders\": {\n                    \"$ref\": \"#/components/schemas/libraryFolders\"\n                  },\n                  \"displayOrder\": {\n                    \"$ref\": \"#/components/schemas/libraryDisplayOrder\"\n                  },\n                  \"icon\": {\n                    \"$ref\": \"#/components/schemas/libraryIcon\"\n                  },\n                  \"mediaType\": {\n                    \"$ref\": \"#/components/schemas/libraryMediaType\"\n                  },\n                  \"provider\": {\n                    \"$ref\": \"#/components/schemas/libraryProvider\"\n                  },\n                  \"settings\": {\n                    \"$ref\": \"#/components/schemas/librarySettings\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/library200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/libraries/{id}\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the library.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getLibraryById\",\n        \"summary\": \"Get a single library by ID on server\",\n        \"description\": \"Get a single library by ID on server.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"parameters\": [\n          {\n            \"in\": \"query\",\n            \"name\": \"include\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/minified\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/library200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      },\n      \"patch\": {\n        \"operationId\": \"updateLibraryById\",\n        \"summary\": \"Update a single library by ID on server\",\n        \"description\": \"Update a single library by ID on server.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"description\": \"The library object to update.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"name\": {\n                    \"$ref\": \"#/components/schemas/libraryName\"\n                  },\n                  \"folders\": {\n                    \"$ref\": \"#/components/schemas/libraryFolders\"\n                  },\n                  \"displayOrder\": {\n                    \"$ref\": \"#/components/schemas/libraryDisplayOrder\"\n                  },\n                  \"icon\": {\n                    \"$ref\": \"#/components/schemas/libraryIcon\"\n                  },\n                  \"mediaType\": {\n                    \"$ref\": \"#/components/schemas/libraryMediaType\"\n                  },\n                  \"provider\": {\n                    \"$ref\": \"#/components/schemas/libraryProvider\"\n                  },\n                  \"settings\": {\n                    \"$ref\": \"#/components/schemas/librarySettings\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/library200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      },\n      \"delete\": {\n        \"operationId\": \"deleteLibraryById\",\n        \"summary\": \"Delete a single library by ID on server\",\n        \"description\": \"Delete a single library by ID on server and return the deleted object.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/library200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/libraries/{id}/authors\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the library.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getLibraryAuthors\",\n        \"summary\": \"Get all authors in a library\",\n        \"description\": \"Get all authors in a library by ID on server.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getLibraryAuthors OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"authors\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/authorExpanded\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/libraries/{id}/items\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the library.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getLibraryItems\",\n        \"summary\": \"Get items in a library\",\n        \"description\": \"Get items in a library by ID on server.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/limit\"\n          },\n          {\n            \"$ref\": \"#/components/parameters/page\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"sort\",\n            \"description\": \"The field to sort by from the request.\",\n            \"example\": \"numBooks\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"default\": \"name\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/desc\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"filter\",\n            \"description\": \"The filter for the library.\",\n            \"example\": \"media.metadata.title\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"include\",\n            \"description\": \"The fields to include in the response. The only current option is `rssfeed`.\",\n            \"allowReserved\": true,\n            \"example\": \"rssfeed\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/minified\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"collapseSeries\",\n            \"description\": \"Whether to collapse series into a single cover\",\n            \"schema\": {\n              \"type\": \"integer\",\n              \"default\": 0\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getLibraryItems OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"results\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/libraryItemBase\"\n                      }\n                    },\n                    \"total\": {\n                      \"$ref\": \"#/components/schemas/total\"\n                    },\n                    \"limit\": {\n                      \"$ref\": \"#/components/schemas/limit\"\n                    },\n                    \"page\": {\n                      \"$ref\": \"#/components/schemas/page\"\n                    },\n                    \"sortBy\": {\n                      \"$ref\": \"#/components/schemas/sortBy\"\n                    },\n                    \"sortDesc\": {\n                      \"$ref\": \"#/components/schemas/sortDesc\"\n                    },\n                    \"filterBy\": {\n                      \"$ref\": \"#/components/schemas/filterBy\"\n                    },\n                    \"mediaType\": {\n                      \"$ref\": \"#/components/schemas/mediaType\"\n                    },\n                    \"minified\": {\n                      \"$ref\": \"#/components/schemas/minified\"\n                    },\n                    \"collapseSeries\": {\n                      \"$ref\": \"#/components/schemas/collapseSeries\"\n                    },\n                    \"include\": {\n                      \"$ref\": \"#/components/schemas/libraryInclude\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/libraries/{id}/issues\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the library.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          }\n        }\n      ],\n      \"delete\": {\n        \"operationId\": \"deleteLibraryIssues\",\n        \"summary\": \"Delete items with issues in a library.\",\n        \"description\": \"Delete all items with issues in a library by library ID on the server. This only removes the items from the ABS database and does not delete media files.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"deleteLibraryIssues OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"string\",\n                  \"example\": \"Issues deleted.\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/libraries/{id}/series\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the library.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getLibrarySeries\",\n        \"summary\": \"Get library series\",\n        \"description\": \"Get series in a library. Filtering and sorting can be applied.\",\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/limit\"\n          },\n          {\n            \"$ref\": \"#/components/parameters/page\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"sort\",\n            \"description\": \"The field to sort by from the request.\",\n            \"example\": \"numBooks\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"name\",\n                \"numBooks\",\n                \"totalDuration\",\n                \"addedAt\",\n                \"lastBookAdded\",\n                \"lastBookUpdated\"\n              ],\n              \"default\": \"name\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/desc\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"filter\",\n            \"description\": \"The filter for the library.\",\n            \"example\": \"media.metadata.title\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"include\",\n            \"description\": \"The fields to include in the response. The only current option is `rssfeed`.\",\n            \"allowReserved\": true,\n            \"example\": \"rssfeed\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/minified\"\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getLibrarySeries OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"results\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/seriesBooks\"\n                      }\n                    },\n                    \"total\": {\n                      \"$ref\": \"#/components/schemas/total\"\n                    },\n                    \"limit\": {\n                      \"$ref\": \"#/components/schemas/limit\"\n                    },\n                    \"page\": {\n                      \"$ref\": \"#/components/schemas/page\"\n                    },\n                    \"sortBy\": {\n                      \"$ref\": \"#/components/schemas/sortBy\"\n                    },\n                    \"sortDesc\": {\n                      \"$ref\": \"#/components/schemas/sortDesc\"\n                    },\n                    \"filterBy\": {\n                      \"$ref\": \"#/components/schemas/filterBy\"\n                    },\n                    \"minified\": {\n                      \"$ref\": \"#/components/schemas/minified\"\n                    },\n                    \"include\": {\n                      \"$ref\": \"#/components/schemas/libraryInclude\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/libraries/{id}/series/{seriesId}\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the library.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          }\n        },\n        {\n          \"name\": \"seriesId\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the series.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/seriesId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getLibrarySeriesById\",\n        \"summary\": \"Get single series in library\",\n        \"description\": \"Get a single series in a library by ID on server. This endpoint is deprecated and `/api/series/{id}` should be used instead.\",\n        \"deprecated\": true,\n        \"tags\": [\n          \"Libraries\"\n        ],\n        \"parameters\": [\n          {\n            \"$ref\": \"#/components/parameters/limit\"\n          },\n          {\n            \"$ref\": \"#/components/parameters/page\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"sort\",\n            \"description\": \"The field to sort by from the request.\",\n            \"example\": \"numBooks\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"name\",\n                \"numBooks\",\n                \"totalDuration\",\n                \"addedAt\",\n                \"lastBookAdded\",\n                \"lastBookUpdated\"\n              ],\n              \"default\": \"name\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/desc\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"filter\",\n            \"description\": \"The filter for the library.\",\n            \"example\": \"media.metadata.title\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          },\n          {\n            \"$ref\": \"#/components/parameters/minified\"\n          },\n          {\n            \"in\": \"query\",\n            \"name\": \"include\",\n            \"description\": \"The fields to include in the response. The only current option is `rssfeed`.\",\n            \"allowReserved\": true,\n            \"example\": \"rssfeed\",\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"getLibrarySeriesById OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/seriesWithProgressAndRSS\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/library404\"\n          }\n        }\n      }\n    },\n    \"/api/notifications\": {\n      \"get\": {\n        \"operationId\": \"getNotifications\",\n        \"summary\": \"Get notification settings\",\n        \"description\": \"Get all Apprise notification events and notification settings for server.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Success\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"data\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"events\": {\n                          \"type\": \"array\",\n                          \"items\": {\n                            \"$ref\": \"#/components/schemas/NotificationEvent\"\n                          }\n                        }\n                      }\n                    },\n                    \"settings\": {\n                      \"$ref\": \"#/components/schemas/NotificationSettings\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      },\n      \"patch\": {\n        \"operationId\": \"configureNotificationSettings\",\n        \"summary\": \"Update select notification settings\",\n        \"description\": \"Update the URL, max failed attempts, and maximum notifications that can be queued for Apprise.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"appriseApiUrl\": {\n                    \"$ref\": \"#/components/schemas/appriseApiUrl\"\n                  },\n                  \"maxFailedAttempts\": {\n                    \"$ref\": \"#/components/schemas/maxFailedAttempts\"\n                  },\n                  \"maxNotificationQueue\": {\n                    \"$ref\": \"#/components/schemas/maxNotificationQueue\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/notification200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      },\n      \"post\": {\n        \"operationId\": \"createNotification\",\n        \"summary\": \"Create notification settings\",\n        \"description\": \"Create or update Notification settings.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"libraryId\": {\n                    \"$ref\": \"#/components/schemas/libraryIdNullable\"\n                  },\n                  \"eventName\": {\n                    \"$ref\": \"#/components/schemas/notificationEventName\"\n                  },\n                  \"urls\": {\n                    \"$ref\": \"#/components/schemas/urls\"\n                  },\n                  \"titleTemplate\": {\n                    \"$ref\": \"#/components/schemas/titleTemplate\"\n                  },\n                  \"bodyTemplate\": {\n                    \"$ref\": \"#/components/schemas/bodyTemplate\"\n                  },\n                  \"enabled\": {\n                    \"$ref\": \"#/components/schemas/enabled\"\n                  },\n                  \"type\": {\n                    \"$ref\": \"#/components/schemas/notificationType\"\n                  }\n                },\n                \"required\": [\n                  \"eventName\",\n                  \"urls\",\n                  \"titleTemplate\",\n                  \"bodyTemplate\"\n                ]\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Success\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"settings\": {\n                      \"$ref\": \"#/components/schemas/NotificationSettings\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      }\n    },\n    \"/api/notificationdata\": {\n      \"get\": {\n        \"operationId\": \"getNotificationEventData\",\n        \"summary\": \"Get notification event data\",\n        \"description\": \"Get all Apprise notification event data for the server.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Success\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"events\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/NotificationEvent\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      }\n    },\n    \"/api/notifications/test\": {\n      \"get\": {\n        \"operationId\": \"sendDefaultTestNotification\",\n        \"summary\": \"Send general test notification\",\n        \"description\": \"Send a test notification.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"parameters\": [\n          {\n            \"in\": \"query\",\n            \"name\": \"fail\",\n            \"description\": \"Whether to intentionally cause the notification to fail. `0` for false, `1` for true.\",\n            \"schema\": {\n              \"type\": \"integer\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/notification200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      }\n    },\n    \"/api/notifications/{id}\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the notification.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/notificationId\"\n          }\n        }\n      ],\n      \"delete\": {\n        \"operationId\": \"deleteNotification\",\n        \"summary\": \"Delete a notification\",\n        \"description\": \"Delete the notification by ID and return the notification settings.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Success\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"settings\": {\n                      \"$ref\": \"#/components/schemas/NotificationSettings\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      },\n      \"patch\": {\n        \"operationId\": \"updateNotification\",\n        \"summary\": \"Update a notification\",\n        \"description\": \"Update an individual Notification by ID\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"libraryId\": {\n                    \"$ref\": \"#/components/schemas/libraryId\"\n                  },\n                  \"eventName\": {\n                    \"$ref\": \"#/components/schemas/notificationEventName\"\n                  },\n                  \"urls\": {\n                    \"$ref\": \"#/components/schemas/urls\"\n                  },\n                  \"titleTemplate\": {\n                    \"$ref\": \"#/components/schemas/titleTemplate\"\n                  },\n                  \"bodyTemplate\": {\n                    \"$ref\": \"#/components/schemas/bodyTemplate\"\n                  },\n                  \"enabled\": {\n                    \"$ref\": \"#/components/schemas/enabled\"\n                  },\n                  \"type\": {\n                    \"$ref\": \"#/components/schemas/notificationType\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Success\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"settings\": {\n                      \"$ref\": \"#/components/schemas/NotificationSettings\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      }\n    },\n    \"/api/notifications/{id}/test\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the notification.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/notificationId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"sendTestNotification\",\n        \"summary\": \"Send a test notification\",\n        \"description\": \"Send a test to the given notification by ID.\",\n        \"tags\": [\n          \"Notification\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"$ref\": \"#/components/responses/notification200\"\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/notification404\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts\": {\n      \"post\": {\n        \"summary\": \"Create a new podcast\",\n        \"operationId\": \"createPodcast\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/Podcast\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully created a podcast\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Podcast\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad request\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/feed\": {\n      \"post\": {\n        \"summary\": \"Get podcast feed\",\n        \"operationId\": \"getPodcastFeed\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"rssFeed\": {\n                    \"type\": \"string\",\n                    \"description\": \"The RSS feed URL of the podcast\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully retrieved podcast feed\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"podcast\": {\n                      \"$ref\": \"#/components/schemas/Podcast\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad request\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/opml/parse\": {\n      \"post\": {\n        \"summary\": \"Get feeds from OPML text\",\n        \"description\": \"Parse OPML text and return an array of feeds\",\n        \"operationId\": \"getFeedsFromOPMLText\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"opmlText\": {\n                    \"type\": \"string\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully parsed OPML text and returned feeds\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"feeds\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"type\": \"object\",\n                        \"properties\": {\n                          \"title\": {\n                            \"type\": \"string\"\n                          },\n                          \"feedUrl\": {\n                            \"type\": \"string\"\n                          }\n                        }\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"Bad request, OPML text not provided\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden, user is not admin\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/opml/create\": {\n      \"post\": {\n        \"summary\": \"Bulk create podcasts from OPML feed URLs\",\n        \"operationId\": \"bulkCreatePodcastsFromOpmlFeedUrls\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"feeds\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                      \"type\": \"string\"\n                    }\n                  },\n                  \"libraryId\": {\n                    \"$ref\": \"#/components/schemas/libraryId\"\n                  },\n                  \"folderId\": {\n                    \"$ref\": \"#/components/schemas/folderId\"\n                  },\n                  \"autoDownloadEpisodes\": {\n                    \"$ref\": \"#/components/schemas/autoDownloadEpisodes\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully created podcasts from feed URLs\"\n          },\n          \"400\": {\n            \"description\": \"Bad request, invalid request body\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden, user is not admin\"\n          },\n          \"404\": {\n            \"description\": \"Folder not found\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/checknew\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"summary\": \"Check and download new episodes\",\n        \"operationId\": \"checkNewEpisodes\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"limit\",\n            \"in\": \"query\",\n            \"description\": \"Maximum number of episodes to download\",\n            \"required\": false,\n            \"schema\": {\n              \"type\": \"integer\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully checked and downloaded new episodes\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"episodes\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/PodcastEpisode\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          },\n          \"500\": {\n            \"description\": \"Server error\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/clear-queue\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"summary\": \"Clear episode download queue\",\n        \"operationId\": \"clearEpisodeDownloadQueue\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully cleared download queue\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/downloads\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"summary\": \"Get episode downloads\",\n        \"operationId\": \"getEpisodeDownloads\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully retrieved episode downloads\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"downloads\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/PodcastEpisode\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/search-episode\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"summary\": \"Find episode by title\",\n        \"operationId\": \"findEpisode\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"title\",\n            \"in\": \"query\",\n            \"description\": \"Title of the episode to search for\",\n            \"required\": true,\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully found episodes\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"episodes\": {\n                      \"type\": \"array\",\n                      \"items\": {\n                        \"$ref\": \"#/components/schemas/PodcastEpisode\"\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          },\n          \"500\": {\n            \"description\": \"Server error\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/download-episodes\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"post\": {\n        \"summary\": \"Download podcast episodes\",\n        \"operationId\": \"downloadEpisodes\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"array\",\n                \"items\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully started episode download\"\n          },\n          \"400\": {\n            \"description\": \"Bad request\"\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/match-episodes\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"post\": {\n        \"summary\": \"Quick match podcast episodes\",\n        \"operationId\": \"quickMatchEpisodes\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"override\",\n            \"in\": \"query\",\n            \"description\": \"Override existing details if set to 1\",\n            \"required\": false,\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully matched episodes\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"numEpisodesUpdated\": {\n                      \"type\": \"integer\"\n                    }\n                  }\n                }\n              }\n            }\n          },\n          \"403\": {\n            \"description\": \"Forbidden\"\n          }\n        }\n      }\n    },\n    \"/api/podcasts/{id}/episode/{episodeId}\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"Podcast ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        },\n        {\n          \"name\": \"episodeId\",\n          \"in\": \"path\",\n          \"description\": \"Episode ID\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          }\n        }\n      ],\n      \"patch\": {\n        \"summary\": \"Update a podcast episode\",\n        \"operationId\": \"updateEpisode\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"requestBody\": {\n          \"required\": true,\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\"\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully updated episode\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Podcast\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          }\n        }\n      },\n      \"get\": {\n        \"summary\": \"Get a specific podcast episode\",\n        \"operationId\": \"getEpisode\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully retrieved episode\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/PodcastEpisode\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          }\n        }\n      },\n      \"delete\": {\n        \"summary\": \"Remove a podcast episode\",\n        \"operationId\": \"removeEpisode\",\n        \"tags\": [\n          \"Podcasts\"\n        ],\n        \"parameters\": [\n          {\n            \"name\": \"hard\",\n            \"in\": \"query\",\n            \"description\": \"Hard delete the episode if set to 1\",\n            \"required\": false,\n            \"schema\": {\n              \"type\": \"string\"\n            }\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"Successfully removed episode\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Podcast\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"Not found\"\n          },\n          \"500\": {\n            \"description\": \"Server error\"\n          }\n        }\n      }\n    },\n    \"/api/series/{id}\": {\n      \"parameters\": [\n        {\n          \"name\": \"id\",\n          \"in\": \"path\",\n          \"description\": \"The ID of the series.\",\n          \"required\": true,\n          \"schema\": {\n            \"$ref\": \"#/components/schemas/seriesId\"\n          }\n        }\n      ],\n      \"get\": {\n        \"operationId\": \"getSeries\",\n        \"tags\": [\n          \"Series\"\n        ],\n        \"summary\": \"Get series\",\n        \"description\": \"Get a series by ID.\",\n        \"requestBody\": {\n          \"required\": false,\n          \"description\": \"A comma separated list of what to include with the series.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"include\": {\n                    \"type\": \"string\",\n                    \"description\": \"A comma separated list of what to include with the series.\",\n                    \"example\": \"progress,rssfeed\",\n                    \"enum\": [\n                      \"progress\",\n                      \"rssfeed\",\n                      \"progress,rssfeed\",\n                      \"rssfeed,progress\"\n                    ]\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/seriesWithProgressAndRSS\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/series404\"\n          }\n        }\n      },\n      \"patch\": {\n        \"operationId\": \"updateSeries\",\n        \"tags\": [\n          \"Series\"\n        ],\n        \"summary\": \"Update series\",\n        \"description\": \"Update a series by ID.\",\n        \"requestBody\": {\n          \"required\": true,\n          \"description\": \"The series to update.\",\n          \"content\": {\n            \"application/json\": {\n              \"schema\": {\n                \"properties\": {\n                  \"name\": {\n                    \"$ref\": \"#/components/schemas/seriesName\"\n                  },\n                  \"description\": {\n                    \"$ref\": \"#/components/schemas/seriesDescription\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"OK\",\n            \"content\": {\n              \"application/json\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/series\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"$ref\": \"#/components/responses/series404\"\n          }\n        }\n      }\n    }\n  },\n  \"components\": {\n    \"securitySchemes\": {\n      \"BearerAuth\": {\n        \"description\": \"Bearer authentication\",\n        \"type\": \"http\",\n        \"scheme\": \"bearer\"\n      }\n    },\n    \"schemas\": {\n      \"authorId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of the author.\",\n        \"format\": \"uuid\",\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"authorAsin\": {\n        \"description\": \"The Audible identifier (ASIN) of the author. Will be null if unknown. Not the Amazon identifier.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"example\": \"B000APZOQA\"\n      },\n      \"authorName\": {\n        \"description\": \"The name of the author.\",\n        \"type\": \"string\",\n        \"example\": \"Terry Goodkind\"\n      },\n      \"authorDescription\": {\n        \"description\": \"The new description of the author.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"example\": \"Terry Goodkind is a\"\n      },\n      \"authorImagePath\": {\n        \"description\": \"The absolute path for the author image. This will be in the `metadata/` directory. Will be null if there is no image.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"example\": \"/metadata/authors/aut_z3leimgybl7uf3y4ab.jpg\"\n      },\n      \"addedAt\": {\n        \"type\": \"integer\",\n        \"description\": \"The time (in ms since POSIX epoch) when added to the server.\",\n        \"example\": 1633522963509\n      },\n      \"updatedAt\": {\n        \"type\": \"integer\",\n        \"description\": \"The time (in ms since POSIX epoch) when last updated.\",\n        \"example\": 1633522963509\n      },\n      \"libraryItemId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of library items after 2.3.0.\",\n        \"format\": \"uuid\",\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"oldLibraryItemId\": {\n        \"description\": \"The ID of library items on server version 2.2.23 and before.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"format\": \"li_[a-z0-9]{18}\",\n        \"example\": \"li_o78uaoeuh78h6aoeif\"\n      },\n      \"inode\": {\n        \"description\": \"The inode of the item in the file system.\",\n        \"type\": \"string\",\n        \"format\": \"[0-9]*\",\n        \"example\": \"649644248522215260\"\n      },\n      \"libraryId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of the library.\",\n        \"format\": \"uuid\",\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"folderId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of the folder.\",\n        \"format\": \"uuid\",\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"mediaType\": {\n        \"type\": \"string\",\n        \"description\": \"The type of media, will be book or podcast.\",\n        \"enum\": [\n          \"book\",\n          \"podcast\"\n        ]\n      },\n      \"libraryItemBase\": {\n        \"type\": \"object\",\n        \"description\": \"Base library item schema\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/libraryItemId\"\n          },\n          \"oldLibraryItemId\": {\n            \"$ref\": \"#/components/schemas/oldLibraryItemId\"\n          },\n          \"ino\": {\n            \"$ref\": \"#/components/schemas/inode\"\n          },\n          \"libraryId\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          },\n          \"folderId\": {\n            \"$ref\": \"#/components/schemas/folderId\"\n          },\n          \"path\": {\n            \"description\": \"The path of the library item on the server.\",\n            \"type\": \"string\"\n          },\n          \"relPath\": {\n            \"description\": \"The path, relative to the library folder, of the library item.\",\n            \"type\": \"string\"\n          },\n          \"isFile\": {\n            \"description\": \"Whether the library item is a single file in the root of the library folder.\",\n            \"type\": \"boolean\"\n          },\n          \"mtimeMs\": {\n            \"description\": \"The time (in ms since POSIX epoch) when the library item was last modified on disk.\",\n            \"type\": \"integer\"\n          },\n          \"ctimeMs\": {\n            \"description\": \"The time (in ms since POSIX epoch) when the library item status was changed on disk.\",\n            \"type\": \"integer\"\n          },\n          \"birthtimeMs\": {\n            \"description\": \"The time (in ms since POSIX epoch) when the library item was created on disk. Will be 0 if unknown.\",\n            \"type\": \"integer\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          },\n          \"updatedAt\": {\n            \"$ref\": \"#/components/schemas/updatedAt\"\n          },\n          \"isMissing\": {\n            \"description\": \"Whether the library item was scanned and no longer exists.\",\n            \"type\": \"boolean\"\n          },\n          \"isInvalid\": {\n            \"description\": \"Whether the library item was scanned and no longer has media files.\",\n            \"type\": \"boolean\"\n          },\n          \"mediaType\": {\n            \"$ref\": \"#/components/schemas/mediaType\"\n          }\n        }\n      },\n      \"bookMetadataBase\": {\n        \"type\": \"object\",\n        \"description\": \"The base book metadata object for minified, normal, and extended schemas to inherit from.\",\n        \"properties\": {\n          \"title\": {\n            \"description\": \"The title of the book. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Wizards First Rule\"\n          },\n          \"subtitle\": {\n            \"description\": \"The subtitle of the book. Will be null if there is no subtitle.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"genres\": {\n            \"description\": \"The genres of the book.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"example\": [\n              \"Fantasy\",\n              \"Sci-Fi\",\n              \"Nonfiction: History\"\n            ]\n          },\n          \"publishedYear\": {\n            \"description\": \"The year the book was published. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"2008\"\n          },\n          \"publishedDate\": {\n            \"description\": \"The date the book was published. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"publisher\": {\n            \"description\": \"The publisher of the book. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Brilliance Audio\"\n          },\n          \"description\": {\n            \"description\": \"A description for the book. Will be null if empty.\",\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"The masterpiece that started Terry Goodkind's New York Times bestselling epic Sword of Truth In the aftermath of the brutal murder of his father, a mysterious woman, Kahlan Amnell, appears in Richard Cypher's forest sanctuary seeking help...and more. His world, his very beliefs, are shattered when ancient debts come due with thundering violence. In a dark age it takes courage to live, and more than mere courage to challenge those who hold dominion, Richard and Kahlan must take up that challenge or become the next victims. Beyond awaits a bewitching land where even the best of their hearts could betray them. Yet, Richard fears nothing so much as what secrets his sword might reveal about his own soul. Falling in love would destroy them - for reasons Richard can't imagine and Kahlan dare not say. In their darkest hour, hunted relentlessly, tormented by treachery and loss, Kahlan calls upon Richard to reach beyond his sword - to invoke within himself something more noble. Neither knows that the rules of battle have just changed...or that their time has run out. Wizard's First Rule is the beginning. One book. One Rule. Witness the birth of a legend.\"\n          },\n          \"isbn\": {\n            \"description\": \"The ISBN of the book. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"asin\": {\n            \"description\": \"The ASIN of the book. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"B002V0QK4C\"\n          },\n          \"language\": {\n            \"description\": \"The language of the book. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"explicit\": {\n            \"description\": \"Whether the book has been marked as explicit.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          }\n        }\n      },\n      \"bookMetadataMinified\": {\n        \"type\": \"object\",\n        \"description\": \"The minified metadata for a book in the database.\",\n        \"allOf\": [\n          {\n            \"$ref\": \"#/components/schemas/bookMetadataBase\"\n          },\n          {\n            \"type\": \"object\",\n            \"properties\": {\n              \"titleIgnorePrefix\": {\n                \"description\": \"The title of the book with any prefix moved to the end.\",\n                \"type\": \"string\"\n              },\n              \"authorName\": {\n                \"description\": \"The name of the book's author(s).\",\n                \"type\": \"string\",\n                \"example\": \"Terry Goodkind\"\n              },\n              \"authorNameLF\": {\n                \"description\": \"The name of the book's author(s) with last names first.\",\n                \"type\": \"string\",\n                \"example\": \"Goodkind, Terry\"\n              },\n              \"narratorName\": {\n                \"description\": \"The name of the audiobook's narrator(s).\",\n                \"type\": \"string\",\n                \"example\": \"Sam Tsoutsouvas\"\n              },\n              \"seriesName\": {\n                \"description\": \"The name of the book's series.\",\n                \"type\": \"string\",\n                \"example\": \"Sword of Truth\"\n              }\n            }\n          }\n        ]\n      },\n      \"bookCoverPath\": {\n        \"description\": \"The absolute path on the server of the cover file. Will be null if there is no cover.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"example\": \"/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/cover.jpg\"\n      },\n      \"tags\": {\n        \"description\": \"Tags applied to items.\",\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"example\": [\n          \"To Be Read\",\n          \"Genre: Nonfiction\"\n        ]\n      },\n      \"durationSec\": {\n        \"description\": \"The total length (in seconds) of the item or file.\",\n        \"type\": \"number\",\n        \"example\": 33854.905\n      },\n      \"size\": {\n        \"description\": \"The total size (in bytes) of the item or file.\",\n        \"type\": \"integer\",\n        \"example\": 268824228\n      },\n      \"bookMinified\": {\n        \"type\": \"object\",\n        \"description\": \"Minified book schema. Does not depend on `bookBase` because there's pretty much no overlap.\",\n        \"properties\": {\n          \"metadata\": {\n            \"$ref\": \"#/components/schemas/bookMetadataMinified\"\n          },\n          \"coverPath\": {\n            \"$ref\": \"#/components/schemas/bookCoverPath\"\n          },\n          \"tags\": {\n            \"$ref\": \"#/components/schemas/tags\"\n          },\n          \"numTracks\": {\n            \"description\": \"The number of tracks the book's audio files have.\",\n            \"type\": \"integer\",\n            \"example\": 1\n          },\n          \"numAudioFiles\": {\n            \"description\": \"The number of audio files the book has.\",\n            \"type\": \"integer\",\n            \"example\": 1\n          },\n          \"numChapters\": {\n            \"description\": \"The number of chapters the book has.\",\n            \"type\": \"integer\",\n            \"example\": 1\n          },\n          \"numMissingParts\": {\n            \"description\": \"The total number of missing parts the book has.\",\n            \"type\": \"integer\",\n            \"example\": 0\n          },\n          \"numInvalidAudioFiles\": {\n            \"description\": \"The number of invalid audio files the book has.\",\n            \"type\": \"integer\",\n            \"example\": 0\n          },\n          \"duration\": {\n            \"$ref\": \"#/components/schemas/durationSec\"\n          },\n          \"size\": {\n            \"$ref\": \"#/components/schemas/size\"\n          },\n          \"ebookFormat\": {\n            \"description\": \"The format of ebook of the book. Will be null if the book is an audiobook.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          }\n        }\n      },\n      \"mediaMinified\": {\n        \"description\": \"The minified media of the library item.\",\n        \"oneOf\": [\n          {\n            \"$ref\": \"#/components/schemas/bookMinified\"\n          }\n        ]\n      },\n      \"libraryItemMinified\": {\n        \"type\": \"object\",\n        \"description\": \"A single item on the server, like a book or podcast. Minified media format.\",\n        \"allOf\": [\n          {\n            \"$ref\": \"#/components/schemas/libraryItemBase\"\n          },\n          {\n            \"type\": \"object\",\n            \"properties\": {\n              \"media\": {\n                \"$ref\": \"#/components/schemas/mediaMinified\"\n              }\n            }\n          }\n        ]\n      },\n      \"seriesId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of the series.\",\n        \"format\": \"uuid\",\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"seriesName\": {\n        \"description\": \"The name of the series.\",\n        \"type\": \"string\",\n        \"example\": \"Sword of Truth\"\n      },\n      \"authorSeries\": {\n        \"type\": \"object\",\n        \"description\": \"Series and the included library items that an author has written.\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/seriesId\"\n          },\n          \"name\": {\n            \"$ref\": \"#/components/schemas/seriesName\"\n          },\n          \"items\": {\n            \"description\": \"The items in the series. Each library item's media's metadata will have a `series` attribute, a `Series Sequence`, which is the matching series.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/libraryItemMinified\"\n            }\n          }\n        }\n      },\n      \"author\": {\n        \"description\": \"An author object which includes a description and image path. The library items and series associated with the author are optionally included.\",\n        \"type\": \"object\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/authorId\"\n          },\n          \"asin\": {\n            \"$ref\": \"#/components/schemas/authorAsin\"\n          },\n          \"name\": {\n            \"$ref\": \"#/components/schemas/authorName\"\n          },\n          \"description\": {\n            \"$ref\": \"#/components/schemas/authorDescription\"\n          },\n          \"imagePath\": {\n            \"$ref\": \"#/components/schemas/authorImagePath\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          },\n          \"updatedAt\": {\n            \"$ref\": \"#/components/schemas/updatedAt\"\n          },\n          \"libraryItems\": {\n            \"description\": \"The items associated with the author\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/libraryItemMinified\"\n            }\n          },\n          \"series\": {\n            \"description\": \"The series associated with the author\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/authorSeries\"\n            }\n          }\n        }\n      },\n      \"authorUpdated\": {\n        \"description\": \"Whether the author was updated without errors. Will not exist if author was merged.\",\n        \"type\": \"boolean\",\n        \"nullable\": true\n      },\n      \"authorMerged\": {\n        \"description\": \"Whether the author was merged with another author. Will not exist if author was updated.\",\n        \"type\": \"boolean\",\n        \"nullable\": true\n      },\n      \"imageWidth\": {\n        \"description\": \"The requested width of image in pixels.\",\n        \"type\": \"integer\",\n        \"default\": 400,\n        \"example\": 400\n      },\n      \"imageHeight\": {\n        \"description\": \"The requested height of image in pixels. If `null`, the height is scaled to maintain aspect ratio based on the requested width.\",\n        \"type\": \"integer\",\n        \"nullable\": true,\n        \"default\": null,\n        \"example\": 600\n      },\n      \"imageFormat\": {\n        \"description\": \"The requested output format.\",\n        \"type\": \"string\",\n        \"default\": \"jpeg\",\n        \"example\": \"webp\"\n      },\n      \"imageRaw\": {\n        \"description\": \"Return the raw image without scaling if true.\",\n        \"type\": \"boolean\",\n        \"default\": false\n      },\n      \"imageUrl\": {\n        \"description\": \"The URL of the image to add to the server\",\n        \"type\": \"string\",\n        \"format\": \"uri\",\n        \"example\": \"https://images-na.ssl-images-amazon.com/images/I/51NoQTm33OL.__01_SX120_CR0,0,120,120__.jpg\"\n      },\n      \"authorSearchName\": {\n        \"description\": \"The name of the author to use for searching.\",\n        \"type\": \"string\",\n        \"example\": \"Terry Goodkind\"\n      },\n      \"region\": {\n        \"description\": \"The region used to search.\",\n        \"type\": \"string\",\n        \"example\": \"us\",\n        \"default\": \"us\"\n      },\n      \"ereaderName\": {\n        \"type\": \"string\",\n        \"description\": \"The name of the e-reader device.\"\n      },\n      \"EreaderDeviceObject\": {\n        \"type\": \"object\",\n        \"description\": \"An e-reader device configured to receive EPUB through e-mail.\",\n        \"properties\": {\n          \"name\": {\n            \"$ref\": \"#/components/schemas/ereaderName\"\n          },\n          \"email\": {\n            \"type\": \"string\",\n            \"description\": \"The email address associated with the e-reader device.\"\n          },\n          \"availabilityOption\": {\n            \"type\": \"string\",\n            \"description\": \"The availability option for the device.\",\n            \"enum\": [\n              \"adminOrUp\",\n              \"userOrUp\",\n              \"guestOrUp\",\n              \"specificUsers\"\n            ]\n          },\n          \"users\": {\n            \"type\": \"array\",\n            \"description\": \"List of specific users allowed to access the device.\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          }\n        },\n        \"required\": [\n          \"name\",\n          \"email\",\n          \"availabilityOption\"\n        ]\n      },\n      \"EmailSettings\": {\n        \"type\": \"object\",\n        \"description\": \"The email settings configuration for the server. This includes the credentials to send e-books and an array of e-reader devices.\",\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"description\": \"The unique identifier for the email settings. Currently this is always `email-settings`\",\n            \"example\": \"email-settings\"\n          },\n          \"host\": {\n            \"type\": \"string\",\n            \"description\": \"The SMTP host address.\",\n            \"nullable\": true\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\",\n            \"description\": \"The port number for the SMTP server.\",\n            \"example\": 465\n          },\n          \"secure\": {\n            \"type\": \"boolean\",\n            \"description\": \"Indicates if the connection should use SSL/TLS.\",\n            \"example\": true\n          },\n          \"rejectUnauthorized\": {\n            \"type\": \"boolean\",\n            \"description\": \"Indicates if unauthorized SSL/TLS certificates should be rejected.\",\n            \"example\": true\n          },\n          \"user\": {\n            \"type\": \"string\",\n            \"description\": \"The username for SMTP authentication.\",\n            \"nullable\": true\n          },\n          \"pass\": {\n            \"type\": \"string\",\n            \"description\": \"The password for SMTP authentication.\",\n            \"nullable\": true\n          },\n          \"testAddress\": {\n            \"type\": \"string\",\n            \"description\": \"The test email address used for sending test emails.\",\n            \"nullable\": true\n          },\n          \"fromAddress\": {\n            \"type\": \"string\",\n            \"description\": \"The default \\\"from\\\" email address for outgoing emails.\",\n            \"nullable\": true\n          },\n          \"ereaderDevices\": {\n            \"type\": \"array\",\n            \"description\": \"List of configured e-reader devices.\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/EreaderDeviceObject\"\n            }\n          }\n        },\n        \"required\": [\n          \"id\",\n          \"port\",\n          \"secure\",\n          \"ereaderDevices\"\n        ]\n      },\n      \"libraryName\": {\n        \"description\": \"The name of the library.\",\n        \"type\": \"string\",\n        \"example\": \"My Audiobooks\"\n      },\n      \"folder\": {\n        \"type\": \"object\",\n        \"description\": \"Folder used in library\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/folderId\"\n          },\n          \"fullPath\": {\n            \"description\": \"The path on the server for the folder. (Read Only)\",\n            \"type\": \"string\",\n            \"example\": \"/podcasts\"\n          },\n          \"libraryId\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          }\n        }\n      },\n      \"librarySettings\": {\n        \"description\": \"The settings for the library.\",\n        \"type\": \"object\",\n        \"properties\": {\n          \"coverAspectRatio\": {\n            \"description\": \"Whether the library should use square book covers. Must be 0 (for false) or 1 (for true).\",\n            \"type\": \"integer\",\n            \"example\": 1\n          },\n          \"disableWatcher\": {\n            \"description\": \"Whether to disable the folder watcher for the library.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          },\n          \"skipMatchingMediaWithAsin\": {\n            \"description\": \"Whether to skip matching books that already have an ASIN.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          },\n          \"skipMatchingMediaWithIsbn\": {\n            \"description\": \"Whether to skip matching books that already have an ISBN.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          },\n          \"autoScanCronExpression\": {\n            \"description\": \"The cron expression for when to automatically scan the library folders. If null, automatic scanning will be disabled.\",\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"0 0 0 * * *\"\n          },\n          \"audiobooksOnly\": {\n            \"description\": \"Whether the library should ignore ebook files and only allow ebook files to be supplementary.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          },\n          \"hideSingleBookSeries\": {\n            \"description\": \"Whether to hide series with only one book.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          },\n          \"onlyShowLaterBooksInContinueSeries\": {\n            \"description\": \"Whether to only show books in a series after the highest series sequence.\",\n            \"type\": \"boolean\",\n            \"example\": false\n          },\n          \"metadataPrecedence\": {\n            \"description\": \"The precedence of metadata sources. See Metadata Providers for a list of possible providers.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"example\": [\n              \"folderStructure\",\n              \"audioMetatags\",\n              \"nfoFile\",\n              \"txtFiles\",\n              \"opfFile\",\n              \"absMetadata\"\n            ]\n          },\n          \"podcastSearchRegion\": {\n            \"description\": \"The region to use when searching for podcasts.\",\n            \"type\": \"string\",\n            \"example\": \"us\"\n          }\n        }\n      },\n      \"createdAt\": {\n        \"type\": \"integer\",\n        \"description\": \"The time (in ms since POSIX epoch) when was created.\",\n        \"example\": 1633522963509\n      },\n      \"library\": {\n        \"description\": \"A library object which includes either books or podcasts.\",\n        \"type\": \"object\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/libraryId\"\n          },\n          \"name\": {\n            \"$ref\": \"#/components/schemas/libraryName\"\n          },\n          \"folders\": {\n            \"description\": \"The folders that belong to the library.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/folder\"\n            }\n          },\n          \"displayOrder\": {\n            \"description\": \"Display position of the library in the list of libraries. Must be >= 1.\",\n            \"type\": \"integer\",\n            \"example\": 1\n          },\n          \"icon\": {\n            \"description\": \"The selected icon for the library. See Library Icons for a list of possible icons.\",\n            \"type\": \"string\",\n            \"example\": \"audiobookshelf\"\n          },\n          \"mediaType\": {\n            \"description\": \"The type of media that the library contains. Will be `book` or `podcast`. (Read Only)\",\n            \"type\": \"string\",\n            \"example\": \"book\"\n          },\n          \"provider\": {\n            \"description\": \"Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.\",\n            \"type\": \"string\",\n            \"example\": \"audible\"\n          },\n          \"settings\": {\n            \"$ref\": \"#/components/schemas/librarySettings\"\n          },\n          \"createdAt\": {\n            \"$ref\": \"#/components/schemas/createdAt\"\n          },\n          \"lastUpdate\": {\n            \"$ref\": \"#/components/schemas/updatedAt\"\n          }\n        }\n      },\n      \"libraryFolders\": {\n        \"description\": \"The folders of the library. Only specify the fullPath.\",\n        \"type\": \"array\",\n        \"items\": {\n          \"$ref\": \"#/components/schemas/folder\"\n        }\n      },\n      \"libraryDisplayOrder\": {\n        \"description\": \"The display order of the library. Must be >= 1.\",\n        \"type\": \"integer\",\n        \"minimum\": 1,\n        \"example\": 1\n      },\n      \"libraryIcon\": {\n        \"description\": \"The icon of the library. See Library Icons for a list of possible icons.\",\n        \"type\": \"string\",\n        \"example\": \"audiobookshelf\"\n      },\n      \"libraryMediaType\": {\n        \"description\": \"The type of media that the library contains. Must be `book` or `podcast`.\",\n        \"type\": \"string\",\n        \"example\": \"book\"\n      },\n      \"libraryProvider\": {\n        \"description\": \"Preferred metadata provider for the library. See Metadata Providers for a list of possible providers.\",\n        \"type\": \"string\",\n        \"example\": \"audible\"\n      },\n      \"authorExpanded\": {\n        \"type\": \"object\",\n        \"description\": \"The author schema with the total number of books in the library.\",\n        \"allOf\": [\n          {\n            \"$ref\": \"#/components/schemas/author\"\n          },\n          {\n            \"type\": \"object\",\n            \"properties\": {\n              \"numBooks\": {\n                \"description\": \"The number of books associated with the author in the library.\",\n                \"type\": \"integer\",\n                \"example\": 1\n              }\n            }\n          }\n        ]\n      },\n      \"total\": {\n        \"description\": \"The total number of items in the response.\",\n        \"type\": \"integer\",\n        \"example\": 100\n      },\n      \"limit\": {\n        \"description\": \"The number of items to return. If 0, no items are returned.\",\n        \"type\": \"integer\",\n        \"example\": 10,\n        \"default\": 0\n      },\n      \"page\": {\n        \"description\": \"The page number (zero indexed) to return. If no limit is specified, then page will have no effect.\",\n        \"type\": \"integer\",\n        \"example\": 1,\n        \"default\": 0\n      },\n      \"sortBy\": {\n        \"type\": \"string\",\n        \"description\": \"The field to sort by from the request.\",\n        \"example\": \"media.metadata.title\"\n      },\n      \"sortDesc\": {\n        \"description\": \"Whether to sort in descending order.\",\n        \"type\": \"boolean\",\n        \"example\": true\n      },\n      \"filterBy\": {\n        \"type\": \"string\",\n        \"description\": \"The field to filter by from the request. TODO\",\n        \"example\": \"media.metadata.title\"\n      },\n      \"minified\": {\n        \"description\": \"Return minified items if true.\",\n        \"type\": \"boolean\",\n        \"example\": true,\n        \"default\": false\n      },\n      \"collapseSeries\": {\n        \"type\": \"boolean\",\n        \"description\": \"Whether collapse series was set in the request.\",\n        \"example\": true\n      },\n      \"libraryInclude\": {\n        \"description\": \"The fields to include in the response. The only current option is `rssfeed`.\",\n        \"type\": \"string\",\n        \"example\": \"rssfeed\"\n      },\n      \"sequence\": {\n        \"description\": \"The position in the series the book is.\",\n        \"type\": \"string\",\n        \"nullable\": true\n      },\n      \"libraryItemSequence\": {\n        \"type\": \"object\",\n        \"description\": \"A single item on the server, like a book or podcast. Includes series sequence information.\",\n        \"allOf\": [\n          {\n            \"$ref\": \"#/components/schemas/libraryItemBase\"\n          },\n          {\n            \"type\": \"object\",\n            \"properties\": {\n              \"sequence\": {\n                \"$ref\": \"#/components/schemas/sequence\"\n              }\n            }\n          }\n        ]\n      },\n      \"seriesBooks\": {\n        \"type\": \"object\",\n        \"description\": \"A series object which includes the name and books in the series.\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/seriesId\"\n          },\n          \"name\": {\n            \"$ref\": \"#/components/schemas/seriesName\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          },\n          \"nameIgnorePrefix\": {\n            \"description\": \"The name of the series with any prefix moved to the end.\",\n            \"type\": \"string\"\n          },\n          \"nameIgnorePrefixSort\": {\n            \"description\": \"The name of the series with any prefix removed.\",\n            \"type\": \"string\"\n          },\n          \"type\": {\n            \"description\": \"Will always be `series`.\",\n            \"type\": \"string\"\n          },\n          \"books\": {\n            \"description\": \"The library items that contain the books in the series. A sequence attribute that denotes the position in the series the book is in, is tacked on.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/libraryItemSequence\"\n            }\n          },\n          \"totalDuration\": {\n            \"description\": \"The combined duration (in seconds) of all books in the series.\",\n            \"type\": \"number\"\n          }\n        }\n      },\n      \"seriesDescription\": {\n        \"description\": \"A description for the series. Will be null if there is none.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"example\": \"The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind.\"\n      },\n      \"series\": {\n        \"type\": \"object\",\n        \"description\": \"A series object which includes the name and description of the series.\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/seriesId\"\n          },\n          \"name\": {\n            \"$ref\": \"#/components/schemas/seriesName\"\n          },\n          \"description\": {\n            \"$ref\": \"#/components/schemas/seriesDescription\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          },\n          \"updatedAt\": {\n            \"$ref\": \"#/components/schemas/updatedAt\"\n          }\n        }\n      },\n      \"seriesProgress\": {\n        \"type\": \"object\",\n        \"description\": \"The user's progress of a series.\",\n        \"properties\": {\n          \"libraryItemIds\": {\n            \"description\": \"The IDs of the library items in the series.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/libraryItemId\"\n            }\n          },\n          \"libraryItemIdsFinished\": {\n            \"description\": \"The IDs of the library items in the series that are finished.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/libraryItemId\"\n            }\n          },\n          \"isFinished\": {\n            \"description\": \"Whether the series is finished.\",\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"seriesWithProgressAndRSS\": {\n        \"type\": \"object\",\n        \"description\": \"A series object which includes the name and progress of the series.\",\n        \"oneOf\": [\n          {\n            \"$ref\": \"#/components/schemas/series\"\n          },\n          {\n            \"type\": \"object\",\n            \"properties\": {\n              \"progress\": {\n                \"$ref\": \"#/components/schemas/seriesProgress\"\n              },\n              \"rssFeed\": {\n                \"description\": \"The RSS feed for the series.\",\n                \"type\": \"string\",\n                \"example\": \"TBD\"\n              }\n            }\n          }\n        ]\n      },\n      \"NotificationEvent\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\",\n            \"description\": \"The name of the notification event. The names and allowable values are defined at https://github.com/advplyr/audiobookshelf/blob/master/server/utils/notifications.js\"\n          },\n          \"requiresLibrary\": {\n            \"type\": \"boolean\",\n            \"description\": \"Whether the notification event depends on a library existing.\"\n          },\n          \"libraryMediaType\": {\n            \"type\": \"string\",\n            \"description\": \"The type of media of the library the notification depends on existing. Will not exist if requiresLibrary is false.\",\n            \"nullable\": true\n          },\n          \"description\": {\n            \"type\": \"string\",\n            \"description\": \"The description of the notification event.\"\n          },\n          \"variables\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"description\": \"The variables of the notification event that can be used in the notification templates.\"\n          },\n          \"defaults\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"title\": {\n                \"type\": \"string\",\n                \"description\": \"The default title template for notifications using the notification event.\"\n              },\n              \"body\": {\n                \"type\": \"string\",\n                \"description\": \"The default body template for notifications using the notification event.\"\n              }\n            }\n          },\n          \"testData\": {\n            \"type\": \"object\",\n            \"description\": \"The keys of the testData object will match the list of variables. The values will be the data used when sending a test notification.\",\n            \"additionalProperties\": {\n              \"type\": \"string\"\n            }\n          }\n        }\n      },\n      \"notificationId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of the notification.\",\n        \"example\": \"notification-settings\"\n      },\n      \"appriseApiUrl\": {\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"description\": \"The full URL where the Apprise API to use is located.\"\n      },\n      \"libraryIdNullable\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of the library. Applies to all libraries if `null`.\",\n        \"format\": \"uuid\",\n        \"nullable\": true,\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"notificationEventName\": {\n        \"type\": \"string\",\n        \"description\": \"The name of the event the notification will fire on.\",\n        \"enum\": [\n          \"onPodcastEpisodeDownloaded\",\n          \"onBackupCompleted\",\n          \"onBackupFailed\",\n          \"onTest\"\n        ]\n      },\n      \"urls\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"description\": \"The Apprise URLs to use for the notification.\",\n        \"example\": \"http://192.168.0.3:8000/notify/my-cool-notification\"\n      },\n      \"titleTemplate\": {\n        \"type\": \"string\",\n        \"description\": \"The template for the notification title.\",\n        \"example\": \"New {{podcastTitle}} Episode!\"\n      },\n      \"bodyTemplate\": {\n        \"type\": \"string\",\n        \"description\": \"The template for the notification body.\",\n        \"example\": \"{{episodeTitle}} has been added to {{libraryName}} library.\"\n      },\n      \"enabled\": {\n        \"type\": \"boolean\",\n        \"default\": false,\n        \"description\": \"Whether the notification is enabled.\"\n      },\n      \"notificationType\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"info\",\n          \"success\",\n          \"warning\",\n          \"failure\"\n        ],\n        \"nullable\": true,\n        \"default\": \"info\",\n        \"description\": \"The notification's type.\"\n      },\n      \"Notification\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/notificationId\"\n          },\n          \"libraryId\": {\n            \"$ref\": \"#/components/schemas/libraryIdNullable\"\n          },\n          \"eventName\": {\n            \"$ref\": \"#/components/schemas/notificationEventName\"\n          },\n          \"urls\": {\n            \"$ref\": \"#/components/schemas/urls\"\n          },\n          \"titleTemplate\": {\n            \"$ref\": \"#/components/schemas/titleTemplate\"\n          },\n          \"bodyTemplate\": {\n            \"$ref\": \"#/components/schemas/bodyTemplate\"\n          },\n          \"enabled\": {\n            \"$ref\": \"#/components/schemas/enabled\"\n          },\n          \"type\": {\n            \"$ref\": \"#/components/schemas/notificationType\"\n          },\n          \"lastFiredAt\": {\n            \"type\": \"integer\",\n            \"nullable\": true,\n            \"description\": \"The time (in ms since POSIX epoch) when the notification was last fired. Will be null if the notification has not fired.\"\n          },\n          \"lastAttemptFailed\": {\n            \"type\": \"boolean\",\n            \"description\": \"Whether the last notification attempt failed.\"\n          },\n          \"numConsecutiveFailedAttempts\": {\n            \"type\": \"integer\",\n            \"description\": \"The number of consecutive times the notification has failed.\",\n            \"default\": 0\n          },\n          \"numTimesFired\": {\n            \"type\": \"integer\",\n            \"description\": \"The number of times the notification has fired.\",\n            \"default\": 0\n          },\n          \"createdAt\": {\n            \"$ref\": \"#/components/schemas/createdAt\"\n          }\n        }\n      },\n      \"maxFailedAttempts\": {\n        \"type\": \"integer\",\n        \"minimum\": 0,\n        \"default\": 5,\n        \"description\": \"The maximum number of times a notification fails before being disabled.\"\n      },\n      \"maxNotificationQueue\": {\n        \"type\": \"integer\",\n        \"description\": \"The maximum number of notifications in the notification queue before events are ignored.\"\n      },\n      \"NotificationSettings\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/notificationId\"\n          },\n          \"appriseType\": {\n            \"type\": \"string\",\n            \"description\": \"The type of Apprise that will be used. At the moment, only api is available.\"\n          },\n          \"appriseApiUrl\": {\n            \"$ref\": \"#/components/schemas/appriseApiUrl\"\n          },\n          \"notifications\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/Notification\"\n            },\n            \"description\": \"The set notifications.\"\n          },\n          \"maxFailedAttempts\": {\n            \"$ref\": \"#/components/schemas/maxFailedAttempts\"\n          },\n          \"maxNotificationQueue\": {\n            \"$ref\": \"#/components/schemas/maxNotificationQueue\"\n          },\n          \"notificationDelay\": {\n            \"type\": \"integer\",\n            \"description\": \"The time (in ms) between notification pushes.\"\n          }\n        }\n      },\n      \"podcastId\": {\n        \"type\": \"string\",\n        \"description\": \"The ID of podcasts and podcast episodes after 2.3.0.\",\n        \"format\": \"uuid\",\n        \"example\": \"e4bb1afb-4a4f-4dd6-8be0-e615d233185b\"\n      },\n      \"PodcastMetadata\": {\n        \"type\": \"object\",\n        \"description\": \"Metadata for a podcast.\",\n        \"properties\": {\n          \"title\": {\n            \"type\": \"string\",\n            \"description\": \"The title of the podcast.\",\n            \"nullable\": true\n          },\n          \"author\": {\n            \"type\": \"string\",\n            \"description\": \"The author of the podcast.\",\n            \"nullable\": true\n          },\n          \"description\": {\n            \"type\": \"string\",\n            \"description\": \"The description of the podcast.\",\n            \"nullable\": true\n          },\n          \"releaseDate\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\",\n            \"description\": \"The release date of the podcast.\",\n            \"nullable\": true\n          },\n          \"genres\": {\n            \"type\": \"array\",\n            \"description\": \"The genres of the podcast.\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"feedUrl\": {\n            \"type\": \"string\",\n            \"description\": \"The URL of the podcast feed.\",\n            \"nullable\": true\n          },\n          \"imageUrl\": {\n            \"type\": \"string\",\n            \"description\": \"The URL of the podcast's image.\",\n            \"nullable\": true\n          },\n          \"itunesPageUrl\": {\n            \"type\": \"string\",\n            \"description\": \"The URL of the podcast's iTunes page.\",\n            \"nullable\": true\n          },\n          \"itunesId\": {\n            \"type\": \"string\",\n            \"description\": \"The iTunes ID of the podcast.\",\n            \"nullable\": true\n          },\n          \"itunesArtistId\": {\n            \"type\": \"string\",\n            \"description\": \"The iTunes artist ID of the podcast.\",\n            \"nullable\": true\n          },\n          \"explicit\": {\n            \"type\": \"boolean\",\n            \"description\": \"Whether the podcast contains explicit content.\"\n          },\n          \"language\": {\n            \"type\": \"string\",\n            \"description\": \"The language of the podcast.\",\n            \"nullable\": true\n          },\n          \"type\": {\n            \"type\": \"string\",\n            \"description\": \"The type of podcast (e.g., episodic, serial).\",\n            \"nullable\": true\n          }\n        }\n      },\n      \"oldPodcastId\": {\n        \"description\": \"The ID of podcasts on server version 2.2.23 and before.\",\n        \"type\": \"string\",\n        \"nullable\": true,\n        \"format\": \"pod_[a-z0-9]{18}\",\n        \"example\": \"pod_o78uaoeuh78h6aoeif\"\n      },\n      \"fileMetadata\": {\n        \"type\": \"object\",\n        \"description\": \"The metadata for a file, including the path, size, and unix timestamps of the file.\",\n        \"nullable\": true,\n        \"properties\": {\n          \"filename\": {\n            \"description\": \"The filename of the file.\",\n            \"type\": \"string\",\n            \"example\": \"Wizards First Rule 01.mp3\"\n          },\n          \"ext\": {\n            \"description\": \"The file extension of the file.\",\n            \"type\": \"string\",\n            \"example\": \".mp3\"\n          },\n          \"path\": {\n            \"description\": \"The absolute path on the server of the file.\",\n            \"type\": \"string\",\n            \"example\": \"/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3\"\n          },\n          \"relPath\": {\n            \"description\": \"The path of the file, relative to the book's or podcast's folder.\",\n            \"type\": \"string\",\n            \"example\": \"Wizards First Rule 01.mp3\"\n          },\n          \"size\": {\n            \"$ref\": \"#/components/schemas/size\"\n          },\n          \"mtimeMs\": {\n            \"description\": \"The time (in ms since POSIX epoch) when the file was last modified on disk.\",\n            \"type\": \"integer\",\n            \"example\": 1632223180278\n          },\n          \"ctimeMs\": {\n            \"description\": \"The time (in ms since POSIX epoch) when the file status was changed on disk.\",\n            \"type\": \"integer\",\n            \"example\": 1645978261001\n          },\n          \"birthtimeMs\": {\n            \"description\": \"The time (in ms since POSIX epoch) when the file was created on disk. Will be 0 if unknown.\",\n            \"type\": \"integer\",\n            \"example\": 0\n          }\n        }\n      },\n      \"bookChapter\": {\n        \"type\": \"object\",\n        \"description\": \"A book chapter. Includes the title and timestamps.\",\n        \"properties\": {\n          \"id\": {\n            \"description\": \"The ID of the book chapter.\",\n            \"type\": \"integer\",\n            \"example\": 0\n          },\n          \"start\": {\n            \"description\": \"When in the book (in seconds) the chapter starts.\",\n            \"type\": \"integer\",\n            \"example\": 0\n          },\n          \"end\": {\n            \"description\": \"When in the book (in seconds) the chapter ends.\",\n            \"type\": \"number\",\n            \"example\": 6004.6675\n          },\n          \"title\": {\n            \"description\": \"The title of the chapter.\",\n            \"type\": \"string\",\n            \"example\": \"Wizards First Rule 01 Chapter 1\"\n          }\n        }\n      },\n      \"audioMetaTags\": {\n        \"description\": \"ID3 metadata tags pulled from the audio file on import. Only non-null tags will be returned in requests.\",\n        \"type\": \"object\",\n        \"properties\": {\n          \"tagAlbum\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"SOT Bk01\"\n          },\n          \"tagArtist\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Terry Goodkind\"\n          },\n          \"tagGenre\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Audiobook Fantasy\"\n          },\n          \"tagTitle\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Wizards First Rule 01\"\n          },\n          \"tagSeries\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagSeriesPart\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagTrack\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"01/20\"\n          },\n          \"tagDisc\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagSubtitle\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagAlbumArtist\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Terry Goodkind\"\n          },\n          \"tagDate\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagComposer\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"example\": \"Terry Goodkind\"\n          },\n          \"tagPublisher\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagComment\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagDescription\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagEncoder\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagEncodedBy\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagIsbn\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagLanguage\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagASIN\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagOverdriveMediaMarker\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagOriginalYear\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagReleaseCountry\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagReleaseType\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagReleaseStatus\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagISRC\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagMusicBrainzTrackId\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagMusicBrainzAlbumId\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagMusicBrainzAlbumArtistId\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"tagMusicBrainzArtistId\": {\n            \"type\": \"string\",\n            \"nullable\": true\n          }\n        }\n      },\n      \"audioFile\": {\n        \"type\": \"object\",\n        \"description\": \"An audio file for a book. Includes audio metadata and track numbers.\",\n        \"properties\": {\n          \"index\": {\n            \"description\": \"The index of the audio file.\",\n            \"type\": \"integer\",\n            \"example\": 1\n          },\n          \"ino\": {\n            \"$ref\": \"#/components/schemas/inode\"\n          },\n          \"metadata\": {\n            \"$ref\": \"#/components/schemas/fileMetadata\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          },\n          \"updatedAt\": {\n            \"$ref\": \"#/components/schemas/updatedAt\"\n          },\n          \"trackNumFromMeta\": {\n            \"description\": \"The track number of the audio file as pulled from the file's metadata. Will be null if unknown.\",\n            \"type\": \"integer\",\n            \"nullable\": true,\n            \"example\": 1\n          },\n          \"discNumFromMeta\": {\n            \"description\": \"The disc number of the audio file as pulled from the file's metadata. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"trackNumFromFilename\": {\n            \"description\": \"The track number of the audio file as determined from the file's name. Will be null if unknown.\",\n            \"type\": \"integer\",\n            \"nullable\": true,\n            \"example\": 1\n          },\n          \"discNumFromFilename\": {\n            \"description\": \"The disc number of the audio file as determined from the file's name. Will be null if unknown.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"manuallyVerified\": {\n            \"description\": \"Whether the audio file has been manually verified by a user.\",\n            \"type\": \"boolean\"\n          },\n          \"invalid\": {\n            \"description\": \"Whether the audio file is missing from the server.\",\n            \"type\": \"boolean\"\n          },\n          \"exclude\": {\n            \"description\": \"Whether the audio file has been marked for exclusion.\",\n            \"type\": \"boolean\"\n          },\n          \"error\": {\n            \"description\": \"Any error with the audio file. Will be null if there is none.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"format\": {\n            \"description\": \"The format of the audio file.\",\n            \"type\": \"string\",\n            \"example\": \"MP2/3 (MPEG audio layer 2/3)\"\n          },\n          \"duration\": {\n            \"$ref\": \"#/components/schemas/durationSec\"\n          },\n          \"bitRate\": {\n            \"description\": \"The bit rate (in bit/s) of the audio file.\",\n            \"type\": \"integer\",\n            \"example\": 64000\n          },\n          \"language\": {\n            \"description\": \"The language of the audio file.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"codec\": {\n            \"description\": \"The codec of the audio file.\",\n            \"type\": \"string\",\n            \"example\": \"mp3\"\n          },\n          \"timeBase\": {\n            \"description\": \"The time base of the audio file.\",\n            \"type\": \"string\",\n            \"example\": \"1/14112000\"\n          },\n          \"channels\": {\n            \"description\": \"The number of channels the audio file has.\",\n            \"type\": \"integer\",\n            \"example\": 2\n          },\n          \"channelLayout\": {\n            \"description\": \"The layout of the audio file's channels.\",\n            \"type\": \"string\",\n            \"example\": \"stereo\"\n          },\n          \"chapters\": {\n            \"description\": \"If the audio file is part of an audiobook, the chapters the file contains.\",\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/bookChapter\"\n            }\n          },\n          \"embeddedCoverArt\": {\n            \"description\": \"The type of embedded cover art in the audio file. Will be null if none exists.\",\n            \"type\": \"string\",\n            \"nullable\": true\n          },\n          \"metaTags\": {\n            \"$ref\": \"#/components/schemas/audioMetaTags\"\n          },\n          \"mimeType\": {\n            \"description\": \"The MIME type of the audio file.\",\n            \"type\": \"string\",\n            \"example\": \"audio/mpeg\"\n          }\n        }\n      },\n      \"AudioTrack\": {\n        \"type\": \"object\",\n        \"description\": \"Represents an audio track with various properties.\",\n        \"properties\": {\n          \"index\": {\n            \"type\": \"integer\",\n            \"nullable\": true,\n            \"description\": \"The index of the audio track.\",\n            \"example\": null\n          },\n          \"startOffset\": {\n            \"type\": \"number\",\n            \"format\": \"float\",\n            \"nullable\": true,\n            \"description\": \"The start offset of the audio track in seconds.\",\n            \"example\": null\n          },\n          \"duration\": {\n            \"type\": \"number\",\n            \"format\": \"float\",\n            \"nullable\": true,\n            \"description\": \"The duration of the audio track in seconds.\",\n            \"example\": null\n          },\n          \"title\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"description\": \"The title of the audio track.\",\n            \"example\": null\n          },\n          \"contentUrl\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"description\": \"The URL where the audio track content is located.\",\n            \"example\": \"`/api/items/${itemId}/file/${audioFile.ino}`\"\n          },\n          \"mimeType\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"description\": \"The MIME type of the audio track.\",\n            \"example\": null\n          },\n          \"codec\": {\n            \"type\": \"string\",\n            \"nullable\": true,\n            \"description\": \"The codec used for the audio track.\",\n            \"example\": \"aac\"\n          },\n          \"metadata\": {\n            \"$ref\": \"#/components/schemas/fileMetadata\"\n          }\n        }\n      },\n      \"PodcastEpisode\": {\n        \"type\": \"object\",\n        \"description\": \"A single episode of a podcast.\",\n        \"properties\": {\n          \"libraryItemId\": {\n            \"$ref\": \"#/components/schemas/libraryItemId\"\n          },\n          \"podcastId\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          },\n          \"id\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          },\n          \"oldEpisodeId\": {\n            \"$ref\": \"#/components/schemas/oldPodcastId\"\n          },\n          \"index\": {\n            \"type\": \"integer\",\n            \"description\": \"The index of the episode within the podcast.\",\n            \"nullable\": true\n          },\n          \"season\": {\n            \"type\": \"string\",\n            \"description\": \"The season number of the episode.\",\n            \"nullable\": true\n          },\n          \"episode\": {\n            \"type\": \"string\",\n            \"description\": \"The episode number within the season.\",\n            \"nullable\": true\n          },\n          \"episodeType\": {\n            \"type\": \"string\",\n            \"description\": \"The type of episode (e.g., full, trailer).\",\n            \"nullable\": true\n          },\n          \"title\": {\n            \"type\": \"string\",\n            \"description\": \"The title of the episode.\",\n            \"nullable\": true\n          },\n          \"subtitle\": {\n            \"type\": \"string\",\n            \"description\": \"The subtitle of the episode.\",\n            \"nullable\": true\n          },\n          \"description\": {\n            \"type\": \"string\",\n            \"description\": \"The description of the episode.\",\n            \"nullable\": true\n          },\n          \"enclosure\": {\n            \"type\": \"object\",\n            \"description\": \"The enclosure object containing additional episode data.\",\n            \"nullable\": true,\n            \"additionalProperties\": true\n          },\n          \"guid\": {\n            \"type\": \"string\",\n            \"description\": \"The globally unique identifier for the episode.\",\n            \"nullable\": true\n          },\n          \"pubDate\": {\n            \"type\": \"string\",\n            \"description\": \"The publication date of the episode.\",\n            \"nullable\": true\n          },\n          \"chapters\": {\n            \"type\": \"array\",\n            \"description\": \"The chapters within the episode.\",\n            \"items\": {\n              \"type\": \"object\"\n            }\n          },\n          \"audioFile\": {\n            \"$ref\": \"#/components/schemas/audioFile\"\n          },\n          \"publishedAt\": {\n            \"$ref\": \"#/components/schemas/createdAt\"\n          },\n          \"addedAt\": {\n            \"$ref\": \"#/components/schemas/addedAt\"\n          },\n          \"updatedAt\": {\n            \"$ref\": \"#/components/schemas/updatedAt\"\n          },\n          \"audioTrack\": {\n            \"$ref\": \"#/components/schemas/AudioTrack\"\n          },\n          \"duration\": {\n            \"$ref\": \"#/components/schemas/durationSec\"\n          },\n          \"size\": {\n            \"$ref\": \"#/components/schemas/size\"\n          }\n        }\n      },\n      \"autoDownloadEpisodes\": {\n        \"type\": \"boolean\",\n        \"description\": \"Whether episodes are automatically downloaded.\"\n      },\n      \"Podcast\": {\n        \"type\": \"object\",\n        \"description\": \"A podcast containing multiple episodes.\",\n        \"properties\": {\n          \"id\": {\n            \"$ref\": \"#/components/schemas/podcastId\"\n          },\n          \"libraryItemId\": {\n            \"$ref\": \"#/components/schemas/libraryItemId\"\n          },\n          \"metadata\": {\n            \"$ref\": \"#/components/schemas/PodcastMetadata\"\n          },\n          \"coverPath\": {\n            \"type\": \"string\",\n            \"description\": \"The file path to the podcast's cover image.\",\n            \"nullable\": true\n          },\n          \"tags\": {\n            \"type\": \"array\",\n            \"description\": \"The tags associated with the podcast.\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"episodes\": {\n            \"type\": \"array\",\n            \"description\": \"The episodes of the podcast.\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/PodcastEpisode\"\n            }\n          },\n          \"autoDownloadEpisodes\": {\n            \"$ref\": \"#/components/schemas/autoDownloadEpisodes\"\n          },\n          \"autoDownloadSchedule\": {\n            \"type\": \"string\",\n            \"description\": \"The schedule for automatic episode downloads, in cron format.\",\n            \"nullable\": true\n          },\n          \"lastEpisodeCheck\": {\n            \"type\": \"integer\",\n            \"description\": \"The timestamp of the last episode check.\"\n          },\n          \"maxEpisodesToKeep\": {\n            \"type\": \"integer\",\n            \"description\": \"The maximum number of episodes to keep.\"\n          },\n          \"maxNewEpisodesToDownload\": {\n            \"type\": \"integer\",\n            \"description\": \"The maximum number of new episodes to download when automatically downloading epsiodes.\"\n          },\n          \"lastCoverSearch\": {\n            \"type\": \"integer\",\n            \"description\": \"The timestamp of the last cover search.\",\n            \"nullable\": true\n          },\n          \"lastCoverSearchQuery\": {\n            \"type\": \"string\",\n            \"description\": \"The query used for the last cover search.\",\n            \"nullable\": true\n          },\n          \"size\": {\n            \"type\": \"integer\",\n            \"description\": \"The total size of all episodes in bytes.\"\n          },\n          \"duration\": {\n            \"type\": \"integer\",\n            \"description\": \"The total duration of all episodes in seconds.\"\n          },\n          \"numTracks\": {\n            \"type\": \"integer\",\n            \"description\": \"The number of tracks (episodes) in the podcast.\"\n          },\n          \"latestEpisodePublished\": {\n            \"type\": \"integer\",\n            \"description\": \"The timestamp of the most recently published episode.\"\n          }\n        }\n      }\n    },\n    \"responses\": {\n      \"author404\": {\n        \"description\": \"Author not found.\",\n        \"content\": {\n          \"text/html\": {\n            \"schema\": {\n              \"type\": \"string\",\n              \"example\": \"Author not found.\"\n            }\n          }\n        }\n      },\n      \"email200\": {\n        \"description\": \"Successful response - Email\",\n        \"content\": {\n          \"application/json\": {\n            \"schema\": {\n              \"$ref\": \"#/components/schemas/EmailSettings\"\n            }\n          }\n        }\n      },\n      \"ereader200\": {\n        \"description\": \"Successful response - Ereader\",\n        \"content\": {\n          \"application/json\": {\n            \"schema\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"ereaderDevices\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/EreaderDeviceObject\"\n                  }\n                }\n              }\n            }\n          }\n        }\n      },\n      \"library200\": {\n        \"description\": \"Library found.\",\n        \"content\": {\n          \"application/json\": {\n            \"schema\": {\n              \"$ref\": \"#/components/schemas/library\"\n            }\n          }\n        }\n      },\n      \"library404\": {\n        \"description\": \"Library not found.\",\n        \"content\": {\n          \"text/html\": {\n            \"schema\": {\n              \"type\": \"string\",\n              \"example\": \"Library not found.\"\n            }\n          }\n        }\n      },\n      \"notification404\": {\n        \"description\": \"An admin user is required or notification with the given ID not found.\",\n        \"content\": {\n          \"text/html\": {\n            \"schema\": {\n              \"type\": \"string\",\n              \"example\": \"Series not found.\"\n            }\n          }\n        }\n      },\n      \"notification200\": {\n        \"description\": \"Notification endpoint success.\",\n        \"content\": {\n          \"text/html\": {\n            \"schema\": {\n              \"type\": \"string\",\n              \"example\": \"OK\"\n            }\n          }\n        }\n      },\n      \"series404\": {\n        \"description\": \"Series not found.\",\n        \"content\": {\n          \"text/html\": {\n            \"schema\": {\n              \"type\": \"string\",\n              \"example\": \"Series not found.\"\n            }\n          }\n        }\n      }\n    },\n    \"parameters\": {\n      \"minified\": {\n        \"in\": \"query\",\n        \"name\": \"minified\",\n        \"description\": \"Return minified items if true\",\n        \"schema\": {\n          \"type\": \"integer\",\n          \"minimum\": 0,\n          \"example\": 1\n        }\n      },\n      \"limit\": {\n        \"in\": \"query\",\n        \"name\": \"limit\",\n        \"description\": \"The number of items to return. This the size of a single page for the optional `page` query.\",\n        \"example\": 10,\n        \"schema\": {\n          \"type\": \"integer\",\n          \"default\": 0\n        }\n      },\n      \"page\": {\n        \"in\": \"query\",\n        \"name\": \"page\",\n        \"description\": \"The page number (zero indexed) to return. If no limit is specified, then page will have no effect.\",\n        \"example\": 0,\n        \"schema\": {\n          \"type\": \"integer\",\n          \"default\": 0\n        }\n      },\n      \"desc\": {\n        \"in\": \"query\",\n        \"name\": \"desc\",\n        \"description\": \"Return items in reversed order if true.\",\n        \"example\": 0,\n        \"schema\": {\n          \"type\": \"integer\",\n          \"default\": 0\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "docs/root.yaml",
    "content": "openapi: 3.0.0\ninfo:\n  title: Audiobookshelf API\n  version: 0.1.0\n  description: Audiobookshelf API with autogenerated OpenAPI doc\nservers:\n  - url: http://localhost:3000\n    description: Development server\ncomponents:\n  securitySchemes:\n    BearerAuth:\n      description: Bearer authentication\n      type: http\n      scheme: bearer\nsecurity:\n  - BearerAuth: []\npaths:\n  /api/authors/{id}:\n    $ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}'\n  /api/authors/{id}/image:\n    $ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'\n  /api/authors/{id}/match:\n    $ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1match'\n  /api/emails/settings:\n    $ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1settings'\n  /api/emails/test:\n    $ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1test'\n  /api/emails/ereader-devices:\n    $ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1ereader-devices'\n  /api/emails/send-ebook-to-device:\n    $ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1send-ebook-to-device'\n  /api/libraries:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries'\n  /api/libraries/{id}:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}'\n  /api/libraries/{id}/authors:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1authors'\n  /api/libraries/{id}/items:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1items'\n  /api/libraries/{id}/issues:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1issues'\n  /api/libraries/{id}/series:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series'\n  /api/libraries/{id}/series/{seriesId}:\n    $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series~1{seriesId}'\n  /api/notifications:\n    $ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications'\n  /api/notificationdata:\n    $ref: './controllers/NotificationController.yaml#/paths/~1api~1notificationdata'\n  /api/notifications/test:\n    $ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications~1test'\n  /api/notifications/{id}:\n    $ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications~1{id}'\n  /api/notifications/{id}/test:\n    $ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications~1{id}~1test'\n  /api/podcasts:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts'\n  /api/podcasts/feed:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1feed'\n  /api/podcasts/opml/parse:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml~1parse'\n  /api/podcasts/opml/create:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml~1create'\n  /api/podcasts/{id}/checknew:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1checknew'\n  /api/podcasts/{id}/clear-queue:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1clear-queue'\n  /api/podcasts/{id}/downloads:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1downloads'\n  /api/podcasts/{id}/search-episode:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1search-episode'\n  /api/podcasts/{id}/download-episodes:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1download-episodes'\n  /api/podcasts/{id}/match-episodes:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1match-episodes'\n  /api/podcasts/{id}/episode/{episodeId}:\n    $ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1episode~1{episodeId}'\n  /api/series/{id}:\n    $ref: './controllers/SeriesController.yaml#/paths/~1api~1series~1{id}'\ntags:\n  - name: Authors\n    description: Author endpoints\n  - name: Libraries\n    description: Library endpoints\n  - name: Series\n    description: Series endpoints\n  - name: Email\n    description: Email endpoints\n  - name: Notification\n    description: Notifications endpoints\n  - name: Podcasts\n    description: Podcast endpoints\n"
  },
  {
    "path": "docs/schemas.yaml",
    "content": "components:\n  schemas:\n    addedAt:\n      type: integer\n      description: The time (in ms since POSIX epoch) when added to the server.\n      example: 1633522963509\n    createdAt:\n      type: integer\n      description: The time (in ms since POSIX epoch) when was created.\n      example: 1633522963509\n    updatedAt:\n      type: integer\n      description: The time (in ms since POSIX epoch) when last updated.\n      example: 1633522963509\n    size:\n      description: The total size (in bytes) of the item or file.\n      type: integer\n      example: 268824228\n    durationSec:\n      description: The total length (in seconds) of the item or file.\n      type: number\n      example: 33854.905\n    duration:\n      description: The total length of the item or file.\n      type: string\n      example: '01:23:45'\n    tags:\n      description: Tags applied to items.\n      type: array\n      items:\n        type: string\n      example: ['To Be Read', 'Genre: Nonfiction']\n    inode:\n      description: The inode of the item in the file system.\n      type: string\n      format: '[0-9]*'\n      example: '649644248522215260'\n    total:\n      description: The total number of items in the response.\n      type: integer\n      example: 100\n    limit:\n      description: The number of items to return. If 0, no items are returned.\n      type: integer\n      example: 10\n      default: 0\n    page:\n      description: The page number (zero indexed) to return. If no limit is specified, then page will have no effect.\n      type: integer\n      example: 1\n      default: 0\n    sortDesc:\n      description: Return items in reversed order if true.\n      type: boolean\n      example: true\n      default: false\n    minified:\n      description: Return minified items if true.\n      type: boolean\n      example: true\n      default: false\n    region:\n      description: The region used to search.\n      type: string\n      example: 'us'\n      default: 'us'\n  parameters:\n    minified:\n      in: query\n      name: minified\n      description: Return minified items if true\n      schema:\n        type: integer\n        minimum: 0\n        example: 1\n"
  },
  {
    "path": "index.js",
    "content": "const optionDefinitions = [\n  { name: 'config', alias: 'c', type: String },\n  { name: 'metadata', alias: 'm', type: String },\n  { name: 'port', alias: 'p', type: String },\n  { name: 'host', alias: 'h', type: String },\n  { name: 'source', alias: 's', type: String },\n  { name: 'dev', alias: 'd', type: Boolean },\n  // Run in production mode and use dev.js config\n  { name: 'prod-with-dev-env', alias: 'r', type: Boolean }\n]\n\nconst commandLineArgs = require('./server/libs/commandLineArgs')\nconst options = commandLineArgs(optionDefinitions)\n\nconst Path = require('path')\nprocess.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'\n\nconst server = require('./server/Server')\nglobal.appRoot = __dirname\n\nconst isDev = process.env.NODE_ENV !== 'production'\nif (isDev || options['prod-with-dev-env']) {\n  const devEnv = require('./dev').config\n  if (devEnv.Port) process.env.PORT = devEnv.Port\n  if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath\n  if (devEnv.MetadataPath) process.env.METADATA_PATH = devEnv.MetadataPath\n  if (devEnv.FFmpegPath) process.env.FFMPEG_PATH = devEnv.FFmpegPath\n  if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath\n  if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath\n  if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'\n  if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'\n  if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath\n  if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath\n  process.env.SOURCE = 'local'\n  process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'\n}\n\nconst inputConfig = options.config ? Path.resolve(options.config) : null\nconst inputMetadata = options.metadata ? Path.resolve(options.metadata) : null\n\nconst PORT = options.port || process.env.PORT || 3333\nconst HOST = options.host || process.env.HOST\nconst CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')\nconst METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')\nconst SOURCE = options.source || process.env.SOURCE || 'debian'\n\nconst ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'\n\nconsole.log(`Running in ${process.env.NODE_ENV} mode.`)\nconsole.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`)\n\nconst Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)\nServer.start()\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"audiobookshelf\",\n  \"version\": \"2.33.1\",\n  \"buildNumber\": 1,\n  \"description\": \"Self-hosted audiobook and podcast server\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"dev\": \"nodemon --watch server index.js -- --dev\",\n    \"start\": \"node index.js\",\n    \"start-dev\": \"node index.js --prod-with-dev-env\",\n    \"client\": \"cd client && npm ci && npm run generate\",\n    \"prod\": \"npm run client && npm ci && node index.js\",\n    \"build-win\": \"npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .\",\n    \"build-win-no-compress\": \"npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf .\",\n    \"build-linux\": \"build/linuxpackager\",\n    \"docker\": \"docker buildx build --platform linux/amd64,linux/arm64 --push .  -t advplyr/audiobookshelf\",\n    \"docker-amd64-local\": \"docker buildx build --platform linux/amd64 --load .  -t advplyr/audiobookshelf-amd64-local\",\n    \"docker-arm64-local\": \"docker buildx build --platform linux/arm64 --load .  -t advplyr/audiobookshelf-arm64-local\",\n    \"deploy-linux\": \"node deploy/linux\",\n    \"test\": \"mocha\",\n    \"coverage\": \"nyc mocha\"\n  },\n  \"bin\": \"index.js\",\n  \"pkg\": {\n    \"assets\": [\n      \"client/dist/**/*\",\n      \"node_modules/sqlite3/lib/binding/**/*.node\",\n      \"server/migrations/*.js\"\n    ],\n    \"scripts\": [\n      \"index.js\",\n      \"server/**/*.js\"\n    ]\n  },\n  \"mocha\": {\n    \"recursive\": true\n  },\n  \"author\": \"advplyr\",\n  \"license\": \"GPL-3.0\",\n  \"dependencies\": {\n    \"axios\": \"^0.27.2\",\n    \"cookie-parser\": \"^1.4.6\",\n    \"express\": \"^4.17.1\",\n    \"express-rate-limit\": \"^7.5.1\",\n    \"express-session\": \"^1.17.3\",\n    \"graceful-fs\": \"^4.2.10\",\n    \"htmlparser2\": \"^8.0.1\",\n    \"lru-cache\": \"^10.0.3\",\n    \"node-unrar-js\": \"^2.0.2\",\n    \"nodemailer\": \"^6.9.13\",\n    \"openid-client\": \"^5.6.1\",\n    \"p-throttle\": \"^4.1.1\",\n    \"passport\": \"^0.6.0\",\n    \"passport-jwt\": \"^4.0.1\",\n    \"semver\": \"^7.6.3\",\n    \"sequelize\": \"^6.35.2\",\n    \"socket.io\": \"^4.5.4\",\n    \"sqlite3\": \"^5.1.7\",\n    \"ssrf-req-filter\": \"^1.1.0\",\n    \"xml2js\": \"^0.5.0\"\n  },\n  \"devDependencies\": {\n    \"chai\": \"^4.3.10\",\n    \"mocha\": \"^10.2.0\",\n    \"nodemon\": \"^2.0.20\",\n    \"nyc\": \"^15.1.0\",\n    \"sinon\": \"^17.0.1\"\n  }\n}\n"
  },
  {
    "path": "prod.js",
    "content": "const optionDefinitions = [\n  { name: 'config', alias: 'c', type: String },\n  { name: 'metadata', alias: 'm', type: String },\n  { name: 'port', alias: 'p', type: String },\n  { name: 'host', alias: 'h', type: String },\n  { name: 'source', alias: 's', type: String }\n]\n\nconst commandLineArgs = require('./server/libs/commandLineArgs')\nconst options = commandLineArgs(optionDefinitions)\n\nconst Path = require('path')\nprocess.env.NODE_ENV = 'production'\n\nconst server = require('./server/Server')\n\nglobal.appRoot = __dirname\n\nvar inputConfig = options.config ? Path.resolve(options.config) : null\nvar inputMetadata = options.metadata ? Path.resolve(options.metadata) : null\n\nconst PORT = options.port || process.env.PORT || 3333\nconst HOST = options.host || process.env.HOST\nconst CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')\nconst METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')\nconst SOURCE = options.source || process.env.SOURCE || 'debian'\n\nconst ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'\n\nconsole.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)\n\nconst Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)\nServer.start()\n"
  },
  {
    "path": "readme.md",
    "content": "<br />\n<div align=\"center\">\n   <img alt=\"Audiobookshelf Banner\" src=\"https://github.com/advplyr/audiobookshelf/raw/master/images/banner.svg\" width=\"600\">\n\n  <p align=\"center\">\n    <br />\n    <a href=\"https://audiobookshelf.org/docs\">Documentation</a>\n    ·\n    <a href=\"https://audiobookshelf.org/guides\">User Guides</a>\n    ·\n    <a href=\"https://audiobookshelf.org/support\">Support</a>\n  </p>\n</div>\n\n# About\n\nAudiobookshelf is a self-hosted audiobook and podcast server.\n\n### Features\n\n- Fully **open-source**, including the [android & iOS app](https://github.com/advplyr/audiobookshelf-app) _(in beta)_\n- Stream all audio formats on the fly\n- Search and add podcasts to download episodes w/ auto-download\n- Multi-user support w/ custom permissions\n- Keeps progress per user and syncs across devices\n- Auto-detects library updates, no need to re-scan\n- Upload books and podcasts w/ bulk upload drag and drop folders\n- Backup your metadata + automated daily backups\n- Progressive Web App (PWA)\n- Chromecast support on the web app and android app\n- Fetch metadata and cover art from several sources\n- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))\n- Merge your audio files into a single m4b\n- Embed metadata and cover image into your audio files\n- Basic ebook support and ereader\n  - Epub, pdf, cbr, cbz\n  - Send ebook to device (i.e. Kindle)\n- Open RSS feeds for podcasts and audiobooks\n\nIs there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)\n\nJoin us on [Discord](https://discord.gg/HQgCbd6E75)\n\n### Demo\n\nCheck out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)\n\nUsername/password: `demo`/`demo` (user account)\n\n### Android App (beta)\n\nTry it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)\n\n### iOS App (beta)\n\n**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord.**\n\nUsing Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**\n\n### Build your own tools & clients\n\nCheck out the [API documentation](https://api.audiobookshelf.org/)\n\n<br />\n\n<img alt=\"Library Screenshot\" src=\"https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png\" />\n\n<br />\n\n# Organizing your audiobooks\n\n#### Directory structure and folder names are important to Audiobookshelf!\n\nSee [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.\n\n<br />\n\n# Installation\n\nSee [install docs](https://www.audiobookshelf.org/docs)\n\n<br />\n\n# Reverse Proxy Set Up\n\n#### Important! Audiobookshelf requires a websocket connection.\n\n#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)\n\n### NGINX Proxy Manager\n\nToggle websockets support.\n\n<img alt=\"NGINX Web socket\" src=\"https://user-images.githubusercontent.com/67830747/153679106-b2a7f5b9-0702-48c6-9740-b26b401986e9.png\" />\n\n### NGINX Reverse Proxy\n\nAdd this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.\n\n```bash\nserver {\n   listen 443 ssl;\n   server_name <sub>.<domain>.<tld>;\n\n   access_log /var/log/nginx/audiobookshelf.access.log;\n   error_log /var/log/nginx/audiobookshelf.error.log;\n\n   ssl_certificate      /path/to/certificate;\n   ssl_certificate_key  /path/to/key;\n\n   location / {\n      proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;\n      proxy_set_header X-Forwarded-Proto  $scheme;\n      proxy_set_header Host               $http_host;\n      proxy_set_header Upgrade            $http_upgrade;\n      proxy_set_header Connection         \"upgrade\";\n\n      proxy_http_version                  1.1;\n\n      proxy_pass                          http://<URL_to_forward_to>;\n      proxy_redirect                      http:// https://;\n\n      # Prevent 413 Request Entity Too Large error\n      # by increasing the maximum allowed size of the client request body\n      # For example, set it to 10 GiB\n      client_max_body_size                10240M;\n   }\n}\n```\n\n### Apache Reverse Proxy\n\nAdd this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.\n\nFor this to work you must enable at least the following mods using `a2enmod`:\n\n- `ssl`\n- `proxy`\n- `proxy_http`\n- `proxy_balancer`\n- `proxy_wstunnel`\n- `rewrite`\n\n```bash\n<IfModule mod_ssl.c>\n<VirtualHost *:443>\n    ServerName <sub>.<domain>.<tld>\n\n    ErrorLog ${APACHE_LOG_DIR}/error.log\n    CustomLog ${APACHE_LOG_DIR}/access.log combined\n\n    ProxyPreserveHost On\n    ProxyPass / http://localhost:<audiobookshelf_port>/\n    RewriteEngine on\n    RewriteCond %{HTTP:Upgrade} websocket [NC]\n    RewriteCond %{HTTP:Connection} upgrade [NC]\n    RewriteRule ^/?(.*) \"ws://localhost:<audiobookshelf_port>/$1\" [P,L]\n\n    # unless you're doing something special this should be generated by a\n    # tool like certbot by let's encrypt\n    SSLCertificateFile /path/to/cert/file\n    SSLCertificateKeyFile /path/to/key/file\n</VirtualHost>\n</IfModule>\n```\n\nIf using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example:\n\n```xml\n    <Location /audiobookshelf>\n        ProxyPreserveHost on\n        ProxyPass http://localhost:<audiobookshelf_port>/audiobookshelf upgrade=websocket\n        ProxyPassReverse http://localhost:<audiobookshelf_port>/audiobookshelf\n    </Location>\n```\n\nSome SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly:\n\n```bash\n<VirtualHost *:443>\n    # ...\n\n    # create the directory structure  /.well-known/acme-challenges\n    # within DocumentRoot and give the HTTP user recursive write\n    # access to it.\n    DocumentRoot /path/to/local/directory\n\n    ProxyPreserveHost On\n    ProxyPass /.well-known !\n    ProxyPass / http://localhost:<audiobookshelf_port>/\n\n    # ...\n</VirtualHost>\n```\n\n### SWAG Reverse Proxy\n\n[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)\n\n### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)\n\n1. **Open Control Panel**\n\n   - Navigate to `Login Portal > Advanced`.\n\n2. **General Tab**\n\n   - Click `Reverse Proxy` > `Create`.\n\n   | Setting            | Value          |\n   | ------------------ | -------------- |\n   | Reverse Proxy Name | audiobookshelf |\n\n3. **Source Configuration**\n\n   | Setting                | Value                                    |\n   | ---------------------- | ---------------------------------------- |\n   | Protocol               | HTTPS                                    |\n   | Hostname               | `<sub>.<quickconnectdomain>.synology.me` |\n   | Port                   | 443                                      |\n   | Access Control Profile | Leave as is                              |\n\n   - Example Hostname: `audiobookshelf.mydomain.synology.me`\n\n4. **Destination Configuration**\n\n   | Setting  | Value       |\n   | -------- | ----------- |\n   | Protocol | HTTP        |\n   | Hostname | Your NAS IP |\n   | Port     | 13378       |\n\n5. **Custom Header Tab**\n\n   - Go to `Create > Websocket`.\n   - Configure Headers (leave as is):\n\n   | Header Name | Value                 |\n   | ----------- | --------------------- |\n   | Upgrade     | `$http_upgrade`       |\n   | Connection  | `$connection_upgrade` |\n\n6. **Advanced Settings Tab**\n   - Leave as is.\n\n### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)\n\nMiddleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:\n\n<ul>\n   <li>accessControlAllowMethods</li>\n   <li>accessControlAllowOriginList</li>\n   <li>accessControlMaxAge</li>\n</ul>\n\nFrom [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) <br />\n\n### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)\n\n```\nsubdomain.domain.com {\n        encode gzip zstd\n        reverse_proxy <LOCAL_IP>:<PORT>\n}\n```\n\n### HAProxy\n\nBelow is a generic HAProxy config, using `audiobookshelf.YOUR_DOMAIN.COM`.\n\nTo use `http2`, `ssl` is needed.\n\n```make\nglobal\n    # ... (your global settings go here)\n\ndefaults\n    mode http\n    # ... (your default settings go here)\n\nfrontend my_frontend\n    # Bind to port 443, enable SSL, and specify the certificate list file\n    bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1\n    mode http\n\n    # Define an ACL for subdomains starting with \"audiobookshelf\"\n    acl is_audiobookshelf hdr_beg(host) -i audiobookshelf\n\n    # Use the ACL to route traffic to audiobookshelf_backend if the condition is met,\n    # otherwise, use the default_backend\n    use_backend audiobookshelf_backend if is_audiobookshelf\n    default_backend default_backend\n\nbackend audiobookshelf_backend\n    mode http\n    # ... (backend settings for audiobookshelf go here)\n\n    # Define the server for the audiobookshelf backend\n    server audiobookshelf_server 127.0.0.99:13378\n\nbackend default_backend\n    mode http\n    # ... (default backend settings go here)\n\n    # Define the server for the default backend\n    server default_server 127.0.0.123:8081\n\n```\n\n### pfSense and HAProxy\n\nFor pfSense the inputs are graphical, and `Health checking` is enabled.\n\n#### Frontend, Default backend, access control lists and actions\n\n##### Access Control lists\n\n|      Name      |    Expression     | CS  | Not |      Value      |\n| :------------: | :---------------: | :-: | :-: | :-------------: |\n| audiobookshelf | Host starts with: |     |     | audiobookshelf. |\n\n##### Actions\n\nThe `condition acl names` needs to match the name above `audiobookshelf`.\n\n|    Action     |   Parameters   | Condition acl names |\n| :-----------: | :------------: | :-----------------: |\n| `Use Backend` | audiobookshelf |   audiobookshelf    |\n\n#### Backend\n\nThe `Name` needs to match the `Parameters` above `audiobookshelf`.\n\n| Name | audiobookshelf |\n| ---- | -------------- |\n\n##### Server list:\n\n|      Name      |    Expression     | CS  | Not |      Value      |\n| :------------: | :---------------: | :-: | :-: | :-------------: |\n| audiobookshelf | Host starts with: |     |     | audiobookshelf. |\n\n##### Health checking:\n\nHealth checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. Need to do one of following:\n\n- To disable: Change `Health check method` to `none`.\n- To make Health checking function: Change `Http check method` to `HEAD` or `GET`.\n\n# Run from source\n\n# Contributing\n\nThis application is built using [NodeJs](https://nodejs.org/).\n\n### Localization\n\nThank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href=\"https://hosted.weblate.org/engage/audiobookshelf/\"> <img src=\"https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg\" alt=\"Translation status\" /> </a>\n\n### Dev Container Setup\n\nThe easiest way to begin developing this project is to use a dev container. An introduction to dev containers in VSCode can be found [here](https://code.visualstudio.com/docs/devcontainers/containers).\n\nRequired Software:\n\n- [Docker Desktop](https://www.docker.com/products/docker-desktop/)\n- [VSCode](https://code.visualstudio.com/download)\n\n_Note, it is possible to use other container software than Docker and IDEs other than VSCode. However, this setup is more complicated and not covered here._\n\n<div>\n<details>\n<summary>Install the required software on Windows with <a href=(https://docs.microsoft.com/en-us/windows/package-manager/winget/#production-recommended)>winget</a></summary>\n\n<p>\nNote: This requires a PowerShell prompt with winget installed.  You should be able to copy and paste the code block to install.  If you use an elevated PowerShell prompt, UAC will not pop up during the installs.\n\n```PowerShell\nwinget install -e --id Docker.DockerDesktop; `\nwinget install -e --id Microsoft.VisualStudioCode\n```\n\n</p>\n</details>\n</div>\n\n<div>\n<details>\n<summary>Install the required software on MacOS with <a href=(https://snapcraft.io/)>homebrew</a></summary>\n\n<p>\n\n```sh\nbrew install --cask docker visual-studio-code\n```\n\n</p>\n</details>\n</div>\n\n<div style=\"padding-bottom: 1em\">\n<details>\n<summary>Install the required software on Linux with <a href=(https://brew.sh/)>snap</a></summary>\n\n<p>\n\n```sh\nsudo snap install docker; \\\nsudo snap install code --classic\n```\n\n</p>\n</details>\n</div>\n\nAfter installing these packages, you can now install the [Remote Development](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) extension for VSCode. After installing this extension open the command pallet (`ctrl+shift+p` or `cmd+shift+p`) and select the command `>Dev Containers: Rebuild and Reopen in Container`. This will cause the development environment container to be built and launched.\n\nYou are now ready to start development!\n\n### Manual Environment Setup\n\nIf you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 20) and [FFmpeg](https://ffmpeg.org/).\n\nNext you will need to create a `dev.js` file in the project's root directory. This contains configuration information and paths unique to your development environment. You can find an example of this file in `.devcontainer/dev.js`.\n\nYou are now ready to build the client:\n\n```sh\nnpm ci\ncd client\nnpm ci\nnpm run generate\ncd ..\n```\n\n### Development Commands\n\nAfter setting up your development environment, either using the dev container or using your own custom environment, the following commands will help you run the server and client.\n\nTo run the server, you can use the command `npm run dev`. This will use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. If you make changes to the server, you will need to restart the server. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.\n\nYou can also build a version of the client that supports live reloading. To do this, start the server, then run the command `(cd client; npm run dev)`. This will run a separate instance of the client at `localhost:3000` that will be automatically updated as you make changes to the client.\n\nIf you are using VSCode, this project includes a couple of pre-defined targets to speed up this process. First, if you build the project (`ctrl+shift+b` or `cmd+shift+b`) it will automatically generate the client. Next, there are debug commands for running the server and client. You can view these targets using the debug panel (bring it up with (`ctrl+shift+d` or `cmd+shift+d`):\n\n- `Debug server`—Run the server.\n- `Debug client (nuxt)`—Run the client with live reload.\n- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.\n\n# How to Support\n\n[See the incomplete \"How to Support\" page](https://www.audiobookshelf.org/support)\n"
  },
  {
    "path": "server/Auth.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst passport = require('passport')\nconst JwtStrategy = require('passport-jwt').Strategy\nconst ExtractJwt = require('passport-jwt').ExtractJwt\n\nconst Database = require('./Database')\nconst Logger = require('./Logger')\nconst TokenManager = require('./auth/TokenManager')\nconst LocalAuthStrategy = require('./auth/LocalAuthStrategy')\nconst OidcAuthStrategy = require('./auth/OidcAuthStrategy')\n\nconst RateLimiterFactory = require('./utils/rateLimiterFactory')\nconst { escapeRegExp } = require('./utils')\n\n/**\n * @class Class for handling all the authentication related functionality.\n */\nclass Auth {\n  constructor() {\n    const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)\n    this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]\n\n    /** @type {import('express-rate-limit').RateLimitRequestHandler} */\n    this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter()\n\n    this.tokenManager = new TokenManager()\n    this.localAuthStrategy = new LocalAuthStrategy()\n    this.oidcAuthStrategy = new OidcAuthStrategy()\n  }\n\n  /**\n   * Checks if the request should not be authenticated.\n   * @param {Request} req\n   * @returns {boolean}\n   */\n  authNotNeeded(req) {\n    return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path))\n  }\n\n  /**\n   * Middleware to register passport in express-session\n   *\n   * @param {function} middleware\n   */\n  ifAuthNeeded(middleware) {\n    return (req, res, next) => {\n      if (this.authNotNeeded(req)) {\n        return next()\n      }\n      middleware(req, res, next)\n    }\n  }\n\n  /**\n   * middleware to use in express to only allow authenticated users.\n   *\n   * @param {Request} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  isAuthenticated(req, res, next) {\n    return passport.authenticate('jwt', { session: false })(req, res, next)\n  }\n\n  /**\n   * Function to generate a jwt token for a given user\n   * TODO: Old method with no expiration\n   * @deprecated\n   *\n   * @param {{ id:string, username:string }} user\n   * @returns {string}\n   */\n  generateAccessToken(user) {\n    return this.tokenManager.generateAccessToken(user)\n  }\n\n  /**\n   * Invalidate all JWT sessions for a given user\n   * If user is current user and refresh token is valid, rotate tokens for the current session\n   *\n   * @param {import('./models/User')} user\n   * @param {Request} req\n   * @param {Response} res\n   * @returns {Promise<string>} accessToken only if user is current user and refresh token is valid\n   */\n  async invalidateJwtSessionsForUser(user, req, res) {\n    return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)\n  }\n\n  /**\n   * Return the login info payload for a user\n   *\n   * @param {import('./models/User')} user\n   * @returns {Promise<Object>} jsonPayload\n   */\n  async getUserLoginResponsePayload(user) {\n    const libraryIds = await Database.libraryModel.getAllLibraryIds()\n    return {\n      user: user.toOldJSONForBrowser(),\n      userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),\n      serverSettings: Database.serverSettings.toJSONForBrowser(),\n      ereaderDevices: Database.emailSettings.getEReaderDevices(user),\n      Source: global.Source\n    }\n  }\n\n  // #region Passport strategies\n  /**\n   * Inializes all passportjs strategies and other passportjs ralated initialization.\n   */\n  async initPassportJs() {\n    // Check if we should load the local strategy (username + password login)\n    if (global.ServerSettings.authActiveAuthMethods.includes('local')) {\n      this.localAuthStrategy.init()\n    }\n\n    // Check if we should load the openid strategy\n    if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {\n      this.oidcAuthStrategy.init()\n    }\n\n    // Load the JwtStrategy (always) -> for bearer token auth\n    passport.use(\n      new JwtStrategy(\n        {\n          jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),\n          secretOrKey: TokenManager.TokenSecret,\n          // Handle expiration manaully in order to disable api keys that are expired\n          ignoreExpiration: true\n        },\n        this.tokenManager.jwtAuthCheck.bind(this)\n      )\n    )\n\n    // define how to seralize a user (to be put into the session)\n    passport.serializeUser(function (user, cb) {\n      process.nextTick(function () {\n        // only store id to session\n        return cb(\n          null,\n          JSON.stringify({\n            id: user.id\n          })\n        )\n      })\n    })\n\n    // define how to deseralize a user (use the ID to get it from the database)\n    passport.deserializeUser(\n      function (user, cb) {\n        process.nextTick(\n          async function () {\n            const parsedUserInfo = JSON.parse(user)\n            // load the user by ID that is stored in the session\n            const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)\n            return cb(null, dbUser)\n          }.bind(this)\n        )\n      }.bind(this)\n    )\n  }\n  // #endregion\n\n  /**\n   * Unuse strategy\n   *\n   * @param {string} name\n   */\n  unuseAuthStrategy(name) {\n    if (name === 'openid') {\n      this.oidcAuthStrategy.unuse()\n    } else if (name === 'local') {\n      this.localAuthStrategy.unuse()\n    } else {\n      Logger.error('[Auth] Invalid auth strategy ' + name)\n    }\n  }\n\n  /**\n   * Use strategy\n   *\n   * @param {string} name\n   */\n  useAuthStrategy(name) {\n    if (name === 'openid') {\n      this.oidcAuthStrategy.init()\n    } else if (name === 'local') {\n      this.localAuthStrategy.init()\n    } else {\n      Logger.error('[Auth] Invalid auth strategy ' + name)\n    }\n  }\n\n  /**\n   * Returns if the given auth method is API based.\n   *\n   * @param {string} authMethod\n   * @returns {boolean}\n   */\n  isAuthMethodAPIBased(authMethod) {\n    return ['api', 'openid-mobile'].includes(authMethod)\n  }\n\n  /**\n   * Stores the client's choice of login callback method in temporary cookies.\n   *\n   * The `authMethod` parameter specifies the authentication strategy and can have the following values:\n   * - 'local': Standard authentication,\n   * - 'api': Authentication for API use\n   * - 'openid': OpenID authentication directly over web\n   * - 'openid-mobile': OpenID authentication, but done via an mobile device\n   *\n   * @param {Request} req\n   * @param {Response} res\n   * @param {string} authMethod - The authentication method, default is 'local'.\n   * @returns {Object|null} - Returns error object if validation fails, null if successful\n   */\n  paramsToCookies(req, res, authMethod = 'local') {\n    const TWO_MINUTES = 120000 // 2 minutes in milliseconds\n    const callback = req.query.redirect_uri || req.query.callback\n\n    // Additional handling for non-API based authMethod\n    if (!this.isAuthMethodAPIBased(authMethod)) {\n      // Store 'auth_state' if present in the request\n      if (req.query.state) {\n        res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })\n      }\n\n      // Validate and store the callback URL\n      if (!callback) {\n        res.status(400).send({ message: 'No callback parameter' })\n        return { error: 'No callback parameter' }\n      }\n\n      // Security: Validate callback URL is same-origin only\n      if (!this.oidcAuthStrategy.isValidWebCallbackUrl(callback, req)) {\n        Logger.warn(`[Auth] Rejected invalid callback URL: ${callback}`)\n        res.status(400).send({ message: 'Invalid callback URL - must be same-origin' })\n        return { error: 'Invalid callback URL - must be same-origin' }\n      }\n\n      res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })\n    }\n\n    // Store the authentication method for long\n    Logger.debug(`[Auth] paramsToCookies: setting auth_method cookie to ${authMethod}`)\n    res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })\n    return null\n  }\n\n  /**\n   * Informs the client in the right mode about a successfull login and the token\n   * (clients choise is restored from cookies).\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async handleLoginSuccessBasedOnCookie(req, res) {\n    // Handle token generation and get userResponse object\n    // For API based auth (e.g. mobile), we will return the refresh token in the response\n    const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method)\n    Logger.debug(`[Auth] handleLoginSuccessBasedOnCookie: isApiBased: ${isApiBased}, auth_method: ${req.cookies.auth_method}`)\n    const userResponse = await this.handleLoginSuccess(req, res, isApiBased)\n\n    if (isApiBased) {\n      // REST request - send data\n      res.json(userResponse)\n    } else {\n      // UI request -> check if we have a callback url\n      if (req.cookies.auth_cb) {\n        let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''\n        // UI request -> redirect to auth_cb url and send the jwt token as parameter\n        // TODO: Temporarily continue sending the old token as setToken\n        res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)\n      } else {\n        res.status(400).send('No callback or already expired')\n      }\n    }\n  }\n\n  /**\n   * After login success from local or oidc\n   * req.user is set by passport.authenticate\n   *\n   * attaches the access token to the user in the response\n   * if returnTokens is true, also attaches the refresh token to the user in the response\n   *\n   * if returnTokens is false, sets the refresh token cookie\n   *\n   * @param {Request} req\n   * @param {Response} res\n   * @param {boolean} returnTokens\n   */\n  async handleLoginSuccess(req, res, returnTokens = false) {\n    // Create tokens and session\n    const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req)\n\n    const userResponse = await this.getUserLoginResponsePayload(req.user)\n\n    userResponse.user.refreshToken = returnTokens ? refreshToken : null\n    userResponse.user.accessToken = accessToken\n\n    Logger.debug(`[Auth] handleLoginSuccess: returnTokens: ${returnTokens}, isRefreshTokenInResponse: ${!!userResponse.user.refreshToken}`)\n\n    if (!returnTokens) {\n      this.tokenManager.setRefreshTokenCookie(req, res, refreshToken)\n    }\n\n    return userResponse\n  }\n\n  // #region Auth routes\n  /**\n   * Creates all (express) routes required for authentication.\n   *\n   * @param {import('express').Router} router\n   */\n  async initAuthRoutes(router) {\n    // Local strategy login route (takes username and password)\n    router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => {\n      // Check if mobile app wants refresh token in response\n      const returnTokens = req.headers['x-return-tokens'] === 'true'\n\n      const userResponse = await this.handleLoginSuccess(req, res, returnTokens)\n      res.json(userResponse)\n    })\n\n    // Refresh token route\n    router.post('/auth/refresh', this.authRateLimiter, async (req, res) => {\n      let refreshToken = req.cookies.refresh_token\n\n      // If x-refresh-token header is present, use it instead of the cookie\n      // and return the refresh token in the response\n      let shouldReturnRefreshToken = false\n      if (req.headers['x-refresh-token']) {\n        refreshToken = req.headers['x-refresh-token']\n        shouldReturnRefreshToken = true\n      }\n\n      if (!refreshToken) {\n        Logger.error(`[Auth] Failed to refresh token. No refresh token provided`)\n        return res.status(401).json({ error: 'No refresh token provided' })\n      }\n\n      Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`)\n\n      const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res)\n      if (refreshResponse.error) {\n        return res.status(401).json({ error: refreshResponse.error })\n      }\n\n      const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user)\n\n      userResponse.user.accessToken = refreshResponse.accessToken\n      userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null\n      res.json(userResponse)\n    })\n\n    // openid strategy login route (this redirects to the configured openid login provider)\n    router.get('/auth/openid', this.authRateLimiter, (req, res) => {\n      const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req)\n\n      if (authorizationUrlResponse.error) {\n        return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)\n      }\n\n      // Check if paramsToCookies sent a response (e.g., due to invalid callback URL)\n      const cookieResult = this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid')\n      if (cookieResult && cookieResult.error) {\n        return // Response already sent by paramsToCookies\n      }\n\n      res.redirect(authorizationUrlResponse.authorizationUrl)\n    })\n\n    // This will be the oauth2 callback route for mobile clients\n    // It will redirect to an app-link like audiobookshelf://oauth\n    router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res))\n\n    // openid strategy callback route (this receives the token from the configured openid login provider)\n    router.get(\n      '/auth/openid/callback',\n      this.authRateLimiter,\n      (req, res, next) => {\n        const sessionKey = this.oidcAuthStrategy.getStrategy()._key\n\n        if (!req.session[sessionKey]) {\n          return res.status(400).send('No session')\n        }\n\n        // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request\n        // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request\n        // Crucial for API/Mobile clients\n        if (req.query.code_verifier) {\n          req.session[sessionKey].code_verifier = req.query.code_verifier\n        }\n\n        function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {\n          Logger.error(JSON.stringify(logMessage, null, 2))\n          if (response) {\n            // Depending on the error, it can also have a body\n            // We also log the request header the passport plugin sents for the URL\n            const header = response.req?._header.replace(/Authorization: [^\\r\\n]*/i, 'Authorization: REDACTED')\n            Logger.debug(header + '\\n' + JSON.stringify(response.body, null, 2))\n          }\n\n          if (isMobile) {\n            return res.status(errorCode).send(errorMessage)\n          } else {\n            return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)\n          }\n        }\n\n        function passportCallback(req, res, next) {\n          return (err, user, info) => {\n            const isMobile = req.session[sessionKey]?.mobile === true\n            if (err) {\n              return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)\n            }\n\n            if (!user) {\n              // Info usually contains the error message from the SSO provider\n              return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)\n            }\n\n            req.logIn(user, (loginError) => {\n              if (loginError) {\n                return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)\n              }\n\n              // The id_token does not provide access to the user, but is used to identify the user to the SSO provider\n              //   instead it containts a JWT with userinfo like user email, username, etc.\n              //   the client will get to know it anyway in the logout url according to the oauth2 spec\n              //   so it is safe to send it to the client, but we use strict settings\n              res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })\n              next()\n            })\n          }\n        }\n\n        // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request\n        // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided\n        // We set it here again because the passport param can change between requests\n        return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)\n      },\n      // on a successfull login: read the cookies and react like the client requested (callback or json)\n      this.handleLoginSuccessBasedOnCookie.bind(this)\n    )\n\n    /**\n     * Helper route used to auto-populate the openid URLs in config/authentication\n     * Takes an issuer URL as a query param and requests the config data at \"/.well-known/openid-configuration\"\n     *\n     * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/\n     */\n    router.get('/auth/openid/config', this.authRateLimiter, this.isAuthenticated, async (req, res) => {\n      if (!req.user.isAdminOrUp) {\n        Logger.error(`[Auth] Non-admin user \"${req.user.username}\" attempted to get issuer config`)\n        return res.sendStatus(403)\n      }\n\n      if (!req.query.issuer || typeof req.query.issuer !== 'string') {\n        return res.status(400).send(\"Invalid request. Query param 'issuer' is required\")\n      }\n\n      const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer)\n      if (openIdIssuerConfig.error) {\n        return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error)\n      }\n\n      res.json(openIdIssuerConfig)\n    })\n\n    // Logout route\n    router.post('/logout', async (req, res) => {\n      // Refresh token be alternatively be sent in the header\n      const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']\n\n      // Clear refresh token cookie\n      res.clearCookie('refresh_token', {\n        path: '/'\n      })\n\n      // Invalidate the session in database using refresh token\n      if (refreshToken) {\n        await this.tokenManager.invalidateRefreshToken(refreshToken)\n      } else {\n        Logger.info(`[Auth] logout: No refresh token on request`)\n      }\n\n      req.logout((err) => {\n        if (err) {\n          res.sendStatus(500)\n        } else {\n          const authMethod = req.cookies.auth_method\n\n          res.clearCookie('auth_method')\n\n          let logoutUrl = null\n\n          if (authMethod === 'openid' || authMethod === 'openid-mobile') {\n            logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod)\n            res.clearCookie('openid_id_token')\n          }\n\n          // Tell the user agent (browser) to redirect to the authentification provider's logout URL\n          // (or redirect_url: null if we don't have one)\n          res.send({ redirect_url: logoutUrl })\n        }\n      })\n    })\n  }\n  // #endregion\n}\n\nmodule.exports = Auth\n"
  },
  {
    "path": "server/Database.js",
    "content": "const Path = require('path')\nconst { Sequelize, Op } = require('sequelize')\n\nconst packageJson = require('../package.json')\nconst fs = require('./libs/fsExtra')\nconst Logger = require('./Logger')\n\nconst dbMigration = require('./utils/migrations/dbMigration')\nconst Auth = require('./Auth')\n\nconst MigrationManager = require('./managers/MigrationManager')\n\nclass Database {\n  constructor() {\n    this.sequelize = null\n    this.dbPath = null\n    this.isNew = false // New absdatabase.sqlite created\n    this.hasRootUser = false // Used to show initialization page in web ui\n\n    this.settings = []\n\n    // Cached library filter data\n    this.libraryFilterData = {}\n\n    /** @type {import('./objects/settings/ServerSettings')} */\n    this.serverSettings = null\n    /** @type {import('./objects/settings/NotificationSettings')} */\n    this.notificationSettings = null\n    /** @type {import('./objects/settings/EmailSettings')} */\n    this.emailSettings = null\n\n    this.supportsUnaccent = false\n    this.supportsUnicodeFoldings = false\n  }\n\n  get models() {\n    return this.sequelize?.models || {}\n  }\n\n  /** @type {typeof import('./models/User')} */\n  get userModel() {\n    return this.models.user\n  }\n\n  /** @type {typeof import('./models/Session')} */\n  get sessionModel() {\n    return this.models.session\n  }\n\n  /** @type {typeof import('./models/ApiKey')} */\n  get apiKeyModel() {\n    return this.models.apiKey\n  }\n\n  /** @type {typeof import('./models/Library')} */\n  get libraryModel() {\n    return this.models.library\n  }\n\n  /** @type {typeof import('./models/LibraryFolder')} */\n  get libraryFolderModel() {\n    return this.models.libraryFolder\n  }\n\n  /** @type {typeof import('./models/Author')} */\n  get authorModel() {\n    return this.models.author\n  }\n\n  /** @type {typeof import('./models/Series')} */\n  get seriesModel() {\n    return this.models.series\n  }\n\n  /** @type {typeof import('./models/Book')} */\n  get bookModel() {\n    return this.models.book\n  }\n\n  /** @type {typeof import('./models/BookSeries')} */\n  get bookSeriesModel() {\n    return this.models.bookSeries\n  }\n\n  /** @type {typeof import('./models/BookAuthor')} */\n  get bookAuthorModel() {\n    return this.models.bookAuthor\n  }\n\n  /** @type {typeof import('./models/Podcast')} */\n  get podcastModel() {\n    return this.models.podcast\n  }\n\n  /** @type {typeof import('./models/PodcastEpisode')} */\n  get podcastEpisodeModel() {\n    return this.models.podcastEpisode\n  }\n\n  /** @type {typeof import('./models/LibraryItem')} */\n  get libraryItemModel() {\n    return this.models.libraryItem\n  }\n\n  /** @type {typeof import('./models/PodcastEpisode')} */\n  get podcastEpisodeModel() {\n    return this.models.podcastEpisode\n  }\n\n  /** @type {typeof import('./models/MediaProgress')} */\n  get mediaProgressModel() {\n    return this.models.mediaProgress\n  }\n\n  /** @type {typeof import('./models/Collection')} */\n  get collectionModel() {\n    return this.models.collection\n  }\n\n  /** @type {typeof import('./models/CollectionBook')} */\n  get collectionBookModel() {\n    return this.models.collectionBook\n  }\n\n  /** @type {typeof import('./models/Playlist')} */\n  get playlistModel() {\n    return this.models.playlist\n  }\n\n  /** @type {typeof import('./models/PlaylistMediaItem')} */\n  get playlistMediaItemModel() {\n    return this.models.playlistMediaItem\n  }\n\n  /** @type {typeof import('./models/Feed')} */\n  get feedModel() {\n    return this.models.feed\n  }\n\n  /** @type {typeof import('./models/FeedEpisode')} */\n  get feedEpisodeModel() {\n    return this.models.feedEpisode\n  }\n\n  /** @type {typeof import('./models/PlaybackSession')} */\n  get playbackSessionModel() {\n    return this.models.playbackSession\n  }\n\n  /** @type {typeof import('./models/CustomMetadataProvider')} */\n  get customMetadataProviderModel() {\n    return this.models.customMetadataProvider\n  }\n\n  /** @type {typeof import('./models/MediaItemShare')} */\n  get mediaItemShareModel() {\n    return this.models.mediaItemShare\n  }\n\n  /** @type {typeof import('./models/Device')} */\n  get deviceModel() {\n    return this.models.device\n  }\n\n  /**\n   * Check if db file exists\n   * @returns {boolean}\n   */\n  async checkHasDb() {\n    if (!(await fs.pathExists(this.dbPath))) {\n      Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)\n      return false\n    }\n    return true\n  }\n\n  /**\n   * Connect to db, build models and run migrations\n   * @param {boolean} [force=false] Used for testing, drops & re-creates all tables\n   */\n  async init(force = false) {\n    this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')\n\n    // First check if this is a new database\n    this.isNew = !(await this.checkHasDb()) || force\n\n    if (!(await this.connect())) {\n      throw new Error('Database connection failed')\n    }\n\n    try {\n      const migrationManager = new MigrationManager(this.sequelize, this.isNew, global.ConfigPath)\n      await migrationManager.init(packageJson.version)\n      await migrationManager.runMigrations()\n    } catch (error) {\n      Logger.error(`[Database] Failed to run migrations`, error)\n      throw new Error('Database migration failed')\n    }\n\n    await this.buildModels(force)\n    Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))\n\n    await this.addTriggers()\n\n    await this.loadData()\n\n    Logger.info(`[Database] running ANALYZE`)\n    await this.sequelize.query('ANALYZE')\n    Logger.info(`[Database] ANALYZE completed`)\n  }\n\n  /**\n   * Connect to db\n   * @returns {boolean}\n   */\n  async connect() {\n    Logger.info(`[Database] Initializing db at \"${this.dbPath}\"`)\n\n    let logging = false\n    let benchmark = false\n    if (process.env.QUERY_LOGGING === 'log') {\n      // Setting QUERY_LOGGING=log will log all Sequelize queries before they run\n      Logger.info(`[Database] Query logging enabled`)\n      logging = (query) => Logger.debug(`Running the following query:\\n ${query}`)\n    } else if (process.env.QUERY_LOGGING === 'benchmark') {\n      // Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run\n      Logger.info(`[Database] Query benchmarking enabled\"`)\n      logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\\n ${query}`)\n      benchmark = true\n    }\n\n    this.sequelize = new Sequelize({\n      dialect: 'sqlite',\n      storage: this.dbPath,\n      logging: logging,\n      benchmark: benchmark,\n      transactionType: 'IMMEDIATE'\n    })\n\n    // Helper function\n    this.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')\n\n    try {\n      await this.sequelize.authenticate()\n\n      // Set SQLite pragmas from environment variables\n      const allowedPragmas = [\n        { name: 'mmap_size', env: 'SQLITE_MMAP_SIZE' },\n        { name: 'cache_size', env: 'SQLITE_CACHE_SIZE' },\n        { name: 'temp_store', env: 'SQLITE_TEMP_STORE' }\n      ]\n\n      for (const pragma of allowedPragmas) {\n        const value = process.env[pragma.env]\n        if (value !== undefined) {\n          try {\n            Logger.info(`[Database] Running \"PRAGMA ${pragma.name} = ${value}\"`)\n            await this.sequelize.query(`PRAGMA ${pragma.name} = ${value}`)\n            const [result] = await this.sequelize.query(`PRAGMA ${pragma.name}`)\n            Logger.debug(`[Database] \"PRAGMA ${pragma.name}\" query result:`, result)\n          } catch (error) {\n            Logger.error(`[Database] Failed to set SQLite pragma ${pragma.name}`, error)\n          }\n        }\n      }\n\n      if (process.env.NUSQLITE3_PATH) {\n        await this.loadExtension(process.env.NUSQLITE3_PATH)\n        Logger.info(`[Database] Db supports unaccent and unicode foldings`)\n        this.supportsUnaccent = true\n        this.supportsUnicodeFoldings = true\n      }\n      Logger.info(`[Database] Db connection was successful`)\n      return true\n    } catch (error) {\n      Logger.error(`[Database] Failed to connect to db`, error)\n      return false\n    }\n  }\n\n  /**\n   * @param {string} extension paths to extension binary\n   */\n  async loadExtension(extension) {\n    // This is a hack to get the db connection for loading extensions.\n    // The proper way would be to use the 'afterConnect' hook, but that hook is never called for sqlite due to a bug in sequelize.\n    // See https://github.com/sequelize/sequelize/issues/12487\n    // This is not a public API and may break in the future.\n    const db = await this.sequelize.dialect.connectionManager.getConnection()\n    if (typeof db?.loadExtension !== 'function') throw new Error('Failed to get db connection for loading extensions')\n\n    Logger.info(`[Database] Loading extension ${extension}`)\n    await new Promise((resolve, reject) => {\n      db.loadExtension(extension, (err) => {\n        if (err) {\n          Logger.error(`[Database] Failed to load extension ${extension}`, err)\n          reject(err)\n          return\n        }\n        Logger.info(`[Database] Successfully loaded extension ${extension}`)\n        resolve()\n      })\n    })\n  }\n\n  /**\n   * Disconnect from db\n   */\n  async disconnect() {\n    Logger.info(`[Database] Disconnecting sqlite db`)\n    await this.sequelize.close()\n  }\n\n  /**\n   * Reconnect to db and init\n   */\n  async reconnect() {\n    Logger.info(`[Database] Reconnecting sqlite db`)\n    await this.init()\n  }\n\n  buildModels(force = false) {\n    require('./models/User').init(this.sequelize)\n    require('./models/Session').init(this.sequelize)\n    require('./models/ApiKey').init(this.sequelize)\n    require('./models/Library').init(this.sequelize)\n    require('./models/LibraryFolder').init(this.sequelize)\n    require('./models/Book').init(this.sequelize)\n    require('./models/Podcast').init(this.sequelize)\n    require('./models/PodcastEpisode').init(this.sequelize)\n    require('./models/LibraryItem').init(this.sequelize)\n    require('./models/MediaProgress').init(this.sequelize)\n    require('./models/Series').init(this.sequelize)\n    require('./models/BookSeries').init(this.sequelize)\n    require('./models/Author').init(this.sequelize)\n    require('./models/BookAuthor').init(this.sequelize)\n    require('./models/Collection').init(this.sequelize)\n    require('./models/CollectionBook').init(this.sequelize)\n    require('./models/Playlist').init(this.sequelize)\n    require('./models/PlaylistMediaItem').init(this.sequelize)\n    require('./models/Device').init(this.sequelize)\n    require('./models/PlaybackSession').init(this.sequelize)\n    require('./models/Feed').init(this.sequelize)\n    require('./models/FeedEpisode').init(this.sequelize)\n    require('./models/Setting').init(this.sequelize)\n    require('./models/CustomMetadataProvider').init(this.sequelize)\n    require('./models/MediaItemShare').init(this.sequelize)\n\n    return this.sequelize.sync({ force, alter: false })\n  }\n\n  /**\n   * Compare two server versions\n   * @param {string} v1\n   * @param {string} v2\n   * @returns {-1|0|1} 1 if v1 > v2\n   */\n  compareVersions(v1, v2) {\n    if (!v1 || !v2) return 0\n    return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })\n  }\n\n  /**\n   * Checks if migration to sqlite db is necessary & runs migration.\n   *\n   * Check if version was upgraded and run any version specific migrations.\n   *\n   * Loads most of the data from the database. This is a temporary solution.\n   */\n  async loadData() {\n    if (this.isNew && (await dbMigration.checkShouldMigrate())) {\n      Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)\n      await dbMigration.migrate(this.models)\n    }\n\n    const settingsData = await this.models.setting.getOldSettings()\n    this.settings = settingsData.settings\n    this.emailSettings = settingsData.emailSettings\n    this.serverSettings = settingsData.serverSettings\n    this.notificationSettings = settingsData.notificationSettings\n    global.ServerSettings = this.serverSettings.toJSON()\n\n    // Version specific migrations\n    if (packageJson.version !== this.serverSettings.version) {\n      if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) {\n        await dbMigration.migrationPatch(this)\n      }\n      if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) {\n        await dbMigration.migrationPatch2(this)\n      }\n    }\n    // Build migrations\n    if (this.serverSettings.buildNumber <= 0) {\n      await require('./utils/migrations/absMetadataMigration').migrate(this)\n    }\n\n    await this.cleanDatabase()\n\n    // Set if root user has been created\n    this.hasRootUser = await this.models.user.getHasRootUser()\n\n    // Update server settings with version/build\n    let updateServerSettings = false\n    if (packageJson.version !== this.serverSettings.version) {\n      Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)\n      this.serverSettings.version = packageJson.version\n      this.serverSettings.buildNumber = packageJson.buildNumber\n      updateServerSettings = true\n    } else if (packageJson.buildNumber !== this.serverSettings.buildNumber) {\n      Logger.info(`[Database] Server v${packageJson.version} build upgraded from ${this.serverSettings.buildNumber} to ${packageJson.buildNumber}`)\n      this.serverSettings.buildNumber = packageJson.buildNumber\n      updateServerSettings = true\n    }\n    if (updateServerSettings) {\n      await this.updateServerSettings()\n    }\n  }\n\n  /**\n   * Create root user\n   * @param {string} username\n   * @param {string} pash\n   * @param {Auth} auth\n   * @returns {Promise<boolean>} true if created\n   */\n  async createRootUser(username, pash, auth) {\n    if (!this.sequelize) return false\n    await this.userModel.createRootUser(username, pash, auth)\n    this.hasRootUser = true\n    return true\n  }\n\n  updateServerSettings() {\n    if (!this.sequelize) return false\n    global.ServerSettings = this.serverSettings.toJSON()\n    return this.updateSetting(this.serverSettings)\n  }\n\n  updateSetting(settings) {\n    if (!this.sequelize) return false\n    return this.models.setting.updateSettingObj(settings.toJSON())\n  }\n\n  getPlaybackSessions(where = null) {\n    if (!this.sequelize) return false\n    return this.models.playbackSession.getOldPlaybackSessions(where)\n  }\n\n  getPlaybackSession(sessionId) {\n    if (!this.sequelize) return false\n    return this.models.playbackSession.getById(sessionId)\n  }\n\n  createPlaybackSession(oldSession) {\n    if (!this.sequelize) return false\n    return this.models.playbackSession.createFromOld(oldSession)\n  }\n\n  updatePlaybackSession(oldSession) {\n    if (!this.sequelize) return false\n    return this.models.playbackSession.updateFromOld(oldSession)\n  }\n\n  removePlaybackSession(sessionId) {\n    if (!this.sequelize) return false\n    return this.models.playbackSession.removeById(sessionId)\n  }\n\n  replaceTagInFilterData(oldTag, newTag) {\n    for (const libraryId in this.libraryFilterData) {\n      const indexOf = this.libraryFilterData[libraryId].tags.findIndex((n) => n === oldTag)\n      if (indexOf >= 0) {\n        this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)\n      }\n    }\n  }\n\n  removeTagFromFilterData(tag) {\n    for (const libraryId in this.libraryFilterData) {\n      this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter((t) => t !== tag)\n    }\n  }\n\n  addTagsToFilterData(libraryId, tags) {\n    if (!this.libraryFilterData[libraryId] || !tags?.length) return\n    tags.forEach((t) => {\n      if (!this.libraryFilterData[libraryId].tags.includes(t)) {\n        this.libraryFilterData[libraryId].tags.push(t)\n      }\n    })\n  }\n\n  replaceGenreInFilterData(oldGenre, newGenre) {\n    for (const libraryId in this.libraryFilterData) {\n      const indexOf = this.libraryFilterData[libraryId].genres.findIndex((n) => n === oldGenre)\n      if (indexOf >= 0) {\n        this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)\n      }\n    }\n  }\n\n  removeGenreFromFilterData(genre) {\n    for (const libraryId in this.libraryFilterData) {\n      this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter((g) => g !== genre)\n    }\n  }\n\n  addGenresToFilterData(libraryId, genres) {\n    if (!this.libraryFilterData[libraryId] || !genres?.length) return\n    genres.forEach((g) => {\n      if (!this.libraryFilterData[libraryId].genres.includes(g)) {\n        this.libraryFilterData[libraryId].genres.push(g)\n      }\n    })\n  }\n\n  replaceNarratorInFilterData(oldNarrator, newNarrator) {\n    for (const libraryId in this.libraryFilterData) {\n      const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)\n      if (indexOf >= 0) {\n        this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)\n      }\n    }\n  }\n\n  removeNarratorFromFilterData(narrator) {\n    for (const libraryId in this.libraryFilterData) {\n      this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)\n    }\n  }\n\n  addNarratorsToFilterData(libraryId, narrators) {\n    if (!this.libraryFilterData[libraryId] || !narrators?.length) return\n    narrators.forEach((n) => {\n      if (!this.libraryFilterData[libraryId].narrators.includes(n)) {\n        this.libraryFilterData[libraryId].narrators.push(n)\n      }\n    })\n  }\n\n  removeSeriesFromFilterData(libraryId, seriesId) {\n    if (!this.libraryFilterData[libraryId]) return\n    this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter((se) => se.id !== seriesId)\n  }\n\n  addSeriesToFilterData(libraryId, seriesName, seriesId) {\n    if (!this.libraryFilterData[libraryId]) return\n    // Check if series is already added\n    if (this.libraryFilterData[libraryId].series.some((se) => se.id === seriesId)) return\n    this.libraryFilterData[libraryId].series.push({\n      id: seriesId,\n      name: seriesName\n    })\n  }\n\n  removeAuthorFromFilterData(libraryId, authorId) {\n    if (!this.libraryFilterData[libraryId]) return\n    this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter((au) => au.id !== authorId)\n  }\n\n  addAuthorToFilterData(libraryId, authorName, authorId) {\n    if (!this.libraryFilterData[libraryId]) return\n    // Check if author is already added\n    if (this.libraryFilterData[libraryId].authors.some((au) => au.id === authorId)) return\n    this.libraryFilterData[libraryId].authors.push({\n      id: authorId,\n      name: authorName\n    })\n  }\n\n  addPublisherToFilterData(libraryId, publisher) {\n    if (!this.libraryFilterData[libraryId] || !publisher || this.libraryFilterData[libraryId].publishers.includes(publisher)) return\n    this.libraryFilterData[libraryId].publishers.push(publisher)\n  }\n\n  addPublishedDecadeToFilterData(libraryId, decade) {\n    if (!this.libraryFilterData[libraryId] || !decade || this.libraryFilterData[libraryId].publishedDecades.includes(decade)) return\n    this.libraryFilterData[libraryId].publishedDecades.push(decade)\n  }\n\n  addLanguageToFilterData(libraryId, language) {\n    if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return\n    this.libraryFilterData[libraryId].languages.push(language)\n  }\n\n  /**\n   * Used when updating items to make sure author id exists\n   * If library filter data is set then use that for check\n   * otherwise lookup in db\n   * @param {string} libraryId\n   * @param {string} authorId\n   * @returns {Promise<boolean>}\n   */\n  async checkAuthorExists(libraryId, authorId) {\n    if (!this.libraryFilterData[libraryId]) {\n      return this.authorModel.checkExistsById(authorId)\n    }\n    return this.libraryFilterData[libraryId].authors.some((au) => au.id === authorId)\n  }\n\n  /**\n   * Used when updating items to make sure series id exists\n   * If library filter data is set then use that for check\n   * otherwise lookup in db\n   * @param {string} libraryId\n   * @param {string} seriesId\n   * @returns {Promise<boolean>}\n   */\n  async checkSeriesExists(libraryId, seriesId) {\n    if (!this.libraryFilterData[libraryId]) {\n      return this.seriesModel.checkExistsById(seriesId)\n    }\n    return this.libraryFilterData[libraryId].series.some((se) => se.id === seriesId)\n  }\n\n  /**\n   * Get author id for library by name. Uses library filter data if available\n   *\n   * @param {string} libraryId\n   * @param {string} authorName\n   * @returns {Promise<string>} author id or null if not found\n   */\n  async getAuthorIdByName(libraryId, authorName) {\n    if (!this.libraryFilterData[libraryId]) {\n      return (await this.authorModel.getByNameAndLibrary(authorName, libraryId))?.id || null\n    }\n    return this.libraryFilterData[libraryId].authors.find((au) => au.name === authorName)?.id || null\n  }\n\n  /**\n   * Get series id for library by name. Uses library filter data if available\n   *\n   * @param {string} libraryId\n   * @param {string} seriesName\n   * @returns {Promise<string>} series id or null if not found\n   */\n  async getSeriesIdByName(libraryId, seriesName) {\n    if (!this.libraryFilterData[libraryId]) {\n      return (await this.seriesModel.getByNameAndLibrary(seriesName, libraryId))?.id || null\n    }\n    return this.libraryFilterData[libraryId].series.find((se) => se.name === seriesName)?.id || null\n  }\n\n  /**\n   * Reset numIssues for library\n   * @param {string} libraryId\n   */\n  async resetLibraryIssuesFilterData(libraryId) {\n    if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set\n\n    this.libraryFilterData[libraryId].numIssues = await this.libraryItemModel.count({\n      where: {\n        libraryId,\n        [Sequelize.Op.or]: [\n          {\n            isMissing: true\n          },\n          {\n            isInvalid: true\n          }\n        ]\n      }\n    })\n  }\n\n  /**\n   * Clean invalid records in database\n   * Series should have atleast one Book\n   * Book and Podcast must have an associated LibraryItem (and vice versa)\n   * Remove playback sessions that are 3 seconds or less\n   * Remove duplicate mediaProgresses\n   * Remove expired auth sessions\n   * Deactivate expired api keys\n   */\n  async cleanDatabase() {\n    // Remove invalid Podcast records\n    const podcastsWithNoLibraryItem = await this.podcastModel.findAll({\n      include: {\n        model: this.libraryItemModel,\n        required: false\n      },\n      where: { '$libraryItem.id$': null }\n    })\n    for (const podcast of podcastsWithNoLibraryItem) {\n      Logger.warn(`Found podcast \"${podcast.title}\" with no libraryItem - removing it`)\n      await podcast.destroy()\n    }\n\n    // Remove invalid Book records\n    const booksWithNoLibraryItem = await this.bookModel.findAll({\n      include: {\n        model: this.libraryItemModel,\n        required: false\n      },\n      where: { '$libraryItem.id$': null }\n    })\n    for (const book of booksWithNoLibraryItem) {\n      Logger.warn(`Found book \"${book.title}\" with no libraryItem - removing it`)\n      await book.destroy()\n    }\n\n    // Remove invalid LibraryItem records\n    const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({\n      include: [\n        {\n          model: this.bookModel,\n          attributes: ['id']\n        },\n        {\n          model: this.podcastModel,\n          attributes: ['id']\n        }\n      ],\n      where: {\n        '$book.id$': null,\n        '$podcast.id$': null\n      }\n    })\n    for (const libraryItem of libraryItemsWithNoMedia) {\n      Logger.warn(`Found libraryItem \"${libraryItem.id}\" with no media - removing it`)\n      await libraryItem.destroy()\n    }\n\n    // Remove invalid PlaylistMediaItem records\n    const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({\n      include: [\n        {\n          model: this.bookModel,\n          attributes: ['id']\n        },\n        {\n          model: this.podcastEpisodeModel,\n          attributes: ['id']\n        }\n      ],\n      where: {\n        '$book.id$': null,\n        '$podcastEpisode.id$': null\n      }\n    })\n    for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) {\n      Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`)\n      await playlistMediaItem.destroy()\n    }\n\n    // Remove invalid CollectionBook records\n    const collectionBooksWithNoBook = await this.collectionBookModel.findAll({\n      include: {\n        model: this.bookModel,\n        required: false\n      },\n      where: { '$book.id$': null }\n    })\n    for (const collectionBook of collectionBooksWithNoBook) {\n      Logger.warn(`Found collectionBook with no book - removing it`)\n      await collectionBook.destroy()\n    }\n\n    // Remove empty series\n    const emptySeries = await this.seriesModel.findAll({\n      include: {\n        model: this.bookSeriesModel,\n        required: false\n      },\n      where: { '$bookSeries.id$': null }\n    })\n    for (const series of emptySeries) {\n      Logger.warn(`Found series \"${series.name}\" with no books - removing it`)\n      await series.destroy()\n    }\n\n    // Remove playback sessions that were 3 seconds or less\n    const badSessionsRemoved = await this.playbackSessionModel.destroy({\n      where: {\n        timeListening: {\n          [Op.lte]: 3\n        }\n      }\n    })\n    if (badSessionsRemoved > 0) {\n      Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)\n    }\n\n    // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one)\n    const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId\nFROM mediaProgresses mp1\nWHERE EXISTS (\n    SELECT 1\n    FROM mediaProgresses mp2\n    WHERE mp2.mediaItemId = mp1.mediaItemId\n    AND mp2.userId = mp1.userId\n    AND (\n        mp2.updatedAt > mp1.updatedAt\n        OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id)\n    )\n)`)\n    for (const duplicateMediaProgress of duplicateMediaProgresses) {\n      Logger.warn(`Found duplicate mediaProgress for mediaItem \"${duplicateMediaProgress.mediaItemId}\" - removing it`)\n      await this.mediaProgressModel.destroy({\n        where: { id: duplicateMediaProgress.id }\n      })\n    }\n\n    // Remove expired Session records\n    await this.cleanupExpiredSessions()\n\n    // Deactivate expired api keys\n    await this.deactivateExpiredApiKeys()\n  }\n\n  /**\n   * Deactivate expired api keys\n   */\n  async deactivateExpiredApiKeys() {\n    try {\n      const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()\n      if (affectedCount > 0) {\n        Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)\n      }\n    } catch (error) {\n      Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)\n    }\n  }\n\n  /**\n   * Clean up expired sessions from the database\n   */\n  async cleanupExpiredSessions() {\n    try {\n      const deletedCount = await this.sessionModel.cleanupExpiredSessions()\n      if (deletedCount > 0) {\n        Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)\n      }\n    } catch (error) {\n      Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)\n    }\n  }\n\n  async createTextSearchQuery(query) {\n    const textQuery = new this.TextSearchQuery(this.sequelize, this.supportsUnaccent, query)\n    await textQuery.init()\n    return textQuery\n  }\n\n  /**\n   * This is used to create necessary triggers for new databases.\n   * It adds triggers to update libraryItems.title[IgnorePrefix] when (books|podcasts).title[IgnorePrefix] is updated\n   */\n  async addTriggers() {\n    await this.addTriggerIfNotExists('books', 'title', 'id', 'libraryItems', 'title', 'mediaId')\n    await this.addTriggerIfNotExists('books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')\n    await this.addTriggerIfNotExists('podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')\n    await this.addTriggerIfNotExists('podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')\n    await this.addAuthorNamesTriggersIfNotExist()\n  }\n\n  async addTriggerIfNotExists(sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {\n    const action = `update_${targetTable}_${targetColumn}`\n    const fromSource = sourceTable === 'books' ? '' : `_from_${sourceTable}_${sourceColumn}`\n    const triggerName = this.convertToSnakeCase(`${action}${fromSource}`)\n\n    const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)\n    if (count > 0) return // Trigger already exists\n\n    Logger.info(`[Database] Adding trigger ${triggerName}`)\n\n    await this.sequelize.query(`\n      CREATE TRIGGER ${triggerName}\n        AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}\n        FOR EACH ROW\n        BEGIN\n          UPDATE ${targetTable}\n            SET ${targetColumn} = NEW.${sourceColumn}\n          WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};\n        END;\n    `)\n  }\n\n  async addAuthorNamesTriggersIfNotExist() {\n    const libraryItems = 'libraryItems'\n    const bookAuthors = 'bookAuthors'\n    const authors = 'authors'\n    const columns = [\n      { name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } },\n      { name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } }\n    ]\n    const authorsSort = `${bookAuthors}.createdAt ASC`\n    const columnNames = columns.map((column) => column.name).join(', ')\n    const columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ')\n    const authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId`\n\n    const addBookAuthorsTriggerIfNotExists = async (action) => {\n      const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW'\n      const triggerName = this.convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`)\n      const authorNamesSubQuery = `\n        SELECT ${columnSourcesExpression}\n        FROM ${authorsJoin}\n        WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId\n      `\n      const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)\n      if (count > 0) return // Trigger already exists\n\n      Logger.info(`[Database] Adding trigger ${triggerName}`)\n\n      await this.sequelize.query(`\n        CREATE TRIGGER ${triggerName}\n          AFTER ${action} ON ${bookAuthors}\n          FOR EACH ROW\n          BEGIN\n            UPDATE ${libraryItems}\n              SET (${columnNames}) = (${authorNamesSubQuery})\n            WHERE mediaId = ${modifiedRecord}.bookId;\n          END;\n      `)\n    }\n\n    const addAuthorsUpdateTriggerIfNotExists = async () => {\n      const triggerName = this.convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`)\n      const authorNamesSubQuery = `\n        SELECT ${columnSourcesExpression}\n        FROM ${authorsJoin}\n        WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId\n      `\n\n      const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)\n      if (count > 0) return // Trigger already exists\n\n      Logger.info(`[Database] Adding trigger ${triggerName}`)\n\n      await this.sequelize.query(`\n        CREATE TRIGGER ${triggerName}\n          AFTER UPDATE OF name ON ${authors}\n          FOR EACH ROW\n          BEGIN\n            UPDATE ${libraryItems}\n              SET (${columnNames}) = (${authorNamesSubQuery})\n            WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id);\n        END;\n      `)\n    }\n\n    await addBookAuthorsTriggerIfNotExists('insert')\n    await addBookAuthorsTriggerIfNotExists('delete')\n    await addAuthorsUpdateTriggerIfNotExists()\n  }\n\n  convertToSnakeCase(str) {\n    return str.replace(/([A-Z])/g, '_$1').toLowerCase()\n  }\n\n  TextSearchQuery = class {\n    constructor(sequelize, supportsUnaccent, query) {\n      this.sequelize = sequelize\n      this.supportsUnaccent = supportsUnaccent\n      this.query = query\n      this.hasAccents = false\n    }\n\n    /**\n     * Returns a normalized (accents-removed) expression for the specified value.\n     *\n     * @param {string} value\n     * @returns {string}\n     */\n    normalize(value) {\n      return `unaccent(${value})`\n    }\n\n    /**\n     * Initialize the text query.\n     *\n     */\n    async init() {\n      if (!this.supportsUnaccent) return\n      const escapedQuery = this.sequelize.escape(this.query)\n      const normalizedQueryExpression = this.normalize(escapedQuery)\n      const normalizedQueryResult = await this.sequelize.query(`SELECT ${normalizedQueryExpression} as normalized_query`)\n      const normalizedQuery = normalizedQueryResult[0][0].normalized_query\n      this.hasAccents = escapedQuery !== this.sequelize.escape(normalizedQuery)\n    }\n\n    /**\n     * Get match expression for the specified column.\n     * If the query contains accents, match against the column as-is (case-insensitive exact match).\n     * otherwise match against a normalized column (case-insensitive match with accents removed).\n     *\n     * @param {string} column\n     * @returns {string}\n     */\n    matchExpression(column) {\n      const pattern = this.sequelize.escape(`%${this.query}%`)\n      if (!this.supportsUnaccent) return `${column} LIKE ${pattern}`\n      const normalizedColumn = this.hasAccents ? column : this.normalize(column)\n      return `${normalizedColumn} LIKE ${pattern}`\n    }\n  }\n}\n\nmodule.exports = new Database()\n"
  },
  {
    "path": "server/Logger.js",
    "content": "const date = require('./libs/dateAndTime')\nconst { LogLevel } = require('./utils/constants')\nconst util = require('util')\n\nclass Logger {\n  constructor() {\n    /** @type {import('./managers/LogManager')} */\n    this.logManager = null\n\n    this.isDev = process.env.NODE_ENV !== 'production'\n\n    this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE\n    this.socketListeners = []\n  }\n\n  /**\n   * @returns {string}\n   */\n  get timestamp() {\n    return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS')\n  }\n\n  get levelString() {\n    return this.getLogLevelString(this.logLevel)\n  }\n\n  /**\n   * @returns {string}\n   */\n  get source() {\n    const regex = global.isWin ? /^.*\\\\([^\\\\:]*:[0-9]*):[0-9]*\\)*/ : /^.*\\/([^/:]*:[0-9]*):[0-9]*\\)*/\n    return Error().stack.split('\\n')[3].replace(regex, '$1')\n  }\n\n  getLogLevelString(level) {\n    for (const key in LogLevel) {\n      if (LogLevel[key] === level) {\n        return key\n      }\n    }\n    return 'UNKNOWN'\n  }\n\n  addSocketListener(socket, level) {\n    var index = this.socketListeners.findIndex((s) => s.id === socket.id)\n    if (index >= 0) {\n      this.socketListeners.splice(index, 1, {\n        id: socket.id,\n        socket,\n        level\n      })\n    } else {\n      this.socketListeners.push({\n        id: socket.id,\n        socket,\n        level\n      })\n    }\n  }\n\n  removeSocketListener(socketId) {\n    this.socketListeners = this.socketListeners.filter((s) => s.id !== socketId)\n  }\n\n  /**\n   *\n   * @param {number} level\n   * @param {string} levelName\n   * @param {string[]} args\n   * @param {string} src\n   */\n  async #logToFileAndListeners(level, levelName, args, src) {\n    const expandedArgs = args.map((arg) => (typeof arg !== 'string' ? util.inspect(arg) : arg))\n    const logObj = {\n      timestamp: this.timestamp,\n      source: src,\n      message: expandedArgs.join(' '),\n      levelName,\n      level\n    }\n\n    // Emit log to sockets that are listening to log events\n    this.socketListeners.forEach((socketListener) => {\n      if (level >= LogLevel.FATAL || level >= socketListener.level) {\n        socketListener.socket.emit('log', logObj)\n      }\n    })\n\n    // Save log to file\n    if (level >= LogLevel.FATAL || level >= this.logLevel) {\n      await this.logManager?.logToFile(logObj)\n    }\n  }\n\n  setLogLevel(level) {\n    this.logLevel = level\n    this.debug(`Set Log Level to ${this.levelString}`)\n  }\n\n  static ConsoleMethods = {\n    TRACE: 'trace',\n    DEBUG: 'debug',\n    INFO: 'info',\n    WARN: 'warn',\n    ERROR: 'error',\n    FATAL: 'error',\n    NOTE: 'log'\n  }\n\n  #log(levelName, source, ...args) {\n    const level = LogLevel[levelName]\n    if (level < LogLevel.FATAL && level < this.logLevel) return\n    const consoleMethod = Logger.ConsoleMethods[levelName]\n    console[consoleMethod](`[${this.timestamp}] ${levelName}:`, ...args)\n    return this.#logToFileAndListeners(level, levelName, args, source)\n  }\n\n  trace(...args) {\n    this.#log('TRACE', this.source, ...args)\n  }\n\n  debug(...args) {\n    this.#log('DEBUG', this.source, ...args)\n  }\n\n  info(...args) {\n    this.#log('INFO', this.source, ...args)\n  }\n\n  warn(...args) {\n    this.#log('WARN', this.source, ...args)\n  }\n\n  error(...args) {\n    this.#log('ERROR', this.source, ...args)\n  }\n\n  fatal(...args) {\n    return this.#log('FATAL', this.source, ...args)\n  }\n\n  note(...args) {\n    this.#log('NOTE', this.source, ...args)\n  }\n}\nmodule.exports = new Logger()\n"
  },
  {
    "path": "server/Server.js",
    "content": "const Path = require('path')\nconst Sequelize = require('sequelize')\nconst express = require('express')\nconst http = require('http')\nconst util = require('util')\nconst fs = require('./libs/fsExtra')\nconst fileUpload = require('./libs/expressFileupload')\nconst cookieParser = require('cookie-parser')\nconst axios = require('axios')\n\nconst { version } = require('../package.json')\n\n// Utils\nconst is = require('./libs/requestIp/isJs')\nconst fileUtils = require('./utils/fileUtils')\nconst { toNumber } = require('./utils/index')\nconst Logger = require('./Logger')\n\nconst Auth = require('./Auth')\nconst Watcher = require('./Watcher')\nconst Database = require('./Database')\nconst SocketAuthority = require('./SocketAuthority')\n\nconst ApiRouter = require('./routers/ApiRouter')\nconst HlsRouter = require('./routers/HlsRouter')\nconst PublicRouter = require('./routers/PublicRouter')\n\nconst LogManager = require('./managers/LogManager')\nconst EmailManager = require('./managers/EmailManager')\nconst AbMergeManager = require('./managers/AbMergeManager')\nconst CacheManager = require('./managers/CacheManager')\nconst BackupManager = require('./managers/BackupManager')\nconst PlaybackSessionManager = require('./managers/PlaybackSessionManager')\nconst PodcastManager = require('./managers/PodcastManager')\nconst AudioMetadataMangaer = require('./managers/AudioMetadataManager')\nconst RssFeedManager = require('./managers/RssFeedManager')\nconst CronManager = require('./managers/CronManager')\nconst ApiCacheManager = require('./managers/ApiCacheManager')\nconst BinaryManager = require('./managers/BinaryManager')\nconst ShareManager = require('./managers/ShareManager')\nconst LibraryScanner = require('./scanner/LibraryScanner')\n\n//Import the main Passport and Express-Session library\nconst passport = require('passport')\nconst expressSession = require('express-session')\nconst MemoryStore = require('./libs/memorystore')\n\nclass Server {\n  constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {\n    this.Port = PORT\n    this.Host = HOST\n    global.Source = SOURCE\n    global.isWin = process.platform === 'win32'\n    global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))\n    global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))\n    global.RouterBasePath = ROUTER_BASE_PATH\n    global.XAccel = process.env.USE_X_ACCEL\n    global.AllowCors = process.env.ALLOW_CORS === '1'\n\n    if (process.env.EXP_PROXY_SUPPORT === '1') {\n      // https://github.com/advplyr/audiobookshelf/pull/3754\n      Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)\n      global.DisableSsrfRequestFilter = () => true\n\n      axios.defaults.maxRedirects = 0\n      axios.interceptors.response.use(\n        (response) => response,\n        (error) => {\n          if ([301, 302].includes(error.response?.status)) {\n            return axios({\n              ...error.config,\n              url: error.response.headers.location\n            })\n          }\n\n          return Promise.reject(error)\n        }\n      )\n    } else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {\n      Logger.info(`[Server] SSRF Request Filter Disabled`)\n      global.DisableSsrfRequestFilter = () => true\n    } else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {\n      const whitelistedUrls = process.env.SSRF_REQUEST_FILTER_WHITELIST.split(',').map((url) => url.trim())\n      if (whitelistedUrls.length) {\n        Logger.info(`[Server] SSRF Request Filter Whitelisting: ${whitelistedUrls.join(',')}`)\n        global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)\n      }\n    }\n    global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000)\n    global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24)\n\n    if (!fs.pathExistsSync(global.ConfigPath)) {\n      fs.mkdirSync(global.ConfigPath)\n    }\n    if (!fs.pathExistsSync(global.MetadataPath)) {\n      fs.mkdirSync(global.MetadataPath)\n    }\n\n    this.auth = new Auth()\n\n    // Managers\n    this.emailManager = new EmailManager()\n    this.backupManager = new BackupManager()\n    this.abMergeManager = new AbMergeManager()\n    this.playbackSessionManager = new PlaybackSessionManager()\n    this.podcastManager = new PodcastManager()\n    this.audioMetadataManager = new AudioMetadataMangaer()\n    this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)\n    this.apiCacheManager = new ApiCacheManager()\n    this.binaryManager = new BinaryManager()\n\n    // Routers\n    this.apiRouter = new ApiRouter(this)\n    this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)\n    this.publicRouter = new PublicRouter(this.playbackSessionManager)\n\n    Logger.logManager = new LogManager()\n\n    this.server = null\n  }\n\n  /**\n   * Middleware to check if the current request is authenticated\n   *\n   * @param {import('express').Request} req\n   * @param {import('express').Response} res\n   * @param {import('express').NextFunction} next\n   */\n  authMiddleware(req, res, next) {\n    // ask passportjs if the current request is authenticated\n    this.auth.isAuthenticated(req, res, next)\n  }\n\n  cancelLibraryScan(libraryId) {\n    LibraryScanner.setCancelLibraryScan(libraryId)\n  }\n\n  /**\n   * Initialize database, backups, logs, rss feeds, cron jobs & watcher\n   * Cleanup stale/invalid data\n   */\n  async init() {\n    Logger.info('[Server] Init v' + version)\n    Logger.info('[Server] Node.js Version:', process.version)\n    Logger.info('[Server] Platform:', process.platform)\n    Logger.info('[Server] Arch:', process.arch)\n\n    await this.playbackSessionManager.removeOrphanStreams()\n\n    /**\n     * Docker container ffmpeg/ffprobe binaries are included in the image.\n     * Docker is currently using ffmpeg/ffprobe v6.1 instead of v5.1 so skipping the check\n     * TODO: Support binary check for all sources\n     */\n    if (global.Source !== 'docker') {\n      await this.binaryManager.init()\n    }\n\n    await Database.init(false)\n    // Create or set JWT secret in token manager\n    await this.auth.tokenManager.initTokenSecret()\n\n    await Logger.logManager.init()\n\n    await this.cleanUserData() // Remove invalid user item progress\n    await CacheManager.ensureCachePaths()\n\n    await ShareManager.init()\n    await this.backupManager.init()\n    await RssFeedManager.init()\n\n    const libraries = await Database.libraryModel.getAllWithFolders()\n    await this.cronManager.init(libraries)\n    this.apiCacheManager.init()\n\n    if (Database.serverSettings.scannerDisableWatcher) {\n      Logger.info(`[Server] Watcher is disabled`)\n      Watcher.disabled = true\n    } else {\n      Watcher.initWatcher(libraries)\n      Watcher.on('scanFilesChanged', (pendingFileUpdates, pendingTask) => {\n        LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)\n      })\n    }\n  }\n\n  /**\n   * Listen for SIGINT and uncaught exceptions\n   */\n  initProcessEventListeners() {\n    let sigintAlreadyReceived = false\n    process.on('SIGINT', async () => {\n      if (!sigintAlreadyReceived) {\n        sigintAlreadyReceived = true\n        Logger.info('SIGINT (Ctrl+C) received. Shutting down...')\n        await this.stop()\n        Logger.info('Server stopped. Exiting.')\n      } else {\n        Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.')\n      }\n      process.exit(0)\n    })\n\n    /**\n     * @see https://nodejs.org/api/process.html#event-uncaughtexceptionmonitor\n     */\n    process.on('uncaughtExceptionMonitor', async (error, origin) => {\n      await Logger.fatal(`[Server] Uncaught exception origin: ${origin}, error:`, util.format('%O', error))\n    })\n    /**\n     * @see https://nodejs.org/api/process.html#event-unhandledrejection\n     */\n    process.on('unhandledRejection', async (reason, promise) => {\n      await Logger.fatal('[Server] Unhandled rejection:', reason, '\\npromise:', util.format('%O', promise))\n      process.exit(1)\n    })\n  }\n\n  async start() {\n    Logger.info('=== Starting Server ===')\n\n    this.initProcessEventListeners()\n    await this.init()\n\n    const app = express()\n\n    app.use((req, res, next) => {\n      if (!global.ServerSettings.allowIframe) {\n        // Prevent clickjacking by disallowing iframes\n        res.setHeader('Content-Security-Policy', \"frame-ancestors 'self'\")\n      }\n\n      // Security: Prevent referrer leakage to protect against token exposure\n      // Using 'no-referrer' to completely prevent token leakage in referer headers\n      res.setHeader('Referrer-Policy', 'no-referrer')\n\n      /**\n       * @temporary\n       * This is necessary for the ebook & cover API endpoint in the mobile apps\n       * The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests\n       * so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint\n       * The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors\n       * @see https://ionicframework.com/docs/troubleshooting/cors\n       *\n       * Running in development allows cors to allow testing the mobile apps in the browser\n       * or env variable ALLOW_CORS = '1'\n       */\n      if (global.AllowCors || Logger.isDev || req.path.match(/\\/api\\/items\\/([a-z0-9-]{36})\\/(ebook|cover)(\\/[0-9]+)?/) || global.ServerSettings.allowedOrigins?.length) {\n        const allowedOrigins = ['capacitor://localhost', 'http://localhost', ...(global.ServerSettings.allowedOrigins ? global.ServerSettings.allowedOrigins : [])]\n        if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {\n          res.header('Access-Control-Allow-Origin', req.get('origin'))\n          res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')\n          res.header('Access-Control-Allow-Headers', '*')\n          res.header('Access-Control-Allow-Credentials', true)\n          if (req.method === 'OPTIONS') {\n            return res.sendStatus(200)\n          }\n        }\n      }\n\n      next()\n    })\n\n    // parse cookies in requests\n    app.use(cookieParser())\n    // enable express-session\n    app.use(\n      expressSession({\n        secret: this.auth.tokenManager.TokenSecret,\n        resave: false,\n        saveUninitialized: false,\n        cookie: {\n          // also send the cookie if were are not on https (not every use has https)\n          secure: false\n        },\n        store: new MemoryStore(86400000, 86400000, 1000)\n      })\n    )\n    // init passport.js\n    app.use(passport.initialize())\n    // register passport in express-session\n    app.use(this.auth.ifAuthNeeded(passport.session()))\n    // config passport.js\n    await this.auth.initPassportJs()\n\n    const router = express.Router()\n\n    // if RouterBasePath is set, modify all requests to include the base path\n    app.use((req, res, next) => {\n      const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)\n      const host = req.get('host')\n      const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'\n      const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''\n      req.originalHostPrefix = `${protocol}://${host}${prefix}`\n      if (!urlStartsWithRouterBasePath) {\n        req.url = `${global.RouterBasePath}${req.url}`\n      }\n      next()\n    })\n    app.use(global.RouterBasePath, router)\n    app.disable('x-powered-by')\n\n    this.server = http.createServer(app)\n\n    router.use(\n      fileUpload({\n        defCharset: 'utf8',\n        defParamCharset: 'utf8',\n        useTempFiles: true,\n        tempFileDir: Path.join(global.MetadataPath, 'tmp')\n      })\n    )\n    router.use(express.urlencoded({ extended: true, limit: '5mb' }))\n\n    // Skip JSON parsing for internal-api routes\n    router.use(/^(?!\\/internal-api).*/, express.json({ limit: '10mb' }))\n\n    router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)\n    router.use('/hls', this.hlsRouter.router)\n    router.use('/public', this.publicRouter.router)\n\n    // Static folder\n    router.use(express.static(Path.join(global.appRoot, 'static')))\n\n    // RSS Feed temp route\n    router.get('/feed/:slug', (req, res) => {\n      Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)\n      RssFeedManager.getFeed(req, res)\n    })\n    router.get('/feed/:slug/cover*', (req, res) => {\n      RssFeedManager.getFeedCover(req, res)\n    })\n    router.get('/feed/:slug/item/:episodeId/*', (req, res) => {\n      Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)\n      RssFeedManager.getFeedItem(req, res)\n    })\n\n    // Auth routes\n    await this.auth.initAuthRoutes(router)\n\n    router.post('/init', (req, res) => {\n      if (Database.hasRootUser) {\n        Logger.error(`[Server] attempt to init server when server already has a root user`)\n        return res.sendStatus(500)\n      }\n      this.initializeServer(req, res)\n    })\n    router.get('/status', (req, res) => {\n      // status check for client to see if server has been initialized\n      // server has been initialized if a root user exists\n      const payload = {\n        app: 'audiobookshelf',\n        serverVersion: version,\n        isInit: Database.hasRootUser,\n        language: Database.serverSettings.language,\n        authMethods: Database.serverSettings.authActiveAuthMethods,\n        authFormData: Database.serverSettings.authFormData\n      }\n      if (!payload.isInit) {\n        payload.ConfigPath = global.ConfigPath\n        payload.MetadataPath = global.MetadataPath\n      }\n      res.json(payload)\n    })\n    router.get('/ping', (req, res) => {\n      Logger.info('Received ping')\n      res.json({ success: true })\n    })\n    router.get('/healthcheck', (req, res) => res.sendStatus(200))\n\n    const ReactClientPath = process.env.REACT_CLIENT_PATH\n    if (!ReactClientPath) {\n      // Static path to generated nuxt\n      const distPath = Path.join(global.appRoot, '/client/dist')\n      router.use(express.static(distPath))\n\n      // Client dynamic routes\n      const dynamicRoutes = [\n        '/item/:id',\n        '/author/:id',\n        '/audiobook/:id/chapters',\n        '/audiobook/:id/edit',\n        '/audiobook/:id/manage',\n        '/library/:library',\n        '/library/:library/search',\n        '/library/:library/bookshelf/:id?',\n        '/library/:library/authors',\n        '/library/:library/narrators',\n        '/library/:library/stats',\n        '/library/:library/series/:id?',\n        '/library/:library/podcast/search',\n        '/library/:library/podcast/latest',\n        '/library/:library/podcast/download-queue',\n        '/config/users/:id',\n        '/config/users/:id/sessions',\n        '/config/item-metadata-utils/:id',\n        '/collection/:id',\n        '/playlist/:id',\n        '/share/:slug'\n      ]\n      dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))\n    } else {\n      // This is for using the experimental Next.js client\n      Logger.info(`Using React client at ${ReactClientPath}`)\n      const nextPath = Path.join(ReactClientPath, 'node_modules/next')\n      const next = require(nextPath)\n      const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })\n      const handle = nextApp.getRequestHandler()\n      await nextApp.prepare()\n      router.all('*', (req, res) => handle(req, res))\n    }\n\n    const unixSocketPrefix = 'unix/'\n    if (this.Host?.startsWith(unixSocketPrefix)) {\n      const sockPath = this.Host.slice(unixSocketPrefix.length)\n      this.server.listen(sockPath, async () => {\n        await fs.chmod(sockPath, 0o666)\n        Logger.info(`Listening on unix socket ${sockPath}`)\n      })\n    } else {\n      this.server.listen(this.Port, this.Host, () => {\n        if (this.Host) Logger.info(`Listening on http://${is.ipv6(this.Host) ? `[${this.Host}]` : this.Host}:${this.Port}`)\n        else Logger.info(`Listening on port :${this.Port}`)\n      })\n    }\n\n    // Start listening for socket connections\n    SocketAuthority.initialize(this)\n  }\n\n  async initializeServer(req, res) {\n    Logger.info(`[Server] Initializing new server`)\n    const newRoot = req.body.newRoot\n    const rootUsername = newRoot.username || 'root'\n    const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''\n    if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)\n    await Database.createRootUser(rootUsername, rootPash, this.auth)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist\n   */\n  async cleanUserData() {\n    // Get all media progress without an associated media item\n    const mediaProgressToRemove = await Database.mediaProgressModel.findAll({\n      where: {\n        '$podcastEpisode.id$': null,\n        '$book.id$': null\n      },\n      attributes: ['id'],\n      include: [\n        {\n          model: Database.bookModel,\n          attributes: ['id']\n        },\n        {\n          model: Database.podcastEpisodeModel,\n          attributes: ['id']\n        }\n      ]\n    })\n    if (mediaProgressToRemove.length) {\n      // Remove media progress\n      const mediaProgressRemoved = await Database.mediaProgressModel.destroy({\n        where: {\n          id: {\n            [Sequelize.Op.in]: mediaProgressToRemove.map((mp) => mp.id)\n          }\n        }\n      })\n      if (mediaProgressRemoved) {\n        Logger.info(`[Server] Removed ${mediaProgressRemoved} media progress for media items that no longer exist in db`)\n      }\n    }\n\n    // Remove series from hide from continue listening that no longer exist\n    try {\n      const users = await Database.sequelize.query(`SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove FROM users u, json_each(u.extraData->\"seriesHideFromContinueListening\") LEFT JOIN series se ON se.id = value WHERE se.id IS NULL GROUP BY u.id;`, {\n        model: Database.userModel,\n        type: Sequelize.QueryTypes.SELECT\n      })\n      for (const user of users) {\n        const extraData = JSON.parse(user.extraData)\n        const existingSeriesIds = extraData.seriesHideFromContinueListening\n        const seriesIdsToRemove = JSON.parse(user.dataValues.seriesIdsToRemove)\n        Logger.info(`[Server] Found ${seriesIdsToRemove.length} non-existent series in seriesHideFromContinueListening for user \"${user.username}\" - Removing (${seriesIdsToRemove.join(',')})`)\n        const newExtraData = {\n          ...extraData,\n          seriesHideFromContinueListening: existingSeriesIds.filter((s) => !seriesIdsToRemove.includes(s))\n        }\n        await user.update({ extraData: newExtraData })\n      }\n    } catch (error) {\n      Logger.error(`[Server] Failed to cleanup users seriesHideFromContinueListening`, error)\n    }\n  }\n\n  /**\n   * Gracefully stop server\n   * Stops watcher and socket server\n   */\n  async stop() {\n    Logger.info('=== Stopping Server ===')\n    Watcher.close()\n    Logger.info('[Server] Watcher Closed')\n    await SocketAuthority.close()\n    Logger.info('[Server] Closing HTTP Server')\n    await new Promise((resolve) => this.server.close(resolve))\n    Logger.info('[Server] HTTP Server Closed')\n  }\n}\nmodule.exports = Server\n"
  },
  {
    "path": "server/SocketAuthority.js",
    "content": "const SocketIO = require('socket.io')\nconst Logger = require('./Logger')\nconst Database = require('./Database')\nconst TokenManager = require('./auth/TokenManager')\nconst CoverSearchManager = require('./managers/CoverSearchManager')\n\n/**\n * @typedef SocketClient\n * @property {string} id socket id\n * @property {SocketIO.Socket} socket\n * @property {number} connected_at\n * @property {import('./models/User')} user\n */\n\nclass SocketAuthority {\n  constructor() {\n    this.Server = null\n    this.socketIoServers = []\n\n    /** @type {Object.<string, SocketClient>} */\n    this.clients = {}\n  }\n\n  /**\n   * returns an array of User.toJSONForPublic with `connections` for the # of socket connections\n   *  a user can have many socket connections\n   * @returns {object[]}\n   */\n  getUsersOnline() {\n    const onlineUsersMap = {}\n    Object.values(this.clients)\n      .filter((c) => c.user)\n      .forEach((client) => {\n        if (onlineUsersMap[client.user.id]) {\n          onlineUsersMap[client.user.id].connections++\n        } else {\n          onlineUsersMap[client.user.id] = {\n            ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),\n            connections: 1\n          }\n        }\n      })\n    return Object.values(onlineUsersMap)\n  }\n\n  getClientsForUser(userId) {\n    return Object.values(this.clients).filter((c) => c.user?.id === userId)\n  }\n\n  /**\n   * Emits event to all authorized clients\n   * @param {string} evt\n   * @param {any} data\n   * @param {Function} [filter] optional filter function to only send event to specific users\n   */\n  emitter(evt, data, filter = null) {\n    for (const socketId in this.clients) {\n      if (this.clients[socketId].user) {\n        if (filter && !filter(this.clients[socketId].user)) continue\n\n        this.clients[socketId].socket.emit(evt, data)\n      }\n    }\n  }\n\n  // Emits event to all clients for a specific user\n  clientEmitter(userId, evt, data) {\n    const clients = this.getClientsForUser(userId)\n    if (!clients.length) {\n      return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`)\n    }\n    clients.forEach((client) => {\n      if (client.socket) {\n        client.socket.emit(evt, data)\n      }\n    })\n  }\n\n  // Emits event to all admin user clients\n  adminEmitter(evt, data) {\n    for (const socketId in this.clients) {\n      if (this.clients[socketId].user?.isAdminOrUp) {\n        this.clients[socketId].socket.emit(evt, data)\n      }\n    }\n  }\n\n  /**\n   * Emits event with library item to all clients that can access the library item\n   * Note: Emits toOldJSONExpanded()\n   *\n   * @param {string} evt\n   * @param {import('./models/LibraryItem')} libraryItem\n   */\n  libraryItemEmitter(evt, libraryItem) {\n    for (const socketId in this.clients) {\n      if (this.clients[socketId].user?.checkCanAccessLibraryItem(libraryItem)) {\n        this.clients[socketId].socket.emit(evt, libraryItem.toOldJSONExpanded())\n      }\n    }\n  }\n\n  /**\n   * Emits event with library items to all clients that can access the library items\n   * Note: Emits toOldJSONExpanded()\n   *\n   * @param {string} evt\n   * @param {import('./models/LibraryItem')[]} libraryItems\n   */\n  libraryItemsEmitter(evt, libraryItems) {\n    for (const socketId in this.clients) {\n      if (this.clients[socketId].user) {\n        const libraryItemsAccessibleToUser = libraryItems.filter((li) => this.clients[socketId].user.checkCanAccessLibraryItem(li))\n        if (libraryItemsAccessibleToUser.length) {\n          this.clients[socketId].socket.emit(\n            evt,\n            libraryItemsAccessibleToUser.map((li) => li.toOldJSONExpanded())\n          )\n        }\n      }\n    }\n  }\n\n  /**\n   * Closes the Socket.IO server and disconnect all clients\n   *\n   * @param {Function} callback\n   */\n  async close() {\n    Logger.info('[SocketAuthority] closing...')\n    const closePromises = this.socketIoServers.map((io) => {\n      return new Promise((resolve) => {\n        Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)\n        io.close(() => {\n          Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)\n          resolve()\n        })\n      })\n    })\n    await Promise.all(closePromises)\n    Logger.info('[SocketAuthority] closed')\n    this.socketIoServers = []\n  }\n\n  initialize(Server) {\n    this.Server = Server\n\n    const socketIoOptions = {\n      cors: {\n        origin: '*',\n        methods: ['GET', 'POST']\n      }\n    }\n\n    const ioServer = new SocketIO.Server(Server.server, socketIoOptions)\n    ioServer.path = '/socket.io'\n    this.socketIoServers.push(ioServer)\n\n    if (global.RouterBasePath) {\n      // open a separate socket.io server for the router base path, keeping the original server open for legacy clients\n      const ioBasePath = `${global.RouterBasePath}/socket.io`\n      const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })\n      ioBasePathServer.path = ioBasePath\n      this.socketIoServers.push(ioBasePathServer)\n    }\n\n    this.socketIoServers.forEach((io) => {\n      io.on('connection', (socket) => {\n        this.clients[socket.id] = {\n          id: socket.id,\n          socket,\n          connected_at: Date.now()\n        }\n        socket.sheepClient = this.clients[socket.id]\n\n        Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)\n\n        // Required for associating a User with a socket\n        socket.on('auth', (token) => this.authenticateSocket(socket, token))\n\n        // Scanning\n        socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))\n\n        // Cover search streaming\n        socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))\n        socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))\n\n        // Logs\n        socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))\n        socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))\n\n        // Sent automatically from socket.io clients\n        socket.on('disconnect', (reason) => {\n          Logger.removeSocketListener(socket.id)\n\n          const _client = this.clients[socket.id]\n          if (!_client) {\n            Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)\n          } else if (!_client.user) {\n            Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)\n            delete this.clients[socket.id]\n          } else {\n            Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)\n            this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))\n\n            const disconnectTime = Date.now() - _client.connected_at\n            Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client \"${_client.user.username}\" after ${disconnectTime}ms (Reason: ${reason})`)\n\n            // Cancel any active cover searches for this socket\n            this.cancelSocketCoverSearches(socket.id)\n\n            delete this.clients[socket.id]\n          }\n        })\n\n        //\n        // Events for testing\n        //\n        socket.on('message_all_users', (payload) => {\n          // admin user can send a message to all authenticated users\n          //   displays on the web app as a toast\n          const client = this.clients[socket.id] || {}\n          if (client.user?.isAdminOrUp) {\n            this.emitter('admin_message', payload.message || '')\n          } else {\n            Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)\n          }\n        })\n        socket.on('ping', () => {\n          const client = this.clients[socket.id] || {}\n          const user = client.user || {}\n          Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)\n          socket.emit('pong')\n        })\n      })\n    })\n  }\n\n  /**\n   * When setting up a socket connection the user needs to be associated with a socket id\n   * for this the client will send a 'auth' event that includes the users API token\n   *\n   * Sends event 'init' to the socket. For admins this contains an array of users online.\n   * For failed authentication it sends event 'auth_failed' with a message\n   *\n   * @param {SocketIO.Socket} socket\n   * @param {string} token JWT\n   */\n  async authenticateSocket(socket, token) {\n    // we don't use passport to authenticate the jwt we get over the socket connection.\n    // it's easier to directly verify/decode it.\n    // TODO: Support API keys for web socket connections\n    const token_data = TokenManager.validateAccessToken(token)\n\n    if (!token_data?.userId) {\n      // Token invalid\n      Logger.error('Cannot validate socket - invalid token')\n      return socket.emit('auth_failed', { message: 'Invalid token' })\n    }\n\n    // get the user via the id from the decoded jwt.\n    const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)\n    if (!user) {\n      // user not found\n      Logger.error('Cannot validate socket - invalid token')\n      return socket.emit('auth_failed', { message: 'Invalid token' })\n    }\n    if (!user.isActive) {\n      Logger.error('Cannot validate socket - user is not active')\n      return socket.emit('auth_failed', { message: 'Invalid user' })\n    }\n\n    const client = this.clients[socket.id]\n    if (!client) {\n      Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)\n      return\n    }\n\n    if (client.user !== undefined) {\n      if (client.user.id === user.id) {\n        // Allow re-authentication of a socket to the same user\n        Logger.info(`[SocketAuthority] Authenticating socket already associated to user \"${client.user.username}\"`)\n      } else {\n        // Allow re-authentication of a socket to a different user but shouldn't happen\n        Logger.warn(`[SocketAuthority] Authenticating socket to user \"${user.username}\", but is already associated with a different user \"${client.user.username}\"`)\n      }\n    } else {\n      Logger.debug(`[SocketAuthority] Authenticating socket to user \"${user.username}\"`)\n    }\n\n    client.user = user\n    this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))\n\n    // Update user lastSeen without firing sequelize bulk update hooks\n    user.lastSeen = Date.now()\n    await user.save({ hooks: false })\n\n    const initialPayload = {\n      userId: client.user.id,\n      username: client.user.username\n    }\n    if (user.isAdminOrUp) {\n      initialPayload.usersOnline = this.getUsersOnline()\n    }\n    client.socket.emit('init', initialPayload)\n  }\n\n  cancelScan(id) {\n    Logger.debug('[SocketAuthority] Cancel scan', id)\n    this.Server.cancelLibraryScan(id)\n  }\n\n  /**\n   * Handle cover search request via WebSocket\n   * @param {SocketIO.Socket} socket\n   * @param {Object} payload\n   */\n  async handleCoverSearch(socket, payload) {\n    const client = this.clients[socket.id]\n    if (!client?.user) {\n      Logger.error('[SocketAuthority] Unauthorized cover search request')\n      socket.emit('cover_search_error', {\n        requestId: payload.requestId,\n        error: 'Unauthorized'\n      })\n      return\n    }\n\n    const { requestId, title, author, provider, podcast } = payload\n\n    if (!requestId || !title) {\n      Logger.error('[SocketAuthority] Invalid cover search request')\n      socket.emit('cover_search_error', {\n        requestId,\n        error: 'Invalid request parameters'\n      })\n      return\n    }\n\n    Logger.info(`[SocketAuthority] User ${client.user.username} initiated cover search ${requestId}`)\n\n    // Callback for streaming results to client\n    const onResult = (result) => {\n      socket.emit('cover_search_result', {\n        requestId,\n        provider: result.provider,\n        covers: result.covers,\n        total: result.total\n      })\n    }\n\n    // Callback when search completes\n    const onComplete = () => {\n      Logger.info(`[SocketAuthority] Cover search ${requestId} completed`)\n      socket.emit('cover_search_complete', { requestId })\n    }\n\n    // Callback for provider errors\n    const onError = (provider, errorMessage) => {\n      socket.emit('cover_search_provider_error', {\n        requestId,\n        provider,\n        error: errorMessage\n      })\n    }\n\n    // Start the search\n    CoverSearchManager.startSearch(requestId, { title, author, provider, podcast }, onResult, onComplete, onError).catch((error) => {\n      Logger.error(`[SocketAuthority] Cover search ${requestId} failed:`, error)\n      socket.emit('cover_search_error', {\n        requestId,\n        error: error.message\n      })\n    })\n  }\n\n  /**\n   * Handle cancel cover search request\n   * @param {SocketIO.Socket} socket\n   * @param {string} requestId\n   */\n  handleCancelCoverSearch(socket, requestId) {\n    const client = this.clients[socket.id]\n    if (!client?.user) {\n      Logger.error('[SocketAuthority] Unauthorized cancel cover search request')\n      return\n    }\n\n    Logger.info(`[SocketAuthority] User ${client.user.username} cancelled cover search ${requestId}`)\n\n    const cancelled = CoverSearchManager.cancelSearch(requestId)\n    if (cancelled) {\n      socket.emit('cover_search_cancelled', { requestId })\n    }\n  }\n\n  /**\n   * Cancel all cover searches associated with a socket (called on disconnect)\n   * @param {string} socketId\n   */\n  cancelSocketCoverSearches(socketId) {\n    // Get all active search request IDs and cancel those that might belong to this socket\n    // Since we don't track socket-to-request mapping, we log this for debugging\n    // The client will handle reconnection gracefully\n    Logger.debug(`[SocketAuthority] Socket ${socketId} disconnected, any active searches will timeout`)\n  }\n}\nmodule.exports = new SocketAuthority()\n"
  },
  {
    "path": "server/Watcher.js",
    "content": "const Path = require('path')\nconst EventEmitter = require('events')\nconst Watcher = require('./libs/watcher/watcher')\nconst Logger = require('./Logger')\nconst Task = require('./objects/Task')\nconst TaskManager = require('./managers/TaskManager')\n\nconst { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs, shouldIgnoreFile } = require('./utils/fileUtils')\n\n/**\n * @typedef PendingFileUpdate\n * @property {string} path\n * @property {string} relPath\n * @property {string} folderId\n * @property {string} type\n */\nclass FolderWatcher extends EventEmitter {\n  constructor() {\n    super()\n\n    /** @type {{id:string, name:string, libraryFolders:import('./models/Folder')[], paths:string[], watcher:Watcher[]}[]} */\n    this.libraryWatchers = []\n    /** @type {PendingFileUpdate[]} */\n    this.pendingFileUpdates = []\n    this.pendingDelay = 10000\n    /** @type {NodeJS.Timeout} */\n    this.pendingTimeout = null\n    /** @type {Task} */\n    this.pendingTask = null\n\n    this.filesBeingAdded = new Set()\n\n    /** @type {Set<string>} */\n    this.ignoreFilePathsDownloading = new Set()\n    /** @type {string[]} */\n    this.ignoreDirs = []\n    /** @type {string[]} */\n    this.pendingDirsToRemoveFromIgnore = []\n    /** @type {NodeJS.Timeout} */\n    this.removeFromIgnoreTimer = null\n\n    this.disabled = false\n  }\n\n  get pendingFilePaths() {\n    return this.pendingFileUpdates.map((f) => f.path)\n  }\n\n  /**\n   *\n   * @param {import('./models/Library')} library\n   */\n  buildLibraryWatcher(library) {\n    if (this.libraryWatchers.find((w) => w.id === library.id)) {\n      Logger.warn('[Watcher] Already watching library', library.name)\n      return\n    }\n    Logger.info(`[Watcher] Initializing watcher for \"${library.name}\".`)\n\n    const folderPaths = library.libraryFolders.map((f) => f.path)\n    folderPaths.forEach((fp) => {\n      Logger.debug(`[Watcher] Init watcher for library folder path \"${fp}\"`)\n    })\n    const watcher = new Watcher(folderPaths, {\n      ignored: /(^|[\\/\\\\])\\../, // ignore dotfiles\n      renameDetection: true,\n      renameTimeout: 2000,\n      recursive: true,\n      ignoreInitial: true,\n      persistent: true\n    })\n    watcher\n      .on('add', (path) => {\n        this.onFileAdded(library.id, filePathToPOSIX(path))\n      })\n      .on('change', (path) => {\n        // This is triggered from metadata changes, not what we want\n      })\n      .on('unlink', (path) => {\n        this.onFileRemoved(library.id, filePathToPOSIX(path))\n      })\n      .on('rename', (path, pathNext) => {\n        this.onFileRename(library.id, filePathToPOSIX(path), filePathToPOSIX(pathNext))\n      })\n      .on('error', (error) => {\n        Logger.error(`[Watcher] ${error}`)\n      })\n      .on('ready', () => {\n        Logger.info(`[Watcher] \"${library.name}\" Ready`)\n      })\n      .on('close', () => {\n        Logger.debug(`[Watcher] \"${library.name}\" Closed`)\n      })\n\n    this.libraryWatchers.push({\n      id: library.id,\n      name: library.name,\n      libraryFolders: library.libraryFolders,\n      paths: folderPaths,\n      watcher\n    })\n  }\n\n  /**\n   *\n   * @param {import('./models/Library')[]} libraries\n   */\n  initWatcher(libraries) {\n    libraries.forEach((lib) => {\n      if (!lib.settings.disableWatcher) {\n        this.buildLibraryWatcher(lib)\n      }\n    })\n  }\n\n  /**\n   *\n   * @param {import('./models/Library')} library\n   */\n  addLibrary(library) {\n    if (this.disabled || library.settings.disableWatcher) return\n    this.buildLibraryWatcher(library)\n  }\n\n  /**\n   *\n   * @param {import('./models/Library')} library\n   */\n  updateLibrary(library) {\n    if (this.disabled) return\n\n    const libwatcher = this.libraryWatchers.find((lib) => lib.id === library.id)\n    if (libwatcher) {\n      // Library watcher was disabled\n      if (library.settings.disableWatcher) {\n        Logger.info(`[Watcher] updateLibrary: Library \"${library.name}\" watcher disabled`)\n        libwatcher.watcher.close()\n        this.libraryWatchers = this.libraryWatchers.filter((lw) => lw.id !== libwatcher.id)\n        return\n      }\n\n      libwatcher.name = library.name\n\n      // If any folder paths were added or removed then re-init watcher\n      const folderPaths = library.libraryFolders.map((f) => f.path)\n      const pathsToAdd = folderPaths.filter((path) => !libwatcher.paths.includes(path))\n      const pathsRemoved = libwatcher.paths.filter((path) => !folderPaths.includes(path))\n      if (pathsToAdd.length || pathsRemoved.length) {\n        Logger.info(`[Watcher] Re-Initializing watcher for \"${library.name}\".`)\n\n        libwatcher.watcher.close()\n        this.libraryWatchers = this.libraryWatchers.filter((lw) => lw.id !== libwatcher.id)\n        this.buildLibraryWatcher(library)\n      }\n    } else if (!library.settings.disableWatcher) {\n      // Library watcher was enabled\n      Logger.info(`[Watcher] updateLibrary: Library \"${library.name}\" watcher enabled - initializing`)\n      this.buildLibraryWatcher(library)\n    }\n  }\n\n  /**\n   *\n   * @param {import('./models/Library')} library\n   */\n  removeLibrary(library) {\n    if (this.disabled) return\n    var libwatcher = this.libraryWatchers.find((lib) => lib.id === library.id)\n    if (libwatcher) {\n      Logger.info(`[Watcher] Removed watcher for \"${library.name}\"`)\n      libwatcher.watcher.close()\n      this.libraryWatchers = this.libraryWatchers.filter((lib) => lib.id !== library.id)\n    } else {\n      Logger.error(`[Watcher] Library watcher not found for \"${library.name}\"`)\n    }\n  }\n\n  close() {\n    return this.libraryWatchers.map((lib) => lib.watcher.close())\n  }\n\n  /**\n   * Watcher detected file added\n   *\n   * @param {string} libraryId\n   * @param {string} path\n   */\n  onFileAdded(libraryId, path) {\n    if (this.checkShouldIgnorePath(path)) {\n      return\n    }\n    Logger.debug('[Watcher] File Added', path)\n    if (!this.addFileUpdate(libraryId, path, 'added')) {\n      return\n    }\n\n    if (!this.filesBeingAdded.has(path)) {\n      this.filesBeingAdded.add(path)\n      this.waitForFileToAdd(path)\n    }\n  }\n\n  /**\n   * Watcher detected file removed\n   *\n   * @param {string} libraryId\n   * @param {string} path\n   */\n  onFileRemoved(libraryId, path) {\n    if (this.checkShouldIgnorePath(path)) {\n      return\n    }\n    Logger.debug('[Watcher] File Removed', path)\n    this.addFileUpdate(libraryId, path, 'deleted')\n  }\n\n  /**\n   * Watcher detected file renamed\n   *\n   * @param {string} libraryId\n   * @param {string} path\n   */\n  onFileRename(libraryId, pathFrom, pathTo) {\n    if (this.checkShouldIgnorePath(pathTo)) {\n      return\n    }\n    Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)\n    this.addFileUpdate(libraryId, pathTo, 'renamed')\n  }\n\n  /**\n   * Get mtimeMs from an added file every 3 seconds until it is no longer changing\n   * Times out after 600s\n   *\n   * @param {string} path\n   * @param {number} [lastMTimeMs=0]\n   * @param {number} [loop=0]\n   */\n  async waitForFileToAdd(path, lastMTimeMs = 0, loop = 0) {\n    // Safety to catch infinite loop (600s)\n    if (loop >= 200) {\n      Logger.warn(`[Watcher] Waiting to add file at \"${path}\" timeout (loop ${loop}) - bailing`)\n      this.pendingFileUpdates = this.pendingFileUpdates.filter((pfu) => pfu.path !== path)\n      return this.filesBeingAdded.delete(path)\n    }\n\n    const mtimeMs = await getFileMTimeMs(path)\n    if (mtimeMs === lastMTimeMs) {\n      if (lastMTimeMs) Logger.debug(`[Watcher] File finished adding at \"${path}\"`)\n      return this.filesBeingAdded.delete(path)\n    }\n    if (loop % 5 === 0) {\n      Logger.debug(`[Watcher] Waiting to add file at \"${path}\". mtimeMs=${mtimeMs} lastMTimeMs=${lastMTimeMs} (loop ${loop})`)\n    }\n    // Wait 3 seconds\n    await new Promise((resolve) => setTimeout(resolve, 3000))\n    this.waitForFileToAdd(path, mtimeMs, ++loop)\n  }\n\n  /**\n   * Queue file update\n   *\n   * @param {string} libraryId\n   * @param {string} path\n   * @param {string} type\n   * @returns {boolean} - If file was added to pending updates\n   */\n  addFileUpdate(libraryId, path, type) {\n    if (this.pendingFilePaths.includes(path)) return false\n\n    // Get file library\n    const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId)\n    if (!libwatcher) {\n      Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)\n      return false\n    }\n\n    // Get file folder\n    const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path))\n    if (!folder) {\n      Logger.error(`[Watcher] New file folder not found in library \"${libwatcher.name}\" with path \"${path}\"`)\n      return false\n    }\n\n    const folderPath = filePathToPOSIX(folder.path)\n\n    const relPath = path.replace(folderPath, '')\n\n    // Check for ignored extensions or directories, such as dotfiles and hidden directories\n    const shouldIgnore = shouldIgnoreFile(relPath)\n    if (shouldIgnore) {\n      Logger.debug(`[Watcher] Ignoring ${shouldIgnore} - \"${relPath}\"`)\n      return false\n    }\n\n    Logger.debug(`[Watcher] Modified file in library \"${libwatcher.name}\" and folder \"${folder.id}\" with relPath \"${relPath}\"`)\n\n    if (!this.pendingTask) {\n      const taskData = {\n        libraryId,\n        libraryName: libwatcher.name\n      }\n      const taskTitleString = {\n        text: `Scanning file changes in \"${libwatcher.name}\"`,\n        key: 'MessageTaskScanningFileChanges',\n        subs: [libwatcher.name]\n      }\n      this.pendingTask = TaskManager.createAndAddTask('watcher-scan', taskTitleString, null, true, taskData)\n    }\n    this.pendingFileUpdates.push({\n      path,\n      relPath,\n      folderId: folder.id,\n      libraryId,\n      type\n    })\n\n    this.handlePendingFileUpdatesTimeout()\n    return true\n  }\n\n  /**\n   * Wait X seconds before notifying scanner that files changed\n   * reset timer if files are still copying\n   */\n  handlePendingFileUpdatesTimeout() {\n    clearTimeout(this.pendingTimeout)\n    this.pendingTimeout = setTimeout(() => {\n      // Check that files are not still being added\n      if (this.pendingFileUpdates.some((pfu) => this.filesBeingAdded.has(pfu.path))) {\n        Logger.debug(`[Watcher] Still waiting for pending files \"${[...this.filesBeingAdded].join(', ')}\"`)\n        return this.handlePendingFileUpdatesTimeout()\n      }\n\n      if (this.pendingFileUpdates.length) {\n        this.emit('scanFilesChanged', this.pendingFileUpdates, this.pendingTask)\n      } else {\n        const taskFinishedString = {\n          text: 'No files to scan',\n          key: 'MessageTaskNoFilesToScan'\n        }\n        this.pendingTask.setFinished(taskFinishedString)\n        TaskManager.taskFinished(this.pendingTask)\n      }\n      this.pendingTask = null\n      this.pendingFileUpdates = []\n      this.filesBeingAdded.clear()\n    }, this.pendingDelay)\n  }\n\n  /**\n   *\n   * @param {string} path\n   * @returns {boolean}\n   */\n  checkShouldIgnorePath(path) {\n    return !!this.ignoreDirs.find((dirpath) => {\n      return isSameOrSubPath(dirpath, path)\n    })\n  }\n\n  /**\n   * When scanning a library item folder these files should be ignored\n   * Either a podcast episode downloading or a file that is pending by the watcher\n   *\n   * @param {string} path\n   * @returns {boolean}\n   */\n  checkShouldIgnoreFilePath(path) {\n    if (this.pendingFilePaths.includes(path)) return true\n    return this.ignoreFilePathsDownloading.has(path)\n  }\n\n  /**\n   * Convert to POSIX and remove trailing slash\n   * @param {string} path\n   * @returns {string}\n   */\n  cleanDirPath(path) {\n    path = filePathToPOSIX(path)\n    if (path.endsWith('/')) path = path.slice(0, -1)\n    return path\n  }\n\n  /**\n   * Ignore this directory if files are picked up by watcher\n   * @param {string} path\n   */\n  addIgnoreDir(path) {\n    path = this.cleanDirPath(path)\n    this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter((p) => p !== path)\n    if (this.ignoreDirs.includes(path)) {\n      // Already ignoring dir\n      return\n    }\n    Logger.debug(`[Watcher] addIgnoreDir: Ignoring directory \"${path}\"`)\n    this.ignoreDirs.push(path)\n  }\n\n  /**\n   * When downloading a podcast episode we dont want the scanner triggering for that podcast\n   * when the episode finishes the watcher may have a delayed response so a timeout is added\n   * to prevent the watcher from picking up the episode\n   *\n   * @param {string} path\n   */\n  removeIgnoreDir(path) {\n    path = this.cleanDirPath(path)\n    if (!this.ignoreDirs.includes(path)) {\n      Logger.debug(`[Watcher] removeIgnoreDir: Path is not being ignored \"${path}\"`)\n      return\n    }\n\n    // Add a 5 second delay before removing the ignore from this dir\n    if (!this.pendingDirsToRemoveFromIgnore.includes(path)) {\n      this.pendingDirsToRemoveFromIgnore.push(path)\n    }\n\n    clearTimeout(this.removeFromIgnoreTimer)\n    this.removeFromIgnoreTimer = setTimeout(() => {\n      if (this.pendingDirsToRemoveFromIgnore.includes(path)) {\n        this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter((p) => p !== path)\n        Logger.debug(`[Watcher] removeIgnoreDir: No longer ignoring directory \"${path}\"`)\n        this.ignoreDirs = this.ignoreDirs.filter((p) => p !== path)\n      }\n    }, 5000)\n  }\n}\nmodule.exports = new FolderWatcher()\n"
  },
  {
    "path": "server/auth/LocalAuthStrategy.js",
    "content": "const passport = require('passport')\nconst LocalStrategy = require('../libs/passportLocal')\nconst Database = require('../Database')\nconst Logger = require('../Logger')\n\nconst bcrypt = require('../libs/bcryptjs')\nconst requestIp = require('../libs/requestIp')\n\n/**\n * Local authentication strategy using username/password\n */\nclass LocalAuthStrategy {\n  constructor() {\n    this.name = 'local'\n    this.strategy = null\n  }\n\n  /**\n   * Get the passport strategy instance\n   * @returns {LocalStrategy}\n   */\n  getStrategy() {\n    if (!this.strategy) {\n      this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this))\n    }\n    return this.strategy\n  }\n\n  /**\n   * Initialize the strategy with passport\n   */\n  init() {\n    passport.use(this.name, this.getStrategy())\n  }\n\n  /**\n   * Remove the strategy from passport\n   */\n  unuse() {\n    passport.unuse(this.name)\n    this.strategy = null\n  }\n\n  /**\n   * Verify user credentials\n   * @param {import('express').Request} req\n   * @param {string} username\n   * @param {string} password\n   * @param {Function} done - Passport callback\n   */\n  async verifyCredentials(req, username, password, done) {\n    // Load the user given it's username\n    const user = await Database.userModel.getUserByUsername(username.toLowerCase())\n\n    if (!user?.isActive) {\n      if (user) {\n        this.logFailedLoginAttempt(req, user.username, 'User is not active')\n      } else {\n        this.logFailedLoginAttempt(req, username, 'User not found')\n      }\n      done(null, null)\n      return\n    }\n\n    // Check passwordless root user\n    if (user.type === 'root' && !user.pash) {\n      if (password) {\n        // deny login\n        this.logFailedLoginAttempt(req, user.username, 'Root user has no password set')\n        done(null, null)\n        return\n      }\n      // approve login\n      Logger.info(`[LocalAuth] User \"${user.username}\" logged in from ip ${requestIp.getClientIp(req)}`)\n\n      done(null, user)\n      return\n    } else if (!user.pash) {\n      this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')\n      done(null, null)\n      return\n    }\n\n    // Check password match\n    const compare = await bcrypt.compare(password, user.pash)\n    if (compare) {\n      // approve login\n      Logger.info(`[LocalAuth] User \"${user.username}\" logged in from ip ${requestIp.getClientIp(req)}`)\n\n      done(null, user)\n      return\n    }\n\n    // deny login\n    this.logFailedLoginAttempt(req, user.username, 'Invalid password')\n    done(null, null)\n  }\n\n  /**\n   * Log failed login attempts\n   * @param {import('express').Request} req\n   * @param {string} username\n   * @param {string} message\n   */\n  logFailedLoginAttempt(req, username, message) {\n    if (!req || !username || !message) return\n    Logger.error(`[LocalAuth] Failed login attempt for username \"${username}\" from ip ${requestIp.getClientIp(req)} (${message})`)\n  }\n\n  /**\n   * Hash a password with bcrypt\n   * @param {string} password\n   * @returns {Promise<string>} hash\n   */\n  hashPassword(password) {\n    return new Promise((resolve) => {\n      bcrypt.hash(password, 8, (err, hash) => {\n        if (err) {\n          resolve(null)\n        } else {\n          resolve(hash)\n        }\n      })\n    })\n  }\n\n  /**\n   * Compare password with user's hashed password\n   * @param {string} password\n   * @param {import('../models/User')} user\n   * @returns {Promise<boolean>}\n   */\n  comparePassword(password, user) {\n    if (user.type === 'root' && !password && !user.pash) return true\n    if (!password || !user.pash) return false\n    return bcrypt.compare(password, user.pash)\n  }\n\n  /**\n   * Change user password\n   * @param {import('../models/User')} user\n   * @param {string} password\n   * @param {string} newPassword\n   */\n  async changePassword(user, password, newPassword) {\n    // Only root can have an empty password\n    if (user.type !== 'root' && !newPassword) {\n      return {\n        error: 'Invalid new password - Only root can have an empty password'\n      }\n    }\n\n    // Check password match\n    const compare = await this.comparePassword(password, user)\n    if (!compare) {\n      return {\n        error: 'Invalid password'\n      }\n    }\n\n    let pw = ''\n    if (newPassword) {\n      pw = await this.hashPassword(newPassword)\n      if (!pw) {\n        return {\n          error: 'Hash failed'\n        }\n      }\n    }\n\n    try {\n      await user.update({ pash: pw })\n      Logger.info(`[LocalAuth] User \"${user.username}\" changed password`)\n      return {\n        success: true\n      }\n    } catch (error) {\n      Logger.error(`[LocalAuth] User \"${user.username}\" failed to change password`, error)\n      return {\n        error: 'Unknown error'\n      }\n    }\n  }\n}\n\nmodule.exports = LocalAuthStrategy\n"
  },
  {
    "path": "server/auth/OidcAuthStrategy.js",
    "content": "const { Request, Response } = require('express')\nconst passport = require('passport')\nconst OpenIDClient = require('openid-client')\nconst axios = require('axios')\nconst Database = require('../Database')\nconst Logger = require('../Logger')\n\n/**\n * OpenID Connect authentication strategy\n */\nclass OidcAuthStrategy {\n  constructor() {\n    this.name = 'openid-client'\n    this.strategy = null\n    this.client = null\n    // Map of openId sessions indexed by oauth2 state-variable\n    this.openIdAuthSession = new Map()\n  }\n\n  /**\n   * Get the passport strategy instance\n   * @returns {OpenIDClient.Strategy}\n   */\n  getStrategy() {\n    if (!this.strategy) {\n      this.strategy = new OpenIDClient.Strategy(\n        {\n          client: this.getClient(),\n          params: {\n            redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,\n            scope: this.getScope()\n          }\n        },\n        this.verifyCallback.bind(this)\n      )\n    }\n    return this.strategy\n  }\n\n  /**\n   * Get the OpenID Connect client\n   * @returns {OpenIDClient.Client}\n   */\n  getClient() {\n    if (!this.client) {\n      if (!Database.serverSettings.isOpenIDAuthSettingsValid) {\n        throw new Error('OpenID Connect settings are not valid')\n      }\n\n      // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing\n      OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })\n\n      const openIdIssuerClient = new OpenIDClient.Issuer({\n        issuer: global.ServerSettings.authOpenIDIssuerURL,\n        authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,\n        token_endpoint: global.ServerSettings.authOpenIDTokenURL,\n        userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,\n        jwks_uri: global.ServerSettings.authOpenIDJwksURL,\n        end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL\n      }).Client\n\n      this.client = new openIdIssuerClient({\n        client_id: global.ServerSettings.authOpenIDClientID,\n        client_secret: global.ServerSettings.authOpenIDClientSecret,\n        id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm\n      })\n    }\n    return this.client\n  }\n\n  /**\n   * Get the scope string for the OpenID Connect request\n   * @returns {string}\n   */\n  getScope() {\n    let scope = 'openid profile email'\n    if (global.ServerSettings.authOpenIDGroupClaim) {\n      scope += ' ' + global.ServerSettings.authOpenIDGroupClaim\n    }\n    if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {\n      scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim\n    }\n    return scope\n  }\n\n  /**\n   * Initialize the strategy with passport\n   */\n  init() {\n    if (!Database.serverSettings.isOpenIDAuthSettingsValid) {\n      Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)\n      return\n    }\n    passport.use(this.name, this.getStrategy())\n  }\n\n  /**\n   * Remove the strategy from passport\n   */\n  unuse() {\n    passport.unuse(this.name)\n    this.strategy = null\n    this.client = null\n  }\n\n  /**\n   * Verify callback for OpenID Connect authentication\n   * @param {Object} tokenset\n   * @param {Object} userinfo\n   * @param {Function} done - Passport callback\n   */\n  async verifyCallback(tokenset, userinfo, done) {\n    let isNewUser = false\n    let user = null\n    try {\n      Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))\n\n      if (!userinfo.sub) {\n        throw new Error('Invalid userinfo, no sub')\n      }\n\n      if (!this.validateGroupClaim(userinfo)) {\n        throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)\n      }\n\n      user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)\n\n      if (user?.error) {\n        throw new Error('Invalid userinfo or already linked')\n      }\n\n      if (!user) {\n        // If no existing user was matched, auto-register if configured\n        if (global.ServerSettings.authOpenIDAutoRegister) {\n          Logger.info(`[User] openid: Auto-registering user with sub \"${userinfo.sub}\"`, userinfo)\n          user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo)\n          isNewUser = true\n        } else {\n          Logger.warn(`[User] openid: User not found and auto-register is disabled`)\n        }\n      }\n\n      if (!user.isActive) {\n        throw new Error('User not active or not found')\n      }\n\n      await this.setUserGroup(user, userinfo)\n      await this.updateUserPermissions(user, userinfo)\n\n      // We also have to save the id_token for later (used for logout) because we cannot set cookies here\n      user.openid_id_token = tokenset.id_token\n\n      return done(null, user)\n    } catch (error) {\n      Logger.error(`[OidcAuth] openid callback error: ${error?.message}\\n${error?.stack}`)\n      // Remove new user if an error occurs\n      if (isNewUser && user) {\n        await user.destroy()\n      }\n      return done(null, null, 'Unauthorized')\n    }\n  }\n\n  /**\n   * Validates the presence and content of the group claim in userinfo.\n   * @param {Object} userinfo\n   * @returns {boolean}\n   */\n  validateGroupClaim(userinfo) {\n    const groupClaimName = Database.serverSettings.authOpenIDGroupClaim\n    if (!groupClaimName)\n      // Allow no group claim when configured like this\n      return true\n\n    // If configured it must exist in userinfo\n    if (!userinfo[groupClaimName]) {\n      return false\n    }\n    return true\n  }\n\n  /**\n   * Sets the user group based on group claim in userinfo.\n   * @param {import('../models/User')} user\n   * @param {Object} userinfo\n   */\n  async setUserGroup(user, userinfo) {\n    const groupClaimName = Database.serverSettings.authOpenIDGroupClaim\n    if (!groupClaimName)\n      // No group claim configured, don't set anything\n      return\n\n    if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)\n\n    const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())\n    const rolesInOrderOfPriority = ['admin', 'user', 'guest']\n\n    let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))\n    if (userType) {\n      if (user.type === 'root') {\n        // Check OpenID Group\n        if (userType !== 'admin') {\n          throw new Error(`Root user \"${user.username}\" cannot be downgraded to ${userType}. Denying login.`)\n        } else {\n          // If root user is logging in via OpenID, we will not change the type\n          return\n        }\n      }\n\n      if (user.type !== userType) {\n        Logger.info(`[OidcAuth] openid callback: Updating user \"${user.username}\" type to \"${userType}\" from \"${user.type}\"`)\n        user.type = userType\n        await user.save()\n      }\n    } else {\n      throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)\n    }\n  }\n\n  /**\n   * Updates user permissions based on the advanced permissions claim.\n   * @param {import('../models/User')} user\n   * @param {Object} userinfo\n   */\n  async updateUserPermissions(user, userinfo) {\n    const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim\n    if (!absPermissionsClaim)\n      // No advanced permissions claim configured, don't set anything\n      return\n\n    if (user.type === 'admin' || user.type === 'root') return\n\n    const absPermissions = userinfo[absPermissionsClaim]\n    if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)\n\n    if (await user.updatePermissionsFromExternalJSON(absPermissions)) {\n      Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user \"${user.username}\" using \"${JSON.stringify(absPermissions)}\"`)\n    }\n  }\n\n  /**\n   * Generate PKCE parameters for the authorization request\n   * @param {Request} req\n   * @param {boolean} isMobileFlow\n   * @returns {Object|{error: string}}\n   */\n  generatePkce(req, isMobileFlow) {\n    if (isMobileFlow) {\n      if (!req.query.code_challenge) {\n        return {\n          error: 'code_challenge required for mobile flow (PKCE)'\n        }\n      }\n      if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {\n        return {\n          error: 'Only S256 code_challenge_method method supported'\n        }\n      }\n      return {\n        code_challenge: req.query.code_challenge,\n        code_challenge_method: req.query.code_challenge_method || 'S256'\n      }\n    } else {\n      const code_verifier = OpenIDClient.generators.codeVerifier()\n      const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)\n      return { code_challenge, code_challenge_method: 'S256', code_verifier }\n    }\n  }\n\n  /**\n   * Check if a redirect URI is valid\n   * @param {string} uri\n   * @returns {boolean}\n   */\n  isValidRedirectUri(uri) {\n    // Check if the redirect_uri is in the whitelist\n    return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')\n  }\n\n  /**\n   * Get the authorization URL for OpenID Connect\n   * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow\n   * @param {Request} req\n   * @returns {{ authorizationUrl: string }|{status: number, error: string}}\n   */\n  getAuthorizationUrl(req) {\n    const client = this.getClient()\n    const strategy = this.getStrategy()\n    const sessionKey = strategy._key\n\n    try {\n      const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'\n      const hostUrl = new URL(`${protocol}://${req.get('host')}`)\n      const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge\n\n      // Only allow code flow (for mobile clients)\n      if (req.query.response_type && req.query.response_type !== 'code') {\n        Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)\n        return {\n          status: 400,\n          error: 'Invalid response_type, only code supported'\n        }\n      }\n\n      // Generate a state on web flow or if no state supplied\n      const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state\n\n      // Redirect URL for the SSO provider\n      let redirectUri\n      if (isMobileFlow) {\n        // Mobile required redirect uri\n        // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect\n        //    where we will handle the redirect to it\n        if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {\n          Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)\n          return {\n            status: 400,\n            error: 'Invalid redirect_uri'\n          }\n        }\n        // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API\n        //   for the request to mobile-redirect and as such the session is not shared\n        this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })\n\n        redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()\n      } else {\n        redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()\n\n        if (req.query.state) {\n          Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)\n          return {\n            status: 400,\n            error: 'Invalid state, not allowed on web flow'\n          }\n        }\n      }\n\n      // Update the strategy's redirect_uri for this request\n      strategy._params.redirect_uri = redirectUri\n      Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)\n\n      const pkceData = this.generatePkce(req, isMobileFlow)\n      if (pkceData.error) {\n        return {\n          status: 400,\n          error: pkceData.error\n        }\n      }\n\n      req.session[sessionKey] = {\n        ...req.session[sessionKey],\n        state: state,\n        max_age: strategy._params.max_age,\n        response_type: 'code',\n        code_verifier: pkceData.code_verifier, // not null if web flow\n        mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out\n        sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback\n      }\n\n      const authorizationUrl = client.authorizationUrl({\n        ...strategy._params,\n        redirect_uri: redirectUri,\n        state: state,\n        response_type: 'code',\n        scope: this.getScope(),\n        code_challenge: pkceData.code_challenge,\n        code_challenge_method: pkceData.code_challenge_method\n      })\n\n      return {\n        authorizationUrl,\n        isMobileFlow\n      }\n    } catch (error) {\n      Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\\n${error?.stack}`)\n      return {\n        status: 500,\n        error: error.message || 'Unknown error'\n      }\n    }\n  }\n\n  /**\n   * Get the end session URL for logout\n   * @param {Request} req\n   * @param {string} idToken\n   * @param {string} authMethod\n   * @returns {string|null}\n   */\n  getEndSessionUrl(req, idToken, authMethod) {\n    const client = this.getClient()\n\n    if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {\n      let postLogoutRedirectUri = null\n\n      if (authMethod === 'openid') {\n        const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'\n        const host = req.get('host')\n        // TODO: ABS does currently not support subfolders for installation\n        // If we want to support it we need to include a config for the serverurl\n        postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`\n      }\n      // else for openid-mobile we keep postLogoutRedirectUri on null\n      //  nice would be to redirect to the app here, but for example Authentik does not implement\n      //  the post_logout_redirect_uri parameter at all and for other providers\n      //  we would also need again to implement (and even before get to know somehow for 3rd party apps)\n      //  the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).\n      //   Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like\n      //  &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution\n      //   (The URL needs to be whitelisted in the config of the SSO/ID provider)\n\n      return client.endSessionUrl({\n        id_token_hint: idToken,\n        post_logout_redirect_uri: postLogoutRedirectUri\n      })\n    }\n\n    return null\n  }\n\n  /**\n   * @typedef {Object} OpenIdIssuerConfig\n   * @property {string} issuer\n   * @property {string} authorization_endpoint\n   * @property {string} token_endpoint\n   * @property {string} userinfo_endpoint\n   * @property {string} end_session_endpoint\n   * @property {string} jwks_uri\n   * @property {string} id_token_signing_alg_values_supported\n   *\n   * Get OpenID Connect configuration from an issuer URL\n   * @param {string} issuerUrl\n   * @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}\n   */\n  async getIssuerConfig(issuerUrl) {\n    // Strip trailing slash\n    if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)\n\n    // Append config pathname and validate URL\n    let configUrl = null\n    try {\n      configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)\n      if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {\n        throw new Error('Invalid pathname')\n      }\n    } catch (error) {\n      Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL \"${configUrl}\"`, error)\n      return {\n        status: 400,\n        error: \"Invalid request. Query param 'issuer' is invalid\"\n      }\n    }\n\n    try {\n      const { data } = await axios.get(configUrl.toString())\n      return {\n        issuer: data.issuer,\n        authorization_endpoint: data.authorization_endpoint,\n        token_endpoint: data.token_endpoint,\n        userinfo_endpoint: data.userinfo_endpoint,\n        end_session_endpoint: data.end_session_endpoint,\n        jwks_uri: data.jwks_uri,\n        id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported\n      }\n    } catch (error) {\n      Logger.error(`[OidcAuth] Failed to get openid configuration at \"${configUrl}\"`, error)\n      return {\n        status: 400,\n        error: 'Failed to get openid configuration'\n      }\n    }\n  }\n\n  /**\n   * Handle mobile redirect for OAuth2 callback\n   * @param {Request} req\n   * @param {Response} res\n   */\n  handleMobileRedirect(req, res) {\n    try {\n      // Extract the state parameter from the request\n      const { state, code } = req.query\n\n      // Check if the state provided is in our list\n      if (!state || !this.openIdAuthSession.has(state)) {\n        Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch')\n        return res.status(400).send('State parameter mismatch')\n      }\n\n      let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri\n\n      if (!mobile_redirect_uri) {\n        Logger.error('[OidcAuth] No redirect URI')\n        return res.status(400).send('No redirect URI')\n      }\n\n      this.openIdAuthSession.delete(state)\n\n      const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`\n      // Redirect to the overwrite URI saved in the map\n      res.redirect(redirectUri)\n    } catch (error) {\n      Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\\n${error?.stack}`)\n      res.status(500).send('Internal Server Error')\n    }\n  }\n\n  /**\n   * Validates if a callback URL is safe for redirect (same-origin only)\n   * @param {string} callbackUrl - The callback URL to validate\n   * @param {Request} req - Express request object to get current host\n   * @returns {boolean} - True if the URL is safe (same-origin), false otherwise\n   */\n  isValidWebCallbackUrl(callbackUrl, req) {\n    if (!callbackUrl) return false\n\n    try {\n      // Handle relative URLs - these are always safe if they start with router base path\n      if (callbackUrl.startsWith('/')) {\n        // Only allow relative paths that start with the router base path\n        if (callbackUrl.startsWith(global.RouterBasePath + '/')) {\n          return true\n        }\n        Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)\n        return false\n      }\n\n      // For absolute URLs, ensure they point to the same origin\n      const callbackUrlObj = new URL(callbackUrl)\n      // NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both\n      const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()\n      const currentProtocol =\n        req.secure ||\n        xfp\n          .split(',')\n          .map((s) => s.trim())\n          .includes('https')\n          ? 'https'\n          : 'http'\n      const currentHost = req.get('host')\n\n      // Check if protocol and host match exactly\n      if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {\n        // Additional check: ensure path starts with router base path\n        if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {\n          return true\n        }\n        Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)\n        return false\n      }\n\n      Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)\n      return false\n    } catch (error) {\n      Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)\n      return false\n    }\n  }\n}\n\nmodule.exports = OidcAuthStrategy\n"
  },
  {
    "path": "server/auth/TokenManager.js",
    "content": "const { Op } = require('sequelize')\n\nconst Database = require('../Database')\nconst Logger = require('../Logger')\n\nconst requestIp = require('../libs/requestIp')\nconst jwt = require('../libs/jsonwebtoken')\n\nclass TokenManager {\n  /** @type {string} JWT secret key */\n  static TokenSecret = null\n\n  constructor() {\n    /** @type {number} Refresh token expiry in seconds */\n    this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days\n    /** @type {number} Access token expiry in seconds */\n    this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour\n\n    if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {\n      Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)\n    }\n    if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {\n      Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)\n    }\n  }\n\n  get TokenSecret() {\n    return TokenManager.TokenSecret\n  }\n\n  /**\n   * Token secret is used to sign and verify JWTs\n   * Set by ENV variable \"JWT_SECRET_KEY\" or generated and stored on server settings if not set\n   */\n  async initTokenSecret() {\n    if (process.env.JWT_SECRET_KEY) {\n      // Use user supplied token secret\n      Logger.info('[TokenManager] JWT secret key set from ENV variable')\n      TokenManager.TokenSecret = process.env.JWT_SECRET_KEY\n    } else if (!Database.serverSettings.tokenSecret) {\n      // Generate new token secret and store it on server settings\n      Logger.info('[TokenManager] JWT secret key not found, generating one')\n      TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64')\n      Database.serverSettings.tokenSecret = TokenManager.TokenSecret\n      await Database.updateServerSettings()\n    } else {\n      // Use existing token secret from server settings\n      TokenManager.TokenSecret = Database.serverSettings.tokenSecret\n    }\n  }\n\n  /**\n   * Sets the refresh token cookie\n   * @param {import('express').Request} req\n   * @param {import('express').Response} res\n   * @param {string} refreshToken\n   */\n  setRefreshTokenCookie(req, res, refreshToken) {\n    res.cookie('refresh_token', refreshToken, {\n      httpOnly: true,\n      secure: req.secure || req.get('x-forwarded-proto') === 'https',\n      sameSite: 'lax',\n      maxAge: this.RefreshTokenExpiry * 1000,\n      path: '/'\n    })\n  }\n\n  /**\n   * Function to validate a jwt token for a given user\n   * Used to authenticate socket connections\n   * TODO: Support API keys for web socket connections\n   *\n   * @param {string} token\n   * @returns {Object} tokens data\n   */\n  static validateAccessToken(token) {\n    try {\n      return jwt.verify(token, TokenManager.TokenSecret)\n    } catch (err) {\n      return null\n    }\n  }\n\n  /**\n   * Generate a JWT token for a given user\n   * TODO: Old method with no expiration\n   * @deprecated\n   *\n   * @param {{ id:string, username:string }} user\n   * @returns {string}\n   */\n  static generateAccessToken(user) {\n    return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)\n  }\n\n  /**\n   * Function to generate a jwt token for a given user\n   * TODO: Old method with no expiration\n   * @deprecated\n   *\n   * @param {{ id:string, username:string }} user\n   * @returns {string}\n   */\n  generateAccessToken(user) {\n    return TokenManager.generateAccessToken(user)\n  }\n\n  /**\n   * Generate access token for a given user\n   *\n   * @param {{ id:string, username:string }} user\n   * @returns {string}\n   */\n  generateTempAccessToken(user) {\n    const payload = {\n      userId: user.id,\n      username: user.username,\n      type: 'access'\n    }\n    const options = {\n      expiresIn: this.AccessTokenExpiry\n    }\n    try {\n      return jwt.sign(payload, TokenManager.TokenSecret, options)\n    } catch (error) {\n      Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)\n      return null\n    }\n  }\n\n  /**\n   * Generate refresh token for a given user\n   *\n   * @param {{ id:string, username:string }} user\n   * @returns {string}\n   */\n  generateRefreshToken(user) {\n    const payload = {\n      userId: user.id,\n      username: user.username,\n      type: 'refresh'\n    }\n    const options = {\n      expiresIn: this.RefreshTokenExpiry\n    }\n    try {\n      return jwt.sign(payload, TokenManager.TokenSecret, options)\n    } catch (error) {\n      Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)\n      return null\n    }\n  }\n\n  /**\n   * Create tokens and session for a given user\n   *\n   * @param {{ id:string, username:string }} user\n   * @param {import('express').Request} req\n   * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}\n   */\n  async createTokensAndSession(user, req) {\n    const ipAddress = requestIp.getClientIp(req)\n    const userAgent = req.headers['user-agent']\n    const accessToken = this.generateTempAccessToken(user)\n    const refreshToken = this.generateRefreshToken(user)\n\n    // Calculate expiration time for the refresh token\n    const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)\n\n    const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)\n\n    return {\n      accessToken,\n      refreshToken,\n      session\n    }\n  }\n\n  /**\n   * Rotate tokens for a given session\n   *\n   * @param {import('../models/Session')} session\n   * @param {import('../models/User')} user\n   * @param {import('express').Request} req\n   * @param {import('express').Response} res\n   * @returns {Promise<{ accessToken:string, refreshToken:string }>}\n   */\n  async rotateTokensForSession(session, user, req, res) {\n    // Generate new tokens\n    const newAccessToken = this.generateTempAccessToken(user)\n    const newRefreshToken = this.generateRefreshToken(user)\n\n    // Calculate new expiration time\n    const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)\n\n    // Update the session with the new refresh token and expiration\n    session.refreshToken = newRefreshToken\n    session.expiresAt = newExpiresAt\n    await session.save()\n\n    // Set new refresh token cookie\n    this.setRefreshTokenCookie(req, res, newRefreshToken)\n\n    return {\n      accessToken: newAccessToken,\n      refreshToken: newRefreshToken\n    }\n  }\n\n  /**\n   * Check if the jwt is valid\n   *\n   * @param {Object} jwt_payload\n   * @param {Function} done - passportjs callback\n   */\n  async jwtAuthCheck(jwt_payload, done) {\n    if (jwt_payload.type === 'api') {\n      // Api key based authentication\n      const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)\n\n      if (!apiKey?.isActive) {\n        done(null, null)\n        return\n      }\n\n      // Check if the api key is expired and deactivate it\n      if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {\n        done(null, null)\n\n        apiKey.isActive = false\n        await apiKey.save()\n        Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)\n        return\n      }\n\n      const user = await Database.userModel.getUserById(apiKey.userId)\n\n      if (!user?.isActive) {\n        // deny login\n        done(null, null)\n        return\n      }\n\n      done(null, user)\n    } else {\n      // JWT based authentication\n\n      // Check if the jwt is expired\n      if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {\n        done(null, null)\n        return\n      }\n\n      // load user by id from the jwt token\n      const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)\n\n      if (!user?.isActive) {\n        // deny login\n        done(null, null)\n        return\n      }\n\n      // TODO: Temporary flag to report old tokens to users\n      // May be a better place for this but here means we dont have to decode the token again\n      if (!jwt_payload.exp && !user.isOldToken) {\n        Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)\n        user.isOldToken = true\n      } else if (jwt_payload.exp && user.isOldToken !== undefined) {\n        delete user.isOldToken\n      }\n\n      // approve login\n      done(null, user)\n    }\n  }\n\n  /**\n   * Handle refresh token\n   *\n   * @param {string} refreshToken\n   * @param {import('express').Request} req\n   * @param {import('express').Response} res\n   * @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}\n   */\n  async handleRefreshToken(refreshToken, req, res) {\n    try {\n      // Verify the refresh token\n      const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret)\n\n      if (decoded.type !== 'refresh') {\n        Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)\n        return {\n          error: 'Invalid token type'\n        }\n      }\n\n      const session = await Database.sessionModel.findOne({\n        where: { refreshToken: refreshToken }\n      })\n\n      if (!session) {\n        Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)\n        return {\n          error: 'Invalid refresh token'\n        }\n      }\n\n      // Check if session is expired in database\n      if (session.expiresAt < new Date()) {\n        Logger.info(`[TokenManager] Session expired in database, cleaning up`)\n        await session.destroy()\n        return {\n          error: 'Refresh token expired'\n        }\n      }\n\n      const user = await Database.userModel.getUserById(decoded.userId)\n      if (!user?.isActive) {\n        Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)\n        return {\n          error: 'User not found or inactive'\n        }\n      }\n\n      const newTokens = await this.rotateTokensForSession(session, user, req, res)\n      return {\n        accessToken: newTokens.accessToken,\n        refreshToken: newTokens.refreshToken,\n        user\n      }\n    } catch (error) {\n      if (error.name === 'TokenExpiredError') {\n        Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)\n\n        // Clean up the expired session from database\n        try {\n          await Database.sessionModel.destroy({\n            where: { refreshToken: refreshToken }\n          })\n          Logger.info(`[TokenManager] Expired session cleaned up`)\n        } catch (cleanupError) {\n          Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)\n        }\n\n        return {\n          error: 'Refresh token expired'\n        }\n      } else if (error.name === 'JsonWebTokenError') {\n        Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)\n        return {\n          error: 'Invalid refresh token'\n        }\n      } else {\n        Logger.error(`[TokenManager] Refresh token error: ${error.message}`)\n        return {\n          error: 'Invalid refresh token'\n        }\n      }\n    }\n  }\n\n  /**\n   * Invalidate all JWT sessions for a given user\n   * If user is current user and refresh token is valid, rotate tokens for the current session\n   *\n   * @param {import('../models/User')} user\n   * @param {import('express').Request} req\n   * @param {import('express').Response} res\n   * @returns {Promise<string>} accessToken only if user is current user and refresh token is valid\n   */\n  async invalidateJwtSessionsForUser(user, req, res) {\n    const currentRefreshToken = req.cookies.refresh_token\n    if (req.user.id === user.id && currentRefreshToken) {\n      // Current user is the same as the user to invalidate sessions for\n      // So rotate token for current session\n      const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })\n      if (currentSession) {\n        const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)\n\n        // Invalidate all sessions for the user except the current one\n        await Database.sessionModel.destroy({\n          where: {\n            id: {\n              [Op.ne]: currentSession.id\n            },\n            userId: user.id\n          }\n        })\n\n        return newTokens.accessToken\n      } else {\n        Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)\n      }\n    }\n\n    // Current user is not the same as the user to invalidate sessions for (or no refresh token)\n    // So invalidate all sessions for the user\n    await Database.sessionModel.destroy({ where: { userId: user.id } })\n    return null\n  }\n\n  /**\n   * Invalidate a refresh token - used for logout\n   *\n   * @param {string} refreshToken\n   * @returns {Promise<boolean>}\n   */\n  async invalidateRefreshToken(refreshToken) {\n    if (!refreshToken) {\n      Logger.error(`[TokenManager] No refresh token provided to invalidate`)\n      return false\n    }\n\n    try {\n      const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })\n      Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)\n      return true\n    } catch (error) {\n      Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)\n      return false\n    }\n  }\n}\n\nmodule.exports = TokenManager\n"
  },
  {
    "path": "server/controllers/ApiKeyController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst uuidv4 = require('uuid').v4\nconst Logger = require('../Logger')\nconst Database = require('../Database')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass ApiKeyController {\n  constructor() {}\n\n  /**\n   * GET: /api/api-keys\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAll(req, res) {\n    const apiKeys = await Database.apiKeyModel.findAll({\n      include: [\n        {\n          model: Database.userModel,\n          attributes: ['id', 'username', 'type']\n        },\n        {\n          model: Database.userModel,\n          as: 'createdByUser',\n          attributes: ['id', 'username', 'type']\n        }\n      ]\n    })\n\n    return res.json({\n      apiKeys: apiKeys.map((a) => a.toJSON())\n    })\n  }\n\n  /**\n   * POST: /api/api-keys\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    if (!req.body.name || typeof req.body.name !== 'string') {\n      Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`)\n      return res.sendStatus(400)\n    }\n    if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) {\n      Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)\n      return res.sendStatus(400)\n    }\n    if (!req.body.userId || typeof req.body.userId !== 'string') {\n      Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)\n      return res.sendStatus(400)\n    }\n    const user = await Database.userModel.getUserById(req.body.userId)\n    if (!user) {\n      Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)\n      return res.sendStatus(400)\n    }\n    if (user.type === 'root' && !req.user.isRoot) {\n      Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)\n      return res.sendStatus(403)\n    }\n\n    const keyId = uuidv4() // Generate key id ahead of time to use in JWT\n    const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn)\n\n    if (!apiKey) {\n      Logger.error(`[ApiKeyController] create: Error generating API key`)\n      return res.sendStatus(500)\n    }\n\n    // Calculate expiration time for the api key\n    const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null\n\n    const apiKeyInstance = await Database.apiKeyModel.create({\n      id: keyId,\n      name: req.body.name,\n      expiresAt,\n      userId: req.body.userId,\n      isActive: !!req.body.isActive,\n      createdByUserId: req.user.id\n    })\n    apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({\n      attributes: ['id', 'username', 'type']\n    })\n\n    Logger.info(`[ApiKeyController] Created API key \"${apiKeyInstance.name}\"`)\n    return res.json({\n      apiKey: {\n        apiKey, // Actual key only shown to user on creation\n        ...apiKeyInstance.toJSON()\n      }\n    })\n  }\n\n  /**\n   * PATCH: /api/api-keys/:id\n   * Only isActive and userId can be updated because name and expiresIn are in the JWT\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {\n      include: {\n        model: Database.userModel\n      }\n    })\n    if (!apiKey) {\n      return res.sendStatus(404)\n    }\n    // Only root user can update root user API keys\n    if (apiKey.user.type === 'root' && !req.user.isRoot) {\n      Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)\n      return res.sendStatus(403)\n    }\n\n    let hasUpdates = false\n    if (req.body.userId !== undefined) {\n      if (typeof req.body.userId !== 'string') {\n        Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)\n        return res.sendStatus(400)\n      }\n      const user = await Database.userModel.getUserById(req.body.userId)\n      if (!user) {\n        Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)\n        return res.sendStatus(400)\n      }\n      if (user.type === 'root' && !req.user.isRoot) {\n        Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)\n        return res.sendStatus(403)\n      }\n      if (apiKey.userId !== req.body.userId) {\n        apiKey.userId = req.body.userId\n        hasUpdates = true\n      }\n    }\n\n    if (req.body.isActive !== undefined) {\n      if (typeof req.body.isActive !== 'boolean') {\n        return res.sendStatus(400)\n      }\n      if (apiKey.isActive !== req.body.isActive) {\n        apiKey.isActive = req.body.isActive\n        hasUpdates = true\n      }\n    }\n\n    if (hasUpdates) {\n      await apiKey.save()\n      apiKey.dataValues.user = await apiKey.getUser({\n        attributes: ['id', 'username', 'type']\n      })\n      Logger.info(`[ApiKeyController] Updated API key \"${apiKey.name}\"`)\n    } else {\n      Logger.info(`[ApiKeyController] No updates needed to API key \"${apiKey.name}\"`)\n    }\n\n    return res.json({\n      apiKey: apiKey.toJSON()\n    })\n  }\n\n  /**\n   * DELETE: /api/api-keys/:id\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    const apiKey = await Database.apiKeyModel.findByPk(req.params.id)\n    if (!apiKey) {\n      return res.sendStatus(404)\n    }\n\n    await apiKey.destroy()\n    Logger.info(`[ApiKeyController] Deleted API key \"${apiKey.name}\"`)\n\n    return res.sendStatus(200)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[ApiKeyController] Non-admin user \"${req.user.username}\" attempting to access api keys`)\n      return res.sendStatus(403)\n    }\n\n    next()\n  }\n}\n\nmodule.exports = new ApiKeyController()\n"
  },
  {
    "path": "server/controllers/AuthorController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst sequelize = require('sequelize')\nconst fs = require('../libs/fsExtra')\nconst { createNewSortInstance } = require('../libs/fastSort')\n\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst CacheManager = require('../managers/CacheManager')\nconst CoverManager = require('../managers/CoverManager')\nconst AuthorFinder = require('../finders/AuthorFinder')\n\nconst { reqSupportsWebp, isValidASIN } = require('../utils/index')\n\nconst naturalSort = createNewSortInstance({\n  comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare\n})\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/Author')} author\n *\n * @typedef {RequestWithUser & RequestEntityObject} AuthorControllerRequest\n */\n\nclass AuthorController {\n  constructor() {}\n\n  /**\n   * GET: /api/authors/:id\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    const include = (req.query.include || '').split(',')\n\n    const authorJson = req.author.toOldJSON()\n\n    // Used on author landing page to include library items and items grouped in series\n    if (include.includes('items')) {\n      const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)\n\n      if (include.includes('series')) {\n        const seriesMap = {}\n        // Group items into series\n        libraryItems.forEach((li) => {\n          if (li.media.series?.length) {\n            li.media.series.forEach((series) => {\n              const itemWithSeries = li.toOldJSONMinified()\n              itemWithSeries.media.metadata.series = {\n                id: series.id,\n                name: series.name,\n                nameIgnorePrefix: series.nameIgnorePrefix,\n                sequence: series.bookSeries.sequence\n              }\n\n              if (seriesMap[series.id]) {\n                seriesMap[series.id].items.push(itemWithSeries)\n              } else {\n                seriesMap[series.id] = {\n                  id: series.id,\n                  name: series.name,\n                  items: [itemWithSeries]\n                }\n              }\n            })\n          }\n        })\n        // Sort series items\n        for (const key in seriesMap) {\n          seriesMap[key].items = naturalSort(seriesMap[key].items).asc((li) => li.media.metadata.series.sequence)\n        }\n\n        authorJson.series = Object.values(seriesMap)\n      }\n\n      // Minify library items\n      authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())\n    }\n\n    return res.json(authorJson)\n  }\n\n  /**\n   * PATCH: /api/authors/:id\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    const keysToUpdate = ['name', 'description', 'asin']\n    const payload = {}\n    for (const key in req.body) {\n      if (keysToUpdate.includes(key) && (typeof req.body[key] === 'string' || req.body[key] === null)) {\n        payload[key] = req.body[key]\n      }\n    }\n    if (!Object.keys(payload).length) {\n      Logger.error(`[AuthorController] Invalid request payload. No valid keys found`, req.body)\n      return res.status(400).send('Invalid request payload. No valid keys found')\n    }\n\n    let hasUpdated = false\n\n    const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name\n    if (authorNameUpdate) {\n      payload.lastFirst = Database.authorModel.getLastFirst(payload.name)\n    }\n\n    // Check if author name matches another author in the same library and merge the authors\n    let existingAuthor = null\n    if (authorNameUpdate) {\n      existingAuthor = await Database.authorModel.findOne({\n        where: {\n          id: {\n            [sequelize.Op.not]: req.author.id\n          },\n          name: payload.name,\n          libraryId: req.author.libraryId\n        }\n      })\n    }\n    if (existingAuthor) {\n      Logger.info(`[AuthorController] Merging author \"${req.author.name}\" with \"${existingAuthor.name}\"`)\n      const bookAuthorsToCreate = []\n      const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)\n\n      const libraryItems = []\n      allItemsWithAuthor.forEach((libraryItem) => {\n        // Replace old author with merging author for each book\n        libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)\n        libraryItem.media.authors.push({\n          id: existingAuthor.id,\n          name: existingAuthor.name\n        })\n\n        libraryItems.push(libraryItem)\n\n        bookAuthorsToCreate.push({\n          bookId: libraryItem.media.id,\n          authorId: existingAuthor.id\n        })\n      })\n      if (libraryItems.length) {\n        await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor\n        await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor\n        for (const libraryItem of libraryItems) {\n          await libraryItem.saveMetadataFile()\n        }\n        SocketAuthority.libraryItemsEmitter('items_updated', libraryItems)\n      }\n\n      // Remove old author\n      const oldAuthorJSON = req.author.toOldJSON()\n      await req.author.destroy()\n      SocketAuthority.emitter('author_removed', oldAuthorJSON)\n      // Update filter data\n      Database.removeAuthorFromFilterData(oldAuthorJSON.libraryId, oldAuthorJSON.id)\n\n      // Send updated num books for merged author\n      const numBooks = await Database.bookAuthorModel.getCountForAuthor(existingAuthor.id)\n      SocketAuthority.emitter('author_updated', existingAuthor.toOldJSONExpanded(numBooks))\n\n      res.json({\n        author: existingAuthor.toOldJSON(),\n        merged: true\n      })\n      return\n    }\n\n    // If lastFirst is not set, get it from the name\n    if (!authorNameUpdate && !req.author.lastFirst) {\n      payload.lastFirst = Database.authorModel.getLastFirst(req.author.name)\n    }\n\n    // Regular author update\n    req.author.set(payload)\n    if (req.author.changed()) {\n      await req.author.save()\n      hasUpdated = true\n    }\n\n    if (hasUpdated) {\n      let numBooksForAuthor = 0\n      if (authorNameUpdate) {\n        const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)\n\n        numBooksForAuthor = allItemsWithAuthor.length\n        const libraryItems = []\n        // Update author name on all books\n        for (const libraryItem of allItemsWithAuthor) {\n          libraryItem.media.authors = libraryItem.media.authors.map((au) => {\n            if (au.id === req.author.id) {\n              au.name = req.author.name\n            }\n            return au\n          })\n\n          libraryItems.push(libraryItem)\n\n          await libraryItem.saveMetadataFile()\n        }\n\n        if (libraryItems.length) {\n          SocketAuthority.libraryItemsEmitter('items_updated', libraryItems)\n        }\n      } else {\n        numBooksForAuthor = await Database.bookAuthorModel.getCountForAuthor(req.author.id)\n      }\n\n      SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooksForAuthor))\n    }\n\n    res.json({\n      author: req.author.toOldJSON(),\n      updated: hasUpdated\n    })\n  }\n\n  /**\n   * DELETE: /api/authors/:id\n   * Remove author from all books and delete\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    Logger.info(`[AuthorController] Removing author \"${req.author.name}\"`)\n\n    if (req.author.imagePath) {\n      await CacheManager.purgeImageCache(req.author.id) // Purge cache\n    }\n\n    // Load library items so that metadata file can be updated\n    const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)\n    allItemsWithAuthor.forEach((libraryItem) => {\n      libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)\n    })\n\n    await req.author.destroy()\n\n    for (const libraryItem of allItemsWithAuthor) {\n      await libraryItem.saveMetadataFile()\n    }\n\n    SocketAuthority.emitter('author_removed', req.author.toOldJSON())\n\n    // Update filter data\n    Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/authors/:id/image\n   * Upload author image from web URL\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async uploadImage(req, res) {\n    if (!req.user.canUpload) {\n      Logger.warn(`User \"${req.user.username}\" attempted to upload an image without permission`)\n      return res.sendStatus(403)\n    }\n    if (!req.body.url) {\n      Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`)\n      return res.status(400).send(`Invalid request payload. 'url' not in request body`)\n    }\n    if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) {\n      Logger.error(`[AuthorController] Invalid request payload. Invalid url \"${req.body.url}\"`)\n      return res.status(400).send(`Invalid request payload. Invalid url \"${req.body.url}\"`)\n    }\n\n    Logger.debug(`[AuthorController] Requesting download author image from url \"${req.body.url}\"`)\n    const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)\n\n    if (result?.error) {\n      return res.status(400).send(result.error)\n    } else if (!result?.path) {\n      return res.status(500).send('Unknown error occurred')\n    }\n\n    if (req.author.imagePath) {\n      await CacheManager.purgeImageCache(req.author.id) // Purge cache\n    }\n\n    req.author.imagePath = result.path\n    // imagePath may not have changed, but we still want to update the updatedAt field to bust image cache\n    req.author.changed('imagePath', true)\n    await req.author.save()\n\n    const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)\n    SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))\n    res.json({\n      author: req.author.toOldJSON()\n    })\n  }\n\n  /**\n   * DELETE: /api/authors/:id/image\n   * Remove author image & delete image file\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async deleteImage(req, res) {\n    if (!req.author.imagePath) {\n      Logger.error(`[AuthorController] Author \"${req.author.imagePath}\" has no imagePath set`)\n      return res.status(400).send('Author has no image path set')\n    }\n    Logger.info(`[AuthorController] Removing image for author \"${req.author.name}\" at \"${req.author.imagePath}\"`)\n    await CacheManager.purgeImageCache(req.author.id) // Purge cache\n    await CoverManager.removeFile(req.author.imagePath)\n    req.author.imagePath = null\n    await req.author.save()\n\n    const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)\n    SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))\n    res.json({\n      author: req.author.toOldJSON()\n    })\n  }\n\n  /**\n   * POST: /api/authors/:id/match\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async match(req, res) {\n    let authorData = null\n    const region = req.body.region || 'us'\n    if (req.body.asin && isValidASIN(req.body.asin.toUpperCase?.())) {\n      authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)\n    } else {\n      authorData = await AuthorFinder.findAuthorByName(req.body.q, region)\n    }\n    if (!authorData) {\n      return res.status(404).send('Author not found')\n    }\n    Logger.debug(`[AuthorController] match author with \"${req.body.q || req.body.asin}\"`, authorData)\n\n    let hasUpdates = false\n    if (authorData.asin && req.author.asin !== authorData.asin) {\n      req.author.asin = authorData.asin\n      hasUpdates = true\n    }\n\n    // Only updates image if there was no image before or the author ASIN was updated\n    if (authorData.image && (!req.author.imagePath || hasUpdates)) {\n      await CacheManager.purgeImageCache(req.author.id)\n\n      const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)\n      if (imageData?.path) {\n        req.author.imagePath = imageData.path\n        hasUpdates = true\n      }\n    }\n\n    if (authorData.description && req.author.description !== authorData.description) {\n      req.author.description = authorData.description\n      hasUpdates = true\n    }\n\n    if (hasUpdates) {\n      await req.author.save()\n\n      const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)\n      SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))\n    }\n\n    res.json({\n      updated: hasUpdates,\n      author: req.author.toOldJSON()\n    })\n  }\n\n  /**\n   * GET: /api/authors/:id/image\n   *\n   * @param {AuthorControllerRequest} req\n   * @param {Response} res\n   */\n  async getImage(req, res) {\n    const {\n      query: { width, height, format, raw }\n    } = req\n\n    const authorId = req.params.id\n\n    if (raw) {\n      const author = await Database.authorModel.findByPk(authorId)\n      if (!author) {\n        Logger.warn(`[AuthorController] Author \"${authorId}\" not found`)\n        return res.sendStatus(404)\n      }\n\n      if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {\n        Logger.warn(`[AuthorController] Author \"${author.name}\" has invalid imagePath: ${author.imagePath}`)\n        return res.sendStatus(404)\n      }\n\n      return res.sendFile(author.imagePath)\n    }\n\n    const options = {\n      format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),\n      height: height ? parseInt(height) : null,\n      width: width ? parseInt(width) : null\n    }\n    return CacheManager.handleAuthorCache(res, authorId, options)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    const author = await Database.authorModel.findByPk(req.params.id)\n    if (!author) return res.sendStatus(404)\n\n    if (req.method == 'DELETE' && !req.user.canDelete) {\n      Logger.warn(`[AuthorController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {\n      Logger.warn(`[AuthorController] User \"${req.user.username}\" attempted to update without permission`)\n      return res.sendStatus(403)\n    }\n\n    req.author = author\n    next()\n  }\n}\nmodule.exports = new AuthorController()\n"
  },
  {
    "path": "server/controllers/BackupController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Path = require('path')\nconst fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\nconst fileUtils = require('../utils/fileUtils')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass BackupController {\n  constructor() {}\n\n  /**\n   * GET: /api/backups\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  getAll(req, res) {\n    res.json({\n      backups: this.backupManager.backups.map((b) => b.toJSON()),\n      backupLocation: this.backupManager.backupPath,\n      backupPathEnvSet: this.backupManager.backupPathEnvSet\n    })\n  }\n\n  /**\n   * POST: /api/backups\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  create(req, res) {\n    this.backupManager.requestCreateBackup(res)\n  }\n\n  /**\n   * DELETE: /api/backups/:id\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    await this.backupManager.removeBackup(req.backup)\n\n    res.json({\n      backups: this.backupManager.backups.map((b) => b.toJSON())\n    })\n  }\n\n  /**\n   * POST: /api/backups/upload\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  upload(req, res) {\n    if (!req.files.file) {\n      Logger.error('[BackupController] Upload backup invalid')\n      return res.sendStatus(500)\n    }\n    this.backupManager.uploadBackup(req, res)\n  }\n\n  /**\n   * PATCH: /api/backups/path\n   * Update the backup path\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updatePath(req, res) {\n    // Validate path is not empty and is a string\n    if (!req.body.path || !req.body.path?.trim?.()) {\n      Logger.error('[BackupController] Update backup path invalid')\n      return res.status(400).send('Invalid request body. Must include path.')\n    }\n\n    const newBackupPath = fileUtils.filePathToPOSIX(Path.resolve(req.body.path))\n\n    if (newBackupPath === this.backupManager.backupPath) {\n      Logger.debug(`[BackupController] Backup path unchanged: ${newBackupPath}`)\n      return res.status(200).send('Backup path unchanged')\n    }\n\n    Logger.info(`[BackupController] Updating backup path to \"${newBackupPath}\" from \"${this.backupManager.backupPath}\"`)\n\n    // Check if backup path is set in environment variable\n    if (process.env.BACKUP_PATH) {\n      Logger.warn(`[BackupController] Backup path is set in environment variable BACKUP_PATH. Backup path will be reverted on server restart.`)\n    }\n\n    // Validate backup path is writable and create folder if it does not exist\n    try {\n      const direxists = await fs.pathExists(newBackupPath)\n      if (!direxists) {\n        // If folder does not exist try to make it\n        await fs.mkdir(newBackupPath)\n      }\n    } catch (error) {\n      Logger.error(`[BackupController] updatePath: Failed to ensure backup path \"${newBackupPath}\"`, error)\n      return res.status(400).send(`Invalid backup path \"${req.body.path}\"`)\n    }\n\n    Database.serverSettings.backupPath = newBackupPath\n    await Database.updateServerSettings()\n\n    await this.backupManager.reload()\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/backups/:id/download\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  download(req, res) {\n    if (global.XAccel) {\n      const encodedURI = fileUtils.encodeUriPath(global.XAccel + req.backup.fullPath)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    res.setHeader('Content-disposition', 'attachment; filename=' + req.backup.filename)\n\n    res.sendFile(req.backup.fullPath)\n  }\n\n  /**\n   * GET: /api/backups/:id/apply\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  apply(req, res) {\n    this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[BackupController] Non-admin user \"${req.user.username}\" attempting to access backups`)\n      return res.sendStatus(403)\n    }\n\n    if (req.params.id) {\n      req.backup = this.backupManager.backups.find((b) => b.id === req.params.id)\n      if (!req.backup) {\n        return res.sendStatus(404)\n      }\n    }\n\n    next()\n  }\n}\nmodule.exports = new BackupController()\n"
  },
  {
    "path": "server/controllers/CacheController.js",
    "content": "const { Request, Response } = require('express')\nconst CacheManager = require('../managers/CacheManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass CacheController {\n  constructor() {}\n\n  /**\n   * POST: /api/cache/purge\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async purgeCache(req, res) {\n    if (!req.user.isAdminOrUp) {\n      return res.sendStatus(403)\n    }\n    await CacheManager.purgeAll()\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/cache/items/purge\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async purgeItemsCache(req, res) {\n    if (!req.user.isAdminOrUp) {\n      return res.sendStatus(403)\n    }\n    await CacheManager.purgeItems()\n    res.sendStatus(200)\n  }\n}\nmodule.exports = new CacheController()\n"
  },
  {
    "path": "server/controllers/CollectionController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Sequelize = require('sequelize')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\nconst RssFeedManager = require('../managers/RssFeedManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/Collection')} collection\n *\n * @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest\n */\n\nclass CollectionController {\n  constructor() {}\n\n  /**\n   * POST: /api/collections\n   * Create new collection\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    const reqBody = req.body || {}\n\n    const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)\n\n    // Validation\n    if (!nameCleaned || !reqBody.libraryId) {\n      return res.status(400).send('Invalid collection data')\n    }\n    if (reqBody.description && typeof reqBody.description !== 'string') {\n      return res.status(400).send('Invalid collection description')\n    }\n    const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')\n    if (!libraryItemIds.length) {\n      return res.status(400).send('Invalid collection data. No books')\n    }\n\n    // Load library items\n    const libraryItems = await Database.libraryItemModel.findAll({\n      attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],\n      where: {\n        id: libraryItemIds,\n        libraryId: reqBody.libraryId,\n        mediaType: 'book'\n      }\n    })\n    if (libraryItems.length !== libraryItemIds.length) {\n      return res.status(400).send('Invalid collection data. Invalid books')\n    }\n\n    /** @type {import('../models/Collection')} */\n    let newCollection = null\n\n    const transaction = await Database.sequelize.transaction()\n    try {\n      // Create collection\n      newCollection = await Database.collectionModel.create(\n        {\n          libraryId: reqBody.libraryId,\n          name: nameCleaned,\n          description: reqBody.description || null\n        },\n        { transaction }\n      )\n\n      // Create collectionBooks\n      const collectionBookPayloads = libraryItemIds.map((llid, index) => {\n        const libraryItem = libraryItems.find((li) => li.id === llid)\n        return {\n          collectionId: newCollection.id,\n          bookId: libraryItem.mediaId,\n          order: index + 1\n        }\n      })\n      await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })\n\n      await transaction.commit()\n    } catch (error) {\n      await transaction.rollback()\n      Logger.error('[CollectionController] create:', error)\n      return res.status(500).send('Failed to create collection')\n    }\n\n    // Load books expanded\n    newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()\n\n    // Note: The old collection model stores expanded libraryItems in the books property\n    const jsonExpanded = newCollection.toOldJSONExpanded()\n    SocketAuthority.emitter('collection_added', jsonExpanded)\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * GET: /api/collections\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findAll(req, res) {\n    const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)\n    res.json({\n      collections: collectionsExpanded\n    })\n  }\n\n  /**\n   * GET: /api/collections/:id\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    const includeEntities = (req.query.include || '').split(',')\n\n    const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)\n    if (!collectionExpanded) {\n      // This may happen if the user is restricted from all books\n      return res.sendStatus(404)\n    }\n\n    res.json(collectionExpanded)\n  }\n\n  /**\n   * PATCH: /api/collections/:id\n   * Update collection\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    let wasUpdated = false\n\n    // Update description and name if defined\n    const collectionUpdatePayload = {}\n    if (req.body.description !== undefined && req.body.description !== req.collection.description) {\n      collectionUpdatePayload.description = req.body.description\n      wasUpdated = true\n    }\n    if (req.body.name !== undefined && typeof req.body.name === 'string') {\n      const nameCleaned = htmlSanitizer.stripAllTags(req.body.name)\n      if (nameCleaned !== req.collection.name) {\n        collectionUpdatePayload.name = nameCleaned\n        wasUpdated = true\n      }\n    }\n\n    if (wasUpdated) {\n      await req.collection.update(collectionUpdatePayload)\n    }\n\n    // If books array is passed in then update order in collection\n    let collectionBooksUpdated = false\n    if (req.body.books?.length) {\n      const collectionBooks = await req.collection.getCollectionBooks({\n        include: {\n          model: Database.bookModel,\n          include: Database.libraryItemModel\n        },\n        order: [['order', 'ASC']]\n      })\n      collectionBooks.sort((a, b) => {\n        const aIndex = req.body.books.findIndex((lid) => lid === a.book.libraryItem.id)\n        const bIndex = req.body.books.findIndex((lid) => lid === b.book.libraryItem.id)\n        return aIndex - bIndex\n      })\n      for (let i = 0; i < collectionBooks.length; i++) {\n        if (collectionBooks[i].order !== i + 1) {\n          await collectionBooks[i].update({\n            order: i + 1\n          })\n          collectionBooksUpdated = true\n        }\n      }\n\n      if (collectionBooksUpdated) {\n        req.collection.changed('updatedAt', true)\n        await req.collection.save()\n        wasUpdated = true\n      }\n    }\n\n    const jsonExpanded = await req.collection.getOldJsonExpanded()\n    if (wasUpdated) {\n      SocketAuthority.emitter('collection_updated', jsonExpanded)\n    }\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * DELETE: /api/collections/:id\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    const jsonExpanded = await req.collection.getOldJsonExpanded()\n\n    // Close rss feed - remove from db and emit socket event\n    await RssFeedManager.closeFeedForEntityId(req.collection.id)\n\n    await req.collection.destroy()\n\n    SocketAuthority.emitter('collection_removed', jsonExpanded)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/collections/:id/book\n   * Add a single book to a collection\n   * Req.body { id: <library item id> }\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async addBook(req, res) {\n    const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, {\n      attributes: ['libraryId', 'mediaId']\n    })\n    if (!libraryItem) {\n      return res.status(404).send('Book not found')\n    }\n    if (libraryItem.libraryId !== req.collection.libraryId) {\n      return res.status(400).send('Book in different library')\n    }\n\n    // Check if book is already in collection\n    const collectionBooks = await req.collection.getCollectionBooks()\n    if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {\n      return res.status(400).send('Book already in collection')\n    }\n\n    // Create collectionBook record\n    await Database.collectionBookModel.create({\n      collectionId: req.collection.id,\n      bookId: libraryItem.mediaId,\n      order: collectionBooks.length + 1\n    })\n    const jsonExpanded = await req.collection.getOldJsonExpanded()\n    SocketAuthority.emitter('collection_updated', jsonExpanded)\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * DELETE: /api/collections/:id/book/:bookId\n   * Remove a single book from a collection. Re-order books\n   * Users with update permission can remove books from collections\n   * TODO: bookId is actually libraryItemId. Clients need updating to use bookId\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async removeBook(req, res) {\n    const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, {\n      attributes: ['mediaId']\n    })\n    if (!libraryItem) {\n      return res.sendStatus(404)\n    }\n\n    // Get books in collection ordered\n    const collectionBooks = await req.collection.getCollectionBooks({\n      order: [['order', 'ASC']]\n    })\n\n    let jsonExpanded = null\n    const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)\n    if (collectionBookToRemove) {\n      // Remove collection book record\n      await collectionBookToRemove.destroy()\n\n      // Update order on collection books\n      let order = 1\n      for (const collectionBook of collectionBooks) {\n        if (collectionBook.bookId === libraryItem.mediaId) continue\n        if (collectionBook.order !== order) {\n          await collectionBook.update({\n            order\n          })\n        }\n        order++\n      }\n\n      jsonExpanded = await req.collection.getOldJsonExpanded()\n      SocketAuthority.emitter('collection_updated', jsonExpanded)\n    } else {\n      jsonExpanded = await req.collection.getOldJsonExpanded()\n    }\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * POST: /api/collections/:id/batch/add\n   * Add multiple books to collection\n   * Req.body { books: <Array of library item ids> }\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async addBatch(req, res) {\n    // filter out invalid libraryItemIds\n    const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')\n    if (!bookIdsToAdd.length) {\n      return res.status(400).send('Invalid request body')\n    }\n\n    // Get library items associated with ids\n    const libraryItems = await Database.libraryItemModel.findAll({\n      attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],\n      where: {\n        id: bookIdsToAdd,\n        libraryId: req.collection.libraryId,\n        mediaType: 'book'\n      }\n    })\n    if (!libraryItems.length) {\n      return res.status(400).send('Invalid request body. No valid books')\n    }\n\n    // Get collection books already in collection\n    /** @type {import('../models/CollectionBook')[]} */\n    const collectionBooks = await req.collection.getCollectionBooks()\n\n    let order = collectionBooks.length + 1\n    const collectionBooksToAdd = []\n    let hasUpdated = false\n\n    // Check and set new collection books to add\n    for (const libraryItem of libraryItems) {\n      if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {\n        collectionBooksToAdd.push({\n          collectionId: req.collection.id,\n          bookId: libraryItem.mediaId,\n          order: order++\n        })\n        hasUpdated = true\n      } else {\n        Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)\n      }\n    }\n\n    let jsonExpanded = null\n    if (hasUpdated) {\n      await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)\n\n      jsonExpanded = await req.collection.getOldJsonExpanded()\n      SocketAuthority.emitter('collection_updated', jsonExpanded)\n    } else {\n      jsonExpanded = await req.collection.getOldJsonExpanded()\n    }\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * POST: /api/collections/:id/batch/remove\n   * Remove multiple books from collection\n   * Req.body { books: <Array of library item ids> }\n   *\n   * @param {CollectionControllerRequest} req\n   * @param {Response} res\n   */\n  async removeBatch(req, res) {\n    // filter out invalid libraryItemIds\n    const bookIdsToRemove = (req.body.books || []).filter((b) => !!b && typeof b == 'string')\n    if (!bookIdsToRemove.length) {\n      return res.status(500).send('Invalid request body')\n    }\n\n    // Get library items associated with ids\n    const libraryItems = await Database.libraryItemModel.findAll({\n      where: {\n        id: bookIdsToRemove\n      },\n      include: {\n        model: Database.bookModel\n      }\n    })\n\n    // Get collection books already in collection\n    /** @type {import('../models/CollectionBook')[]} */\n    const collectionBooks = await req.collection.getCollectionBooks({\n      order: [['order', 'ASC']]\n    })\n\n    // Remove collection books and update order\n    let order = 1\n    let hasUpdated = false\n    for (const collectionBook of collectionBooks) {\n      if (libraryItems.some((li) => li.media.id === collectionBook.bookId)) {\n        await collectionBook.destroy()\n        hasUpdated = true\n        continue\n      } else if (collectionBook.order !== order) {\n        await collectionBook.update({\n          order\n        })\n        hasUpdated = true\n      }\n      order++\n    }\n\n    let jsonExpanded = await req.collection.getOldJsonExpanded()\n    if (hasUpdated) {\n      SocketAuthority.emitter('collection_updated', jsonExpanded)\n    }\n    res.json(jsonExpanded)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (req.params.id) {\n      const collection = await Database.collectionModel.findByPk(req.params.id)\n      if (!collection) {\n        return res.status(404).send('Collection not found')\n      }\n      req.collection = collection\n    }\n\n    // Users with update permission can remove books from collections\n    if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) {\n      Logger.warn(`[CollectionController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {\n      Logger.warn(`[CollectionController] User \"${req.user.username}\" attempted to update without permission`)\n      return res.sendStatus(403)\n    }\n\n    next()\n  }\n}\nmodule.exports = new CollectionController()\n"
  },
  {
    "path": "server/controllers/CustomMetadataProviderController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst { validateUrl } = require('../utils/index')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass CustomMetadataProviderController {\n  constructor() {}\n\n  /**\n   * GET: /api/custom-metadata-providers\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAll(req, res) {\n    const providers = await Database.customMetadataProviderModel.findAll()\n\n    res.json({\n      providers\n    })\n  }\n\n  /**\n   * POST: /api/custom-metadata-providers\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    const { name, url, mediaType, authHeaderValue } = req.body\n\n    if (!name || !url || !mediaType) {\n      return res.status(400).send('Invalid request body')\n    }\n\n    const validUrl = validateUrl(url)\n    if (!validUrl) {\n      Logger.error(`[CustomMetadataProviderController] Invalid url \"${url}\"`)\n      return res.status(400).send('Invalid url')\n    }\n\n    const provider = await Database.customMetadataProviderModel.create({\n      name,\n      mediaType,\n      url,\n      authHeaderValue: !authHeaderValue ? null : authHeaderValue\n    })\n\n    // TODO: Necessary to emit to all clients?\n    SocketAuthority.emitter('custom_metadata_provider_added', provider.toClientJson())\n\n    res.json({\n      provider\n    })\n  }\n\n  /**\n   * DELETE: /api/custom-metadata-providers/:id\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    const slug = `custom-${req.params.id}`\n\n    /** @type {import('../models/CustomMetadataProvider')} */\n    const provider = req.customMetadataProvider\n    const providerClientJson = provider.toClientJson()\n\n    const fallbackProvider = provider.mediaType === 'book' ? 'google' : 'itunes'\n\n    await provider.destroy()\n\n    // Libraries using this provider fallback to default provider\n    await Database.libraryModel.update(\n      {\n        provider: fallbackProvider\n      },\n      {\n        where: {\n          provider: slug\n        }\n      }\n    )\n\n    // TODO: Necessary to emit to all clients?\n    SocketAuthority.emitter('custom_metadata_provider_removed', providerClientJson)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * Middleware that requires admin or up\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      Logger.warn(`[CustomMetadataProviderController] Non-admin user \"${req.user.username}\" attempted access route \"${req.path}\"`)\n      return res.sendStatus(403)\n    }\n\n    // If id param then add req.customMetadataProvider\n    if (req.params.id) {\n      req.customMetadataProvider = await Database.customMetadataProviderModel.findByPk(req.params.id)\n      if (!req.customMetadataProvider) {\n        return res.sendStatus(404)\n      }\n    }\n\n    next()\n  }\n}\nmodule.exports = new CustomMetadataProviderController()\n"
  },
  {
    "path": "server/controllers/EmailController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass EmailController {\n  constructor() {}\n\n  /**\n   * GET: /api/emails/settings\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  getSettings(req, res) {\n    res.json({\n      settings: Database.emailSettings\n    })\n  }\n\n  /**\n   * PATCH: /api/emails/settings\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateSettings(req, res) {\n    const updated = Database.emailSettings.update(req.body)\n    if (updated) {\n      await Database.updateSetting(Database.emailSettings)\n    }\n    res.json({\n      settings: Database.emailSettings\n    })\n  }\n\n  /**\n   * POST: /api/emails/test\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async sendTest(req, res) {\n    this.emailManager.sendTest(res)\n  }\n\n  /**\n   * POST: /api/emails/ereader-devices\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateEReaderDevices(req, res) {\n    if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {\n      return res.status(400).send('Invalid payload. ereaderDevices array required')\n    }\n\n    const ereaderDevices = req.body.ereaderDevices\n    for (const device of ereaderDevices) {\n      if (!device.name || !device.email) {\n        return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')\n      }\n    }\n\n    const updated = Database.emailSettings.update({\n      ereaderDevices\n    })\n    if (updated) {\n      await Database.updateSetting(Database.emailSettings)\n      SocketAuthority.adminEmitter('ereader-devices-updated', {\n        ereaderDevices: Database.emailSettings.ereaderDevices\n      })\n    }\n    res.json({\n      ereaderDevices: Database.emailSettings.ereaderDevices\n    })\n  }\n\n  /**\n   * POST: /api/emails/send-ebook-to-device\n   * Send ebook to device\n   * User must have access to device and library item\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async sendEBookToDevice(req, res) {\n    Logger.debug(`[EmailController] Send ebook to device requested by user \"${req.user.username}\" for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)\n\n    const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)\n    if (!device) {\n      return res.status(404).send('Ereader device not found')\n    }\n\n    // Check user has access to device\n    if (!Database.emailSettings.checkUserCanAccessDevice(device, req.user)) {\n      return res.sendStatus(403)\n    }\n\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)\n    if (!libraryItem) {\n      return res.status(404).send('Library item not found')\n    }\n\n    // Check user has access to library item\n    if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n      return res.sendStatus(403)\n    }\n\n    const ebookFile = libraryItem.media.ebookFile\n    if (!ebookFile) {\n      return res.status(404).send('Ebook file not found')\n    }\n\n    this.emailManager.sendEBookToDevice(ebookFile, device, res)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  adminMiddleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      return res.sendStatus(404)\n    }\n\n    next()\n  }\n}\nmodule.exports = new EmailController()\n"
  },
  {
    "path": "server/controllers/FileSystemController.js",
    "content": "const { Request, Response } = require('express')\nconst Path = require('path')\nconst Logger = require('../Logger')\nconst fs = require('../libs/fsExtra')\nconst { toNumber } = require('../utils/index')\nconst fileUtils = require('../utils/fileUtils')\nconst Database = require('../Database')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass FileSystemController {\n  constructor() {}\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getPaths(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[FileSystemController] Non-admin user \"${req.user.username}\" attempting to get filesystem paths`)\n      return res.sendStatus(403)\n    }\n\n    const relpath = req.query.path\n    const level = toNumber(req.query.level, 0)\n\n    // Validate path. Must be absolute\n    if (relpath && (!Path.isAbsolute(relpath) || !(await fs.pathExists(relpath)))) {\n      Logger.error(`[FileSystemController] Invalid path in query string \"${relpath}\"`)\n      return res.status(400).send('Invalid \"path\" query string')\n    }\n    Logger.debug(`[FileSystemController] Getting file paths at ${relpath || 'root'} (${level})`)\n\n    let directories = []\n\n    // Windows returns drives first\n    if (global.isWin) {\n      if (relpath) {\n        directories = await fileUtils.getDirectoriesInPath(relpath, level)\n      } else {\n        const drives = await fileUtils.getWindowsDrives().catch((error) => {\n          Logger.error(`[FileSystemController] Failed to get windows drives`, error)\n          return []\n        })\n        if (drives.length) {\n          directories = drives.map((d) => {\n            return {\n              path: d,\n              dirname: d,\n              level: 0\n            }\n          })\n        }\n      }\n    } else {\n      directories = await fileUtils.getDirectoriesInPath(relpath || '/', level)\n    }\n\n    // Exclude some dirs from this project to be cleaner in Docker\n    const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map((dirname) => {\n      return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname))\n    })\n    directories = directories.filter((dir) => {\n      return !excludedDirs.includes(dir.path)\n    })\n\n    res.json({\n      posix: !global.isWin,\n      directories\n    })\n  }\n\n  /**\n   * POST: /api/filesystem/pathexists\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async checkPathExists(req, res) {\n    if (!req.user.canUpload) {\n      Logger.error(`[FileSystemController] User \"${req.user.username}\" without upload permissions attempting to check path exists`)\n      return res.sendStatus(403)\n    }\n\n    const { directory, folderPath } = req.body\n    if (!directory?.length || typeof directory !== 'string' || !folderPath?.length || typeof folderPath !== 'string') {\n      Logger.error(`[FileSystemController] Invalid request body: ${JSON.stringify(req.body)}`)\n      return res.status(400).json({\n        error: 'Invalid request body'\n      })\n    }\n\n    // Check that library folder exists\n    const libraryFolder = await Database.libraryFolderModel.findOne({\n      where: {\n        path: folderPath\n      }\n    })\n\n    if (!libraryFolder) {\n      Logger.error(`[FileSystemController] Library folder not found: ${folderPath}`)\n      return res.sendStatus(404)\n    }\n\n    if (!req.user.checkCanAccessLibrary(libraryFolder.libraryId)) {\n      Logger.error(`[FileSystemController] User \"${req.user.username}\" attempting to check path exists for library \"${libraryFolder.libraryId}\" without access`)\n      return res.sendStatus(403)\n    }\n\n    let filepath = Path.join(libraryFolder.path, directory)\n    filepath = fileUtils.filePathToPOSIX(filepath)\n\n    // Ensure filepath is inside library folder (prevents directory traversal)\n    if (!filepath.startsWith(libraryFolder.path)) {\n      Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)\n      return res.sendStatus(400)\n    }\n\n    if (await fs.pathExists(filepath)) {\n      return res.json({\n        exists: true\n      })\n    }\n\n    // Check if a library item exists in a subdirectory\n    // See: https://github.com/advplyr/audiobookshelf/issues/4146\n    const cleanedDirectory = directory.split('/').filter(Boolean).join('/')\n    if (cleanedDirectory.includes('/')) {\n      // Can only be 2 levels deep\n      const possiblePaths = []\n      const subdir = Path.dirname(directory)\n      possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, subdir)))\n      if (subdir.includes('/')) {\n        possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, Path.dirname(subdir))))\n      }\n\n      const libraryItem = await Database.libraryItemModel.findOne({\n        where: {\n          path: possiblePaths\n        }\n      })\n\n      if (libraryItem) {\n        return res.json({\n          exists: true,\n          libraryItemTitle: libraryItem.title\n        })\n      }\n    }\n\n    return res.json({\n      exists: false\n    })\n  }\n}\nmodule.exports = new FileSystemController()\n"
  },
  {
    "path": "server/controllers/LibraryController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Sequelize = require('sequelize')\nconst Path = require('path')\nconst fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst libraryHelpers = require('../utils/libraryHelpers')\nconst libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')\nconst libraryItemFilters = require('../utils/queries/libraryItemFilters')\nconst seriesFilters = require('../utils/queries/seriesFilters')\nconst fileUtils = require('../utils/fileUtils')\nconst { createNewSortInstance } = require('../libs/fastSort')\nconst naturalSort = createNewSortInstance({\n  comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare\n})\n\nconst LibraryScanner = require('../scanner/LibraryScanner')\nconst Scanner = require('../scanner/Scanner')\nconst Database = require('../Database')\nconst Watcher = require('../Watcher')\nconst RssFeedManager = require('../managers/RssFeedManager')\n\nconst libraryFilters = require('../utils/queries/libraryFilters')\nconst libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')\nconst authorFilters = require('../utils/queries/authorFilters')\nconst zipHelpers = require('../utils/zipHelpers')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/Library')} library\n *\n * @typedef {RequestWithUser & RequestEntityObject} LibraryControllerRequest\n */\n\nclass LibraryController {\n  constructor() {}\n\n  /**\n   * POST: /api/libraries\n   * Create a new library\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to create library`)\n      return res.sendStatus(403)\n    }\n\n    // Validation\n    if (!req.body.name || typeof req.body.name !== 'string') {\n      return res.status(400).send('Invalid request. Name must be a string')\n    }\n    if (\n      !Array.isArray(req.body.folders) ||\n      req.body.folders.some((f) => {\n        // Old model uses fullPath and new model will use path. Support both for now\n        const path = f?.fullPath || f?.path\n        return !path || typeof path !== 'string'\n      })\n    ) {\n      return res.status(400).send('Invalid request. Folders must be a non-empty array of objects with path string')\n    }\n    const optionalStringFields = ['mediaType', 'icon', 'provider']\n    for (const field of optionalStringFields) {\n      if (req.body[field] && typeof req.body[field] !== 'string') {\n        return res.status(400).send(`Invalid request. ${field} must be a string`)\n      }\n    }\n    if (req.body.settings && (typeof req.body.settings !== 'object' || Array.isArray(req.body.settings))) {\n      return res.status(400).send('Invalid request. Settings must be an object')\n    }\n\n    const mediaType = req.body.mediaType || 'book'\n    const newLibraryPayload = {\n      name: req.body.name,\n      provider: req.body.provider || 'google',\n      mediaType,\n      icon: req.body.icon || 'database',\n      settings: Database.libraryModel.getDefaultLibrarySettingsForMediaType(mediaType)\n    }\n\n    // Validate settings\n    if (req.body.settings) {\n      for (const key in req.body.settings) {\n        if (newLibraryPayload.settings[key] !== undefined) {\n          if (key === 'metadataPrecedence') {\n            if (!Array.isArray(req.body.settings[key])) {\n              return res.status(400).send('Invalid request. Settings \"metadataPrecedence\" must be an array')\n            }\n            newLibraryPayload.settings[key] = [...req.body.settings[key]]\n          } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {\n            if (!req.body.settings[key]) continue\n            if (typeof req.body.settings[key] !== 'string') {\n              return res.status(400).send(`Invalid request. Settings \"${key}\" must be a string`)\n            }\n            newLibraryPayload.settings[key] = req.body.settings[key]\n          } else if (key === 'markAsFinishedPercentComplete' || key === 'markAsFinishedTimeRemaining') {\n            if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {\n              return res.status(400).send(`Invalid request. Setting \"${key}\" must be a number`)\n            } else if (key === 'markAsFinishedPercentComplete' && req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {\n              return res.status(400).send(`Invalid request. Setting \"${key}\" must be between 0 and 100`)\n            } else if (key === 'markAsFinishedTimeRemaining' && req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {\n              return res.status(400).send(`Invalid request. Setting \"${key}\" must be greater than or equal to 0`)\n            }\n            newLibraryPayload.settings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])\n          } else {\n            if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) {\n              return res.status(400).send(`Invalid request. Setting \"${key}\" must be of type ${typeof newLibraryPayload.settings[key]}`)\n            }\n            newLibraryPayload.settings[key] = req.body.settings[key]\n          }\n        }\n      }\n    }\n\n    // Validate that the custom provider exists if given any\n    if (newLibraryPayload.provider.startsWith('custom-')) {\n      if (!(await Database.customMetadataProviderModel.checkExistsBySlug(newLibraryPayload.provider))) {\n        Logger.error(`[LibraryController] Custom metadata provider \"${newLibraryPayload.provider}\" does not exist`)\n        return res.status(400).send('Invalid request. Custom metadata provider does not exist')\n      }\n    }\n\n    // Validate folder paths exist or can be created & resolve rel paths\n    //   returns 400 if a folder fails to access\n    newLibraryPayload.libraryFolders = req.body.folders.map((f) => {\n      const fpath = f.fullPath || f.path\n      f.path = fileUtils.filePathToPOSIX(Path.resolve(fpath))\n      return f\n    })\n    for (const folder of newLibraryPayload.libraryFolders) {\n      try {\n        // Create folder if it doesn't exist\n        await fs.ensureDir(folder.path)\n      } catch (error) {\n        Logger.error(`[LibraryController] Failed to ensure folder dir \"${folder.path}\"`, error)\n        return res.status(400).send(`Invalid request. Invalid folder directory \"${folder.path}\"`)\n      }\n    }\n\n    // Set display order\n    let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder()\n    if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0\n    newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1\n\n    // Create library with libraryFolders\n    const library = await Database.libraryModel\n      .create(newLibraryPayload, {\n        include: Database.libraryFolderModel\n      })\n      .catch((error) => {\n        Logger.error(`[LibraryController] Failed to create library \"${newLibraryPayload.name}\"`, error)\n      })\n    if (!library) {\n      return res.status(500).send('Failed to create library')\n    }\n\n    library.libraryFolders = await library.getLibraryFolders()\n\n    // Only emit to users with access to library\n    const userFilter = (user) => {\n      return user.checkCanAccessLibrary?.(library.id)\n    }\n    SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter)\n\n    // Add library watcher\n    Watcher.addLibrary(library)\n\n    res.json(library.toOldJSON())\n  }\n\n  /**\n   * GET: /api/libraries\n   * Get all libraries\n   *\n   * ?include=stats to load library stats - used in android auto to filter out libraries with no audio\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findAll(req, res) {\n    let libraries = await Database.libraryModel.getAllWithFolders()\n\n    const librariesAccessible = req.user.permissions?.librariesAccessible || []\n    if (librariesAccessible.length) {\n      libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))\n    }\n\n    libraries = libraries.map((lib) => lib.toOldJSON())\n\n    const includeArray = (req.query.include || '').split(',')\n    if (includeArray.includes('stats')) {\n      for (const library of libraries) {\n        if (library.mediaType === 'book') {\n          library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)\n        } else if (library.mediaType === 'podcast') {\n          library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)\n        }\n      }\n    }\n\n    res.json({\n      libraries\n    })\n  }\n\n  /**\n   * GET: /api/libraries/:id\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    const includeArray = (req.query.include || '').split(',')\n    if (includeArray.includes('filterdata')) {\n      const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)\n\n      return res.json({\n        filterdata,\n        issues: filterdata.numIssues,\n        numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),\n        library: req.library.toOldJSON()\n      })\n    }\n    res.json(req.library.toOldJSON())\n  }\n\n  /**\n   * GET: /api/libraries/:id/episode-downloads\n   * Get podcast episodes in download queue\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getEpisodeDownloadQueue(req, res) {\n    const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)\n    res.json(libraryDownloadQueueDetails)\n  }\n\n  /**\n   * PATCH: /api/libraries/:id\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to update library`)\n      return res.sendStatus(403)\n    }\n\n    // Validation\n    const updatePayload = {}\n    const keysToCheck = ['name', 'provider', 'mediaType', 'icon']\n    for (const key of keysToCheck) {\n      if (!req.body[key]) continue\n      if (typeof req.body[key] !== 'string') {\n        Logger.error(`[LibraryController] Invalid request. ${key} must be a string`)\n        return res.status(400).send(`Invalid request. ${key} must be a string`)\n      }\n      updatePayload[key] = req.body[key]\n    }\n    if (req.body.displayOrder !== undefined) {\n      if (isNaN(req.body.displayOrder)) {\n        Logger.error(`[LibraryController] Invalid request. displayOrder must be a number`)\n        return res.status(400).send('Invalid request. displayOrder must be a number')\n      }\n      updatePayload.displayOrder = req.body.displayOrder\n    }\n\n    // Validate that the custom provider exists if given any\n    if (req.body.provider?.startsWith('custom-')) {\n      if (!(await Database.customMetadataProviderModel.checkExistsBySlug(req.body.provider))) {\n        Logger.error(`[LibraryController] Custom metadata provider \"${req.body.provider}\" does not exist`)\n        return res.status(400).send('Custom metadata provider does not exist')\n      }\n    }\n\n    // Validate settings\n    const defaultLibrarySettings = Database.libraryModel.getDefaultLibrarySettingsForMediaType(req.library.mediaType)\n    const updatedSettings = {\n      ...(req.library.settings || defaultLibrarySettings)\n    }\n    // In case new settings are added in the future, ensure all settings are present\n    for (const key in defaultLibrarySettings) {\n      if (updatedSettings[key] === undefined) {\n        updatedSettings[key] = defaultLibrarySettings[key]\n      }\n    }\n\n    let hasUpdates = false\n    let hasUpdatedDisableWatcher = false\n    let hasUpdatedScanCron = false\n    if (req.body.settings) {\n      for (const key in req.body.settings) {\n        if (!Object.keys(defaultLibrarySettings).includes(key)) {\n          continue\n        }\n\n        if (key === 'metadataPrecedence') {\n          if (!Array.isArray(req.body.settings[key])) {\n            Logger.error(`[LibraryController] Invalid request. Settings \"metadataPrecedence\" must be an array`)\n            return res.status(400).send('Invalid request. Settings \"metadataPrecedence\" must be an array')\n          }\n          if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) {\n            hasUpdates = true\n            updatedSettings[key] = [...req.body.settings[key]]\n            Logger.debug(`[LibraryController] Library \"${req.library.name}\" updating setting \"${key}\" to \"${updatedSettings[key]}\"`)\n          }\n        } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {\n          if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') {\n            Logger.error(`[LibraryController] Invalid request. Settings \"${key}\" must be a string`)\n            return res.status(400).send(`Invalid request. Settings \"${key}\" must be a string`)\n          }\n          if (req.body.settings[key] !== updatedSettings[key]) {\n            if (key === 'autoScanCronExpression') hasUpdatedScanCron = true\n\n            hasUpdates = true\n            updatedSettings[key] = req.body.settings[key]\n            Logger.debug(`[LibraryController] Library \"${req.library.name}\" updating setting \"${key}\" to \"${updatedSettings[key]}\"`)\n          }\n        } else if (key === 'markAsFinishedPercentComplete') {\n          if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {\n            Logger.error(`[LibraryController] Invalid request. Setting \"${key}\" must be a number`)\n            return res.status(400).send(`Invalid request. Setting \"${key}\" must be a number`)\n          } else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {\n            Logger.error(`[LibraryController] Invalid request. Setting \"${key}\" must be between 0 and 100`)\n            return res.status(400).send(`Invalid request. Setting \"${key}\" must be between 0 and 100`)\n          }\n          if (req.body.settings[key] !== updatedSettings[key]) {\n            hasUpdates = true\n            updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])\n            Logger.debug(`[LibraryController] Library \"${req.library.name}\" updating setting \"${key}\" to \"${updatedSettings[key]}\"`)\n          }\n        } else if (key === 'markAsFinishedTimeRemaining') {\n          if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {\n            Logger.error(`[LibraryController] Invalid request. Setting \"${key}\" must be a number`)\n            return res.status(400).send(`Invalid request. Setting \"${key}\" must be a number`)\n          } else if (req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {\n            Logger.error(`[LibraryController] Invalid request. Setting \"${key}\" must be greater than or equal to 0`)\n            return res.status(400).send(`Invalid request. Setting \"${key}\" must be greater than or equal to 0`)\n          }\n          if (req.body.settings[key] !== updatedSettings[key]) {\n            hasUpdates = true\n            updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])\n            Logger.debug(`[LibraryController] Library \"${req.library.name}\" updating setting \"${key}\" to \"${updatedSettings[key]}\"`)\n          }\n        } else {\n          if (typeof req.body.settings[key] !== typeof updatedSettings[key]) {\n            Logger.error(`[LibraryController] Invalid request. Setting \"${key}\" must be of type ${typeof updatedSettings[key]}`)\n            return res.status(400).send(`Invalid request. Setting \"${key}\" must be of type ${typeof updatedSettings[key]}`)\n          }\n          if (req.body.settings[key] !== updatedSettings[key]) {\n            if (key === 'disableWatcher') hasUpdatedDisableWatcher = true\n\n            hasUpdates = true\n            updatedSettings[key] = req.body.settings[key]\n            Logger.debug(`[LibraryController] Library \"${req.library.name}\" updating setting \"${key}\" to \"${updatedSettings[key]}\"`)\n          }\n        }\n      }\n      if (hasUpdates) {\n        updatePayload.settings = updatedSettings\n        req.library.changed('settings', true)\n      }\n    }\n\n    let hasFolderUpdates = false\n    // Validate new folder paths exist or can be created & resolve rel paths\n    //   returns 400 if a new folder fails to access\n    if (Array.isArray(req.body.folders)) {\n      const newFolderPaths = []\n      req.body.folders = req.body.folders.map((f) => {\n        if (!f.id) {\n          const path = f.fullPath || f.path\n          f.path = fileUtils.filePathToPOSIX(Path.resolve(path))\n          newFolderPaths.push(f.path)\n        }\n        return f\n      })\n      for (const path of newFolderPaths) {\n        const pathExists = await fs.pathExists(path)\n        if (!pathExists) {\n          const success = await fs\n            .ensureDir(path)\n            .then(() => true)\n            .catch((error) => {\n              Logger.error(`[LibraryController] Failed to ensure folder dir \"${path}\"`, error)\n              return false\n            })\n          if (!success) {\n            Logger.error(`[LibraryController] Invalid folder directory \"${path}\"`)\n            return res.status(400).send(`Invalid folder directory \"${path}\"`)\n          }\n        }\n        // Create folder\n        const libraryFolder = await Database.libraryFolderModel.create({\n          path,\n          libraryId: req.library.id\n        })\n        Logger.info(`[LibraryController] Created folder \"${libraryFolder.path}\" for library \"${req.library.name}\"`)\n        hasFolderUpdates = true\n      }\n\n      // Handle removing folders\n      for (const folder of req.library.libraryFolders) {\n        if (!req.body.folders.some((f) => f.id === folder.id)) {\n          // Remove library items in folder\n          const libraryItemsInFolder = await Database.libraryItemModel.findAll({\n            where: {\n              libraryFolderId: folder.id\n            },\n            attributes: ['id', 'mediaId', 'mediaType'],\n            include: [\n              {\n                model: Database.podcastModel,\n                attributes: ['id'],\n                include: {\n                  model: Database.podcastEpisodeModel,\n                  attributes: ['id']\n                }\n              },\n              {\n                model: Database.bookModel,\n                attributes: ['id'],\n                include: [\n                  {\n                    model: Database.bookAuthorModel,\n                    attributes: ['authorId']\n                  },\n                  {\n                    model: Database.bookSeriesModel,\n                    attributes: ['seriesId']\n                  }\n                ]\n              }\n            ]\n          })\n          Logger.info(`[LibraryController] Removed folder \"${folder.path}\" from library \"${req.library.name}\" with ${libraryItemsInFolder.length} library items`)\n          const seriesIds = []\n          const authorIds = []\n          for (const libraryItem of libraryItemsInFolder) {\n            let mediaItemIds = []\n            if (req.library.isPodcast) {\n              mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)\n            } else {\n              mediaItemIds.push(libraryItem.mediaId)\n              if (libraryItem.media.bookAuthors.length) {\n                authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))\n              }\n              if (libraryItem.media.bookSeries.length) {\n                seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))\n              }\n            }\n            Logger.info(`[LibraryController] Removing library item \"${libraryItem.id}\" from folder \"${folder.path}\"`)\n            await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)\n          }\n\n          if (authorIds.length) {\n            await this.checkRemoveAuthorsWithNoBooks(authorIds)\n          }\n          if (seriesIds.length) {\n            await this.checkRemoveEmptySeries(seriesIds)\n          }\n\n          // Remove folder\n          await folder.destroy()\n          hasFolderUpdates = true\n        }\n      }\n    }\n\n    if (Object.keys(updatePayload).length) {\n      req.library.set(updatePayload)\n      if (req.library.changed()) {\n        Logger.debug(`[LibraryController] Updated library \"${req.library.name}\" with changed keys ${req.library.changed()}`)\n        hasUpdates = true\n        await req.library.save()\n      }\n    }\n\n    if (hasUpdatedScanCron) {\n      Logger.debug(`[LibraryController] Updated library \"${req.library.name}\" auto scan cron`)\n      // Update auto scan cron\n      this.cronManager.updateLibraryScanCron(req.library)\n    }\n\n    if (hasFolderUpdates || hasUpdatedDisableWatcher) {\n      req.library.libraryFolders = await req.library.getLibraryFolders()\n\n      // Update watcher\n      Watcher.updateLibrary(req.library)\n\n      hasUpdates = true\n    }\n\n    if (hasUpdates) {\n      // Only emit to users with access to library\n      const userFilter = (user) => {\n        return user.checkCanAccessLibrary?.(req.library.id)\n      }\n      SocketAuthority.emitter('library_updated', req.library.toOldJSON(), userFilter)\n\n      await Database.resetLibraryIssuesFilterData(req.library.id)\n    }\n    return res.json(req.library.toOldJSON())\n  }\n\n  /**\n   * DELETE: /api/libraries/:id\n   * Delete a library\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to delete library`)\n      return res.sendStatus(403)\n    }\n\n    // Remove library watcher\n    Watcher.removeLibrary(req.library)\n\n    // Remove collections for library\n    const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id)\n    if (numCollectionsRemoved) {\n      Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library \"${req.library.name}\"`)\n    }\n\n    // Remove items in this library\n    const libraryItemsInLibrary = await Database.libraryItemModel.findAll({\n      where: {\n        libraryId: req.library.id\n      },\n      attributes: ['id', 'mediaId', 'mediaType'],\n      include: [\n        {\n          model: Database.podcastModel,\n          attributes: ['id'],\n          include: {\n            model: Database.podcastEpisodeModel,\n            attributes: ['id']\n          }\n        }\n      ]\n    })\n    Logger.info(`[LibraryController] Removing ${libraryItemsInLibrary.length} library items in library \"${req.library.name}\"`)\n    for (const libraryItem of libraryItemsInLibrary) {\n      let mediaItemIds = []\n      if (req.library.isPodcast) {\n        mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)\n      } else {\n        mediaItemIds.push(libraryItem.mediaId)\n      }\n      Logger.info(`[LibraryController] Removing library item \"${libraryItem.id}\" from library \"${req.library.name}\"`)\n      await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)\n    }\n\n    // Set PlaybackSessions libraryId to null\n    const [sessionsUpdated] = await Database.playbackSessionModel.update(\n      {\n        libraryId: null\n      },\n      {\n        where: {\n          libraryId: req.library.id\n        }\n      }\n    )\n    Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)\n\n    const libraryJson = req.library.toOldJSON()\n    await req.library.destroy()\n\n    // Re-order libraries\n    await Database.libraryModel.resetDisplayOrder()\n\n    SocketAuthority.emitter('library_removed', libraryJson)\n\n    // Remove library filter data\n    if (Database.libraryFilterData[req.library.id]) {\n      delete Database.libraryFilterData[req.library.id]\n    }\n\n    return res.json(libraryJson)\n  }\n\n  /**\n   * GET /api/libraries/:id/items\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getLibraryItems(req, res) {\n    const include = (req.query.include || '')\n      .split(',')\n      .map((v) => v.trim().toLowerCase())\n      .filter((v) => !!v)\n\n    const payload = {\n      results: [],\n      total: undefined,\n      limit: req.query.limit || 0,\n      page: req.query.page || 0,\n      sortBy: req.query.sort,\n      sortDesc: req.query.desc === '1',\n      filterBy: req.query.filter,\n      mediaType: req.library.mediaType,\n      minified: req.query.minified === '1',\n      collapseseries: req.query.collapseseries === '1',\n      include: include.join(',')\n    }\n\n    payload.offset = payload.page * payload.limit\n\n    // TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries\n    const filterByGroup = payload.filterBy?.split('.').shift()\n    const filterByValue = filterByGroup ? libraryFilters.decode(payload.filterBy.replace(`${filterByGroup}.`, '')) : null\n    if (filterByGroup === 'series' && filterByValue !== 'no-series' && payload.collapseseries) {\n      const seriesId = libraryFilters.decode(payload.filterBy.split('.')[1])\n      payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, req.library)\n    } else {\n      const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)\n      payload.results = libraryItems\n      payload.total = count\n    }\n\n    res.json(payload)\n  }\n\n  /**\n   * DELETE: /api/libraries/:id/issues\n   * Remove all library items missing or invalid\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async removeLibraryItemsWithIssues(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to delete library items missing or invalid`)\n      return res.sendStatus(403)\n    }\n\n    const libraryItemsWithIssues = await Database.libraryItemModel.findAll({\n      where: {\n        libraryId: req.library.id,\n        [Sequelize.Op.or]: [\n          {\n            isMissing: true\n          },\n          {\n            isInvalid: true\n          }\n        ]\n      },\n      attributes: ['id', 'mediaId', 'mediaType'],\n      include: [\n        {\n          model: Database.podcastModel,\n          attributes: ['id'],\n          include: {\n            model: Database.podcastEpisodeModel,\n            attributes: ['id']\n          }\n        },\n        {\n          model: Database.bookModel,\n          attributes: ['id'],\n          include: [\n            {\n              model: Database.bookAuthorModel,\n              attributes: ['authorId']\n            },\n            {\n              model: Database.bookSeriesModel,\n              attributes: ['seriesId']\n            }\n          ]\n        }\n      ]\n    })\n\n    if (!libraryItemsWithIssues.length) {\n      Logger.warn(`[LibraryController] No library items have issues`)\n      return res.sendStatus(200)\n    }\n\n    Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)\n    const authorIds = []\n    const seriesIds = []\n    for (const libraryItem of libraryItemsWithIssues) {\n      let mediaItemIds = []\n      if (req.library.isPodcast) {\n        mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)\n      } else {\n        mediaItemIds.push(libraryItem.mediaId)\n        if (libraryItem.media.bookAuthors.length) {\n          authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))\n        }\n        if (libraryItem.media.bookSeries.length) {\n          seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))\n        }\n      }\n      Logger.info(`[LibraryController] Removing library item \"${libraryItem.id}\" with issue`)\n      await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)\n    }\n\n    if (authorIds.length) {\n      await this.checkRemoveAuthorsWithNoBooks(authorIds)\n    }\n    if (seriesIds.length) {\n      await this.checkRemoveEmptySeries(seriesIds)\n    }\n\n    // Set numIssues to 0 for library filter data\n    if (Database.libraryFilterData[req.library.id]) {\n      Database.libraryFilterData[req.library.id].numIssues = 0\n    }\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/libraries/:id/series\n   * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getAllSeriesForLibrary(req, res) {\n    const include = (req.query.include || '')\n      .split(',')\n      .map((v) => v.trim().toLowerCase())\n      .filter((v) => !!v)\n\n    const payload = {\n      results: [],\n      total: 0,\n      limit: req.query.limit || 0,\n      page: req.query.page || 0,\n      sortBy: req.query.sort,\n      sortDesc: req.query.desc === '1',\n      filterBy: req.query.filter,\n      minified: req.query.minified === '1',\n      include: include.join(',')\n    }\n\n    const offset = payload.page * payload.limit\n    const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset)\n\n    payload.total = count\n    payload.results = series\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/libraries/:id/series/:seriesId\n   *\n   * Optional includes (e.g. `?include=rssfeed,progress`)\n   * rssfeed: adds `rssFeed` to series object if a feed is open\n   * progress: adds `progress` to series object with { libraryItemIds:Array<llid>, libraryItemIdsFinished:Array<llid>, isFinished:boolean }\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res - Series\n   */\n  async getSeriesForLibrary(req, res) {\n    const include = (req.query.include || '')\n      .split(',')\n      .map((v) => v.trim().toLowerCase())\n      .filter((v) => !!v)\n\n    const series = await Database.seriesModel.findByPk(req.params.seriesId)\n    if (!series) return res.sendStatus(404)\n\n    const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)\n\n    const seriesJson = series.toOldJSON()\n    if (include.includes('progress')) {\n      const libraryItemsFinished = libraryItemsInSeries.filter((li) => !!req.user.getMediaProgress(li.media.id)?.isFinished)\n      seriesJson.progress = {\n        libraryItemIds: libraryItemsInSeries.map((li) => li.id),\n        libraryItemIdsFinished: libraryItemsFinished.map((li) => li.id),\n        isFinished: libraryItemsFinished.length >= libraryItemsInSeries.length\n      }\n    }\n\n    if (include.includes('rssfeed')) {\n      const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)\n      seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null\n    }\n\n    res.json(seriesJson)\n  }\n\n  /**\n   * GET: /api/libraries/:id/collections\n   * Get all collections for library\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getCollectionsForLibrary(req, res) {\n    const include = (req.query.include || '')\n      .split(',')\n      .map((v) => v.trim().toLowerCase())\n      .filter((v) => !!v)\n\n    const payload = {\n      results: [],\n      total: 0,\n      limit: req.query.limit || 0,\n      page: req.query.page || 0,\n      sortBy: req.query.sort,\n      sortDesc: req.query.desc === '1',\n      filterBy: req.query.filter,\n      minified: req.query.minified === '1',\n      include: include.join(',')\n    }\n\n    // TODO: Create paginated queries\n    let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include)\n\n    payload.total = collections.length\n\n    if (payload.limit) {\n      const startIndex = payload.page * payload.limit\n      collections = collections.slice(startIndex, startIndex + payload.limit)\n    }\n\n    payload.results = collections\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/libraries/:id/playlists\n   * Get playlists for user in library\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getUserPlaylistsForLibrary(req, res) {\n    let playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.library.id)\n\n    const payload = {\n      results: [],\n      total: playlistsForUser.length,\n      limit: req.query.limit || 0,\n      page: req.query.page || 0\n    }\n\n    if (payload.limit) {\n      const startIndex = payload.page * payload.limit\n      playlistsForUser = playlistsForUser.slice(startIndex, startIndex + payload.limit)\n    }\n\n    payload.results = playlistsForUser\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/libraries/:id/filterdata\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getLibraryFilterData(req, res) {\n    const filterData = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)\n    res.json(filterData)\n  }\n\n  /**\n   * GET: /api/libraries/:id/personalized\n   * Home page shelves\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getUserPersonalizedShelves(req, res) {\n    const limitPerShelf = req.query.limit || 10\n    const include = (req.query.include || '')\n      .split(',')\n      .map((v) => v.trim().toLowerCase())\n      .filter((v) => !!v)\n    const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)\n    res.json(shelves)\n  }\n\n  /**\n   * POST: /api/libraries/order\n   * Change the display order of libraries\n   *\n   * @typedef LibraryReorderObj\n   * @property {string} id\n   * @property {number} newOrder\n   *\n   * @typedef {Request<{}, {}, LibraryReorderObj[], {}> & RequestUserObject} LibraryReorderRequest\n   *\n   * @param {LibraryReorderRequest} req\n   * @param {Response} res\n   */\n  async reorder(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user}\" attempted to reorder libraries`)\n      return res.sendStatus(403)\n    }\n\n    const libraries = await Database.libraryModel.getAllWithFolders()\n\n    const orderdata = req.body\n    if (!Array.isArray(orderdata) || orderdata.some((o) => typeof o?.id !== 'string' || typeof o?.newOrder !== 'number')) {\n      return res.status(400).send('Invalid request. Request body must be an array of objects')\n    }\n\n    let hasUpdates = false\n    for (let i = 0; i < orderdata.length; i++) {\n      const library = libraries.find((lib) => lib.id === orderdata[i].id)\n      if (!library) {\n        Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`)\n        return res.status(400).send(`Library not found with id ${orderdata[i].id}`)\n      }\n      if (library.displayOrder === orderdata[i].newOrder) continue\n      library.displayOrder = orderdata[i].newOrder\n      await library.save()\n      hasUpdates = true\n    }\n\n    if (hasUpdates) {\n      libraries.sort((a, b) => a.displayOrder - b.displayOrder)\n      Logger.debug(`[LibraryController] Updated library display orders`)\n    } else {\n      Logger.debug(`[LibraryController] Library orders were up to date`)\n    }\n\n    res.json({\n      libraries: libraries.map((lib) => lib.toOldJSON())\n    })\n  }\n\n  /**\n   * GET: /api/libraries/:id/search\n   * Search library items with query\n   *\n   * ?q=search\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async search(req, res) {\n    if (!req.query.q || typeof req.query.q !== 'string') {\n      return res.status(400).send('Invalid request. Query param \"q\" must be a string')\n    }\n\n    const limit = req.query.limit || 12\n    const query = req.query.q.trim()\n\n    const matches = await libraryItemFilters.search(req.user, req.library, query, limit)\n    res.json(matches)\n  }\n\n  /**\n   * GET: /api/libraries/:id/stats\n   * Get stats for library\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async stats(req, res) {\n    const stats = {\n      largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10)\n    }\n\n    if (req.library.mediaType === 'book') {\n      const authors = await authorFilters.getAuthorsWithCount(req.library.id, 10)\n      const genres = await libraryItemsBookFilters.getGenresWithCount(req.library.id)\n      const bookStats = await libraryItemsBookFilters.getBookLibraryStats(req.library.id)\n      const longestBooks = await libraryItemsBookFilters.getLongestBooks(req.library.id, 10)\n\n      stats.totalAuthors = await authorFilters.getAuthorsTotalCount(req.library.id)\n      stats.authorsWithCount = authors\n      stats.totalGenres = genres.length\n      stats.genresWithCount = genres\n      stats.totalItems = bookStats.totalItems\n      stats.longestItems = longestBooks\n      stats.totalSize = bookStats.totalSize\n      stats.totalDuration = bookStats.totalDuration\n      stats.numAudioTracks = bookStats.numAudioFiles\n    } else {\n      const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)\n      const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)\n      const longestPodcasts = await libraryItemsPodcastFilters.getLongestPodcasts(req.library.id, 10)\n\n      stats.totalGenres = genres.length\n      stats.genresWithCount = genres\n      stats.totalItems = podcastStats.totalItems\n      stats.longestItems = longestPodcasts\n      stats.totalSize = podcastStats.totalSize\n      stats.totalDuration = podcastStats.totalDuration\n      stats.numAudioTracks = podcastStats.numAudioFiles\n    }\n    res.json(stats)\n  }\n\n  /**\n   * GET: /api/libraries/:id/authors\n   * Get authors for library\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getAuthors(req, res) {\n    const isPaginated = req.query.limit && !isNaN(req.query.limit) && !isNaN(req.query.page)\n\n    const payload = {\n      results: [],\n      total: 0,\n      limit: isPaginated ? Number(req.query.limit) : 0,\n      page: isPaginated ? Number(req.query.page) : 0,\n      sortBy: req.query.sort,\n      sortDesc: req.query.desc === '1',\n      filterBy: req.query.filter,\n      minified: req.query.minified === '1',\n      include: req.query.include\n    }\n\n    // create order, limit and offset for pagination\n    let offset = isPaginated ? payload.page * payload.limit : undefined\n    let limit = isPaginated ? payload.limit : undefined\n    let order = undefined\n    const direction = payload.sortDesc ? 'DESC' : 'ASC'\n    if (payload.sortBy === 'name') {\n      order = [[Sequelize.literal('name COLLATE NOCASE'), direction]]\n    } else if (payload.sortBy === 'lastFirst') {\n      order = [[Sequelize.literal('lastFirst COLLATE NOCASE'), direction]]\n    } else if (payload.sortBy === 'addedAt') {\n      order = [['createdAt', direction]]\n    } else if (payload.sortBy === 'updatedAt') {\n      order = [['updatedAt', direction]]\n    } else if (payload.sortBy === 'numBooks') {\n      offset = undefined\n      limit = undefined\n    }\n\n    const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)\n    const { rows: authors, count } = await Database.authorModel.findAndCountAll({\n      where: {\n        libraryId: req.library.id\n      },\n      replacements,\n      include: {\n        model: Database.bookModel,\n        attributes: ['id', 'tags', 'explicit'],\n        where: bookWhere,\n        required: !req.user.isAdminOrUp, // Only show authors with 0 books for admin users or up\n        through: {\n          attributes: []\n        }\n      },\n      order: order,\n      limit: limit,\n      offset: offset,\n      distinct: true\n    })\n\n    let oldAuthors = []\n\n    for (const author of authors) {\n      const oldAuthor = author.toOldJSONExpanded(author.books.length)\n      oldAuthor.lastFirst = author.lastFirst\n      oldAuthors.push(oldAuthor)\n    }\n\n    // numBooks sort is handled post-query\n    if (payload.sortBy === 'numBooks') {\n      oldAuthors.sort((a, b) => (payload.sortDesc ? b.numBooks - a.numBooks : a.numBooks - b.numBooks))\n      if (isPaginated) {\n        const startIndex = payload.page * payload.limit\n        const endIndex = startIndex + payload.limit\n        oldAuthors = oldAuthors.slice(startIndex, endIndex)\n      }\n    }\n\n    payload.results = oldAuthors\n    if (isPaginated) {\n      payload.total = count\n      res.json(payload)\n    } else {\n      res.json({\n        authors: payload.results\n      })\n    }\n  }\n\n  /**\n   * GET: /api/libraries/:id/narrators\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getNarrators(req, res) {\n    // Get all books with narrators\n    const booksWithNarrators = await Database.bookModel.findAll({\n      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('narrators')), {\n        [Sequelize.Op.gt]: 0\n      }),\n      include: {\n        model: Database.libraryItemModel,\n        attributes: ['id', 'libraryId'],\n        where: {\n          libraryId: req.library.id\n        }\n      },\n      attributes: ['id', 'narrators']\n    })\n\n    const narrators = {}\n    for (const book of booksWithNarrators) {\n      book.narrators.forEach((n) => {\n        if (typeof n !== 'string') {\n          Logger.error(`[LibraryController] getNarrators: Invalid narrator \"${n}\" on book \"${book.title}\"`)\n        } else if (!narrators[n]) {\n          narrators[n] = {\n            id: encodeURIComponent(Buffer.from(n).toString('base64')),\n            name: n,\n            numBooks: 1\n          }\n        } else {\n          narrators[n].numBooks++\n        }\n      })\n    }\n\n    res.json({\n      narrators: naturalSort(Object.values(narrators)).asc((n) => n.name)\n    })\n  }\n\n  /**\n   * PATCH: /api/libraries/:id/narrators/:narratorId\n   * Update narrator name\n   * :narratorId is base64 encoded name\n   * req.body { name }\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async updateNarrator(req, res) {\n    if (!req.user.canUpdate) {\n      Logger.error(`[LibraryController] Unauthorized user \"${req.user.username}\" attempted to update narrator`)\n      return res.sendStatus(403)\n    }\n\n    const narratorName = libraryFilters.decode(req.params.narratorId)\n    const updatedName = req.body.name\n    if (!updatedName) {\n      return res.status(400).send('Invalid request payload. Name not specified.')\n    }\n\n    // Update filter data\n    Database.replaceNarratorInFilterData(narratorName, updatedName)\n\n    const itemsUpdated = []\n\n    const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])\n\n    for (const libraryItem of itemsWithNarrator) {\n      libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)\n      if (!libraryItem.media.narrators.includes(updatedName)) {\n        libraryItem.media.narrators.push(updatedName)\n      }\n      await libraryItem.media.update({\n        narrators: libraryItem.media.narrators\n      })\n\n      itemsUpdated.push(libraryItem)\n    }\n\n    if (itemsUpdated.length) {\n      SocketAuthority.libraryItemsEmitter('items_updated', itemsUpdated)\n    }\n\n    res.json({\n      updated: itemsUpdated.length\n    })\n  }\n\n  /**\n   * DELETE: /api/libraries/:id/narrators/:narratorId\n   * Remove narrator\n   * :narratorId is base64 encoded name\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async removeNarrator(req, res) {\n    if (!req.user.canUpdate) {\n      Logger.error(`[LibraryController] Unauthorized user \"${req.user.username}\" attempted to remove narrator`)\n      return res.sendStatus(403)\n    }\n\n    const narratorName = libraryFilters.decode(req.params.narratorId)\n\n    // Update filter data\n    Database.removeNarratorFromFilterData(narratorName)\n\n    const itemsUpdated = []\n\n    const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])\n\n    for (const libraryItem of itemsWithNarrator) {\n      libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)\n      await libraryItem.media.update({\n        narrators: libraryItem.media.narrators\n      })\n\n      itemsUpdated.push(libraryItem)\n    }\n\n    if (itemsUpdated.length) {\n      SocketAuthority.libraryItemsEmitter('items_updated', itemsUpdated)\n    }\n\n    res.json({\n      updated: itemsUpdated.length\n    })\n  }\n\n  /**\n   * GET: /api/libraries/:id/matchall\n   * Quick match all library items. Book libraries only.\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async matchAll(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-root user \"${req.user.username}\" attempted to match library items`)\n      return res.sendStatus(403)\n    }\n    Scanner.matchLibraryItems(this, req.library)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/libraries/:id/scan\n   * Optional query:\n   * ?force=1\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async scan(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to scan library`)\n      return res.sendStatus(403)\n    }\n    res.sendStatus(200)\n\n    const forceRescan = req.query.force === '1'\n    await LibraryScanner.scan(req.library, forceRescan)\n\n    await Database.resetLibraryIssuesFilterData(req.library.id)\n    Logger.info('[LibraryController] Scan complete')\n  }\n\n  /**\n   * GET: /api/libraries/:id/recent-episodes\n   * Used for latest page\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getRecentEpisodes(req, res) {\n    if (req.library.mediaType !== 'podcast') {\n      return res.sendStatus(404)\n    }\n\n    const payload = {\n      episodes: [],\n      limit: req.query.limit || 0,\n      page: req.query.page || 0\n    }\n\n    const offset = payload.page * payload.limit\n    payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset)\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/libraries/:id/opml\n   * Get OPML file for a podcast library\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getOPMLFile(req, res) {\n    const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user)\n    const podcasts = await Database.podcastModel.findAll({\n      attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'],\n      where: userPermissionPodcastWhere.podcastWhere,\n      replacements: userPermissionPodcastWhere.replacements,\n      include: {\n        model: Database.libraryItemModel,\n        attributes: ['id', 'libraryId'],\n        where: {\n          libraryId: req.library.id\n        }\n      }\n    })\n\n    const opmlText = this.podcastManager.generateOPMLFileText(podcasts)\n    res.type('application/xml')\n    res.send(opmlText)\n  }\n\n  /**\n   * POST: /api/libraries/:id/remove-metadata\n   * Remove all metadata.json or metadata.abs files in library item folders\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async removeAllMetadataFiles(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to remove all metadata files`)\n      return res.sendStatus(403)\n    }\n\n    const fileExt = req.query.ext === 'abs' ? 'abs' : 'json'\n    const metadataFilename = `metadata.${fileExt}`\n    const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({\n      attributes: ['id', 'libraryFiles'],\n      where: [\n        {\n          libraryId: req.library.id\n        },\n        Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(libraryFiles) AND json_extract(json_each.value, \"$.metadata.filename\") = \"${metadataFilename}\")`), {\n          [Sequelize.Op.gte]: 1\n        })\n      ]\n    })\n    if (!libraryItemsWithMetadata.length) {\n      Logger.info(`[LibraryController] No ${metadataFilename} files found to remove`)\n      return res.json({\n        found: 0\n      })\n    }\n\n    Logger.info(`[LibraryController] Found ${libraryItemsWithMetadata.length} ${metadataFilename} files to remove`)\n\n    let numRemoved = 0\n    for (const libraryItem of libraryItemsWithMetadata) {\n      const metadataFilepath = libraryItem.libraryFiles.find((lf) => lf.metadata.filename === metadataFilename)?.metadata.path\n      if (!metadataFilepath) continue\n      Logger.debug(`[LibraryController] Removing file \"${metadataFilepath}\"`)\n      if (await fileUtils.removeFile(metadataFilepath)) {\n        numRemoved++\n      }\n    }\n\n    res.json({\n      found: libraryItemsWithMetadata.length,\n      removed: numRemoved\n    })\n  }\n\n  /**\n   * GET: /api/libraries/:id/podcast-titles\n   *\n   * Get podcast titles with itunesId and libraryItemId for library\n   * Used on the podcast add page in order to check if a podcast is already in the library and redirect to it\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async getPodcastTitles(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryController] Non-admin user \"${req.user.username}\" attempted to get podcast titles`)\n      return res.sendStatus(403)\n    }\n\n    const podcasts = await Database.podcastModel.findAll({\n      attributes: ['id', 'title', 'itunesId'],\n      include: {\n        model: Database.libraryItemModel,\n        attributes: ['id', 'libraryId'],\n        where: {\n          libraryId: req.library.id\n        }\n      }\n    })\n\n    res.json({\n      podcasts: podcasts.map((p) => {\n        return {\n          title: p.title,\n          itunesId: p.itunesId,\n          libraryItemId: p.libraryItem.id,\n          libraryId: p.libraryItem.libraryId\n        }\n      })\n    })\n  }\n\n  /**\n   * GET: /api/library/:id/download\n   * Downloads multiple library items\n   *\n   * @param {LibraryControllerRequest} req\n   * @param {Response} res\n   */\n  async downloadMultiple(req, res) {\n    if (!req.user.canDownload) {\n      Logger.warn(`User \"${req.user.username}\" attempted to download without permission`)\n      return res.sendStatus(403)\n    }\n\n    if (!req.query.ids || typeof req.query.ids !== 'string') {\n      res.status(400).send('Invalid request. ids must be a string')\n      return\n    }\n\n    const itemIds = req.query.ids.split(',')\n\n    const libraryItems = await Database.libraryItemModel.findAll({\n      attributes: ['id', 'libraryId', 'path', 'isFile'],\n      where: {\n        id: itemIds\n      }\n    })\n\n    Logger.info(`[LibraryController] User \"${req.user.username}\" requested download for items \"${itemIds}\"`)\n\n    const filename = `LibraryItems-${Date.now()}.zip`\n    const pathObjects = libraryItems.map((li) => ({ path: li.path, isFile: li.isFile }))\n\n    if (!pathObjects.length) {\n      Logger.warn(`[LibraryController] No library items found for ids \"${itemIds}\"`)\n      return res.status(404).send('Library items not found')\n    }\n\n    try {\n      await zipHelpers.zipDirectoriesPipe(pathObjects, filename, res)\n      Logger.info(`[LibraryController] Downloaded ${pathObjects.length} items \"${filename}\"`)\n    } catch (error) {\n      Logger.error(`[LibraryController] Download failed for items \"${filename}\" at ${pathObjects.map((po) => po.path).join(', ')}`, error)\n      zipHelpers.handleDownloadError(error, res)\n    }\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (!req.user.checkCanAccessLibrary(req.params.id)) {\n      Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)\n      return res.sendStatus(403)\n    }\n\n    const library = await Database.libraryModel.findByIdWithFolders(req.params.id)\n    if (!library) {\n      return res.status(404).send('Library not found')\n    }\n    req.library = library\n\n    // Ensure pagination query params are positive integers\n    for (const queryKey of ['limit', 'page']) {\n      if (req.query[queryKey] !== undefined) {\n        req.query[queryKey] = !isNaN(req.query[queryKey]) ? Number(req.query[queryKey]) : 0\n        if (!Number.isInteger(req.query[queryKey]) || req.query[queryKey] < 0) {\n          return res.status(400).send(`Invalid request. ${queryKey} must be a positive integer`)\n        }\n      }\n    }\n\n    next()\n  }\n}\nmodule.exports = new LibraryController()\n"
  },
  {
    "path": "server/controllers/LibraryItemController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Path = require('path')\nconst fs = require('../libs/fsExtra')\nconst uaParserJs = require('../libs/uaParser')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst zipHelpers = require('../utils/zipHelpers')\nconst { reqSupportsWebp } = require('../utils/index')\nconst { ScanResult, AudioMimeType } = require('../utils/constants')\nconst { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')\nconst LibraryItemScanner = require('../scanner/LibraryItemScanner')\nconst AudioFileScanner = require('../scanner/AudioFileScanner')\nconst Scanner = require('../scanner/Scanner')\n\nconst RssFeedManager = require('../managers/RssFeedManager')\nconst CacheManager = require('../managers/CacheManager')\nconst CoverManager = require('../managers/CoverManager')\nconst ShareManager = require('../managers/ShareManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/LibraryItem')} libraryItem\n *\n * @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest\n *\n * @typedef RequestLibraryFileObject\n * @property {import('../objects/files/LibraryFile')} libraryFile\n *\n * @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile\n */\n\nclass LibraryItemController {\n  constructor() {}\n\n  /**\n   * GET: /api/items/:id\n   * Optional query params:\n   * ?include=progress,rssfeed,downloads,share\n   * ?expanded=1\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    const includeEntities = (req.query.include || '').split(',')\n    if (req.query.expanded == 1) {\n      const item = req.libraryItem.toOldJSONExpanded()\n\n      // Include users media progress\n      if (includeEntities.includes('progress')) {\n        const episodeId = req.query.episode || null\n        item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)\n      }\n\n      if (includeEntities.includes('rssfeed')) {\n        const feedData = await RssFeedManager.findFeedForEntityId(item.id)\n        item.rssFeed = feedData?.toOldJSONMinified() || null\n      }\n\n      if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {\n        item.mediaItemShare = ShareManager.findByMediaItemId(item.media.id)\n      }\n\n      if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {\n        const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)\n        item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient())\n        if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {\n          item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]\n        }\n      }\n\n      return res.json(item)\n    }\n    res.json(req.libraryItem.toOldJSON())\n  }\n\n  /**\n   * DELETE: /api/items/:id\n   * Delete library item. Will delete from database and file system if hard delete is requested.\n   * Optional query params:\n   * ?hard=1\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    const hardDelete = req.query.hard == 1 // Delete from file system\n    const libraryItemPath = req.libraryItem.path\n\n    const mediaItemIds = []\n    const authorIds = []\n    const seriesIds = []\n    if (req.libraryItem.isPodcast) {\n      mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.map((ep) => ep.id))\n    } else {\n      mediaItemIds.push(req.libraryItem.media.id)\n      if (req.libraryItem.media.authors?.length) {\n        authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))\n      }\n      if (req.libraryItem.media.series?.length) {\n        seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))\n      }\n    }\n\n    await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)\n    if (hardDelete) {\n      Logger.info(`[LibraryItemController] Deleting library item from file system at \"${libraryItemPath}\"`)\n      await fs.remove(libraryItemPath).catch((error) => {\n        Logger.error(`[LibraryItemController] Failed to delete library item from file system at \"${libraryItemPath}\"`, error)\n      })\n    }\n\n    if (authorIds.length) {\n      await this.checkRemoveAuthorsWithNoBooks(authorIds)\n    }\n    if (seriesIds.length) {\n      await this.checkRemoveEmptySeries(seriesIds)\n    }\n\n    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)\n    res.sendStatus(200)\n  }\n\n  static handleDownloadError(error, res) {\n    if (!res.headersSent) {\n      if (error.code === 'ENOENT') {\n        return res.status(404).send('File not found')\n      } else {\n        return res.status(500).send('Download failed')\n      }\n    }\n  }\n\n  /**\n   * GET: /api/items/:id/download\n   * Download library item. Zip file if multiple files.\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async download(req, res) {\n    if (!req.user.canDownload) {\n      Logger.warn(`User \"${req.user.username}\" attempted to download without permission`)\n      return res.sendStatus(403)\n    }\n    const libraryItemPath = req.libraryItem.path\n    const itemTitle = req.libraryItem.media.title\n\n    Logger.info(`[LibraryItemController] User \"${req.user.username}\" requested download for item \"${itemTitle}\" at \"${libraryItemPath}\"`)\n\n    try {\n      // If library item is a single file in root dir then no need to zip\n      if (req.libraryItem.isFile) {\n        // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available\n        const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))\n        if (audioMimeType) {\n          res.setHeader('Content-Type', audioMimeType)\n        }\n        await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve())))\n      } else {\n        const filename = `${itemTitle}.zip`\n        await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)\n      }\n      Logger.info(`[LibraryItemController] Downloaded item \"${itemTitle}\" at \"${libraryItemPath}\"`)\n    } catch (error) {\n      Logger.error(`[LibraryItemController] Download failed for item \"${itemTitle}\" at \"${libraryItemPath}\"`, error)\n      LibraryItemController.handleDownloadError(error, res)\n    }\n  }\n\n  /**\n   * PATCH: /items/:id/media\n   * Update media for a library item. Will create new authors & series when necessary\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async updateMedia(req, res) {\n    const mediaPayload = req.body\n\n    if (mediaPayload.url) {\n      await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false)\n      if (res.writableEnded || res.headersSent) return\n    }\n\n    // Podcast specific\n    let isPodcastAutoDownloadUpdated = false\n    if (req.libraryItem.isPodcast) {\n      if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {\n        isPodcastAutoDownloadUpdated = true\n      } else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {\n        isPodcastAutoDownloadUpdated = true\n      }\n    }\n\n    let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url\n\n    if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {\n      const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)\n      if (seriesUpdateData?.seriesRemoved.length) {\n        // Check remove empty series\n        Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)\n        await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))\n      }\n      if (seriesUpdateData?.seriesAdded.length) {\n        // Add series to filter data\n        seriesUpdateData.seriesAdded.forEach((se) => {\n          Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)\n        })\n      }\n      if (seriesUpdateData?.hasUpdates) {\n        hasUpdates = true\n      }\n    }\n\n    if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {\n      const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)\n      const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)\n      if (authorUpdateData?.authorsRemoved.length) {\n        // Check remove empty authors\n        Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)\n        await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))\n        hasUpdates = true\n      }\n      if (authorUpdateData?.authorsAdded.length) {\n        // Add authors to filter data\n        authorUpdateData.authorsAdded.forEach((au) => {\n          Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)\n        })\n        hasUpdates = true\n      }\n    }\n\n    if (hasUpdates) {\n      req.libraryItem.changed('updatedAt', true)\n      await req.libraryItem.save()\n\n      await req.libraryItem.saveMetadataFile()\n\n      if (isPodcastAutoDownloadUpdated) {\n        this.cronManager.checkUpdatePodcastCron(req.libraryItem)\n      }\n\n      Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)\n      SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    }\n    res.json({\n      updated: hasUpdates,\n      libraryItem: req.libraryItem.toOldJSON()\n    })\n  }\n\n  /**\n   * POST: /api/items/:id/cover\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   * @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally\n   */\n  async uploadCover(req, res, updateAndReturnJson = true) {\n    if (!req.user.canUpload) {\n      Logger.warn(`User \"${req.user.username}\" attempted to upload a cover without permission`)\n      return res.sendStatus(403)\n    }\n\n    let result = null\n    if (req.body?.url) {\n      Logger.debug(`[LibraryItemController] Requesting download cover from url \"${req.body.url}\"`)\n      result = await CoverManager.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path)\n    } else if (req.files?.cover) {\n      Logger.debug(`[LibraryItemController] Handling uploaded cover`)\n      result = await CoverManager.uploadCover(req.libraryItem, req.files.cover)\n    } else {\n      return res.status(400).send('Invalid request no file or url')\n    }\n\n    if (result?.error) {\n      return res.status(400).send(result.error)\n    } else if (!result?.cover) {\n      return res.status(500).send('Unknown error occurred')\n    }\n\n    req.libraryItem.media.coverPath = result.cover\n    req.libraryItem.media.changed('coverPath', true)\n    await req.libraryItem.media.save()\n\n    if (updateAndReturnJson) {\n      // client uses updatedAt timestamp in URL to force refresh cover\n      req.libraryItem.changed('updatedAt', true)\n      await req.libraryItem.save()\n\n      SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n      res.json({\n        success: true,\n        cover: result.cover\n      })\n    }\n  }\n\n  /**\n   * PATCH: /api/items/:id/cover\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async updateCover(req, res) {\n    if (!req.body.cover) {\n      return res.status(400).send('Invalid request no cover path')\n    }\n\n    const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)\n    if (validationResult.error) {\n      return res.status(500).send(validationResult.error)\n    }\n    if (validationResult.updated) {\n      req.libraryItem.media.coverPath = validationResult.cover\n      req.libraryItem.media.changed('coverPath', true)\n      await req.libraryItem.media.save()\n\n      // client uses updatedAt timestamp in URL to force refresh cover\n      req.libraryItem.changed('updatedAt', true)\n      await req.libraryItem.save()\n\n      SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    }\n    res.json({\n      success: true,\n      cover: validationResult.cover\n    })\n  }\n\n  /**\n   * DELETE: /api/items/:id/cover\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async removeCover(req, res) {\n    if (req.libraryItem.media.coverPath) {\n      req.libraryItem.media.coverPath = null\n      req.libraryItem.media.changed('coverPath', true)\n      await req.libraryItem.media.save()\n\n      // client uses updatedAt timestamp in URL to force refresh cover\n      req.libraryItem.changed('updatedAt', true)\n      await req.libraryItem.save()\n\n      await CacheManager.purgeCoverCache(req.libraryItem.id)\n\n      SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    }\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/items/:id/cover\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async getCover(req, res) {\n    const {\n      query: { width, height, format, raw }\n    } = req\n\n    if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400')\n\n    const libraryItemId = req.params.id\n    if (!libraryItemId) {\n      return res.sendStatus(400)\n    }\n\n    if (raw) {\n      const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)\n      if (!coverPath || !(await fs.pathExists(coverPath))) {\n        return res.sendStatus(404)\n      }\n      // any value\n      if (global.XAccel) {\n        const encodedURI = encodeUriPath(global.XAccel + coverPath)\n        Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n        return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n      }\n      return res.sendFile(coverPath)\n    }\n\n    const options = {\n      format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),\n      height: height ? parseInt(height) : null,\n      width: width ? parseInt(width) : null\n    }\n    return CacheManager.handleCoverCache(res, libraryItemId, options)\n  }\n\n  /**\n   * POST: /api/items/:id/play\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  startPlaybackSession(req, res) {\n    if (!req.libraryItem.hasAudioTracks) {\n      Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)\n      return res.sendStatus(404)\n    }\n\n    this.playbackSessionManager.startSessionRequest(req, res, null)\n  }\n\n  /**\n   * POST: /api/items/:id/play/:episodeId\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  startEpisodePlaybackSession(req, res) {\n    if (!req.libraryItem.isPodcast) {\n      Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)\n      return res.sendStatus(400)\n    }\n\n    const episodeId = req.params.episodeId\n    if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {\n      Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)\n      return res.sendStatus(404)\n    }\n\n    this.playbackSessionManager.startSessionRequest(req, res, episodeId)\n  }\n\n  /**\n   * PATCH: /api/items/:id/tracks\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async updateTracks(req, res) {\n    const orderedFileData = req.body?.orderedFileData\n\n    if (!req.libraryItem.isBook) {\n      Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)\n      return res.sendStatus(400)\n    }\n    if (!Array.isArray(orderedFileData) || !orderedFileData.length) {\n      Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)\n      return res.sendStatus(400)\n    }\n    // Ensure that each orderedFileData has a valid ino and is in the book audioFiles\n    if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) {\n      Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)\n      return res.sendStatus(400)\n    }\n\n    let index = 1\n    const updatedAudioFiles = orderedFileData.map((fileData) => {\n      const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino)\n      audioFile.manuallyVerified = true\n      audioFile.exclude = !!fileData.exclude\n      if (audioFile.exclude) {\n        audioFile.index = -1\n      } else {\n        audioFile.index = index++\n      }\n      return audioFile\n    })\n    updatedAudioFiles.sort((a, b) => a.index - b.index)\n\n    req.libraryItem.media.audioFiles = updatedAudioFiles\n    req.libraryItem.media.changed('audioFiles', true)\n    await req.libraryItem.media.save()\n\n    SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    res.json(req.libraryItem.toOldJSON())\n  }\n\n  /**\n   * POST /api/items/:id/match\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async match(req, res) {\n    const reqBody = req.body || {}\n\n    const options = {}\n    const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin']\n    for (const key of matchOptions) {\n      if (reqBody[key] && typeof reqBody[key] === 'string') {\n        options[key] = reqBody[key]\n      }\n    }\n    if (reqBody.overrideCover !== undefined) {\n      options.overrideCover = !!reqBody.overrideCover\n    }\n    if (reqBody.overrideDetails !== undefined) {\n      options.overrideDetails = !!reqBody.overrideDetails\n    }\n\n    const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)\n    res.json(matchResult)\n  }\n\n  /**\n   * POST: /api/items/batch/delete\n   * Batch delete library items. Will delete from database and file system if hard delete is requested.\n   * Optional query params:\n   * ?hard=1\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchDelete(req, res) {\n    if (!req.user.canDelete) {\n      Logger.warn(`[LibraryItemController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    }\n    const hardDelete = req.query.hard == 1 // Delete files from filesystem\n\n    const { libraryItemIds } = req.body\n    if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {\n      return res.status(400).send('Invalid request body')\n    }\n\n    const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({\n      id: libraryItemIds\n    })\n\n    if (!itemsToDelete.length) {\n      return res.sendStatus(404)\n    }\n\n    const libraryId = itemsToDelete[0].libraryId\n    for (const libraryItem of itemsToDelete) {\n      const libraryItemPath = libraryItem.path\n      Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item \"${libraryItem.media.title}\" with id \"${libraryItem.id}\"`)\n      const mediaItemIds = []\n      const seriesIds = []\n      const authorIds = []\n      if (libraryItem.isPodcast) {\n        mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id))\n      } else {\n        mediaItemIds.push(libraryItem.media.id)\n        if (libraryItem.media.series?.length) {\n          seriesIds.push(...libraryItem.media.series.map((se) => se.id))\n        }\n        if (libraryItem.media.authors?.length) {\n          authorIds.push(...libraryItem.media.authors.map((au) => au.id))\n        }\n      }\n      await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)\n      if (hardDelete) {\n        Logger.info(`[LibraryItemController] Deleting library item from file system at \"${libraryItemPath}\"`)\n        await fs.remove(libraryItemPath).catch((error) => {\n          Logger.error(`[LibraryItemController] Failed to delete library item from file system at \"${libraryItemPath}\"`, error)\n        })\n      }\n      if (seriesIds.length) {\n        await this.checkRemoveEmptySeries(seriesIds)\n      }\n      if (authorIds.length) {\n        await this.checkRemoveAuthorsWithNoBooks(authorIds)\n      }\n    }\n\n    await Database.resetLibraryIssuesFilterData(libraryId)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/items/batch/update\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchUpdate(req, res) {\n    const updatePayloads = req.body\n    if (!Array.isArray(updatePayloads) || !updatePayloads.length) {\n      Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)\n      return res.sendStatus(400)\n    }\n\n    // Ensure that each update payload has a unique library item id\n    const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]\n    if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {\n      Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)\n      return res.sendStatus(400)\n    }\n\n    // Get all library items to update\n    const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({\n      id: libraryItemIds\n    })\n    if (updatePayloads.length !== libraryItems.length) {\n      Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)\n      return res.sendStatus(404)\n    }\n\n    let itemsUpdated = 0\n\n    const seriesIdsRemoved = []\n    const authorIdsRemoved = []\n\n    for (const updatePayload of updatePayloads) {\n      const mediaPayload = updatePayload.mediaPayload\n      const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)\n\n      let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)\n\n      if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {\n        const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)\n        if (seriesUpdateData?.seriesRemoved.length) {\n          seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id))\n        }\n        if (seriesUpdateData?.seriesAdded.length) {\n          seriesUpdateData.seriesAdded.forEach((se) => {\n            Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)\n          })\n        }\n        if (seriesUpdateData?.hasUpdates) {\n          hasUpdates = true\n        }\n      }\n\n      if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {\n        const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)\n        const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)\n        if (authorUpdateData?.authorsRemoved.length) {\n          authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))\n          hasUpdates = true\n        }\n        if (authorUpdateData?.authorsAdded.length) {\n          authorUpdateData.authorsAdded.forEach((au) => {\n            Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)\n          })\n          hasUpdates = true\n        }\n      }\n\n      if (hasUpdates) {\n        libraryItem.changed('updatedAt', true)\n        await libraryItem.save()\n\n        await libraryItem.saveMetadataFile()\n\n        Logger.debug(`[LibraryItemController] Updated library item media \"${libraryItem.media.title}\"`)\n        SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n        itemsUpdated++\n      }\n    }\n\n    if (seriesIdsRemoved.length) {\n      await this.checkRemoveEmptySeries(seriesIdsRemoved)\n    }\n    if (authorIdsRemoved.length) {\n      await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)\n    }\n\n    res.json({\n      success: true,\n      updates: itemsUpdated\n    })\n  }\n\n  /**\n   * POST: /api/items/batch/get\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchGet(req, res) {\n    const libraryItemIds = req.body.libraryItemIds || []\n    if (!libraryItemIds.length) {\n      return res.status(403).send('Invalid payload')\n    }\n    const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({\n      id: libraryItemIds\n    })\n    res.json({\n      libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())\n    })\n  }\n\n  /**\n   * POST: /api/items/batch/quickmatch\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchQuickMatch(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.warn(`Non-admin user \"${req.user.username}\" other than admin attempted to batch quick match library items`)\n      return res.sendStatus(403)\n    }\n\n    let itemsUpdated = 0\n    let itemsUnmatched = 0\n\n    if (!req.body.libraryItemIds?.length) {\n      return res.sendStatus(400)\n    }\n\n    const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({\n      id: req.body.libraryItemIds\n    })\n    if (!libraryItems?.length) {\n      return res.sendStatus(400)\n    }\n\n    res.sendStatus(200)\n\n    const reqBodyOptions = req.body.options || {}\n    const options = {}\n    if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') {\n      options.provider = reqBodyOptions.provider\n    }\n    if (reqBodyOptions.overrideCover !== undefined) {\n      options.overrideCover = !!reqBodyOptions.overrideCover\n    }\n    if (reqBodyOptions.overrideDetails !== undefined) {\n      options.overrideDetails = !!reqBodyOptions.overrideDetails\n    }\n\n    for (const libraryItem of libraryItems) {\n      const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)\n      if (matchResult.updated) {\n        itemsUpdated++\n      } else if (matchResult.warning) {\n        itemsUnmatched++\n      }\n    }\n\n    const result = {\n      success: itemsUpdated > 0,\n      updates: itemsUpdated,\n      unmatched: itemsUnmatched\n    }\n    SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)\n  }\n\n  /**\n   * POST: /api/items/batch/scan\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchScan(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.warn(`Non-admin user \"${req.user.username}\" other than admin attempted to batch scan library items`)\n      return res.sendStatus(403)\n    }\n\n    if (!req.body.libraryItemIds?.length) {\n      return res.sendStatus(400)\n    }\n\n    const libraryItems = await Database.libraryItemModel.findAll({\n      where: {\n        id: req.body.libraryItemIds\n      },\n      attributes: ['id', 'libraryId', 'isFile']\n    })\n    if (!libraryItems?.length) {\n      return res.sendStatus(400)\n    }\n\n    res.sendStatus(200)\n\n    const libraryId = libraryItems[0].libraryId\n    for (const libraryItem of libraryItems) {\n      if (libraryItem.isFile) {\n        Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)\n      } else {\n        await LibraryItemScanner.scanLibraryItem(libraryItem.id)\n      }\n    }\n\n    await Database.resetLibraryIssuesFilterData(libraryId)\n  }\n\n  /**\n   * POST: /api/items/:id/scan\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async scan(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryItemController] Non-admin user \"${req.user.username}\" attempted to scan library item`)\n      return res.sendStatus(403)\n    }\n\n    if (req.libraryItem.isFile) {\n      Logger.error(`[LibraryItemController] Re-scanning file library items not yet supported`)\n      return res.sendStatus(500)\n    }\n\n    const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id)\n    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)\n    res.json({\n      result: Object.keys(ScanResult).find((key) => ScanResult[key] == result)\n    })\n  }\n\n  /**\n   * GET: /api/items/:id/metadata-object\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  getMetadataObject(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryItemController] Non-admin user \"${req.user.username}\" attempted to get metadata object`)\n      return res.sendStatus(403)\n    }\n\n    if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {\n      Logger.error(`[LibraryItemController] getMetadataObject: Invalid library item \"${req.libraryItem.media.title}\"`)\n      return res.sendStatus(400)\n    }\n\n    res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))\n  }\n\n  /**\n   * POST: /api/items/:id/chapters\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async updateMediaChapters(req, res) {\n    if (!req.user.canUpdate) {\n      Logger.error(`[LibraryItemController] User \"${req.user.username}\" attempted to update chapters with invalid permissions`)\n      return res.sendStatus(403)\n    }\n\n    if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) {\n      Logger.error(`[LibraryItemController] Invalid library item`)\n      return res.sendStatus(500)\n    }\n\n    if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) {\n      Logger.error(`[LibraryItemController] Invalid payload`)\n      return res.sendStatus(400)\n    }\n\n    const chapters = req.body.chapters || []\n\n    let hasUpdates = false\n    if (chapters.length !== req.libraryItem.media.chapters.length) {\n      req.libraryItem.media.chapters = chapters.map((c, index) => {\n        return {\n          id: index,\n          title: c.title,\n          start: c.start,\n          end: c.end\n        }\n      })\n      hasUpdates = true\n    } else {\n      for (const [index, chapter] of chapters.entries()) {\n        const currentChapter = req.libraryItem.media.chapters[index]\n        if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) {\n          currentChapter.title = chapter.title\n          currentChapter.start = chapter.start\n          currentChapter.end = chapter.end\n          hasUpdates = true\n        }\n      }\n    }\n\n    if (hasUpdates) {\n      req.libraryItem.media.changed('chapters', true)\n      await req.libraryItem.media.save()\n\n      await req.libraryItem.saveMetadataFile()\n\n      SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    }\n\n    res.json({\n      success: true,\n      updated: hasUpdates\n    })\n  }\n\n  /**\n   * GET: /api/items/:id/ffprobe/:fileid\n   * FFProbe JSON result from audio file\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async getFFprobeData(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryItemController] Non-admin user \"${req.user.username}\" attempted to get ffprobe data`)\n      return res.sendStatus(403)\n    }\n\n    const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid)\n    if (!audioFile) {\n      Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)\n      return res.sendStatus(404)\n    }\n\n    const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)\n    res.json(ffprobeData)\n  }\n\n  /**\n   * GET api/items/:id/file/:fileid\n   *\n   * @param {LibraryItemControllerRequestWithFile} req\n   * @param {Response} res\n   */\n  async getLibraryFile(req, res) {\n    const libraryFile = req.libraryFile\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available\n    const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))\n    if (audioMimeType) {\n      res.setHeader('Content-Type', audioMimeType)\n    }\n    res.sendFile(libraryFile.metadata.path)\n  }\n\n  /**\n   * DELETE api/items/:id/file/:fileid\n   *\n   * @param {LibraryItemControllerRequestWithFile} req\n   * @param {Response} res\n   */\n  async deleteLibraryFile(req, res) {\n    const libraryFile = req.libraryFile\n\n    Logger.info(`[LibraryItemController] User \"${req.user.username}\" requested file delete at \"${libraryFile.metadata.path}\"`)\n\n    await fs.remove(libraryFile.metadata.path).catch((error) => {\n      Logger.error(`[LibraryItemController] Failed to delete library file at \"${libraryFile.metadata.path}\"`, error)\n    })\n\n    req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)\n    req.libraryItem.changed('libraryFiles', true)\n\n    if (req.libraryItem.isBook) {\n      if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) {\n        req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid)\n        req.libraryItem.media.changed('audioFiles', true)\n      } else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) {\n        req.libraryItem.media.ebookFile = null\n        req.libraryItem.media.changed('ebookFile', true)\n      }\n      if (!req.libraryItem.media.hasMediaFiles) {\n        req.libraryItem.isMissing = true\n      }\n    } else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) {\n      const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid)\n      // Remove episode from all playlists\n      await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])\n\n      // Remove episode media progress\n      const numProgressRemoved = await Database.mediaProgressModel.destroy({\n        where: {\n          mediaItemId: episodeToRemove.id\n        }\n      })\n      if (numProgressRemoved > 0) {\n        Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`)\n      }\n\n      // Remove episode\n      await episodeToRemove.destroy()\n\n      req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)\n    }\n\n    if (req.libraryItem.media.changed()) {\n      await req.libraryItem.media.save()\n    }\n\n    await req.libraryItem.save()\n\n    SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET api/items/:id/file/:fileid/download\n   * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads\n   *\n   * @param {LibraryItemControllerRequestWithFile} req\n   * @param {Response} res\n   */\n  async downloadLibraryFile(req, res) {\n    const libraryFile = req.libraryFile\n    const ua = uaParserJs(req.headers['user-agent'])\n\n    if (!req.user.canDownload) {\n      Logger.error(`[LibraryItemController] User \"${req.user.username}\" without download permission attempted to download file \"${libraryFile.metadata.path}\"`)\n      return res.sendStatus(403)\n    }\n\n    Logger.info(`[LibraryItemController] User \"${req.user.username}\" requested download for item \"${req.libraryItem.media.title}\" file at \"${libraryFile.metadata.path}\"`)\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available\n    let audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))\n    if (audioMimeType) {\n      // Work-around for Apple devices mishandling Content-Type on mobile browsers:\n      // https://github.com/advplyr/audiobookshelf/issues/3310\n      // We actually need to check for Webkit on Apple mobile devices because this issue impacts all browsers on iOS/iPadOS/etc, not just Safari.\n      const isAppleMobileBrowser = ua.device.vendor === 'Apple' && ua.device.type === 'mobile' && ua.engine.name === 'WebKit'\n      if (isAppleMobileBrowser && audioMimeType === AudioMimeType.M4B) {\n        audioMimeType = 'audio/m4b'\n      }\n      res.setHeader('Content-Type', audioMimeType)\n    }\n\n    try {\n      await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve())))\n      Logger.info(`[LibraryItemController] Downloaded file \"${libraryFile.metadata.path}\"`)\n    } catch (error) {\n      Logger.error(`[LibraryItemController] Failed to download file \"${libraryFile.metadata.path}\"`, error)\n      LibraryItemController.handleDownloadError(error, res)\n    }\n  }\n\n  /**\n   * GET api/items/:id/ebook/:fileid?\n   * fileid is the inode value stored in LibraryFile.ino or EBookFile.ino\n   * fileid is only required when reading a supplementary ebook\n   * when no fileid is passed in the primary ebook will be returned\n   *\n   * @param {LibraryItemControllerRequest} req\n   * @param {Response} res\n   */\n  async getEBookFile(req, res) {\n    let ebookFile = null\n    if (req.params.fileid) {\n      ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)\n      if (!ebookFile?.isEBookFile) {\n        Logger.error(`[LibraryItemController] Invalid ebook file id \"${req.params.fileid}\"`)\n        return res.status(400).send('Invalid ebook file id')\n      }\n    } else {\n      ebookFile = req.libraryItem.media.ebookFile\n    }\n\n    if (!ebookFile) {\n      Logger.error(`[LibraryItemController] No ebookFile for library item \"${req.libraryItem.media.title}\"`)\n      return res.sendStatus(404)\n    }\n    const ebookFilePath = ebookFile.metadata.path\n\n    Logger.info(`[LibraryItemController] User \"${req.user.username}\" requested download for item \"${req.libraryItem.media.title}\" ebook at \"${ebookFilePath}\"`)\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    try {\n      await new Promise((resolve, reject) => res.sendFile(ebookFilePath, (error) => (error ? reject(error) : resolve())))\n      Logger.info(`[LibraryItemController] Downloaded ebook file \"${ebookFilePath}\"`)\n    } catch (error) {\n      Logger.error(`[LibraryItemController] Failed to download ebook file \"${ebookFilePath}\"`, error)\n      LibraryItemController.handleDownloadError(error, res)\n    }\n  }\n\n  /**\n   * PATCH api/items/:id/ebook/:fileid/status\n   * toggle the status of an ebook file.\n   * if an ebook file is the primary ebook, then it will be changed to supplementary\n   * if an ebook file is supplementary, then it will be changed to primary\n   *\n   * @param {LibraryItemControllerRequestWithFile} req\n   * @param {Response} res\n   */\n  async updateEbookFileStatus(req, res) {\n    if (!req.libraryItem.isBook) {\n      Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`)\n      return res.sendStatus(400)\n    }\n    if (!req.libraryFile?.isEBookFile) {\n      Logger.error(`[LibraryItemController] Invalid ebook file id \"${req.params.fileid}\"`)\n      return res.status(400).send('Invalid ebook file id')\n    }\n\n    const ebookLibraryFile = req.libraryFile\n    let primaryEbookFile = null\n\n    const ebookLibraryFileInos = req.libraryItem\n      .getLibraryFiles()\n      .filter((lf) => lf.isEBookFile)\n      .map((lf) => lf.ino)\n\n    if (ebookLibraryFile.isSupplementary) {\n      Logger.info(`[LibraryItemController] Updating ebook file \"${ebookLibraryFile.metadata.filename}\" to primary`)\n\n      primaryEbookFile = ebookLibraryFile.toJSON()\n      delete primaryEbookFile.isSupplementary\n      delete primaryEbookFile.fileType\n      primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format\n    } else {\n      Logger.info(`[LibraryItemController] Updating ebook file \"${ebookLibraryFile.metadata.filename}\" to supplementary`)\n    }\n\n    req.libraryItem.media.ebookFile = primaryEbookFile\n    req.libraryItem.media.changed('ebookFile', true)\n    await req.libraryItem.media.save()\n\n    req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {\n      if (ebookLibraryFileInos.includes(lf.ino)) {\n        lf.isSupplementary = lf.ino !== primaryEbookFile?.ino\n      }\n      return lf\n    })\n    req.libraryItem.changed('libraryFiles', true)\n\n    req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles\n\n    await req.libraryItem.save()\n\n    SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    res.sendStatus(200)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)\n    if (!req.libraryItem?.media) return res.sendStatus(404)\n\n    // Check user can access this library item\n    if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {\n      return res.sendStatus(403)\n    }\n\n    // For library file routes, get the library file\n    if (req.params.fileid) {\n      req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)\n      if (!req.libraryFile) {\n        Logger.error(`[LibraryItemController] Library file \"${req.params.fileid}\" does not exist for library item`)\n        return res.sendStatus(404)\n      }\n    }\n\n    if (req.path.includes('/play')) {\n      // allow POST requests using /play and /play/:episodeId\n    } else if (req.method == 'DELETE' && !req.user.canDelete) {\n      Logger.warn(`[LibraryItemController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {\n      Logger.warn(`[LibraryItemController] User \"${req.user.username}\" attempted to update without permission`)\n      return res.sendStatus(403)\n    }\n\n    next()\n  }\n}\nmodule.exports = new LibraryItemController()\n"
  },
  {
    "path": "server/controllers/MeController.js",
    "content": "const { Request, Response } = require('express')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst { sort } = require('../libs/fastSort')\nconst { toNumber, isNullOrNaN } = require('../utils/index')\nconst userStats = require('../utils/queries/userStats')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass MeController {\n  constructor() {}\n\n  /**\n   * GET: /api/me\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  getCurrentUser(req, res) {\n    res.json(req.user.toOldJSONForBrowser())\n  }\n\n  /**\n   * GET: /api/me/listening-sessions\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getListeningSessions(req, res) {\n    const listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)\n\n    const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10\n    const page = toNumber(req.query.page, 0)\n\n    const start = page * itemsPerPage\n    const sessions = listeningSessions.slice(start, start + itemsPerPage)\n\n    const payload = {\n      total: listeningSessions.length,\n      numPages: Math.ceil(listeningSessions.length / itemsPerPage),\n      page,\n      itemsPerPage,\n      sessions\n    }\n\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/me/item/listening-sessions/:libraryItemId/:episodeId\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getItemListeningSessions(req, res) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)\n    const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)\n\n    if (!libraryItem || (libraryItem.isPodcast && !episode)) {\n      Logger.error(`[MeController] Media item not found for library item id \"${req.params.libraryItemId}\"`)\n      return res.sendStatus(404)\n    }\n\n    // Check if user has access to this library item\n    if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n      Logger.error(`[MeController] User \"${req.user.username}\" attempted to access listening sessions for library item \"${req.params.libraryItemId}\" without access`)\n      return res.sendStatus(403)\n    }\n\n    const mediaItemId = episode?.id || libraryItem.mediaId\n    let listeningSessions = await this.getUserItemListeningSessionsHelper(req.user.id, mediaItemId)\n\n    const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10\n    const page = toNumber(req.query.page, 0)\n\n    const start = page * itemsPerPage\n    const sessions = listeningSessions.slice(start, start + itemsPerPage)\n\n    const payload = {\n      total: listeningSessions.length,\n      numPages: Math.ceil(listeningSessions.length / itemsPerPage),\n      page,\n      itemsPerPage,\n      sessions\n    }\n\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/me/listening-stats\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getListeningStats(req, res) {\n    const listeningStats = await this.getUserListeningStatsHelpers(req.user.id)\n    res.json(listeningStats)\n  }\n\n  /**\n   * GET: /api/me/progress/:id/:episodeId?\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getMediaProgress(req, res) {\n    const mediaProgress = req.user.getOldMediaProgress(req.params.id, req.params.episodeId || null)\n    if (!mediaProgress) {\n      return res.sendStatus(404)\n    }\n    res.json(mediaProgress)\n  }\n\n  /**\n   * DELETE: /api/me/progress/:id\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async removeMediaProgress(req, res) {\n    // Verify the media progress belongs to the current user\n    const mediaProgress = req.user.mediaProgresses.find((mp) => mp.id === req.params.id)\n    if (!mediaProgress) {\n      Logger.error(`[MeController] Media progress not found or does not belong to user \"${req.user.username}\"`)\n      return res.sendStatus(404)\n    }\n\n    await Database.mediaProgressModel.removeById(req.params.id)\n    req.user.mediaProgresses = req.user.mediaProgresses.filter((mp) => mp.id !== req.params.id)\n\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    res.sendStatus(200)\n  }\n\n  /**\n   * PATCH: /api/me/progress/:libraryItemId/:episodeId?\n   * TODO: Update to use mediaItemId and mediaItemType\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async createUpdateMediaProgress(req, res) {\n    const progressUpdatePayload = {\n      ...req.body,\n      libraryItemId: req.params.libraryItemId,\n      episodeId: req.params.episodeId\n    }\n    const mediaProgressResponse = await req.user.createUpdateMediaProgressFromPayload(progressUpdatePayload)\n    if (mediaProgressResponse.error) {\n      return res.status(mediaProgressResponse.statusCode || 400).send(mediaProgressResponse.error)\n    }\n\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    res.sendStatus(200)\n  }\n\n  /**\n   * PATCH: /api/me/progress/batch/update\n   * TODO: Update to use mediaItemId and mediaItemType\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchUpdateMediaProgress(req, res) {\n    const itemProgressPayloads = req.body\n    if (!itemProgressPayloads?.length) {\n      return res.status(400).send('Missing request payload')\n    }\n\n    let hasUpdated = false\n    for (const itemProgress of itemProgressPayloads) {\n      const mediaProgressResponse = await req.user.createUpdateMediaProgressFromPayload(itemProgress)\n      if (mediaProgressResponse.error) {\n        Logger.error(`[MeController] batchUpdateMediaProgress: ${mediaProgressResponse.error}`)\n        continue\n      } else {\n        hasUpdated = true\n      }\n    }\n\n    if (hasUpdated) {\n      SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    }\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/me/item/:id/bookmark\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async createBookmark(req, res) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)\n    if (!libraryItem) {\n      return res.sendStatus(404)\n    }\n\n    // Check if user has access to this library item\n    if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n      Logger.error(`[MeController] User \"${req.user.username}\" attempted to create bookmark for library item \"${req.params.id}\" without access`)\n      return res.sendStatus(403)\n    }\n\n    const { time, title } = req.body\n    if (isNullOrNaN(time)) {\n      Logger.error(`[MeController] createBookmark invalid time`, time)\n      return res.status(400).send('Invalid time')\n    }\n    if (!title || typeof title !== 'string') {\n      Logger.error(`[MeController] createBookmark invalid title`, title)\n      return res.status(400).send('Invalid title')\n    }\n\n    const bookmark = await req.user.createBookmark(req.params.id, time, title)\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    res.json(bookmark)\n  }\n\n  /**\n   * PATCH: /api/me/item/:id/bookmark\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateBookmark(req, res) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)\n    if (!libraryItem) {\n      return res.sendStatus(404)\n    }\n\n    // Check if user has access to this library item\n    if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n      Logger.error(`[MeController] User \"${req.user.username}\" attempted to update bookmark for library item \"${req.params.id}\" without access`)\n      return res.sendStatus(403)\n    }\n\n    const { time, title } = req.body\n    if (isNullOrNaN(time)) {\n      Logger.error(`[MeController] updateBookmark invalid time`, time)\n      return res.status(400).send('Invalid time')\n    }\n    if (!title || typeof title !== 'string') {\n      Logger.error(`[MeController] updateBookmark invalid title`, title)\n      return res.status(400).send('Invalid title')\n    }\n\n    const bookmark = await req.user.updateBookmark(req.params.id, time, title)\n    if (!bookmark) {\n      Logger.error(`[MeController] updateBookmark not found for library item id \"${req.params.id}\" and time \"${time}\"`)\n      return res.sendStatus(404)\n    }\n\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    res.json(bookmark)\n  }\n\n  /**\n   * DELETE: /api/me/item/:id/bookmark/:time\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async removeBookmark(req, res) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)\n    if (!libraryItem) {\n      return res.sendStatus(404)\n    }\n\n    // Check if user has access to this library item\n    if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n      Logger.error(`[MeController] User \"${req.user.username}\" attempted to remove bookmark for library item \"${req.params.id}\" without access`)\n      return res.sendStatus(403)\n    }\n\n    const time = Number(req.params.time)\n    if (isNaN(time)) {\n      return res.status(400).send('Invalid time')\n    }\n\n    if (!req.user.findBookmark(req.params.id, time)) {\n      Logger.error(`[MeController] removeBookmark not found`)\n      return res.sendStatus(404)\n    }\n\n    await req.user.removeBookmark(req.params.id, time)\n\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    res.sendStatus(200)\n  }\n\n  /**\n   * PATCH: /api/me/password\n   * User change password. Requires current password.\n   * Guest users cannot change password.\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updatePassword(req, res) {\n    if (req.user.isGuest) {\n      Logger.error(`[MeController] Guest user \"${req.user.username}\" attempted to change password`)\n      return res.sendStatus(403)\n    }\n\n    const { password, newPassword } = req.body\n    if ((typeof password !== 'string' && password !== null) || (typeof newPassword !== 'string' && newPassword !== null)) {\n      return res.status(400).send('Missing or invalid password or new password')\n    }\n\n    const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)\n\n    if (result.error) {\n      return res.status(400).send(result.error)\n    }\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/me/items-in-progress\n   * Pull items in progress for all libraries\n   * Used in Android Auto in progress list since there is no easy library selection\n   * TODO: Update to use mediaItemId and mediaItemType. Use sort & limit in query\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAllLibraryItemsInProgress(req, res) {\n    const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25\n\n    const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))\n\n    const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]\n    const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds })\n\n    let itemsInProgress = []\n\n    for (const mediaProgress of mediaProgressesInProgress) {\n      const oldMediaProgress = mediaProgress.getOldMediaProgress()\n      const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)\n      if (libraryItem) {\n        if (oldMediaProgress.episodeId && libraryItem.isPodcast) {\n          const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)\n          if (episode) {\n            const libraryItemWithEpisode = {\n              ...libraryItem.toOldJSONMinified(),\n              recentEpisode: episode.toOldJSON(libraryItem.id),\n              progressLastUpdate: oldMediaProgress.lastUpdate\n            }\n            itemsInProgress.push(libraryItemWithEpisode)\n          }\n        } else if (!oldMediaProgress.episodeId) {\n          itemsInProgress.push({\n            ...libraryItem.toOldJSONMinified(),\n            progressLastUpdate: oldMediaProgress.lastUpdate\n          })\n        }\n      }\n    }\n\n    itemsInProgress = sort(itemsInProgress)\n      .desc((li) => li.progressLastUpdate)\n      .slice(0, limit)\n    res.json({\n      libraryItems: itemsInProgress\n    })\n  }\n\n  /**\n   * GET: /api/me/series/:id/remove-from-continue-listening\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async removeSeriesFromContinueListening(req, res) {\n    if (!(await Database.seriesModel.checkExistsById(req.params.id))) {\n      Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)\n      return res.sendStatus(404)\n    }\n\n    const hasUpdated = await req.user.addSeriesToHideFromContinueListening(req.params.id)\n    if (hasUpdated) {\n      SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    }\n    res.json(req.user.toOldJSONForBrowser())\n  }\n\n  /**\n   * GET: api/me/series/:id/readd-to-continue-listening\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async readdSeriesFromContinueListening(req, res) {\n    if (!(await Database.seriesModel.checkExistsById(req.params.id))) {\n      Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)\n      return res.sendStatus(404)\n    }\n\n    const hasUpdated = await req.user.removeSeriesFromHideFromContinueListening(req.params.id)\n    if (hasUpdated) {\n      SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n    }\n    res.json(req.user.toOldJSONForBrowser())\n  }\n\n  /**\n   * GET: api/me/progress/:id/remove-from-continue-listening\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async removeItemFromContinueListening(req, res) {\n    const mediaProgress = req.user.mediaProgresses.find((mp) => mp.id === req.params.id)\n    if (!mediaProgress) {\n      return res.sendStatus(404)\n    }\n\n    // Already hidden\n    if (mediaProgress.hideFromContinueListening) {\n      return res.json(req.user.toOldJSONForBrowser())\n    }\n\n    mediaProgress.hideFromContinueListening = true\n    await mediaProgress.save()\n\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())\n\n    res.json(req.user.toOldJSONForBrowser())\n  }\n\n  /**\n   * POST: /api/me/ereader-devices\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateUserEReaderDevices(req, res) {\n    if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {\n      return res.status(400).send('Invalid payload. ereaderDevices array required')\n    }\n\n    const userEReaderDevices = req.body.ereaderDevices\n    for (const device of userEReaderDevices) {\n      if (!device.name || !device.email) {\n        return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')\n      } else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) {\n        return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption \"specificUsers\" and only the current user')\n      }\n    }\n\n    const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => {\n      return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1\n    })\n\n    const ereaderDevices = otherDevices.concat(userEReaderDevices)\n\n    // Check for duplicate names\n    const nameSet = new Set()\n    const hasDupes = ereaderDevices.some((device) => {\n      if (nameSet.has(device.name)) {\n        return true // Duplicate found\n      }\n      nameSet.add(device.name)\n      return false\n    })\n\n    if (hasDupes) {\n      return res.status(400).send('Invalid payload. Duplicate \"name\" field found.')\n    }\n\n    const updated = Database.emailSettings.update({ ereaderDevices })\n    if (updated) {\n      await Database.updateSetting(Database.emailSettings)\n      SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {\n        ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)\n      })\n    }\n    res.json({\n      ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)\n    })\n  }\n\n  /**\n   * GET: /api/me/stats/year/:year\n   *\n   * @param {import('express').Request} req\n   * @param {import('express').Response} res\n   */\n  async getStatsForYear(req, res) {\n    const year = Number(req.params.year)\n    if (isNaN(year) || year < 2000 || year > 9999) {\n      Logger.error(`[MeController] Invalid year \"${year}\"`)\n      return res.status(400).send('Invalid year')\n    }\n    const data = await userStats.getStatsForYear(req.user.id, year)\n    res.json(data)\n  }\n}\nmodule.exports = new MeController()\n"
  },
  {
    "path": "server/controllers/MiscController.js",
    "content": "const Sequelize = require('sequelize')\nconst Path = require('path')\nconst { Request, Response } = require('express')\nconst fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst Watcher = require('../Watcher')\n\nconst libraryItemFilters = require('../utils/queries/libraryItemFilters')\nconst patternValidation = require('../libs/nodeCron/pattern-validation')\nconst { isObject, getTitleIgnorePrefix } = require('../utils/index')\nconst { sanitizeFilename } = require('../utils/fileUtils')\n\nconst TaskManager = require('../managers/TaskManager')\nconst adminStats = require('../utils/queries/adminStats')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass MiscController {\n  constructor() {}\n\n  /**\n   * POST: /api/upload\n   * Update library item\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async handleUpload(req, res) {\n    if (!req.user.canUpload) {\n      Logger.warn(`User \"${req.user.username}\" attempted to upload without permission`)\n      return res.sendStatus(403)\n    }\n    if (!req.files || !Object.values(req.files).length) {\n      Logger.error('Invalid request, no files')\n      return res.sendStatus(400)\n    }\n\n    const files = Object.values(req.files)\n    let { title, author, series, folder: folderId, library: libraryId } = req.body\n    // Validate request body\n    if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {\n      return res.status(400).send('Invalid request body')\n    }\n    if (!series || typeof series !== 'string') {\n      series = null\n    }\n    if (!author || typeof author !== 'string') {\n      author = null\n    }\n\n    const library = await Database.libraryModel.findByIdWithFolders(libraryId)\n    if (!library) {\n      return res.status(404).send('Library not found')\n    }\n\n    if (!req.user.checkCanAccessLibrary(library.id)) {\n      Logger.error(`[MiscController] User \"${req.user.username}\" attempting to upload to library \"${library.id}\" without access`)\n      return res.sendStatus(403)\n    }\n\n    const folder = library.libraryFolders.find((fold) => fold.id === folderId)\n    if (!folder) {\n      return res.status(404).send('Folder not found')\n    }\n\n    // Podcasts should only be one folder deep\n    const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]\n    // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)\n    // before sanitizing all the directory parts to remove illegal chars and finally prepending\n    // the base folder path\n    const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))\n    const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])\n\n    await fs.ensureDir(outputDirectory)\n\n    Logger.info(`Uploading ${files.length} files to`, outputDirectory)\n\n    for (const file of files) {\n      const path = Path.join(outputDirectory, sanitizeFilename(file.name))\n\n      await file\n        .mv(path)\n        .then(() => {\n          return true\n        })\n        .catch((error) => {\n          Logger.error('Failed to move file', path, error)\n          return false\n        })\n    }\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/tasks\n   * Get tasks for task manager\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  getTasks(req, res) {\n    const includeArray = (req.query.include || '').split(',')\n\n    const data = {\n      tasks: TaskManager.tasks.map((t) => t.toJSON())\n    }\n\n    if (includeArray.includes('queue')) {\n      data.queuedTaskData = {\n        embedMetadata: this.audioMetadataManager.getQueuedTaskData()\n      }\n    }\n\n    res.json(data)\n  }\n\n  /**\n   * PATCH: /api/settings\n   * Update server settings\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateServerSettings(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`User \"${req.user.username}\" other than admin attempting to update server settings`)\n      return res.sendStatus(403)\n    }\n    const settingsUpdate = req.body\n    if (!isObject(settingsUpdate)) {\n      return res.status(400).send('Invalid settings update object')\n    }\n    if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {\n      Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')\n      return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')\n    }\n    if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {\n      return res.status(400).send('allowedOrigins must be an array')\n    }\n\n    const madeUpdates = Database.serverSettings.update(settingsUpdate)\n    if (madeUpdates) {\n      await Database.updateServerSettings()\n\n      // If backup schedule is updated - update backup manager\n      if (settingsUpdate.backupSchedule !== undefined) {\n        this.backupManager.updateCronSchedule()\n      }\n    }\n    return res.json({\n      serverSettings: Database.serverSettings.toJSONForBrowser()\n    })\n  }\n\n  /**\n   * PATCH: /api/sorting-prefixes\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateSortingPrefixes(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`User \"${req.user.username}\" other than admin attempting to update server sorting prefixes`)\n      return res.sendStatus(403)\n    }\n    let sortingPrefixes = req.body.sortingPrefixes\n    if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {\n      return res.status(400).send('Invalid request body')\n    }\n    sortingPrefixes = [...new Set(sortingPrefixes.map((p) => p?.trim?.().toLowerCase()).filter((p) => p))]\n    if (!sortingPrefixes.length) {\n      return res.status(400).send('Invalid sortingPrefixes in request body')\n    }\n\n    Logger.debug(`[MiscController] Updating sorting prefixes ${sortingPrefixes.join(', ')}`)\n    Database.serverSettings.sortingPrefixes = sortingPrefixes\n    await Database.updateServerSettings()\n\n    let rowsUpdated = 0\n    // Update titleIgnorePrefix column on books\n    const books = await Database.bookModel.findAll({\n      attributes: ['id', 'title', 'titleIgnorePrefix']\n    })\n    const bulkUpdateBooks = []\n    books.forEach((book) => {\n      const titleIgnorePrefix = getTitleIgnorePrefix(book.title)\n      if (titleIgnorePrefix !== book.titleIgnorePrefix) {\n        bulkUpdateBooks.push({\n          id: book.id,\n          titleIgnorePrefix\n        })\n      }\n    })\n    if (bulkUpdateBooks.length) {\n      Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdateBooks.length} books`)\n      rowsUpdated += bulkUpdateBooks.length\n      await Database.bookModel.bulkCreate(bulkUpdateBooks, {\n        updateOnDuplicate: ['titleIgnorePrefix']\n      })\n    }\n\n    // Update titleIgnorePrefix column on podcasts\n    const podcasts = await Database.podcastModel.findAll({\n      attributes: ['id', 'title', 'titleIgnorePrefix']\n    })\n    const bulkUpdatePodcasts = []\n    podcasts.forEach((podcast) => {\n      const titleIgnorePrefix = getTitleIgnorePrefix(podcast.title)\n      if (titleIgnorePrefix !== podcast.titleIgnorePrefix) {\n        bulkUpdatePodcasts.push({\n          id: podcast.id,\n          titleIgnorePrefix\n        })\n      }\n    })\n    if (bulkUpdatePodcasts.length) {\n      Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdatePodcasts.length} podcasts`)\n      rowsUpdated += bulkUpdatePodcasts.length\n      await Database.podcastModel.bulkCreate(bulkUpdatePodcasts, {\n        updateOnDuplicate: ['titleIgnorePrefix']\n      })\n    }\n\n    // Update nameIgnorePrefix column on series\n    const allSeries = await Database.seriesModel.findAll({\n      attributes: ['id', 'name', 'nameIgnorePrefix', 'libraryId']\n    })\n    const bulkUpdateSeries = []\n    allSeries.forEach((series) => {\n      const nameIgnorePrefix = getTitleIgnorePrefix(series.name)\n      if (nameIgnorePrefix !== series.nameIgnorePrefix) {\n        bulkUpdateSeries.push({\n          id: series.id,\n          name: series.name,\n          libraryId: series.libraryId,\n          nameIgnorePrefix\n        })\n      }\n    })\n    if (bulkUpdateSeries.length) {\n      Logger.info(`[MiscController] Updating nameIgnorePrefix on ${bulkUpdateSeries.length} series`)\n      rowsUpdated += bulkUpdateSeries.length\n      await Database.seriesModel.bulkCreate(bulkUpdateSeries, {\n        updateOnDuplicate: ['nameIgnorePrefix']\n      })\n    }\n\n    res.json({\n      rowsUpdated,\n      serverSettings: Database.serverSettings.toJSONForBrowser()\n    })\n  }\n\n  /**\n   * POST: /api/authorize\n   * Used to authorize an API token\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async authorize(req, res) {\n    const userResponse = await this.auth.getUserLoginResponsePayload(req.user)\n    res.json(userResponse)\n  }\n\n  /**\n   * GET: /api/tags\n   * Get all tags\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAllTags(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to getAllTags`)\n      return res.sendStatus(403)\n    }\n\n    const tags = []\n    const books = await Database.bookModel.findAll({\n      attributes: ['tags'],\n      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {\n        [Sequelize.Op.gt]: 0\n      })\n    })\n    for (const book of books) {\n      for (const tag of book.tags) {\n        if (!tags.includes(tag)) tags.push(tag)\n      }\n    }\n\n    const podcasts = await Database.podcastModel.findAll({\n      attributes: ['tags'],\n      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {\n        [Sequelize.Op.gt]: 0\n      })\n    })\n    for (const podcast of podcasts) {\n      for (const tag of podcast.tags) {\n        if (!tags.includes(tag)) tags.push(tag)\n      }\n    }\n\n    res.json({\n      tags: tags.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))\n    })\n  }\n\n  /**\n   * POST: /api/tags/rename\n   * Rename tag\n   * Req.body { tag, newTag }\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async renameTag(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to renameTag`)\n      return res.sendStatus(403)\n    }\n\n    const tag = req.body.tag\n    const newTag = req.body.newTag\n    if (!tag || !newTag) {\n      Logger.error(`[MiscController] Invalid request body for renameTag`)\n      return res.sendStatus(400)\n    }\n\n    let tagMerged = false\n    let numItemsUpdated = 0\n\n    // Update filter data\n    Database.replaceTagInFilterData(tag, newTag)\n\n    const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])\n    for (const libraryItem of libraryItemsWithTag) {\n      if (libraryItem.media.tags.includes(newTag)) {\n        tagMerged = true // new tag is an existing tag so this is a merge\n      }\n\n      if (libraryItem.media.tags.includes(tag)) {\n        libraryItem.media.tags = libraryItem.media.tags.filter((t) => t !== tag) // Remove old tag\n        if (!libraryItem.media.tags.includes(newTag)) {\n          libraryItem.media.tags.push(newTag)\n        }\n        Logger.debug(`[MiscController] Rename tag \"${tag}\" to \"${newTag}\" for item \"${libraryItem.media.title}\"`)\n        await libraryItem.media.update({\n          tags: libraryItem.media.tags\n        })\n        await libraryItem.saveMetadataFile()\n\n        SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n        numItemsUpdated++\n      }\n    }\n\n    res.json({\n      tagMerged,\n      numItemsUpdated\n    })\n  }\n\n  /**\n   * DELETE: /api/tags/:tag\n   * Remove a tag\n   * :tag param is base64 encoded\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async deleteTag(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to deleteTag`)\n      return res.sendStatus(403)\n    }\n\n    const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()\n\n    // Get all items with tag\n    const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])\n\n    // Update filterdata\n    Database.removeTagFromFilterData(tag)\n\n    let numItemsUpdated = 0\n    // Remove tag from items\n    for (const libraryItem of libraryItemsWithTag) {\n      Logger.debug(`[MiscController] Remove tag \"${tag}\" from item \"${libraryItem.media.title}\"`)\n      libraryItem.media.tags = libraryItem.media.tags.filter((t) => t !== tag)\n      await libraryItem.media.update({\n        tags: libraryItem.media.tags\n      })\n      await libraryItem.saveMetadataFile()\n\n      SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n      numItemsUpdated++\n    }\n\n    res.json({\n      numItemsUpdated\n    })\n  }\n\n  /**\n   * GET: /api/genres\n   * Get all genres\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAllGenres(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to getAllGenres`)\n      return res.sendStatus(403)\n    }\n    const genres = []\n    const books = await Database.bookModel.findAll({\n      attributes: ['genres'],\n      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {\n        [Sequelize.Op.gt]: 0\n      })\n    })\n    for (const book of books) {\n      for (const tag of book.genres) {\n        if (!genres.includes(tag)) genres.push(tag)\n      }\n    }\n\n    const podcasts = await Database.podcastModel.findAll({\n      attributes: ['genres'],\n      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {\n        [Sequelize.Op.gt]: 0\n      })\n    })\n    for (const podcast of podcasts) {\n      for (const tag of podcast.genres) {\n        if (!genres.includes(tag)) genres.push(tag)\n      }\n    }\n\n    res.json({\n      genres\n    })\n  }\n\n  /**\n   * POST: /api/genres/rename\n   * Rename genres\n   * Req.body { genre, newGenre }\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async renameGenre(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to renameGenre`)\n      return res.sendStatus(403)\n    }\n\n    const genre = req.body.genre\n    const newGenre = req.body.newGenre\n    if (!genre || !newGenre) {\n      Logger.error(`[MiscController] Invalid request body for renameGenre`)\n      return res.sendStatus(400)\n    }\n\n    let genreMerged = false\n    let numItemsUpdated = 0\n\n    // Update filter data\n    Database.replaceGenreInFilterData(genre, newGenre)\n\n    const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])\n    for (const libraryItem of libraryItemsWithGenre) {\n      if (libraryItem.media.genres.includes(newGenre)) {\n        genreMerged = true // new genre is an existing genre so this is a merge\n      }\n\n      if (libraryItem.media.genres.includes(genre)) {\n        libraryItem.media.genres = libraryItem.media.genres.filter((t) => t !== genre) // Remove old genre\n        if (!libraryItem.media.genres.includes(newGenre)) {\n          libraryItem.media.genres.push(newGenre)\n        }\n        Logger.debug(`[MiscController] Rename genre \"${genre}\" to \"${newGenre}\" for item \"${libraryItem.media.title}\"`)\n        await libraryItem.media.update({\n          genres: libraryItem.media.genres\n        })\n        await libraryItem.saveMetadataFile()\n\n        SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n        numItemsUpdated++\n      }\n    }\n\n    res.json({\n      genreMerged,\n      numItemsUpdated\n    })\n  }\n\n  /**\n   * DELETE: /api/genres/:genre\n   * Remove a genre\n   * :genre param is base64 encoded\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async deleteGenre(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to deleteGenre`)\n      return res.sendStatus(403)\n    }\n\n    const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()\n\n    // Update filter data\n    Database.removeGenreFromFilterData(genre)\n\n    // Get all items with genre\n    const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])\n\n    let numItemsUpdated = 0\n    // Remove genre from items\n    for (const libraryItem of libraryItemsWithGenre) {\n      Logger.debug(`[MiscController] Remove genre \"${genre}\" from item \"${libraryItem.media.title}\"`)\n      libraryItem.media.genres = libraryItem.media.genres.filter((g) => g !== genre)\n      await libraryItem.media.update({\n        genres: libraryItem.media.genres\n      })\n      await libraryItem.saveMetadataFile()\n\n      SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n      numItemsUpdated++\n    }\n\n    res.json({\n      numItemsUpdated\n    })\n  }\n\n  /**\n   * POST: /api/watcher/update\n   * Update a watch path\n   * Req.body { libraryId, path, type, [oldPath] }\n   * type = add, unlink, rename\n   * oldPath = required only for rename\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  updateWatchedPath(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to updateWatchedPath`)\n      return res.sendStatus(403)\n    }\n\n    const libraryId = req.body.libraryId\n    const path = req.body.path\n    const type = req.body.type\n    if (!libraryId || !path || !type) {\n      Logger.error(`[MiscController] Invalid request body for updateWatchedPath. libraryId: \"${libraryId}\", path: \"${path}\", type: \"${type}\"`)\n      return res.sendStatus(400)\n    }\n\n    switch (type) {\n      case 'add':\n        Watcher.onFileAdded(libraryId, path)\n        break\n      case 'unlink':\n        Watcher.onFileRemoved(libraryId, path)\n        break\n      case 'rename':\n        const oldPath = req.body.oldPath\n        if (!oldPath) {\n          Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`)\n          return res.sendStatus(400)\n        }\n        Watcher.onFileRename(libraryId, oldPath, path)\n        break\n      default:\n        Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: \"${type}\"`)\n        return res.sendStatus(400)\n    }\n\n    res.sendStatus(200)\n  }\n\n  validateCronExpression(req, res) {\n    const expression = req.body.expression\n    if (!expression) {\n      return res.sendStatus(400)\n    }\n\n    try {\n      patternValidation(expression)\n      res.sendStatus(200)\n    } catch (error) {\n      Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)\n      res.status(400).send(error.message)\n    }\n  }\n\n  /**\n   * GET: api/auth-settings (admin only)\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  getAuthSettings(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to get auth settings`)\n      return res.sendStatus(403)\n    }\n    return res.json(Database.serverSettings.authenticationSettings)\n  }\n\n  /**\n   * PATCH: api/auth-settings\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateAuthSettings(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to update auth settings`)\n      return res.sendStatus(403)\n    }\n\n    const settingsUpdate = req.body\n    if (!isObject(settingsUpdate)) {\n      return res.status(400).send('Invalid auth settings update object')\n    }\n\n    let hasUpdates = false\n\n    const currentAuthenticationSettings = Database.serverSettings.authenticationSettings\n    const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]\n\n    // TODO: Better validation of auth settings once auth settings are separated from server settings\n    for (const key in currentAuthenticationSettings) {\n      if (settingsUpdate[key] === undefined) continue\n\n      if (key === 'authActiveAuthMethods') {\n        let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))\n        if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {\n          updatedAuthMethods.sort()\n          currentAuthenticationSettings[key].sort()\n          if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {\n            Logger.debug(`[MiscController] Updating auth settings key \"authActiveAuthMethods\" from \"${currentAuthenticationSettings[key].join()}\" to \"${updatedAuthMethods.join()}\"`)\n            Database.serverSettings[key] = updatedAuthMethods\n            hasUpdates = true\n          }\n        } else {\n          Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)\n        }\n      } else if (key === 'authOpenIDMobileRedirectURIs') {\n        function isValidRedirectURI(uri) {\n          if (typeof uri !== 'string') return false\n          const pattern = new RegExp('^\\\\w+://[\\\\w\\\\.-]+(/[\\\\w\\\\./-]*)*$', 'i')\n          return pattern.test(uri)\n        }\n\n        const uris = settingsUpdate[key]\n        if (!Array.isArray(uris) || (uris.includes('*') && uris.length > 1) || uris.some((uri) => uri !== '*' && !isValidRedirectURI(uri))) {\n          Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)\n          continue\n        }\n\n        // Update the URIs\n        if (Database.serverSettings[key].some((uri) => !uris.includes(uri)) || uris.some((uri) => !Database.serverSettings[key].includes(uri))) {\n          Logger.debug(`[MiscController] Updating auth settings key \"${key}\" from \"${Database.serverSettings[key]}\" to \"${uris}\"`)\n          Database.serverSettings[key] = uris\n          hasUpdates = true\n        }\n      } else {\n        const updatedValueType = typeof settingsUpdate[key]\n        if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {\n          if (updatedValueType !== 'boolean') {\n            Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)\n            continue\n          }\n        } else if (settingsUpdate[key] !== null && updatedValueType !== 'string') {\n          Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`)\n          continue\n        }\n        let updatedValue = settingsUpdate[key]\n        if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null\n        let currentValue = currentAuthenticationSettings[key]\n        if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null\n\n        if (updatedValue !== currentValue) {\n          Logger.debug(`[MiscController] Updating auth settings key \"${key}\" from \"${currentValue}\" to \"${updatedValue}\"`)\n          Database.serverSettings[key] = updatedValue\n          hasUpdates = true\n        }\n      }\n    }\n\n    if (hasUpdates) {\n      await Database.updateServerSettings()\n\n      // Use/unuse auth methods\n      Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {\n        if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {\n          // Auth method has been removed\n          Logger.info(`[MiscController] Disabling active auth method \"${authMethod}\"`)\n          this.auth.unuseAuthStrategy(authMethod)\n        } else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {\n          // Auth method has been added\n          Logger.info(`[MiscController] Enabling active auth method \"${authMethod}\"`)\n          this.auth.useAuthStrategy(authMethod)\n        }\n      })\n    }\n\n    res.json({\n      updated: hasUpdates,\n      serverSettings: Database.serverSettings.toJSONForBrowser()\n    })\n  }\n\n  /**\n   * GET: /api/stats/year/:year\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAdminStatsForYear(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to get admin stats for year`)\n      return res.sendStatus(403)\n    }\n    const year = Number(req.params.year)\n    if (isNaN(year) || year < 2000 || year > 9999) {\n      Logger.error(`[MiscController] Invalid year \"${year}\"`)\n      return res.status(400).send('Invalid year')\n    }\n    const stats = await adminStats.getStatsForYear(year)\n    res.json(stats)\n  }\n\n  /**\n   * GET: /api/logger-data\n   * admin or up\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getLoggerData(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[MiscController] Non-admin user \"${req.user.username}\" attempted to get logger data`)\n      return res.sendStatus(403)\n    }\n\n    res.json({\n      currentDailyLogs: Logger.logManager.getMostRecentCurrentDailyLogs()\n    })\n  }\n}\nmodule.exports = new MiscController()\n"
  },
  {
    "path": "server/controllers/NotificationController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Database = require('../Database')\nconst { version } = require('../../package.json')\nconst NotificationManager = require('../managers/NotificationManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass NotificationController {\n  constructor() {}\n\n  /**\n   * GET: /api/notifications\n   * Get notifications, settings and data\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  get(req, res) {\n    res.json({\n      data: NotificationManager.getData(),\n      settings: Database.notificationSettings\n    })\n  }\n\n  /**\n   * PATCH: /api/notifications\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    const updated = Database.notificationSettings.update(req.body)\n    if (updated) {\n      await Database.updateSetting(Database.notificationSettings)\n    }\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/notificationdata\n   * @deprecated Use /api/notifications\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  getData(req, res) {\n    res.json(NotificationManager.getData())\n  }\n\n  /**\n   * GET: /api/notifications/test\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async fireTestEvent(req, res) {\n    await NotificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/notifications\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async createNotification(req, res) {\n    const success = Database.notificationSettings.createNotification(req.body)\n\n    if (success) {\n      await Database.updateSetting(Database.notificationSettings)\n    }\n    res.json(Database.notificationSettings)\n  }\n\n  /**\n   * DELETE: /api/notifications/:id\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async deleteNotification(req, res) {\n    if (Database.notificationSettings.removeNotification(req.notification.id)) {\n      await Database.updateSetting(Database.notificationSettings)\n    }\n    res.json(Database.notificationSettings)\n  }\n\n  /**\n   * PATCH: /api/notifications/:id\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async updateNotification(req, res) {\n    const success = Database.notificationSettings.updateNotification(req.body)\n    if (success) {\n      await Database.updateSetting(Database.notificationSettings)\n    }\n    res.json(Database.notificationSettings)\n  }\n\n  /**\n   * GET: /api/notifications/:id/test\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async sendNotificationTest(req, res) {\n    if (!Database.notificationSettings.isUseable) return res.status(400).send('Apprise is not configured')\n\n    const success = await NotificationManager.sendTestNotification(req.notification)\n    if (success) res.sendStatus(200)\n    else res.sendStatus(500)\n  }\n\n  /**\n   * Requires admin or up\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      return res.sendStatus(403)\n    }\n\n    if (req.params.id) {\n      const notification = Database.notificationSettings.getNotification(req.params.id)\n      if (!notification) {\n        return res.sendStatus(404)\n      }\n      req.notification = notification\n    }\n\n    next()\n  }\n}\nmodule.exports = new NotificationController()\n"
  },
  {
    "path": "server/controllers/PlaylistController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/Playlist')} playlist\n *\n * @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest\n */\n\nclass PlaylistController {\n  constructor() {}\n\n  /**\n   * POST: /api/playlists\n   * Create playlist\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    const reqBody = req.body || {}\n\n    // Validation\n    const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)\n    if (!nameCleaned || !reqBody.libraryId) {\n      return res.status(400).send('Invalid playlist data')\n    }\n    if (reqBody.description && typeof reqBody.description !== 'string') {\n      return res.status(400).send('Invalid playlist description')\n    }\n    const items = reqBody.items || []\n    const isPodcast = items.some((i) => i.episodeId)\n    const libraryItemIds = new Set()\n    for (const item of items) {\n      if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {\n        return res.status(400).send('Invalid playlist item')\n      }\n      if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {\n        return res.status(400).send('Invalid playlist item episodeId')\n      } else if (!isPodcast && item.episodeId) {\n        return res.status(400).send('Invalid playlist item episodeId')\n      }\n      libraryItemIds.add(item.libraryItemId)\n    }\n\n    // Load library items\n    const libraryItems = await Database.libraryItemModel.findAll({\n      attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],\n      where: {\n        id: Array.from(libraryItemIds),\n        libraryId: reqBody.libraryId,\n        mediaType: isPodcast ? 'podcast' : 'book'\n      }\n    })\n    if (libraryItems.length !== libraryItemIds.size) {\n      return res.status(400).send('Invalid playlist data. Invalid items')\n    }\n\n    // Validate podcast episodes\n    if (isPodcast) {\n      const podcastEpisodeIds = items.map((i) => i.episodeId)\n      const podcastEpisodes = await Database.podcastEpisodeModel.findAll({\n        attributes: ['id'],\n        where: {\n          id: podcastEpisodeIds\n        }\n      })\n      if (podcastEpisodes.length !== podcastEpisodeIds.length) {\n        return res.status(400).send('Invalid playlist data. Invalid podcast episodes')\n      }\n    }\n\n    const transaction = await Database.sequelize.transaction()\n    try {\n      // Create playlist\n      const newPlaylist = await Database.playlistModel.create(\n        {\n          libraryId: reqBody.libraryId,\n          userId: req.user.id,\n          name: nameCleaned,\n          description: reqBody.description || null\n        },\n        { transaction }\n      )\n\n      // Create playlistMediaItems\n      const playlistItemPayloads = []\n      for (const [index, item] of items.entries()) {\n        const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)\n        playlistItemPayloads.push({\n          playlistId: newPlaylist.id,\n          mediaItemId: item.episodeId || libraryItem.mediaId,\n          mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',\n          order: index + 1\n        })\n      }\n\n      await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })\n\n      await transaction.commit()\n\n      newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()\n\n      const jsonExpanded = newPlaylist.toOldJSONExpanded()\n      SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)\n      res.json(jsonExpanded)\n    } catch (error) {\n      await transaction.rollback()\n      Logger.error('[PlaylistController] create:', error)\n      res.status(500).send('Failed to create playlist')\n    }\n  }\n\n  /**\n   * @deprecated - Use /api/libraries/:libraryId/playlists\n   * This is not used by Abs web client or mobile apps\n   * TODO: Remove this endpoint or make it the primary\n   *\n   * GET: /api/playlists\n   * Get all playlists for user\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findAllForUser(req, res) {\n    const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)\n    res.json({\n      playlists: playlistsForUser\n    })\n  }\n\n  /**\n   * GET: /api/playlists/:id\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n    res.json(req.playlist.toOldJSONExpanded())\n  }\n\n  /**\n   * PATCH: /api/playlists/:id\n   * Update playlist\n   *\n   * Used for updating name and description or reordering items\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    // Validation\n    const reqBody = req.body || {}\n    if (reqBody.libraryId || reqBody.userId) {\n      // Could allow support for this if needed with additional validation\n      return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')\n    }\n    if (reqBody.name && typeof reqBody.name !== 'string') {\n      return res.status(400).send('Invalid playlist name')\n    }\n    if (reqBody.description && typeof reqBody.description !== 'string') {\n      return res.status(400).send('Invalid playlist description')\n    }\n    if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {\n      return res.status(400).send('Invalid playlist items')\n    }\n\n    const playlistUpdatePayload = {}\n\n    const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)\n    if (nameCleaned) {\n      playlistUpdatePayload.name = nameCleaned\n    }\n    if (reqBody.description) playlistUpdatePayload.description = reqBody.description\n\n    // Update name and description\n    let wasUpdated = false\n    if (Object.keys(playlistUpdatePayload).length) {\n      req.playlist.set(playlistUpdatePayload)\n      const changed = req.playlist.changed()\n      if (changed?.length) {\n        await req.playlist.save()\n        Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)\n        wasUpdated = true\n      }\n    }\n\n    // If array of items is set then update order of playlist media items\n    if (reqBody.items?.length) {\n      const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))\n      const libraryItems = await Database.libraryItemModel.findAll({\n        attributes: ['id', 'mediaId', 'mediaType'],\n        where: {\n          id: libraryItemIds\n        }\n      })\n      if (libraryItems.length !== libraryItemIds.length) {\n        return res.status(400).send('Invalid playlist items. Items not found')\n      }\n      /** @type {import('../models/PlaylistMediaItem')[]} */\n      const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({\n        order: [['order', 'ASC']]\n      })\n      if (existingPlaylistMediaItems.length !== reqBody.items.length) {\n        return res.status(400).send('Invalid playlist items. Length mismatch')\n      }\n\n      // Set an array of mediaItemId\n      const newMediaItemIdOrder = []\n      for (const item of reqBody.items) {\n        const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)\n        const mediaItemId = item.episodeId || libraryItem.mediaId\n        newMediaItemIdOrder.push(mediaItemId)\n      }\n\n      // Sort existing playlist media items into new order\n      existingPlaylistMediaItems.sort((a, b) => {\n        const aIndex = newMediaItemIdOrder.findIndex((i) => i === a.mediaItemId)\n        const bIndex = newMediaItemIdOrder.findIndex((i) => i === b.mediaItemId)\n        return aIndex - bIndex\n      })\n\n      // Update order on playlistMediaItem records\n      for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {\n        if (playlistMediaItem.order !== index + 1) {\n          await playlistMediaItem.update({\n            order: index + 1\n          })\n          wasUpdated = true\n        }\n      }\n    }\n\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n\n    const jsonExpanded = req.playlist.toOldJSONExpanded()\n    if (wasUpdated) {\n      SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)\n    }\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * DELETE: /api/playlists/:id\n   * Remove playlist\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n    const jsonExpanded = req.playlist.toOldJSONExpanded()\n\n    await req.playlist.destroy()\n    SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/playlists/:id/item\n   * Add item to playlist\n   *\n   * This is not used by Abs web client or mobile apps. Only the batch endpoints are used.\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async addItem(req, res) {\n    const itemToAdd = req.body || {}\n\n    if (!itemToAdd.libraryItemId) {\n      return res.status(400).send('Request body has no libraryItemId')\n    }\n\n    const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)\n    if (!libraryItem) {\n      return res.status(400).send('Library item not found')\n    }\n    if (libraryItem.libraryId !== req.playlist.libraryId) {\n      return res.status(400).send('Library item in different library')\n    }\n    if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {\n      return res.status(400).send('Invalid item to add for this library type')\n    }\n    if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) {\n      return res.status(400).send('Episode not found in library item')\n    }\n\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n\n    if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {\n      return res.status(400).send('Item already in playlist')\n    }\n\n    const jsonExpanded = req.playlist.toOldJSONExpanded()\n\n    const playlistMediaItem = {\n      playlistId: req.playlist.id,\n      mediaItemId: itemToAdd.episodeId || libraryItem.media.id,\n      mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',\n      order: req.playlist.playlistMediaItems.length + 1\n    }\n    await Database.playlistMediaItemModel.create(playlistMediaItem)\n\n    // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items\n    if (itemToAdd.episodeId) {\n      const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)\n      jsonExpanded.items.push({\n        episodeId: itemToAdd.episodeId,\n        episode: episode.toOldJSONExpanded(libraryItem.id),\n        libraryItemId: libraryItem.id,\n        libraryItem: libraryItem.toOldJSONMinified()\n      })\n    } else {\n      jsonExpanded.items.push({\n        libraryItemId: libraryItem.id,\n        libraryItem: libraryItem.toOldJSONExpanded()\n      })\n    }\n\n    SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?\n   * Remove item from playlist\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async removeItem(req, res) {\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n\n    let playlistMediaItem = null\n    if (req.params.episodeId) {\n      playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)\n    } else {\n      playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)\n    }\n    if (!playlistMediaItem) {\n      return res.status(404).send('Media item not found in playlist')\n    }\n\n    // Remove record\n    await playlistMediaItem.destroy()\n    req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)\n\n    // Update playlist media items order\n    for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {\n      if (mediaItem.order !== index + 1) {\n        await mediaItem.update({\n          order: index + 1\n        })\n      }\n    }\n\n    const jsonExpanded = req.playlist.toOldJSONExpanded()\n\n    // Playlist is removed when there are no items\n    if (!jsonExpanded.items.length) {\n      Logger.info(`[PlaylistController] Playlist \"${jsonExpanded.name}\" has no more items - removing it`)\n      await req.playlist.destroy()\n      SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)\n    } else {\n      SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)\n    }\n\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * POST: /api/playlists/:id/batch/add\n   * Batch add playlist items\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async addBatch(req, res) {\n    if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {\n      return res.status(400).send('Invalid request body items')\n    }\n\n    // Find all library items\n    const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))\n\n    const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })\n    if (libraryItems.length !== libraryItemIds.size) {\n      return res.status(400).send('Invalid request body items')\n    }\n\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n\n    const mediaItemsToAdd = []\n    const jsonExpanded = req.playlist.toOldJSONExpanded()\n\n    // Setup array of playlistMediaItem records to add\n    let order = req.playlist.playlistMediaItems.length + 1\n    for (const item of req.body.items) {\n      const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)\n\n      const mediaItemId = item.episodeId || libraryItem.media.id\n      if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {\n        // Already exists in playlist\n        continue\n      } else {\n        mediaItemsToAdd.push({\n          playlistId: req.playlist.id,\n          mediaItemId,\n          mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',\n          order: order++\n        })\n\n        // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items\n        if (item.episodeId) {\n          const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)\n          jsonExpanded.items.push({\n            episodeId: item.episodeId,\n            episode: episode.toOldJSONExpanded(libraryItem.id),\n            libraryItemId: libraryItem.id,\n            libraryItem: libraryItem.toOldJSONMinified()\n          })\n        } else {\n          jsonExpanded.items.push({\n            libraryItemId: libraryItem.id,\n            libraryItem: libraryItem.toOldJSONExpanded()\n          })\n        }\n      }\n    }\n\n    if (mediaItemsToAdd.length) {\n      await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)\n\n      SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)\n    }\n\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * POST: /api/playlists/:id/batch/remove\n   * Batch remove playlist items\n   *\n   * @param {PlaylistControllerRequest} req\n   * @param {Response} res\n   */\n  async removeBatch(req, res) {\n    if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {\n      return res.status(400).send('Invalid request body items')\n    }\n\n    req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()\n\n    // Remove playlist media items\n    let hasUpdated = false\n    for (const item of req.body.items) {\n      let playlistMediaItem = null\n      if (item.episodeId) {\n        playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)\n      } else {\n        playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)\n      }\n      if (!playlistMediaItem) {\n        Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)\n        continue\n      }\n\n      await playlistMediaItem.destroy()\n      req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)\n\n      hasUpdated = true\n    }\n\n    const jsonExpanded = req.playlist.toOldJSONExpanded()\n    if (hasUpdated) {\n      // Playlist is removed when there are no items\n      if (!req.playlist.playlistMediaItems.length) {\n        Logger.info(`[PlaylistController] Playlist \"${req.playlist.name}\" has no more items - removing it`)\n        await req.playlist.destroy()\n        SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)\n      } else {\n        SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)\n      }\n    }\n    res.json(jsonExpanded)\n  }\n\n  /**\n   * POST: /api/playlists/collection/:collectionId\n   * Create a playlist from a collection\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async createFromCollection(req, res) {\n    const collection = await Database.collectionModel.findByPk(req.params.collectionId)\n    if (!collection) {\n      return res.status(404).send('Collection not found')\n    }\n    // Expand collection to get library items\n    const collectionExpanded = await collection.getOldJsonExpanded(req.user)\n    if (!collectionExpanded) {\n      // This can happen if the user has no access to all items in collection\n      return res.status(404).send('Collection not found')\n    }\n\n    // Playlists cannot be empty\n    if (!collectionExpanded.books.length) {\n      return res.status(400).send('Collection has no books')\n    }\n\n    const transaction = await Database.sequelize.transaction()\n    try {\n      const playlist = await Database.playlistModel.create(\n        {\n          userId: req.user.id,\n          libraryId: collection.libraryId,\n          name: collection.name,\n          description: collection.description || null\n        },\n        { transaction }\n      )\n\n      const mediaItemsToAdd = []\n      for (const [index, libraryItem] of collectionExpanded.books.entries()) {\n        mediaItemsToAdd.push({\n          playlistId: playlist.id,\n          mediaItemId: libraryItem.media.id,\n          mediaItemType: 'book',\n          order: index + 1\n        })\n      }\n      await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })\n\n      await transaction.commit()\n\n      playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()\n\n      const jsonExpanded = playlist.toOldJSONExpanded()\n      SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)\n      res.json(jsonExpanded)\n    } catch (error) {\n      await transaction.rollback()\n      Logger.error('[PlaylistController] createFromCollection:', error)\n      res.status(500).send('Failed to create playlist')\n    }\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (req.params.id) {\n      const playlist = await Database.playlistModel.findByPk(req.params.id)\n      if (!playlist) {\n        return res.status(404).send('Playlist not found')\n      }\n      if (playlist.userId !== req.user.id) {\n        Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)\n        return res.sendStatus(403)\n      }\n      req.playlist = playlist\n    }\n\n    next()\n  }\n}\nmodule.exports = new PlaylistController()\n"
  },
  {
    "path": "server/controllers/PodcastController.js",
    "content": "const Path = require('path')\nconst { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst fs = require('../libs/fsExtra')\n\nconst { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')\nconst { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')\nconst { validateUrl } = require('../utils/index')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\nconst Scanner = require('../scanner/Scanner')\nconst CoverManager = require('../managers/CoverManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/LibraryItem')} libraryItem\n *\n * @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem\n */\n\nclass PodcastController {\n  /**\n   * POST /api/podcasts\n   * Create podcast\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to create podcast`)\n      return res.sendStatus(403)\n    }\n    const payload = req.body\n    if (!payload.media || !payload.media.metadata) {\n      return res.status(400).send('Invalid request body. \"media\" and \"media.metadata\" are required')\n    }\n\n    const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)\n    if (!library) {\n      Logger.error(`[PodcastController] Create: Library not found \"${payload.libraryId}\"`)\n      return res.status(404).send('Library not found')\n    }\n\n    const folder = library.libraryFolders.find((fold) => fold.id === payload.folderId)\n    if (!folder) {\n      Logger.error(`[PodcastController] Create: Folder not found \"${payload.folderId}\"`)\n      return res.status(404).send('Folder not found')\n    }\n\n    const podcastPath = filePathToPOSIX(payload.path)\n\n    // Check if a library item with this podcast folder exists already\n    const existingLibraryItem =\n      (await Database.libraryItemModel.count({\n        where: {\n          path: podcastPath\n        }\n      })) > 0\n    if (existingLibraryItem) {\n      Logger.error(`[PodcastController] Podcast already exists at path \"${podcastPath}\"`)\n      return res.status(400).send('Podcast already exists')\n    }\n\n    const success = await fs\n      .ensureDir(podcastPath)\n      .then(() => true)\n      .catch((error) => {\n        Logger.error(`[PodcastController] Failed to ensure podcast dir \"${podcastPath}\"`, error)\n        return false\n      })\n    if (!success) return res.status(400).send('Invalid podcast path')\n\n    const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)\n\n    let relPath = payload.path.replace(folder.fullPath, '')\n    if (relPath.startsWith('/')) relPath = relPath.slice(1)\n\n    let newLibraryItem = null\n    const transaction = await Database.sequelize.transaction()\n    try {\n      const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)\n\n      newLibraryItem = await Database.libraryItemModel.create(\n        {\n          ino: libraryItemFolderStats.ino,\n          path: podcastPath,\n          relPath,\n          mediaId: podcast.id,\n          mediaType: 'podcast',\n          isFile: false,\n          isMissing: false,\n          isInvalid: false,\n          mtime: libraryItemFolderStats.mtimeMs || 0,\n          ctime: libraryItemFolderStats.ctimeMs || 0,\n          birthtime: libraryItemFolderStats.birthtimeMs || 0,\n          size: 0,\n          libraryFiles: [],\n          extraData: {},\n          libraryId: library.id,\n          libraryFolderId: folder.id,\n          title: podcast.title,\n          titleIgnorePrefix: podcast.titleIgnorePrefix\n        },\n        { transaction }\n      )\n\n      await transaction.commit()\n    } catch (error) {\n      Logger.error(`[PodcastController] Failed to create podcast: ${error}`)\n      await transaction.rollback()\n      return res.status(500).send('Failed to create podcast')\n    }\n\n    newLibraryItem.media = await newLibraryItem.getMediaExpanded()\n\n    // Download and save cover image\n    if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {\n      // Podcast cover will always go into library item folder\n      const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)\n      if (coverResponse.error) {\n        Logger.error(`[PodcastController] Download cover error from \"${payload.media.metadata.imageUrl}\": ${coverResponse.error}`)\n      } else if (coverResponse.cover) {\n        const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)\n        if (!coverImageFileStats) {\n          Logger.error(`[PodcastController] Failed to get cover image stats for \"${coverResponse.cover}\"`)\n        } else {\n          // Add libraryFile to libraryItem and coverPath to podcast\n          const newLibraryFile = {\n            ino: coverImageFileStats.ino,\n            fileType: 'image',\n            addedAt: Date.now(),\n            updatedAt: Date.now(),\n            metadata: {\n              filename: Path.basename(coverResponse.cover),\n              ext: Path.extname(coverResponse.cover).slice(1),\n              path: coverResponse.cover,\n              relPath: Path.basename(coverResponse.cover),\n              size: coverImageFileStats.size,\n              mtimeMs: coverImageFileStats.mtimeMs || 0,\n              ctimeMs: coverImageFileStats.ctimeMs || 0,\n              birthtimeMs: coverImageFileStats.birthtimeMs || 0\n            }\n          }\n          newLibraryItem.libraryFiles.push(newLibraryFile)\n          newLibraryItem.changed('libraryFiles', true)\n          await newLibraryItem.save()\n\n          newLibraryItem.media.coverPath = coverResponse.cover\n          await newLibraryItem.media.save()\n        }\n      }\n    }\n\n    SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)\n\n    res.json(newLibraryItem.toOldJSONExpanded())\n\n    // Turn on podcast auto download cron if not already on\n    if (newLibraryItem.media.autoDownloadEpisodes) {\n      this.cronManager.checkUpdatePodcastCron(newLibraryItem)\n    }\n  }\n\n  /**\n   * POST: /api/podcasts/feed\n   *\n   * @typedef getPodcastFeedReqBody\n   * @property {string} rssFeed\n   *\n   * @param {Request<{}, {}, getPodcastFeedReqBody, {}> & RequestUserObject} req\n   * @param {Response} res\n   */\n  async getPodcastFeed(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to get podcast feed`)\n      return res.sendStatus(403)\n    }\n\n    const url = validateUrl(req.body.rssFeed)\n    if (!url) {\n      return res.status(400).send('Invalid request body. \"rssFeed\" must be a valid URL')\n    }\n\n    const podcast = await getPodcastFeed(url)\n    if (!podcast) {\n      return res.status(404).send('Podcast RSS feed request failed or invalid response data')\n    }\n    res.json({ podcast })\n  }\n\n  /**\n   * POST: /api/podcasts/opml\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getFeedsFromOPMLText(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to get feeds from opml`)\n      return res.sendStatus(403)\n    }\n\n    if (!req.body.opmlText) {\n      return res.sendStatus(400)\n    }\n\n    res.json({\n      feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)\n    })\n  }\n\n  /**\n   * POST: /api/podcasts/opml/create\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async bulkCreatePodcastsFromOpmlFeedUrls(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to bulk create podcasts`)\n      return res.sendStatus(403)\n    }\n\n    const rssFeeds = req.body.feeds\n    if (!Array.isArray(rssFeeds) || !rssFeeds.length || rssFeeds.some((feed) => !validateUrl(feed))) {\n      return res.status(400).send('Invalid request body. \"feeds\" must be an array of RSS feed URLs')\n    }\n\n    const libraryId = req.body.libraryId\n    const folderId = req.body.folderId\n    if (!libraryId || !folderId) {\n      return res.status(400).send('Invalid request body. \"libraryId\" and \"folderId\" are required')\n    }\n\n    const folder = await Database.libraryFolderModel.findByPk(folderId)\n    if (!folder || folder.libraryId !== libraryId) {\n      return res.status(404).send('Folder not found')\n    }\n    const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes\n    this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/podcasts/:id/checknew\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async checkNewEpisodes(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to check/download episodes`)\n      return res.sendStatus(403)\n    }\n\n    if (!req.libraryItem.media.feedURL) {\n      Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)\n      return res.status(400).send('Podcast has no rss feed url')\n    }\n\n    const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3\n\n    const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)\n    res.json({\n      episodes: newEpisodes || []\n    })\n  }\n\n  /**\n   * GET: /api/podcasts/:id/clear-queue\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  clearEpisodeDownloadQueue(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempting to clear download queue`)\n      return res.sendStatus(403)\n    }\n    this.podcastManager.clearDownloadQueue(req.params.id)\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/podcasts/:id/downloads\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  getEpisodeDownloads(req, res) {\n    const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)\n    res.json({\n      downloads: downloadsInQueue.map((d) => d.toJSONForClient())\n    })\n  }\n\n  /**\n   * GET: /api/podcasts/:id/search-episode\n   * Search for an episode in a podcast\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async findEpisode(req, res) {\n    const rssFeedUrl = req.libraryItem.media.feedURL\n    if (!rssFeedUrl) {\n      Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)\n      return res.status(400).send('Podcast does not have an RSS feed URL')\n    }\n\n    const searchTitle = req.query.title\n    if (!searchTitle || typeof searchTitle !== 'string') {\n      return res.sendStatus(500)\n    }\n    const episodes = await findMatchingEpisodes(rssFeedUrl, searchTitle)\n    res.json({\n      episodes: episodes || []\n    })\n  }\n\n  /**\n   * POST: /api/podcasts/:id/download-episodes\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async downloadEpisodes(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to download episodes`)\n      return res.sendStatus(403)\n    }\n\n    const episodes = req.body\n    if (!Array.isArray(episodes) || !episodes.length) {\n      return res.sendStatus(400)\n    }\n\n    this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/podcasts/:id/match-episodes\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async quickMatchEpisodes(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[PodcastController] Non-admin user \"${req.user.username}\" attempted to download episodes`)\n      return res.sendStatus(403)\n    }\n\n    const overrideDetails = req.query.override === '1'\n    const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })\n    if (episodesUpdated) {\n      SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    }\n\n    res.json({\n      numEpisodesUpdated: episodesUpdated\n    })\n  }\n\n  /**\n   * PATCH: /api/podcasts/:id/episode/:episodeId\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async updateEpisode(req, res) {\n    /** @type {import('../models/PodcastEpisode')} */\n    const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)\n    if (!episode) {\n      return res.status(404).send('Episode not found')\n    }\n\n    const updatePayload = {}\n    const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']\n    for (const key in req.body) {\n      if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {\n        // Sanitize description HTML\n        if (key === 'description' && req.body[key]) {\n          const sanitizedDescription = htmlSanitizer.sanitize(req.body[key])\n          if (sanitizedDescription !== req.body[key]) {\n            Logger.debug(`[PodcastController] Sanitized description from \"${req.body[key]}\" to \"${sanitizedDescription}\"`)\n            req.body[key] = sanitizedDescription\n          }\n        } else if (key === 'subtitle' && req.body[key]) {\n          const sanitizedSubtitle = htmlSanitizer.sanitize(req.body[key])\n          if (sanitizedSubtitle !== req.body[key]) {\n            Logger.debug(`[PodcastController] Sanitized subtitle from \"${req.body[key]}\" to \"${sanitizedSubtitle}\"`)\n            req.body[key] = sanitizedSubtitle\n          }\n        }\n\n        updatePayload[key] = req.body[key]\n      } else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {\n        updatePayload[key] = req.body[key]\n      } else if (key === 'publishedAt' && typeof req.body[key] === 'number') {\n        updatePayload[key] = req.body[key]\n      }\n    }\n\n    if (Object.keys(updatePayload).length) {\n      episode.set(updatePayload)\n      if (episode.changed()) {\n        Logger.info(`[PodcastController] Updated episode \"${episode.title}\" keys`, episode.changed())\n        await episode.save()\n\n        SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n      } else {\n        Logger.info(`[PodcastController] No changes to episode \"${episode.title}\"`)\n      }\n    }\n\n    res.json(req.libraryItem.toOldJSONExpanded())\n  }\n\n  /**\n   * GET: /api/podcasts/:id/episode/:episodeId\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async getEpisode(req, res) {\n    const episodeId = req.params.episodeId\n\n    /** @type {import('../models/PodcastEpisode')} */\n    const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)\n      return res.sendStatus(404)\n    }\n\n    res.json(episode.toOldJSON(req.libraryItem.id))\n  }\n\n  /**\n   * DELETE: /api/podcasts/:id/episode/:episodeId\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async removeEpisode(req, res) {\n    const episodeId = req.params.episodeId\n    const hardDelete = req.query.hard === '1'\n\n    /** @type {import('../models/PodcastEpisode')} */\n    const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)\n      return res.sendStatus(404)\n    }\n\n    // Remove it from the podcastEpisodes array\n    req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeId)\n\n    if (hardDelete) {\n      const audioFile = episode.audioFile\n      // TODO: this will trigger the watcher. should maybe handle this gracefully\n      await fs\n        .remove(audioFile.metadata.path)\n        .then(() => {\n          Logger.info(`[PodcastController] Hard deleted episode file at \"${audioFile.metadata.path}\"`)\n        })\n        .catch((error) => {\n          Logger.error(`[PodcastController] Failed to hard delete episode file at \"${audioFile.metadata.path}\"`, error)\n        })\n    }\n\n    // Remove episode from playlists\n    await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])\n\n    // Remove media progress for this episode\n    const mediaProgressRemoved = await Database.mediaProgressModel.destroy({\n      where: {\n        mediaItemId: episode.id\n      }\n    })\n    if (mediaProgressRemoved) {\n      Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)\n    }\n\n    // Remove episode\n    await episode.destroy()\n\n    // Remove library file\n    req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)\n    req.libraryItem.changed('libraryFiles', true)\n    await req.libraryItem.save()\n\n    // update number of episodes\n    req.libraryItem.media.numEpisodes = req.libraryItem.media.podcastEpisodes.length\n    await req.libraryItem.media.save()\n\n    SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)\n    res.json(req.libraryItem.toOldJSON())\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)\n    if (!libraryItem?.media) return res.sendStatus(404)\n\n    if (!libraryItem.isPodcast) {\n      return res.sendStatus(500)\n    }\n\n    // Check user can access this library item\n    if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n      return res.sendStatus(403)\n    }\n\n    if (req.method == 'DELETE' && !req.user.canDelete) {\n      Logger.warn(`[PodcastController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {\n      Logger.warn(`[PodcastController] User \"${req.user.username}\" attempted to update without permission`)\n      return res.sendStatus(403)\n    }\n\n    req.libraryItem = libraryItem\n    next()\n  }\n}\nmodule.exports = new PodcastController()\n"
  },
  {
    "path": "server/controllers/RSSFeedController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\n\nconst RssFeedManager = require('../managers/RssFeedManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass RSSFeedController {\n  constructor() {}\n\n  /**\n   * GET: /api/feeds\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAll(req, res) {\n    const feeds = await RssFeedManager.getFeeds()\n    res.json({\n      feeds: feeds.map((f) => f.toOldJSON()),\n      minified: feeds.map((f) => f.toOldJSONMinified())\n    })\n  }\n\n  /**\n   * POST: /api/feeds/item/:itemId/open\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async openRSSFeedForItem(req, res) {\n    const reqBody = req.body || {}\n\n    const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)\n    if (!itemExpanded) return res.sendStatus(404)\n\n    // Check user can access this library item\n    if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {\n      Logger.error(`[RSSFeedController] User \"${req.user.username}\" attempted to open an RSS feed for item \"${itemExpanded.media.title}\" that they don\\'t have access to`)\n      return res.sendStatus(403)\n    }\n\n    // Check request body options exist\n    if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {\n      Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)\n      return res.status(400).send('Invalid request body')\n    }\n\n    // Check item has audio tracks\n    if (!itemExpanded.hasAudioTracks()) {\n      Logger.error(`[RSSFeedController] Cannot open RSS feed for item \"${itemExpanded.media.title}\" because it has no audio tracks`)\n      return res.status(400).send('Item has no audio tracks')\n    }\n\n    // Check that this slug is not being used for another feed (slug will also be the Feed id)\n    if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {\n      Logger.error(`[RSSFeedController] Cannot open RSS feed because slug \"${reqBody.slug}\" is already in use`)\n      return res.status(400).send('Slug already in use')\n    }\n\n    const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)\n    if (!feed) {\n      Logger.error(`[RSSFeedController] Failed to open RSS feed for item \"${itemExpanded.media.title}\"`)\n      return res.status(500).send('Failed to open RSS feed')\n    }\n\n    res.json({\n      feed: feed.toOldJSONMinified()\n    })\n  }\n\n  /**\n   * POST: /api/feeds/collection/:collectionId/open\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async openRSSFeedForCollection(req, res) {\n    const reqBody = req.body || {}\n\n    // Check request body options exist\n    if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {\n      Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)\n      return res.status(400).send('Invalid request body')\n    }\n\n    // Check that this slug is not being used for another feed (slug will also be the Feed id)\n    if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {\n      Logger.error(`[RSSFeedController] Cannot open RSS feed because slug \"${reqBody.slug}\" is already in use`)\n      return res.status(400).send('Slug already in use')\n    }\n\n    const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)\n    if (!collection) return res.sendStatus(404)\n\n    // Check collection has audio tracks\n    if (!collection.books.some((book) => book.includedAudioFiles.length)) {\n      Logger.error(`[RSSFeedController] Cannot open RSS feed for collection \"${collection.name}\" because it has no audio tracks`)\n      return res.status(400).send('Collection has no audio tracks')\n    }\n\n    const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)\n    if (!feed) {\n      Logger.error(`[RSSFeedController] Failed to open RSS feed for collection \"${collection.name}\"`)\n      return res.status(500).send('Failed to open RSS feed')\n    }\n\n    res.json({\n      feed: feed.toOldJSONMinified()\n    })\n  }\n\n  /**\n   * POST: /api/feeds/series/:seriesId/open\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async openRSSFeedForSeries(req, res) {\n    const reqBody = req.body || {}\n\n    // Check request body options exist\n    if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {\n      Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)\n      return res.status(400).send('Invalid request body')\n    }\n\n    // Check that this slug is not being used for another feed (slug will also be the Feed id)\n    if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {\n      Logger.error(`[RSSFeedController] Cannot open RSS feed because slug \"${reqBody.slug}\" is already in use`)\n      return res.status(400).send('Slug already in use')\n    }\n\n    const series = await Database.seriesModel.getExpandedById(req.params.seriesId)\n    if (!series) return res.sendStatus(404)\n\n    // Check series has audio tracks\n    if (!series.books.some((book) => book.includedAudioFiles.length)) {\n      Logger.error(`[RSSFeedController] Cannot open RSS feed for series \"${series.name}\" because it has no audio tracks`)\n      return res.status(400).send('Series has no audio tracks')\n    }\n\n    const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)\n    if (!feed) {\n      Logger.error(`[RSSFeedController] Failed to open RSS feed for series \"${series.name}\"`)\n      return res.status(500).send('Failed to open RSS feed')\n    }\n\n    res.json({\n      feed: feed.toOldJSONMinified()\n    })\n  }\n\n  /**\n   * POST: /api/feeds/:id/close\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async closeRSSFeed(req, res) {\n    const feed = await Database.feedModel.findByPk(req.params.id)\n    if (!feed) {\n      Logger.error(`[RSSFeedController] Cannot close RSS feed because feed \"${req.params.id}\" does not exist`)\n      return res.sendStatus(404)\n    }\n\n    await RssFeedManager.handleCloseFeed(feed)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      // Only admins can manage rss feeds\n      Logger.error(`[RSSFeedController] Non-admin user \"${req.user.username}\" attempted to make a request to an RSS feed route`)\n      return res.sendStatus(403)\n    }\n\n    next()\n  }\n}\nmodule.exports = new RSSFeedController()\n"
  },
  {
    "path": "server/controllers/SearchController.js",
    "content": "const { Request, Response } = require('express')\nconst Logger = require('../Logger')\nconst BookFinder = require('../finders/BookFinder')\nconst PodcastFinder = require('../finders/PodcastFinder')\nconst AuthorFinder = require('../finders/AuthorFinder')\nconst Database = require('../Database')\nconst { isValidASIN, getQueryParamAsString, ValidationError, NotFoundError } = require('../utils')\n\n// Provider name mappings for display purposes\nconst providerMap = {\n  all: 'All',\n  best: 'Best',\n  google: 'Google Books',\n  itunes: 'iTunes',\n  openlibrary: 'Open Library',\n  fantlab: 'FantLab.ru',\n  audiobookcovers: 'AudiobookCovers.com',\n  audible: 'Audible.com',\n  'audible.ca': 'Audible.ca',\n  'audible.uk': 'Audible.co.uk',\n  'audible.au': 'Audible.com.au',\n  'audible.fr': 'Audible.fr',\n  'audible.de': 'Audible.de',\n  'audible.jp': 'Audible.co.jp',\n  'audible.it': 'Audible.it',\n  'audible.in': 'Audible.in',\n  'audible.es': 'Audible.es',\n  audnexus: 'Audnexus'\n}\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass SearchController {\n  constructor() {}\n\n  /**\n   * Fetches a library item by ID\n   * @param {string} id - Library item ID\n   * @param {string} methodName - Name of the calling method for logging\n   * @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded>}\n   */\n  static async fetchLibraryItem(id) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(id)\n    if (!libraryItem) {\n      throw new NotFoundError(`library item \"${id}\" not found`)\n    }\n    return libraryItem\n  }\n\n  /**\n   * Maps custom metadata providers to standardized format\n   * @param {Array} providers - Array of custom provider objects\n   * @returns {Array<{value: string, text: string}>}\n   */\n  static mapCustomProviders(providers) {\n    return providers.map((provider) => ({\n      value: provider.getSlug(),\n      text: provider.name\n    }))\n  }\n\n  /**\n   * Static helper method to format provider for client (for use in array methods)\n   * @param {string} providerValue - Provider identifier\n   * @returns {{value: string, text: string}}\n   */\n  static formatProvider(providerValue) {\n    return {\n      value: providerValue,\n      text: providerMap[providerValue] || providerValue\n    }\n  }\n\n  /**\n   * GET: /api/search/books\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findBooks(req, res) {\n    try {\n      const query = req.query\n      const provider = getQueryParamAsString(query, 'provider', 'google')\n      const title = getQueryParamAsString(query, 'title', '')\n      const author = getQueryParamAsString(query, 'author', '')\n      const id = getQueryParamAsString(query, 'id', undefined)\n\n      // Fetch library item\n      const libraryItem = id ? await SearchController.fetchLibraryItem(id) : null\n\n      const results = await BookFinder.search(libraryItem, provider, title, author)\n      res.json(results)\n    } catch (error) {\n      Logger.error(`[SearchController] findBooks: ${error.message}`)\n      if (error instanceof ValidationError || error instanceof NotFoundError) {\n        return res.status(error.status).json({ error: error.message })\n      }\n      return res.status(500).json({ error: 'Internal server error' })\n    }\n  }\n\n  /**\n   * GET: /api/search/covers\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findCovers(req, res) {\n    try {\n      const query = req.query\n      const podcast = query.podcast === '1' || query.podcast === 1\n      const title = getQueryParamAsString(query, 'title', '', true)\n      const author = getQueryParamAsString(query, 'author', '')\n      const provider = getQueryParamAsString(query, 'provider', 'google')\n\n      let results = null\n      if (podcast) results = await PodcastFinder.findCovers(title)\n      else results = await BookFinder.findCovers(provider, title, author)\n      res.json({ results })\n    } catch (error) {\n      Logger.error(`[SearchController] findCovers: ${error.message}`)\n      if (error instanceof ValidationError) {\n        return res.status(error.status).json({ error: error.message })\n      }\n      return res.status(500).json({ error: 'Internal server error' })\n    }\n  }\n\n  /**\n   * GET: /api/search/podcasts\n   * Find podcast RSS feeds given a term\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findPodcasts(req, res) {\n    try {\n      const query = req.query\n      const term = getQueryParamAsString(query, 'term', '', true)\n      const country = getQueryParamAsString(query, 'country', 'us')\n\n      const results = await PodcastFinder.search(term, { country })\n      res.json(results)\n    } catch (error) {\n      Logger.error(`[SearchController] findPodcasts: ${error.message}`)\n      if (error instanceof ValidationError) {\n        return res.status(error.status).json({ error: error.message })\n      }\n      return res.status(500).json({ error: 'Internal server error' })\n    }\n  }\n\n  /**\n   * GET: /api/search/authors\n   * Note: This endpoint is not currently used in the web client.\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findAuthor(req, res) {\n    try {\n      const query = getQueryParamAsString(req.query, 'q', '', true)\n\n      const author = await AuthorFinder.findAuthorByName(query)\n      res.json(author)\n    } catch (error) {\n      Logger.error(`[SearchController] findAuthor: ${error.message}`)\n      if (error instanceof ValidationError) {\n        return res.status(error.status).json({ error: error.message })\n      }\n      return res.status(500).json({ error: 'Internal server error' })\n    }\n  }\n\n  /**\n   * GET: /api/search/chapters\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findChapters(req, res) {\n    try {\n      const query = req.query\n      const asin = getQueryParamAsString(query, 'asin', '', true)\n      const region = getQueryParamAsString(query, 'region', 'us').toLowerCase()\n\n      if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid')\n\n      const chapterData = await BookFinder.findChapters(asin, region)\n      if (!chapterData) {\n        return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })\n      }\n      res.json(chapterData)\n    } catch (error) {\n      Logger.error(`[SearchController] findChapters: ${error.message}`)\n      if (error instanceof ValidationError) {\n        if (error.paramName === 'asin') {\n          return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })\n        }\n        if (error.paramName === 'region') {\n          return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' })\n        }\n      }\n      return res.status(500).json({ error: 'Internal server error' })\n    }\n  }\n\n  /**\n   * GET: /api/search/providers\n   * Get all available metadata providers\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAllProviders(req, res) {\n    const customProviders = await Database.customMetadataProviderModel.findAll()\n\n    const customBookProviders = customProviders.filter((p) => p.mediaType === 'book')\n    const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast')\n\n    const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers')\n\n    // Build minimized payload with custom providers merged in\n    const providers = {\n      books: [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders)],\n      booksCovers: [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders), SearchController.formatProvider('all')],\n      podcasts: [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customPodcastProviders)]\n    }\n\n    res.json({ providers })\n  }\n}\nmodule.exports = new SearchController()\n"
  },
  {
    "path": "server/controllers/SeriesController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst RssFeedManager = require('../managers/RssFeedManager')\n\nconst libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/Series')} series\n *\n * @typedef {RequestWithUser & RequestEntityObject} SeriesControllerRequest\n */\n\nclass SeriesController {\n  constructor() {}\n\n  /**\n   * @deprecated\n   * /api/series/:id\n   *\n   * TODO: Update mobile app to use /api/libraries/:id/series/:seriesId API route instead\n   * Series are not library specific so we need to know what the library id is\n   *\n   * @param {SeriesControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    const include = (req.query.include || '')\n      .split(',')\n      .map((v) => v.trim())\n      .filter((v) => !!v)\n\n    const seriesJson = req.series.toOldJSON()\n\n    // Add progress map with isFinished flag\n    if (include.includes('progress')) {\n      const libraryItemsInSeries = req.libraryItemsInSeries\n      const libraryItemsFinished = libraryItemsInSeries.filter((li) => {\n        return req.user.getMediaProgress(li.media.id)?.isFinished\n      })\n      seriesJson.progress = {\n        libraryItemIds: libraryItemsInSeries.map((li) => li.id),\n        libraryItemIdsFinished: libraryItemsFinished.map((li) => li.id),\n        isFinished: libraryItemsFinished.length === libraryItemsInSeries.length\n      }\n    }\n\n    if (include.includes('rssfeed')) {\n      const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)\n      seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null\n    }\n\n    res.json(seriesJson)\n  }\n\n  /**\n   * TODO: Currently unused in the client, should check for duplicate name\n   *\n   * @param {SeriesControllerRequest} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    const keysToUpdate = ['name', 'description']\n    const payload = {}\n    for (const key of keysToUpdate) {\n      if (req.body[key] !== undefined && typeof req.body[key] === 'string') {\n        payload[key] = req.body[key]\n      }\n    }\n    if (!Object.keys(payload).length) {\n      return res.status(400).send('No valid fields to update')\n    }\n    req.series.set(payload)\n    if (req.series.changed()) {\n      await req.series.save()\n      SocketAuthority.emitter('series_updated', req.series.toOldJSON())\n    }\n    res.json(req.series.toOldJSON())\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    const series = await Database.seriesModel.findByPk(req.params.id)\n    if (!series) return res.sendStatus(404)\n\n    /**\n     * Filter out any library items not accessible to user\n     */\n    const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)\n    if (!libraryItems.length) {\n      Logger.warn(`[SeriesController] User \"${req.user.username}\" attempted to access series \"${series.id}\" with no accessible books`)\n      return res.sendStatus(404)\n    }\n\n    if (req.method == 'DELETE' && !req.user.canDelete) {\n      Logger.warn(`[SeriesController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {\n      Logger.warn(`[SeriesController] User \"${req.user.username}\" attempted to update without permission`)\n      return res.sendStatus(403)\n    }\n\n    req.series = series\n    req.libraryItemsInSeries = libraryItems\n    next()\n  }\n}\nmodule.exports = new SeriesController()\n"
  },
  {
    "path": "server/controllers/SessionController.js",
    "content": "const Path = require('path')\nconst { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\nconst { toNumber, isUUID } = require('../utils/index')\nconst { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')\nconst { PlayMethod } = require('../utils/constants')\n\nconst ShareManager = require('../managers/ShareManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass SessionController {\n  constructor() {}\n\n  /**\n   * GET: /api/sessions\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAllWithUserData(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[SessionController] getAllWithUserData: Non-admin user \"${req.user.username}\" requested all session data`)\n      return res.sendStatus(404)\n    }\n    // Validate \"user\" query\n    let userId = req.query.user\n    if (userId && !isUUID(userId)) {\n      Logger.warn(`[SessionController] Invalid \"user\" query string \"${userId}\"`)\n      userId = null\n    }\n    // Validate \"sort\" query\n    const validSortOrders = ['displayTitle', 'duration', 'playMethod', 'startTime', 'currentTime', 'timeListening', 'updatedAt', 'createdAt']\n    let orderKey = req.query.sort || 'updatedAt'\n    if (!validSortOrders.includes(orderKey)) {\n      Logger.warn(`[SessionController] Invalid \"sort\" query string \"${orderKey}\" (Must be one of \"${validSortOrders.join('|')}\")`)\n      orderKey = 'updatedAt'\n    }\n    let orderDesc = req.query.desc === '1' ? 'DESC' : 'ASC'\n    // Validate \"itemsPerPage\" and \"page\" query\n    let itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10\n    if (itemsPerPage < 1) {\n      Logger.warn(`[SessionController] Invalid \"itemsPerPage\" query string \"${itemsPerPage}\"`)\n      itemsPerPage = 10\n    }\n    let page = toNumber(req.query.page, 0)\n    if (page < 0) {\n      Logger.warn(`[SessionController] Invalid \"page\" query string \"${page}\"`)\n      page = 0\n    }\n\n    let where = null\n\n    if (userId) {\n      where = {\n        userId\n      }\n    }\n\n    const { rows, count } = await Database.playbackSessionModel.findAndCountAll({\n      where,\n      include: [\n        {\n          model: Database.deviceModel\n        },\n        {\n          model: Database.userModel,\n          attributes: ['id', 'username']\n        }\n      ],\n      order: [[orderKey, orderDesc]],\n      limit: itemsPerPage,\n      offset: itemsPerPage * page\n    })\n\n    // Map playback sessions to old playback sessions\n    const sessions = rows.map((session) => {\n      const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)\n      if (session.user) {\n        return {\n          ...oldPlaybackSession,\n          user: {\n            id: session.user.id,\n            username: session.user.username\n          }\n        }\n      } else {\n        return oldPlaybackSession.toJSON()\n      }\n    })\n\n    const payload = {\n      total: count,\n      numPages: Math.ceil(count / itemsPerPage),\n      page,\n      itemsPerPage,\n      sessions\n    }\n    if (userId) {\n      payload.userId = userId\n    }\n\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/sessions/open\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getOpenSessions(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[SessionController] getOpenSessions: Non-admin user \"${req.user.username}\" requested open session data`)\n      return res.sendStatus(404)\n    }\n\n    const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()\n    const openSessions = this.playbackSessionManager.sessions.map((se) => {\n      return {\n        ...se.toJSON(),\n        user: minifiedUserObjects.find((u) => u.id === se.userId) || null\n      }\n    })\n\n    const shareSessions = ShareManager.openSharePlaybackSessions.map((se) => se.toJSON())\n\n    res.json({\n      sessions: openSessions,\n      shareSessions\n    })\n  }\n\n  /**\n   * GET: /api/session/:id\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getOpenSession(req, res) {\n    const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId)\n    const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)\n    res.json(sessionForClient)\n  }\n\n  /**\n   * POST: /api/session/:id/sync\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  sync(req, res) {\n    this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res)\n  }\n\n  /**\n   * POST: /api/session/:id/close\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  close(req, res) {\n    let syncData = req.body\n    if (syncData && !Object.keys(syncData).length) syncData = null\n    this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res)\n  }\n\n  /**\n   * DELETE: /api/session/:id\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    // if session is open then remove it\n    const openSession = this.playbackSessionManager.getSession(req.playbackSession.id)\n    if (openSession) {\n      await this.playbackSessionManager.removeSession(req.playbackSession.id)\n    }\n\n    await Database.removePlaybackSession(req.playbackSession.id)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/sessions/batch/delete\n   * @this import('../routers/ApiRouter')\n   *\n   * @typedef batchDeleteReqBody\n   * @property {string[]} sessions\n   *\n   * @param {Request<{}, {}, batchDeleteReqBody, {}> & RequestUserObject} req\n   * @param {Response} res\n   */\n  async batchDelete(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[SessionController] Non-admin user \"${req.user.username}\" attempted to batch delete sessions`)\n      return res.sendStatus(403)\n    }\n    // Validate session ids\n    if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some((s) => !isUUID(s))) {\n      Logger.error(`[SessionController] Invalid request body. \"sessions\" array is required`, req.body)\n      return res.status(400).send('Invalid request body. \"sessions\" array of session id strings is required.')\n    }\n\n    // Check if any of these sessions are open and close it\n    for (const sessionId of req.body.sessions) {\n      const openSession = this.playbackSessionManager.getSession(sessionId)\n      if (openSession) {\n        await this.playbackSessionManager.removeSession(sessionId)\n      }\n    }\n\n    try {\n      const sessionsRemoved = await Database.playbackSessionModel.destroy({\n        where: {\n          id: req.body.sessions\n        }\n      })\n      Logger.info(`[SessionController] ${sessionsRemoved} playback sessions removed by \"${req.user.username}\"`)\n      res.sendStatus(200)\n    } catch (error) {\n      Logger.error(`[SessionController] Failed to remove playback sessions`, error)\n      res.status(500).send('Failed to remove sessions')\n    }\n  }\n\n  /**\n   * POST: /api/session/local\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  syncLocal(req, res) {\n    this.playbackSessionManager.syncLocalSessionRequest(req, res)\n  }\n\n  /**\n   * POST: /api/session/local-all\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  syncLocalSessions(req, res) {\n    this.playbackSessionManager.syncLocalSessionsRequest(req, res)\n  }\n\n  /**\n   * GET: /public/session/:id/track/:index\n   * While a session is open, this endpoint can be used to stream the audio track\n   *\n   * @this {import('../routers/PublicRouter')}\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getTrack(req, res) {\n    const audioTrackIndex = toNumber(req.params.index, null)\n    if (audioTrackIndex === null) {\n      Logger.error(`[SessionController] Invalid audio track index \"${req.params.index}\"`)\n      return res.sendStatus(400)\n    }\n\n    const playbackSession = this.playbackSessionManager.getSession(req.params.id)\n    if (!playbackSession) {\n      Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)\n      return res.sendStatus(404)\n    }\n\n    let audioTrack = playbackSession.audioTracks.find((t) => toNumber(t.index, 1) === audioTrackIndex)\n\n    // Support clients passing 0 or 1 for podcast episode audio track index (handles old episodes pre-v2.21.0 having null index)\n    if (!audioTrack && playbackSession.mediaType === 'podcast' && audioTrackIndex === 0) {\n      audioTrack = playbackSession.audioTracks[0]\n    }\n    if (!audioTrack) {\n      Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`)\n      return res.sendStatus(404)\n    }\n\n    // Redirect transcode requests to the HLS router\n    // Handles bug introduced in android v0.10.0-beta where transcode requests are made to this endpoint\n    if (playbackSession.playMethod === PlayMethod.TRANSCODE && audioTrack.contentUrl) {\n      Logger.debug(`[SessionController] Redirecting transcode request to \"${audioTrack.contentUrl}\"`)\n      return res.redirect(audioTrack.contentUrl)\n    }\n\n    if (!audioTrack.metadata?.path) {\n      Logger.error(`[SessionController] Invalid audio track \"${audioTrack.index}\" for session \"${req.params.id}\"`)\n      return res.sendStatus(500)\n    }\n\n    const user = await Database.userModel.getUserById(playbackSession.userId)\n    Logger.debug(`[SessionController] Serving audio track ${audioTrack.index} for session \"${req.params.id}\" belonging to user \"${user.username}\"`)\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + audioTrack.metadata.path)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available\n    const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(audioTrack.metadata.path))\n    if (audioMimeType) {\n      res.setHeader('Content-Type', audioMimeType)\n    }\n    res.sendFile(audioTrack.metadata.path)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  openSessionMiddleware(req, res, next) {\n    var playbackSession = this.playbackSessionManager.getSession(req.params.id)\n    if (!playbackSession) return res.sendStatus(404)\n\n    if (playbackSession.userId !== req.user.id && !req.user.isAdminOrUp) {\n      Logger.error(`[SessionController] Non-admin user \"${req.user.username}\" attempting to access session belonging to another user \"${req.params.id}\"`)\n      return res.sendStatus(403)\n    }\n\n    req.playbackSession = playbackSession\n    next()\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    const playbackSession = await Database.getPlaybackSession(req.params.id)\n    if (!playbackSession) {\n      Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)\n      return res.sendStatus(404)\n    }\n\n    if (req.method == 'DELETE' && !req.user.canDelete) {\n      Logger.warn(`[SessionController] User \"${req.user.username}\" attempted to delete without permission`)\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {\n      Logger.warn(`[SessionController] User \"${req.user.username}\" attempted to update without permission`)\n      return res.sendStatus(403)\n    }\n\n    req.playbackSession = playbackSession\n    next()\n  }\n}\nmodule.exports = new SessionController()\n"
  },
  {
    "path": "server/controllers/ShareController.js",
    "content": "const { Request, Response } = require('express')\nconst uuid = require('uuid')\nconst Path = require('path')\nconst { Op } = require('sequelize')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\n\nconst { PlayMethod } = require('../utils/constants')\nconst { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')\nconst zipHelpers = require('../utils/zipHelpers')\n\nconst PlaybackSession = require('../objects/PlaybackSession')\nconst ShareManager = require('../managers/ShareManager')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass ShareController {\n  constructor() {}\n\n  /**\n   * Public route\n   * GET: /api/share/:slug\n   * Get media item share by slug\n   *\n   * @this {import('../routers/PublicRouter')}\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getMediaItemShareBySlug(req, res) {\n    const { slug } = req.params\n    // Optional start time\n    let startTime = req.query.t && !isNaN(req.query.t) ? Math.max(0, parseInt(req.query.t)) : 0\n\n    const mediaItemShare = ShareManager.findBySlug(slug)\n    if (!mediaItemShare) {\n      Logger.warn(`[ShareController] Media item share not found with slug ${slug}`)\n      return res.sendStatus(404)\n    }\n    if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {\n      ShareManager.removeMediaItemShare(mediaItemShare.id)\n      return res.status(404).send('Media item share not found')\n    }\n\n    if (req.cookies.share_session_id) {\n      const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)\n\n      if (playbackSession) {\n        if (mediaItemShare.id === playbackSession.mediaItemShareId) {\n          Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)\n          mediaItemShare.playbackSession = playbackSession.toJSONForClient()\n          return res.json(mediaItemShare)\n        } else {\n          // Changed media item share - close other session\n          Logger.debug(`[ShareController] Other playback session is already open for share session. Closing session \"${playbackSession.displayTitle}\"`)\n          ShareManager.closeSharePlaybackSession(playbackSession)\n        }\n      } else {\n        Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`)\n        if (!uuid.validate(req.cookies.share_session_id) || uuid.version(req.cookies.share_session_id) !== 4) {\n          Logger.warn(`[ShareController] Invalid share session id ${req.cookies.share_session_id}`)\n          res.clearCookie('share_session_id')\n        }\n      }\n    }\n\n    try {\n      const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)\n      if (!libraryItem) {\n        return res.status(404).send('Media item not found')\n      }\n\n      let startOffset = 0\n      const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => {\n        const audioTrack = {\n          index: audioFile.index,\n          startOffset,\n          duration: audioFile.duration,\n          title: audioFile.metadata.filename || '',\n          contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`,\n          mimeType: audioFile.mimeType,\n          codec: audioFile.codec || null,\n          metadata: structuredClone(audioFile.metadata)\n        }\n        startOffset += audioTrack.duration\n        return audioTrack\n      })\n\n      if (startTime > startOffset) {\n        Logger.warn(`[ShareController] Start time ${startTime} is greater than total duration ${startOffset}`)\n        startTime = 0\n      }\n\n      const shareSessionId = req.cookies.share_session_id || uuid.v4()\n      const clientDeviceInfo = {\n        clientName: 'Abs Web Share',\n        deviceId: shareSessionId\n      }\n      const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)\n\n      const newPlaybackSession = new PlaybackSession()\n      newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime)\n      newPlaybackSession.audioTracks = publicTracks\n      newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY\n      newPlaybackSession.shareSessionId = shareSessionId\n      newPlaybackSession.mediaItemShareId = mediaItemShare.id\n      newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio\n\n      mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient()\n      ShareManager.addOpenSharePlaybackSession(newPlaybackSession)\n\n      // 30 day cookie\n      res.cookie('share_session_id', newPlaybackSession.shareSessionId, { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true })\n\n      res.json(mediaItemShare)\n    } catch (error) {\n      Logger.error(`[ShareController] Failed`, error)\n      res.status(500).send('Internal server error')\n    }\n  }\n\n  /**\n   * Public route - requires share_session_id cookie\n   *\n   * GET: /api/share/:slug/cover\n   * Get media item share cover image\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getMediaItemShareCoverImage(req, res) {\n    if (!req.cookies.share_session_id) {\n      return res.status(404).send('Share session not set')\n    }\n\n    const { slug } = req.params\n\n    const mediaItemShare = ShareManager.findBySlug(slug)\n    if (!mediaItemShare) {\n      return res.status(404)\n    }\n\n    const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)\n    if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {\n      return res.status(404).send('Share session not found')\n    }\n\n    const coverPath = playbackSession.coverPath\n    if (!coverPath) {\n      return res.status(404).send('Cover image not found')\n    }\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + coverPath)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    res.sendFile(coverPath)\n  }\n\n  /**\n   * Public route - requires share_session_id cookie\n   *\n   * GET: /api/share/:slug/track/:index\n   * Get media item share audio track\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getMediaItemShareAudioTrack(req, res) {\n    if (!req.cookies.share_session_id) {\n      return res.status(404).send('Share session not set')\n    }\n\n    const { slug, index } = req.params\n\n    const mediaItemShare = ShareManager.findBySlug(slug)\n    if (!mediaItemShare) {\n      return res.status(404)\n    }\n\n    const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)\n    if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {\n      return res.status(404).send('Share session not found')\n    }\n\n    const audioTrack = playbackSession.audioTracks.find((t) => t.index === parseInt(index))\n    if (!audioTrack) {\n      return res.status(404).send('Track not found')\n    }\n    const audioTrackPath = audioTrack.metadata.path\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + audioTrackPath)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available\n    const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(audioTrackPath))\n    if (audioMimeType) {\n      res.setHeader('Content-Type', audioMimeType)\n    }\n    res.sendFile(audioTrackPath)\n  }\n\n  /**\n   * Public route - requires share_session_id cookie\n   *\n   * GET: /api/share/:slug/download\n   * Downloads media item share\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async downloadMediaItemShare(req, res) {\n    if (!req.cookies.share_session_id) {\n      return res.status(404).send('Share session not set')\n    }\n\n    const { slug } = req.params\n    const mediaItemShare = ShareManager.findBySlug(slug)\n    if (!mediaItemShare) {\n      return res.status(404)\n    }\n    if (!mediaItemShare.isDownloadable) {\n      return res.status(403).send('Download is not allowed for this item')\n    }\n\n    const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)\n    if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {\n      return res.status(404).send('Share session not found')\n    }\n\n    const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {\n      attributes: ['id', 'path', 'relPath', 'isFile']\n    })\n    if (!libraryItem) {\n      return res.status(404).send('Library item not found')\n    }\n\n    const itemPath = libraryItem.path\n    const itemTitle = playbackSession.displayTitle\n\n    Logger.info(`[ShareController] Requested download for book \"${itemTitle}\" at \"${itemPath}\"`)\n\n    try {\n      if (libraryItem.isFile) {\n        const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))\n        if (audioMimeType) {\n          res.setHeader('Content-Type', audioMimeType)\n        }\n        await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))\n      } else {\n        const filename = `${itemTitle}.zip`\n        await zipHelpers.zipDirectoryPipe(itemPath, filename, res)\n      }\n\n      Logger.info(`[ShareController] Downloaded item \"${itemTitle}\" at \"${itemPath}\"`)\n    } catch (error) {\n      Logger.error(`[ShareController] Download failed for item \"${itemTitle}\" at \"${itemPath}\"`, error)\n      res.status(500).send('Failed to download the item')\n    }\n  }\n\n  /**\n   * Public route - requires share_session_id cookie\n   *\n   * PATCH: /api/share/:slug/progress\n   * Update media item share progress\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async updateMediaItemShareProgress(req, res) {\n    if (!req.cookies.share_session_id) {\n      return res.status(404).send('Share session not set')\n    }\n\n    const { slug } = req.params\n    const { currentTime } = req.body\n    if (currentTime === null || isNaN(currentTime) || currentTime < 0) {\n      return res.status(400).send('Invalid current time')\n    }\n\n    const mediaItemShare = ShareManager.findBySlug(slug)\n    if (!mediaItemShare) {\n      return res.status(404)\n    }\n\n    const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)\n    if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {\n      return res.status(404).send('Share session not found')\n    }\n\n    playbackSession.currentTime = Math.min(currentTime, playbackSession.duration)\n    playbackSession.updatedAt = Date.now()\n    Logger.debug(`[ShareController] Update share playback session ${req.cookies.share_session_id} currentTime: ${playbackSession.currentTime}`)\n    res.sendStatus(204)\n  }\n\n  /**\n   * POST: /api/share/mediaitem\n   * Create a new media item share\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async createMediaItemShare(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[ShareController] Non-admin user \"${req.user.username}\" attempted to create item share`)\n      return res.sendStatus(403)\n    }\n\n    const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body\n\n    if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {\n      return res.status(400).send('Missing or invalid required fields')\n    }\n    if (expiresAt === null || isNaN(expiresAt) || expiresAt < 0) {\n      return res.status(400).send('Invalid expiration date')\n    }\n    if (!['book', 'podcastEpisode'].includes(mediaItemType)) {\n      return res.status(400).send('Invalid media item type')\n    }\n\n    try {\n      // Check if the media item share already exists by slug or mediaItemId\n      const existingMediaItemShare = await Database.mediaItemShareModel.findOne({\n        where: {\n          [Op.or]: [{ slug }, { mediaItemId }]\n        }\n      })\n      if (existingMediaItemShare) {\n        if (existingMediaItemShare.mediaItemId === mediaItemId) {\n          return res.status(409).send('Item is already shared')\n        } else {\n          return res.status(409).send('Slug is already in use')\n        }\n      }\n\n      // Check that media item exists\n      const mediaItemModel = mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel\n      const mediaItem = await mediaItemModel.findByPk(mediaItemId)\n      if (!mediaItem) {\n        return res.status(404).send('Media item not found')\n      }\n\n      const mediaItemShare = await Database.mediaItemShareModel.create({\n        slug,\n        expiresAt: expiresAt || null,\n        mediaItemId,\n        mediaItemType,\n        userId: req.user.id,\n        isDownloadable\n      })\n\n      ShareManager.openMediaItemShare(mediaItemShare)\n\n      res.status(201).json(mediaItemShare?.toJSONForClient())\n    } catch (error) {\n      Logger.error(`[ShareController] Failed`, error)\n      res.status(500).send('Internal server error')\n    }\n  }\n\n  /**\n   * DELETE: /api/share/mediaitem/:id\n   * Delete media item share\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async deleteMediaItemShare(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[ShareController] Non-admin user \"${req.user.username}\" attempted to delete item share`)\n      return res.sendStatus(403)\n    }\n\n    try {\n      const mediaItemShare = await Database.mediaItemShareModel.findByPk(req.params.id)\n      if (!mediaItemShare) {\n        return res.status(404).send('Media item share not found')\n      }\n\n      ShareManager.removeMediaItemShare(mediaItemShare.id)\n\n      await mediaItemShare.destroy()\n      res.sendStatus(204)\n    } catch (error) {\n      Logger.error(`[ShareController] Failed`, error)\n      res.status(500).send('Internal server error')\n    }\n  }\n}\nmodule.exports = new ShareController()\n"
  },
  {
    "path": "server/controllers/StatsController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\n\nconst adminStats = require('../utils/queries/adminStats')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n */\n\nclass StatsController {\n  constructor() {}\n\n  /**\n   * GET: /api/stats/server\n   * Currently not in use\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getServerStats(req, res) {\n    Logger.debug('[StatsController] getServerStats')\n    const totalSize = await adminStats.getTotalSize()\n    const numAudioFiles = await adminStats.getNumAudioFiles()\n\n    res.json({\n      books: {\n        ...totalSize.books,\n        numAudioFiles: numAudioFiles.numBookAudioFiles\n      },\n      podcasts: {\n        ...totalSize.podcasts,\n        numAudioFiles: numAudioFiles.numPodcastAudioFiles\n      },\n      total: {\n        ...totalSize.total,\n        numAudioFiles: numAudioFiles.numAudioFiles\n      }\n    })\n  }\n\n  /**\n   * GET: /api/stats/year/:year\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getAdminStatsForYear(req, res) {\n    const year = Number(req.params.year)\n    if (isNaN(year) || year < 2000 || year > 9999) {\n      Logger.error(`[StatsController] Invalid year \"${year}\"`)\n      return res.status(400).send('Invalid year')\n    }\n    const stats = await adminStats.getStatsForYear(year)\n    res.json(stats)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[StatsController] Non-admin user \"${req.user.username}\" attempted to access stats route`)\n      return res.sendStatus(403)\n    }\n\n    next()\n  }\n}\nmodule.exports = new StatsController()\n"
  },
  {
    "path": "server/controllers/ToolsController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/LibraryItem')} libraryItem\n *\n * @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem\n */\n\nclass ToolsController {\n  constructor() {}\n\n  /**\n   * POST: /api/tools/item/:id/encode-m4b\n   * Start an audiobook merge to m4b task\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async encodeM4b(req, res) {\n    if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {\n      Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)\n      return res.status(404).send('Audiobook not found')\n    }\n\n    if (!req.libraryItem.isBook) {\n      Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)\n      return res.status(400).send('Invalid library item: not a book')\n    }\n\n    if (!req.libraryItem.hasAudioTracks) {\n      Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)\n      return res.status(400).send('Invalid audiobook: no audio tracks')\n    }\n\n    if (this.abMergeManager.getPendingTaskByLibraryItemId(req.libraryItem.id)) {\n      Logger.error(`[MiscController] encodeM4b: Audiobook ${req.params.id} is already processing`)\n      return res.status(400).send('Audiobook is already processing')\n    }\n\n    const options = req.query || {}\n    Logger.info(`[ToolsController] encodeM4b: Starting audiobook merge for \"${req.libraryItem.media.title}\" with options: ${JSON.stringify(options)}`)\n    this.abMergeManager.startAudiobookMerge(req.user.id, req.libraryItem, options)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * DELETE: /api/tools/item/:id/encode-m4b\n   * Cancel a running m4b merge task\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async cancelM4bEncode(req, res) {\n    const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)\n    if (!workerTask) return res.sendStatus(404)\n\n    this.abMergeManager.cancelEncode(workerTask.task)\n\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/tools/item/:id/embed-metadata\n   * Start audiobook embed task\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithLibraryItem} req\n   * @param {Response} res\n   */\n  async embedAudioFileMetadata(req, res) {\n    if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {\n      Logger.error(`[ToolsController] Invalid library item`)\n      return res.sendStatus(400)\n    }\n\n    if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) {\n      Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`)\n      return res.status(400).send('Library item is already in queue or processing')\n    }\n\n    const options = {\n      forceEmbedChapters: req.query.forceEmbedChapters === '1',\n      backup: req.query.backup === '1'\n    }\n    this.audioMetadataManager.updateMetadataForItem(req.user.id, req.libraryItem, options)\n    res.sendStatus(200)\n  }\n\n  /**\n   * POST: /api/tools/batch/embed-metadata\n   * Start batch audiobook embed task\n   *\n   * @this import('../routers/ApiRouter')\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async batchEmbedMetadata(req, res) {\n    const libraryItemIds = req.body.libraryItemIds || []\n    if (!libraryItemIds.length) {\n      return res.status(400).send('Invalid request payload')\n    }\n\n    const libraryItems = []\n    for (const libraryItemId of libraryItemIds) {\n      const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)\n      if (!libraryItem) {\n        Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)\n        return res.sendStatus(404)\n      }\n\n      // Check user can access this library item\n      if (!req.user.checkCanAccessLibraryItem(libraryItem)) {\n        Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user \"${req.user.username}\"`)\n        return res.sendStatus(403)\n      }\n\n      if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {\n        Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)\n        return res.sendStatus(400)\n      }\n\n      if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) {\n        Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`)\n        return res.status(400).send('Library item is already in queue or processing')\n      }\n\n      libraryItems.push(libraryItem)\n    }\n\n    const options = {\n      forceEmbedChapters: req.query.forceEmbedChapters === '1',\n      backup: req.query.backup === '1'\n    }\n    this.audioMetadataManager.handleBatchEmbed(req.user.id, libraryItems, options)\n    res.sendStatus(200)\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`[LibraryItemController] Non-root user \"${req.user.username}\" attempted to access tools route`)\n      return res.sendStatus(403)\n    }\n\n    if (req.params.id) {\n      const item = await Database.libraryItemModel.getExpandedById(req.params.id)\n      if (!item?.media) return res.sendStatus(404)\n\n      // Check user can access this library item\n      if (!req.user.checkCanAccessLibraryItem(item)) {\n        return res.sendStatus(403)\n      }\n\n      req.libraryItem = item\n    }\n\n    next()\n  }\n}\nmodule.exports = new ToolsController()\n"
  },
  {
    "path": "server/controllers/UserController.js",
    "content": "const { Request, Response, NextFunction } = require('express')\nconst uuidv4 = require('uuid').v4\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst { toNumber } = require('../utils/index')\n\n/**\n * @typedef RequestUserObject\n * @property {import('../models/User')} user\n *\n * @typedef {Request & RequestUserObject} RequestWithUser\n *\n * @typedef RequestEntityObject\n * @property {import('../models/User')} reqUser\n *\n * @typedef {RequestWithUser & RequestEntityObject} UserControllerRequest\n */\n\nclass UserController {\n  constructor() {}\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async findAll(req, res) {\n    if (!req.user.isAdminOrUp) return res.sendStatus(403)\n    const hideRootToken = !req.user.isRoot\n\n    const includes = (req.query.include || '').split(',').map((i) => i.trim())\n\n    // Minimal toJSONForBrowser does not include mediaProgress and bookmarks\n    const allUsers = await Database.userModel.findAll()\n    const users = allUsers.map((u) => u.toOldJSONForBrowser(hideRootToken, true))\n\n    if (includes.includes('latestSession')) {\n      for (const user of users) {\n        const userSessions = await Database.getPlaybackSessions({ userId: user.id })\n        user.latestSession = userSessions.sort((a, b) => b.updatedAt - a.updatedAt).shift() || null\n      }\n    }\n\n    res.json({\n      users\n    })\n  }\n\n  /**\n   * GET: /api/users/:id\n   * Get a single user toJSONForBrowser\n   * Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`\n   *\n   * @param {UserControllerRequest} req\n   * @param {Response} res\n   */\n  async findOne(req, res) {\n    if (!req.user.isAdminOrUp) {\n      Logger.error(`Non-admin user \"${req.user.username}\" attempted to get user`)\n      return res.sendStatus(403)\n    }\n\n    // Get user media progress with associated mediaItem\n    const mediaProgresses = await Database.mediaProgressModel.findAll({\n      where: {\n        userId: req.reqUser.id\n      },\n      include: [\n        {\n          model: Database.bookModel,\n          attributes: ['id', 'title', 'coverPath', 'updatedAt']\n        },\n        {\n          model: Database.podcastEpisodeModel,\n          attributes: ['id', 'title'],\n          include: {\n            model: Database.podcastModel,\n            attributes: ['id', 'title', 'coverPath', 'updatedAt']\n          }\n        }\n      ]\n    })\n\n    const oldMediaProgresses = mediaProgresses.map((mp) => {\n      const oldMediaProgress = mp.getOldMediaProgress()\n      oldMediaProgress.displayTitle = mp.mediaItem?.title\n      if (mp.mediaItem?.podcast) {\n        oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title\n        oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath\n        oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt\n      } else if (mp.mediaItem) {\n        oldMediaProgress.coverPath = mp.mediaItem.coverPath\n        oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt\n      }\n      return oldMediaProgress\n    })\n\n    const userJson = req.reqUser.toOldJSONForBrowser(!req.user.isRoot)\n\n    userJson.mediaProgress = oldMediaProgresses\n\n    res.json(userJson)\n  }\n\n  /**\n   * POST: /api/users\n   * Create a new user\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async create(req, res) {\n    if (!req.body.username || !req.body.password || typeof req.body.username !== 'string' || typeof req.body.password !== 'string') {\n      return res.status(400).send('Username and password are required')\n    }\n    if (req.body.type && !Database.userModel.accountTypes.includes(req.body.type)) {\n      return res.status(400).send('Invalid account type')\n    }\n\n    const usernameExists = await Database.userModel.checkUserExistsWithUsername(req.body.username)\n    if (usernameExists) {\n      return res.status(400).send('Username already taken')\n    }\n\n    const userId = uuidv4()\n    const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)\n    const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })\n    const userType = req.body.type || 'user'\n\n    // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions\n    // Old model stored them outside of permissions, new model stores them inside permissions\n    let reqLibrariesAccessible = req.body.librariesAccessible || req.body.permissions?.librariesAccessible\n    if (reqLibrariesAccessible && (!Array.isArray(reqLibrariesAccessible) || reqLibrariesAccessible.some((libId) => typeof libId !== 'string'))) {\n      Logger.warn(`[UserController] create: Invalid librariesAccessible value: ${reqLibrariesAccessible}`)\n      reqLibrariesAccessible = null\n    }\n    let reqItemTagsSelected = req.body.itemTagsSelected || req.body.permissions?.itemTagsSelected\n    if (reqItemTagsSelected && (!Array.isArray(reqItemTagsSelected) || reqItemTagsSelected.some((tagId) => typeof tagId !== 'string'))) {\n      Logger.warn(`[UserController] create: Invalid itemTagsSelected value: ${reqItemTagsSelected}`)\n      reqItemTagsSelected = null\n    }\n    if (req.body.permissions?.itemTagsSelected || req.body.permissions?.librariesAccessible) {\n      delete req.body.permissions.itemTagsSelected\n      delete req.body.permissions.librariesAccessible\n    }\n\n    // Map permissions\n    const permissions = Database.userModel.getDefaultPermissionsForUserType(userType)\n    if (req.body.permissions && typeof req.body.permissions === 'object') {\n      for (const key in req.body.permissions) {\n        if (permissions[key] !== undefined) {\n          if (typeof req.body.permissions[key] !== 'boolean') {\n            Logger.warn(`[UserController] create: Invalid permission value for key ${key}. Should be boolean`)\n          } else {\n            permissions[key] = req.body.permissions[key]\n          }\n        } else {\n          Logger.warn(`[UserController] create: Invalid permission key: ${key}`)\n        }\n      }\n    }\n\n    permissions.itemTagsSelected = reqItemTagsSelected || []\n    permissions.librariesAccessible = reqLibrariesAccessible || []\n\n    const newUser = {\n      id: userId,\n      type: userType,\n      username: req.body.username,\n      email: typeof req.body.email === 'string' ? req.body.email : null,\n      pash,\n      token,\n      isActive: !!req.body.isActive,\n      permissions,\n      bookmarks: [],\n      extraData: {\n        seriesHideFromContinueListening: []\n      }\n    }\n\n    const user = await Database.userModel.create(newUser)\n    if (user) {\n      SocketAuthority.adminEmitter('user_added', user.toOldJSONForBrowser())\n      res.json({\n        user: user.toOldJSONForBrowser()\n      })\n    } else {\n      return res.status(500).send('Failed to save new user')\n    }\n  }\n\n  /**\n   * PATCH: /api/users/:id\n   * Update user\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {UserControllerRequest} req\n   * @param {Response} res\n   */\n  async update(req, res) {\n    const user = req.reqUser\n\n    if (user.isRoot && !req.user.isRoot) {\n      Logger.error(`[UserController] Admin user \"${req.user.username}\" attempted to update root user`)\n      return res.sendStatus(403)\n    } else if (user.isRoot) {\n      // Root user cannot update type\n      delete req.body.type\n    }\n\n    const updatePayload = req.body\n\n    // Validate payload\n    const keysThatCannotBeUpdated = ['id', 'pash', 'token', 'extraData', 'bookmarks']\n    for (const key of keysThatCannotBeUpdated) {\n      if (updatePayload[key] !== undefined) {\n        return res.status(400).send(`Key \"${key}\" cannot be updated`)\n      }\n    }\n    if (updatePayload.email && typeof updatePayload.email !== 'string') {\n      return res.status(400).send('Invalid email')\n    }\n    if (updatePayload.username && typeof updatePayload.username !== 'string') {\n      return res.status(400).send('Invalid username')\n    }\n    if (updatePayload.type && !Database.userModel.accountTypes.includes(updatePayload.type)) {\n      return res.status(400).send('Invalid account type')\n    }\n    if (updatePayload.permissions && typeof updatePayload.permissions !== 'object') {\n      return res.status(400).send('Invalid permissions')\n    }\n\n    let hasUpdates = false\n    let shouldUpdateToken = false\n    let shouldInvalidateJwtSessions = false\n    // When changing username create a new API token\n    if (updatePayload.username && updatePayload.username !== user.username) {\n      const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)\n      if (usernameExists) {\n        return res.status(400).send('Username already taken')\n      }\n      user.username = updatePayload.username\n      shouldUpdateToken = true\n      shouldInvalidateJwtSessions = true\n      hasUpdates = true\n    }\n\n    // Updating password\n    if (updatePayload.password) {\n      user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)\n      hasUpdates = true\n    }\n\n    let hasPermissionsUpdates = false\n    let updateLibrariesAccessible = updatePayload.librariesAccessible || updatePayload.permissions?.librariesAccessible\n    if (updateLibrariesAccessible && (!Array.isArray(updateLibrariesAccessible) || updateLibrariesAccessible.some((libId) => typeof libId !== 'string'))) {\n      Logger.warn(`[UserController] update: Invalid librariesAccessible value: ${updateLibrariesAccessible}`)\n      updateLibrariesAccessible = null\n    }\n    let updateItemTagsSelected = updatePayload.itemTagsSelected || updatePayload.permissions?.itemTagsSelected\n    if (updateItemTagsSelected && (!Array.isArray(updateItemTagsSelected) || updateItemTagsSelected.some((tagId) => typeof tagId !== 'string'))) {\n      Logger.warn(`[UserController] update: Invalid itemTagsSelected value: ${updateItemTagsSelected}`)\n      updateItemTagsSelected = null\n    }\n    if (updatePayload.permissions?.itemTagsSelected || updatePayload.permissions?.librariesAccessible) {\n      delete updatePayload.permissions.itemTagsSelected\n      delete updatePayload.permissions.librariesAccessible\n    }\n    if (updatePayload.permissions && typeof updatePayload.permissions === 'object') {\n      const permissions = {\n        ...user.permissions\n      }\n      const defaultPermissions = Database.userModel.getDefaultPermissionsForUserType(updatePayload.type || user.type || 'user')\n      for (const key in updatePayload.permissions) {\n        // Check that the key is a valid permission key or is included in the default permissions\n        if (permissions[key] !== undefined || defaultPermissions[key] !== undefined) {\n          if (typeof updatePayload.permissions[key] !== 'boolean') {\n            Logger.warn(`[UserController] update: Invalid permission value for key ${key}. Should be boolean`)\n          } else if (permissions[key] !== updatePayload.permissions[key]) {\n            permissions[key] = updatePayload.permissions[key]\n            hasPermissionsUpdates = true\n          }\n        } else {\n          Logger.warn(`[UserController] update: Invalid permission key: ${key}`)\n        }\n      }\n\n      if (updateItemTagsSelected && updateItemTagsSelected.join(',') !== user.permissions.itemTagsSelected.join(',')) {\n        permissions.itemTagsSelected = updateItemTagsSelected\n        hasPermissionsUpdates = true\n      }\n      if (updateLibrariesAccessible && updateLibrariesAccessible.join(',') !== user.permissions.librariesAccessible.join(',')) {\n        permissions.librariesAccessible = updateLibrariesAccessible\n        hasPermissionsUpdates = true\n      }\n      updatePayload.permissions = permissions\n    }\n\n    // Permissions were updated\n    if (hasPermissionsUpdates) {\n      user.permissions = updatePayload.permissions\n      user.changed('permissions', true)\n      hasUpdates = true\n    }\n\n    if (updatePayload.email && updatePayload.email !== user.email) {\n      user.email = updatePayload.email\n      hasUpdates = true\n    }\n    if (updatePayload.type && updatePayload.type !== user.type) {\n      user.type = updatePayload.type\n      hasUpdates = true\n    }\n    if (updatePayload.isActive !== undefined && !!updatePayload.isActive !== user.isActive) {\n      user.isActive = updatePayload.isActive\n      hasUpdates = true\n    }\n    if (updatePayload.lastSeen && typeof updatePayload.lastSeen === 'number') {\n      user.lastSeen = updatePayload.lastSeen\n      hasUpdates = true\n    }\n\n    if (hasUpdates) {\n      if (shouldUpdateToken) {\n        user.token = this.auth.generateAccessToken(user)\n        Logger.info(`[UserController] User ${user.username} has generated a new api token`)\n      }\n\n      // Handle JWT session invalidation for username changes\n      if (shouldInvalidateJwtSessions) {\n        const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)\n        if (newAccessToken) {\n          user.accessToken = newAccessToken\n          // Refresh tokens are only returned for mobile clients\n          // Mobile apps currently do not use this API endpoint so always set to null\n          user.refreshToken = null\n          Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)\n        } else {\n          Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)\n        }\n      }\n\n      await user.save()\n      SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())\n    }\n\n    res.json({\n      success: true,\n      user: user.toOldJSONForBrowser()\n    })\n  }\n\n  /**\n   * DELETE: /api/users/:id\n   * Delete a user\n   *\n   * @param {UserControllerRequest} req\n   * @param {Response} res\n   */\n  async delete(req, res) {\n    if (req.params.id === 'root') {\n      Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')\n      return res.sendStatus(400)\n    }\n    if (req.user.id === req.params.id) {\n      Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)\n      return res.sendStatus(400)\n    }\n    const user = req.reqUser\n\n    // Todo: check if user is logged in and cancel streams\n\n    // Remove user playlists\n    const userPlaylists = await Database.playlistModel.findAll({\n      where: {\n        userId: user.id\n      }\n    })\n    for (const playlist of userPlaylists) {\n      await playlist.destroy()\n    }\n\n    // Set PlaybackSessions userId to null\n    const [sessionsUpdated] = await Database.playbackSessionModel.update(\n      {\n        userId: null\n      },\n      {\n        where: {\n          userId: user.id\n        }\n      }\n    )\n    Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)\n\n    const userJson = user.toOldJSONForBrowser()\n    await user.destroy()\n    SocketAuthority.adminEmitter('user_removed', userJson)\n    res.json({\n      success: true\n    })\n  }\n\n  /**\n   * PATCH: /api/users/:id/openid-unlink\n   *\n   * @param {UserControllerRequest} req\n   * @param {Response} res\n   */\n  async unlinkFromOpenID(req, res) {\n    Logger.debug(`[UserController] Unlinking user \"${req.reqUser.username}\" from OpenID with sub \"${req.reqUser.authOpenIDSub}\"`)\n\n    if (!req.reqUser.authOpenIDSub) {\n      return res.sendStatus(200)\n    }\n\n    req.reqUser.extraData.authOpenIDSub = null\n    req.reqUser.changed('extraData', true)\n    await req.reqUser.save()\n    SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.reqUser.toOldJSONForBrowser())\n    res.sendStatus(200)\n  }\n\n  /**\n   * GET: /api/users/:id/listening-sessions\n   *\n   * @param {UserControllerRequest} req\n   * @param {Response} res\n   */\n  async getListeningSessions(req, res) {\n    var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)\n\n    const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10\n    const page = toNumber(req.query.page, 0)\n\n    const start = page * itemsPerPage\n    // Map user to sessions to match the format of the sessions endpoint\n    const sessions = listeningSessions.slice(start, start + itemsPerPage).map((session) => {\n      return {\n        ...session,\n        user: {\n          id: req.reqUser.id,\n          username: req.reqUser.username\n        }\n      }\n    })\n\n    const payload = {\n      total: listeningSessions.length,\n      numPages: Math.ceil(listeningSessions.length / itemsPerPage),\n      page,\n      itemsPerPage,\n      sessions\n    }\n\n    res.json(payload)\n  }\n\n  /**\n   * GET: /api/users/:id/listening-stats\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {UserControllerRequest} req\n   * @param {Response} res\n   */\n  async getListeningStats(req, res) {\n    var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)\n    res.json(listeningStats)\n  }\n\n  /**\n   * GET: /api/users/online\n   *\n   * @this {import('../routers/ApiRouter')}\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   */\n  async getOnlineUsers(req, res) {\n    if (!req.user.isAdminOrUp) {\n      return res.sendStatus(403)\n    }\n\n    res.json({\n      usersOnline: SocketAuthority.getUsersOnline(),\n      openSessions: this.playbackSessionManager.sessions\n    })\n  }\n\n  /**\n   *\n   * @param {RequestWithUser} req\n   * @param {Response} res\n   * @param {NextFunction} next\n   */\n  async middleware(req, res, next) {\n    if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {\n      return res.sendStatus(403)\n    } else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {\n      return res.sendStatus(403)\n    }\n\n    if (req.params.id) {\n      req.reqUser = await Database.userModel.getUserById(req.params.id)\n      if (!req.reqUser) {\n        return res.sendStatus(404)\n      }\n    }\n\n    next()\n  }\n}\nmodule.exports = new UserController()\n"
  },
  {
    "path": "server/finders/AuthorFinder.js",
    "content": "const fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst Path = require('path')\nconst Audnexus = require('../providers/Audnexus')\n\nconst { downloadImageFile } = require('../utils/fileUtils')\n\nclass AuthorFinder {\n  constructor() {\n    this.audnexus = new Audnexus()\n  }\n\n  findAuthorByASIN(asin, region) {\n    if (!asin) return null\n    return this.audnexus.findAuthorByASIN(asin, region)\n  }\n\n  /**\n   * \n   * @param {string} name \n   * @param {string} region \n   * @param {Object} [options={}] \n   * @returns {Promise<import('../providers/Audnexus').AuthorSearchObj>}\n   */\n  async findAuthorByName(name, region, options = {}) {\n    if (!name) return null\n    const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3\n\n    const author = await this.audnexus.findAuthorByName(name, region, maxLevenshtein)\n    if (!author?.name) {\n      return null\n    }\n    return author\n  }\n\n  /**\n   * Download author image from url and save in authors folder\n   * \n   * @param {string} authorId \n   * @param {string} url \n   * @returns {Promise<{path:string, error:string}>}\n   */\n  async saveAuthorImage(authorId, url) {\n    const authorDir = Path.join(global.MetadataPath, 'authors')\n\n    if (!await fs.pathExists(authorDir)) {\n      await fs.ensureDir(authorDir)\n    }\n\n    const imageExtension = url.toLowerCase().split('.').pop()\n    const ext = imageExtension === 'png' ? 'png' : 'jpg'\n    const filename = authorId + '.' + ext\n    const outputPath = Path.posix.join(authorDir, filename)\n\n    return downloadImageFile(url, outputPath).then(() => {\n      return {\n        path: outputPath\n      }\n    }).catch((err) => {\n      let errorMsg = err.message || 'Unknown error'\n      Logger.error(`[AuthorFinder] Download image file failed for \"${url}\"`, errorMsg)\n      return {\n        error: errorMsg\n      }\n    })\n  }\n}\nmodule.exports = new AuthorFinder()"
  },
  {
    "path": "server/finders/BookFinder.js",
    "content": "const OpenLibrary = require('../providers/OpenLibrary')\nconst GoogleBooks = require('../providers/GoogleBooks')\nconst Audible = require('../providers/Audible')\nconst iTunes = require('../providers/iTunes')\nconst Audnexus = require('../providers/Audnexus')\nconst FantLab = require('../providers/FantLab')\nconst AudiobookCovers = require('../providers/AudiobookCovers')\nconst CustomProviderAdapter = require('../providers/CustomProviderAdapter')\nconst Logger = require('../Logger')\nconst { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\nclass BookFinder {\n  #providerResponseTimeout = 10000\n\n  constructor() {\n    this.openLibrary = new OpenLibrary()\n    this.googleBooks = new GoogleBooks()\n    this.audible = new Audible()\n    this.iTunesApi = new iTunes()\n    this.audnexus = new Audnexus()\n    this.fantLab = new FantLab()\n    this.audiobookCovers = new AudiobookCovers()\n    this.customProviderAdapter = new CustomProviderAdapter()\n\n    this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es']\n\n    this.verbose = false\n  }\n\n  async findByISBN(isbn) {\n    var book = await this.openLibrary.isbnLookup(isbn)\n    if (book.errorCode) {\n      Logger.error('Book not found')\n    }\n    return book\n  }\n\n  filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {\n    var searchTitle = cleanTitleForCompares(title)\n    var searchAuthor = cleanAuthorForCompares(author)\n    return books\n      .map((b) => {\n        b.cleanedTitle = cleanTitleForCompares(b.title)\n        b.titleDistance = levenshteinDistance(b.cleanedTitle, title)\n\n        // Total length of search (title or both title & author)\n        b.totalPossibleDistance = b.title.length\n\n        if (author) {\n          if (!b.author) {\n            b.authorDistance = author.length\n          } else {\n            b.totalPossibleDistance += b.author.length\n            b.cleanedAuthor = cleanAuthorForCompares(b.author)\n\n            var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)\n            var authorDistance = levenshteinDistance(b.author || '', author)\n\n            // Use best distance\n            b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)\n\n            // Check book author contains searchAuthor\n            if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor\n            else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author\n          }\n        }\n        b.totalDistance = b.titleDistance + (b.authorDistance || 0)\n\n        // Check book title contains the searchTitle\n        if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle\n        else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title\n\n        return b\n      })\n      .filter((b) => {\n        if (b.includesTitle) {\n          // If search title was found in result title then skip over leven distance check\n          if (this.verbose) Logger.debug(`Exact title was included in \"${b.title}\", Search: \"${b.includesTitle}\"`)\n        } else if (b.titleDistance > maxTitleDistance) {\n          if (this.verbose) Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: \"${b.cleanedTitle}\"/\"${searchTitle}\"`)\n          return false\n        }\n\n        if (author) {\n          if (b.includesAuthor) {\n            // If search author was found in result author then skip over leven distance check\n            if (this.verbose) Logger.debug(`Exact author was included in \"${b.author}\", Search: \"${b.includesAuthor}\"`)\n          } else if (b.authorDistance > maxAuthorDistance) {\n            if (this.verbose) Logger.debug(`Filtering out search result \"${b.author}\", author distance = ${b.authorDistance}: \"${b.author}\"/\"${author}\"`)\n            return false\n          }\n        }\n\n        // If book total search length < 5 and was not exact match, then filter out\n        if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false\n        return true\n      })\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @param {number} maxTitleDistance\n   * @param {number} maxAuthorDistance\n   * @returns {Promise<Object[]>}\n   */\n  async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {\n    var books = await this.openLibrary.searchTitle(title, this.#providerResponseTimeout)\n    if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)\n    if (books.errorCode) {\n      Logger.error(`OpenLib Search Error ${books.errorCode}`)\n      return []\n    }\n    var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)\n    if (!booksFiltered.length && books.length) {\n      if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)\n    }\n    booksFiltered.sort((a, b) => {\n      return a.totalDistance - b.totalDistance\n    })\n\n    return booksFiltered\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @returns {Promise<Object[]>}\n   */\n  async getGoogleBooksResults(title, author) {\n    var books = await this.googleBooks.search(title, author, this.#providerResponseTimeout)\n    if (this.verbose) Logger.debug(`GoogleBooks Book Search Results: ${books.length || 0}`)\n    if (books.errorCode) {\n      Logger.error(`GoogleBooks Search Error ${books.errorCode}`)\n      return []\n    }\n    // Google has good sort\n    return books\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @returns {Promise<Object[]>}\n   */\n  async getFantLabResults(title, author) {\n    var books = await this.fantLab.search(title, author, this.#providerResponseTimeout)\n    if (this.verbose) Logger.debug(`FantLab Book Search Results: ${books.length || 0}`)\n    if (books.errorCode) {\n      Logger.error(`FantLab Search Error ${books.errorCode}`)\n      return []\n    }\n\n    return books\n  }\n\n  /**\n   *\n   * @param {string} search\n   * @returns {Promise<Object[]>}\n   */\n  async getAudiobookCoversResults(search) {\n    const covers = await this.audiobookCovers.search(search, this.#providerResponseTimeout)\n    if (this.verbose) Logger.debug(`AudiobookCovers Search Results: ${covers.length || 0}`)\n    return covers || []\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @returns {Promise<Object[]>}\n   */\n  async getiTunesAudiobooksResults(title) {\n    return this.iTunesApi.searchAudiobooks(title, this.#providerResponseTimeout)\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @param {string} asin\n   * @param {string} provider\n   * @returns {Promise<Object[]>}\n   */\n  async getAudibleResults(title, author, asin, provider) {\n    const region = provider.includes('.') ? provider.split('.').pop() : ''\n    const books = await this.audible.search(title, author, asin, region, this.#providerResponseTimeout)\n    if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)\n    if (!books) return []\n    return books\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @param {string} isbn\n   * @param {string} providerSlug\n   * @returns {Promise<Object[]>}\n   */\n  async getCustomProviderResults(title, author, isbn, providerSlug) {\n    try {\n      const books = await this.customProviderAdapter.search(title, author, isbn, providerSlug, 'book', this.#providerResponseTimeout)\n      if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`)\n      return books\n    } catch (error) {\n      Logger.error(`Error searching Custom provider '${providerSlug}':`, error)\n      return []\n    }\n  }\n\n  static TitleCandidates = class {\n    constructor(cleanAuthor) {\n      this.candidates = new Set()\n      this.cleanAuthor = cleanAuthor\n      this.priorities = {}\n      this.positions = {}\n      this.currentPosition = 0\n    }\n\n    add(title) {\n      // if title contains the author, remove it\n      title = this.#removeAuthorFromTitle(title)\n\n      const titleTransformers = [\n        [/(: |[,;_]| by ).*/g, ''], // Remove subtitle\n        [/(^| )\\d+k(bps)?( |$)/, ' '], // Remove bitrate\n        [/ (2nd|3rd|\\d+th)\\s+ed(\\.|ition)?/g, ''], // Remove edition\n        [/(^| |\\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type\n        [/ a novel.*$/g, ''], // Remove \"a novel\"\n        [/(^| )(un)?abridged( |$)/g, ' '], // Remove \"unabridged/abridged\"\n        [/^\\d+ | \\d+$/g, ''] // Remove preceding/trailing numbers\n      ]\n\n      // Main variant\n      const cleanTitle = cleanTitleForCompares(title).trim()\n      if (!cleanTitle) return\n      this.candidates.add(cleanTitle)\n      this.priorities[cleanTitle] = 0\n      this.positions[cleanTitle] = this.currentPosition\n\n      let candidate = cleanTitle\n\n      for (const transformer of titleTransformers) candidate = candidate.replace(transformer[0], transformer[1]).trim()\n\n      if (candidate != cleanTitle) {\n        if (candidate) {\n          this.candidates.add(candidate)\n          this.priorities[candidate] = 0\n          this.positions[candidate] = this.currentPosition\n        }\n        this.priorities[cleanTitle] = 1\n      }\n      this.currentPosition++\n    }\n\n    get size() {\n      return this.candidates.size\n    }\n\n    getCandidates() {\n      var candidates = [...this.candidates]\n      candidates.sort((a, b) => {\n        // Candidates that include only digits are also likely low quality\n        const onlyDigits = /^\\d+$/\n        const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b)\n        if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff\n        // transformed candidates receive higher priority\n        const priorityDiff = this.priorities[a] - this.priorities[b]\n        if (priorityDiff) return priorityDiff\n        // if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles)\n        const positionDiff = this.positions[a] - this.positions[b]\n        return positionDiff // candidates with same priority always have different positions\n      })\n      Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`)\n      Logger.debug(candidates)\n      return candidates\n    }\n\n    delete(title) {\n      return this.candidates.delete(title)\n    }\n\n    #removeAuthorFromTitle(title) {\n      if (!this.cleanAuthor) return title\n      const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, 'g')\n      const authorCleanedTitle = cleanAuthorForCompares(title)\n      const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '')\n      if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) {\n        return authorCleanedTitleWithoutAuthor.trim()\n      }\n      return title\n    }\n  }\n\n  static AuthorCandidates = class {\n    constructor(cleanAuthor, audnexus) {\n      this.audnexus = audnexus\n      this.candidates = new Set()\n      this.cleanAuthor = cleanAuthor\n      if (cleanAuthor) this.candidates.add(cleanAuthor)\n    }\n\n    validateAuthor(name, region = '', maxLevenshtein = 2) {\n      return this.audnexus.authorASINsRequest(name, region).then((asins) => {\n        for (const [i, asin] of asins.entries()) {\n          if (i > 10) break\n          let cleanName = cleanAuthorForCompares(asin.name)\n          if (!cleanName) continue\n          if (cleanName.includes(name)) return name\n          if (name.includes(cleanName)) return cleanName\n          if (levenshteinDistance(cleanName, name) <= maxLevenshtein) return cleanName\n        }\n        return ''\n      })\n    }\n\n    add(author) {\n      const cleanAuthor = cleanAuthorForCompares(author).trim()\n      if (!cleanAuthor) return\n      this.candidates.add(cleanAuthor)\n    }\n\n    get size() {\n      return this.candidates.size\n    }\n\n    get agressivelyCleanAuthor() {\n      if (this.cleanAuthor) {\n        const agressivelyCleanAuthor = this.cleanAuthor.replace(/[,/-].*$/, '').trim()\n        return agressivelyCleanAuthor ? agressivelyCleanAuthor : this.cleanAuthor\n      }\n      return ''\n    }\n\n    async getCandidates() {\n      var filteredCandidates = []\n      var promises = []\n      for (const candidate of this.candidates) {\n        promises.push(this.validateAuthor(candidate))\n      }\n      const results = [...new Set(await Promise.all(promises))]\n      filteredCandidates = results.filter((author) => author)\n      // If no valid candidates were found, add back an aggresively cleaned author version\n      if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.agressivelyCleanAuthor)\n      // Always add an empty author candidate\n      filteredCandidates.push('')\n      Logger.debug(`[${this.constructor.name}] Found ${filteredCandidates.length} fuzzy author candidates`)\n      Logger.debug(filteredCandidates)\n      return filteredCandidates\n    }\n\n    delete(author) {\n      return this.candidates.delete(author)\n    }\n  }\n\n  /**\n   * Search for books including fuzzy searches\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {string} provider\n   * @param {string} title\n   * @param {string} author\n   * @param {string} isbn\n   * @param {string} asin\n   * @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options\n   * @returns {Promise<Object[]>}\n   */\n  async search(libraryItem, provider, title, author, isbn, asin, options = {}) {\n    let books = []\n    const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4\n    const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4\n    const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5\n    let numFuzzySearches = 0\n\n    // Custom providers are assumed to be correct\n    if (provider.startsWith('custom-')) {\n      return this.getCustomProviderResults(title, author, isbn, provider)\n    }\n\n    if (!title) return books\n\n    // Truncate excessively long inputs to prevent ReDoS attacks\n    const MAX_INPUT_LENGTH = 500\n    title = title.substring(0, MAX_INPUT_LENGTH)\n    author = author?.substring(0, MAX_INPUT_LENGTH) || author\n\n    const isTitleAsin = isValidASIN(title.toUpperCase())\n\n    let actualTitleQuery = title\n    let actualAuthorQuery = author\n    books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)\n\n    if (!books.length && maxFuzzySearches > 0) {\n      // Normalize title and author\n      title = title.trim().toLowerCase()\n      author = author?.trim().toLowerCase() || ''\n\n      const cleanAuthor = cleanAuthorForCompares(author)\n\n      // Now run up to maxFuzzySearches fuzzy searches\n      let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)\n\n      // Remove underscores and parentheses with their contents, and replace with a separator\n      // Use negated character classes to prevent ReDoS vulnerability (input length validated at entry point)\n      const cleanTitle = title.replace(/\\[[^\\]]*\\]|\\([^)]*\\)|{[^}]*}|_/g, ' - ')\n      // Split title into hypen-separated parts\n      const titleParts = cleanTitle.split(/ - | -|- /)\n      for (const titlePart of titleParts) authorCandidates.add(titlePart)\n      authorCandidates = await authorCandidates.getCandidates()\n      loop_author: for (const authorCandidate of authorCandidates) {\n        let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)\n        for (const titlePart of titleParts) titleCandidates.add(titlePart)\n        titleCandidates = titleCandidates.getCandidates()\n        for (const titleCandidate of titleCandidates) {\n          if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this\n          if (++numFuzzySearches > maxFuzzySearches) break loop_author\n          actualTitleQuery = titleCandidate\n          actualAuthorQuery = authorCandidate\n          books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)\n          if (books.length) break loop_author\n        }\n      }\n    }\n\n    if (books.length && libraryItem) {\n      const isAudibleProvider = provider.startsWith('audible')\n      const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null\n\n      books.forEach((book) => {\n        if (typeof book !== 'object' || !isAudibleProvider) return\n        book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin)\n      })\n\n      if (isAudibleProvider && libraryItemDurationMinutes) {\n        books.sort((a, b) => {\n          const aDuration = a.duration || Number.POSITIVE_INFINITY\n          const bDuration = b.duration || Number.POSITIVE_INFINITY\n          const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes)\n          const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes)\n          return aDurationDiff - bDurationDiff\n        })\n      }\n    }\n    return books\n  }\n\n  /**\n   * Calculate match confidence score for a book\n   * @param {Object} book - The book object to calculate confidence for\n   * @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes\n   * @param {string} actualTitleQuery - Actual title query\n   * @param {string} actualAuthorQuery - Actual author query\n   * @param {boolean} isTitleAsin - Whether the title is an ASIN\n   * @returns {number|null} - Match confidence score or null if not applicable\n   */\n  calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) {\n    // ASIN results are always a match\n    if (isTitleAsin) return 1.0\n\n    let durationScore\n    if (libraryItemDurationMinutes && typeof book.duration === 'number') {\n      const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes)\n      // Duration scores:\n      // diff | score\n      // 0    | 1.0\n      // 1    | 1.0\n      // 2    | 0.9\n      // 3    | 0.8\n      // 4    | 0.7\n      // 5    | 0.6\n      // 6    | 0.48\n      // 7    | 0.36\n      // 8    | 0.24\n      // 9    | 0.12\n      // 10   | 0.0\n      if (durationDiff <= 1) {\n        // Covers durationDiff = 0 for score 1.0\n        durationScore = 1.0\n      } else if (durationDiff <= 5) {\n        // (1, 5] - Score from 1.0 down to 0.6\n        // Linearly interpolates between (1, 1.0) and (5, 0.6)\n        // Equation: y = 1.0 - 0.08 * x\n        durationScore = 1.1 - 0.1 * durationDiff\n      } else if (durationDiff <= 10) {\n        // (5, 10] - Score from 0.6 down to 0.0\n        // Linearly interpolates between (5, 0.6) and (10, 0.0)\n        // Equation: y = 1.2 - 0.12 * x\n        durationScore = 1.2 - 0.12 * durationDiff\n      } else {\n        // durationDiff > 10 - Score is 0.0\n        durationScore = 0.0\n      }\n      Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`)\n    } else {\n      // Default score if library item duration or book duration is not available\n      durationScore = 0.1\n    }\n\n    const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => {\n      const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle)\n      const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : ''\n      const normBookTitle = `${cleanTitle}${cleanSubtitle}`\n      const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle)\n      const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle)\n      Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`)\n      return titleSimilarity\n    }\n    const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery)\n    const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle)\n\n    let authorScore\n    const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery)\n    const normBookAuthor = cleanAuthorForCompares(book.author || '')\n    if (!normAuthorQuery) {\n      // Original query had no author\n      authorScore = 1.0 // Neutral score\n    } else {\n      // Original query HAS an author (cleanedQueryAuthorForScore is not empty)\n      if (normBookAuthor) {\n        const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase())\n        // Filter out empty parts that might result from \", ,\" or trailing/leading commas\n        const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0)\n\n        if (validBookAuthorParts.length === 0) {\n          // Book author string was present but effectively empty (e.g. \",,\")\n          // Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch.\n          authorScore = 0.0\n        } else {\n          let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor)\n          Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`)\n          if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) {\n            validBookAuthorParts.forEach((part) => {\n              // part is guaranteed to be non-empty here\n              // cleanedQueryAuthorForScore is also guaranteed non-empty here.\n              // levenshteinDistance lowercases by default, but part is already lowercased.\n              const similarity = levenshteinSimilarity(normAuthorQuery, part)\n              Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`)\n              const currentPartScore = similarity\n              maxPartScore = Math.max(maxPartScore, currentPartScore)\n            })\n          }\n          authorScore = maxPartScore\n        }\n      } else {\n        // Book has NO author (or not a string, or empty string)\n        // Query has an author (cleanedQueryAuthorForScore is non-empty), book does not.\n        authorScore = 0.0\n      }\n    }\n\n    const W_DURATION = 0.7\n    const W_TITLE = 0.2\n    const W_AUTHOR = 0.1\n\n    Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`)\n    const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore\n    Logger.debug(`[BookFinder] Confidence: ${confidence}`)\n    return Math.max(0, Math.min(1, confidence))\n  }\n\n  /**\n   * Search for books\n   *\n   * @param {string} title\n   * @param {string} author\n   * @param {string} provider\n   * @param {string} asin only used for audible providers\n   * @param {number} maxTitleDistance only used for openlibrary provider\n   * @param {number} maxAuthorDistance only used for openlibrary provider\n   * @returns {Promise<Object[]>}\n   */\n  async runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance) {\n    Logger.debug(`Book Search: title: \"${title}\", author: \"${author || ''}\", provider: ${provider}`)\n\n    let books = []\n\n    if (provider === 'google') {\n      books = await this.getGoogleBooksResults(title, author)\n    } else if (provider.startsWith('audible')) {\n      books = await this.getAudibleResults(title, author, asin, provider)\n    } else if (provider === 'itunes') {\n      books = await this.getiTunesAudiobooksResults(title)\n    } else if (provider === 'openlibrary') {\n      books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)\n    } else if (provider === 'fantlab') {\n      books = await this.getFantLabResults(title, author)\n    } else if (provider === 'audiobookcovers') {\n      books = await this.getAudiobookCoversResults(title)\n    } else {\n      books = await this.getGoogleBooksResults(title, author)\n    }\n\n    books.forEach((book) => {\n      if (book.description) {\n        book.description = htmlSanitizer.sanitize(book.description)\n        book.descriptionPlain = htmlSanitizer.stripAllTags(book.description)\n      }\n    })\n    return books\n  }\n\n  async findCovers(provider, title, author, options = {}) {\n    let searchResults = []\n\n    if (provider === 'all') {\n      for (const providerString of this.providers) {\n        const providerResults = await this.search(null, providerString, title, author, options)\n        Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)\n        searchResults.push(...providerResults)\n      }\n    } else if (provider === 'best') {\n      // Best providers: google, fantlab, and audible.com\n      const bestProviders = ['google', 'fantlab', 'audible']\n      for (const providerString of bestProviders) {\n        const providerResults = await this.search(null, providerString, title, author, options)\n        Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)\n        searchResults.push(...providerResults)\n      }\n    } else {\n      searchResults = await this.search(null, provider, title, author, options)\n    }\n    Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)\n\n    const covers = []\n    searchResults.forEach((result) => {\n      if (result.covers && result.covers.length) {\n        covers.push(...result.covers)\n      }\n      if (result.cover) {\n        covers.push(result.cover)\n      }\n    })\n    return [...new Set(covers)]\n  }\n\n  findChapters(asin, region) {\n    return this.audnexus.getChaptersByASIN(asin, region)\n  }\n}\nmodule.exports = new BookFinder()\n\nfunction hasSubtitle(title) {\n  return title.includes(': ') || title.includes(' - ')\n}\nfunction stripSubtitle(title) {\n  if (title.includes(': ')) {\n    return title.split(': ')[0].trim()\n  } else if (title.includes(' - ')) {\n    return title.split(' - ')[0].trim()\n  }\n  return title\n}\n\nfunction replaceAccentedChars(str) {\n  try {\n    return str.normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n  } catch (error) {\n    Logger.error('[BookFinder] str normalize error', error)\n    return str\n  }\n}\n\nfunction cleanTitleForCompares(title, keepSubtitle = false) {\n  if (!title) return ''\n  title = stripRedundantSpaces(title)\n\n  // Remove subtitle if there (i.e. \"Cool Book: Coolest Ever\" becomes \"Cool Book\")\n  let stripped = keepSubtitle ? title : stripSubtitle(title)\n\n  // Remove text in paranthesis (i.e. \"Ender's Game (Ender's Saga)\" becomes \"Ender's Game\")\n  // Use negated character class to prevent ReDoS vulnerability (input length validated at entry point)\n  let cleaned = stripped.replace(/\\([^)]*\\)/g, '') // Remove parenthetical content\n  cleaned = cleaned.replace(/\\s+/g, ' ').trim() // Clean up any resulting multiple spaces\n\n  // Remove single quotes (i.e. \"Ender's Game\" becomes \"Enders Game\")\n  cleaned = cleaned.replace(/'/g, '')\n  return replaceAccentedChars(cleaned).toLowerCase()\n}\n\nfunction cleanAuthorForCompares(author) {\n  if (!author) return ''\n  author = stripRedundantSpaces(author)\n\n  let cleanAuthor = replaceAccentedChars(author).toLowerCase()\n  // separate initials\n  cleanAuthor = cleanAuthor.replace(/([a-z])\\.([a-z])/g, '$1. $2')\n  // remove middle initials\n  cleanAuthor = cleanAuthor.replace(/(?<=\\w\\w)(\\s+[a-z]\\.?)+(?=\\s+\\w\\w)/g, '')\n  // remove et al.\n  cleanAuthor = cleanAuthor.replace(/ et al\\.?(?= |$)/g, '')\n  return cleanAuthor\n}\n\nfunction stripRedundantSpaces(str) {\n  return str.replace(/\\s+/g, ' ').trim()\n}\n"
  },
  {
    "path": "server/finders/PodcastFinder.js",
    "content": "const Logger = require('../Logger')\nconst iTunes = require('../providers/iTunes')\n\nclass PodcastFinder {\n  constructor() {\n    this.iTunesApi = new iTunes()\n  }\n\n  /**\n   *\n   * @param {string} term\n   * @param {{country:string}} options\n   * @returns {Promise<import('../providers/iTunes').iTunesPodcastSearchResult[]>}\n   */\n  async search(term, options = {}) {\n    if (!term) return null\n    Logger.debug(`[iTunes] Searching for podcast with term \"${term}\"`)\n    const results = await this.iTunesApi.searchPodcasts(term, options)\n    Logger.debug(`[iTunes] Podcast search for \"${term}\" returned ${results.length} results`)\n    return results\n  }\n\n  /**\n   * @param {string} term\n   * @returns {Promise<string[]>}\n   */\n  async findCovers(term) {\n    if (!term) return null\n    Logger.debug(`[iTunes] Searching for podcast covers with term \"${term}\"`)\n    const results = await this.iTunesApi.searchPodcasts(term)\n    if (!results) return []\n    return results.map((r) => r.cover).filter((r) => r)\n  }\n}\nmodule.exports = new PodcastFinder()\n"
  },
  {
    "path": "server/libs/archiver/LICENSE",
    "content": "Copyright (c) 2012-2014 Chris Talkington, contributors.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/balancedMatch/LICENSE",
    "content": "(MIT)\n\nCopyright (c) 2013 Julian Gruber &lt;julian@juliangruber.com&gt;\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\nof the Software, and to permit persons to whom the Software is furnished to do\nso, 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": "server/libs/archiver/archiverUtils/balancedMatch/index.js",
    "content": "'use strict'\n\n/**\n * @param {string | RegExp} a\n * @param {string | RegExp} b\n * @param {string} str\n */\nfunction balanced(a, b, str) {\n  if (a instanceof RegExp) a = maybeMatch(a, str)\n  if (b instanceof RegExp) b = maybeMatch(b, str)\n\n  const r = range(a, b, str)\n\n  return (\n    r && {\n      start: r[0],\n      end: r[1],\n      pre: str.slice(0, r[0]),\n      body: str.slice(r[0] + a.length, r[1]),\n      post: str.slice(r[1] + b.length)\n    }\n  )\n}\n\n/**\n * @param {RegExp} reg\n * @param {string} str\n */\nfunction maybeMatch(reg, str) {\n  const m = str.match(reg)\n  return m ? m[0] : null\n}\n\nbalanced.range = range\n\n/**\n * @param {string} a\n * @param {string} b\n * @param {string} str\n */\nfunction range(a, b, str) {\n  let begs, beg, left, right, result\n  let ai = str.indexOf(a)\n  let bi = str.indexOf(b, ai + 1)\n  let i = ai\n\n  if (ai >= 0 && bi > 0) {\n    if (a === b) {\n      return [ai, bi]\n    }\n    begs = []\n    left = str.length\n\n    while (i >= 0 && !result) {\n      if (i === ai) {\n        begs.push(i)\n        ai = str.indexOf(a, i + 1)\n      } else if (begs.length === 1) {\n        result = [begs.pop(), bi]\n      } else {\n        beg = begs.pop()\n        if (beg < left) {\n          left = beg\n          right = bi\n        }\n\n        bi = str.indexOf(b, i + 1)\n      }\n\n      i = ai < bi && ai >= 0 ? ai : bi\n    }\n\n    if (begs.length) {\n      result = [left, right]\n    }\n  }\n\n  return result\n}\n\nmodule.exports = balanced"
  },
  {
    "path": "server/libs/archiver/archiverUtils/braceExpansion/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2013 Julian Gruber <julian@juliangruber.com>\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": "server/libs/archiver/archiverUtils/braceExpansion/index.js",
    "content": "const balanced = require('../balancedMatch');\n\nconst escSlash = '\\0SLASH' + Math.random() + '\\0';\nconst escOpen = '\\0OPEN' + Math.random() + '\\0';\nconst escClose = '\\0CLOSE' + Math.random() + '\\0';\nconst escComma = '\\0COMMA' + Math.random() + '\\0';\nconst escPeriod = '\\0PERIOD' + Math.random() + '\\0';\n\n/**\n * @return {number}\n */\nfunction numeric(str) {\n  return parseInt(str, 10) == str\n    ? parseInt(str, 10)\n    : str.charCodeAt(0);\n}\n\n/**\n * @param {string} str\n */\nfunction escapeBraces(str) {\n  return str.split('\\\\\\\\').join(escSlash)\n    .split('\\\\{').join(escOpen)\n    .split('\\\\}').join(escClose)\n    .split('\\\\,').join(escComma)\n    .split('\\\\.').join(escPeriod);\n}\n\n/**\n * @param {string} str\n */\nfunction unescapeBraces(str) {\n  return str.split(escSlash).join('\\\\')\n    .split(escOpen).join('{')\n    .split(escClose).join('}')\n    .split(escComma).join(',')\n    .split(escPeriod).join('.');\n}\n\n/**\n * Basically just str.split(\",\"), but handling cases\n * where we have nested braced sections, which should be\n * treated as individual members, like {a,{b,c},d}\n * @param {string} str\n */\nfunction parseCommaParts(str) {\n  if (!str)\n    return [''];\n\n  const parts = [];\n  const m = balanced('{', '}', str);\n\n  if (!m)\n    return str.split(',');\n\n  const { pre, body, post } = m;\n  const p = pre.split(',');\n\n  p[p.length - 1] += '{' + body + '}';\n  const postParts = parseCommaParts(post);\n  if (post.length) {\n    p[p.length - 1] += postParts.shift();\n    p.push.apply(p, postParts);\n  }\n\n  parts.push.apply(parts, p);\n\n  return parts;\n}\n\n/**\n * @param {string} str\n */\nfunction expandTop(str) {\n  if (!str)\n    return [];\n\n  // I don't know why Bash 4.3 does this, but it does.\n  // Anything starting with {} will have the first two bytes preserved\n  // but *only* at the top level, so {},a}b will not expand to anything,\n  // but a{},b}c will be expanded to [a}c,abc].\n  // One could argue that this is a bug in Bash, but since the goal of\n  // this module is to match Bash's rules, we escape a leading {}\n  if (str.slice(0, 2) === '{}') {\n    str = '\\\\{\\\\}' + str.slice(2);\n  }\n\n  return expand(escapeBraces(str), true).map(unescapeBraces);\n}\n\n/**\n * @param {string} str\n */\nfunction embrace(str) {\n  return '{' + str + '}';\n}\n\n/**\n * @param {string} el\n */\nfunction isPadded(el) {\n  return /^-?0\\d/.test(el);\n}\n\n/**\n * @param {number} i\n * @param {number} y\n */\nfunction lte(i, y) {\n  return i <= y;\n}\n\n/**\n * @param {number} i\n * @param {number} y\n */\nfunction gte(i, y) {\n  return i >= y;\n}\n\n/**\n * @param {string} str\n * @param {boolean} [isTop]\n */\nfunction expand(str, isTop) {\n  /** @type {string[]} */\n  const expansions = [];\n\n  const m = balanced('{', '}', str);\n  if (!m) return [str];\n\n  // no need to expand pre, since it is guaranteed to be free of brace-sets\n  const pre = m.pre;\n  const post = m.post.length\n    ? expand(m.post, false)\n    : [''];\n\n  if (/\\$$/.test(m.pre)) {\n    for (let k = 0; k < post.length; k++) {\n      const expansion = pre + '{' + m.body + '}' + post[k];\n      expansions.push(expansion);\n    }\n  } else {\n    const isNumericSequence = /^-?\\d+\\.\\.-?\\d+(?:\\.\\.-?\\d+)?$/.test(m.body);\n    const isAlphaSequence = /^[a-zA-Z]\\.\\.[a-zA-Z](?:\\.\\.-?\\d+)?$/.test(m.body);\n    const isSequence = isNumericSequence || isAlphaSequence;\n    const isOptions = m.body.indexOf(',') >= 0;\n    if (!isSequence && !isOptions) {\n      // {a},b}\n      if (m.post.match(/,.*\\}/)) {\n        str = m.pre + '{' + m.body + escClose + m.post;\n        return expand(str);\n      }\n      return [str];\n    }\n\n    let n;\n    if (isSequence) {\n      n = m.body.split(/\\.\\./);\n    } else {\n      n = parseCommaParts(m.body);\n      if (n.length === 1) {\n        // x{{a,b}}y ==> x{a}y x{b}y\n        n = expand(n[0], false).map(embrace);\n        if (n.length === 1) {\n          return post.map(function (p) {\n            return m.pre + n[0] + p;\n          });\n        }\n      }\n    }\n\n    // at this point, n is the parts, and we know it's not a comma set\n    // with a single entry.\n    let N;\n\n    if (isSequence) {\n      const x = numeric(n[0]);\n      const y = numeric(n[1]);\n      const width = Math.max(n[0].length, n[1].length)\n      let incr = n.length == 3\n        ? Math.abs(numeric(n[2]))\n        : 1;\n      let test = lte;\n      const reverse = y < x;\n      if (reverse) {\n        incr *= -1;\n        test = gte;\n      }\n      const pad = n.some(isPadded);\n\n      N = [];\n\n      for (let i = x; test(i, y); i += incr) {\n        let c;\n        if (isAlphaSequence) {\n          c = String.fromCharCode(i);\n          if (c === '\\\\')\n            c = '';\n        } else {\n          c = String(i);\n          if (pad) {\n            const need = width - c.length;\n            if (need > 0) {\n              const z = new Array(need + 1).join('0');\n              if (i < 0)\n                c = '-' + z + c.slice(1);\n              else\n                c = z + c;\n            }\n          }\n        }\n        N.push(c);\n      }\n    } else {\n      N = [];\n\n      for (let j = 0; j < n.length; j++) {\n        N.push.apply(N, expand(n[j], false));\n      }\n    }\n\n    for (let j = 0; j < N.length; j++) {\n      for (let k = 0; k < post.length; k++) {\n        const expansion = pre + N[j] + post[k];\n        if (!isTop || isSequence || expansion)\n          expansions.push(expansion);\n      }\n    }\n  }\n\n  return expansions;\n}\n\nmodule.exports = expandTop;"
  },
  {
    "path": "server/libs/archiver/archiverUtils/file.js",
    "content": "/**\n * archiver-utils\n *\n * Copyright (c) 2012-2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-archiver/blob/master/LICENSE-MIT\n */\nvar fs = require('graceful-fs');\nvar path = require('path');\n\nvar flatten = require('./lodash.flatten')\nvar difference = require('./lodash.difference');\nvar union = require('./lodash.union');\nvar isPlainObject = require('./lodash.isplainobject');\n\nvar glob = require('./glob');\n\nvar file = module.exports = {};\n\nvar pathSeparatorRe = /[\\/\\\\]/g;\n\n// Process specified wildcard glob patterns or filenames against a\n// callback, excluding and uniquing files in the result set.\nvar processPatterns = function (patterns, fn) {\n  // Filepaths to return.\n  var result = [];\n  // Iterate over flattened patterns array.\n  flatten(patterns).forEach(function (pattern) {\n    // If the first character is ! it should be omitted\n    var exclusion = pattern.indexOf('!') === 0;\n    // If the pattern is an exclusion, remove the !\n    if (exclusion) { pattern = pattern.slice(1); }\n    // Find all matching files for this pattern.\n    var matches = fn(pattern);\n    if (exclusion) {\n      // If an exclusion, remove matching files.\n      result = difference(result, matches);\n    } else {\n      // Otherwise add matching files.\n      result = union(result, matches);\n    }\n  });\n  return result;\n};\n\n// True if the file path exists.\nfile.exists = function () {\n  var filepath = path.join.apply(path, arguments);\n  return fs.existsSync(filepath);\n};\n\n// Return an array of all file paths that match the given wildcard patterns.\nfile.expand = function (...args) {\n  // If the first argument is an options object, save those options to pass\n  // into the File.prototype.glob.sync method.\n  var options = isPlainObject(args[0]) ? args.shift() : {};\n  // Use the first argument if it's an Array, otherwise convert the arguments\n  // object to an array and use that.\n  var patterns = Array.isArray(args[0]) ? args[0] : args;\n  // Return empty set if there are no patterns or filepaths.\n  if (patterns.length === 0) { return []; }\n  // Return all matching filepaths.\n  var matches = processPatterns(patterns, function (pattern) {\n    // Find all matching files for this pattern.\n    return glob.sync(pattern, options);\n  });\n  // Filter result set?\n  if (options.filter) {\n    matches = matches.filter(function (filepath) {\n      filepath = path.join(options.cwd || '', filepath);\n      try {\n        if (typeof options.filter === 'function') {\n          return options.filter(filepath);\n        } else {\n          // If the file is of the right type and exists, this should work.\n          return fs.statSync(filepath)[options.filter]();\n        }\n      } catch (e) {\n        // Otherwise, it's probably not the right type.\n        return false;\n      }\n    });\n  }\n  return matches;\n};\n\n// Build a multi task \"files\" object dynamically.\nfile.expandMapping = function (patterns, destBase, options) {\n  options = Object.assign({\n    rename: function (destBase, destPath) {\n      return path.join(destBase || '', destPath);\n    }\n  }, options);\n  var files = [];\n  var fileByDest = {};\n  // Find all files matching pattern, using passed-in options.\n  file.expand(options, patterns).forEach(function (src) {\n    var destPath = src;\n    // Flatten?\n    if (options.flatten) {\n      destPath = path.basename(destPath);\n    }\n    // Change the extension?\n    if (options.ext) {\n      destPath = destPath.replace(/(\\.[^\\/]*)?$/, options.ext);\n    }\n    // Generate destination filename.\n    var dest = options.rename(destBase, destPath, options);\n    // Prepend cwd to src path if necessary.\n    if (options.cwd) { src = path.join(options.cwd, src); }\n    // Normalize filepaths to be unix-style.\n    dest = dest.replace(pathSeparatorRe, '/');\n    src = src.replace(pathSeparatorRe, '/');\n    // Map correct src path to dest path.\n    if (fileByDest[dest]) {\n      // If dest already exists, push this src onto that dest's src array.\n      fileByDest[dest].src.push(src);\n    } else {\n      // Otherwise create a new src-dest file mapping object.\n      files.push({\n        src: [src],\n        dest: dest,\n      });\n      // And store a reference for later use.\n      fileByDest[dest] = files[files.length - 1];\n    }\n  });\n  return files;\n};\n\n// reusing bits of grunt's multi-task source normalization\nfile.normalizeFilesArray = function (data) {\n  var files = [];\n\n  data.forEach(function (obj) {\n    var prop;\n    if ('src' in obj || 'dest' in obj) {\n      files.push(obj);\n    }\n  });\n\n  if (files.length === 0) {\n    return [];\n  }\n\n  files = _(files).chain().forEach(function (obj) {\n    if (!('src' in obj) || !obj.src) { return; }\n    // Normalize .src properties to flattened array.\n    if (Array.isArray(obj.src)) {\n      obj.src = flatten(obj.src);\n    } else {\n      obj.src = [obj.src];\n    }\n  }).map(function (obj) {\n    // Build options object, removing unwanted properties.\n    var expandOptions = Object.assign({}, obj);\n    delete expandOptions.src;\n    delete expandOptions.dest;\n\n    // Expand file mappings.\n    if (obj.expand) {\n      return file.expandMapping(obj.src, obj.dest, expandOptions).map(function (mapObj) {\n        // Copy obj properties to result.\n        var result = Object.assign({}, obj);\n        // Make a clone of the orig obj available.\n        result.orig = Object.assign({}, obj);\n        // Set .src and .dest, processing both as templates.\n        result.src = mapObj.src;\n        result.dest = mapObj.dest;\n        // Remove unwanted properties.\n        ['expand', 'cwd', 'flatten', 'rename', 'ext'].forEach(function (prop) {\n          delete result[prop];\n        });\n        return result;\n      });\n    }\n\n    // Copy obj properties to result, adding an .orig property.\n    var result = Object.assign({}, obj);\n    // Make a clone of the orig obj available.\n    result.orig = Object.assign({}, obj);\n\n    if ('src' in result) {\n      // Expose an expand-on-demand getter method as .src.\n      Object.defineProperty(result, 'src', {\n        enumerable: true,\n        get: function fn() {\n          var src;\n          if (!('result' in fn)) {\n            src = obj.src;\n            // If src is an array, flatten it. Otherwise, make it into an array.\n            src = Array.isArray(src) ? flatten(src) : [src];\n            // Expand src files, memoizing result.\n            fn.result = file.expand(expandOptions, src);\n          }\n          return fn.result;\n        }\n      });\n    }\n\n    if ('dest' in result) {\n      result.dest = obj.dest;\n    }\n\n    return result;\n  }).flatten().value();\n\n  return files;\n};\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/fsRealpath/LICENSE",
    "content": "The ISC License\n\nCopyright (c) 2016-2022 Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n\n----\n\nThis library bundles a version of the `fs.realpath` and `fs.realpathSync`\nmethods from Node.js v0.10 under the terms of the Node.js MIT license.\n\nNode's license follows, also included at the header of `old.js` which contains\nthe licensed code:\n\nCopyright (c) 2016-2022 Joyent, Inc. and other Node contributors.\n\n  Permission is hereby granted, free of charge, to any person obtaining a\n  copy of this software and associated documentation files (the \"Software\"),\n  to deal in the Software without restriction, including without limitation\n  the rights to use, copy, modify, merge, publish, distribute, sublicense,\n  and/or sell copies of the Software, and to permit persons to whom the\n  Software is furnished to do so, subject to the following conditions:\n\n  The above copyright notice and this permission notice shall be included in\n  all copies or substantial portions of the Software.\n\n  THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\n  DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/fsRealpath/index.js",
    "content": "module.exports = realpath\nrealpath.realpath = realpath\nrealpath.sync = realpathSync\nrealpath.realpathSync = realpathSync\n\nvar fs = require('fs')\nvar origRealpath = fs.realpath\nvar origRealpathSync = fs.realpathSync\n\nvar version = process.version\nvar ok = /^v[0-5]\\./.test(version)\nvar old = require('./old.js')\n\nfunction newError(er) {\n  return er && er.syscall === 'realpath' && (\n    er.code === 'ELOOP' ||\n    er.code === 'ENOMEM' ||\n    er.code === 'ENAMETOOLONG'\n  )\n}\n\nfunction realpath(p, cache, cb) {\n  if (ok) {\n    return origRealpath(p, cache, cb)\n  }\n\n  if (typeof cache === 'function') {\n    cb = cache\n    cache = null\n  }\n  origRealpath(p, cache, function (er, result) {\n    if (newError(er)) {\n      old.realpath(p, cache, cb)\n    } else {\n      cb(er, result)\n    }\n  })\n}\n\nfunction realpathSync(p, cache) {\n  if (ok) {\n    return origRealpathSync(p, cache)\n  }\n\n  try {\n    return origRealpathSync(p, cache)\n  } catch (er) {\n    if (newError(er)) {\n      return old.realpathSync(p, cache)\n    } else {\n      throw er\n    }\n  }\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/fsRealpath/old.js",
    "content": "/* istanbul ignore file - tested in node */\n// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nvar pathModule = require('path');\nvar isWindows = process.platform === 'win32';\nvar fs = require('fs');\n\n// JavaScript implementation of realpath, ported from node pre-v6\n\nvar DEBUG = process.env.NODE_DEBUG && /fs/.test(process.env.NODE_DEBUG);\n\nfunction rethrow() {\n  // Only enable in debug mode. A backtrace uses ~1000 bytes of heap space and\n  // is fairly slow to generate.\n  var callback;\n  if (DEBUG) {\n    var backtrace = new Error;\n    callback = debugCallback;\n  } else\n    callback = missingCallback;\n\n  return callback;\n\n  function debugCallback(err) {\n    if (err) {\n      backtrace.message = err.message;\n      err = backtrace;\n      missingCallback(err);\n    }\n  }\n\n  function missingCallback(err) {\n    if (err) {\n      if (process.throwDeprecation)\n        throw err;  // Forgot a callback but don't know where? Use NODE_DEBUG=fs\n      else if (!process.noDeprecation) {\n        var msg = 'fs: missing callback ' + (err.stack || err.message);\n        if (process.traceDeprecation)\n          console.trace(msg);\n        else\n          console.error(msg);\n      }\n    }\n  }\n}\n\nfunction maybeCallback(cb) {\n  return typeof cb === 'function' ? cb : rethrow();\n}\n\n// Regexp that finds the next partion of a (partial) path\n// result is [base_with_slash, base], e.g. ['somedir/', 'somedir']\nif (isWindows) {\n  var nextPartRe = /(.*?)(?:[\\/\\\\]+|$)/g;\n} else {\n  var nextPartRe = /(.*?)(?:[\\/]+|$)/g;\n}\n\n// Regex to find the device root, including trailing slash. E.g. 'c:\\\\'.\nif (isWindows) {\n  var splitRootRe = /^(?:[a-zA-Z]:|[\\\\\\/]{2}[^\\\\\\/]+[\\\\\\/][^\\\\\\/]+)?[\\\\\\/]*/;\n} else {\n  var splitRootRe = /^[\\/]*/;\n}\n\nexports.realpathSync = function realpathSync(p, cache) {\n  // make p is absolute\n  p = pathModule.resolve(p);\n\n  if (cache && Object.prototype.hasOwnProperty.call(cache, p)) {\n    return cache[p];\n  }\n\n  var original = p,\n    seenLinks = {},\n    knownHard = {};\n\n  // current character position in p\n  var pos;\n  // the partial path so far, including a trailing slash if any\n  var current;\n  // the partial path without a trailing slash (except when pointing at a root)\n  var base;\n  // the partial path scanned in the previous round, with slash\n  var previous;\n\n  start();\n\n  function start() {\n    // Skip over roots\n    var m = splitRootRe.exec(p);\n    pos = m[0].length;\n    current = m[0];\n    base = m[0];\n    previous = '';\n\n    // On windows, check that the root exists. On unix there is no need.\n    if (isWindows && !knownHard[base]) {\n      fs.lstatSync(base);\n      knownHard[base] = true;\n    }\n  }\n\n  // walk down the path, swapping out linked pathparts for their real\n  // values\n  // NB: p.length changes.\n  while (pos < p.length) {\n    // find the next part\n    nextPartRe.lastIndex = pos;\n    var result = nextPartRe.exec(p);\n    previous = current;\n    current += result[0];\n    base = previous + result[1];\n    pos = nextPartRe.lastIndex;\n\n    // continue if not a symlink\n    if (knownHard[base] || (cache && cache[base] === base)) {\n      continue;\n    }\n\n    var resolvedLink;\n    if (cache && Object.prototype.hasOwnProperty.call(cache, base)) {\n      // some known symbolic link.  no need to stat again.\n      resolvedLink = cache[base];\n    } else {\n      var stat = fs.lstatSync(base);\n      if (!stat.isSymbolicLink()) {\n        knownHard[base] = true;\n        if (cache) cache[base] = base;\n        continue;\n      }\n\n      // read the link if it wasn't read before\n      // dev/ino always return 0 on windows, so skip the check.\n      var linkTarget = null;\n      if (!isWindows) {\n        var id = stat.dev.toString(32) + ':' + stat.ino.toString(32);\n        if (seenLinks.hasOwnProperty(id)) {\n          linkTarget = seenLinks[id];\n        }\n      }\n      if (linkTarget === null) {\n        fs.statSync(base);\n        linkTarget = fs.readlinkSync(base);\n      }\n      resolvedLink = pathModule.resolve(previous, linkTarget);\n      // track this, if given a cache.\n      if (cache) cache[base] = resolvedLink;\n      if (!isWindows) seenLinks[id] = linkTarget;\n    }\n\n    // resolve the link, then start over\n    p = pathModule.resolve(resolvedLink, p.slice(pos));\n    start();\n  }\n\n  if (cache) cache[original] = p;\n\n  return p;\n};\n\n\nexports.realpath = function realpath(p, cache, cb) {\n  if (typeof cb !== 'function') {\n    cb = maybeCallback(cache);\n    cache = null;\n  }\n\n  // make p is absolute\n  p = pathModule.resolve(p);\n\n  if (cache && Object.prototype.hasOwnProperty.call(cache, p)) {\n    return process.nextTick(cb.bind(null, null, cache[p]));\n  }\n\n  var original = p,\n    seenLinks = {},\n    knownHard = {};\n\n  // current character position in p\n  var pos;\n  // the partial path so far, including a trailing slash if any\n  var current;\n  // the partial path without a trailing slash (except when pointing at a root)\n  var base;\n  // the partial path scanned in the previous round, with slash\n  var previous;\n\n  start();\n\n  function start() {\n    // Skip over roots\n    var m = splitRootRe.exec(p);\n    pos = m[0].length;\n    current = m[0];\n    base = m[0];\n    previous = '';\n\n    // On windows, check that the root exists. On unix there is no need.\n    if (isWindows && !knownHard[base]) {\n      fs.lstat(base, function (err) {\n        if (err) return cb(err);\n        knownHard[base] = true;\n        LOOP();\n      });\n    } else {\n      process.nextTick(LOOP);\n    }\n  }\n\n  // walk down the path, swapping out linked pathparts for their real\n  // values\n  function LOOP() {\n    // stop if scanned past end of path\n    if (pos >= p.length) {\n      if (cache) cache[original] = p;\n      return cb(null, p);\n    }\n\n    // find the next part\n    nextPartRe.lastIndex = pos;\n    var result = nextPartRe.exec(p);\n    previous = current;\n    current += result[0];\n    base = previous + result[1];\n    pos = nextPartRe.lastIndex;\n\n    // continue if not a symlink\n    if (knownHard[base] || (cache && cache[base] === base)) {\n      return process.nextTick(LOOP);\n    }\n\n    if (cache && Object.prototype.hasOwnProperty.call(cache, base)) {\n      // known symbolic link.  no need to stat again.\n      return gotResolvedLink(cache[base]);\n    }\n\n    return fs.lstat(base, gotStat);\n  }\n\n  function gotStat(err, stat) {\n    if (err) return cb(err);\n\n    // if not a symlink, skip to the next path part\n    if (!stat.isSymbolicLink()) {\n      knownHard[base] = true;\n      if (cache) cache[base] = base;\n      return process.nextTick(LOOP);\n    }\n\n    // stat & read the link if not read before\n    // call gotTarget as soon as the link target is known\n    // dev/ino always return 0 on windows, so skip the check.\n    if (!isWindows) {\n      var id = stat.dev.toString(32) + ':' + stat.ino.toString(32);\n      if (seenLinks.hasOwnProperty(id)) {\n        return gotTarget(null, seenLinks[id], base);\n      }\n    }\n    fs.stat(base, function (err) {\n      if (err) return cb(err);\n\n      fs.readlink(base, function (err, target) {\n        if (!isWindows) seenLinks[id] = target;\n        gotTarget(err, target);\n      });\n    });\n  }\n\n  function gotTarget(err, target, base) {\n    if (err) return cb(err);\n\n    var resolvedLink = pathModule.resolve(previous, target);\n    if (cache) cache[base] = resolvedLink;\n    gotResolvedLink(resolvedLink);\n  }\n\n  function gotResolvedLink(resolvedLink) {\n    // resolve the link, then start over\n    p = pathModule.resolve(resolvedLink, p.slice(pos));\n    start();\n  }\n};"
  },
  {
    "path": "server/libs/archiver/archiverUtils/glob/LICENSE",
    "content": "The ISC License\n\nCopyright (c) 2009-2022 Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/glob/common.js",
    "content": "exports.setopts = setopts\nexports.ownProp = ownProp\nexports.makeAbs = makeAbs\nexports.finish = finish\nexports.mark = mark\nexports.isIgnored = isIgnored\nexports.childrenIgnored = childrenIgnored\n\nfunction ownProp(obj, field) {\n  return Object.prototype.hasOwnProperty.call(obj, field)\n}\n\nvar fs = require(\"fs\")\nvar path = require(\"path\")\nvar minimatch = require(\"../minimatch\")\nvar isAbsolute = require(\"path\").isAbsolute\nvar Minimatch = minimatch.Minimatch\n\nfunction alphasort(a, b) {\n  return a.localeCompare(b, 'en')\n}\n\nfunction setupIgnores(self, options) {\n  self.ignore = options.ignore || []\n\n  if (!Array.isArray(self.ignore))\n    self.ignore = [self.ignore]\n\n  if (self.ignore.length) {\n    self.ignore = self.ignore.map(ignoreMap)\n  }\n}\n\n// ignore patterns are always in dot:true mode.\nfunction ignoreMap(pattern) {\n  var gmatcher = null\n  if (pattern.slice(-3) === '/**') {\n    var gpattern = pattern.replace(/(\\/\\*\\*)+$/, '')\n    gmatcher = new Minimatch(gpattern, { dot: true })\n  }\n\n  return {\n    matcher: new Minimatch(pattern, { dot: true }),\n    gmatcher: gmatcher\n  }\n}\n\nfunction setopts(self, pattern, options) {\n  if (!options)\n    options = {}\n\n  // base-matching: just use globstar for that.\n  if (options.matchBase && -1 === pattern.indexOf(\"/\")) {\n    if (options.noglobstar) {\n      throw new Error(\"base matching requires globstar\")\n    }\n    pattern = \"**/\" + pattern\n  }\n\n  self.silent = !!options.silent\n  self.pattern = pattern\n  self.strict = options.strict !== false\n  self.realpath = !!options.realpath\n  self.realpathCache = options.realpathCache || Object.create(null)\n  self.follow = !!options.follow\n  self.dot = !!options.dot\n  self.mark = !!options.mark\n  self.nodir = !!options.nodir\n  if (self.nodir)\n    self.mark = true\n  self.sync = !!options.sync\n  self.nounique = !!options.nounique\n  self.nonull = !!options.nonull\n  self.nosort = !!options.nosort\n  self.nocase = !!options.nocase\n  self.stat = !!options.stat\n  self.noprocess = !!options.noprocess\n  self.absolute = !!options.absolute\n  self.fs = options.fs || fs\n\n  self.maxLength = options.maxLength || Infinity\n  self.cache = options.cache || Object.create(null)\n  self.statCache = options.statCache || Object.create(null)\n  self.symlinks = options.symlinks || Object.create(null)\n\n  setupIgnores(self, options)\n\n  self.changedCwd = false\n  var cwd = process.cwd()\n  if (!ownProp(options, \"cwd\"))\n    self.cwd = path.resolve(cwd)\n  else {\n    self.cwd = path.resolve(options.cwd)\n    self.changedCwd = self.cwd !== cwd\n  }\n\n  self.root = options.root || path.resolve(self.cwd, \"/\")\n  self.root = path.resolve(self.root)\n\n  // TODO: is an absolute `cwd` supposed to be resolved against `root`?\n  // e.g. { cwd: '/test', root: __dirname } === path.join(__dirname, '/test')\n  self.cwdAbs = isAbsolute(self.cwd) ? self.cwd : makeAbs(self, self.cwd)\n  self.nomount = !!options.nomount\n\n  if (process.platform === \"win32\") {\n    self.root = self.root.replace(/\\\\/g, \"/\")\n    self.cwd = self.cwd.replace(/\\\\/g, \"/\")\n    self.cwdAbs = self.cwdAbs.replace(/\\\\/g, \"/\")\n  }\n\n  // disable comments and negation in Minimatch.\n  // Note that they are not supported in Glob itself anyway.\n  options.nonegate = true\n  options.nocomment = true\n  // always treat \\ in patterns as escapes, not path separators\n  options.allowWindowsEscape = true\n\n  self.minimatch = new Minimatch(pattern, options)\n  self.options = self.minimatch.options\n}\n\nfunction finish(self) {\n  var nou = self.nounique\n  var all = nou ? [] : Object.create(null)\n\n  for (var i = 0, l = self.matches.length; i < l; i++) {\n    var matches = self.matches[i]\n    if (!matches || Object.keys(matches).length === 0) {\n      if (self.nonull) {\n        // do like the shell, and spit out the literal glob\n        var literal = self.minimatch.globSet[i]\n        if (nou)\n          all.push(literal)\n        else\n          all[literal] = true\n      }\n    } else {\n      // had matches\n      var m = Object.keys(matches)\n      if (nou)\n        all.push.apply(all, m)\n      else\n        m.forEach(function (m) {\n          all[m] = true\n        })\n    }\n  }\n\n  if (!nou)\n    all = Object.keys(all)\n\n  if (!self.nosort)\n    all = all.sort(alphasort)\n\n  // at *some* point we statted all of these\n  if (self.mark) {\n    for (var i = 0; i < all.length; i++) {\n      all[i] = self._mark(all[i])\n    }\n    if (self.nodir) {\n      all = all.filter(function (e) {\n        var notDir = !(/\\/$/.test(e))\n        var c = self.cache[e] || self.cache[makeAbs(self, e)]\n        if (notDir && c)\n          notDir = c !== 'DIR' && !Array.isArray(c)\n        return notDir\n      })\n    }\n  }\n\n  if (self.ignore.length)\n    all = all.filter(function (m) {\n      return !isIgnored(self, m)\n    })\n\n  self.found = all\n}\n\nfunction mark(self, p) {\n  var abs = makeAbs(self, p)\n  var c = self.cache[abs]\n  var m = p\n  if (c) {\n    var isDir = c === 'DIR' || Array.isArray(c)\n    var slash = p.slice(-1) === '/'\n\n    if (isDir && !slash)\n      m += '/'\n    else if (!isDir && slash)\n      m = m.slice(0, -1)\n\n    if (m !== p) {\n      var mabs = makeAbs(self, m)\n      self.statCache[mabs] = self.statCache[abs]\n      self.cache[mabs] = self.cache[abs]\n    }\n  }\n\n  return m\n}\n\n// lotta situps...\nfunction makeAbs(self, f) {\n  var abs = f\n  if (f.charAt(0) === '/') {\n    abs = path.join(self.root, f)\n  } else if (isAbsolute(f) || f === '') {\n    abs = f\n  } else if (self.changedCwd) {\n    abs = path.resolve(self.cwd, f)\n  } else {\n    abs = path.resolve(f)\n  }\n\n  if (process.platform === 'win32')\n    abs = abs.replace(/\\\\/g, '/')\n\n  return abs\n}\n\n\n// Return true, if pattern ends with globstar '**', for the accompanying parent directory.\n// Ex:- If node_modules/** is the pattern, add 'node_modules' to ignore list along with it's contents\nfunction isIgnored(self, path) {\n  if (!self.ignore.length)\n    return false\n\n  return self.ignore.some(function (item) {\n    return item.matcher.match(path) || !!(item.gmatcher && item.gmatcher.match(path))\n  })\n}\n\nfunction childrenIgnored(self, path) {\n  if (!self.ignore.length)\n    return false\n\n  return self.ignore.some(function (item) {\n    return !!(item.gmatcher && item.gmatcher.match(path))\n  })\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/glob/index.js",
    "content": "// Approach:\n//\n// 1. Get the minimatch set\n// 2. For each pattern in the set, PROCESS(pattern, false)\n// 3. Store matches per-set, then uniq them\n//\n// PROCESS(pattern, inGlobStar)\n// Get the first [n] items from pattern that are all strings\n// Join these together.  This is PREFIX.\n//   If there is no more remaining, then stat(PREFIX) and\n//   add to matches if it succeeds.  END.\n//\n// If inGlobStar and PREFIX is symlink and points to dir\n//   set ENTRIES = []\n// else readdir(PREFIX) as ENTRIES\n//   If fail, END\n//\n// with ENTRIES\n//   If pattern[n] is GLOBSTAR\n//     // handle the case where the globstar match is empty\n//     // by pruning it out, and testing the resulting pattern\n//     PROCESS(pattern[0..n] + pattern[n+1 .. $], false)\n//     // handle other cases.\n//     for ENTRY in ENTRIES (not dotfiles)\n//       // attach globstar + tail onto the entry\n//       // Mark that this entry is a globstar match\n//       PROCESS(pattern[0..n] + ENTRY + pattern[n .. $], true)\n//\n//   else // not globstar\n//     for ENTRY in ENTRIES (not dotfiles, unless pattern[n] is dot)\n//       Test ENTRY against pattern[n]\n//       If fails, continue\n//       If passes, PROCESS(pattern[0..n] + item + pattern[n+1 .. $])\n//\n// Caveat:\n//   Cache all stats and readdirs results to minimize syscall.  Since all\n//   we ever care about is existence and directory-ness, we can just keep\n//   `true` for files, and [children,...] for directories, or `false` for\n//   things that don't exist.\n\nmodule.exports = glob\n\nvar rp = require('../fsRealpath')\nvar minimatch = require('../minimatch')\n// var inherits = require('inherits')\nconst util = require('util')\nvar EE = require('events').EventEmitter\nvar path = require('path')\nvar assert = require('assert')\nvar isAbsolute = require('path').isAbsolute\nvar globSync = require('./sync.js')\nvar common = require('./common.js')\nvar setopts = common.setopts\nvar ownProp = common.ownProp\nvar inflight = require('../inflight')\nvar childrenIgnored = common.childrenIgnored\nvar isIgnored = common.isIgnored\n\nvar once = require('../../../lodash.once')\n\nfunction glob(pattern, options, cb) {\n  if (typeof options === 'function') cb = options, options = {}\n  if (!options) options = {}\n\n  if (options.sync) {\n    if (cb)\n      throw new TypeError('callback provided to sync glob')\n    return globSync(pattern, options)\n  }\n\n  return new Glob(pattern, options, cb)\n}\n\nglob.sync = globSync\nvar GlobSync = glob.GlobSync = globSync.GlobSync\n\n// old api surface\nglob.glob = glob\n\nfunction extend(origin, add) {\n  if (add === null || typeof add !== 'object') {\n    return origin\n  }\n\n  var keys = Object.keys(add)\n  var i = keys.length\n  while (i--) {\n    origin[keys[i]] = add[keys[i]]\n  }\n  return origin\n}\n\nglob.hasMagic = function (pattern, options_) {\n  var options = extend({}, options_)\n  options.noprocess = true\n\n  var g = new Glob(pattern, options)\n  var set = g.minimatch.set\n\n  if (!pattern)\n    return false\n\n  if (set.length > 1)\n    return true\n\n  for (var j = 0; j < set[0].length; j++) {\n    if (typeof set[0][j] !== 'string')\n      return true\n  }\n\n  return false\n}\n\nglob.Glob = Glob\nutil.inherits(Glob, EE)\nfunction Glob(pattern, options, cb) {\n  if (typeof options === 'function') {\n    cb = options\n    options = null\n  }\n\n  if (options && options.sync) {\n    if (cb)\n      throw new TypeError('callback provided to sync glob')\n    return new GlobSync(pattern, options)\n  }\n\n  if (!(this instanceof Glob))\n    return new Glob(pattern, options, cb)\n\n  setopts(this, pattern, options)\n  this._didRealPath = false\n\n  // process each pattern in the minimatch set\n  var n = this.minimatch.set.length\n\n  // The matches are stored as {<filename>: true,...} so that\n  // duplicates are automagically pruned.\n  // Later, we do an Object.keys() on these.\n  // Keep them as a list so we can fill in when nonull is set.\n  this.matches = new Array(n)\n\n  if (typeof cb === 'function') {\n    cb = once(cb)\n    this.on('error', cb)\n    this.on('end', function (matches) {\n      cb(null, matches)\n    })\n  }\n\n  var self = this\n  this._processing = 0\n\n  this._emitQueue = []\n  this._processQueue = []\n  this.paused = false\n\n  if (this.noprocess)\n    return this\n\n  if (n === 0)\n    return done()\n\n  var sync = true\n  for (var i = 0; i < n; i++) {\n    this._process(this.minimatch.set[i], i, false, done)\n  }\n  sync = false\n\n  function done() {\n    --self._processing\n    if (self._processing <= 0) {\n      if (sync) {\n        process.nextTick(function () {\n          self._finish()\n        })\n      } else {\n        self._finish()\n      }\n    }\n  }\n}\n\nGlob.prototype._finish = function () {\n  assert(this instanceof Glob)\n  if (this.aborted)\n    return\n\n  if (this.realpath && !this._didRealpath)\n    return this._realpath()\n\n  common.finish(this)\n  this.emit('end', this.found)\n}\n\nGlob.prototype._realpath = function () {\n  if (this._didRealpath)\n    return\n\n  this._didRealpath = true\n\n  var n = this.matches.length\n  if (n === 0)\n    return this._finish()\n\n  var self = this\n  for (var i = 0; i < this.matches.length; i++)\n    this._realpathSet(i, next)\n\n  function next() {\n    if (--n === 0)\n      self._finish()\n  }\n}\n\nGlob.prototype._realpathSet = function (index, cb) {\n  var matchset = this.matches[index]\n  if (!matchset)\n    return cb()\n\n  var found = Object.keys(matchset)\n  var self = this\n  var n = found.length\n\n  if (n === 0)\n    return cb()\n\n  var set = this.matches[index] = Object.create(null)\n  found.forEach(function (p, i) {\n    // If there's a problem with the stat, then it means that\n    // one or more of the links in the realpath couldn't be\n    // resolved.  just return the abs value in that case.\n    p = self._makeAbs(p)\n    rp.realpath(p, self.realpathCache, function (er, real) {\n      if (!er)\n        set[real] = true\n      else if (er.syscall === 'stat')\n        set[p] = true\n      else\n        self.emit('error', er) // srsly wtf right here\n\n      if (--n === 0) {\n        self.matches[index] = set\n        cb()\n      }\n    })\n  })\n}\n\nGlob.prototype._mark = function (p) {\n  return common.mark(this, p)\n}\n\nGlob.prototype._makeAbs = function (f) {\n  return common.makeAbs(this, f)\n}\n\nGlob.prototype.abort = function () {\n  this.aborted = true\n  this.emit('abort')\n}\n\nGlob.prototype.pause = function () {\n  if (!this.paused) {\n    this.paused = true\n    this.emit('pause')\n  }\n}\n\nGlob.prototype.resume = function () {\n  if (this.paused) {\n    this.emit('resume')\n    this.paused = false\n    if (this._emitQueue.length) {\n      var eq = this._emitQueue.slice(0)\n      this._emitQueue.length = 0\n      for (var i = 0; i < eq.length; i++) {\n        var e = eq[i]\n        this._emitMatch(e[0], e[1])\n      }\n    }\n    if (this._processQueue.length) {\n      var pq = this._processQueue.slice(0)\n      this._processQueue.length = 0\n      for (var i = 0; i < pq.length; i++) {\n        var p = pq[i]\n        this._processing--\n        this._process(p[0], p[1], p[2], p[3])\n      }\n    }\n  }\n}\n\nGlob.prototype._process = function (pattern, index, inGlobStar, cb) {\n  assert(this instanceof Glob)\n  assert(typeof cb === 'function')\n\n  if (this.aborted)\n    return\n\n  this._processing++\n  if (this.paused) {\n    this._processQueue.push([pattern, index, inGlobStar, cb])\n    return\n  }\n\n  //console.error('PROCESS %d', this._processing, pattern)\n\n  // Get the first [n] parts of pattern that are all strings.\n  var n = 0\n  while (typeof pattern[n] === 'string') {\n    n++\n  }\n  // now n is the index of the first one that is *not* a string.\n\n  // see if there's anything else\n  var prefix\n  switch (n) {\n    // if not, then this is rather simple\n    case pattern.length:\n      this._processSimple(pattern.join('/'), index, cb)\n      return\n\n    case 0:\n      // pattern *starts* with some non-trivial item.\n      // going to readdir(cwd), but not include the prefix in matches.\n      prefix = null\n      break\n\n    default:\n      // pattern has some string bits in the front.\n      // whatever it starts with, whether that's 'absolute' like /foo/bar,\n      // or 'relative' like '../baz'\n      prefix = pattern.slice(0, n).join('/')\n      break\n  }\n\n  var remain = pattern.slice(n)\n\n  // get the list of entries.\n  var read\n  if (prefix === null)\n    read = '.'\n  else if (isAbsolute(prefix) ||\n    isAbsolute(pattern.map(function (p) {\n      return typeof p === 'string' ? p : '[*]'\n    }).join('/'))) {\n    if (!prefix || !isAbsolute(prefix))\n      prefix = '/' + prefix\n    read = prefix\n  } else\n    read = prefix\n\n  var abs = this._makeAbs(read)\n\n  //if ignored, skip _processing\n  if (childrenIgnored(this, read))\n    return cb()\n\n  var isGlobStar = remain[0] === minimatch.GLOBSTAR\n  if (isGlobStar)\n    this._processGlobStar(prefix, read, abs, remain, index, inGlobStar, cb)\n  else\n    this._processReaddir(prefix, read, abs, remain, index, inGlobStar, cb)\n}\n\nGlob.prototype._processReaddir = function (prefix, read, abs, remain, index, inGlobStar, cb) {\n  var self = this\n  this._readdir(abs, inGlobStar, function (er, entries) {\n    return self._processReaddir2(prefix, read, abs, remain, index, inGlobStar, entries, cb)\n  })\n}\n\nGlob.prototype._processReaddir2 = function (prefix, read, abs, remain, index, inGlobStar, entries, cb) {\n\n  // if the abs isn't a dir, then nothing can match!\n  if (!entries)\n    return cb()\n\n  // It will only match dot entries if it starts with a dot, or if\n  // dot is set.  Stuff like @(.foo|.bar) isn't allowed.\n  var pn = remain[0]\n  var negate = !!this.minimatch.negate\n  var rawGlob = pn._glob\n  var dotOk = this.dot || rawGlob.charAt(0) === '.'\n\n  var matchedEntries = []\n  for (var i = 0; i < entries.length; i++) {\n    var e = entries[i]\n    if (e.charAt(0) !== '.' || dotOk) {\n      var m\n      if (negate && !prefix) {\n        m = !e.match(pn)\n      } else {\n        m = e.match(pn)\n      }\n      if (m)\n        matchedEntries.push(e)\n    }\n  }\n\n  //console.error('prd2', prefix, entries, remain[0]._glob, matchedEntries)\n\n  var len = matchedEntries.length\n  // If there are no matched entries, then nothing matches.\n  if (len === 0)\n    return cb()\n\n  // if this is the last remaining pattern bit, then no need for\n  // an additional stat *unless* the user has specified mark or\n  // stat explicitly.  We know they exist, since readdir returned\n  // them.\n\n  if (remain.length === 1 && !this.mark && !this.stat) {\n    if (!this.matches[index])\n      this.matches[index] = Object.create(null)\n\n    for (var i = 0; i < len; i++) {\n      var e = matchedEntries[i]\n      if (prefix) {\n        if (prefix !== '/')\n          e = prefix + '/' + e\n        else\n          e = prefix + e\n      }\n\n      if (e.charAt(0) === '/' && !this.nomount) {\n        e = path.join(this.root, e)\n      }\n      this._emitMatch(index, e)\n    }\n    // This was the last one, and no stats were needed\n    return cb()\n  }\n\n  // now test all matched entries as stand-ins for that part\n  // of the pattern.\n  remain.shift()\n  for (var i = 0; i < len; i++) {\n    var e = matchedEntries[i]\n    var newPattern\n    if (prefix) {\n      if (prefix !== '/')\n        e = prefix + '/' + e\n      else\n        e = prefix + e\n    }\n    this._process([e].concat(remain), index, inGlobStar, cb)\n  }\n  cb()\n}\n\nGlob.prototype._emitMatch = function (index, e) {\n  if (this.aborted)\n    return\n\n  if (isIgnored(this, e))\n    return\n\n  if (this.paused) {\n    this._emitQueue.push([index, e])\n    return\n  }\n\n  var abs = isAbsolute(e) ? e : this._makeAbs(e)\n\n  if (this.mark)\n    e = this._mark(e)\n\n  if (this.absolute)\n    e = abs\n\n  if (this.matches[index][e])\n    return\n\n  if (this.nodir) {\n    var c = this.cache[abs]\n    if (c === 'DIR' || Array.isArray(c))\n      return\n  }\n\n  this.matches[index][e] = true\n\n  var st = this.statCache[abs]\n  if (st)\n    this.emit('stat', e, st)\n\n  this.emit('match', e)\n}\n\nGlob.prototype._readdirInGlobStar = function (abs, cb) {\n  if (this.aborted)\n    return\n\n  // follow all symlinked directories forever\n  // just proceed as if this is a non-globstar situation\n  if (this.follow)\n    return this._readdir(abs, false, cb)\n\n  var lstatkey = 'lstat\\0' + abs\n  var self = this\n  var lstatcb = inflight(lstatkey, lstatcb_)\n\n  if (lstatcb)\n    self.fs.lstat(abs, lstatcb)\n\n  function lstatcb_(er, lstat) {\n    if (er && er.code === 'ENOENT')\n      return cb()\n\n    var isSym = lstat && lstat.isSymbolicLink()\n    self.symlinks[abs] = isSym\n\n    // If it's not a symlink or a dir, then it's definitely a regular file.\n    // don't bother doing a readdir in that case.\n    if (!isSym && lstat && !lstat.isDirectory()) {\n      self.cache[abs] = 'FILE'\n      cb()\n    } else\n      self._readdir(abs, false, cb)\n  }\n}\n\nGlob.prototype._readdir = function (abs, inGlobStar, cb) {\n  if (this.aborted)\n    return\n\n  cb = inflight('readdir\\0' + abs + '\\0' + inGlobStar, cb)\n  if (!cb)\n    return\n\n  //console.error('RD %j %j', +inGlobStar, abs)\n  if (inGlobStar && !ownProp(this.symlinks, abs))\n    return this._readdirInGlobStar(abs, cb)\n\n  if (ownProp(this.cache, abs)) {\n    var c = this.cache[abs]\n    if (!c || c === 'FILE')\n      return cb()\n\n    if (Array.isArray(c))\n      return cb(null, c)\n  }\n\n  var self = this\n  self.fs.readdir(abs, readdirCb(this, abs, cb))\n}\n\nfunction readdirCb(self, abs, cb) {\n  return function (er, entries) {\n    if (er)\n      self._readdirError(abs, er, cb)\n    else\n      self._readdirEntries(abs, entries, cb)\n  }\n}\n\nGlob.prototype._readdirEntries = function (abs, entries, cb) {\n  if (this.aborted)\n    return\n\n  // if we haven't asked to stat everything, then just\n  // assume that everything in there exists, so we can avoid\n  // having to stat it a second time.\n  if (!this.mark && !this.stat) {\n    for (var i = 0; i < entries.length; i++) {\n      var e = entries[i]\n      if (abs === '/')\n        e = abs + e\n      else\n        e = abs + '/' + e\n      this.cache[e] = true\n    }\n  }\n\n  this.cache[abs] = entries\n  return cb(null, entries)\n}\n\nGlob.prototype._readdirError = function (f, er, cb) {\n  if (this.aborted)\n    return\n\n  // handle errors, and cache the information\n  switch (er.code) {\n    case 'ENOTSUP': // https://github.com/isaacs/node-glob/issues/205\n    case 'ENOTDIR': // totally normal. means it *does* exist.\n      var abs = this._makeAbs(f)\n      this.cache[abs] = 'FILE'\n      if (abs === this.cwdAbs) {\n        var error = new Error(er.code + ' invalid cwd ' + this.cwd)\n        error.path = this.cwd\n        error.code = er.code\n        this.emit('error', error)\n        this.abort()\n      }\n      break\n\n    case 'ENOENT': // not terribly unusual\n    case 'ELOOP':\n    case 'ENAMETOOLONG':\n    case 'UNKNOWN':\n      this.cache[this._makeAbs(f)] = false\n      break\n\n    default: // some unusual error.  Treat as failure.\n      this.cache[this._makeAbs(f)] = false\n      if (this.strict) {\n        this.emit('error', er)\n        // If the error is handled, then we abort\n        // if not, we threw out of here\n        this.abort()\n      }\n      if (!this.silent)\n        console.error('glob error', er)\n      break\n  }\n\n  return cb()\n}\n\nGlob.prototype._processGlobStar = function (prefix, read, abs, remain, index, inGlobStar, cb) {\n  var self = this\n  this._readdir(abs, inGlobStar, function (er, entries) {\n    self._processGlobStar2(prefix, read, abs, remain, index, inGlobStar, entries, cb)\n  })\n}\n\n\nGlob.prototype._processGlobStar2 = function (prefix, read, abs, remain, index, inGlobStar, entries, cb) {\n  //console.error('pgs2', prefix, remain[0], entries)\n\n  // no entries means not a dir, so it can never have matches\n  // foo.txt/** doesn't match foo.txt\n  if (!entries)\n    return cb()\n\n  // test without the globstar, and with every child both below\n  // and replacing the globstar.\n  var remainWithoutGlobStar = remain.slice(1)\n  var gspref = prefix ? [prefix] : []\n  var noGlobStar = gspref.concat(remainWithoutGlobStar)\n\n  // the noGlobStar pattern exits the inGlobStar state\n  this._process(noGlobStar, index, false, cb)\n\n  var isSym = this.symlinks[abs]\n  var len = entries.length\n\n  // If it's a symlink, and we're in a globstar, then stop\n  if (isSym && inGlobStar)\n    return cb()\n\n  for (var i = 0; i < len; i++) {\n    var e = entries[i]\n    if (e.charAt(0) === '.' && !this.dot)\n      continue\n\n    // these two cases enter the inGlobStar state\n    var instead = gspref.concat(entries[i], remainWithoutGlobStar)\n    this._process(instead, index, true, cb)\n\n    var below = gspref.concat(entries[i], remain)\n    this._process(below, index, true, cb)\n  }\n\n  cb()\n}\n\nGlob.prototype._processSimple = function (prefix, index, cb) {\n  // XXX review this.  Shouldn't it be doing the mounting etc\n  // before doing stat?  kinda weird?\n  var self = this\n  this._stat(prefix, function (er, exists) {\n    self._processSimple2(prefix, index, er, exists, cb)\n  })\n}\nGlob.prototype._processSimple2 = function (prefix, index, er, exists, cb) {\n\n  //console.error('ps2', prefix, exists)\n\n  if (!this.matches[index])\n    this.matches[index] = Object.create(null)\n\n  // If it doesn't exist, then just mark the lack of results\n  if (!exists)\n    return cb()\n\n  if (prefix && isAbsolute(prefix) && !this.nomount) {\n    var trail = /[\\/\\\\]$/.test(prefix)\n    if (prefix.charAt(0) === '/') {\n      prefix = path.join(this.root, prefix)\n    } else {\n      prefix = path.resolve(this.root, prefix)\n      if (trail)\n        prefix += '/'\n    }\n  }\n\n  if (process.platform === 'win32')\n    prefix = prefix.replace(/\\\\/g, '/')\n\n  // Mark this as a match\n  this._emitMatch(index, prefix)\n  cb()\n}\n\n// Returns either 'DIR', 'FILE', or false\nGlob.prototype._stat = function (f, cb) {\n  var abs = this._makeAbs(f)\n  var needDir = f.slice(-1) === '/'\n\n  if (f.length > this.maxLength)\n    return cb()\n\n  if (!this.stat && ownProp(this.cache, abs)) {\n    var c = this.cache[abs]\n\n    if (Array.isArray(c))\n      c = 'DIR'\n\n    // It exists, but maybe not how we need it\n    if (!needDir || c === 'DIR')\n      return cb(null, c)\n\n    if (needDir && c === 'FILE')\n      return cb()\n\n    // otherwise we have to stat, because maybe c=true\n    // if we know it exists, but not what it is.\n  }\n\n  var exists\n  var stat = this.statCache[abs]\n  if (stat !== undefined) {\n    if (stat === false)\n      return cb(null, stat)\n    else {\n      var type = stat.isDirectory() ? 'DIR' : 'FILE'\n      if (needDir && type === 'FILE')\n        return cb()\n      else\n        return cb(null, type, stat)\n    }\n  }\n\n  var self = this\n  var statcb = inflight('stat\\0' + abs, lstatcb_)\n  if (statcb)\n    self.fs.lstat(abs, statcb)\n\n  function lstatcb_(er, lstat) {\n    if (lstat && lstat.isSymbolicLink()) {\n      // If it's a symlink, then treat it as the target, unless\n      // the target does not exist, then treat it as a file.\n      return self.fs.stat(abs, function (er, stat) {\n        if (er)\n          self._stat2(f, abs, null, lstat, cb)\n        else\n          self._stat2(f, abs, er, stat, cb)\n      })\n    } else {\n      self._stat2(f, abs, er, lstat, cb)\n    }\n  }\n}\n\nGlob.prototype._stat2 = function (f, abs, er, stat, cb) {\n  if (er && (er.code === 'ENOENT' || er.code === 'ENOTDIR')) {\n    this.statCache[abs] = false\n    return cb()\n  }\n\n  var needDir = f.slice(-1) === '/'\n  this.statCache[abs] = stat\n\n  if (abs.slice(-1) === '/' && stat && !stat.isDirectory())\n    return cb(null, false, stat)\n\n  var c = true\n  if (stat)\n    c = stat.isDirectory() ? 'DIR' : 'FILE'\n  this.cache[abs] = this.cache[abs] || c\n\n  if (needDir && c === 'FILE')\n    return cb()\n\n  return cb(null, c, stat)\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/glob/sync.js",
    "content": "module.exports = globSync\nglobSync.GlobSync = GlobSync\n\nvar rp = require('../fsRealpath')\nvar minimatch = require('../minimatch')\nvar path = require('path')\nvar assert = require('assert')\nvar isAbsolute = require('path').isAbsolute\nvar common = require('./common.js')\nvar setopts = common.setopts\nvar ownProp = common.ownProp\nvar childrenIgnored = common.childrenIgnored\nvar isIgnored = common.isIgnored\n\nfunction globSync(pattern, options) {\n  if (typeof options === 'function' || arguments.length === 3)\n    throw new TypeError('callback provided to sync glob\\n' +\n      'See: https://github.com/isaacs/node-glob/issues/167')\n\n  return new GlobSync(pattern, options).found\n}\n\nfunction GlobSync(pattern, options) {\n  if (!pattern)\n    throw new Error('must provide pattern')\n\n  if (typeof options === 'function' || arguments.length === 3)\n    throw new TypeError('callback provided to sync glob\\n' +\n      'See: https://github.com/isaacs/node-glob/issues/167')\n\n  if (!(this instanceof GlobSync))\n    return new GlobSync(pattern, options)\n\n  setopts(this, pattern, options)\n\n  if (this.noprocess)\n    return this\n\n  var n = this.minimatch.set.length\n  this.matches = new Array(n)\n  for (var i = 0; i < n; i++) {\n    this._process(this.minimatch.set[i], i, false)\n  }\n  this._finish()\n}\n\nGlobSync.prototype._finish = function () {\n  assert.ok(this instanceof GlobSync)\n  if (this.realpath) {\n    var self = this\n    this.matches.forEach(function (matchset, index) {\n      var set = self.matches[index] = Object.create(null)\n      for (var p in matchset) {\n        try {\n          p = self._makeAbs(p)\n          var real = rp.realpathSync(p, self.realpathCache)\n          set[real] = true\n        } catch (er) {\n          if (er.syscall === 'stat')\n            set[self._makeAbs(p)] = true\n          else\n            throw er\n        }\n      }\n    })\n  }\n  common.finish(this)\n}\n\n\nGlobSync.prototype._process = function (pattern, index, inGlobStar) {\n  assert.ok(this instanceof GlobSync)\n\n  // Get the first [n] parts of pattern that are all strings.\n  var n = 0\n  while (typeof pattern[n] === 'string') {\n    n++\n  }\n  // now n is the index of the first one that is *not* a string.\n\n  // See if there's anything else\n  var prefix\n  switch (n) {\n    // if not, then this is rather simple\n    case pattern.length:\n      this._processSimple(pattern.join('/'), index)\n      return\n\n    case 0:\n      // pattern *starts* with some non-trivial item.\n      // going to readdir(cwd), but not include the prefix in matches.\n      prefix = null\n      break\n\n    default:\n      // pattern has some string bits in the front.\n      // whatever it starts with, whether that's 'absolute' like /foo/bar,\n      // or 'relative' like '../baz'\n      prefix = pattern.slice(0, n).join('/')\n      break\n  }\n\n  var remain = pattern.slice(n)\n\n  // get the list of entries.\n  var read\n  if (prefix === null)\n    read = '.'\n  else if (isAbsolute(prefix) ||\n    isAbsolute(pattern.map(function (p) {\n      return typeof p === 'string' ? p : '[*]'\n    }).join('/'))) {\n    if (!prefix || !isAbsolute(prefix))\n      prefix = '/' + prefix\n    read = prefix\n  } else\n    read = prefix\n\n  var abs = this._makeAbs(read)\n\n  //if ignored, skip processing\n  if (childrenIgnored(this, read))\n    return\n\n  var isGlobStar = remain[0] === minimatch.GLOBSTAR\n  if (isGlobStar)\n    this._processGlobStar(prefix, read, abs, remain, index, inGlobStar)\n  else\n    this._processReaddir(prefix, read, abs, remain, index, inGlobStar)\n}\n\n\nGlobSync.prototype._processReaddir = function (prefix, read, abs, remain, index, inGlobStar) {\n  var entries = this._readdir(abs, inGlobStar)\n\n  // if the abs isn't a dir, then nothing can match!\n  if (!entries)\n    return\n\n  // It will only match dot entries if it starts with a dot, or if\n  // dot is set.  Stuff like @(.foo|.bar) isn't allowed.\n  var pn = remain[0]\n  var negate = !!this.minimatch.negate\n  var rawGlob = pn._glob\n  var dotOk = this.dot || rawGlob.charAt(0) === '.'\n\n  var matchedEntries = []\n  for (var i = 0; i < entries.length; i++) {\n    var e = entries[i]\n    if (e.charAt(0) !== '.' || dotOk) {\n      var m\n      if (negate && !prefix) {\n        m = !e.match(pn)\n      } else {\n        m = e.match(pn)\n      }\n      if (m)\n        matchedEntries.push(e)\n    }\n  }\n\n  var len = matchedEntries.length\n  // If there are no matched entries, then nothing matches.\n  if (len === 0)\n    return\n\n  // if this is the last remaining pattern bit, then no need for\n  // an additional stat *unless* the user has specified mark or\n  // stat explicitly.  We know they exist, since readdir returned\n  // them.\n\n  if (remain.length === 1 && !this.mark && !this.stat) {\n    if (!this.matches[index])\n      this.matches[index] = Object.create(null)\n\n    for (var i = 0; i < len; i++) {\n      var e = matchedEntries[i]\n      if (prefix) {\n        if (prefix.slice(-1) !== '/')\n          e = prefix + '/' + e\n        else\n          e = prefix + e\n      }\n\n      if (e.charAt(0) === '/' && !this.nomount) {\n        e = path.join(this.root, e)\n      }\n      this._emitMatch(index, e)\n    }\n    // This was the last one, and no stats were needed\n    return\n  }\n\n  // now test all matched entries as stand-ins for that part\n  // of the pattern.\n  remain.shift()\n  for (var i = 0; i < len; i++) {\n    var e = matchedEntries[i]\n    var newPattern\n    if (prefix)\n      newPattern = [prefix, e]\n    else\n      newPattern = [e]\n    this._process(newPattern.concat(remain), index, inGlobStar)\n  }\n}\n\n\nGlobSync.prototype._emitMatch = function (index, e) {\n  if (isIgnored(this, e))\n    return\n\n  var abs = this._makeAbs(e)\n\n  if (this.mark)\n    e = this._mark(e)\n\n  if (this.absolute) {\n    e = abs\n  }\n\n  if (this.matches[index][e])\n    return\n\n  if (this.nodir) {\n    var c = this.cache[abs]\n    if (c === 'DIR' || Array.isArray(c))\n      return\n  }\n\n  this.matches[index][e] = true\n\n  if (this.stat)\n    this._stat(e)\n}\n\n\nGlobSync.prototype._readdirInGlobStar = function (abs) {\n  // follow all symlinked directories forever\n  // just proceed as if this is a non-globstar situation\n  if (this.follow)\n    return this._readdir(abs, false)\n\n  var entries\n  var lstat\n  var stat\n  try {\n    lstat = this.fs.lstatSync(abs)\n  } catch (er) {\n    if (er.code === 'ENOENT') {\n      // lstat failed, doesn't exist\n      return null\n    }\n  }\n\n  var isSym = lstat && lstat.isSymbolicLink()\n  this.symlinks[abs] = isSym\n\n  // If it's not a symlink or a dir, then it's definitely a regular file.\n  // don't bother doing a readdir in that case.\n  if (!isSym && lstat && !lstat.isDirectory())\n    this.cache[abs] = 'FILE'\n  else\n    entries = this._readdir(abs, false)\n\n  return entries\n}\n\nGlobSync.prototype._readdir = function (abs, inGlobStar) {\n  var entries\n\n  if (inGlobStar && !ownProp(this.symlinks, abs))\n    return this._readdirInGlobStar(abs)\n\n  if (ownProp(this.cache, abs)) {\n    var c = this.cache[abs]\n    if (!c || c === 'FILE')\n      return null\n\n    if (Array.isArray(c))\n      return c\n  }\n\n  try {\n    return this._readdirEntries(abs, this.fs.readdirSync(abs))\n  } catch (er) {\n    this._readdirError(abs, er)\n    return null\n  }\n}\n\nGlobSync.prototype._readdirEntries = function (abs, entries) {\n  // if we haven't asked to stat everything, then just\n  // assume that everything in there exists, so we can avoid\n  // having to stat it a second time.\n  if (!this.mark && !this.stat) {\n    for (var i = 0; i < entries.length; i++) {\n      var e = entries[i]\n      if (abs === '/')\n        e = abs + e\n      else\n        e = abs + '/' + e\n      this.cache[e] = true\n    }\n  }\n\n  this.cache[abs] = entries\n\n  // mark and cache dir-ness\n  return entries\n}\n\nGlobSync.prototype._readdirError = function (f, er) {\n  // handle errors, and cache the information\n  switch (er.code) {\n    case 'ENOTSUP': // https://github.com/isaacs/node-glob/issues/205\n    case 'ENOTDIR': // totally normal. means it *does* exist.\n      var abs = this._makeAbs(f)\n      this.cache[abs] = 'FILE'\n      if (abs === this.cwdAbs) {\n        var error = new Error(er.code + ' invalid cwd ' + this.cwd)\n        error.path = this.cwd\n        error.code = er.code\n        throw error\n      }\n      break\n\n    case 'ENOENT': // not terribly unusual\n    case 'ELOOP':\n    case 'ENAMETOOLONG':\n    case 'UNKNOWN':\n      this.cache[this._makeAbs(f)] = false\n      break\n\n    default: // some unusual error.  Treat as failure.\n      this.cache[this._makeAbs(f)] = false\n      if (this.strict)\n        throw er\n      if (!this.silent)\n        console.error('glob error', er)\n      break\n  }\n}\n\nGlobSync.prototype._processGlobStar = function (prefix, read, abs, remain, index, inGlobStar) {\n\n  var entries = this._readdir(abs, inGlobStar)\n\n  // no entries means not a dir, so it can never have matches\n  // foo.txt/** doesn't match foo.txt\n  if (!entries)\n    return\n\n  // test without the globstar, and with every child both below\n  // and replacing the globstar.\n  var remainWithoutGlobStar = remain.slice(1)\n  var gspref = prefix ? [prefix] : []\n  var noGlobStar = gspref.concat(remainWithoutGlobStar)\n\n  // the noGlobStar pattern exits the inGlobStar state\n  this._process(noGlobStar, index, false)\n\n  var len = entries.length\n  var isSym = this.symlinks[abs]\n\n  // If it's a symlink, and we're in a globstar, then stop\n  if (isSym && inGlobStar)\n    return\n\n  for (var i = 0; i < len; i++) {\n    var e = entries[i]\n    if (e.charAt(0) === '.' && !this.dot)\n      continue\n\n    // these two cases enter the inGlobStar state\n    var instead = gspref.concat(entries[i], remainWithoutGlobStar)\n    this._process(instead, index, true)\n\n    var below = gspref.concat(entries[i], remain)\n    this._process(below, index, true)\n  }\n}\n\nGlobSync.prototype._processSimple = function (prefix, index) {\n  // XXX review this.  Shouldn't it be doing the mounting etc\n  // before doing stat?  kinda weird?\n  var exists = this._stat(prefix)\n\n  if (!this.matches[index])\n    this.matches[index] = Object.create(null)\n\n  // If it doesn't exist, then just mark the lack of results\n  if (!exists)\n    return\n\n  if (prefix && isAbsolute(prefix) && !this.nomount) {\n    var trail = /[\\/\\\\]$/.test(prefix)\n    if (prefix.charAt(0) === '/') {\n      prefix = path.join(this.root, prefix)\n    } else {\n      prefix = path.resolve(this.root, prefix)\n      if (trail)\n        prefix += '/'\n    }\n  }\n\n  if (process.platform === 'win32')\n    prefix = prefix.replace(/\\\\/g, '/')\n\n  // Mark this as a match\n  this._emitMatch(index, prefix)\n}\n\n// Returns either 'DIR', 'FILE', or false\nGlobSync.prototype._stat = function (f) {\n  var abs = this._makeAbs(f)\n  var needDir = f.slice(-1) === '/'\n\n  if (f.length > this.maxLength)\n    return false\n\n  if (!this.stat && ownProp(this.cache, abs)) {\n    var c = this.cache[abs]\n\n    if (Array.isArray(c))\n      c = 'DIR'\n\n    // It exists, but maybe not how we need it\n    if (!needDir || c === 'DIR')\n      return c\n\n    if (needDir && c === 'FILE')\n      return false\n\n    // otherwise we have to stat, because maybe c=true\n    // if we know it exists, but not what it is.\n  }\n\n  var exists\n  var stat = this.statCache[abs]\n  if (!stat) {\n    var lstat\n    try {\n      lstat = this.fs.lstatSync(abs)\n    } catch (er) {\n      if (er && (er.code === 'ENOENT' || er.code === 'ENOTDIR')) {\n        this.statCache[abs] = false\n        return false\n      }\n    }\n\n    if (lstat && lstat.isSymbolicLink()) {\n      try {\n        stat = this.fs.statSync(abs)\n      } catch (er) {\n        stat = lstat\n      }\n    } else {\n      stat = lstat\n    }\n  }\n\n  this.statCache[abs] = stat\n\n  var c = true\n  if (stat)\n    c = stat.isDirectory() ? 'DIR' : 'FILE'\n\n  this.cache[abs] = this.cache[abs] || c\n\n  if (needDir && c === 'FILE')\n    return false\n\n  return c\n}\n\nGlobSync.prototype._mark = function (p) {\n  return common.mark(this, p)\n}\n\nGlobSync.prototype._makeAbs = function (f) {\n  return common.makeAbs(this, f)\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/index.js",
    "content": "/**\n * archiver-utils\n *\n * Copyright (c) 2015 Chris Talkington.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/archiver-utils/blob/master/LICENSE\n */\nvar fs = require('graceful-fs');\nvar path = require('path');\n// var lazystream = require('./lazystream');\nvar lazystream = require('./lazystream')\nvar normalizePath = require('../normalize-path');\n\nvar Stream = require('stream').Stream;\nvar PassThrough = require('./readableStream').PassThrough;\n\nvar utils = module.exports = {};\nutils.file = require('./file.js');\n\nutils.collectStream = function (source, callback) {\n  var collection = [];\n  var size = 0;\n\n  source.on('error', callback);\n\n  source.on('data', function (chunk) {\n    collection.push(chunk);\n    size += chunk.length;\n  });\n\n  source.on('end', function () {\n    var buf = Buffer.alloc(size);\n    var offset = 0;\n\n    collection.forEach(function (data) {\n      data.copy(buf, offset);\n      offset += data.length;\n    });\n\n    callback(null, buf);\n  });\n};\n\nutils.dateify = function (dateish) {\n  dateish = dateish || new Date();\n\n  if (dateish instanceof Date) {\n    dateish = dateish;\n  } else if (typeof dateish === 'string') {\n    dateish = new Date(dateish);\n  } else {\n    dateish = new Date();\n  }\n\n  return dateish;\n};\n\n// this is slightly different from lodash version\nutils.defaults = function (object, source) {\n  object = Object(object)\n  const sources = [source]\n  const objectProto = Object.prototype\n  const hasOwnProperty = objectProto.hasOwnProperty\n  sources.forEach((source) => {\n    if (source != null) {\n      source = Object(source)\n      for (const key in source) {\n        const value = object[key]\n        if (value === undefined ||\n          (value == objectProto[key] && !hasOwnProperty.call(object, key))) {\n          object[key] = source[key]\n        }\n      }\n    }\n  })\n  return object\n};\n\nutils.isStream = function (source) {\n  return source instanceof Stream;\n};\n\nutils.lazyReadStream = function (filepath) {\n  return new lazystream.Readable(function () {\n    return fs.createReadStream(filepath);\n  });\n};\n\nutils.normalizeInputSource = function (source) {\n  if (source === null) {\n    return Buffer.alloc(0);\n  } else if (typeof source === 'string') {\n    return Buffer.from(source);\n  } else if (utils.isStream(source)) {\n    // Always pipe through a PassThrough stream to guarantee pausing the stream if it's already flowing,\n    // since it will only be processed in a (distant) future iteration of the event loop, and will lose\n    // data if already flowing now.\n    return source.pipe(new PassThrough());\n  }\n\n  return source;\n};\n\nutils.sanitizePath = function (filepath) {\n  return normalizePath(filepath, false).replace(/^\\w+:/, '').replace(/^(\\.\\.\\/|\\/)+/, '');\n};\n\nutils.trailingSlashIt = function (str) {\n  return str.slice(-1) !== '/' ? str + '/' : str;\n};\n\nutils.unixifyPath = function (filepath) {\n  return normalizePath(filepath, false).replace(/^\\w+:/, '');\n};\n\nutils.walkdir = function (dirpath, base, callback) {\n  var results = [];\n\n  if (typeof base === 'function') {\n    callback = base;\n    base = dirpath;\n  }\n\n  fs.readdir(dirpath, function (err, list) {\n    var i = 0;\n    var file;\n    var filepath;\n\n    if (err) {\n      return callback(err);\n    }\n\n    (function next() {\n      file = list[i++];\n\n      if (!file) {\n        return callback(null, results);\n      }\n\n      filepath = path.join(dirpath, file);\n\n      fs.stat(filepath, function (err, stats) {\n        results.push({\n          path: filepath,\n          relative: path.relative(base, filepath).replace(/\\\\/g, '/'),\n          stats: stats\n        });\n\n        if (stats && stats.isDirectory()) {\n          utils.walkdir(filepath, base, function (err, res) {\n            res.forEach(function (dirEntry) {\n              results.push(dirEntry);\n            });\n            next();\n          });\n        } else {\n          next();\n        }\n      });\n    })();\n  });\n};\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/inflight/LICENSE",
    "content": "The ISC License\n\nCopyright (c) Isaac Z. Schlueter\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/inflight/index.js",
    "content": "var wrappy = require('../wrappy')\nvar reqs = Object.create(null)\nvar once = require('../../../lodash.once')\n\nmodule.exports = wrappy(inflight)\n\nfunction inflight(key, cb) {\n  if (reqs[key]) {\n    reqs[key].push(cb)\n    return null\n  } else {\n    reqs[key] = [cb]\n    return makeres(key)\n  }\n}\n\nfunction makeres(key) {\n  return once(function RES() {\n    var cbs = reqs[key]\n    var len = cbs.length\n    var args = slice(arguments)\n\n    // XXX It's somewhat ambiguous whether a new callback added in this\n    // pass should be queued for later execution if something in the\n    // list of callbacks throws, or if it should just be discarded.\n    // However, it's such an edge case that it hardly matters, and either\n    // choice is likely as surprising as the other.\n    // As it happens, we do go ahead and schedule it for later execution.\n    try {\n      for (var i = 0; i < len; i++) {\n        cbs[i].apply(null, args)\n      }\n    } finally {\n      if (cbs.length > len) {\n        // added more in the interim.\n        // de-zalgo, just in case, but don't call again.\n        cbs.splice(0, len)\n        process.nextTick(function () {\n          RES.apply(null, args)\n        })\n      } else {\n        delete reqs[key]\n      }\n    }\n  })\n}\n\nfunction slice(args) {\n  var length = args.length\n  var array = []\n\n  for (var i = 0; i < length; i++) array[i] = args[i]\n  return array\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/LICENSE",
    "content": "Copyright (c) 2013 J. Pommerening, contributors.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/index.js",
    "content": "//\n// Source: https://github.com/jpommerening/node-lazystream\n//\n\nvar util = require('util');\nvar PassThrough = require('./readable-stream/passthrough')\n\nmodule.exports = {\n  Readable: Readable,\n  Writable: Writable\n};\n\nutil.inherits(Readable, PassThrough);\nutil.inherits(Writable, PassThrough);\n\n// Patch the given method of instance so that the callback\n// is executed once, before the actual method is called the\n// first time.\nfunction beforeFirstCall(instance, method, callback) {\n  instance[method] = function () {\n    delete instance[method];\n    callback.apply(this, arguments);\n    return this[method].apply(this, arguments);\n  };\n}\n\nfunction Readable(fn, options) {\n  if (!(this instanceof Readable))\n    return new Readable(fn, options);\n\n  PassThrough.call(this, options);\n\n  beforeFirstCall(this, '_read', function () {\n    var source = fn.call(this, options);\n    var emit = this.emit.bind(this, 'error');\n    source.on('error', emit);\n    source.pipe(this);\n  });\n\n  this.emit('readable');\n}\n\nfunction Writable(fn, options) {\n  if (!(this instanceof Writable))\n    return new Writable(fn, options);\n\n  PassThrough.call(this, options);\n\n  beforeFirstCall(this, '_write', function () {\n    var destination = fn.call(this, options);\n    var emit = this.emit.bind(this, 'error');\n    destination.on('error', emit);\n    this.pipe(destination);\n  });\n\n  this.emit('writable');\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/_stream_duplex.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n// a duplex stream is just a stream that is both readable and writable.\n// Since JS doesn't have multiple prototypal inheritance, this class\n// prototypally inherits from Readable, and then parasitically from\n// Writable.\n\n'use strict';\n\n/*<replacement>*/\n\n// var pna = require('process-nextick-args');\nvar pna = process\n/*</replacement>*/\n\n/*<replacement>*/\nvar objectKeys = Object.keys || function (obj) {\n  var keys = [];\n  for (var key in obj) {\n    keys.push(key);\n  } return keys;\n};\n/*</replacement>*/\n\nmodule.exports = Duplex;\n\n/*<replacement>*/\nvar util = require('util')\n// var util = Object.create(require('core-util-is'));\n// util.inherits = require('inherits');\n/*</replacement>*/\n\nvar Readable = require('./_stream_readable');\nvar Writable = require('./_stream_writable');\n\nutil.inherits(Duplex, Readable);\n\n{\n  // avoid scope creep, the keys array can then be collected\n  var keys = objectKeys(Writable.prototype);\n  for (var v = 0; v < keys.length; v++) {\n    var method = keys[v];\n    if (!Duplex.prototype[method]) Duplex.prototype[method] = Writable.prototype[method];\n  }\n}\n\nfunction Duplex(options) {\n  if (!(this instanceof Duplex)) return new Duplex(options);\n\n  Readable.call(this, options);\n  Writable.call(this, options);\n\n  if (options && options.readable === false) this.readable = false;\n\n  if (options && options.writable === false) this.writable = false;\n\n  this.allowHalfOpen = true;\n  if (options && options.allowHalfOpen === false) this.allowHalfOpen = false;\n\n  this.once('end', onend);\n}\n\nObject.defineProperty(Duplex.prototype, 'writableHighWaterMark', {\n  // making it explicit this property is not enumerable\n  // because otherwise some prototype manipulation in\n  // userland will fail\n  enumerable: false,\n  get: function () {\n    return this._writableState.highWaterMark;\n  }\n});\n\n// the no-half-open enforcer\nfunction onend() {\n  // if we allow half-open state, or if the writable side ended,\n  // then we're ok.\n  if (this.allowHalfOpen || this._writableState.ended) return;\n\n  // no more data can be written.\n  // But allow more writes to happen in this tick.\n  pna.nextTick(onEndNT, this);\n}\n\nfunction onEndNT(self) {\n  self.end();\n}\n\nObject.defineProperty(Duplex.prototype, 'destroyed', {\n  get: function () {\n    if (this._readableState === undefined || this._writableState === undefined) {\n      return false;\n    }\n    return this._readableState.destroyed && this._writableState.destroyed;\n  },\n  set: function (value) {\n    // we ignore the value if the stream\n    // has not been initialized yet\n    if (this._readableState === undefined || this._writableState === undefined) {\n      return;\n    }\n\n    // backward compatibility, the user is explicitly\n    // managing destroyed\n    this._readableState.destroyed = value;\n    this._writableState.destroyed = value;\n  }\n});\n\nDuplex.prototype._destroy = function (err, cb) {\n  this.push(null);\n  this.end();\n\n  pna.nextTick(cb, err);\n};"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/_stream_passthrough.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n// a passthrough stream.\n// basically just the most minimal sort of Transform stream.\n// Every written chunk gets output as-is.\n\n'use strict';\n\nmodule.exports = PassThrough;\n\nvar Transform = require('./_stream_transform');\n\n/*<replacement>*/\nvar util = require('util')\n// util.inherits = require('inherits');\n/*</replacement>*/\n\nutil.inherits(PassThrough, Transform);\n\nfunction PassThrough(options) {\n  if (!(this instanceof PassThrough)) return new PassThrough(options);\n\n  Transform.call(this, options);\n}\n\nPassThrough.prototype._transform = function (chunk, encoding, cb) {\n  cb(null, chunk);\n};"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/_stream_readable.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n'use strict';\n\n/*<replacement>*/\n\n// var pna = require('process-nextick-args');\nvar pna = process\n/*</replacement>*/\n\nmodule.exports = Readable;\n\n/*<replacement>*/\n// var isArray = require('isarray');\nvar isArray = Array.isArray\n/*</replacement>*/\n\n/*<replacement>*/\nvar Duplex;\n/*</replacement>*/\n\nReadable.ReadableState = ReadableState;\n\n/*<replacement>*/\nvar EE = require('events').EventEmitter;\n\nvar EElistenerCount = function (emitter, type) {\n  return emitter.listeners(type).length;\n};\n/*</replacement>*/\n\n/*<replacement>*/\nvar Stream = require('./internal/streams/stream');\n/*</replacement>*/\n\n/*<replacement>*/\n\n// var Buffer = require('safe-buffer').Buffer;\nvar Buffer = require('../../../safeBuffer').Buffer\nvar OurUint8Array = global.Uint8Array || function () { };\nfunction _uint8ArrayToBuffer(chunk) {\n  return Buffer.from(chunk);\n}\nfunction _isUint8Array(obj) {\n  return Buffer.isBuffer(obj) || obj instanceof OurUint8Array;\n}\n\n/*</replacement>*/\n\n/*<replacement>*/\nvar util = require('util')\n// var util = Object.create(require('core-util-is'));\n// util.inherits = require('inherits');\n/*</replacement>*/\n\n/*<replacement>*/\nvar debugUtil = require('util');\nvar debug = void 0;\nif (debugUtil && debugUtil.debuglog) {\n  debug = debugUtil.debuglog('stream');\n} else {\n  debug = function () { };\n}\n/*</replacement>*/\n\nvar BufferList = require('./internal/streams/BufferList');\nvar destroyImpl = require('./internal/streams/destroy');\nvar StringDecoder;\n\nutil.inherits(Readable, Stream);\n\nvar kProxyEvents = ['error', 'close', 'destroy', 'pause', 'resume'];\n\nfunction prependListener(emitter, event, fn) {\n  // Sadly this is not cacheable as some libraries bundle their own\n  // event emitter implementation with them.\n  if (typeof emitter.prependListener === 'function') return emitter.prependListener(event, fn);\n\n  // This is a hack to make sure that our error handler is attached before any\n  // userland ones.  NEVER DO THIS. This is here only because this code needs\n  // to continue to work with older versions of Node.js that do not include\n  // the prependListener() method. The goal is to eventually remove this hack.\n  if (!emitter._events || !emitter._events[event]) emitter.on(event, fn); else if (isArray(emitter._events[event])) emitter._events[event].unshift(fn); else emitter._events[event] = [fn, emitter._events[event]];\n}\n\nfunction ReadableState(options, stream) {\n  Duplex = Duplex || require('./_stream_duplex');\n\n  options = options || {};\n\n  // Duplex streams are both readable and writable, but share\n  // the same options object.\n  // However, some cases require setting options to different\n  // values for the readable and the writable sides of the duplex stream.\n  // These options can be provided separately as readableXXX and writableXXX.\n  var isDuplex = stream instanceof Duplex;\n\n  // object stream flag. Used to make read(n) ignore n and to\n  // make all the buffer merging and length checks go away\n  this.objectMode = !!options.objectMode;\n\n  if (isDuplex) this.objectMode = this.objectMode || !!options.readableObjectMode;\n\n  // the point at which it stops calling _read() to fill the buffer\n  // Note: 0 is a valid value, means \"don't call _read preemptively ever\"\n  var hwm = options.highWaterMark;\n  var readableHwm = options.readableHighWaterMark;\n  var defaultHwm = this.objectMode ? 16 : 16 * 1024;\n\n  if (hwm || hwm === 0) this.highWaterMark = hwm; else if (isDuplex && (readableHwm || readableHwm === 0)) this.highWaterMark = readableHwm; else this.highWaterMark = defaultHwm;\n\n  // cast to ints.\n  this.highWaterMark = Math.floor(this.highWaterMark);\n\n  // A linked list is used to store data chunks instead of an array because the\n  // linked list can remove elements from the beginning faster than\n  // array.shift()\n  this.buffer = new BufferList();\n  this.length = 0;\n  this.pipes = null;\n  this.pipesCount = 0;\n  this.flowing = null;\n  this.ended = false;\n  this.endEmitted = false;\n  this.reading = false;\n\n  // a flag to be able to tell if the event 'readable'/'data' is emitted\n  // immediately, or on a later tick.  We set this to true at first, because\n  // any actions that shouldn't happen until \"later\" should generally also\n  // not happen before the first read call.\n  this.sync = true;\n\n  // whenever we return null, then we set a flag to say\n  // that we're awaiting a 'readable' event emission.\n  this.needReadable = false;\n  this.emittedReadable = false;\n  this.readableListening = false;\n  this.resumeScheduled = false;\n\n  // has it been destroyed\n  this.destroyed = false;\n\n  // Crypto is kind of old and crusty.  Historically, its default string\n  // encoding is 'binary' so we have to make this configurable.\n  // Everything else in the universe uses 'utf8', though.\n  this.defaultEncoding = options.defaultEncoding || 'utf8';\n\n  // the number of writers that are awaiting a drain event in .pipe()s\n  this.awaitDrain = 0;\n\n  // if true, a maybeReadMore has been scheduled\n  this.readingMore = false;\n\n  this.decoder = null;\n  this.encoding = null;\n  if (options.encoding) {\n    if (!StringDecoder) StringDecoder = require('string_decoder/').StringDecoder;\n    this.decoder = new StringDecoder(options.encoding);\n    this.encoding = options.encoding;\n  }\n}\n\nfunction Readable(options) {\n  Duplex = Duplex || require('./_stream_duplex');\n\n  if (!(this instanceof Readable)) return new Readable(options);\n\n  this._readableState = new ReadableState(options, this);\n\n  // legacy\n  this.readable = true;\n\n  if (options) {\n    if (typeof options.read === 'function') this._read = options.read;\n\n    if (typeof options.destroy === 'function') this._destroy = options.destroy;\n  }\n\n  Stream.call(this);\n}\n\nObject.defineProperty(Readable.prototype, 'destroyed', {\n  get: function () {\n    if (this._readableState === undefined) {\n      return false;\n    }\n    return this._readableState.destroyed;\n  },\n  set: function (value) {\n    // we ignore the value if the stream\n    // has not been initialized yet\n    if (!this._readableState) {\n      return;\n    }\n\n    // backward compatibility, the user is explicitly\n    // managing destroyed\n    this._readableState.destroyed = value;\n  }\n});\n\nReadable.prototype.destroy = destroyImpl.destroy;\nReadable.prototype._undestroy = destroyImpl.undestroy;\nReadable.prototype._destroy = function (err, cb) {\n  this.push(null);\n  cb(err);\n};\n\n// Manually shove something into the read() buffer.\n// This returns true if the highWaterMark has not been hit yet,\n// similar to how Writable.write() returns true if you should\n// write() some more.\nReadable.prototype.push = function (chunk, encoding) {\n  var state = this._readableState;\n  var skipChunkCheck;\n\n  if (!state.objectMode) {\n    if (typeof chunk === 'string') {\n      encoding = encoding || state.defaultEncoding;\n      if (encoding !== state.encoding) {\n        chunk = Buffer.from(chunk, encoding);\n        encoding = '';\n      }\n      skipChunkCheck = true;\n    }\n  } else {\n    skipChunkCheck = true;\n  }\n\n  return readableAddChunk(this, chunk, encoding, false, skipChunkCheck);\n};\n\n// Unshift should *always* be something directly out of read()\nReadable.prototype.unshift = function (chunk) {\n  return readableAddChunk(this, chunk, null, true, false);\n};\n\nfunction readableAddChunk(stream, chunk, encoding, addToFront, skipChunkCheck) {\n  var state = stream._readableState;\n  if (chunk === null) {\n    state.reading = false;\n    onEofChunk(stream, state);\n  } else {\n    var er;\n    if (!skipChunkCheck) er = chunkInvalid(state, chunk);\n    if (er) {\n      stream.emit('error', er);\n    } else if (state.objectMode || chunk && chunk.length > 0) {\n      if (typeof chunk !== 'string' && !state.objectMode && Object.getPrototypeOf(chunk) !== Buffer.prototype) {\n        chunk = _uint8ArrayToBuffer(chunk);\n      }\n\n      if (addToFront) {\n        if (state.endEmitted) stream.emit('error', new Error('stream.unshift() after end event')); else addChunk(stream, state, chunk, true);\n      } else if (state.ended) {\n        stream.emit('error', new Error('stream.push() after EOF'));\n      } else {\n        state.reading = false;\n        if (state.decoder && !encoding) {\n          chunk = state.decoder.write(chunk);\n          if (state.objectMode || chunk.length !== 0) addChunk(stream, state, chunk, false); else maybeReadMore(stream, state);\n        } else {\n          addChunk(stream, state, chunk, false);\n        }\n      }\n    } else if (!addToFront) {\n      state.reading = false;\n    }\n  }\n\n  return needMoreData(state);\n}\n\nfunction addChunk(stream, state, chunk, addToFront) {\n  if (state.flowing && state.length === 0 && !state.sync) {\n    stream.emit('data', chunk);\n    stream.read(0);\n  } else {\n    // update the buffer info.\n    state.length += state.objectMode ? 1 : chunk.length;\n    if (addToFront) state.buffer.unshift(chunk); else state.buffer.push(chunk);\n\n    if (state.needReadable) emitReadable(stream);\n  }\n  maybeReadMore(stream, state);\n}\n\nfunction chunkInvalid(state, chunk) {\n  var er;\n  if (!_isUint8Array(chunk) && typeof chunk !== 'string' && chunk !== undefined && !state.objectMode) {\n    er = new TypeError('Invalid non-string/buffer chunk');\n  }\n  return er;\n}\n\n// if it's past the high water mark, we can push in some more.\n// Also, if we have no data yet, we can stand some\n// more bytes.  This is to work around cases where hwm=0,\n// such as the repl.  Also, if the push() triggered a\n// readable event, and the user called read(largeNumber) such that\n// needReadable was set, then we ought to push more, so that another\n// 'readable' event will be triggered.\nfunction needMoreData(state) {\n  return !state.ended && (state.needReadable || state.length < state.highWaterMark || state.length === 0);\n}\n\nReadable.prototype.isPaused = function () {\n  return this._readableState.flowing === false;\n};\n\n// backwards compatibility.\nReadable.prototype.setEncoding = function (enc) {\n  if (!StringDecoder) StringDecoder = require('string_decoder/').StringDecoder;\n  this._readableState.decoder = new StringDecoder(enc);\n  this._readableState.encoding = enc;\n  return this;\n};\n\n// Don't raise the hwm > 8MB\nvar MAX_HWM = 0x800000;\nfunction computeNewHighWaterMark(n) {\n  if (n >= MAX_HWM) {\n    n = MAX_HWM;\n  } else {\n    // Get the next highest power of 2 to prevent increasing hwm excessively in\n    // tiny amounts\n    n--;\n    n |= n >>> 1;\n    n |= n >>> 2;\n    n |= n >>> 4;\n    n |= n >>> 8;\n    n |= n >>> 16;\n    n++;\n  }\n  return n;\n}\n\n// This function is designed to be inlinable, so please take care when making\n// changes to the function body.\nfunction howMuchToRead(n, state) {\n  if (n <= 0 || state.length === 0 && state.ended) return 0;\n  if (state.objectMode) return 1;\n  if (n !== n) {\n    // Only flow one buffer at a time\n    if (state.flowing && state.length) return state.buffer.head.data.length; else return state.length;\n  }\n  // If we're asking for more than the current hwm, then raise the hwm.\n  if (n > state.highWaterMark) state.highWaterMark = computeNewHighWaterMark(n);\n  if (n <= state.length) return n;\n  // Don't have enough\n  if (!state.ended) {\n    state.needReadable = true;\n    return 0;\n  }\n  return state.length;\n}\n\n// you can override either this method, or the async _read(n) below.\nReadable.prototype.read = function (n) {\n  debug('read', n);\n  n = parseInt(n, 10);\n  var state = this._readableState;\n  var nOrig = n;\n\n  if (n !== 0) state.emittedReadable = false;\n\n  // if we're doing read(0) to trigger a readable event, but we\n  // already have a bunch of data in the buffer, then just trigger\n  // the 'readable' event and move on.\n  if (n === 0 && state.needReadable && (state.length >= state.highWaterMark || state.ended)) {\n    debug('read: emitReadable', state.length, state.ended);\n    if (state.length === 0 && state.ended) endReadable(this); else emitReadable(this);\n    return null;\n  }\n\n  n = howMuchToRead(n, state);\n\n  // if we've ended, and we're now clear, then finish it up.\n  if (n === 0 && state.ended) {\n    if (state.length === 0) endReadable(this);\n    return null;\n  }\n\n  // All the actual chunk generation logic needs to be\n  // *below* the call to _read.  The reason is that in certain\n  // synthetic stream cases, such as passthrough streams, _read\n  // may be a completely synchronous operation which may change\n  // the state of the read buffer, providing enough data when\n  // before there was *not* enough.\n  //\n  // So, the steps are:\n  // 1. Figure out what the state of things will be after we do\n  // a read from the buffer.\n  //\n  // 2. If that resulting state will trigger a _read, then call _read.\n  // Note that this may be asynchronous, or synchronous.  Yes, it is\n  // deeply ugly to write APIs this way, but that still doesn't mean\n  // that the Readable class should behave improperly, as streams are\n  // designed to be sync/async agnostic.\n  // Take note if the _read call is sync or async (ie, if the read call\n  // has returned yet), so that we know whether or not it's safe to emit\n  // 'readable' etc.\n  //\n  // 3. Actually pull the requested chunks out of the buffer and return.\n\n  // if we need a readable event, then we need to do some reading.\n  var doRead = state.needReadable;\n  debug('need readable', doRead);\n\n  // if we currently have less than the highWaterMark, then also read some\n  if (state.length === 0 || state.length - n < state.highWaterMark) {\n    doRead = true;\n    debug('length less than watermark', doRead);\n  }\n\n  // however, if we've ended, then there's no point, and if we're already\n  // reading, then it's unnecessary.\n  if (state.ended || state.reading) {\n    doRead = false;\n    debug('reading or ended', doRead);\n  } else if (doRead) {\n    debug('do read');\n    state.reading = true;\n    state.sync = true;\n    // if the length is currently zero, then we *need* a readable event.\n    if (state.length === 0) state.needReadable = true;\n    // call internal read method\n    this._read(state.highWaterMark);\n    state.sync = false;\n    // If _read pushed data synchronously, then `reading` will be false,\n    // and we need to re-evaluate how much data we can return to the user.\n    if (!state.reading) n = howMuchToRead(nOrig, state);\n  }\n\n  var ret;\n  if (n > 0) ret = fromList(n, state); else ret = null;\n\n  if (ret === null) {\n    state.needReadable = true;\n    n = 0;\n  } else {\n    state.length -= n;\n  }\n\n  if (state.length === 0) {\n    // If we have nothing in the buffer, then we want to know\n    // as soon as we *do* get something into the buffer.\n    if (!state.ended) state.needReadable = true;\n\n    // If we tried to read() past the EOF, then emit end on the next tick.\n    if (nOrig !== n && state.ended) endReadable(this);\n  }\n\n  if (ret !== null) this.emit('data', ret);\n\n  return ret;\n};\n\nfunction onEofChunk(stream, state) {\n  if (state.ended) return;\n  if (state.decoder) {\n    var chunk = state.decoder.end();\n    if (chunk && chunk.length) {\n      state.buffer.push(chunk);\n      state.length += state.objectMode ? 1 : chunk.length;\n    }\n  }\n  state.ended = true;\n\n  // emit 'readable' now to make sure it gets picked up.\n  emitReadable(stream);\n}\n\n// Don't emit readable right away in sync mode, because this can trigger\n// another read() call => stack overflow.  This way, it might trigger\n// a nextTick recursion warning, but that's not so bad.\nfunction emitReadable(stream) {\n  var state = stream._readableState;\n  state.needReadable = false;\n  if (!state.emittedReadable) {\n    debug('emitReadable', state.flowing);\n    state.emittedReadable = true;\n    if (state.sync) pna.nextTick(emitReadable_, stream); else emitReadable_(stream);\n  }\n}\n\nfunction emitReadable_(stream) {\n  debug('emit readable');\n  stream.emit('readable');\n  flow(stream);\n}\n\n// at this point, the user has presumably seen the 'readable' event,\n// and called read() to consume some data.  that may have triggered\n// in turn another _read(n) call, in which case reading = true if\n// it's in progress.\n// However, if we're not ended, or reading, and the length < hwm,\n// then go ahead and try to read some more preemptively.\nfunction maybeReadMore(stream, state) {\n  if (!state.readingMore) {\n    state.readingMore = true;\n    pna.nextTick(maybeReadMore_, stream, state);\n  }\n}\n\nfunction maybeReadMore_(stream, state) {\n  var len = state.length;\n  while (!state.reading && !state.flowing && !state.ended && state.length < state.highWaterMark) {\n    debug('maybeReadMore read 0');\n    stream.read(0);\n    if (len === state.length)\n      // didn't get any data, stop spinning.\n      break; else len = state.length;\n  }\n  state.readingMore = false;\n}\n\n// abstract method.  to be overridden in specific implementation classes.\n// call cb(er, data) where data is <= n in length.\n// for virtual (non-string, non-buffer) streams, \"length\" is somewhat\n// arbitrary, and perhaps not very meaningful.\nReadable.prototype._read = function (n) {\n  this.emit('error', new Error('_read() is not implemented'));\n};\n\nReadable.prototype.pipe = function (dest, pipeOpts) {\n  var src = this;\n  var state = this._readableState;\n\n  switch (state.pipesCount) {\n    case 0:\n      state.pipes = dest;\n      break;\n    case 1:\n      state.pipes = [state.pipes, dest];\n      break;\n    default:\n      state.pipes.push(dest);\n      break;\n  }\n  state.pipesCount += 1;\n  debug('pipe count=%d opts=%j', state.pipesCount, pipeOpts);\n\n  var doEnd = (!pipeOpts || pipeOpts.end !== false) && dest !== process.stdout && dest !== process.stderr;\n\n  var endFn = doEnd ? onend : unpipe;\n  if (state.endEmitted) pna.nextTick(endFn); else src.once('end', endFn);\n\n  dest.on('unpipe', onunpipe);\n  function onunpipe(readable, unpipeInfo) {\n    debug('onunpipe');\n    if (readable === src) {\n      if (unpipeInfo && unpipeInfo.hasUnpiped === false) {\n        unpipeInfo.hasUnpiped = true;\n        cleanup();\n      }\n    }\n  }\n\n  function onend() {\n    debug('onend');\n    dest.end();\n  }\n\n  // when the dest drains, it reduces the awaitDrain counter\n  // on the source.  This would be more elegant with a .once()\n  // handler in flow(), but adding and removing repeatedly is\n  // too slow.\n  var ondrain = pipeOnDrain(src);\n  dest.on('drain', ondrain);\n\n  var cleanedUp = false;\n  function cleanup() {\n    debug('cleanup');\n    // cleanup event handlers once the pipe is broken\n    dest.removeListener('close', onclose);\n    dest.removeListener('finish', onfinish);\n    dest.removeListener('drain', ondrain);\n    dest.removeListener('error', onerror);\n    dest.removeListener('unpipe', onunpipe);\n    src.removeListener('end', onend);\n    src.removeListener('end', unpipe);\n    src.removeListener('data', ondata);\n\n    cleanedUp = true;\n\n    // if the reader is waiting for a drain event from this\n    // specific writer, then it would cause it to never start\n    // flowing again.\n    // So, if this is awaiting a drain, then we just call it now.\n    // If we don't know, then assume that we are waiting for one.\n    if (state.awaitDrain && (!dest._writableState || dest._writableState.needDrain)) ondrain();\n  }\n\n  // If the user pushes more data while we're writing to dest then we'll end up\n  // in ondata again. However, we only want to increase awaitDrain once because\n  // dest will only emit one 'drain' event for the multiple writes.\n  // => Introduce a guard on increasing awaitDrain.\n  var increasedAwaitDrain = false;\n  src.on('data', ondata);\n  function ondata(chunk) {\n    debug('ondata');\n    increasedAwaitDrain = false;\n    var ret = dest.write(chunk);\n    if (false === ret && !increasedAwaitDrain) {\n      // If the user unpiped during `dest.write()`, it is possible\n      // to get stuck in a permanently paused state if that write\n      // also returned false.\n      // => Check whether `dest` is still a piping destination.\n      if ((state.pipesCount === 1 && state.pipes === dest || state.pipesCount > 1 && indexOf(state.pipes, dest) !== -1) && !cleanedUp) {\n        debug('false write response, pause', src._readableState.awaitDrain);\n        src._readableState.awaitDrain++;\n        increasedAwaitDrain = true;\n      }\n      src.pause();\n    }\n  }\n\n  // if the dest has an error, then stop piping into it.\n  // however, don't suppress the throwing behavior for this.\n  function onerror(er) {\n    debug('onerror', er);\n    unpipe();\n    dest.removeListener('error', onerror);\n    if (EElistenerCount(dest, 'error') === 0) dest.emit('error', er);\n  }\n\n  // Make sure our error handler is attached before userland ones.\n  prependListener(dest, 'error', onerror);\n\n  // Both close and finish should trigger unpipe, but only once.\n  function onclose() {\n    dest.removeListener('finish', onfinish);\n    unpipe();\n  }\n  dest.once('close', onclose);\n  function onfinish() {\n    debug('onfinish');\n    dest.removeListener('close', onclose);\n    unpipe();\n  }\n  dest.once('finish', onfinish);\n\n  function unpipe() {\n    debug('unpipe');\n    src.unpipe(dest);\n  }\n\n  // tell the dest that it's being piped to\n  dest.emit('pipe', src);\n\n  // start the flow if it hasn't been started already.\n  if (!state.flowing) {\n    debug('pipe resume');\n    src.resume();\n  }\n\n  return dest;\n};\n\nfunction pipeOnDrain(src) {\n  return function () {\n    var state = src._readableState;\n    debug('pipeOnDrain', state.awaitDrain);\n    if (state.awaitDrain) state.awaitDrain--;\n    if (state.awaitDrain === 0 && EElistenerCount(src, 'data')) {\n      state.flowing = true;\n      flow(src);\n    }\n  };\n}\n\nReadable.prototype.unpipe = function (dest) {\n  var state = this._readableState;\n  var unpipeInfo = { hasUnpiped: false };\n\n  // if we're not piping anywhere, then do nothing.\n  if (state.pipesCount === 0) return this;\n\n  // just one destination.  most common case.\n  if (state.pipesCount === 1) {\n    // passed in one, but it's not the right one.\n    if (dest && dest !== state.pipes) return this;\n\n    if (!dest) dest = state.pipes;\n\n    // got a match.\n    state.pipes = null;\n    state.pipesCount = 0;\n    state.flowing = false;\n    if (dest) dest.emit('unpipe', this, unpipeInfo);\n    return this;\n  }\n\n  // slow case. multiple pipe destinations.\n\n  if (!dest) {\n    // remove all.\n    var dests = state.pipes;\n    var len = state.pipesCount;\n    state.pipes = null;\n    state.pipesCount = 0;\n    state.flowing = false;\n\n    for (var i = 0; i < len; i++) {\n      dests[i].emit('unpipe', this, unpipeInfo);\n    } return this;\n  }\n\n  // try to find the right one.\n  var index = indexOf(state.pipes, dest);\n  if (index === -1) return this;\n\n  state.pipes.splice(index, 1);\n  state.pipesCount -= 1;\n  if (state.pipesCount === 1) state.pipes = state.pipes[0];\n\n  dest.emit('unpipe', this, unpipeInfo);\n\n  return this;\n};\n\n// set up data events if they are asked for\n// Ensure readable listeners eventually get something\nReadable.prototype.on = function (ev, fn) {\n  var res = Stream.prototype.on.call(this, ev, fn);\n\n  if (ev === 'data') {\n    // Start flowing on next tick if stream isn't explicitly paused\n    if (this._readableState.flowing !== false) this.resume();\n  } else if (ev === 'readable') {\n    var state = this._readableState;\n    if (!state.endEmitted && !state.readableListening) {\n      state.readableListening = state.needReadable = true;\n      state.emittedReadable = false;\n      if (!state.reading) {\n        pna.nextTick(nReadingNextTick, this);\n      } else if (state.length) {\n        emitReadable(this);\n      }\n    }\n  }\n\n  return res;\n};\nReadable.prototype.addListener = Readable.prototype.on;\n\nfunction nReadingNextTick(self) {\n  debug('readable nexttick read 0');\n  self.read(0);\n}\n\n// pause() and resume() are remnants of the legacy readable stream API\n// If the user uses them, then switch into old mode.\nReadable.prototype.resume = function () {\n  var state = this._readableState;\n  if (!state.flowing) {\n    debug('resume');\n    state.flowing = true;\n    resume(this, state);\n  }\n  return this;\n};\n\nfunction resume(stream, state) {\n  if (!state.resumeScheduled) {\n    state.resumeScheduled = true;\n    pna.nextTick(resume_, stream, state);\n  }\n}\n\nfunction resume_(stream, state) {\n  if (!state.reading) {\n    debug('resume read 0');\n    stream.read(0);\n  }\n\n  state.resumeScheduled = false;\n  state.awaitDrain = 0;\n  stream.emit('resume');\n  flow(stream);\n  if (state.flowing && !state.reading) stream.read(0);\n}\n\nReadable.prototype.pause = function () {\n  debug('call pause flowing=%j', this._readableState.flowing);\n  if (false !== this._readableState.flowing) {\n    debug('pause');\n    this._readableState.flowing = false;\n    this.emit('pause');\n  }\n  return this;\n};\n\nfunction flow(stream) {\n  var state = stream._readableState;\n  debug('flow', state.flowing);\n  while (state.flowing && stream.read() !== null) { }\n}\n\n// wrap an old-style stream as the async data source.\n// This is *not* part of the readable stream interface.\n// It is an ugly unfortunate mess of history.\nReadable.prototype.wrap = function (stream) {\n  var _this = this;\n\n  var state = this._readableState;\n  var paused = false;\n\n  stream.on('end', function () {\n    debug('wrapped end');\n    if (state.decoder && !state.ended) {\n      var chunk = state.decoder.end();\n      if (chunk && chunk.length) _this.push(chunk);\n    }\n\n    _this.push(null);\n  });\n\n  stream.on('data', function (chunk) {\n    debug('wrapped data');\n    if (state.decoder) chunk = state.decoder.write(chunk);\n\n    // don't skip over falsy values in objectMode\n    if (state.objectMode && (chunk === null || chunk === undefined)) return; else if (!state.objectMode && (!chunk || !chunk.length)) return;\n\n    var ret = _this.push(chunk);\n    if (!ret) {\n      paused = true;\n      stream.pause();\n    }\n  });\n\n  // proxy all the other methods.\n  // important when wrapping filters and duplexes.\n  for (var i in stream) {\n    if (this[i] === undefined && typeof stream[i] === 'function') {\n      this[i] = function (method) {\n        return function () {\n          return stream[method].apply(stream, arguments);\n        };\n      }(i);\n    }\n  }\n\n  // proxy certain important events.\n  for (var n = 0; n < kProxyEvents.length; n++) {\n    stream.on(kProxyEvents[n], this.emit.bind(this, kProxyEvents[n]));\n  }\n\n  // when we try to consume some more bytes, simply unpause the\n  // underlying stream.\n  this._read = function (n) {\n    debug('wrapped _read', n);\n    if (paused) {\n      paused = false;\n      stream.resume();\n    }\n  };\n\n  return this;\n};\n\nObject.defineProperty(Readable.prototype, 'readableHighWaterMark', {\n  // making it explicit this property is not enumerable\n  // because otherwise some prototype manipulation in\n  // userland will fail\n  enumerable: false,\n  get: function () {\n    return this._readableState.highWaterMark;\n  }\n});\n\n// exposed for testing purposes only.\nReadable._fromList = fromList;\n\n// Pluck off n bytes from an array of buffers.\n// Length is the combined lengths of all the buffers in the list.\n// This function is designed to be inlinable, so please take care when making\n// changes to the function body.\nfunction fromList(n, state) {\n  // nothing buffered\n  if (state.length === 0) return null;\n\n  var ret;\n  if (state.objectMode) ret = state.buffer.shift(); else if (!n || n >= state.length) {\n    // read it all, truncate the list\n    if (state.decoder) ret = state.buffer.join(''); else if (state.buffer.length === 1) ret = state.buffer.head.data; else ret = state.buffer.concat(state.length);\n    state.buffer.clear();\n  } else {\n    // read part of list\n    ret = fromListPartial(n, state.buffer, state.decoder);\n  }\n\n  return ret;\n}\n\n// Extracts only enough buffered data to satisfy the amount requested.\n// This function is designed to be inlinable, so please take care when making\n// changes to the function body.\nfunction fromListPartial(n, list, hasStrings) {\n  var ret;\n  if (n < list.head.data.length) {\n    // slice is the same for buffers and strings\n    ret = list.head.data.slice(0, n);\n    list.head.data = list.head.data.slice(n);\n  } else if (n === list.head.data.length) {\n    // first chunk is a perfect match\n    ret = list.shift();\n  } else {\n    // result spans more than one buffer\n    ret = hasStrings ? copyFromBufferString(n, list) : copyFromBuffer(n, list);\n  }\n  return ret;\n}\n\n// Copies a specified amount of characters from the list of buffered data\n// chunks.\n// This function is designed to be inlinable, so please take care when making\n// changes to the function body.\nfunction copyFromBufferString(n, list) {\n  var p = list.head;\n  var c = 1;\n  var ret = p.data;\n  n -= ret.length;\n  while (p = p.next) {\n    var str = p.data;\n    var nb = n > str.length ? str.length : n;\n    if (nb === str.length) ret += str; else ret += str.slice(0, n);\n    n -= nb;\n    if (n === 0) {\n      if (nb === str.length) {\n        ++c;\n        if (p.next) list.head = p.next; else list.head = list.tail = null;\n      } else {\n        list.head = p;\n        p.data = str.slice(nb);\n      }\n      break;\n    }\n    ++c;\n  }\n  list.length -= c;\n  return ret;\n}\n\n// Copies a specified amount of bytes from the list of buffered data chunks.\n// This function is designed to be inlinable, so please take care when making\n// changes to the function body.\nfunction copyFromBuffer(n, list) {\n  var ret = Buffer.allocUnsafe(n);\n  var p = list.head;\n  var c = 1;\n  p.data.copy(ret);\n  n -= p.data.length;\n  while (p = p.next) {\n    var buf = p.data;\n    var nb = n > buf.length ? buf.length : n;\n    buf.copy(ret, ret.length - n, 0, nb);\n    n -= nb;\n    if (n === 0) {\n      if (nb === buf.length) {\n        ++c;\n        if (p.next) list.head = p.next; else list.head = list.tail = null;\n      } else {\n        list.head = p;\n        p.data = buf.slice(nb);\n      }\n      break;\n    }\n    ++c;\n  }\n  list.length -= c;\n  return ret;\n}\n\nfunction endReadable(stream) {\n  var state = stream._readableState;\n\n  // If we get here before consuming all the bytes, then that is a\n  // bug in node.  Should never happen.\n  if (state.length > 0) throw new Error('\"endReadable()\" called on non-empty stream');\n\n  if (!state.endEmitted) {\n    state.ended = true;\n    pna.nextTick(endReadableNT, state, stream);\n  }\n}\n\nfunction endReadableNT(state, stream) {\n  // Check that we didn't get one last unshift.\n  if (!state.endEmitted && state.length === 0) {\n    state.endEmitted = true;\n    stream.readable = false;\n    stream.emit('end');\n  }\n}\n\nfunction indexOf(xs, x) {\n  for (var i = 0, l = xs.length; i < l; i++) {\n    if (xs[i] === x) return i;\n  }\n  return -1;\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/_stream_transform.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n// a transform stream is a readable/writable stream where you do\n// something with the data.  Sometimes it's called a \"filter\",\n// but that's not a great name for it, since that implies a thing where\n// some bits pass through, and others are simply ignored.  (That would\n// be a valid example of a transform, of course.)\n//\n// While the output is causally related to the input, it's not a\n// necessarily symmetric or synchronous transformation.  For example,\n// a zlib stream might take multiple plain-text writes(), and then\n// emit a single compressed chunk some time in the future.\n//\n// Here's how this works:\n//\n// The Transform stream has all the aspects of the readable and writable\n// stream classes.  When you write(chunk), that calls _write(chunk,cb)\n// internally, and returns false if there's a lot of pending writes\n// buffered up.  When you call read(), that calls _read(n) until\n// there's enough pending readable data buffered up.\n//\n// In a transform stream, the written data is placed in a buffer.  When\n// _read(n) is called, it transforms the queued up data, calling the\n// buffered _write cb's as it consumes chunks.  If consuming a single\n// written chunk would result in multiple output chunks, then the first\n// outputted bit calls the readcb, and subsequent chunks just go into\n// the read buffer, and will cause it to emit 'readable' if necessary.\n//\n// This way, back-pressure is actually determined by the reading side,\n// since _read has to be called to start processing a new chunk.  However,\n// a pathological inflate type of transform can cause excessive buffering\n// here.  For example, imagine a stream where every byte of input is\n// interpreted as an integer from 0-255, and then results in that many\n// bytes of output.  Writing the 4 bytes {ff,ff,ff,ff} would result in\n// 1kb of data being output.  In this case, you could write a very small\n// amount of input, and end up with a very large amount of output.  In\n// such a pathological inflating mechanism, there'd be no way to tell\n// the system to stop doing the transform.  A single 4MB write could\n// cause the system to run out of memory.\n//\n// However, even in such a pathological case, only a single written chunk\n// would be consumed, and then the rest would wait (un-transformed) until\n// the results of the previous transformed chunk were consumed.\n\n'use strict';\n\nmodule.exports = Transform;\n\nvar Duplex = require('./_stream_duplex');\n\n/*<replacement>*/\nvar util = require('util')\n// var util = Object.create(require('core-util-is'));\n// util.inherits = require('inherits');\n/*</replacement>*/\n\nutil.inherits(Transform, Duplex);\n\nfunction afterTransform(er, data) {\n  var ts = this._transformState;\n  ts.transforming = false;\n\n  var cb = ts.writecb;\n\n  if (!cb) {\n    return this.emit('error', new Error('write callback called multiple times'));\n  }\n\n  ts.writechunk = null;\n  ts.writecb = null;\n\n  if (data != null) // single equals check for both `null` and `undefined`\n    this.push(data);\n\n  cb(er);\n\n  var rs = this._readableState;\n  rs.reading = false;\n  if (rs.needReadable || rs.length < rs.highWaterMark) {\n    this._read(rs.highWaterMark);\n  }\n}\n\nfunction Transform(options) {\n  if (!(this instanceof Transform)) return new Transform(options);\n\n  Duplex.call(this, options);\n\n  this._transformState = {\n    afterTransform: afterTransform.bind(this),\n    needTransform: false,\n    transforming: false,\n    writecb: null,\n    writechunk: null,\n    writeencoding: null\n  };\n\n  // start out asking for a readable event once data is transformed.\n  this._readableState.needReadable = true;\n\n  // we have implemented the _read method, and done the other things\n  // that Readable wants before the first _read call, so unset the\n  // sync guard flag.\n  this._readableState.sync = false;\n\n  if (options) {\n    if (typeof options.transform === 'function') this._transform = options.transform;\n\n    if (typeof options.flush === 'function') this._flush = options.flush;\n  }\n\n  // When the writable side finishes, then flush out anything remaining.\n  this.on('prefinish', prefinish);\n}\n\nfunction prefinish() {\n  var _this = this;\n\n  if (typeof this._flush === 'function') {\n    this._flush(function (er, data) {\n      done(_this, er, data);\n    });\n  } else {\n    done(this, null, null);\n  }\n}\n\nTransform.prototype.push = function (chunk, encoding) {\n  this._transformState.needTransform = false;\n  return Duplex.prototype.push.call(this, chunk, encoding);\n};\n\n// This is the part where you do stuff!\n// override this function in implementation classes.\n// 'chunk' is an input chunk.\n//\n// Call `push(newChunk)` to pass along transformed output\n// to the readable side.  You may call 'push' zero or more times.\n//\n// Call `cb(err)` when you are done with this chunk.  If you pass\n// an error, then that'll put the hurt on the whole operation.  If you\n// never call cb(), then you'll never get another chunk.\nTransform.prototype._transform = function (chunk, encoding, cb) {\n  throw new Error('_transform() is not implemented');\n};\n\nTransform.prototype._write = function (chunk, encoding, cb) {\n  var ts = this._transformState;\n  ts.writecb = cb;\n  ts.writechunk = chunk;\n  ts.writeencoding = encoding;\n  if (!ts.transforming) {\n    var rs = this._readableState;\n    if (ts.needTransform || rs.needReadable || rs.length < rs.highWaterMark) this._read(rs.highWaterMark);\n  }\n};\n\n// Doesn't matter what the args are here.\n// _transform does all the work.\n// That we got here means that the readable side wants more data.\nTransform.prototype._read = function (n) {\n  var ts = this._transformState;\n\n  if (ts.writechunk !== null && ts.writecb && !ts.transforming) {\n    ts.transforming = true;\n    this._transform(ts.writechunk, ts.writeencoding, ts.afterTransform);\n  } else {\n    // mark that we need a transform, so that any data that comes in\n    // will get processed, now that we've asked for it.\n    ts.needTransform = true;\n  }\n};\n\nTransform.prototype._destroy = function (err, cb) {\n  var _this2 = this;\n\n  Duplex.prototype._destroy.call(this, err, function (err2) {\n    cb(err2);\n    _this2.emit('close');\n  });\n};\n\nfunction done(stream, er, data) {\n  if (er) return stream.emit('error', er);\n\n  if (data != null) // single equals check for both `null` and `undefined`\n    stream.push(data);\n\n  // if there's nothing in the write buffer, then that means\n  // that nothing more will ever be provided\n  if (stream._writableState.length) throw new Error('Calling transform done when ws.length != 0');\n\n  if (stream._transformState.transforming) throw new Error('Calling transform done when still transforming');\n\n  return stream.push(null);\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/_stream_writable.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n// A bit simpler than readable streams.\n// Implement an async ._write(chunk, encoding, cb), and it'll handle all\n// the drain event emission and buffering.\n\n'use strict';\n\n/*<replacement>*/\n\n// var pna = require('process-nextick-args');\nvar pna = process\n/*</replacement>*/\n\nmodule.exports = Writable;\n\n/* <replacement> */\nfunction WriteReq(chunk, encoding, cb) {\n  this.chunk = chunk;\n  this.encoding = encoding;\n  this.callback = cb;\n  this.next = null;\n}\n\n// It seems a linked list but it is not\n// there will be only 2 of these for each stream\nfunction CorkedRequest(state) {\n  var _this = this;\n\n  this.next = null;\n  this.entry = null;\n  this.finish = function () {\n    onCorkedFinish(_this, state);\n  };\n}\n/* </replacement> */\n\n/*<replacement>*/\nvar asyncWrite = !process.browser && ['v0.10', 'v0.9.'].indexOf(process.version.slice(0, 5)) > -1 ? setImmediate : pna.nextTick;\n/*</replacement>*/\n\n/*<replacement>*/\nvar Duplex;\n/*</replacement>*/\n\nWritable.WritableState = WritableState;\n\n/*<replacement>*/\n// var util = Object.create(require('core-util-is'));\n// util.inherits = require('inherits');\nvar util = require('util')\n/*</replacement>*/\n\n/*<replacement>*/\nvar internalUtil = {\n  deprecate: util.deprecate\n};\n/*</replacement>*/\n\n/*<replacement>*/\nvar Stream = require('./internal/streams/stream');\n/*</replacement>*/\n\n/*<replacement>*/\n\nvar Buffer = require('../../../safeBuffer').Buffer;\nvar OurUint8Array = global.Uint8Array || function () { };\nfunction _uint8ArrayToBuffer(chunk) {\n  return Buffer.from(chunk);\n}\nfunction _isUint8Array(obj) {\n  return Buffer.isBuffer(obj) || obj instanceof OurUint8Array;\n}\n\n/*</replacement>*/\n\nvar destroyImpl = require('./internal/streams/destroy');\n\nutil.inherits(Writable, Stream);\n\nfunction nop() { }\n\nfunction WritableState(options, stream) {\n  Duplex = Duplex || require('./_stream_duplex');\n\n  options = options || {};\n\n  // Duplex streams are both readable and writable, but share\n  // the same options object.\n  // However, some cases require setting options to different\n  // values for the readable and the writable sides of the duplex stream.\n  // These options can be provided separately as readableXXX and writableXXX.\n  var isDuplex = stream instanceof Duplex;\n\n  // object stream flag to indicate whether or not this stream\n  // contains buffers or objects.\n  this.objectMode = !!options.objectMode;\n\n  if (isDuplex) this.objectMode = this.objectMode || !!options.writableObjectMode;\n\n  // the point at which write() starts returning false\n  // Note: 0 is a valid value, means that we always return false if\n  // the entire buffer is not flushed immediately on write()\n  var hwm = options.highWaterMark;\n  var writableHwm = options.writableHighWaterMark;\n  var defaultHwm = this.objectMode ? 16 : 16 * 1024;\n\n  if (hwm || hwm === 0) this.highWaterMark = hwm; else if (isDuplex && (writableHwm || writableHwm === 0)) this.highWaterMark = writableHwm; else this.highWaterMark = defaultHwm;\n\n  // cast to ints.\n  this.highWaterMark = Math.floor(this.highWaterMark);\n\n  // if _final has been called\n  this.finalCalled = false;\n\n  // drain event flag.\n  this.needDrain = false;\n  // at the start of calling end()\n  this.ending = false;\n  // when end() has been called, and returned\n  this.ended = false;\n  // when 'finish' is emitted\n  this.finished = false;\n\n  // has it been destroyed\n  this.destroyed = false;\n\n  // should we decode strings into buffers before passing to _write?\n  // this is here so that some node-core streams can optimize string\n  // handling at a lower level.\n  var noDecode = options.decodeStrings === false;\n  this.decodeStrings = !noDecode;\n\n  // Crypto is kind of old and crusty.  Historically, its default string\n  // encoding is 'binary' so we have to make this configurable.\n  // Everything else in the universe uses 'utf8', though.\n  this.defaultEncoding = options.defaultEncoding || 'utf8';\n\n  // not an actual buffer we keep track of, but a measurement\n  // of how much we're waiting to get pushed to some underlying\n  // socket or file.\n  this.length = 0;\n\n  // a flag to see when we're in the middle of a write.\n  this.writing = false;\n\n  // when true all writes will be buffered until .uncork() call\n  this.corked = 0;\n\n  // a flag to be able to tell if the onwrite cb is called immediately,\n  // or on a later tick.  We set this to true at first, because any\n  // actions that shouldn't happen until \"later\" should generally also\n  // not happen before the first write call.\n  this.sync = true;\n\n  // a flag to know if we're processing previously buffered items, which\n  // may call the _write() callback in the same tick, so that we don't\n  // end up in an overlapped onwrite situation.\n  this.bufferProcessing = false;\n\n  // the callback that's passed to _write(chunk,cb)\n  this.onwrite = function (er) {\n    onwrite(stream, er);\n  };\n\n  // the callback that the user supplies to write(chunk,encoding,cb)\n  this.writecb = null;\n\n  // the amount that is being written when _write is called.\n  this.writelen = 0;\n\n  this.bufferedRequest = null;\n  this.lastBufferedRequest = null;\n\n  // number of pending user-supplied write callbacks\n  // this must be 0 before 'finish' can be emitted\n  this.pendingcb = 0;\n\n  // emit prefinish if the only thing we're waiting for is _write cbs\n  // This is relevant for synchronous Transform streams\n  this.prefinished = false;\n\n  // True if the error was already emitted and should not be thrown again\n  this.errorEmitted = false;\n\n  // count buffered requests\n  this.bufferedRequestCount = 0;\n\n  // allocate the first CorkedRequest, there is always\n  // one allocated and free to use, and we maintain at most two\n  this.corkedRequestsFree = new CorkedRequest(this);\n}\n\nWritableState.prototype.getBuffer = function getBuffer() {\n  var current = this.bufferedRequest;\n  var out = [];\n  while (current) {\n    out.push(current);\n    current = current.next;\n  }\n  return out;\n};\n\n(function () {\n  try {\n    Object.defineProperty(WritableState.prototype, 'buffer', {\n      get: internalUtil.deprecate(function () {\n        return this.getBuffer();\n      }, '_writableState.buffer is deprecated. Use _writableState.getBuffer ' + 'instead.', 'DEP0003')\n    });\n  } catch (_) { }\n})();\n\n// Test _writableState for inheritance to account for Duplex streams,\n// whose prototype chain only points to Readable.\nvar realHasInstance;\nif (typeof Symbol === 'function' && Symbol.hasInstance && typeof Function.prototype[Symbol.hasInstance] === 'function') {\n  realHasInstance = Function.prototype[Symbol.hasInstance];\n  Object.defineProperty(Writable, Symbol.hasInstance, {\n    value: function (object) {\n      if (realHasInstance.call(this, object)) return true;\n      if (this !== Writable) return false;\n\n      return object && object._writableState instanceof WritableState;\n    }\n  });\n} else {\n  realHasInstance = function (object) {\n    return object instanceof this;\n  };\n}\n\nfunction Writable(options) {\n  Duplex = Duplex || require('./_stream_duplex');\n\n  // Writable ctor is applied to Duplexes, too.\n  // `realHasInstance` is necessary because using plain `instanceof`\n  // would return false, as no `_writableState` property is attached.\n\n  // Trying to use the custom `instanceof` for Writable here will also break the\n  // Node.js LazyTransform implementation, which has a non-trivial getter for\n  // `_writableState` that would lead to infinite recursion.\n  if (!realHasInstance.call(Writable, this) && !(this instanceof Duplex)) {\n    return new Writable(options);\n  }\n\n  this._writableState = new WritableState(options, this);\n\n  // legacy.\n  this.writable = true;\n\n  if (options) {\n    if (typeof options.write === 'function') this._write = options.write;\n\n    if (typeof options.writev === 'function') this._writev = options.writev;\n\n    if (typeof options.destroy === 'function') this._destroy = options.destroy;\n\n    if (typeof options.final === 'function') this._final = options.final;\n  }\n\n  Stream.call(this);\n}\n\n// Otherwise people can pipe Writable streams, which is just wrong.\nWritable.prototype.pipe = function () {\n  this.emit('error', new Error('Cannot pipe, not readable'));\n};\n\nfunction writeAfterEnd(stream, cb) {\n  var er = new Error('write after end');\n  // TODO: defer error events consistently everywhere, not just the cb\n  stream.emit('error', er);\n  pna.nextTick(cb, er);\n}\n\n// Checks that a user-supplied chunk is valid, especially for the particular\n// mode the stream is in. Currently this means that `null` is never accepted\n// and undefined/non-string values are only allowed in object mode.\nfunction validChunk(stream, state, chunk, cb) {\n  var valid = true;\n  var er = false;\n\n  if (chunk === null) {\n    er = new TypeError('May not write null values to stream');\n  } else if (typeof chunk !== 'string' && chunk !== undefined && !state.objectMode) {\n    er = new TypeError('Invalid non-string/buffer chunk');\n  }\n  if (er) {\n    stream.emit('error', er);\n    pna.nextTick(cb, er);\n    valid = false;\n  }\n  return valid;\n}\n\nWritable.prototype.write = function (chunk, encoding, cb) {\n  var state = this._writableState;\n  var ret = false;\n  var isBuf = !state.objectMode && _isUint8Array(chunk);\n\n  if (isBuf && !Buffer.isBuffer(chunk)) {\n    chunk = _uint8ArrayToBuffer(chunk);\n  }\n\n  if (typeof encoding === 'function') {\n    cb = encoding;\n    encoding = null;\n  }\n\n  if (isBuf) encoding = 'buffer'; else if (!encoding) encoding = state.defaultEncoding;\n\n  if (typeof cb !== 'function') cb = nop;\n\n  if (state.ended) writeAfterEnd(this, cb); else if (isBuf || validChunk(this, state, chunk, cb)) {\n    state.pendingcb++;\n    ret = writeOrBuffer(this, state, isBuf, chunk, encoding, cb);\n  }\n\n  return ret;\n};\n\nWritable.prototype.cork = function () {\n  var state = this._writableState;\n\n  state.corked++;\n};\n\nWritable.prototype.uncork = function () {\n  var state = this._writableState;\n\n  if (state.corked) {\n    state.corked--;\n\n    if (!state.writing && !state.corked && !state.finished && !state.bufferProcessing && state.bufferedRequest) clearBuffer(this, state);\n  }\n};\n\nWritable.prototype.setDefaultEncoding = function setDefaultEncoding(encoding) {\n  // node::ParseEncoding() requires lower case.\n  if (typeof encoding === 'string') encoding = encoding.toLowerCase();\n  if (!(['hex', 'utf8', 'utf-8', 'ascii', 'binary', 'base64', 'ucs2', 'ucs-2', 'utf16le', 'utf-16le', 'raw'].indexOf((encoding + '').toLowerCase()) > -1)) throw new TypeError('Unknown encoding: ' + encoding);\n  this._writableState.defaultEncoding = encoding;\n  return this;\n};\n\nfunction decodeChunk(state, chunk, encoding) {\n  if (!state.objectMode && state.decodeStrings !== false && typeof chunk === 'string') {\n    chunk = Buffer.from(chunk, encoding);\n  }\n  return chunk;\n}\n\nObject.defineProperty(Writable.prototype, 'writableHighWaterMark', {\n  // making it explicit this property is not enumerable\n  // because otherwise some prototype manipulation in\n  // userland will fail\n  enumerable: false,\n  get: function () {\n    return this._writableState.highWaterMark;\n  }\n});\n\n// if we're already writing something, then just put this\n// in the queue, and wait our turn.  Otherwise, call _write\n// If we return false, then we need a drain event, so set that flag.\nfunction writeOrBuffer(stream, state, isBuf, chunk, encoding, cb) {\n  if (!isBuf) {\n    var newChunk = decodeChunk(state, chunk, encoding);\n    if (chunk !== newChunk) {\n      isBuf = true;\n      encoding = 'buffer';\n      chunk = newChunk;\n    }\n  }\n  var len = state.objectMode ? 1 : chunk.length;\n\n  state.length += len;\n\n  var ret = state.length < state.highWaterMark;\n  // we must ensure that previous needDrain will not be reset to false.\n  if (!ret) state.needDrain = true;\n\n  if (state.writing || state.corked) {\n    var last = state.lastBufferedRequest;\n    state.lastBufferedRequest = {\n      chunk: chunk,\n      encoding: encoding,\n      isBuf: isBuf,\n      callback: cb,\n      next: null\n    };\n    if (last) {\n      last.next = state.lastBufferedRequest;\n    } else {\n      state.bufferedRequest = state.lastBufferedRequest;\n    }\n    state.bufferedRequestCount += 1;\n  } else {\n    doWrite(stream, state, false, len, chunk, encoding, cb);\n  }\n\n  return ret;\n}\n\nfunction doWrite(stream, state, writev, len, chunk, encoding, cb) {\n  state.writelen = len;\n  state.writecb = cb;\n  state.writing = true;\n  state.sync = true;\n  if (writev) stream._writev(chunk, state.onwrite); else stream._write(chunk, encoding, state.onwrite);\n  state.sync = false;\n}\n\nfunction onwriteError(stream, state, sync, er, cb) {\n  --state.pendingcb;\n\n  if (sync) {\n    // defer the callback if we are being called synchronously\n    // to avoid piling up things on the stack\n    pna.nextTick(cb, er);\n    // this can emit finish, and it will always happen\n    // after error\n    pna.nextTick(finishMaybe, stream, state);\n    stream._writableState.errorEmitted = true;\n    stream.emit('error', er);\n  } else {\n    // the caller expect this to happen before if\n    // it is async\n    cb(er);\n    stream._writableState.errorEmitted = true;\n    stream.emit('error', er);\n    // this can emit finish, but finish must\n    // always follow error\n    finishMaybe(stream, state);\n  }\n}\n\nfunction onwriteStateUpdate(state) {\n  state.writing = false;\n  state.writecb = null;\n  state.length -= state.writelen;\n  state.writelen = 0;\n}\n\nfunction onwrite(stream, er) {\n  var state = stream._writableState;\n  var sync = state.sync;\n  var cb = state.writecb;\n\n  onwriteStateUpdate(state);\n\n  if (er) onwriteError(stream, state, sync, er, cb); else {\n    // Check if we're actually ready to finish, but don't emit yet\n    var finished = needFinish(state);\n\n    if (!finished && !state.corked && !state.bufferProcessing && state.bufferedRequest) {\n      clearBuffer(stream, state);\n    }\n\n    if (sync) {\n      /*<replacement>*/\n      asyncWrite(afterWrite, stream, state, finished, cb);\n      /*</replacement>*/\n    } else {\n      afterWrite(stream, state, finished, cb);\n    }\n  }\n}\n\nfunction afterWrite(stream, state, finished, cb) {\n  if (!finished) onwriteDrain(stream, state);\n  state.pendingcb--;\n  cb();\n  finishMaybe(stream, state);\n}\n\n// Must force callback to be called on nextTick, so that we don't\n// emit 'drain' before the write() consumer gets the 'false' return\n// value, and has a chance to attach a 'drain' listener.\nfunction onwriteDrain(stream, state) {\n  if (state.length === 0 && state.needDrain) {\n    state.needDrain = false;\n    stream.emit('drain');\n  }\n}\n\n// if there's something in the buffer waiting, then process it\nfunction clearBuffer(stream, state) {\n  state.bufferProcessing = true;\n  var entry = state.bufferedRequest;\n\n  if (stream._writev && entry && entry.next) {\n    // Fast case, write everything using _writev()\n    var l = state.bufferedRequestCount;\n    var buffer = new Array(l);\n    var holder = state.corkedRequestsFree;\n    holder.entry = entry;\n\n    var count = 0;\n    var allBuffers = true;\n    while (entry) {\n      buffer[count] = entry;\n      if (!entry.isBuf) allBuffers = false;\n      entry = entry.next;\n      count += 1;\n    }\n    buffer.allBuffers = allBuffers;\n\n    doWrite(stream, state, true, state.length, buffer, '', holder.finish);\n\n    // doWrite is almost always async, defer these to save a bit of time\n    // as the hot path ends with doWrite\n    state.pendingcb++;\n    state.lastBufferedRequest = null;\n    if (holder.next) {\n      state.corkedRequestsFree = holder.next;\n      holder.next = null;\n    } else {\n      state.corkedRequestsFree = new CorkedRequest(state);\n    }\n    state.bufferedRequestCount = 0;\n  } else {\n    // Slow case, write chunks one-by-one\n    while (entry) {\n      var chunk = entry.chunk;\n      var encoding = entry.encoding;\n      var cb = entry.callback;\n      var len = state.objectMode ? 1 : chunk.length;\n\n      doWrite(stream, state, false, len, chunk, encoding, cb);\n      entry = entry.next;\n      state.bufferedRequestCount--;\n      // if we didn't call the onwrite immediately, then\n      // it means that we need to wait until it does.\n      // also, that means that the chunk and cb are currently\n      // being processed, so move the buffer counter past them.\n      if (state.writing) {\n        break;\n      }\n    }\n\n    if (entry === null) state.lastBufferedRequest = null;\n  }\n\n  state.bufferedRequest = entry;\n  state.bufferProcessing = false;\n}\n\nWritable.prototype._write = function (chunk, encoding, cb) {\n  cb(new Error('_write() is not implemented'));\n};\n\nWritable.prototype._writev = null;\n\nWritable.prototype.end = function (chunk, encoding, cb) {\n  var state = this._writableState;\n\n  if (typeof chunk === 'function') {\n    cb = chunk;\n    chunk = null;\n    encoding = null;\n  } else if (typeof encoding === 'function') {\n    cb = encoding;\n    encoding = null;\n  }\n\n  if (chunk !== null && chunk !== undefined) this.write(chunk, encoding);\n\n  // .end() fully uncorks\n  if (state.corked) {\n    state.corked = 1;\n    this.uncork();\n  }\n\n  // ignore unnecessary end() calls.\n  if (!state.ending && !state.finished) endWritable(this, state, cb);\n};\n\nfunction needFinish(state) {\n  return state.ending && state.length === 0 && state.bufferedRequest === null && !state.finished && !state.writing;\n}\nfunction callFinal(stream, state) {\n  stream._final(function (err) {\n    state.pendingcb--;\n    if (err) {\n      stream.emit('error', err);\n    }\n    state.prefinished = true;\n    stream.emit('prefinish');\n    finishMaybe(stream, state);\n  });\n}\nfunction prefinish(stream, state) {\n  if (!state.prefinished && !state.finalCalled) {\n    if (typeof stream._final === 'function') {\n      state.pendingcb++;\n      state.finalCalled = true;\n      pna.nextTick(callFinal, stream, state);\n    } else {\n      state.prefinished = true;\n      stream.emit('prefinish');\n    }\n  }\n}\n\nfunction finishMaybe(stream, state) {\n  var need = needFinish(state);\n  if (need) {\n    prefinish(stream, state);\n    if (state.pendingcb === 0) {\n      state.finished = true;\n      stream.emit('finish');\n    }\n  }\n  return need;\n}\n\nfunction endWritable(stream, state, cb) {\n  state.ending = true;\n  finishMaybe(stream, state);\n  if (cb) {\n    if (state.finished) pna.nextTick(cb); else stream.once('finish', cb);\n  }\n  state.ended = true;\n  stream.writable = false;\n}\n\nfunction onCorkedFinish(corkReq, state, err) {\n  var entry = corkReq.entry;\n  corkReq.entry = null;\n  while (entry) {\n    var cb = entry.callback;\n    state.pendingcb--;\n    cb(err);\n    entry = entry.next;\n  }\n  if (state.corkedRequestsFree) {\n    state.corkedRequestsFree.next = corkReq;\n  } else {\n    state.corkedRequestsFree = corkReq;\n  }\n}\n\nObject.defineProperty(Writable.prototype, 'destroyed', {\n  get: function () {\n    if (this._writableState === undefined) {\n      return false;\n    }\n    return this._writableState.destroyed;\n  },\n  set: function (value) {\n    // we ignore the value if the stream\n    // has not been initialized yet\n    if (!this._writableState) {\n      return;\n    }\n\n    // backward compatibility, the user is explicitly\n    // managing destroyed\n    this._writableState.destroyed = value;\n  }\n});\n\nWritable.prototype.destroy = destroyImpl.destroy;\nWritable.prototype._undestroy = destroyImpl.undestroy;\nWritable.prototype._destroy = function (err, cb) {\n  this.end();\n  cb(err);\n};"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/internal/streams/BufferList.js",
    "content": "'use strict';\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nvar Buffer = require('../../../../../safeBuffer').Buffer;\nvar util = require('util');\n\nfunction copyBuffer(src, target, offset) {\n  src.copy(target, offset);\n}\n\nmodule.exports = function () {\n  function BufferList() {\n    _classCallCheck(this, BufferList);\n\n    this.head = null;\n    this.tail = null;\n    this.length = 0;\n  }\n\n  BufferList.prototype.push = function push(v) {\n    var entry = { data: v, next: null };\n    if (this.length > 0) this.tail.next = entry; else this.head = entry;\n    this.tail = entry;\n    ++this.length;\n  };\n\n  BufferList.prototype.unshift = function unshift(v) {\n    var entry = { data: v, next: this.head };\n    if (this.length === 0) this.tail = entry;\n    this.head = entry;\n    ++this.length;\n  };\n\n  BufferList.prototype.shift = function shift() {\n    if (this.length === 0) return;\n    var ret = this.head.data;\n    if (this.length === 1) this.head = this.tail = null; else this.head = this.head.next;\n    --this.length;\n    return ret;\n  };\n\n  BufferList.prototype.clear = function clear() {\n    this.head = this.tail = null;\n    this.length = 0;\n  };\n\n  BufferList.prototype.join = function join(s) {\n    if (this.length === 0) return '';\n    var p = this.head;\n    var ret = '' + p.data;\n    while (p = p.next) {\n      ret += s + p.data;\n    } return ret;\n  };\n\n  BufferList.prototype.concat = function concat(n) {\n    if (this.length === 0) return Buffer.alloc(0);\n    if (this.length === 1) return this.head.data;\n    var ret = Buffer.allocUnsafe(n >>> 0);\n    var p = this.head;\n    var i = 0;\n    while (p) {\n      copyBuffer(p.data, ret, i);\n      i += p.data.length;\n      p = p.next;\n    }\n    return ret;\n  };\n\n  return BufferList;\n}();\n\nif (util && util.inspect && util.inspect.custom) {\n  module.exports.prototype[util.inspect.custom] = function () {\n    var obj = util.inspect({ length: this.length });\n    return this.constructor.name + ' ' + obj;\n  };\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/internal/streams/destroy.js",
    "content": "'use strict';\n\n/*<replacement>*/\n\n// var pna = require('process-nextick-args');\nvar pna = process\n/*</replacement>*/\n\n// undocumented cb() API, needed for core, not for public API\nfunction destroy(err, cb) {\n  var _this = this;\n\n  var readableDestroyed = this._readableState && this._readableState.destroyed;\n  var writableDestroyed = this._writableState && this._writableState.destroyed;\n\n  if (readableDestroyed || writableDestroyed) {\n    if (cb) {\n      cb(err);\n    } else if (err && (!this._writableState || !this._writableState.errorEmitted)) {\n      pna.nextTick(emitErrorNT, this, err);\n    }\n    return this;\n  }\n\n  // we set destroyed to true before firing error callbacks in order\n  // to make it re-entrance safe in case destroy() is called within callbacks\n\n  if (this._readableState) {\n    this._readableState.destroyed = true;\n  }\n\n  // if this is a duplex stream mark the writable part as destroyed as well\n  if (this._writableState) {\n    this._writableState.destroyed = true;\n  }\n\n  this._destroy(err || null, function (err) {\n    if (!cb && err) {\n      pna.nextTick(emitErrorNT, _this, err);\n      if (_this._writableState) {\n        _this._writableState.errorEmitted = true;\n      }\n    } else if (cb) {\n      cb(err);\n    }\n  });\n\n  return this;\n}\n\nfunction undestroy() {\n  if (this._readableState) {\n    this._readableState.destroyed = false;\n    this._readableState.reading = false;\n    this._readableState.ended = false;\n    this._readableState.endEmitted = false;\n  }\n\n  if (this._writableState) {\n    this._writableState.destroyed = false;\n    this._writableState.ended = false;\n    this._writableState.ending = false;\n    this._writableState.finished = false;\n    this._writableState.errorEmitted = false;\n  }\n}\n\nfunction emitErrorNT(self, err) {\n  self.emit('error', err);\n}\n\nmodule.exports = {\n  destroy: destroy,\n  undestroy: undestroy\n};"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/internal/streams/stream-browser.js",
    "content": "module.exports = require('events').EventEmitter;\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/lib/internal/streams/stream.js",
    "content": "module.exports = require('stream');\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/passthrough.js",
    "content": "module.exports = require('./readable').PassThrough\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lazystream/readable-stream/readable.js",
    "content": "var Stream = require('stream');\nif (process.env.READABLE_STREAM === 'disable' && Stream) {\n  module.exports = Stream;\n  exports = module.exports = Stream.Readable;\n  exports.Readable = Stream.Readable;\n  exports.Writable = Stream.Writable;\n  exports.Duplex = Stream.Duplex;\n  exports.Transform = Stream.Transform;\n  exports.PassThrough = Stream.PassThrough;\n  exports.Stream = Stream;\n} else {\n  exports = module.exports = require('./lib/_stream_readable.js');\n  exports.Stream = Stream || exports;\n  exports.Readable = exports;\n  exports.Writable = require('./lib/_stream_writable.js');\n  exports.Duplex = require('./lib/_stream_duplex.js');\n  exports.Transform = require('./lib/_stream_transform.js');\n  exports.PassThrough = require('./lib/_stream_passthrough.js');\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.difference/LICENSE",
    "content": "Copyright jQuery Foundation and other contributors <https://jquery.org/>\n\nBased on Underscore.js, copyright Jeremy Ashkenas,\nDocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>\n\nThis software consists of voluntary contributions made by many\nindividuals. For exact contribution history, see the revision history\navailable at https://github.com/lodash/lodash\n\nThe following license applies to all parts of this software except as\ndocumented below:\n\n====\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n====\n\nCopyright and related rights for sample code are waived via CC0. Sample\ncode is defined as all source code displayed within the prose of the\ndocumentation.\n\nCC0: http://creativecommons.org/publicdomain/zero/1.0/\n\n====\n\nFiles located in the node_modules and vendor directories are externally\nmaintained libraries used by this software which have their own\nlicenses; we recommend you read them, as their terms may differ from the\nterms above.\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.difference/index.js",
    "content": "/**\n * lodash (Custom Build) <https://lodash.com/>\n * Build: `lodash modularize exports=\"npm\" -o ./`\n * Copyright jQuery Foundation and other contributors <https://jquery.org/>\n * Released under MIT license <https://lodash.com/license>\n * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>\n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n */\n\n/** Used as the size to enable large array optimizations. */\nvar LARGE_ARRAY_SIZE = 200;\n\n/** Used to stand-in for `undefined` hash values. */\nvar HASH_UNDEFINED = '__lodash_hash_undefined__';\n\n/** Used as references for various `Number` constants. */\nvar MAX_SAFE_INTEGER = 9007199254740991;\n\n/** `Object#toString` result references. */\nvar argsTag = '[object Arguments]',\n  funcTag = '[object Function]',\n  genTag = '[object GeneratorFunction]';\n\n/**\n * Used to match `RegExp`\n * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns).\n */\nvar reRegExpChar = /[\\\\^$.*+?()[\\]{}|]/g;\n\n/** Used to detect host constructors (Safari). */\nvar reIsHostCtor = /^\\[object .+?Constructor\\]$/;\n\n/** Detect free variable `global` from Node.js. */\nvar freeGlobal = typeof global == 'object' && global && global.Object === Object && global;\n\n/** Detect free variable `self`. */\nvar freeSelf = typeof self == 'object' && self && self.Object === Object && self;\n\n/** Used as a reference to the global object. */\nvar root = freeGlobal || freeSelf || Function('return this')();\n\n/**\n * A faster alternative to `Function#apply`, this function invokes `func`\n * with the `this` binding of `thisArg` and the arguments of `args`.\n *\n * @private\n * @param {Function} func The function to invoke.\n * @param {*} thisArg The `this` binding of `func`.\n * @param {Array} args The arguments to invoke `func` with.\n * @returns {*} Returns the result of `func`.\n */\nfunction apply(func, thisArg, args) {\n  switch (args.length) {\n    case 0: return func.call(thisArg);\n    case 1: return func.call(thisArg, args[0]);\n    case 2: return func.call(thisArg, args[0], args[1]);\n    case 3: return func.call(thisArg, args[0], args[1], args[2]);\n  }\n  return func.apply(thisArg, args);\n}\n\n/**\n * A specialized version of `_.includes` for arrays without support for\n * specifying an index to search from.\n *\n * @private\n * @param {Array} [array] The array to inspect.\n * @param {*} target The value to search for.\n * @returns {boolean} Returns `true` if `target` is found, else `false`.\n */\nfunction arrayIncludes(array, value) {\n  var length = array ? array.length : 0;\n  return !!length && baseIndexOf(array, value, 0) > -1;\n}\n\n/**\n * This function is like `arrayIncludes` except that it accepts a comparator.\n *\n * @private\n * @param {Array} [array] The array to inspect.\n * @param {*} target The value to search for.\n * @param {Function} comparator The comparator invoked per element.\n * @returns {boolean} Returns `true` if `target` is found, else `false`.\n */\nfunction arrayIncludesWith(array, value, comparator) {\n  var index = -1,\n    length = array ? array.length : 0;\n\n  while (++index < length) {\n    if (comparator(value, array[index])) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * A specialized version of `_.map` for arrays without support for iteratee\n * shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array} Returns the new mapped array.\n */\nfunction arrayMap(array, iteratee) {\n  var index = -1,\n    length = array ? array.length : 0,\n    result = Array(length);\n\n  while (++index < length) {\n    result[index] = iteratee(array[index], index, array);\n  }\n  return result;\n}\n\n/**\n * Appends the elements of `values` to `array`.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {Array} values The values to append.\n * @returns {Array} Returns `array`.\n */\nfunction arrayPush(array, values) {\n  var index = -1,\n    length = values.length,\n    offset = array.length;\n\n  while (++index < length) {\n    array[offset + index] = values[index];\n  }\n  return array;\n}\n\n/**\n * The base implementation of `_.findIndex` and `_.findLastIndex` without\n * support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Function} predicate The function invoked per iteration.\n * @param {number} fromIndex The index to search from.\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\nfunction baseFindIndex(array, predicate, fromIndex, fromRight) {\n  var length = array.length,\n    index = fromIndex + (fromRight ? 1 : -1);\n\n  while ((fromRight ? index-- : ++index < length)) {\n    if (predicate(array[index], index, array)) {\n      return index;\n    }\n  }\n  return -1;\n}\n\n/**\n * The base implementation of `_.indexOf` without `fromIndex` bounds checks.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} fromIndex The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\nfunction baseIndexOf(array, value, fromIndex) {\n  if (value !== value) {\n    return baseFindIndex(array, baseIsNaN, fromIndex);\n  }\n  var index = fromIndex - 1,\n    length = array.length;\n\n  while (++index < length) {\n    if (array[index] === value) {\n      return index;\n    }\n  }\n  return -1;\n}\n\n/**\n * The base implementation of `_.isNaN` without support for number objects.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is `NaN`, else `false`.\n */\nfunction baseIsNaN(value) {\n  return value !== value;\n}\n\n/**\n * The base implementation of `_.unary` without support for storing metadata.\n *\n * @private\n * @param {Function} func The function to cap arguments for.\n * @returns {Function} Returns the new capped function.\n */\nfunction baseUnary(func) {\n  return function (value) {\n    return func(value);\n  };\n}\n\n/**\n * Checks if a cache value for `key` exists.\n *\n * @private\n * @param {Object} cache The cache to query.\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction cacheHas(cache, key) {\n  return cache.has(key);\n}\n\n/**\n * Gets the value at `key` of `object`.\n *\n * @private\n * @param {Object} [object] The object to query.\n * @param {string} key The key of the property to get.\n * @returns {*} Returns the property value.\n */\nfunction getValue(object, key) {\n  return object == null ? undefined : object[key];\n}\n\n/**\n * Checks if `value` is a host object in IE < 9.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a host object, else `false`.\n */\nfunction isHostObject(value) {\n  // Many host objects are `Object` objects that can coerce to strings\n  // despite having improperly defined `toString` methods.\n  var result = false;\n  if (value != null && typeof value.toString != 'function') {\n    try {\n      result = !!(value + '');\n    } catch (e) { }\n  }\n  return result;\n}\n\n/** Used for built-in method references. */\nvar arrayProto = Array.prototype,\n  funcProto = Function.prototype,\n  objectProto = Object.prototype;\n\n/** Used to detect overreaching core-js shims. */\nvar coreJsData = root['__core-js_shared__'];\n\n/** Used to detect methods masquerading as native. */\nvar maskSrcKey = (function () {\n  var uid = /[^.]+$/.exec(coreJsData && coreJsData.keys && coreJsData.keys.IE_PROTO || '');\n  return uid ? ('Symbol(src)_1.' + uid) : '';\n}());\n\n/** Used to resolve the decompiled source of functions. */\nvar funcToString = funcProto.toString;\n\n/** Used to check objects for own properties. */\nvar hasOwnProperty = objectProto.hasOwnProperty;\n\n/**\n * Used to resolve the\n * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)\n * of values.\n */\nvar objectToString = objectProto.toString;\n\n/** Used to detect if a method is native. */\nvar reIsNative = RegExp('^' +\n  funcToString.call(hasOwnProperty).replace(reRegExpChar, '\\\\$&')\n    .replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g, '$1.*?') + '$'\n);\n\n/** Built-in value references. */\nvar Symbol = root.Symbol,\n  propertyIsEnumerable = objectProto.propertyIsEnumerable,\n  splice = arrayProto.splice,\n  spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined;\n\n/* Built-in method references for those with the same name as other `lodash` methods. */\nvar nativeMax = Math.max;\n\n/* Built-in method references that are verified to be native. */\nvar Map = getNative(root, 'Map'),\n  nativeCreate = getNative(Object, 'create');\n\n/**\n * Creates a hash object.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\nfunction Hash(entries) {\n  var index = -1,\n    length = entries ? entries.length : 0;\n\n  this.clear();\n  while (++index < length) {\n    var entry = entries[index];\n    this.set(entry[0], entry[1]);\n  }\n}\n\n/**\n * Removes all key-value entries from the hash.\n *\n * @private\n * @name clear\n * @memberOf Hash\n */\nfunction hashClear() {\n  this.__data__ = nativeCreate ? nativeCreate(null) : {};\n}\n\n/**\n * Removes `key` and its value from the hash.\n *\n * @private\n * @name delete\n * @memberOf Hash\n * @param {Object} hash The hash to modify.\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\nfunction hashDelete(key) {\n  return this.has(key) && delete this.__data__[key];\n}\n\n/**\n * Gets the hash value for `key`.\n *\n * @private\n * @name get\n * @memberOf Hash\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\nfunction hashGet(key) {\n  var data = this.__data__;\n  if (nativeCreate) {\n    var result = data[key];\n    return result === HASH_UNDEFINED ? undefined : result;\n  }\n  return hasOwnProperty.call(data, key) ? data[key] : undefined;\n}\n\n/**\n * Checks if a hash value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf Hash\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction hashHas(key) {\n  var data = this.__data__;\n  return nativeCreate ? data[key] !== undefined : hasOwnProperty.call(data, key);\n}\n\n/**\n * Sets the hash `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf Hash\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the hash instance.\n */\nfunction hashSet(key, value) {\n  var data = this.__data__;\n  data[key] = (nativeCreate && value === undefined) ? HASH_UNDEFINED : value;\n  return this;\n}\n\n// Add methods to `Hash`.\nHash.prototype.clear = hashClear;\nHash.prototype['delete'] = hashDelete;\nHash.prototype.get = hashGet;\nHash.prototype.has = hashHas;\nHash.prototype.set = hashSet;\n\n/**\n * Creates an list cache object.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\nfunction ListCache(entries) {\n  var index = -1,\n    length = entries ? entries.length : 0;\n\n  this.clear();\n  while (++index < length) {\n    var entry = entries[index];\n    this.set(entry[0], entry[1]);\n  }\n}\n\n/**\n * Removes all key-value entries from the list cache.\n *\n * @private\n * @name clear\n * @memberOf ListCache\n */\nfunction listCacheClear() {\n  this.__data__ = [];\n}\n\n/**\n * Removes `key` and its value from the list cache.\n *\n * @private\n * @name delete\n * @memberOf ListCache\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\nfunction listCacheDelete(key) {\n  var data = this.__data__,\n    index = assocIndexOf(data, key);\n\n  if (index < 0) {\n    return false;\n  }\n  var lastIndex = data.length - 1;\n  if (index == lastIndex) {\n    data.pop();\n  } else {\n    splice.call(data, index, 1);\n  }\n  return true;\n}\n\n/**\n * Gets the list cache value for `key`.\n *\n * @private\n * @name get\n * @memberOf ListCache\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\nfunction listCacheGet(key) {\n  var data = this.__data__,\n    index = assocIndexOf(data, key);\n\n  return index < 0 ? undefined : data[index][1];\n}\n\n/**\n * Checks if a list cache value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf ListCache\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction listCacheHas(key) {\n  return assocIndexOf(this.__data__, key) > -1;\n}\n\n/**\n * Sets the list cache `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf ListCache\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the list cache instance.\n */\nfunction listCacheSet(key, value) {\n  var data = this.__data__,\n    index = assocIndexOf(data, key);\n\n  if (index < 0) {\n    data.push([key, value]);\n  } else {\n    data[index][1] = value;\n  }\n  return this;\n}\n\n// Add methods to `ListCache`.\nListCache.prototype.clear = listCacheClear;\nListCache.prototype['delete'] = listCacheDelete;\nListCache.prototype.get = listCacheGet;\nListCache.prototype.has = listCacheHas;\nListCache.prototype.set = listCacheSet;\n\n/**\n * Creates a map cache object to store key-value pairs.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\nfunction MapCache(entries) {\n  var index = -1,\n    length = entries ? entries.length : 0;\n\n  this.clear();\n  while (++index < length) {\n    var entry = entries[index];\n    this.set(entry[0], entry[1]);\n  }\n}\n\n/**\n * Removes all key-value entries from the map.\n *\n * @private\n * @name clear\n * @memberOf MapCache\n */\nfunction mapCacheClear() {\n  this.__data__ = {\n    'hash': new Hash,\n    'map': new (Map || ListCache),\n    'string': new Hash\n  };\n}\n\n/**\n * Removes `key` and its value from the map.\n *\n * @private\n * @name delete\n * @memberOf MapCache\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\nfunction mapCacheDelete(key) {\n  return getMapData(this, key)['delete'](key);\n}\n\n/**\n * Gets the map value for `key`.\n *\n * @private\n * @name get\n * @memberOf MapCache\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\nfunction mapCacheGet(key) {\n  return getMapData(this, key).get(key);\n}\n\n/**\n * Checks if a map value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf MapCache\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction mapCacheHas(key) {\n  return getMapData(this, key).has(key);\n}\n\n/**\n * Sets the map `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf MapCache\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the map cache instance.\n */\nfunction mapCacheSet(key, value) {\n  getMapData(this, key).set(key, value);\n  return this;\n}\n\n// Add methods to `MapCache`.\nMapCache.prototype.clear = mapCacheClear;\nMapCache.prototype['delete'] = mapCacheDelete;\nMapCache.prototype.get = mapCacheGet;\nMapCache.prototype.has = mapCacheHas;\nMapCache.prototype.set = mapCacheSet;\n\n/**\n *\n * Creates an array cache object to store unique values.\n *\n * @private\n * @constructor\n * @param {Array} [values] The values to cache.\n */\nfunction SetCache(values) {\n  var index = -1,\n    length = values ? values.length : 0;\n\n  this.__data__ = new MapCache;\n  while (++index < length) {\n    this.add(values[index]);\n  }\n}\n\n/**\n * Adds `value` to the array cache.\n *\n * @private\n * @name add\n * @memberOf SetCache\n * @alias push\n * @param {*} value The value to cache.\n * @returns {Object} Returns the cache instance.\n */\nfunction setCacheAdd(value) {\n  this.__data__.set(value, HASH_UNDEFINED);\n  return this;\n}\n\n/**\n * Checks if `value` is in the array cache.\n *\n * @private\n * @name has\n * @memberOf SetCache\n * @param {*} value The value to search for.\n * @returns {number} Returns `true` if `value` is found, else `false`.\n */\nfunction setCacheHas(value) {\n  return this.__data__.has(value);\n}\n\n// Add methods to `SetCache`.\nSetCache.prototype.add = SetCache.prototype.push = setCacheAdd;\nSetCache.prototype.has = setCacheHas;\n\n/**\n * Gets the index at which the `key` is found in `array` of key-value pairs.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} key The key to search for.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\nfunction assocIndexOf(array, key) {\n  var length = array.length;\n  while (length--) {\n    if (eq(array[length][0], key)) {\n      return length;\n    }\n  }\n  return -1;\n}\n\n/**\n * The base implementation of methods like `_.difference` without support\n * for excluding multiple arrays or iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Array} values The values to exclude.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of filtered values.\n */\nfunction baseDifference(array, values, iteratee, comparator) {\n  var index = -1,\n    includes = arrayIncludes,\n    isCommon = true,\n    length = array.length,\n    result = [],\n    valuesLength = values.length;\n\n  if (!length) {\n    return result;\n  }\n  if (iteratee) {\n    values = arrayMap(values, baseUnary(iteratee));\n  }\n  if (comparator) {\n    includes = arrayIncludesWith;\n    isCommon = false;\n  }\n  else if (values.length >= LARGE_ARRAY_SIZE) {\n    includes = cacheHas;\n    isCommon = false;\n    values = new SetCache(values);\n  }\n  outer:\n  while (++index < length) {\n    var value = array[index],\n      computed = iteratee ? iteratee(value) : value;\n\n    value = (comparator || value !== 0) ? value : 0;\n    if (isCommon && computed === computed) {\n      var valuesIndex = valuesLength;\n      while (valuesIndex--) {\n        if (values[valuesIndex] === computed) {\n          continue outer;\n        }\n      }\n      result.push(value);\n    }\n    else if (!includes(values, computed, comparator)) {\n      result.push(value);\n    }\n  }\n  return result;\n}\n\n/**\n * The base implementation of `_.flatten` with support for restricting flattening.\n *\n * @private\n * @param {Array} array The array to flatten.\n * @param {number} depth The maximum recursion depth.\n * @param {boolean} [predicate=isFlattenable] The function invoked per iteration.\n * @param {boolean} [isStrict] Restrict to values that pass `predicate` checks.\n * @param {Array} [result=[]] The initial result value.\n * @returns {Array} Returns the new flattened array.\n */\nfunction baseFlatten(array, depth, predicate, isStrict, result) {\n  var index = -1,\n    length = array.length;\n\n  predicate || (predicate = isFlattenable);\n  result || (result = []);\n\n  while (++index < length) {\n    var value = array[index];\n    if (depth > 0 && predicate(value)) {\n      if (depth > 1) {\n        // Recursively flatten arrays (susceptible to call stack limits).\n        baseFlatten(value, depth - 1, predicate, isStrict, result);\n      } else {\n        arrayPush(result, value);\n      }\n    } else if (!isStrict) {\n      result[result.length] = value;\n    }\n  }\n  return result;\n}\n\n/**\n * The base implementation of `_.isNative` without bad shim checks.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a native function,\n *  else `false`.\n */\nfunction baseIsNative(value) {\n  if (!isObject(value) || isMasked(value)) {\n    return false;\n  }\n  var pattern = (isFunction(value) || isHostObject(value)) ? reIsNative : reIsHostCtor;\n  return pattern.test(toSource(value));\n}\n\n/**\n * The base implementation of `_.rest` which doesn't validate or coerce arguments.\n *\n * @private\n * @param {Function} func The function to apply a rest parameter to.\n * @param {number} [start=func.length-1] The start position of the rest parameter.\n * @returns {Function} Returns the new function.\n */\nfunction baseRest(func, start) {\n  start = nativeMax(start === undefined ? (func.length - 1) : start, 0);\n  return function () {\n    var args = arguments,\n      index = -1,\n      length = nativeMax(args.length - start, 0),\n      array = Array(length);\n\n    while (++index < length) {\n      array[index] = args[start + index];\n    }\n    index = -1;\n    var otherArgs = Array(start + 1);\n    while (++index < start) {\n      otherArgs[index] = args[index];\n    }\n    otherArgs[start] = array;\n    return apply(func, this, otherArgs);\n  };\n}\n\n/**\n * Gets the data for `map`.\n *\n * @private\n * @param {Object} map The map to query.\n * @param {string} key The reference key.\n * @returns {*} Returns the map data.\n */\nfunction getMapData(map, key) {\n  var data = map.__data__;\n  return isKeyable(key)\n    ? data[typeof key == 'string' ? 'string' : 'hash']\n    : data.map;\n}\n\n/**\n * Gets the native function at `key` of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {string} key The key of the method to get.\n * @returns {*} Returns the function if it's native, else `undefined`.\n */\nfunction getNative(object, key) {\n  var value = getValue(object, key);\n  return baseIsNative(value) ? value : undefined;\n}\n\n/**\n * Checks if `value` is a flattenable `arguments` object or array.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is flattenable, else `false`.\n */\nfunction isFlattenable(value) {\n  return isArray(value) || isArguments(value) ||\n    !!(spreadableSymbol && value && value[spreadableSymbol]);\n}\n\n/**\n * Checks if `value` is suitable for use as unique object key.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is suitable, else `false`.\n */\nfunction isKeyable(value) {\n  var type = typeof value;\n  return (type == 'string' || type == 'number' || type == 'symbol' || type == 'boolean')\n    ? (value !== '__proto__')\n    : (value === null);\n}\n\n/**\n * Checks if `func` has its source masked.\n *\n * @private\n * @param {Function} func The function to check.\n * @returns {boolean} Returns `true` if `func` is masked, else `false`.\n */\nfunction isMasked(func) {\n  return !!maskSrcKey && (maskSrcKey in func);\n}\n\n/**\n * Converts `func` to its source code.\n *\n * @private\n * @param {Function} func The function to process.\n * @returns {string} Returns the source code.\n */\nfunction toSource(func) {\n  if (func != null) {\n    try {\n      return funcToString.call(func);\n    } catch (e) { }\n    try {\n      return (func + '');\n    } catch (e) { }\n  }\n  return '';\n}\n\n/**\n * Creates an array of `array` values not included in the other given arrays\n * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons. The order of result values is determined by the\n * order they occur in the first array.\n *\n * **Note:** Unlike `_.pullAll`, this method returns a new array.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {...Array} [values] The values to exclude.\n * @returns {Array} Returns the new array of filtered values.\n * @see _.without, _.xor\n * @example\n *\n * _.difference([2, 1], [2, 3]);\n * // => [1]\n */\nvar difference = baseRest(function (array, values) {\n  return isArrayLikeObject(array)\n    ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true))\n    : [];\n});\n\n/**\n * Performs a\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * comparison between two values to determine if they are equivalent.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if the values are equivalent, else `false`.\n * @example\n *\n * var object = { 'a': 1 };\n * var other = { 'a': 1 };\n *\n * _.eq(object, object);\n * // => true\n *\n * _.eq(object, other);\n * // => false\n *\n * _.eq('a', 'a');\n * // => true\n *\n * _.eq('a', Object('a'));\n * // => false\n *\n * _.eq(NaN, NaN);\n * // => true\n */\nfunction eq(value, other) {\n  return value === other || (value !== value && other !== other);\n}\n\n/**\n * Checks if `value` is likely an `arguments` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an `arguments` object,\n *  else `false`.\n * @example\n *\n * _.isArguments(function() { return arguments; }());\n * // => true\n *\n * _.isArguments([1, 2, 3]);\n * // => false\n */\nfunction isArguments(value) {\n  // Safari 8.1 makes `arguments.callee` enumerable in strict mode.\n  return isArrayLikeObject(value) && hasOwnProperty.call(value, 'callee') &&\n    (!propertyIsEnumerable.call(value, 'callee') || objectToString.call(value) == argsTag);\n}\n\n/**\n * Checks if `value` is classified as an `Array` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array, else `false`.\n * @example\n *\n * _.isArray([1, 2, 3]);\n * // => true\n *\n * _.isArray(document.body.children);\n * // => false\n *\n * _.isArray('abc');\n * // => false\n *\n * _.isArray(_.noop);\n * // => false\n */\nvar isArray = Array.isArray;\n\n/**\n * Checks if `value` is array-like. A value is considered array-like if it's\n * not a function and has a `value.length` that's an integer greater than or\n * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is array-like, else `false`.\n * @example\n *\n * _.isArrayLike([1, 2, 3]);\n * // => true\n *\n * _.isArrayLike(document.body.children);\n * // => true\n *\n * _.isArrayLike('abc');\n * // => true\n *\n * _.isArrayLike(_.noop);\n * // => false\n */\nfunction isArrayLike(value) {\n  return value != null && isLength(value.length) && !isFunction(value);\n}\n\n/**\n * This method is like `_.isArrayLike` except that it also checks if `value`\n * is an object.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array-like object,\n *  else `false`.\n * @example\n *\n * _.isArrayLikeObject([1, 2, 3]);\n * // => true\n *\n * _.isArrayLikeObject(document.body.children);\n * // => true\n *\n * _.isArrayLikeObject('abc');\n * // => false\n *\n * _.isArrayLikeObject(_.noop);\n * // => false\n */\nfunction isArrayLikeObject(value) {\n  return isObjectLike(value) && isArrayLike(value);\n}\n\n/**\n * Checks if `value` is classified as a `Function` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a function, else `false`.\n * @example\n *\n * _.isFunction(_);\n * // => true\n *\n * _.isFunction(/abc/);\n * // => false\n */\nfunction isFunction(value) {\n  // The use of `Object#toString` avoids issues with the `typeof` operator\n  // in Safari 8-9 which returns 'object' for typed array and other constructors.\n  var tag = isObject(value) ? objectToString.call(value) : '';\n  return tag == funcTag || tag == genTag;\n}\n\n/**\n * Checks if `value` is a valid array-like length.\n *\n * **Note:** This method is loosely based on\n * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a valid length, else `false`.\n * @example\n *\n * _.isLength(3);\n * // => true\n *\n * _.isLength(Number.MIN_VALUE);\n * // => false\n *\n * _.isLength(Infinity);\n * // => false\n *\n * _.isLength('3');\n * // => false\n */\nfunction isLength(value) {\n  return typeof value == 'number' &&\n    value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER;\n}\n\n/**\n * Checks if `value` is the\n * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)\n * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`)\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an object, else `false`.\n * @example\n *\n * _.isObject({});\n * // => true\n *\n * _.isObject([1, 2, 3]);\n * // => true\n *\n * _.isObject(_.noop);\n * // => true\n *\n * _.isObject(null);\n * // => false\n */\nfunction isObject(value) {\n  var type = typeof value;\n  return !!value && (type == 'object' || type == 'function');\n}\n\n/**\n * Checks if `value` is object-like. A value is object-like if it's not `null`\n * and has a `typeof` result of \"object\".\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is object-like, else `false`.\n * @example\n *\n * _.isObjectLike({});\n * // => true\n *\n * _.isObjectLike([1, 2, 3]);\n * // => true\n *\n * _.isObjectLike(_.noop);\n * // => false\n *\n * _.isObjectLike(null);\n * // => false\n */\nfunction isObjectLike(value) {\n  return !!value && typeof value == 'object';\n}\n\nmodule.exports = difference;\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.flatten/LICENSE",
    "content": "Copyright jQuery Foundation and other contributors <https://jquery.org/>\n\nBased on Underscore.js, copyright Jeremy Ashkenas,\nDocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>\n\nThis software consists of voluntary contributions made by many\nindividuals. For exact contribution history, see the revision history\navailable at https://github.com/lodash/lodash\n\nThe following license applies to all parts of this software except as\ndocumented below:\n\n====\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n====\n\nCopyright and related rights for sample code are waived via CC0. Sample\ncode is defined as all source code displayed within the prose of the\ndocumentation.\n\nCC0: http://creativecommons.org/publicdomain/zero/1.0/\n\n====\n\nFiles located in the node_modules and vendor directories are externally\nmaintained libraries used by this software which have their own\nlicenses; we recommend you read them, as their terms may differ from the\nterms above.\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.flatten/index.js",
    "content": "/**\n * lodash (Custom Build) <https://lodash.com/>\n * Build: `lodash modularize exports=\"npm\" -o ./`\n * Copyright jQuery Foundation and other contributors <https://jquery.org/>\n * Released under MIT license <https://lodash.com/license>\n * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>\n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n */\n\n/** Used as references for various `Number` constants. */\nvar MAX_SAFE_INTEGER = 9007199254740991;\n\n/** `Object#toString` result references. */\nvar argsTag = '[object Arguments]',\n  funcTag = '[object Function]',\n  genTag = '[object GeneratorFunction]';\n\n/** Detect free variable `global` from Node.js. */\nvar freeGlobal = typeof global == 'object' && global && global.Object === Object && global;\n\n/** Detect free variable `self`. */\nvar freeSelf = typeof self == 'object' && self && self.Object === Object && self;\n\n/** Used as a reference to the global object. */\nvar root = freeGlobal || freeSelf || Function('return this')();\n\n/**\n * Appends the elements of `values` to `array`.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {Array} values The values to append.\n * @returns {Array} Returns `array`.\n */\nfunction arrayPush(array, values) {\n  var index = -1,\n    length = values.length,\n    offset = array.length;\n\n  while (++index < length) {\n    array[offset + index] = values[index];\n  }\n  return array;\n}\n\n/** Used for built-in method references. */\nvar objectProto = Object.prototype;\n\n/** Used to check objects for own properties. */\nvar hasOwnProperty = objectProto.hasOwnProperty;\n\n/**\n * Used to resolve the\n * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)\n * of values.\n */\nvar objectToString = objectProto.toString;\n\n/** Built-in value references. */\nvar Symbol = root.Symbol,\n  propertyIsEnumerable = objectProto.propertyIsEnumerable,\n  spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined;\n\n/**\n * The base implementation of `_.flatten` with support for restricting flattening.\n *\n * @private\n * @param {Array} array The array to flatten.\n * @param {number} depth The maximum recursion depth.\n * @param {boolean} [predicate=isFlattenable] The function invoked per iteration.\n * @param {boolean} [isStrict] Restrict to values that pass `predicate` checks.\n * @param {Array} [result=[]] The initial result value.\n * @returns {Array} Returns the new flattened array.\n */\nfunction baseFlatten(array, depth, predicate, isStrict, result) {\n  var index = -1,\n    length = array.length;\n\n  predicate || (predicate = isFlattenable);\n  result || (result = []);\n\n  while (++index < length) {\n    var value = array[index];\n    if (depth > 0 && predicate(value)) {\n      if (depth > 1) {\n        // Recursively flatten arrays (susceptible to call stack limits).\n        baseFlatten(value, depth - 1, predicate, isStrict, result);\n      } else {\n        arrayPush(result, value);\n      }\n    } else if (!isStrict) {\n      result[result.length] = value;\n    }\n  }\n  return result;\n}\n\n/**\n * Checks if `value` is a flattenable `arguments` object or array.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is flattenable, else `false`.\n */\nfunction isFlattenable(value) {\n  return isArray(value) || isArguments(value) ||\n    !!(spreadableSymbol && value && value[spreadableSymbol]);\n}\n\n/**\n * Flattens `array` a single level deep.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to flatten.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * _.flatten([1, [2, [3, [4]], 5]]);\n * // => [1, 2, [3, [4]], 5]\n */\nfunction flatten(array) {\n  var length = array ? array.length : 0;\n  return length ? baseFlatten(array, 1) : [];\n}\n\n/**\n * Checks if `value` is likely an `arguments` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an `arguments` object,\n *  else `false`.\n * @example\n *\n * _.isArguments(function() { return arguments; }());\n * // => true\n *\n * _.isArguments([1, 2, 3]);\n * // => false\n */\nfunction isArguments(value) {\n  // Safari 8.1 makes `arguments.callee` enumerable in strict mode.\n  return isArrayLikeObject(value) && hasOwnProperty.call(value, 'callee') &&\n    (!propertyIsEnumerable.call(value, 'callee') || objectToString.call(value) == argsTag);\n}\n\n/**\n * Checks if `value` is classified as an `Array` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array, else `false`.\n * @example\n *\n * _.isArray([1, 2, 3]);\n * // => true\n *\n * _.isArray(document.body.children);\n * // => false\n *\n * _.isArray('abc');\n * // => false\n *\n * _.isArray(_.noop);\n * // => false\n */\nvar isArray = Array.isArray;\n\n/**\n * Checks if `value` is array-like. A value is considered array-like if it's\n * not a function and has a `value.length` that's an integer greater than or\n * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is array-like, else `false`.\n * @example\n *\n * _.isArrayLike([1, 2, 3]);\n * // => true\n *\n * _.isArrayLike(document.body.children);\n * // => true\n *\n * _.isArrayLike('abc');\n * // => true\n *\n * _.isArrayLike(_.noop);\n * // => false\n */\nfunction isArrayLike(value) {\n  return value != null && isLength(value.length) && !isFunction(value);\n}\n\n/**\n * This method is like `_.isArrayLike` except that it also checks if `value`\n * is an object.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array-like object,\n *  else `false`.\n * @example\n *\n * _.isArrayLikeObject([1, 2, 3]);\n * // => true\n *\n * _.isArrayLikeObject(document.body.children);\n * // => true\n *\n * _.isArrayLikeObject('abc');\n * // => false\n *\n * _.isArrayLikeObject(_.noop);\n * // => false\n */\nfunction isArrayLikeObject(value) {\n  return isObjectLike(value) && isArrayLike(value);\n}\n\n/**\n * Checks if `value` is classified as a `Function` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a function, else `false`.\n * @example\n *\n * _.isFunction(_);\n * // => true\n *\n * _.isFunction(/abc/);\n * // => false\n */\nfunction isFunction(value) {\n  // The use of `Object#toString` avoids issues with the `typeof` operator\n  // in Safari 8-9 which returns 'object' for typed array and other constructors.\n  var tag = isObject(value) ? objectToString.call(value) : '';\n  return tag == funcTag || tag == genTag;\n}\n\n/**\n * Checks if `value` is a valid array-like length.\n *\n * **Note:** This method is loosely based on\n * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a valid length, else `false`.\n * @example\n *\n * _.isLength(3);\n * // => true\n *\n * _.isLength(Number.MIN_VALUE);\n * // => false\n *\n * _.isLength(Infinity);\n * // => false\n *\n * _.isLength('3');\n * // => false\n */\nfunction isLength(value) {\n  return typeof value == 'number' &&\n    value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER;\n}\n\n/**\n * Checks if `value` is the\n * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)\n * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`)\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an object, else `false`.\n * @example\n *\n * _.isObject({});\n * // => true\n *\n * _.isObject([1, 2, 3]);\n * // => true\n *\n * _.isObject(_.noop);\n * // => true\n *\n * _.isObject(null);\n * // => false\n */\nfunction isObject(value) {\n  var type = typeof value;\n  return !!value && (type == 'object' || type == 'function');\n}\n\n/**\n * Checks if `value` is object-like. A value is object-like if it's not `null`\n * and has a `typeof` result of \"object\".\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is object-like, else `false`.\n * @example\n *\n * _.isObjectLike({});\n * // => true\n *\n * _.isObjectLike([1, 2, 3]);\n * // => true\n *\n * _.isObjectLike(_.noop);\n * // => false\n *\n * _.isObjectLike(null);\n * // => false\n */\nfunction isObjectLike(value) {\n  return !!value && typeof value == 'object';\n}\n\nmodule.exports = flatten;\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.isplainobject/LICENSE",
    "content": "Copyright jQuery Foundation and other contributors <https://jquery.org/>\n\nBased on Underscore.js, copyright Jeremy Ashkenas,\nDocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>\n\nThis software consists of voluntary contributions made by many\nindividuals. For exact contribution history, see the revision history\navailable at https://github.com/lodash/lodash\n\nThe following license applies to all parts of this software except as\ndocumented below:\n\n====\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n====\n\nCopyright and related rights for sample code are waived via CC0. Sample\ncode is defined as all source code displayed within the prose of the\ndocumentation.\n\nCC0: http://creativecommons.org/publicdomain/zero/1.0/\n\n====\n\nFiles located in the node_modules and vendor directories are externally\nmaintained libraries used by this software which have their own\nlicenses; we recommend you read them, as their terms may differ from the\nterms above.\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.isplainobject/index.js",
    "content": "/**\n * lodash (Custom Build) <https://lodash.com/>\n * Build: `lodash modularize exports=\"npm\" -o ./`\n * Copyright jQuery Foundation and other contributors <https://jquery.org/>\n * Released under MIT license <https://lodash.com/license>\n * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>\n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n */\n\n/** `Object#toString` result references. */\nvar objectTag = '[object Object]';\n\n/**\n * Checks if `value` is a host object in IE < 9.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a host object, else `false`.\n */\nfunction isHostObject(value) {\n  // Many host objects are `Object` objects that can coerce to strings\n  // despite having improperly defined `toString` methods.\n  var result = false;\n  if (value != null && typeof value.toString != 'function') {\n    try {\n      result = !!(value + '');\n    } catch (e) { }\n  }\n  return result;\n}\n\n/**\n * Creates a unary function that invokes `func` with its argument transformed.\n *\n * @private\n * @param {Function} func The function to wrap.\n * @param {Function} transform The argument transform.\n * @returns {Function} Returns the new function.\n */\nfunction overArg(func, transform) {\n  return function (arg) {\n    return func(transform(arg));\n  };\n}\n\n/** Used for built-in method references. */\nvar funcProto = Function.prototype,\n  objectProto = Object.prototype;\n\n/** Used to resolve the decompiled source of functions. */\nvar funcToString = funcProto.toString;\n\n/** Used to check objects for own properties. */\nvar hasOwnProperty = objectProto.hasOwnProperty;\n\n/** Used to infer the `Object` constructor. */\nvar objectCtorString = funcToString.call(Object);\n\n/**\n * Used to resolve the\n * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)\n * of values.\n */\nvar objectToString = objectProto.toString;\n\n/** Built-in value references. */\nvar getPrototype = overArg(Object.getPrototypeOf, Object);\n\n/**\n * Checks if `value` is object-like. A value is object-like if it's not `null`\n * and has a `typeof` result of \"object\".\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is object-like, else `false`.\n * @example\n *\n * _.isObjectLike({});\n * // => true\n *\n * _.isObjectLike([1, 2, 3]);\n * // => true\n *\n * _.isObjectLike(_.noop);\n * // => false\n *\n * _.isObjectLike(null);\n * // => false\n */\nfunction isObjectLike(value) {\n  return !!value && typeof value == 'object';\n}\n\n/**\n * Checks if `value` is a plain object, that is, an object created by the\n * `Object` constructor or one with a `[[Prototype]]` of `null`.\n *\n * @static\n * @memberOf _\n * @since 0.8.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a plain object, else `false`.\n * @example\n *\n * function Foo() {\n *   this.a = 1;\n * }\n *\n * _.isPlainObject(new Foo);\n * // => false\n *\n * _.isPlainObject([1, 2, 3]);\n * // => false\n *\n * _.isPlainObject({ 'x': 0, 'y': 0 });\n * // => true\n *\n * _.isPlainObject(Object.create(null));\n * // => true\n */\nfunction isPlainObject(value) {\n  if (!isObjectLike(value) ||\n    objectToString.call(value) != objectTag || isHostObject(value)) {\n    return false;\n  }\n  var proto = getPrototype(value);\n  if (proto === null) {\n    return true;\n  }\n  var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor;\n  return (typeof Ctor == 'function' &&\n    Ctor instanceof Ctor && funcToString.call(Ctor) == objectCtorString);\n}\n\nmodule.exports = isPlainObject;\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.union/LICENSE",
    "content": "Copyright jQuery Foundation and other contributors <https://jquery.org/>\n\nBased on Underscore.js, copyright Jeremy Ashkenas,\nDocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>\n\nThis software consists of voluntary contributions made by many\nindividuals. For exact contribution history, see the revision history\navailable at https://github.com/lodash/lodash\n\nThe following license applies to all parts of this software except as\ndocumented below:\n\n====\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n====\n\nCopyright and related rights for sample code are waived via CC0. Sample\ncode is defined as all source code displayed within the prose of the\ndocumentation.\n\nCC0: http://creativecommons.org/publicdomain/zero/1.0/\n\n====\n\nFiles located in the node_modules and vendor directories are externally\nmaintained libraries used by this software which have their own\nlicenses; we recommend you read them, as their terms may differ from the\nterms above.\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/lodash.union/index.js",
    "content": "/**\n * lodash (Custom Build) <https://lodash.com/>\n * Build: `lodash modularize exports=\"npm\" -o ./`\n * Copyright jQuery Foundation and other contributors <https://jquery.org/>\n * Released under MIT license <https://lodash.com/license>\n * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>\n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n */\n\n/** Used as the size to enable large array optimizations. */\nvar LARGE_ARRAY_SIZE = 200;\n\n/** Used to stand-in for `undefined` hash values. */\nvar HASH_UNDEFINED = '__lodash_hash_undefined__';\n\n/** Used as references for various `Number` constants. */\nvar INFINITY = 1 / 0,\n  MAX_SAFE_INTEGER = 9007199254740991;\n\n/** `Object#toString` result references. */\nvar argsTag = '[object Arguments]',\n  funcTag = '[object Function]',\n  genTag = '[object GeneratorFunction]';\n\n/**\n * Used to match `RegExp`\n * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns).\n */\nvar reRegExpChar = /[\\\\^$.*+?()[\\]{}|]/g;\n\n/** Used to detect host constructors (Safari). */\nvar reIsHostCtor = /^\\[object .+?Constructor\\]$/;\n\n/** Detect free variable `global` from Node.js. */\nvar freeGlobal = typeof global == 'object' && global && global.Object === Object && global;\n\n/** Detect free variable `self`. */\nvar freeSelf = typeof self == 'object' && self && self.Object === Object && self;\n\n/** Used as a reference to the global object. */\nvar root = freeGlobal || freeSelf || Function('return this')();\n\n/**\n * A faster alternative to `Function#apply`, this function invokes `func`\n * with the `this` binding of `thisArg` and the arguments of `args`.\n *\n * @private\n * @param {Function} func The function to invoke.\n * @param {*} thisArg The `this` binding of `func`.\n * @param {Array} args The arguments to invoke `func` with.\n * @returns {*} Returns the result of `func`.\n */\nfunction apply(func, thisArg, args) {\n  switch (args.length) {\n    case 0: return func.call(thisArg);\n    case 1: return func.call(thisArg, args[0]);\n    case 2: return func.call(thisArg, args[0], args[1]);\n    case 3: return func.call(thisArg, args[0], args[1], args[2]);\n  }\n  return func.apply(thisArg, args);\n}\n\n/**\n * A specialized version of `_.includes` for arrays without support for\n * specifying an index to search from.\n *\n * @private\n * @param {Array} [array] The array to inspect.\n * @param {*} target The value to search for.\n * @returns {boolean} Returns `true` if `target` is found, else `false`.\n */\nfunction arrayIncludes(array, value) {\n  var length = array ? array.length : 0;\n  return !!length && baseIndexOf(array, value, 0) > -1;\n}\n\n/**\n * This function is like `arrayIncludes` except that it accepts a comparator.\n *\n * @private\n * @param {Array} [array] The array to inspect.\n * @param {*} target The value to search for.\n * @param {Function} comparator The comparator invoked per element.\n * @returns {boolean} Returns `true` if `target` is found, else `false`.\n */\nfunction arrayIncludesWith(array, value, comparator) {\n  var index = -1,\n    length = array ? array.length : 0;\n\n  while (++index < length) {\n    if (comparator(value, array[index])) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * Appends the elements of `values` to `array`.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {Array} values The values to append.\n * @returns {Array} Returns `array`.\n */\nfunction arrayPush(array, values) {\n  var index = -1,\n    length = values.length,\n    offset = array.length;\n\n  while (++index < length) {\n    array[offset + index] = values[index];\n  }\n  return array;\n}\n\n/**\n * The base implementation of `_.findIndex` and `_.findLastIndex` without\n * support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Function} predicate The function invoked per iteration.\n * @param {number} fromIndex The index to search from.\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\nfunction baseFindIndex(array, predicate, fromIndex, fromRight) {\n  var length = array.length,\n    index = fromIndex + (fromRight ? 1 : -1);\n\n  while ((fromRight ? index-- : ++index < length)) {\n    if (predicate(array[index], index, array)) {\n      return index;\n    }\n  }\n  return -1;\n}\n\n/**\n * The base implementation of `_.indexOf` without `fromIndex` bounds checks.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} fromIndex The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\nfunction baseIndexOf(array, value, fromIndex) {\n  if (value !== value) {\n    return baseFindIndex(array, baseIsNaN, fromIndex);\n  }\n  var index = fromIndex - 1,\n    length = array.length;\n\n  while (++index < length) {\n    if (array[index] === value) {\n      return index;\n    }\n  }\n  return -1;\n}\n\n/**\n * The base implementation of `_.isNaN` without support for number objects.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is `NaN`, else `false`.\n */\nfunction baseIsNaN(value) {\n  return value !== value;\n}\n\n/**\n * Checks if a cache value for `key` exists.\n *\n * @private\n * @param {Object} cache The cache to query.\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction cacheHas(cache, key) {\n  return cache.has(key);\n}\n\n/**\n * Gets the value at `key` of `object`.\n *\n * @private\n * @param {Object} [object] The object to query.\n * @param {string} key The key of the property to get.\n * @returns {*} Returns the property value.\n */\nfunction getValue(object, key) {\n  return object == null ? undefined : object[key];\n}\n\n/**\n * Checks if `value` is a host object in IE < 9.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a host object, else `false`.\n */\nfunction isHostObject(value) {\n  // Many host objects are `Object` objects that can coerce to strings\n  // despite having improperly defined `toString` methods.\n  var result = false;\n  if (value != null && typeof value.toString != 'function') {\n    try {\n      result = !!(value + '');\n    } catch (e) { }\n  }\n  return result;\n}\n\n/**\n * Converts `set` to an array of its values.\n *\n * @private\n * @param {Object} set The set to convert.\n * @returns {Array} Returns the values.\n */\nfunction setToArray(set) {\n  var index = -1,\n    result = Array(set.size);\n\n  set.forEach(function (value) {\n    result[++index] = value;\n  });\n  return result;\n}\n\n/** Used for built-in method references. */\nvar arrayProto = Array.prototype,\n  funcProto = Function.prototype,\n  objectProto = Object.prototype;\n\n/** Used to detect overreaching core-js shims. */\nvar coreJsData = root['__core-js_shared__'];\n\n/** Used to detect methods masquerading as native. */\nvar maskSrcKey = (function () {\n  var uid = /[^.]+$/.exec(coreJsData && coreJsData.keys && coreJsData.keys.IE_PROTO || '');\n  return uid ? ('Symbol(src)_1.' + uid) : '';\n}());\n\n/** Used to resolve the decompiled source of functions. */\nvar funcToString = funcProto.toString;\n\n/** Used to check objects for own properties. */\nvar hasOwnProperty = objectProto.hasOwnProperty;\n\n/**\n * Used to resolve the\n * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)\n * of values.\n */\nvar objectToString = objectProto.toString;\n\n/** Used to detect if a method is native. */\nvar reIsNative = RegExp('^' +\n  funcToString.call(hasOwnProperty).replace(reRegExpChar, '\\\\$&')\n    .replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g, '$1.*?') + '$'\n);\n\n/** Built-in value references. */\nvar Symbol = root.Symbol,\n  propertyIsEnumerable = objectProto.propertyIsEnumerable,\n  splice = arrayProto.splice,\n  spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined;\n\n/* Built-in method references for those with the same name as other `lodash` methods. */\nvar nativeMax = Math.max;\n\n/* Built-in method references that are verified to be native. */\nvar Map = getNative(root, 'Map'),\n  Set = getNative(root, 'Set'),\n  nativeCreate = getNative(Object, 'create');\n\n/**\n * Creates a hash object.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\nfunction Hash(entries) {\n  var index = -1,\n    length = entries ? entries.length : 0;\n\n  this.clear();\n  while (++index < length) {\n    var entry = entries[index];\n    this.set(entry[0], entry[1]);\n  }\n}\n\n/**\n * Removes all key-value entries from the hash.\n *\n * @private\n * @name clear\n * @memberOf Hash\n */\nfunction hashClear() {\n  this.__data__ = nativeCreate ? nativeCreate(null) : {};\n}\n\n/**\n * Removes `key` and its value from the hash.\n *\n * @private\n * @name delete\n * @memberOf Hash\n * @param {Object} hash The hash to modify.\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\nfunction hashDelete(key) {\n  return this.has(key) && delete this.__data__[key];\n}\n\n/**\n * Gets the hash value for `key`.\n *\n * @private\n * @name get\n * @memberOf Hash\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\nfunction hashGet(key) {\n  var data = this.__data__;\n  if (nativeCreate) {\n    var result = data[key];\n    return result === HASH_UNDEFINED ? undefined : result;\n  }\n  return hasOwnProperty.call(data, key) ? data[key] : undefined;\n}\n\n/**\n * Checks if a hash value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf Hash\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction hashHas(key) {\n  var data = this.__data__;\n  return nativeCreate ? data[key] !== undefined : hasOwnProperty.call(data, key);\n}\n\n/**\n * Sets the hash `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf Hash\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the hash instance.\n */\nfunction hashSet(key, value) {\n  var data = this.__data__;\n  data[key] = (nativeCreate && value === undefined) ? HASH_UNDEFINED : value;\n  return this;\n}\n\n// Add methods to `Hash`.\nHash.prototype.clear = hashClear;\nHash.prototype['delete'] = hashDelete;\nHash.prototype.get = hashGet;\nHash.prototype.has = hashHas;\nHash.prototype.set = hashSet;\n\n/**\n * Creates an list cache object.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\nfunction ListCache(entries) {\n  var index = -1,\n    length = entries ? entries.length : 0;\n\n  this.clear();\n  while (++index < length) {\n    var entry = entries[index];\n    this.set(entry[0], entry[1]);\n  }\n}\n\n/**\n * Removes all key-value entries from the list cache.\n *\n * @private\n * @name clear\n * @memberOf ListCache\n */\nfunction listCacheClear() {\n  this.__data__ = [];\n}\n\n/**\n * Removes `key` and its value from the list cache.\n *\n * @private\n * @name delete\n * @memberOf ListCache\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\nfunction listCacheDelete(key) {\n  var data = this.__data__,\n    index = assocIndexOf(data, key);\n\n  if (index < 0) {\n    return false;\n  }\n  var lastIndex = data.length - 1;\n  if (index == lastIndex) {\n    data.pop();\n  } else {\n    splice.call(data, index, 1);\n  }\n  return true;\n}\n\n/**\n * Gets the list cache value for `key`.\n *\n * @private\n * @name get\n * @memberOf ListCache\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\nfunction listCacheGet(key) {\n  var data = this.__data__,\n    index = assocIndexOf(data, key);\n\n  return index < 0 ? undefined : data[index][1];\n}\n\n/**\n * Checks if a list cache value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf ListCache\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction listCacheHas(key) {\n  return assocIndexOf(this.__data__, key) > -1;\n}\n\n/**\n * Sets the list cache `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf ListCache\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the list cache instance.\n */\nfunction listCacheSet(key, value) {\n  var data = this.__data__,\n    index = assocIndexOf(data, key);\n\n  if (index < 0) {\n    data.push([key, value]);\n  } else {\n    data[index][1] = value;\n  }\n  return this;\n}\n\n// Add methods to `ListCache`.\nListCache.prototype.clear = listCacheClear;\nListCache.prototype['delete'] = listCacheDelete;\nListCache.prototype.get = listCacheGet;\nListCache.prototype.has = listCacheHas;\nListCache.prototype.set = listCacheSet;\n\n/**\n * Creates a map cache object to store key-value pairs.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\nfunction MapCache(entries) {\n  var index = -1,\n    length = entries ? entries.length : 0;\n\n  this.clear();\n  while (++index < length) {\n    var entry = entries[index];\n    this.set(entry[0], entry[1]);\n  }\n}\n\n/**\n * Removes all key-value entries from the map.\n *\n * @private\n * @name clear\n * @memberOf MapCache\n */\nfunction mapCacheClear() {\n  this.__data__ = {\n    'hash': new Hash,\n    'map': new (Map || ListCache),\n    'string': new Hash\n  };\n}\n\n/**\n * Removes `key` and its value from the map.\n *\n * @private\n * @name delete\n * @memberOf MapCache\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\nfunction mapCacheDelete(key) {\n  return getMapData(this, key)['delete'](key);\n}\n\n/**\n * Gets the map value for `key`.\n *\n * @private\n * @name get\n * @memberOf MapCache\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\nfunction mapCacheGet(key) {\n  return getMapData(this, key).get(key);\n}\n\n/**\n * Checks if a map value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf MapCache\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\nfunction mapCacheHas(key) {\n  return getMapData(this, key).has(key);\n}\n\n/**\n * Sets the map `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf MapCache\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the map cache instance.\n */\nfunction mapCacheSet(key, value) {\n  getMapData(this, key).set(key, value);\n  return this;\n}\n\n// Add methods to `MapCache`.\nMapCache.prototype.clear = mapCacheClear;\nMapCache.prototype['delete'] = mapCacheDelete;\nMapCache.prototype.get = mapCacheGet;\nMapCache.prototype.has = mapCacheHas;\nMapCache.prototype.set = mapCacheSet;\n\n/**\n *\n * Creates an array cache object to store unique values.\n *\n * @private\n * @constructor\n * @param {Array} [values] The values to cache.\n */\nfunction SetCache(values) {\n  var index = -1,\n    length = values ? values.length : 0;\n\n  this.__data__ = new MapCache;\n  while (++index < length) {\n    this.add(values[index]);\n  }\n}\n\n/**\n * Adds `value` to the array cache.\n *\n * @private\n * @name add\n * @memberOf SetCache\n * @alias push\n * @param {*} value The value to cache.\n * @returns {Object} Returns the cache instance.\n */\nfunction setCacheAdd(value) {\n  this.__data__.set(value, HASH_UNDEFINED);\n  return this;\n}\n\n/**\n * Checks if `value` is in the array cache.\n *\n * @private\n * @name has\n * @memberOf SetCache\n * @param {*} value The value to search for.\n * @returns {number} Returns `true` if `value` is found, else `false`.\n */\nfunction setCacheHas(value) {\n  return this.__data__.has(value);\n}\n\n// Add methods to `SetCache`.\nSetCache.prototype.add = SetCache.prototype.push = setCacheAdd;\nSetCache.prototype.has = setCacheHas;\n\n/**\n * Gets the index at which the `key` is found in `array` of key-value pairs.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} key The key to search for.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\nfunction assocIndexOf(array, key) {\n  var length = array.length;\n  while (length--) {\n    if (eq(array[length][0], key)) {\n      return length;\n    }\n  }\n  return -1;\n}\n\n/**\n * The base implementation of `_.flatten` with support for restricting flattening.\n *\n * @private\n * @param {Array} array The array to flatten.\n * @param {number} depth The maximum recursion depth.\n * @param {boolean} [predicate=isFlattenable] The function invoked per iteration.\n * @param {boolean} [isStrict] Restrict to values that pass `predicate` checks.\n * @param {Array} [result=[]] The initial result value.\n * @returns {Array} Returns the new flattened array.\n */\nfunction baseFlatten(array, depth, predicate, isStrict, result) {\n  var index = -1,\n    length = array.length;\n\n  predicate || (predicate = isFlattenable);\n  result || (result = []);\n\n  while (++index < length) {\n    var value = array[index];\n    if (depth > 0 && predicate(value)) {\n      if (depth > 1) {\n        // Recursively flatten arrays (susceptible to call stack limits).\n        baseFlatten(value, depth - 1, predicate, isStrict, result);\n      } else {\n        arrayPush(result, value);\n      }\n    } else if (!isStrict) {\n      result[result.length] = value;\n    }\n  }\n  return result;\n}\n\n/**\n * The base implementation of `_.isNative` without bad shim checks.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a native function,\n *  else `false`.\n */\nfunction baseIsNative(value) {\n  if (!isObject(value) || isMasked(value)) {\n    return false;\n  }\n  var pattern = (isFunction(value) || isHostObject(value)) ? reIsNative : reIsHostCtor;\n  return pattern.test(toSource(value));\n}\n\n/**\n * The base implementation of `_.rest` which doesn't validate or coerce arguments.\n *\n * @private\n * @param {Function} func The function to apply a rest parameter to.\n * @param {number} [start=func.length-1] The start position of the rest parameter.\n * @returns {Function} Returns the new function.\n */\nfunction baseRest(func, start) {\n  start = nativeMax(start === undefined ? (func.length - 1) : start, 0);\n  return function () {\n    var args = arguments,\n      index = -1,\n      length = nativeMax(args.length - start, 0),\n      array = Array(length);\n\n    while (++index < length) {\n      array[index] = args[start + index];\n    }\n    index = -1;\n    var otherArgs = Array(start + 1);\n    while (++index < start) {\n      otherArgs[index] = args[index];\n    }\n    otherArgs[start] = array;\n    return apply(func, this, otherArgs);\n  };\n}\n\n/**\n * The base implementation of `_.uniqBy` without support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new duplicate free array.\n */\nfunction baseUniq(array, iteratee, comparator) {\n  var index = -1,\n    includes = arrayIncludes,\n    length = array.length,\n    isCommon = true,\n    result = [],\n    seen = result;\n\n  if (comparator) {\n    isCommon = false;\n    includes = arrayIncludesWith;\n  }\n  else if (length >= LARGE_ARRAY_SIZE) {\n    var set = iteratee ? null : createSet(array);\n    if (set) {\n      return setToArray(set);\n    }\n    isCommon = false;\n    includes = cacheHas;\n    seen = new SetCache;\n  }\n  else {\n    seen = iteratee ? [] : result;\n  }\n  outer:\n  while (++index < length) {\n    var value = array[index],\n      computed = iteratee ? iteratee(value) : value;\n\n    value = (comparator || value !== 0) ? value : 0;\n    if (isCommon && computed === computed) {\n      var seenIndex = seen.length;\n      while (seenIndex--) {\n        if (seen[seenIndex] === computed) {\n          continue outer;\n        }\n      }\n      if (iteratee) {\n        seen.push(computed);\n      }\n      result.push(value);\n    }\n    else if (!includes(seen, computed, comparator)) {\n      if (seen !== result) {\n        seen.push(computed);\n      }\n      result.push(value);\n    }\n  }\n  return result;\n}\n\n/**\n * Creates a set object of `values`.\n *\n * @private\n * @param {Array} values The values to add to the set.\n * @returns {Object} Returns the new set.\n */\nvar createSet = !(Set && (1 / setToArray(new Set([, -0]))[1]) == INFINITY) ? noop : function (values) {\n  return new Set(values);\n};\n\n/**\n * Gets the data for `map`.\n *\n * @private\n * @param {Object} map The map to query.\n * @param {string} key The reference key.\n * @returns {*} Returns the map data.\n */\nfunction getMapData(map, key) {\n  var data = map.__data__;\n  return isKeyable(key)\n    ? data[typeof key == 'string' ? 'string' : 'hash']\n    : data.map;\n}\n\n/**\n * Gets the native function at `key` of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {string} key The key of the method to get.\n * @returns {*} Returns the function if it's native, else `undefined`.\n */\nfunction getNative(object, key) {\n  var value = getValue(object, key);\n  return baseIsNative(value) ? value : undefined;\n}\n\n/**\n * Checks if `value` is a flattenable `arguments` object or array.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is flattenable, else `false`.\n */\nfunction isFlattenable(value) {\n  return isArray(value) || isArguments(value) ||\n    !!(spreadableSymbol && value && value[spreadableSymbol]);\n}\n\n/**\n * Checks if `value` is suitable for use as unique object key.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is suitable, else `false`.\n */\nfunction isKeyable(value) {\n  var type = typeof value;\n  return (type == 'string' || type == 'number' || type == 'symbol' || type == 'boolean')\n    ? (value !== '__proto__')\n    : (value === null);\n}\n\n/**\n * Checks if `func` has its source masked.\n *\n * @private\n * @param {Function} func The function to check.\n * @returns {boolean} Returns `true` if `func` is masked, else `false`.\n */\nfunction isMasked(func) {\n  return !!maskSrcKey && (maskSrcKey in func);\n}\n\n/**\n * Converts `func` to its source code.\n *\n * @private\n * @param {Function} func The function to process.\n * @returns {string} Returns the source code.\n */\nfunction toSource(func) {\n  if (func != null) {\n    try {\n      return funcToString.call(func);\n    } catch (e) { }\n    try {\n      return (func + '');\n    } catch (e) { }\n  }\n  return '';\n}\n\n/**\n * Creates an array of unique values, in order, from all given arrays using\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @returns {Array} Returns the new array of combined values.\n * @example\n *\n * _.union([2], [1, 2]);\n * // => [2, 1]\n */\nvar union = baseRest(function (arrays) {\n  return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true));\n});\n\n/**\n * Performs a\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * comparison between two values to determine if they are equivalent.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if the values are equivalent, else `false`.\n * @example\n *\n * var object = { 'a': 1 };\n * var other = { 'a': 1 };\n *\n * _.eq(object, object);\n * // => true\n *\n * _.eq(object, other);\n * // => false\n *\n * _.eq('a', 'a');\n * // => true\n *\n * _.eq('a', Object('a'));\n * // => false\n *\n * _.eq(NaN, NaN);\n * // => true\n */\nfunction eq(value, other) {\n  return value === other || (value !== value && other !== other);\n}\n\n/**\n * Checks if `value` is likely an `arguments` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an `arguments` object,\n *  else `false`.\n * @example\n *\n * _.isArguments(function() { return arguments; }());\n * // => true\n *\n * _.isArguments([1, 2, 3]);\n * // => false\n */\nfunction isArguments(value) {\n  // Safari 8.1 makes `arguments.callee` enumerable in strict mode.\n  return isArrayLikeObject(value) && hasOwnProperty.call(value, 'callee') &&\n    (!propertyIsEnumerable.call(value, 'callee') || objectToString.call(value) == argsTag);\n}\n\n/**\n * Checks if `value` is classified as an `Array` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array, else `false`.\n * @example\n *\n * _.isArray([1, 2, 3]);\n * // => true\n *\n * _.isArray(document.body.children);\n * // => false\n *\n * _.isArray('abc');\n * // => false\n *\n * _.isArray(_.noop);\n * // => false\n */\nvar isArray = Array.isArray;\n\n/**\n * Checks if `value` is array-like. A value is considered array-like if it's\n * not a function and has a `value.length` that's an integer greater than or\n * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is array-like, else `false`.\n * @example\n *\n * _.isArrayLike([1, 2, 3]);\n * // => true\n *\n * _.isArrayLike(document.body.children);\n * // => true\n *\n * _.isArrayLike('abc');\n * // => true\n *\n * _.isArrayLike(_.noop);\n * // => false\n */\nfunction isArrayLike(value) {\n  return value != null && isLength(value.length) && !isFunction(value);\n}\n\n/**\n * This method is like `_.isArrayLike` except that it also checks if `value`\n * is an object.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array-like object,\n *  else `false`.\n * @example\n *\n * _.isArrayLikeObject([1, 2, 3]);\n * // => true\n *\n * _.isArrayLikeObject(document.body.children);\n * // => true\n *\n * _.isArrayLikeObject('abc');\n * // => false\n *\n * _.isArrayLikeObject(_.noop);\n * // => false\n */\nfunction isArrayLikeObject(value) {\n  return isObjectLike(value) && isArrayLike(value);\n}\n\n/**\n * Checks if `value` is classified as a `Function` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a function, else `false`.\n * @example\n *\n * _.isFunction(_);\n * // => true\n *\n * _.isFunction(/abc/);\n * // => false\n */\nfunction isFunction(value) {\n  // The use of `Object#toString` avoids issues with the `typeof` operator\n  // in Safari 8-9 which returns 'object' for typed array and other constructors.\n  var tag = isObject(value) ? objectToString.call(value) : '';\n  return tag == funcTag || tag == genTag;\n}\n\n/**\n * Checks if `value` is a valid array-like length.\n *\n * **Note:** This method is loosely based on\n * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a valid length, else `false`.\n * @example\n *\n * _.isLength(3);\n * // => true\n *\n * _.isLength(Number.MIN_VALUE);\n * // => false\n *\n * _.isLength(Infinity);\n * // => false\n *\n * _.isLength('3');\n * // => false\n */\nfunction isLength(value) {\n  return typeof value == 'number' &&\n    value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER;\n}\n\n/**\n * Checks if `value` is the\n * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)\n * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`)\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an object, else `false`.\n * @example\n *\n * _.isObject({});\n * // => true\n *\n * _.isObject([1, 2, 3]);\n * // => true\n *\n * _.isObject(_.noop);\n * // => true\n *\n * _.isObject(null);\n * // => false\n */\nfunction isObject(value) {\n  var type = typeof value;\n  return !!value && (type == 'object' || type == 'function');\n}\n\n/**\n * Checks if `value` is object-like. A value is object-like if it's not `null`\n * and has a `typeof` result of \"object\".\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is object-like, else `false`.\n * @example\n *\n * _.isObjectLike({});\n * // => true\n *\n * _.isObjectLike([1, 2, 3]);\n * // => true\n *\n * _.isObjectLike(_.noop);\n * // => false\n *\n * _.isObjectLike(null);\n * // => false\n */\nfunction isObjectLike(value) {\n  return !!value && typeof value == 'object';\n}\n\n/**\n * This method returns `undefined`.\n *\n * @static\n * @memberOf _\n * @since 2.3.0\n * @category Util\n * @example\n *\n * _.times(2, _.noop);\n * // => [undefined, undefined]\n */\nfunction noop() {\n  // No operation performed.\n}\n\nmodule.exports = union;\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/minimatch/LICENSE",
    "content": "The ISC License\n\nCopyright (c) 2011-2022 Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/minimatch/index.js",
    "content": "const minimatch = module.exports = (p, pattern, options = {}) => {\n  assertValidPattern(pattern)\n\n  // shortcut: comments match nothing.\n  if (!options.nocomment && pattern.charAt(0) === '#') {\n    return false\n  }\n\n  return new Minimatch(pattern, options).match(p)\n}\n\nmodule.exports = minimatch\n\nconst isWindows = typeof process === 'object' &&\n  process &&\n  process.platform === 'win32'\nconst path = isWindows ? { sep: '\\\\' } : { sep: '/' }\nminimatch.sep = path.sep\n\nconst GLOBSTAR = Symbol('globstar **')\nminimatch.GLOBSTAR = GLOBSTAR\nconst expand = require('../braceExpansion')\n\nconst plTypes = {\n  '!': { open: '(?:(?!(?:', close: '))[^/]*?)' },\n  '?': { open: '(?:', close: ')?' },\n  '+': { open: '(?:', close: ')+' },\n  '*': { open: '(?:', close: ')*' },\n  '@': { open: '(?:', close: ')' }\n}\n\n// any single thing other than /\n// don't need to escape / when using new RegExp()\nconst qmark = '[^/]'\n\n// * => any number of characters\nconst star = qmark + '*?'\n\n// ** when dots are allowed.  Anything goes, except .. and .\n// not (^ or / followed by one or two dots followed by $ or /),\n// followed by anything, any number of times.\nconst twoStarDot = '(?:(?!(?:\\\\\\/|^)(?:\\\\.{1,2})($|\\\\\\/)).)*?'\n\n// not a ^ or / followed by a dot,\n// followed by anything, any number of times.\nconst twoStarNoDot = '(?:(?!(?:\\\\\\/|^)\\\\.).)*?'\n\n// \"abc\" -> { a:true, b:true, c:true }\nconst charSet = s => s.split('').reduce((set, c) => {\n  set[c] = true\n  return set\n}, {})\n\n// characters that need to be escaped in RegExp.\nconst reSpecials = charSet('().*{}+?[]^$\\\\!')\n\n// characters that indicate we have to add the pattern start\nconst addPatternStartSet = charSet('[.(')\n\n// normalizes slashes.\nconst slashSplit = /\\/+/\n\nminimatch.filter = (pattern, options = {}) =>\n  (p, i, list) => minimatch(p, pattern, options)\n\nconst ext = (a, b = {}) => {\n  const t = {}\n  Object.keys(a).forEach(k => t[k] = a[k])\n  Object.keys(b).forEach(k => t[k] = b[k])\n  return t\n}\n\nminimatch.defaults = def => {\n  if (!def || typeof def !== 'object' || !Object.keys(def).length) {\n    return minimatch\n  }\n\n  const orig = minimatch\n\n  const m = (p, pattern, options) => orig(p, pattern, ext(def, options))\n  m.Minimatch = class Minimatch extends orig.Minimatch {\n    constructor(pattern, options) {\n      super(pattern, ext(def, options))\n    }\n  }\n  m.Minimatch.defaults = options => orig.defaults(ext(def, options)).Minimatch\n  m.filter = (pattern, options) => orig.filter(pattern, ext(def, options))\n  m.defaults = options => orig.defaults(ext(def, options))\n  m.makeRe = (pattern, options) => orig.makeRe(pattern, ext(def, options))\n  m.braceExpand = (pattern, options) => orig.braceExpand(pattern, ext(def, options))\n  m.match = (list, pattern, options) => orig.match(list, pattern, ext(def, options))\n\n  return m\n}\n\n\n\n\n\n// Brace expansion:\n// a{b,c}d -> abd acd\n// a{b,}c -> abc ac\n// a{0..3}d -> a0d a1d a2d a3d\n// a{b,c{d,e}f}g -> abg acdfg acefg\n// a{b,c}d{e,f}g -> abdeg acdeg abdeg abdfg\n//\n// Invalid sets are not expanded.\n// a{2..}b -> a{2..}b\n// a{b}c -> a{b}c\nminimatch.braceExpand = (pattern, options) => braceExpand(pattern, options)\n\nconst braceExpand = (pattern, options = {}) => {\n  assertValidPattern(pattern)\n\n  // Thanks to Yeting Li <https://github.com/yetingli> for\n  // improving this regexp to avoid a ReDOS vulnerability.\n  if (options.nobrace || !/\\{(?:(?!\\{).)*\\}/.test(pattern)) {\n    // shortcut. no need to expand.\n    return [pattern]\n  }\n\n  return expand(pattern)\n}\n\nconst MAX_PATTERN_LENGTH = 1024 * 64\nconst assertValidPattern = pattern => {\n  if (typeof pattern !== 'string') {\n    throw new TypeError('invalid pattern')\n  }\n\n  if (pattern.length > MAX_PATTERN_LENGTH) {\n    throw new TypeError('pattern is too long')\n  }\n}\n\n// parse a component of the expanded set.\n// At this point, no pattern may contain \"/\" in it\n// so we're going to return a 2d array, where each entry is the full\n// pattern, split on '/', and then turned into a regular expression.\n// A regexp is made at the end which joins each array with an\n// escaped /, and another full one which joins each regexp with |.\n//\n// Following the lead of Bash 4.1, note that \"**\" only has special meaning\n// when it is the *only* thing in a path portion.  Otherwise, any series\n// of * is equivalent to a single *.  Globstar behavior is enabled by\n// default, and can be disabled by setting options.noglobstar.\nconst SUBPARSE = Symbol('subparse')\n\nminimatch.makeRe = (pattern, options) =>\n  new Minimatch(pattern, options || {}).makeRe()\n\nminimatch.match = (list, pattern, options = {}) => {\n  const mm = new Minimatch(pattern, options)\n  list = list.filter(f => mm.match(f))\n  if (mm.options.nonull && !list.length) {\n    list.push(pattern)\n  }\n  return list\n}\n\n// replace stuff like \\* with *\nconst globUnescape = s => s.replace(/\\\\(.)/g, '$1')\nconst regExpEscape = s => s.replace(/[-[\\]{}()*+?.,\\\\^$|#\\s]/g, '\\\\$&')\n\nclass Minimatch {\n  constructor(pattern, options) {\n    assertValidPattern(pattern)\n\n    if (!options) options = {}\n\n    this.options = options\n    this.set = []\n    this.pattern = pattern\n    this.windowsPathsNoEscape = !!options.windowsPathsNoEscape ||\n      options.allowWindowsEscape === false\n    if (this.windowsPathsNoEscape) {\n      this.pattern = this.pattern.replace(/\\\\/g, '/')\n    }\n    this.regexp = null\n    this.negate = false\n    this.comment = false\n    this.empty = false\n    this.partial = !!options.partial\n\n    // make the set of regexps etc.\n    this.make()\n  }\n\n  debug() { }\n\n  make() {\n    const pattern = this.pattern\n    const options = this.options\n\n    // empty patterns and comments match nothing.\n    if (!options.nocomment && pattern.charAt(0) === '#') {\n      this.comment = true\n      return\n    }\n    if (!pattern) {\n      this.empty = true\n      return\n    }\n\n    // step 1: figure out negation, etc.\n    this.parseNegate()\n\n    // step 2: expand braces\n    let set = this.globSet = this.braceExpand()\n\n    if (options.debug) this.debug = (...args) => console.error(...args)\n\n    this.debug(this.pattern, set)\n\n    // step 3: now we have a set, so turn each one into a series of path-portion\n    // matching patterns.\n    // These will be regexps, except in the case of \"**\", which is\n    // set to the GLOBSTAR object for globstar behavior,\n    // and will not contain any / characters\n    set = this.globParts = set.map(s => s.split(slashSplit))\n\n    this.debug(this.pattern, set)\n\n    // glob --> regexps\n    set = set.map((s, si, set) => s.map(this.parse, this))\n\n    this.debug(this.pattern, set)\n\n    // filter out everything that didn't compile properly.\n    set = set.filter(s => s.indexOf(false) === -1)\n\n    this.debug(this.pattern, set)\n\n    this.set = set\n  }\n\n  parseNegate() {\n    if (this.options.nonegate) return\n\n    const pattern = this.pattern\n    let negate = false\n    let negateOffset = 0\n\n    for (let i = 0; i < pattern.length && pattern.charAt(i) === '!'; i++) {\n      negate = !negate\n      negateOffset++\n    }\n\n    if (negateOffset) this.pattern = pattern.substr(negateOffset)\n    this.negate = negate\n  }\n\n  // set partial to true to test if, for example,\n  // \"/a/b\" matches the start of \"/*/b/*/d\"\n  // Partial means, if you run out of file before you run\n  // out of pattern, then that's fine, as long as all\n  // the parts match.\n  matchOne(file, pattern, partial) {\n    var options = this.options\n\n    this.debug('matchOne',\n      { 'this': this, file: file, pattern: pattern })\n\n    this.debug('matchOne', file.length, pattern.length)\n\n    for (var fi = 0,\n      pi = 0,\n      fl = file.length,\n      pl = pattern.length\n      ; (fi < fl) && (pi < pl)\n      ; fi++, pi++) {\n      this.debug('matchOne loop')\n      var p = pattern[pi]\n      var f = file[fi]\n\n      this.debug(pattern, p, f)\n\n      // should be impossible.\n      // some invalid regexp stuff in the set.\n      /* istanbul ignore if */\n      if (p === false) return false\n\n      if (p === GLOBSTAR) {\n        this.debug('GLOBSTAR', [pattern, p, f])\n\n        // \"**\"\n        // a/**/b/**/c would match the following:\n        // a/b/x/y/z/c\n        // a/x/y/z/b/c\n        // a/b/x/b/x/c\n        // a/b/c\n        // To do this, take the rest of the pattern after\n        // the **, and see if it would match the file remainder.\n        // If so, return success.\n        // If not, the ** \"swallows\" a segment, and try again.\n        // This is recursively awful.\n        //\n        // a/**/b/**/c matching a/b/x/y/z/c\n        // - a matches a\n        // - doublestar\n        //   - matchOne(b/x/y/z/c, b/**/c)\n        //     - b matches b\n        //     - doublestar\n        //       - matchOne(x/y/z/c, c) -> no\n        //       - matchOne(y/z/c, c) -> no\n        //       - matchOne(z/c, c) -> no\n        //       - matchOne(c, c) yes, hit\n        var fr = fi\n        var pr = pi + 1\n        if (pr === pl) {\n          this.debug('** at the end')\n          // a ** at the end will just swallow the rest.\n          // We have found a match.\n          // however, it will not swallow /.x, unless\n          // options.dot is set.\n          // . and .. are *never* matched by **, for explosively\n          // exponential reasons.\n          for (; fi < fl; fi++) {\n            if (file[fi] === '.' || file[fi] === '..' ||\n              (!options.dot && file[fi].charAt(0) === '.')) return false\n          }\n          return true\n        }\n\n        // ok, let's see if we can swallow whatever we can.\n        while (fr < fl) {\n          var swallowee = file[fr]\n\n          this.debug('\\nglobstar while', file, fr, pattern, pr, swallowee)\n\n          // XXX remove this slice.  Just pass the start index.\n          if (this.matchOne(file.slice(fr), pattern.slice(pr), partial)) {\n            this.debug('globstar found match!', fr, fl, swallowee)\n            // found a match.\n            return true\n          } else {\n            // can't swallow \".\" or \"..\" ever.\n            // can only swallow \".foo\" when explicitly asked.\n            if (swallowee === '.' || swallowee === '..' ||\n              (!options.dot && swallowee.charAt(0) === '.')) {\n              this.debug('dot detected!', file, fr, pattern, pr)\n              break\n            }\n\n            // ** swallows a segment, and continue.\n            this.debug('globstar swallow a segment, and continue')\n            fr++\n          }\n        }\n\n        // no match was found.\n        // However, in partial mode, we can't say this is necessarily over.\n        // If there's more *pattern* left, then\n        /* istanbul ignore if */\n        if (partial) {\n          // ran out of file\n          this.debug('\\n>>> no match, partial?', file, fr, pattern, pr)\n          if (fr === fl) return true\n        }\n        return false\n      }\n\n      // something other than **\n      // non-magic patterns just have to match exactly\n      // patterns with magic have been turned into regexps.\n      var hit\n      if (typeof p === 'string') {\n        hit = f === p\n        this.debug('string match', p, f, hit)\n      } else {\n        hit = f.match(p)\n        this.debug('pattern match', p, f, hit)\n      }\n\n      if (!hit) return false\n    }\n\n    // Note: ending in / means that we'll get a final \"\"\n    // at the end of the pattern.  This can only match a\n    // corresponding \"\" at the end of the file.\n    // If the file ends in /, then it can only match a\n    // a pattern that ends in /, unless the pattern just\n    // doesn't have any more for it. But, a/b/ should *not*\n    // match \"a/b/*\", even though \"\" matches against the\n    // [^/]*? pattern, except in partial mode, where it might\n    // simply not be reached yet.\n    // However, a/b/ should still satisfy a/*\n\n    // now either we fell off the end of the pattern, or we're done.\n    if (fi === fl && pi === pl) {\n      // ran out of pattern and filename at the same time.\n      // an exact hit!\n      return true\n    } else if (fi === fl) {\n      // ran out of file, but still had pattern left.\n      // this is ok if we're doing the match as part of\n      // a glob fs traversal.\n      return partial\n    } else /* istanbul ignore else */ if (pi === pl) {\n      // ran out of pattern, still have file left.\n      // this is only acceptable if we're on the very last\n      // empty segment of a file with a trailing slash.\n      // a/* should match a/b/\n      return (fi === fl - 1) && (file[fi] === '')\n    }\n\n    // should be unreachable.\n    /* istanbul ignore next */\n    throw new Error('wtf?')\n  }\n\n  braceExpand() {\n    return braceExpand(this.pattern, this.options)\n  }\n\n  parse(pattern, isSub) {\n    assertValidPattern(pattern)\n\n    const options = this.options\n\n    // shortcuts\n    if (pattern === '**') {\n      if (!options.noglobstar)\n        return GLOBSTAR\n      else\n        pattern = '*'\n    }\n    if (pattern === '') return ''\n\n    let re = ''\n    let hasMagic = !!options.nocase\n    let escaping = false\n    // ? => one single character\n    const patternListStack = []\n    const negativeLists = []\n    let stateChar\n    let inClass = false\n    let reClassStart = -1\n    let classStart = -1\n    let cs\n    let pl\n    let sp\n    // . and .. never match anything that doesn't start with .,\n    // even when options.dot is set.\n    const patternStart = pattern.charAt(0) === '.' ? '' // anything\n      // not (start or / followed by . or .. followed by / or end)\n      : options.dot ? '(?!(?:^|\\\\\\/)\\\\.{1,2}(?:$|\\\\\\/))'\n        : '(?!\\\\.)'\n\n    const clearStateChar = () => {\n      if (stateChar) {\n        // we had some state-tracking character\n        // that wasn't consumed by this pass.\n        switch (stateChar) {\n          case '*':\n            re += star\n            hasMagic = true\n            break\n          case '?':\n            re += qmark\n            hasMagic = true\n            break\n          default:\n            re += '\\\\' + stateChar\n            break\n        }\n        this.debug('clearStateChar %j %j', stateChar, re)\n        stateChar = false\n      }\n    }\n\n    for (let i = 0, c; (i < pattern.length) && (c = pattern.charAt(i)); i++) {\n      this.debug('%s\\t%s %s %j', pattern, i, re, c)\n\n      // skip over any that are escaped.\n      if (escaping) {\n        /* istanbul ignore next - completely not allowed, even escaped. */\n        if (c === '/') {\n          return false\n        }\n\n        if (reSpecials[c]) {\n          re += '\\\\'\n        }\n        re += c\n        escaping = false\n        continue\n      }\n\n      switch (c) {\n        /* istanbul ignore next */\n        case '/': {\n          // Should already be path-split by now.\n          return false\n        }\n\n        case '\\\\':\n          clearStateChar()\n          escaping = true\n          continue\n\n        // the various stateChar values\n        // for the \"extglob\" stuff.\n        case '?':\n        case '*':\n        case '+':\n        case '@':\n        case '!':\n          this.debug('%s\\t%s %s %j <-- stateChar', pattern, i, re, c)\n\n          // all of those are literals inside a class, except that\n          // the glob [!a] means [^a] in regexp\n          if (inClass) {\n            this.debug('  in class')\n            if (c === '!' && i === classStart + 1) c = '^'\n            re += c\n            continue\n          }\n\n          // if we already have a stateChar, then it means\n          // that there was something like ** or +? in there.\n          // Handle the stateChar, then proceed with this one.\n          this.debug('call clearStateChar %j', stateChar)\n          clearStateChar()\n          stateChar = c\n          // if extglob is disabled, then +(asdf|foo) isn't a thing.\n          // just clear the statechar *now*, rather than even diving into\n          // the patternList stuff.\n          if (options.noext) clearStateChar()\n          continue\n\n        case '(':\n          if (inClass) {\n            re += '('\n            continue\n          }\n\n          if (!stateChar) {\n            re += '\\\\('\n            continue\n          }\n\n          patternListStack.push({\n            type: stateChar,\n            start: i - 1,\n            reStart: re.length,\n            open: plTypes[stateChar].open,\n            close: plTypes[stateChar].close\n          })\n          // negation is (?:(?!js)[^/]*)\n          re += stateChar === '!' ? '(?:(?!(?:' : '(?:'\n          this.debug('plType %j %j', stateChar, re)\n          stateChar = false\n          continue\n\n        case ')':\n          if (inClass || !patternListStack.length) {\n            re += '\\\\)'\n            continue\n          }\n\n          clearStateChar()\n          hasMagic = true\n          pl = patternListStack.pop()\n          // negation is (?:(?!js)[^/]*)\n          // The others are (?:<pattern>)<type>\n          re += pl.close\n          if (pl.type === '!') {\n            negativeLists.push(pl)\n          }\n          pl.reEnd = re.length\n          continue\n\n        case '|':\n          if (inClass || !patternListStack.length) {\n            re += '\\\\|'\n            continue\n          }\n\n          clearStateChar()\n          re += '|'\n          continue\n\n        // these are mostly the same in regexp and glob\n        case '[':\n          // swallow any state-tracking char before the [\n          clearStateChar()\n\n          if (inClass) {\n            re += '\\\\' + c\n            continue\n          }\n\n          inClass = true\n          classStart = i\n          reClassStart = re.length\n          re += c\n          continue\n\n        case ']':\n          //  a right bracket shall lose its special\n          //  meaning and represent itself in\n          //  a bracket expression if it occurs\n          //  first in the list.  -- POSIX.2 2.8.3.2\n          if (i === classStart + 1 || !inClass) {\n            re += '\\\\' + c\n            continue\n          }\n\n          // handle the case where we left a class open.\n          // \"[z-a]\" is valid, equivalent to \"\\[z-a\\]\"\n          // split where the last [ was, make sure we don't have\n          // an invalid re. if so, re-walk the contents of the\n          // would-be class to re-translate any characters that\n          // were passed through as-is\n          // TODO: It would probably be faster to determine this\n          // without a try/catch and a new RegExp, but it's tricky\n          // to do safely.  For now, this is safe and works.\n          cs = pattern.substring(classStart + 1, i)\n          try {\n            RegExp('[' + cs + ']')\n          } catch (er) {\n            // not a valid class!\n            sp = this.parse(cs, SUBPARSE)\n            re = re.substr(0, reClassStart) + '\\\\[' + sp[0] + '\\\\]'\n            hasMagic = hasMagic || sp[1]\n            inClass = false\n            continue\n          }\n\n          // finish up the class.\n          hasMagic = true\n          inClass = false\n          re += c\n          continue\n\n        default:\n          // swallow any state char that wasn't consumed\n          clearStateChar()\n\n          if (reSpecials[c] && !(c === '^' && inClass)) {\n            re += '\\\\'\n          }\n\n          re += c\n          break\n\n      } // switch\n    } // for\n\n    // handle the case where we left a class open.\n    // \"[abc\" is valid, equivalent to \"\\[abc\"\n    if (inClass) {\n      // split where the last [ was, and escape it\n      // this is a huge pita.  We now have to re-walk\n      // the contents of the would-be class to re-translate\n      // any characters that were passed through as-is\n      cs = pattern.substr(classStart + 1)\n      sp = this.parse(cs, SUBPARSE)\n      re = re.substr(0, reClassStart) + '\\\\[' + sp[0]\n      hasMagic = hasMagic || sp[1]\n    }\n\n    // handle the case where we had a +( thing at the *end*\n    // of the pattern.\n    // each pattern list stack adds 3 chars, and we need to go through\n    // and escape any | chars that were passed through as-is for the regexp.\n    // Go through and escape them, taking care not to double-escape any\n    // | chars that were already escaped.\n    for (pl = patternListStack.pop(); pl; pl = patternListStack.pop()) {\n      let tail\n      tail = re.slice(pl.reStart + pl.open.length)\n      this.debug('setting tail', re, pl)\n      // maybe some even number of \\, then maybe 1 \\, followed by a |\n      tail = tail.replace(/((?:\\\\{2}){0,64})(\\\\?)\\|/g, (_, $1, $2) => {\n        /* istanbul ignore else - should already be done */\n        if (!$2) {\n          // the | isn't already escaped, so escape it.\n          $2 = '\\\\'\n        }\n\n        // need to escape all those slashes *again*, without escaping the\n        // one that we need for escaping the | character.  As it works out,\n        // escaping an even number of slashes can be done by simply repeating\n        // it exactly after itself.  That's why this trick works.\n        //\n        // I am sorry that you have to see this.\n        return $1 + $1 + $2 + '|'\n      })\n\n      this.debug('tail=%j\\n   %s', tail, tail, pl, re)\n      const t = pl.type === '*' ? star\n        : pl.type === '?' ? qmark\n          : '\\\\' + pl.type\n\n      hasMagic = true\n      re = re.slice(0, pl.reStart) + t + '\\\\(' + tail\n    }\n\n    // handle trailing things that only matter at the very end.\n    clearStateChar()\n    if (escaping) {\n      // trailing \\\\\n      re += '\\\\\\\\'\n    }\n\n    // only need to apply the nodot start if the re starts with\n    // something that could conceivably capture a dot\n    const addPatternStart = addPatternStartSet[re.charAt(0)]\n\n    // Hack to work around lack of negative lookbehind in JS\n    // A pattern like: *.!(x).!(y|z) needs to ensure that a name\n    // like 'a.xyz.yz' doesn't match.  So, the first negative\n    // lookahead, has to look ALL the way ahead, to the end of\n    // the pattern.\n    for (let n = negativeLists.length - 1; n > -1; n--) {\n      const nl = negativeLists[n]\n\n      const nlBefore = re.slice(0, nl.reStart)\n      const nlFirst = re.slice(nl.reStart, nl.reEnd - 8)\n      let nlAfter = re.slice(nl.reEnd)\n      const nlLast = re.slice(nl.reEnd - 8, nl.reEnd) + nlAfter\n\n      // Handle nested stuff like *(*.js|!(*.json)), where open parens\n      // mean that we should *not* include the ) in the bit that is considered\n      // \"after\" the negated section.\n      const openParensBefore = nlBefore.split('(').length - 1\n      let cleanAfter = nlAfter\n      for (let i = 0; i < openParensBefore; i++) {\n        cleanAfter = cleanAfter.replace(/\\)[+*?]?/, '')\n      }\n      nlAfter = cleanAfter\n\n      const dollar = nlAfter === '' && isSub !== SUBPARSE ? '$' : ''\n      re = nlBefore + nlFirst + nlAfter + dollar + nlLast\n    }\n\n    // if the re is not \"\" at this point, then we need to make sure\n    // it doesn't match against an empty path part.\n    // Otherwise a/* will match a/, which it should not.\n    if (re !== '' && hasMagic) {\n      re = '(?=.)' + re\n    }\n\n    if (addPatternStart) {\n      re = patternStart + re\n    }\n\n    // parsing just a piece of a larger pattern.\n    if (isSub === SUBPARSE) {\n      return [re, hasMagic]\n    }\n\n    // skip the regexp for non-magical patterns\n    // unescape anything in it, though, so that it'll be\n    // an exact match against a file etc.\n    if (!hasMagic) {\n      return globUnescape(pattern)\n    }\n\n    const flags = options.nocase ? 'i' : ''\n    try {\n      return Object.assign(new RegExp('^' + re + '$', flags), {\n        _glob: pattern,\n        _src: re,\n      })\n    } catch (er) /* istanbul ignore next - should be impossible */ {\n      // If it was an invalid regular expression, then it can't match\n      // anything.  This trick looks for a character after the end of\n      // the string, which is of course impossible, except in multi-line\n      // mode, but it's not a /m regex.\n      return new RegExp('$.')\n    }\n  }\n\n  makeRe() {\n    if (this.regexp || this.regexp === false) return this.regexp\n\n    // at this point, this.set is a 2d array of partial\n    // pattern strings, or \"**\".\n    //\n    // It's better to use .match().  This function shouldn't\n    // be used, really, but it's pretty convenient sometimes,\n    // when you just want to work with a regex.\n    const set = this.set\n\n    if (!set.length) {\n      this.regexp = false\n      return this.regexp\n    }\n    const options = this.options\n\n    const twoStar = options.noglobstar ? star\n      : options.dot ? twoStarDot\n        : twoStarNoDot\n    const flags = options.nocase ? 'i' : ''\n\n    // coalesce globstars and regexpify non-globstar patterns\n    // if it's the only item, then we just do one twoStar\n    // if it's the first, and there are more, prepend (\\/|twoStar\\/)? to next\n    // if it's the last, append (\\/twoStar|) to previous\n    // if it's in the middle, append (\\/|\\/twoStar\\/) to previous\n    // then filter out GLOBSTAR symbols\n    let re = set.map(pattern => {\n      pattern = pattern.map(p =>\n        typeof p === 'string' ? regExpEscape(p)\n          : p === GLOBSTAR ? GLOBSTAR\n            : p._src\n      ).reduce((set, p) => {\n        if (!(set[set.length - 1] === GLOBSTAR && p === GLOBSTAR)) {\n          set.push(p)\n        }\n        return set\n      }, [])\n      pattern.forEach((p, i) => {\n        if (p !== GLOBSTAR || pattern[i - 1] === GLOBSTAR) {\n          return\n        }\n        if (i === 0) {\n          if (pattern.length > 1) {\n            pattern[i + 1] = '(?:\\\\\\/|' + twoStar + '\\\\\\/)?' + pattern[i + 1]\n          } else {\n            pattern[i] = twoStar\n          }\n        } else if (i === pattern.length - 1) {\n          pattern[i - 1] += '(?:\\\\\\/|' + twoStar + ')?'\n        } else {\n          pattern[i - 1] += '(?:\\\\\\/|\\\\\\/' + twoStar + '\\\\\\/)' + pattern[i + 1]\n          pattern[i + 1] = GLOBSTAR\n        }\n      })\n      return pattern.filter(p => p !== GLOBSTAR).join('/')\n    }).join('|')\n\n    // must match entire pattern\n    // ending in a * or ** will make it less strict.\n    re = '^(?:' + re + ')$'\n\n    // can match anything, as long as it's not this.\n    if (this.negate) re = '^(?!' + re + ').*$'\n\n    try {\n      this.regexp = new RegExp(re, flags)\n    } catch (ex) /* istanbul ignore next - should be impossible */ {\n      this.regexp = false\n    }\n    return this.regexp\n  }\n\n  match(f, partial = this.partial) {\n    this.debug('match', f, this.pattern)\n    // short-circuit in the case of busted things.\n    // comments, etc.\n    if (this.comment) return false\n    if (this.empty) return f === ''\n\n    if (f === '/' && partial) return true\n\n    const options = this.options\n\n    // windows: need to use /, not \\\n    if (path.sep !== '/') {\n      f = f.split(path.sep).join('/')\n    }\n\n    // treat the test path as a set of pathparts.\n    f = f.split(slashSplit)\n    this.debug(this.pattern, 'split', f)\n\n    // just ONE of the pattern sets in this.set needs to match\n    // in order for it to be valid.  If negating, then just one\n    // match means that we have failed.\n    // Either way, return on the first hit.\n\n    const set = this.set\n    this.debug(this.pattern, 'set', set)\n\n    // Find the basename of the path by looking for the last non-empty segment\n    let filename\n    for (let i = f.length - 1; i >= 0; i--) {\n      filename = f[i]\n      if (filename) break\n    }\n\n    for (let i = 0; i < set.length; i++) {\n      const pattern = set[i]\n      let file = f\n      if (options.matchBase && pattern.length === 1) {\n        file = [filename]\n      }\n      const hit = this.matchOne(file, pattern, partial)\n      if (hit) {\n        if (options.flipNegate) return true\n        return !this.negate\n      }\n    }\n\n    // didn't get any hits.  this is success if it's a negative\n    // pattern, failure otherwise.\n    if (options.flipNegate) return false\n    return this.negate\n  }\n\n  static defaults(def) {\n    return minimatch.defaults(def).Minimatch\n  }\n}\n\nminimatch.Minimatch = Minimatch"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/LICENSE",
    "content": "Node.js is licensed for use as follows:\n\n\"\"\"\nCopyright Node.js contributors. All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to\ndeal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or\nsell copies 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\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE.\n\"\"\"\n\nThis license applies to parts of Node.js originating from the\nhttps://github.com/joyent/node repository:\n\n\"\"\"\nCopyright Joyent, Inc. and other Node contributors. All rights reserved.\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to\ndeal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or\nsell copies 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\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE.\n\"\"\""
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/_stream_duplex.js",
    "content": "'use strict' // Keep this file as an alias for the full stream module.\n\nmodule.exports = require('./stream').Duplex\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/_stream_passthrough.js",
    "content": "'use strict' // Keep this file as an alias for the full stream module.\n\nmodule.exports = require('./stream').PassThrough\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/_stream_readable.js",
    "content": "'use strict' // Keep this file as an alias for the full stream module.\n\nmodule.exports = require('./stream').Readable\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/_stream_transform.js",
    "content": "'use strict' // Keep this file as an alias for the full stream module.\n\nmodule.exports = require('./stream').Transform\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/_stream_writable.js",
    "content": "'use strict' // Keep this file as an alias for the full stream module.\n\nmodule.exports = require('./stream').Writable\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/index.js",
    "content": "'use strict'\n\nconst Stream = require('stream')\n\nif (Stream && process.env.READABLE_STREAM === 'disable') {\n  const promises = Stream.promises // Explicit export naming is needed for ESM\n\n  module.exports._uint8ArrayToBuffer = Stream._uint8ArrayToBuffer\n  module.exports._isUint8Array = Stream._isUint8Array\n  module.exports.isDisturbed = Stream.isDisturbed\n  module.exports.isErrored = Stream.isErrored\n  module.exports.isReadable = Stream.isReadable\n  module.exports.Readable = Stream.Readable\n  module.exports.Writable = Stream.Writable\n  module.exports.Duplex = Stream.Duplex\n  module.exports.Transform = Stream.Transform\n  module.exports.PassThrough = Stream.PassThrough\n  module.exports.addAbortSignal = Stream.addAbortSignal\n  module.exports.finished = Stream.finished\n  module.exports.destroy = Stream.destroy\n  module.exports.pipeline = Stream.pipeline\n  module.exports.compose = Stream.compose\n  Object.defineProperty(Stream, 'promises', {\n    configurable: true,\n    enumerable: true,\n\n    get() {\n      return promises\n    }\n  })\n  module.exports.Stream = Stream.Stream\n} else {\n  const CustomStream = require('./stream')\n\n  const promises = require('./stream/promises')\n\n  const originalDestroy = CustomStream.Readable.destroy\n  module.exports = CustomStream.Readable // Explicit export naming is needed for ESM\n\n  module.exports._uint8ArrayToBuffer = CustomStream._uint8ArrayToBuffer\n  module.exports._isUint8Array = CustomStream._isUint8Array\n  module.exports.isDisturbed = CustomStream.isDisturbed\n  module.exports.isErrored = CustomStream.isErrored\n  module.exports.isReadable = CustomStream.isReadable\n  module.exports.Readable = CustomStream.Readable\n  module.exports.Writable = CustomStream.Writable\n  module.exports.Duplex = CustomStream.Duplex\n  module.exports.Transform = CustomStream.Transform\n  module.exports.PassThrough = CustomStream.PassThrough\n  module.exports.addAbortSignal = CustomStream.addAbortSignal\n  module.exports.finished = CustomStream.finished\n  module.exports.destroy = CustomStream.destroy\n  module.exports.destroy = originalDestroy\n  module.exports.pipeline = CustomStream.pipeline\n  module.exports.compose = CustomStream.compose\n  Object.defineProperty(CustomStream, 'promises', {\n    configurable: true,\n    enumerable: true,\n\n    get() {\n      return promises\n    }\n  })\n  module.exports.Stream = CustomStream.Stream\n} // Allow default importing\n\nmodule.exports.default = module.exports\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/add-abort-signal.js",
    "content": "'use strict'\n\nconst { AbortError, codes } = require('../../ours/errors')\n\nconst eos = require('./end-of-stream')\n\nconst { ERR_INVALID_ARG_TYPE } = codes // This method is inlined here for readable-stream\n// It also does not allow for signal to not exist on the stream\n// https://github.com/nodejs/node/pull/36061#discussion_r533718029\n\nconst validateAbortSignal = (signal, name) => {\n  if (typeof signal !== 'object' || !('aborted' in signal)) {\n    throw new ERR_INVALID_ARG_TYPE(name, 'AbortSignal', signal)\n  }\n}\n\nfunction isNodeStream(obj) {\n  return !!(obj && typeof obj.pipe === 'function')\n}\n\nmodule.exports.addAbortSignal = function addAbortSignal(signal, stream) {\n  validateAbortSignal(signal, 'signal')\n\n  if (!isNodeStream(stream)) {\n    throw new ERR_INVALID_ARG_TYPE('stream', 'stream.Stream', stream)\n  }\n\n  return module.exports.addAbortSignalNoValidate(signal, stream)\n}\n\nmodule.exports.addAbortSignalNoValidate = function (signal, stream) {\n  if (typeof signal !== 'object' || !('aborted' in signal)) {\n    return stream\n  }\n\n  const onAbort = () => {\n    stream.destroy(\n      new AbortError(undefined, {\n        cause: signal.reason\n      })\n    )\n  }\n\n  if (signal.aborted) {\n    onAbort()\n  } else {\n    signal.addEventListener('abort', onAbort)\n    eos(stream, () => signal.removeEventListener('abort', onAbort))\n  }\n\n  return stream\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/buffer_list.js",
    "content": "'use strict'\n\nconst { StringPrototypeSlice, SymbolIterator, TypedArrayPrototypeSet, Uint8Array } = require('../../ours/primordials')\n\nconst { inspect } = require('../../ours/util')\n\nmodule.exports = class BufferList {\n  constructor() {\n    this.head = null\n    this.tail = null\n    this.length = 0\n  }\n\n  push(v) {\n    const entry = {\n      data: v,\n      next: null\n    }\n    if (this.length > 0) this.tail.next = entry\n    else this.head = entry\n    this.tail = entry\n    ++this.length\n  }\n\n  unshift(v) {\n    const entry = {\n      data: v,\n      next: this.head\n    }\n    if (this.length === 0) this.tail = entry\n    this.head = entry\n    ++this.length\n  }\n\n  shift() {\n    if (this.length === 0) return\n    const ret = this.head.data\n    if (this.length === 1) this.head = this.tail = null\n    else this.head = this.head.next\n    --this.length\n    return ret\n  }\n\n  clear() {\n    this.head = this.tail = null\n    this.length = 0\n  }\n\n  join(s) {\n    if (this.length === 0) return ''\n    let p = this.head\n    let ret = '' + p.data\n\n    while ((p = p.next) !== null) ret += s + p.data\n\n    return ret\n  }\n\n  concat(n) {\n    if (this.length === 0) return Buffer.alloc(0)\n    const ret = Buffer.allocUnsafe(n >>> 0)\n    let p = this.head\n    let i = 0\n\n    while (p) {\n      TypedArrayPrototypeSet(ret, p.data, i)\n      i += p.data.length\n      p = p.next\n    }\n\n    return ret\n  } // Consumes a specified amount of bytes or characters from the buffered data.\n\n  consume(n, hasStrings) {\n    const data = this.head.data\n\n    if (n < data.length) {\n      // `slice` is the same for buffers and strings.\n      const slice = data.slice(0, n)\n      this.head.data = data.slice(n)\n      return slice\n    }\n\n    if (n === data.length) {\n      // First chunk is a perfect match.\n      return this.shift()\n    } // Result spans more than one buffer.\n\n    return hasStrings ? this._getString(n) : this._getBuffer(n)\n  }\n\n  first() {\n    return this.head.data\n  }\n\n  *[SymbolIterator]() {\n    for (let p = this.head; p; p = p.next) {\n      yield p.data\n    }\n  } // Consumes a specified amount of characters from the buffered data.\n\n  _getString(n) {\n    let ret = ''\n    let p = this.head\n    let c = 0\n\n    do {\n      const str = p.data\n\n      if (n > str.length) {\n        ret += str\n        n -= str.length\n      } else {\n        if (n === str.length) {\n          ret += str\n          ++c\n          if (p.next) this.head = p.next\n          else this.head = this.tail = null\n        } else {\n          ret += StringPrototypeSlice(str, 0, n)\n          this.head = p\n          p.data = StringPrototypeSlice(str, n)\n        }\n\n        break\n      }\n\n      ++c\n    } while ((p = p.next) !== null)\n\n    this.length -= c\n    return ret\n  } // Consumes a specified amount of bytes from the buffered data.\n\n  _getBuffer(n) {\n    const ret = Buffer.allocUnsafe(n)\n    const retLen = n\n    let p = this.head\n    let c = 0\n\n    do {\n      const buf = p.data\n\n      if (n > buf.length) {\n        TypedArrayPrototypeSet(ret, buf, retLen - n)\n        n -= buf.length\n      } else {\n        if (n === buf.length) {\n          TypedArrayPrototypeSet(ret, buf, retLen - n)\n          ++c\n          if (p.next) this.head = p.next\n          else this.head = this.tail = null\n        } else {\n          TypedArrayPrototypeSet(ret, new Uint8Array(buf.buffer, buf.byteOffset, n), retLen - n)\n          this.head = p\n          p.data = buf.slice(n)\n        }\n\n        break\n      }\n\n      ++c\n    } while ((p = p.next) !== null)\n\n    this.length -= c\n    return ret\n  } // Make sure the linked list only shows the minimal necessary information.\n\n  [Symbol.for('nodejs.util.inspect.custom')](_, options) {\n    return inspect(this, {\n      ...options,\n      // Only inspect one level.\n      depth: 0,\n      // It should not recurse.\n      customInspect: false\n    })\n  }\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/compose.js",
    "content": "'use strict'\n\nconst { pipeline } = require('./pipeline')\n\nconst Duplex = require('./duplex')\n\nconst { destroyer } = require('./destroy')\n\nconst { isNodeStream, isReadable, isWritable } = require('./utils')\n\nconst {\n  AbortError,\n  codes: { ERR_INVALID_ARG_VALUE, ERR_MISSING_ARGS }\n} = require('../../ours/errors')\n\nmodule.exports = function compose(...streams) {\n  if (streams.length === 0) {\n    throw new ERR_MISSING_ARGS('streams')\n  }\n\n  if (streams.length === 1) {\n    return Duplex.from(streams[0])\n  }\n\n  const orgStreams = [...streams]\n\n  if (typeof streams[0] === 'function') {\n    streams[0] = Duplex.from(streams[0])\n  }\n\n  if (typeof streams[streams.length - 1] === 'function') {\n    const idx = streams.length - 1\n    streams[idx] = Duplex.from(streams[idx])\n  }\n\n  for (let n = 0; n < streams.length; ++n) {\n    if (!isNodeStream(streams[n])) {\n      // TODO(ronag): Add checks for non streams.\n      continue\n    }\n\n    if (n < streams.length - 1 && !isReadable(streams[n])) {\n      throw new ERR_INVALID_ARG_VALUE(`streams[${n}]`, orgStreams[n], 'must be readable')\n    }\n\n    if (n > 0 && !isWritable(streams[n])) {\n      throw new ERR_INVALID_ARG_VALUE(`streams[${n}]`, orgStreams[n], 'must be writable')\n    }\n  }\n\n  let ondrain\n  let onfinish\n  let onreadable\n  let onclose\n  let d\n\n  function onfinished(err) {\n    const cb = onclose\n    onclose = null\n\n    if (cb) {\n      cb(err)\n    } else if (err) {\n      d.destroy(err)\n    } else if (!readable && !writable) {\n      d.destroy()\n    }\n  }\n\n  const head = streams[0]\n  const tail = pipeline(streams, onfinished)\n  const writable = !!isWritable(head)\n  const readable = !!isReadable(tail) // TODO(ronag): Avoid double buffering.\n  // Implement Writable/Readable/Duplex traits.\n  // See, https://github.com/nodejs/node/pull/33515.\n\n  d = new Duplex({\n    // TODO (ronag): highWaterMark?\n    writableObjectMode: !!(head !== null && head !== undefined && head.writableObjectMode),\n    readableObjectMode: !!(tail !== null && tail !== undefined && tail.writableObjectMode),\n    writable,\n    readable\n  })\n\n  if (writable) {\n    d._write = function (chunk, encoding, callback) {\n      if (head.write(chunk, encoding)) {\n        callback()\n      } else {\n        ondrain = callback\n      }\n    }\n\n    d._final = function (callback) {\n      head.end()\n      onfinish = callback\n    }\n\n    head.on('drain', function () {\n      if (ondrain) {\n        const cb = ondrain\n        ondrain = null\n        cb()\n      }\n    })\n    tail.on('finish', function () {\n      if (onfinish) {\n        const cb = onfinish\n        onfinish = null\n        cb()\n      }\n    })\n  }\n\n  if (readable) {\n    tail.on('readable', function () {\n      if (onreadable) {\n        const cb = onreadable\n        onreadable = null\n        cb()\n      }\n    })\n    tail.on('end', function () {\n      d.push(null)\n    })\n\n    d._read = function () {\n      while (true) {\n        const buf = tail.read()\n\n        if (buf === null) {\n          onreadable = d._read\n          return\n        }\n\n        if (!d.push(buf)) {\n          return\n        }\n      }\n    }\n  }\n\n  d._destroy = function (err, callback) {\n    if (!err && onclose !== null) {\n      err = new AbortError()\n    }\n\n    onreadable = null\n    ondrain = null\n    onfinish = null\n\n    if (onclose === null) {\n      callback(err)\n    } else {\n      onclose = callback\n      destroyer(tail, err)\n    }\n  }\n\n  return d\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/destroy.js",
    "content": "'use strict'\n\nconst {\n  aggregateTwoErrors,\n  codes: { ERR_MULTIPLE_CALLBACK },\n  AbortError\n} = require('../../ours/errors')\n\nconst { Symbol } = require('../../ours/primordials')\n\nconst { kDestroyed, isDestroyed, isFinished, isServerRequest } = require('./utils')\n\nconst kDestroy = Symbol('kDestroy')\nconst kConstruct = Symbol('kConstruct')\n\nfunction checkError(err, w, r) {\n  if (err) {\n    // Avoid V8 leak, https://github.com/nodejs/node/pull/34103#issuecomment-652002364\n    err.stack // eslint-disable-line no-unused-expressions\n\n    if (w && !w.errored) {\n      w.errored = err\n    }\n\n    if (r && !r.errored) {\n      r.errored = err\n    }\n  }\n} // Backwards compat. cb() is undocumented and unused in core but\n// unfortunately might be used by modules.\n\nfunction destroy(err, cb) {\n  const r = this._readableState\n  const w = this._writableState // With duplex streams we use the writable side for state.\n\n  const s = w || r\n\n  if ((w && w.destroyed) || (r && r.destroyed)) {\n    if (typeof cb === 'function') {\n      cb()\n    }\n\n    return this\n  } // We set destroyed to true before firing error callbacks in order\n  // to make it re-entrance safe in case destroy() is called within callbacks\n\n  checkError(err, w, r)\n\n  if (w) {\n    w.destroyed = true\n  }\n\n  if (r) {\n    r.destroyed = true\n  } // If still constructing then defer calling _destroy.\n\n  if (!s.constructed) {\n    this.once(kDestroy, function (er) {\n      _destroy(this, aggregateTwoErrors(er, err), cb)\n    })\n  } else {\n    _destroy(this, err, cb)\n  }\n\n  return this\n}\n\nfunction _destroy(self, err, cb) {\n  let called = false\n\n  function onDestroy(err) {\n    if (called) {\n      return\n    }\n\n    called = true\n    const r = self._readableState\n    const w = self._writableState\n    checkError(err, w, r)\n\n    if (w) {\n      w.closed = true\n    }\n\n    if (r) {\n      r.closed = true\n    }\n\n    if (typeof cb === 'function') {\n      cb(err)\n    }\n\n    if (err) {\n      process.nextTick(emitErrorCloseNT, self, err)\n    } else {\n      process.nextTick(emitCloseNT, self)\n    }\n  }\n\n  try {\n    self._destroy(err || null, onDestroy)\n  } catch (err) {\n    onDestroy(err)\n  }\n}\n\nfunction emitErrorCloseNT(self, err) {\n  emitErrorNT(self, err)\n  emitCloseNT(self)\n}\n\nfunction emitCloseNT(self) {\n  const r = self._readableState\n  const w = self._writableState\n\n  if (w) {\n    w.closeEmitted = true\n  }\n\n  if (r) {\n    r.closeEmitted = true\n  }\n\n  if ((w && w.emitClose) || (r && r.emitClose)) {\n    self.emit('close')\n  }\n}\n\nfunction emitErrorNT(self, err) {\n  const r = self._readableState\n  const w = self._writableState\n\n  if ((w && w.errorEmitted) || (r && r.errorEmitted)) {\n    return\n  }\n\n  if (w) {\n    w.errorEmitted = true\n  }\n\n  if (r) {\n    r.errorEmitted = true\n  }\n\n  self.emit('error', err)\n}\n\nfunction undestroy() {\n  const r = this._readableState\n  const w = this._writableState\n\n  if (r) {\n    r.constructed = true\n    r.closed = false\n    r.closeEmitted = false\n    r.destroyed = false\n    r.errored = null\n    r.errorEmitted = false\n    r.reading = false\n    r.ended = r.readable === false\n    r.endEmitted = r.readable === false\n  }\n\n  if (w) {\n    w.constructed = true\n    w.destroyed = false\n    w.closed = false\n    w.closeEmitted = false\n    w.errored = null\n    w.errorEmitted = false\n    w.finalCalled = false\n    w.prefinished = false\n    w.ended = w.writable === false\n    w.ending = w.writable === false\n    w.finished = w.writable === false\n  }\n}\n\nfunction errorOrDestroy(stream, err, sync) {\n  // We have tests that rely on errors being emitted\n  // in the same tick, so changing this is semver major.\n  // For now when you opt-in to autoDestroy we allow\n  // the error to be emitted nextTick. In a future\n  // semver major update we should change the default to this.\n  const r = stream._readableState\n  const w = stream._writableState\n\n  if ((w && w.destroyed) || (r && r.destroyed)) {\n    return this\n  }\n\n  if ((r && r.autoDestroy) || (w && w.autoDestroy)) stream.destroy(err)\n  else if (err) {\n    // Avoid V8 leak, https://github.com/nodejs/node/pull/34103#issuecomment-652002364\n    err.stack // eslint-disable-line no-unused-expressions\n\n    if (w && !w.errored) {\n      w.errored = err\n    }\n\n    if (r && !r.errored) {\n      r.errored = err\n    }\n\n    if (sync) {\n      process.nextTick(emitErrorNT, stream, err)\n    } else {\n      emitErrorNT(stream, err)\n    }\n  }\n}\n\nfunction construct(stream, cb) {\n  if (typeof stream._construct !== 'function') {\n    return\n  }\n\n  const r = stream._readableState\n  const w = stream._writableState\n\n  if (r) {\n    r.constructed = false\n  }\n\n  if (w) {\n    w.constructed = false\n  }\n\n  stream.once(kConstruct, cb)\n\n  if (stream.listenerCount(kConstruct) > 1) {\n    // Duplex\n    return\n  }\n\n  process.nextTick(constructNT, stream)\n}\n\nfunction constructNT(stream) {\n  let called = false\n\n  function onConstruct(err) {\n    if (called) {\n      errorOrDestroy(stream, err !== null && err !== undefined ? err : new ERR_MULTIPLE_CALLBACK())\n      return\n    }\n\n    called = true\n    const r = stream._readableState\n    const w = stream._writableState\n    const s = w || r\n\n    if (r) {\n      r.constructed = true\n    }\n\n    if (w) {\n      w.constructed = true\n    }\n\n    if (s.destroyed) {\n      stream.emit(kDestroy, err)\n    } else if (err) {\n      errorOrDestroy(stream, err, true)\n    } else {\n      process.nextTick(emitConstructNT, stream)\n    }\n  }\n\n  try {\n    stream._construct(onConstruct)\n  } catch (err) {\n    onConstruct(err)\n  }\n}\n\nfunction emitConstructNT(stream) {\n  stream.emit(kConstruct)\n}\n\nfunction isRequest(stream) {\n  return stream && stream.setHeader && typeof stream.abort === 'function'\n}\n\nfunction emitCloseLegacy(stream) {\n  stream.emit('close')\n}\n\nfunction emitErrorCloseLegacy(stream, err) {\n  stream.emit('error', err)\n  process.nextTick(emitCloseLegacy, stream)\n} // Normalize destroy for legacy.\n\nfunction destroyer(stream, err) {\n  if (!stream || isDestroyed(stream)) {\n    return\n  }\n\n  if (!err && !isFinished(stream)) {\n    err = new AbortError()\n  } // TODO: Remove isRequest branches.\n\n  if (isServerRequest(stream)) {\n    stream.socket = null\n    stream.destroy(err)\n  } else if (isRequest(stream)) {\n    stream.abort()\n  } else if (isRequest(stream.req)) {\n    stream.req.abort()\n  } else if (typeof stream.destroy === 'function') {\n    stream.destroy(err)\n  } else if (typeof stream.close === 'function') {\n    // TODO: Don't lose err?\n    stream.close()\n  } else if (err) {\n    process.nextTick(emitErrorCloseLegacy, stream)\n  } else {\n    process.nextTick(emitCloseLegacy, stream)\n  }\n\n  if (!stream.destroyed) {\n    stream[kDestroyed] = true\n  }\n}\n\nmodule.exports = {\n  construct,\n  destroyer,\n  destroy,\n  undestroy,\n  errorOrDestroy\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/duplex.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n// a duplex stream is just a stream that is both readable and writable.\n// Since JS doesn't have multiple prototype inheritance, this class\n// prototypically inherits from Readable, and then parasitically from\n// Writable.\n'use strict'\n\nconst {\n  ObjectDefineProperties,\n  ObjectGetOwnPropertyDescriptor,\n  ObjectKeys,\n  ObjectSetPrototypeOf\n} = require('../../ours/primordials')\n\nmodule.exports = Duplex\n\nconst Readable = require('./readable')\n\nconst Writable = require('./writable')\n\nObjectSetPrototypeOf(Duplex.prototype, Readable.prototype)\nObjectSetPrototypeOf(Duplex, Readable)\n{\n  const keys = ObjectKeys(Writable.prototype) // Allow the keys array to be GC'ed.\n\n  for (let i = 0; i < keys.length; i++) {\n    const method = keys[i]\n    if (!Duplex.prototype[method]) Duplex.prototype[method] = Writable.prototype[method]\n  }\n}\n\nfunction Duplex(options) {\n  if (!(this instanceof Duplex)) return new Duplex(options)\n  Readable.call(this, options)\n  Writable.call(this, options)\n\n  if (options) {\n    this.allowHalfOpen = options.allowHalfOpen !== false\n\n    if (options.readable === false) {\n      this._readableState.readable = false\n      this._readableState.ended = true\n      this._readableState.endEmitted = true\n    }\n\n    if (options.writable === false) {\n      this._writableState.writable = false\n      this._writableState.ending = true\n      this._writableState.ended = true\n      this._writableState.finished = true\n    }\n  } else {\n    this.allowHalfOpen = true\n  }\n}\n\nObjectDefineProperties(Duplex.prototype, {\n  writable: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writable'),\n  writableHighWaterMark: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableHighWaterMark'),\n  writableObjectMode: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableObjectMode'),\n  writableBuffer: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableBuffer'),\n  writableLength: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableLength'),\n  writableFinished: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableFinished'),\n  writableCorked: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableCorked'),\n  writableEnded: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableEnded'),\n  writableNeedDrain: ObjectGetOwnPropertyDescriptor(Writable.prototype, 'writableNeedDrain'),\n  destroyed: {\n    get() {\n      if (this._readableState === undefined || this._writableState === undefined) {\n        return false\n      }\n\n      return this._readableState.destroyed && this._writableState.destroyed\n    },\n\n    set(value) {\n      // Backward compatibility, the user is explicitly\n      // managing destroyed.\n      if (this._readableState && this._writableState) {\n        this._readableState.destroyed = value\n        this._writableState.destroyed = value\n      }\n    }\n  }\n})\nlet webStreamsAdapters // Lazy to avoid circular references\n\nfunction lazyWebStreams() {\n  if (webStreamsAdapters === undefined) webStreamsAdapters = {}\n  return webStreamsAdapters\n}\n\nDuplex.fromWeb = function (pair, options) {\n  return lazyWebStreams().newStreamDuplexFromReadableWritablePair(pair, options)\n}\n\nDuplex.toWeb = function (duplex) {\n  return lazyWebStreams().newReadableWritablePairFromDuplex(duplex)\n}\n\nlet duplexify\n\nDuplex.from = function (body) {\n  if (!duplexify) {\n    duplexify = require('./duplexify')\n  }\n\n  return duplexify(body, 'body')\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/duplexify.js",
    "content": "'use strict'\n\nconst abortControllerModule = require('../../../../../watcher/aborter/controller')\n\nconst bufferModule = require('buffer')\n\nconst {\n  isReadable,\n  isWritable,\n  isIterable,\n  isNodeStream,\n  isReadableNodeStream,\n  isWritableNodeStream,\n  isDuplexNodeStream\n} = require('./utils')\n\nconst eos = require('./end-of-stream')\n\nconst {\n  AbortError,\n  codes: { ERR_INVALID_ARG_TYPE, ERR_INVALID_RETURN_VALUE }\n} = require('../../ours/errors')\n\nconst { destroyer } = require('./destroy')\n\nconst Duplex = require('./duplex')\n\nconst Readable = require('./readable')\n\nconst { createDeferredPromise } = require('../../ours/util')\n\nconst from = require('./from')\n\nconst Blob = globalThis.Blob || bufferModule.Blob\nconst isBlob =\n  typeof Blob !== 'undefined'\n    ? function isBlob(b) {\n      return b instanceof Blob\n    }\n    : function isBlob(b) {\n      return false\n    }\nconst AbortController = globalThis.AbortController || abortControllerModule.AbortController\n\nconst { FunctionPrototypeCall } = require('../../ours/primordials') // This is needed for pre node 17.\n\nclass Duplexify extends Duplex {\n  constructor(options) {\n    super(options) // https://github.com/nodejs/node/pull/34385\n\n    if ((options === null || options === undefined ? undefined : options.readable) === false) {\n      this._readableState.readable = false\n      this._readableState.ended = true\n      this._readableState.endEmitted = true\n    }\n\n    if ((options === null || options === undefined ? undefined : options.writable) === false) {\n      this._writableState.writable = false\n      this._writableState.ending = true\n      this._writableState.ended = true\n      this._writableState.finished = true\n    }\n  }\n}\n\nmodule.exports = function duplexify(body, name) {\n  if (isDuplexNodeStream(body)) {\n    return body\n  }\n\n  if (isReadableNodeStream(body)) {\n    return _duplexify({\n      readable: body\n    })\n  }\n\n  if (isWritableNodeStream(body)) {\n    return _duplexify({\n      writable: body\n    })\n  }\n\n  if (isNodeStream(body)) {\n    return _duplexify({\n      writable: false,\n      readable: false\n    })\n  } // TODO: Webstreams\n  // if (isReadableStream(body)) {\n  //   return _duplexify({ readable: Readable.fromWeb(body) });\n  // }\n  // TODO: Webstreams\n  // if (isWritableStream(body)) {\n  //   return _duplexify({ writable: Writable.fromWeb(body) });\n  // }\n\n  if (typeof body === 'function') {\n    const { value, write, final, destroy } = fromAsyncGen(body)\n\n    if (isIterable(value)) {\n      return from(Duplexify, value, {\n        // TODO (ronag): highWaterMark?\n        objectMode: true,\n        write,\n        final,\n        destroy\n      })\n    }\n\n    const then = value === null || value === undefined ? undefined : value.then\n\n    if (typeof then === 'function') {\n      let d\n      const promise = FunctionPrototypeCall(\n        then,\n        value,\n        (val) => {\n          if (val != null) {\n            throw new ERR_INVALID_RETURN_VALUE('nully', 'body', val)\n          }\n        },\n        (err) => {\n          destroyer(d, err)\n        }\n      )\n      return (d = new Duplexify({\n        // TODO (ronag): highWaterMark?\n        objectMode: true,\n        readable: false,\n        write,\n\n        final(cb) {\n          final(async () => {\n            try {\n              await promise\n              process.nextTick(cb, null)\n            } catch (err) {\n              process.nextTick(cb, err)\n            }\n          })\n        },\n\n        destroy\n      }))\n    }\n\n    throw new ERR_INVALID_RETURN_VALUE('Iterable, AsyncIterable or AsyncFunction', name, value)\n  }\n\n  if (isBlob(body)) {\n    return duplexify(body.arrayBuffer())\n  }\n\n  if (isIterable(body)) {\n    return from(Duplexify, body, {\n      // TODO (ronag): highWaterMark?\n      objectMode: true,\n      writable: false\n    })\n  } // TODO: Webstreams.\n  // if (\n  //   isReadableStream(body?.readable) &&\n  //   isWritableStream(body?.writable)\n  // ) {\n  //   return Duplexify.fromWeb(body);\n  // }\n\n  if (\n    typeof (body === null || body === undefined ? undefined : body.writable) === 'object' ||\n    typeof (body === null || body === undefined ? undefined : body.readable) === 'object'\n  ) {\n    const readable =\n      body !== null && body !== undefined && body.readable\n        ? isReadableNodeStream(body === null || body === undefined ? undefined : body.readable)\n          ? body === null || body === undefined\n            ? undefined\n            : body.readable\n          : duplexify(body.readable)\n        : undefined\n    const writable =\n      body !== null && body !== undefined && body.writable\n        ? isWritableNodeStream(body === null || body === undefined ? undefined : body.writable)\n          ? body === null || body === undefined\n            ? undefined\n            : body.writable\n          : duplexify(body.writable)\n        : undefined\n    return _duplexify({\n      readable,\n      writable\n    })\n  }\n\n  const then = body === null || body === undefined ? undefined : body.then\n\n  if (typeof then === 'function') {\n    let d\n    FunctionPrototypeCall(\n      then,\n      body,\n      (val) => {\n        if (val != null) {\n          d.push(val)\n        }\n\n        d.push(null)\n      },\n      (err) => {\n        destroyer(d, err)\n      }\n    )\n    return (d = new Duplexify({\n      objectMode: true,\n      writable: false,\n\n      read() { }\n    }))\n  }\n\n  throw new ERR_INVALID_ARG_TYPE(\n    name,\n    [\n      'Blob',\n      'ReadableStream',\n      'WritableStream',\n      'Stream',\n      'Iterable',\n      'AsyncIterable',\n      'Function',\n      '{ readable, writable } pair',\n      'Promise'\n    ],\n    body\n  )\n}\n\nfunction fromAsyncGen(fn) {\n  let { promise, resolve } = createDeferredPromise()\n  const ac = new AbortController()\n  const signal = ac.signal\n  const value = fn(\n    (async function* () {\n      while (true) {\n        const _promise = promise\n        promise = null\n        const { chunk, done, cb } = await _promise\n        process.nextTick(cb)\n        if (done) return\n        if (signal.aborted)\n          throw new AbortError(undefined, {\n            cause: signal.reason\n          })\n          ; ({ promise, resolve } = createDeferredPromise())\n        yield chunk\n      }\n    })(),\n    {\n      signal\n    }\n  )\n  return {\n    value,\n\n    write(chunk, encoding, cb) {\n      const _resolve = resolve\n      resolve = null\n\n      _resolve({\n        chunk,\n        done: false,\n        cb\n      })\n    },\n\n    final(cb) {\n      const _resolve = resolve\n      resolve = null\n\n      _resolve({\n        done: true,\n        cb\n      })\n    },\n\n    destroy(err, cb) {\n      ac.abort()\n      cb(err)\n    }\n  }\n}\n\nfunction _duplexify(pair) {\n  const r = pair.readable && typeof pair.readable.read !== 'function' ? Readable.wrap(pair.readable) : pair.readable\n  const w = pair.writable\n  let readable = !!isReadable(r)\n  let writable = !!isWritable(w)\n  let ondrain\n  let onfinish\n  let onreadable\n  let onclose\n  let d\n\n  function onfinished(err) {\n    const cb = onclose\n    onclose = null\n\n    if (cb) {\n      cb(err)\n    } else if (err) {\n      d.destroy(err)\n    } else if (!readable && !writable) {\n      d.destroy()\n    }\n  } // TODO(ronag): Avoid double buffering.\n  // Implement Writable/Readable/Duplex traits.\n  // See, https://github.com/nodejs/node/pull/33515.\n\n  d = new Duplexify({\n    // TODO (ronag): highWaterMark?\n    readableObjectMode: !!(r !== null && r !== undefined && r.readableObjectMode),\n    writableObjectMode: !!(w !== null && w !== undefined && w.writableObjectMode),\n    readable,\n    writable\n  })\n\n  if (writable) {\n    eos(w, (err) => {\n      writable = false\n\n      if (err) {\n        destroyer(r, err)\n      }\n\n      onfinished(err)\n    })\n\n    d._write = function (chunk, encoding, callback) {\n      if (w.write(chunk, encoding)) {\n        callback()\n      } else {\n        ondrain = callback\n      }\n    }\n\n    d._final = function (callback) {\n      w.end()\n      onfinish = callback\n    }\n\n    w.on('drain', function () {\n      if (ondrain) {\n        const cb = ondrain\n        ondrain = null\n        cb()\n      }\n    })\n    w.on('finish', function () {\n      if (onfinish) {\n        const cb = onfinish\n        onfinish = null\n        cb()\n      }\n    })\n  }\n\n  if (readable) {\n    eos(r, (err) => {\n      readable = false\n\n      if (err) {\n        destroyer(r, err)\n      }\n\n      onfinished(err)\n    })\n    r.on('readable', function () {\n      if (onreadable) {\n        const cb = onreadable\n        onreadable = null\n        cb()\n      }\n    })\n    r.on('end', function () {\n      d.push(null)\n    })\n\n    d._read = function () {\n      while (true) {\n        const buf = r.read()\n\n        if (buf === null) {\n          onreadable = d._read\n          return\n        }\n\n        if (!d.push(buf)) {\n          return\n        }\n      }\n    }\n  }\n\n  d._destroy = function (err, callback) {\n    if (!err && onclose !== null) {\n      err = new AbortError()\n    }\n\n    onreadable = null\n    ondrain = null\n    onfinish = null\n\n    if (onclose === null) {\n      callback(err)\n    } else {\n      onclose = callback\n      destroyer(w, err)\n      destroyer(r, err)\n    }\n  }\n\n  return d\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/end-of-stream.js",
    "content": "// Ported from https://github.com/mafintosh/end-of-stream with\n// permission from the author, Mathias Buus (@mafintosh).\n'use strict'\n\nconst { AbortError, codes } = require('../../ours/errors')\n\nconst { ERR_INVALID_ARG_TYPE, ERR_STREAM_PREMATURE_CLOSE } = codes\n\nconst { once } = require('../../ours/util')\n\nconst { validateAbortSignal, validateFunction, validateObject } = require('../validators')\n\nconst { Promise } = require('../../ours/primordials')\n\nconst {\n  isClosed,\n  isReadable,\n  isReadableNodeStream,\n  isReadableFinished,\n  isReadableErrored,\n  isWritable,\n  isWritableNodeStream,\n  isWritableFinished,\n  isWritableErrored,\n  isNodeStream,\n  willEmitClose: _willEmitClose\n} = require('./utils')\n\nfunction isRequest(stream) {\n  return stream.setHeader && typeof stream.abort === 'function'\n}\n\nconst nop = () => {}\n\nfunction eos(stream, options, callback) {\n  var _options$readable, _options$writable\n\n  if (arguments.length === 2) {\n    callback = options\n    options = {}\n  } else if (options == null) {\n    options = {}\n  } else {\n    validateObject(options, 'options')\n  }\n\n  validateFunction(callback, 'callback')\n  validateAbortSignal(options.signal, 'options.signal')\n  callback = once(callback)\n  const readable =\n    (_options$readable = options.readable) !== null && _options$readable !== undefined\n      ? _options$readable\n      : isReadableNodeStream(stream)\n  const writable =\n    (_options$writable = options.writable) !== null && _options$writable !== undefined\n      ? _options$writable\n      : isWritableNodeStream(stream)\n\n  if (!isNodeStream(stream)) {\n    // TODO: Webstreams.\n    throw new ERR_INVALID_ARG_TYPE('stream', 'Stream', stream)\n  }\n\n  const wState = stream._writableState\n  const rState = stream._readableState\n\n  const onlegacyfinish = () => {\n    if (!stream.writable) {\n      onfinish()\n    }\n  } // TODO (ronag): Improve soft detection to include core modules and\n  // common ecosystem modules that do properly emit 'close' but fail\n  // this generic check.\n\n  let willEmitClose =\n    _willEmitClose(stream) && isReadableNodeStream(stream) === readable && isWritableNodeStream(stream) === writable\n  let writableFinished = isWritableFinished(stream, false)\n\n  const onfinish = () => {\n    writableFinished = true // Stream should not be destroyed here. If it is that\n    // means that user space is doing something differently and\n    // we cannot trust willEmitClose.\n\n    if (stream.destroyed) {\n      willEmitClose = false\n    }\n\n    if (willEmitClose && (!stream.readable || readable)) {\n      return\n    }\n\n    if (!readable || readableFinished) {\n      callback.call(stream)\n    }\n  }\n\n  let readableFinished = isReadableFinished(stream, false)\n\n  const onend = () => {\n    readableFinished = true // Stream should not be destroyed here. If it is that\n    // means that user space is doing something differently and\n    // we cannot trust willEmitClose.\n\n    if (stream.destroyed) {\n      willEmitClose = false\n    }\n\n    if (willEmitClose && (!stream.writable || writable)) {\n      return\n    }\n\n    if (!writable || writableFinished) {\n      callback.call(stream)\n    }\n  }\n\n  const onerror = (err) => {\n    callback.call(stream, err)\n  }\n\n  let closed = isClosed(stream)\n\n  const onclose = () => {\n    closed = true\n    const errored = isWritableErrored(stream) || isReadableErrored(stream)\n\n    if (errored && typeof errored !== 'boolean') {\n      return callback.call(stream, errored)\n    }\n\n    if (readable && !readableFinished && isReadableNodeStream(stream, true)) {\n      if (!isReadableFinished(stream, false)) return callback.call(stream, new ERR_STREAM_PREMATURE_CLOSE())\n    }\n\n    if (writable && !writableFinished) {\n      if (!isWritableFinished(stream, false)) return callback.call(stream, new ERR_STREAM_PREMATURE_CLOSE())\n    }\n\n    callback.call(stream)\n  }\n\n  const onrequest = () => {\n    stream.req.on('finish', onfinish)\n  }\n\n  if (isRequest(stream)) {\n    stream.on('complete', onfinish)\n\n    if (!willEmitClose) {\n      stream.on('abort', onclose)\n    }\n\n    if (stream.req) {\n      onrequest()\n    } else {\n      stream.on('request', onrequest)\n    }\n  } else if (writable && !wState) {\n    // legacy streams\n    stream.on('end', onlegacyfinish)\n    stream.on('close', onlegacyfinish)\n  } // Not all streams will emit 'close' after 'aborted'.\n\n  if (!willEmitClose && typeof stream.aborted === 'boolean') {\n    stream.on('aborted', onclose)\n  }\n\n  stream.on('end', onend)\n  stream.on('finish', onfinish)\n\n  if (options.error !== false) {\n    stream.on('error', onerror)\n  }\n\n  stream.on('close', onclose)\n\n  if (closed) {\n    process.nextTick(onclose)\n  } else if (\n    (wState !== null && wState !== undefined && wState.errorEmitted) ||\n    (rState !== null && rState !== undefined && rState.errorEmitted)\n  ) {\n    if (!willEmitClose) {\n      process.nextTick(onclose)\n    }\n  } else if (\n    !readable &&\n    (!willEmitClose || isReadable(stream)) &&\n    (writableFinished || isWritable(stream) === false)\n  ) {\n    process.nextTick(onclose)\n  } else if (\n    !writable &&\n    (!willEmitClose || isWritable(stream)) &&\n    (readableFinished || isReadable(stream) === false)\n  ) {\n    process.nextTick(onclose)\n  } else if (rState && stream.req && stream.aborted) {\n    process.nextTick(onclose)\n  }\n\n  const cleanup = () => {\n    callback = nop\n    stream.removeListener('aborted', onclose)\n    stream.removeListener('complete', onfinish)\n    stream.removeListener('abort', onclose)\n    stream.removeListener('request', onrequest)\n    if (stream.req) stream.req.removeListener('finish', onfinish)\n    stream.removeListener('end', onlegacyfinish)\n    stream.removeListener('close', onlegacyfinish)\n    stream.removeListener('finish', onfinish)\n    stream.removeListener('end', onend)\n    stream.removeListener('error', onerror)\n    stream.removeListener('close', onclose)\n  }\n\n  if (options.signal && !closed) {\n    const abort = () => {\n      // Keep it because cleanup removes it.\n      const endCallback = callback\n      cleanup()\n      endCallback.call(\n        stream,\n        new AbortError(undefined, {\n          cause: options.signal.reason\n        })\n      )\n    }\n\n    if (options.signal.aborted) {\n      process.nextTick(abort)\n    } else {\n      const originalCallback = callback\n      callback = once((...args) => {\n        options.signal.removeEventListener('abort', abort)\n        originalCallback.apply(stream, args)\n      })\n      options.signal.addEventListener('abort', abort)\n    }\n  }\n\n  return cleanup\n}\n\nfunction finished(stream, opts) {\n  return new Promise((resolve, reject) => {\n    eos(stream, opts, (err) => {\n      if (err) {\n        reject(err)\n      } else {\n        resolve()\n      }\n    })\n  })\n}\n\nmodule.exports = eos\nmodule.exports.finished = finished\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/from.js",
    "content": "'use strict'\n\nconst { PromisePrototypeThen, SymbolAsyncIterator, SymbolIterator } = require('../../ours/primordials')\n\nconst { ERR_INVALID_ARG_TYPE, ERR_STREAM_NULL_VALUES } = require('../../ours/errors').codes\n\nfunction from(Readable, iterable, opts) {\n  let iterator\n\n  if (typeof iterable === 'string' || iterable instanceof Buffer) {\n    return new Readable({\n      objectMode: true,\n      ...opts,\n\n      read() {\n        this.push(iterable)\n        this.push(null)\n      }\n    })\n  }\n\n  let isAsync\n\n  if (iterable && iterable[SymbolAsyncIterator]) {\n    isAsync = true\n    iterator = iterable[SymbolAsyncIterator]()\n  } else if (iterable && iterable[SymbolIterator]) {\n    isAsync = false\n    iterator = iterable[SymbolIterator]()\n  } else {\n    throw new ERR_INVALID_ARG_TYPE('iterable', ['Iterable'], iterable)\n  }\n\n  const readable = new Readable({\n    objectMode: true,\n    highWaterMark: 1,\n    // TODO(ronag): What options should be allowed?\n    ...opts\n  }) // Flag to protect against _read\n  // being called before last iteration completion.\n\n  let reading = false\n\n  readable._read = function () {\n    if (!reading) {\n      reading = true\n      next()\n    }\n  }\n\n  readable._destroy = function (error, cb) {\n    PromisePrototypeThen(\n      close(error),\n      () => process.nextTick(cb, error), // nextTick is here in case cb throws\n      (e) => process.nextTick(cb, e || error)\n    )\n  }\n\n  async function close(error) {\n    const hadError = error !== undefined && error !== null\n    const hasThrow = typeof iterator.throw === 'function'\n\n    if (hadError && hasThrow) {\n      const { value, done } = await iterator.throw(error)\n      await value\n\n      if (done) {\n        return\n      }\n    }\n\n    if (typeof iterator.return === 'function') {\n      const { value } = await iterator.return()\n      await value\n    }\n  }\n\n  async function next() {\n    for (;;) {\n      try {\n        const { value, done } = isAsync ? await iterator.next() : iterator.next()\n\n        if (done) {\n          readable.push(null)\n        } else {\n          const res = value && typeof value.then === 'function' ? await value : value\n\n          if (res === null) {\n            reading = false\n            throw new ERR_STREAM_NULL_VALUES()\n          } else if (readable.push(res)) {\n            continue\n          } else {\n            reading = false\n          }\n        }\n      } catch (err) {\n        readable.destroy(err)\n      }\n\n      break\n    }\n  }\n\n  return readable\n}\n\nmodule.exports = from\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/lazy_transform.js",
    "content": "// LazyTransform is a special type of Transform stream that is lazily loaded.\n// This is used for performance with bi-API-ship: when two APIs are available\n// for the stream, one conventional and one non-conventional.\n'use strict'\n\nconst { ObjectDefineProperties, ObjectDefineProperty, ObjectSetPrototypeOf } = require('../../ours/primordials')\n\nconst stream = require('../../stream')\n\nconst { getDefaultEncoding } = require('../crypto/util')\n\nmodule.exports = LazyTransform\n\nfunction LazyTransform(options) {\n  this._options = options\n}\n\nObjectSetPrototypeOf(LazyTransform.prototype, stream.Transform.prototype)\nObjectSetPrototypeOf(LazyTransform, stream.Transform)\n\nfunction makeGetter(name) {\n  return function () {\n    stream.Transform.call(this, this._options)\n    this._writableState.decodeStrings = false\n\n    if (!this._options || !this._options.defaultEncoding) {\n      this._writableState.defaultEncoding = getDefaultEncoding()\n    }\n\n    return this[name]\n  }\n}\n\nfunction makeSetter(name) {\n  return function (val) {\n    ObjectDefineProperty(this, name, {\n      value: val,\n      enumerable: true,\n      configurable: true,\n      writable: true\n    })\n  }\n}\n\nObjectDefineProperties(LazyTransform.prototype, {\n  _readableState: {\n    get: makeGetter('_readableState'),\n    set: makeSetter('_readableState'),\n    configurable: true,\n    enumerable: true\n  },\n  _writableState: {\n    get: makeGetter('_writableState'),\n    set: makeSetter('_writableState'),\n    configurable: true,\n    enumerable: true\n  }\n})\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/legacy.js",
    "content": "'use strict'\n\nconst { ArrayIsArray, ObjectSetPrototypeOf } = require('../../ours/primordials')\n\nconst { EventEmitter: EE } = require('events')\n\nfunction Stream(opts) {\n  EE.call(this, opts)\n}\n\nObjectSetPrototypeOf(Stream.prototype, EE.prototype)\nObjectSetPrototypeOf(Stream, EE)\n\nStream.prototype.pipe = function (dest, options) {\n  const source = this\n\n  function ondata(chunk) {\n    if (dest.writable && dest.write(chunk) === false && source.pause) {\n      source.pause()\n    }\n  }\n\n  source.on('data', ondata)\n\n  function ondrain() {\n    if (source.readable && source.resume) {\n      source.resume()\n    }\n  }\n\n  dest.on('drain', ondrain) // If the 'end' option is not supplied, dest.end() will be called when\n  // source gets the 'end' or 'close' events.  Only dest.end() once.\n\n  if (!dest._isStdio && (!options || options.end !== false)) {\n    source.on('end', onend)\n    source.on('close', onclose)\n  }\n\n  let didOnEnd = false\n\n  function onend() {\n    if (didOnEnd) return\n    didOnEnd = true\n    dest.end()\n  }\n\n  function onclose() {\n    if (didOnEnd) return\n    didOnEnd = true\n    if (typeof dest.destroy === 'function') dest.destroy()\n  } // Don't leave dangling pipes when there are errors.\n\n  function onerror(er) {\n    cleanup()\n\n    if (EE.listenerCount(this, 'error') === 0) {\n      this.emit('error', er)\n    }\n  }\n\n  prependListener(source, 'error', onerror)\n  prependListener(dest, 'error', onerror) // Remove all the event listeners that were added.\n\n  function cleanup() {\n    source.removeListener('data', ondata)\n    dest.removeListener('drain', ondrain)\n    source.removeListener('end', onend)\n    source.removeListener('close', onclose)\n    source.removeListener('error', onerror)\n    dest.removeListener('error', onerror)\n    source.removeListener('end', cleanup)\n    source.removeListener('close', cleanup)\n    dest.removeListener('close', cleanup)\n  }\n\n  source.on('end', cleanup)\n  source.on('close', cleanup)\n  dest.on('close', cleanup)\n  dest.emit('pipe', source) // Allow for unix-like usage: A.pipe(B).pipe(C)\n\n  return dest\n}\n\nfunction prependListener(emitter, event, fn) {\n  // Sadly this is not cacheable as some libraries bundle their own\n  // event emitter implementation with them.\n  if (typeof emitter.prependListener === 'function') return emitter.prependListener(event, fn) // This is a hack to make sure that our error handler is attached before any\n  // userland ones.  NEVER DO THIS. This is here only because this code needs\n  // to continue to work with older versions of Node.js that do not include\n  // the prependListener() method. The goal is to eventually remove this hack.\n\n  if (!emitter._events || !emitter._events[event]) emitter.on(event, fn)\n  else if (ArrayIsArray(emitter._events[event])) emitter._events[event].unshift(fn)\n  else emitter._events[event] = [fn, emitter._events[event]]\n}\n\nmodule.exports = {\n  Stream,\n  prependListener\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/operators.js",
    "content": "'use strict'\n\nconst abortControllerModule = require('../../../../../watcher/aborter/controller')\n\nconst AbortController = globalThis.AbortController || abortControllerModule.AbortController\n\nconst {\n  codes: { ERR_INVALID_ARG_TYPE, ERR_MISSING_ARGS, ERR_OUT_OF_RANGE },\n  AbortError\n} = require('../../ours/errors')\n\nconst { validateAbortSignal, validateInteger, validateObject } = require('../validators')\n\nconst kWeakHandler = require('../../ours/primordials').Symbol('kWeak')\n\nconst { finished } = require('./end-of-stream')\n\nconst {\n  ArrayPrototypePush,\n  MathFloor,\n  Number,\n  NumberIsNaN,\n  Promise,\n  PromiseReject,\n  PromisePrototypeCatch,\n  Symbol\n} = require('../../ours/primordials')\n\nconst kEmpty = Symbol('kEmpty')\nconst kEof = Symbol('kEof')\n\nfunction map(fn, options) {\n  if (typeof fn !== 'function') {\n    throw new ERR_INVALID_ARG_TYPE('fn', ['Function', 'AsyncFunction'], fn)\n  }\n\n  if (options != null) {\n    validateObject(options, 'options')\n  }\n\n  if ((options === null || options === undefined ? undefined : options.signal) != null) {\n    validateAbortSignal(options.signal, 'options.signal')\n  }\n\n  let concurrency = 1\n\n  if ((options === null || options === undefined ? undefined : options.concurrency) != null) {\n    concurrency = MathFloor(options.concurrency)\n  }\n\n  validateInteger(concurrency, 'concurrency', 1)\n  return async function* map() {\n    var _options$signal, _options$signal2\n\n    const ac = new AbortController()\n    const stream = this\n    const queue = []\n    const signal = ac.signal\n    const signalOpt = {\n      signal\n    }\n\n    const abort = () => ac.abort()\n\n    if (\n      options !== null &&\n      options !== undefined &&\n      (_options$signal = options.signal) !== null &&\n      _options$signal !== undefined &&\n      _options$signal.aborted\n    ) {\n      abort()\n    }\n\n    options === null || options === undefined\n      ? undefined\n      : (_options$signal2 = options.signal) === null || _options$signal2 === undefined\n        ? undefined\n        : _options$signal2.addEventListener('abort', abort)\n    let next\n    let resume\n    let done = false\n\n    function onDone() {\n      done = true\n    }\n\n    async function pump() {\n      try {\n        for await (let val of stream) {\n          var _val\n\n          if (done) {\n            return\n          }\n\n          if (signal.aborted) {\n            throw new AbortError()\n          }\n\n          try {\n            val = fn(val, signalOpt)\n          } catch (err) {\n            val = PromiseReject(err)\n          }\n\n          if (val === kEmpty) {\n            continue\n          }\n\n          if (typeof ((_val = val) === null || _val === undefined ? undefined : _val.catch) === 'function') {\n            val.catch(onDone)\n          }\n\n          queue.push(val)\n\n          if (next) {\n            next()\n            next = null\n          }\n\n          if (!done && queue.length && queue.length >= concurrency) {\n            await new Promise((resolve) => {\n              resume = resolve\n            })\n          }\n        }\n\n        queue.push(kEof)\n      } catch (err) {\n        const val = PromiseReject(err)\n        PromisePrototypeCatch(val, onDone)\n        queue.push(val)\n      } finally {\n        var _options$signal3\n\n        done = true\n\n        if (next) {\n          next()\n          next = null\n        }\n\n        options === null || options === undefined\n          ? undefined\n          : (_options$signal3 = options.signal) === null || _options$signal3 === undefined\n            ? undefined\n            : _options$signal3.removeEventListener('abort', abort)\n      }\n    }\n\n    pump()\n\n    try {\n      while (true) {\n        while (queue.length > 0) {\n          const val = await queue[0]\n\n          if (val === kEof) {\n            return\n          }\n\n          if (signal.aborted) {\n            throw new AbortError()\n          }\n\n          if (val !== kEmpty) {\n            yield val\n          }\n\n          queue.shift()\n\n          if (resume) {\n            resume()\n            resume = null\n          }\n        }\n\n        await new Promise((resolve) => {\n          next = resolve\n        })\n      }\n    } finally {\n      ac.abort()\n      done = true\n\n      if (resume) {\n        resume()\n        resume = null\n      }\n    }\n  }.call(this)\n}\n\nfunction asIndexedPairs(options = undefined) {\n  if (options != null) {\n    validateObject(options, 'options')\n  }\n\n  if ((options === null || options === undefined ? undefined : options.signal) != null) {\n    validateAbortSignal(options.signal, 'options.signal')\n  }\n\n  return async function* asIndexedPairs() {\n    let index = 0\n\n    for await (const val of this) {\n      var _options$signal4\n\n      if (\n        options !== null &&\n        options !== undefined &&\n        (_options$signal4 = options.signal) !== null &&\n        _options$signal4 !== undefined &&\n        _options$signal4.aborted\n      ) {\n        throw new AbortError({\n          cause: options.signal.reason\n        })\n      }\n\n      yield [index++, val]\n    }\n  }.call(this)\n}\n\nasync function some(fn, options = undefined) {\n  // eslint-disable-next-line no-unused-vars\n  for await (const unused of filter.call(this, fn, options)) {\n    return true\n  }\n\n  return false\n}\n\nasync function every(fn, options = undefined) {\n  if (typeof fn !== 'function') {\n    throw new ERR_INVALID_ARG_TYPE('fn', ['Function', 'AsyncFunction'], fn)\n  } // https://en.wikipedia.org/wiki/De_Morgan%27s_laws\n\n  return !(await some.call(\n    this,\n    async (...args) => {\n      return !(await fn(...args))\n    },\n    options\n  ))\n}\n\nasync function find(fn, options) {\n  for await (const result of filter.call(this, fn, options)) {\n    return result\n  }\n\n  return undefined\n}\n\nasync function forEach(fn, options) {\n  if (typeof fn !== 'function') {\n    throw new ERR_INVALID_ARG_TYPE('fn', ['Function', 'AsyncFunction'], fn)\n  }\n\n  async function forEachFn(value, options) {\n    await fn(value, options)\n    return kEmpty\n  } // eslint-disable-next-line no-unused-vars\n\n  for await (const unused of map.call(this, forEachFn, options));\n}\n\nfunction filter(fn, options) {\n  if (typeof fn !== 'function') {\n    throw new ERR_INVALID_ARG_TYPE('fn', ['Function', 'AsyncFunction'], fn)\n  }\n\n  async function filterFn(value, options) {\n    if (await fn(value, options)) {\n      return value\n    }\n\n    return kEmpty\n  }\n\n  return map.call(this, filterFn, options)\n} // Specific to provide better error to reduce since the argument is only\n// missing if the stream has no items in it - but the code is still appropriate\n\nclass ReduceAwareErrMissingArgs extends ERR_MISSING_ARGS {\n  constructor() {\n    super('reduce')\n    this.message = 'Reduce of an empty stream requires an initial value'\n  }\n}\n\nasync function reduce(reducer, initialValue, options) {\n  var _options$signal5\n\n  if (typeof reducer !== 'function') {\n    throw new ERR_INVALID_ARG_TYPE('reducer', ['Function', 'AsyncFunction'], reducer)\n  }\n\n  if (options != null) {\n    validateObject(options, 'options')\n  }\n\n  if ((options === null || options === undefined ? undefined : options.signal) != null) {\n    validateAbortSignal(options.signal, 'options.signal')\n  }\n\n  let hasInitialValue = arguments.length > 1\n\n  if (\n    options !== null &&\n    options !== undefined &&\n    (_options$signal5 = options.signal) !== null &&\n    _options$signal5 !== undefined &&\n    _options$signal5.aborted\n  ) {\n    const err = new AbortError(undefined, {\n      cause: options.signal.reason\n    })\n    this.once('error', () => { }) // The error is already propagated\n\n    await finished(this.destroy(err))\n    throw err\n  }\n\n  const ac = new AbortController()\n  const signal = ac.signal\n\n  if (options !== null && options !== undefined && options.signal) {\n    const opts = {\n      once: true,\n      [kWeakHandler]: this\n    }\n    options.signal.addEventListener('abort', () => ac.abort(), opts)\n  }\n\n  let gotAnyItemFromStream = false\n\n  try {\n    for await (const value of this) {\n      var _options$signal6\n\n      gotAnyItemFromStream = true\n\n      if (\n        options !== null &&\n        options !== undefined &&\n        (_options$signal6 = options.signal) !== null &&\n        _options$signal6 !== undefined &&\n        _options$signal6.aborted\n      ) {\n        throw new AbortError()\n      }\n\n      if (!hasInitialValue) {\n        initialValue = value\n        hasInitialValue = true\n      } else {\n        initialValue = await reducer(initialValue, value, {\n          signal\n        })\n      }\n    }\n\n    if (!gotAnyItemFromStream && !hasInitialValue) {\n      throw new ReduceAwareErrMissingArgs()\n    }\n  } finally {\n    ac.abort()\n  }\n\n  return initialValue\n}\n\nasync function toArray(options) {\n  if (options != null) {\n    validateObject(options, 'options')\n  }\n\n  if ((options === null || options === undefined ? undefined : options.signal) != null) {\n    validateAbortSignal(options.signal, 'options.signal')\n  }\n\n  const result = []\n\n  for await (const val of this) {\n    var _options$signal7\n\n    if (\n      options !== null &&\n      options !== undefined &&\n      (_options$signal7 = options.signal) !== null &&\n      _options$signal7 !== undefined &&\n      _options$signal7.aborted\n    ) {\n      throw new AbortError(undefined, {\n        cause: options.signal.reason\n      })\n    }\n\n    ArrayPrototypePush(result, val)\n  }\n\n  return result\n}\n\nfunction flatMap(fn, options) {\n  const values = map.call(this, fn, options)\n  return async function* flatMap() {\n    for await (const val of values) {\n      yield* val\n    }\n  }.call(this)\n}\n\nfunction toIntegerOrInfinity(number) {\n  // We coerce here to align with the spec\n  // https://github.com/tc39/proposal-iterator-helpers/issues/169\n  number = Number(number)\n\n  if (NumberIsNaN(number)) {\n    return 0\n  }\n\n  if (number < 0) {\n    throw new ERR_OUT_OF_RANGE('number', '>= 0', number)\n  }\n\n  return number\n}\n\nfunction drop(number, options = undefined) {\n  if (options != null) {\n    validateObject(options, 'options')\n  }\n\n  if ((options === null || options === undefined ? undefined : options.signal) != null) {\n    validateAbortSignal(options.signal, 'options.signal')\n  }\n\n  number = toIntegerOrInfinity(number)\n  return async function* drop() {\n    var _options$signal8\n\n    if (\n      options !== null &&\n      options !== undefined &&\n      (_options$signal8 = options.signal) !== null &&\n      _options$signal8 !== undefined &&\n      _options$signal8.aborted\n    ) {\n      throw new AbortError()\n    }\n\n    for await (const val of this) {\n      var _options$signal9\n\n      if (\n        options !== null &&\n        options !== undefined &&\n        (_options$signal9 = options.signal) !== null &&\n        _options$signal9 !== undefined &&\n        _options$signal9.aborted\n      ) {\n        throw new AbortError()\n      }\n\n      if (number-- <= 0) {\n        yield val\n      }\n    }\n  }.call(this)\n}\n\nfunction take(number, options = undefined) {\n  if (options != null) {\n    validateObject(options, 'options')\n  }\n\n  if ((options === null || options === undefined ? undefined : options.signal) != null) {\n    validateAbortSignal(options.signal, 'options.signal')\n  }\n\n  number = toIntegerOrInfinity(number)\n  return async function* take() {\n    var _options$signal10\n\n    if (\n      options !== null &&\n      options !== undefined &&\n      (_options$signal10 = options.signal) !== null &&\n      _options$signal10 !== undefined &&\n      _options$signal10.aborted\n    ) {\n      throw new AbortError()\n    }\n\n    for await (const val of this) {\n      var _options$signal11\n\n      if (\n        options !== null &&\n        options !== undefined &&\n        (_options$signal11 = options.signal) !== null &&\n        _options$signal11 !== undefined &&\n        _options$signal11.aborted\n      ) {\n        throw new AbortError()\n      }\n\n      if (number-- > 0) {\n        yield val\n      } else {\n        return\n      }\n    }\n  }.call(this)\n}\n\nmodule.exports.streamReturningOperators = {\n  asIndexedPairs,\n  drop,\n  filter,\n  flatMap,\n  map,\n  take\n}\nmodule.exports.promiseReturningOperators = {\n  every,\n  forEach,\n  reduce,\n  toArray,\n  some,\n  find\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/passthrough.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n// a passthrough stream.\n// basically just the most minimal sort of Transform stream.\n// Every written chunk gets output as-is.\n'use strict'\n\nconst { ObjectSetPrototypeOf } = require('../../ours/primordials')\n\nmodule.exports = PassThrough\n\nconst Transform = require('./transform')\n\nObjectSetPrototypeOf(PassThrough.prototype, Transform.prototype)\nObjectSetPrototypeOf(PassThrough, Transform)\n\nfunction PassThrough(options) {\n  if (!(this instanceof PassThrough)) return new PassThrough(options)\n  Transform.call(this, options)\n}\n\nPassThrough.prototype._transform = function (chunk, encoding, cb) {\n  cb(null, chunk)\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/pipeline.js",
    "content": "// Ported from https://github.com/mafintosh/pump with\n// permission from the author, Mathias Buus (@mafintosh).\n'use strict'\n\nconst abortControllerModule = require('../../../../../watcher/aborter/controller')\n\nconst { ArrayIsArray, Promise, SymbolAsyncIterator } = require('../../ours/primordials')\n\nconst eos = require('./end-of-stream')\n\nconst { once } = require('../../ours/util')\n\nconst destroyImpl = require('./destroy')\n\nconst Duplex = require('./duplex')\n\nconst {\n  aggregateTwoErrors,\n  codes: { ERR_INVALID_ARG_TYPE, ERR_INVALID_RETURN_VALUE, ERR_MISSING_ARGS, ERR_STREAM_DESTROYED },\n  AbortError\n} = require('../../ours/errors')\n\nconst { validateFunction, validateAbortSignal } = require('../validators')\n\nconst { isIterable, isReadable, isReadableNodeStream, isNodeStream } = require('./utils')\n\nconst AbortController = globalThis.AbortController || abortControllerModule.AbortController\nlet PassThrough\nlet Readable\n\nfunction destroyer(stream, reading, writing) {\n  let finished = false\n  stream.on('close', () => {\n    finished = true\n  })\n  const cleanup = eos(\n    stream,\n    {\n      readable: reading,\n      writable: writing\n    },\n    (err) => {\n      finished = !err\n    }\n  )\n  return {\n    destroy: (err) => {\n      if (finished) return\n      finished = true\n      destroyImpl.destroyer(stream, err || new ERR_STREAM_DESTROYED('pipe'))\n    },\n    cleanup\n  }\n}\n\nfunction popCallback(streams) {\n  // Streams should never be an empty array. It should always contain at least\n  // a single stream. Therefore optimize for the average case instead of\n  // checking for length === 0 as well.\n  validateFunction(streams[streams.length - 1], 'streams[stream.length - 1]')\n  return streams.pop()\n}\n\nfunction makeAsyncIterable(val) {\n  if (isIterable(val)) {\n    return val\n  } else if (isReadableNodeStream(val)) {\n    // Legacy streams are not Iterable.\n    return fromReadable(val)\n  }\n\n  throw new ERR_INVALID_ARG_TYPE('val', ['Readable', 'Iterable', 'AsyncIterable'], val)\n}\n\nasync function* fromReadable(val) {\n  if (!Readable) {\n    Readable = require('./readable')\n  }\n\n  yield* Readable.prototype[SymbolAsyncIterator].call(val)\n}\n\nasync function pump(iterable, writable, finish, { end }) {\n  let error\n  let onresolve = null\n\n  const resume = (err) => {\n    if (err) {\n      error = err\n    }\n\n    if (onresolve) {\n      const callback = onresolve\n      onresolve = null\n      callback()\n    }\n  }\n\n  const wait = () =>\n    new Promise((resolve, reject) => {\n      if (error) {\n        reject(error)\n      } else {\n        onresolve = () => {\n          if (error) {\n            reject(error)\n          } else {\n            resolve()\n          }\n        }\n      }\n    })\n\n  writable.on('drain', resume)\n  const cleanup = eos(\n    writable,\n    {\n      readable: false\n    },\n    resume\n  )\n\n  try {\n    if (writable.writableNeedDrain) {\n      await wait()\n    }\n\n    for await (const chunk of iterable) {\n      if (!writable.write(chunk)) {\n        await wait()\n      }\n    }\n\n    if (end) {\n      writable.end()\n    }\n\n    await wait()\n    finish()\n  } catch (err) {\n    finish(error !== err ? aggregateTwoErrors(error, err) : err)\n  } finally {\n    cleanup()\n    writable.off('drain', resume)\n  }\n}\n\nfunction pipeline(...streams) {\n  return pipelineImpl(streams, once(popCallback(streams)))\n}\n\nfunction pipelineImpl(streams, callback, opts) {\n  if (streams.length === 1 && ArrayIsArray(streams[0])) {\n    streams = streams[0]\n  }\n\n  if (streams.length < 2) {\n    throw new ERR_MISSING_ARGS('streams')\n  }\n\n  const ac = new AbortController()\n  const signal = ac.signal\n  const outerSignal = opts === null || opts === undefined ? undefined : opts.signal // Need to cleanup event listeners if last stream is readable\n  // https://github.com/nodejs/node/issues/35452\n\n  const lastStreamCleanup = []\n  validateAbortSignal(outerSignal, 'options.signal')\n\n  function abort() {\n    finishImpl(new AbortError())\n  }\n\n  outerSignal === null || outerSignal === undefined ? undefined : outerSignal.addEventListener('abort', abort)\n  let error\n  let value\n  const destroys = []\n  let finishCount = 0\n\n  function finish(err) {\n    finishImpl(err, --finishCount === 0)\n  }\n\n  function finishImpl(err, final) {\n    if (err && (!error || error.code === 'ERR_STREAM_PREMATURE_CLOSE')) {\n      error = err\n    }\n\n    if (!error && !final) {\n      return\n    }\n\n    while (destroys.length) {\n      destroys.shift()(error)\n    }\n\n    outerSignal === null || outerSignal === undefined ? undefined : outerSignal.removeEventListener('abort', abort)\n    ac.abort()\n\n    if (final) {\n      if (!error) {\n        lastStreamCleanup.forEach((fn) => fn())\n      }\n\n      process.nextTick(callback, error, value)\n    }\n  }\n\n  let ret\n\n  for (let i = 0; i < streams.length; i++) {\n    const stream = streams[i]\n    const reading = i < streams.length - 1\n    const writing = i > 0\n    const end = reading || (opts === null || opts === undefined ? undefined : opts.end) !== false\n    const isLastStream = i === streams.length - 1\n\n    if (isNodeStream(stream)) {\n      if (end) {\n        const { destroy, cleanup } = destroyer(stream, reading, writing)\n        destroys.push(destroy)\n\n        if (isReadable(stream) && isLastStream) {\n          lastStreamCleanup.push(cleanup)\n        }\n      } // Catch stream errors that occur after pipe/pump has completed.\n\n      function onError(err) {\n        if (err && err.name !== 'AbortError' && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {\n          finish(err)\n        }\n      }\n\n      stream.on('error', onError)\n\n      if (isReadable(stream) && isLastStream) {\n        lastStreamCleanup.push(() => {\n          stream.removeListener('error', onError)\n        })\n      }\n    }\n\n    if (i === 0) {\n      if (typeof stream === 'function') {\n        ret = stream({\n          signal\n        })\n\n        if (!isIterable(ret)) {\n          throw new ERR_INVALID_RETURN_VALUE('Iterable, AsyncIterable or Stream', 'source', ret)\n        }\n      } else if (isIterable(stream) || isReadableNodeStream(stream)) {\n        ret = stream\n      } else {\n        ret = Duplex.from(stream)\n      }\n    } else if (typeof stream === 'function') {\n      ret = makeAsyncIterable(ret)\n      ret = stream(ret, {\n        signal\n      })\n\n      if (reading) {\n        if (!isIterable(ret, true)) {\n          throw new ERR_INVALID_RETURN_VALUE('AsyncIterable', `transform[${i - 1}]`, ret)\n        }\n      } else {\n        var _ret\n\n        if (!PassThrough) {\n          PassThrough = require('./passthrough')\n        } // If the last argument to pipeline is not a stream\n        // we must create a proxy stream so that pipeline(...)\n        // always returns a stream which can be further\n        // composed through `.pipe(stream)`.\n\n        const pt = new PassThrough({\n          objectMode: true\n        }) // Handle Promises/A+ spec, `then` could be a getter that throws on\n        // second use.\n\n        const then = (_ret = ret) === null || _ret === undefined ? undefined : _ret.then\n\n        if (typeof then === 'function') {\n          finishCount++\n          then.call(\n            ret,\n            (val) => {\n              value = val\n\n              if (val != null) {\n                pt.write(val)\n              }\n\n              if (end) {\n                pt.end()\n              }\n\n              process.nextTick(finish)\n            },\n            (err) => {\n              pt.destroy(err)\n              process.nextTick(finish, err)\n            }\n          )\n        } else if (isIterable(ret, true)) {\n          finishCount++\n          pump(ret, pt, finish, {\n            end\n          })\n        } else {\n          throw new ERR_INVALID_RETURN_VALUE('AsyncIterable or Promise', 'destination', ret)\n        }\n\n        ret = pt\n        const { destroy, cleanup } = destroyer(ret, false, true)\n        destroys.push(destroy)\n\n        if (isLastStream) {\n          lastStreamCleanup.push(cleanup)\n        }\n      }\n    } else if (isNodeStream(stream)) {\n      if (isReadableNodeStream(ret)) {\n        finishCount += 2\n        const cleanup = pipe(ret, stream, finish, {\n          end\n        })\n\n        if (isReadable(stream) && isLastStream) {\n          lastStreamCleanup.push(cleanup)\n        }\n      } else if (isIterable(ret)) {\n        finishCount++\n        pump(ret, stream, finish, {\n          end\n        })\n      } else {\n        throw new ERR_INVALID_ARG_TYPE('val', ['Readable', 'Iterable', 'AsyncIterable'], ret)\n      }\n\n      ret = stream\n    } else {\n      ret = Duplex.from(stream)\n    }\n  }\n\n  if (\n    (signal !== null && signal !== undefined && signal.aborted) ||\n    (outerSignal !== null && outerSignal !== undefined && outerSignal.aborted)\n  ) {\n    process.nextTick(abort)\n  }\n\n  return ret\n}\n\nfunction pipe(src, dst, finish, { end }) {\n  src.pipe(dst, {\n    end\n  })\n\n  if (end) {\n    // Compat. Before node v10.12.0 stdio used to throw an error so\n    // pipe() did/does not end() stdio destinations.\n    // Now they allow it but \"secretly\" don't close the underlying fd.\n    src.once('end', () => dst.end())\n  } else {\n    finish()\n  }\n\n  eos(\n    src,\n    {\n      readable: true,\n      writable: false\n    },\n    (err) => {\n      const rState = src._readableState\n\n      if (\n        err &&\n        err.code === 'ERR_STREAM_PREMATURE_CLOSE' &&\n        rState &&\n        rState.ended &&\n        !rState.errored &&\n        !rState.errorEmitted\n      ) {\n        // Some readable streams will emit 'close' before 'end'. However, since\n        // this is on the readable side 'end' should still be emitted if the\n        // stream has been ended and no error emitted. This should be allowed in\n        // favor of backwards compatibility. Since the stream is piped to a\n        // destination this should not result in any observable difference.\n        // We don't need to check if this is a writable premature close since\n        // eos will only fail with premature close on the reading side for\n        // duplex streams.\n        src.once('end', finish).once('error', finish)\n      } else {\n        finish(err)\n      }\n    }\n  )\n  return eos(\n    dst,\n    {\n      readable: false,\n      writable: true\n    },\n    finish\n  )\n}\n\nmodule.exports = {\n  pipelineImpl,\n  pipeline\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/readable.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n'use strict'\n\nconst {\n  ArrayPrototypeIndexOf,\n  NumberIsInteger,\n  NumberIsNaN,\n  NumberParseInt,\n  ObjectDefineProperties,\n  ObjectKeys,\n  ObjectSetPrototypeOf,\n  Promise,\n  SafeSet,\n  SymbolAsyncIterator,\n  Symbol\n} = require('../../ours/primordials')\n\nmodule.exports = Readable\nReadable.ReadableState = ReadableState\n\nconst { EventEmitter: EE } = require('events')\n\nconst { Stream, prependListener } = require('./legacy')\n\nconst { addAbortSignal } = require('./add-abort-signal')\n\nconst eos = require('./end-of-stream')\n\nlet debug = require('../../ours/util').debuglog('stream', (fn) => {\n  debug = fn\n})\n\nconst BufferList = require('./buffer_list')\n\nconst destroyImpl = require('./destroy')\n\nconst { getHighWaterMark, getDefaultHighWaterMark } = require('./state')\n\nconst {\n  aggregateTwoErrors,\n  codes: {\n    ERR_INVALID_ARG_TYPE,\n    ERR_METHOD_NOT_IMPLEMENTED,\n    ERR_OUT_OF_RANGE,\n    ERR_STREAM_PUSH_AFTER_EOF,\n    ERR_STREAM_UNSHIFT_AFTER_END_EVENT\n  }\n} = require('../../ours/errors')\n\nconst { validateObject } = require('../validators')\n\nconst kPaused = Symbol('kPaused')\n\nconst { StringDecoder } = require('string_decoder')\n\nconst from = require('./from')\n\nObjectSetPrototypeOf(Readable.prototype, Stream.prototype)\nObjectSetPrototypeOf(Readable, Stream)\n\nconst nop = () => {}\n\nconst { errorOrDestroy } = destroyImpl\n\nfunction ReadableState(options, stream, isDuplex) {\n  // Duplex streams are both readable and writable, but share\n  // the same options object.\n  // However, some cases require setting options to different\n  // values for the readable and the writable sides of the duplex stream.\n  // These options can be provided separately as readableXXX and writableXXX.\n  if (typeof isDuplex !== 'boolean') isDuplex = stream instanceof require('./duplex') // Object stream flag. Used to make read(n) ignore n and to\n  // make all the buffer merging and length checks go away.\n\n  this.objectMode = !!(options && options.objectMode)\n  if (isDuplex) this.objectMode = this.objectMode || !!(options && options.readableObjectMode) // The point at which it stops calling _read() to fill the buffer\n  // Note: 0 is a valid value, means \"don't call _read preemptively ever\"\n\n  this.highWaterMark = options\n    ? getHighWaterMark(this, options, 'readableHighWaterMark', isDuplex)\n    : getDefaultHighWaterMark(false) // A linked list is used to store data chunks instead of an array because the\n  // linked list can remove elements from the beginning faster than\n  // array.shift().\n\n  this.buffer = new BufferList()\n  this.length = 0\n  this.pipes = []\n  this.flowing = null\n  this.ended = false\n  this.endEmitted = false\n  this.reading = false // Stream is still being constructed and cannot be\n  // destroyed until construction finished or failed.\n  // Async construction is opt in, therefore we start as\n  // constructed.\n\n  this.constructed = true // A flag to be able to tell if the event 'readable'/'data' is emitted\n  // immediately, or on a later tick.  We set this to true at first, because\n  // any actions that shouldn't happen until \"later\" should generally also\n  // not happen before the first read call.\n\n  this.sync = true // Whenever we return null, then we set a flag to say\n  // that we're awaiting a 'readable' event emission.\n\n  this.needReadable = false\n  this.emittedReadable = false\n  this.readableListening = false\n  this.resumeScheduled = false\n  this[kPaused] = null // True if the error was already emitted and should not be thrown again.\n\n  this.errorEmitted = false // Should close be emitted on destroy. Defaults to true.\n\n  this.emitClose = !options || options.emitClose !== false // Should .destroy() be called after 'end' (and potentially 'finish').\n\n  this.autoDestroy = !options || options.autoDestroy !== false // Has it been destroyed.\n\n  this.destroyed = false // Indicates whether the stream has errored. When true no further\n  // _read calls, 'data' or 'readable' events should occur. This is needed\n  // since when autoDestroy is disabled we need a way to tell whether the\n  // stream has failed.\n\n  this.errored = null // Indicates whether the stream has finished destroying.\n\n  this.closed = false // True if close has been emitted or would have been emitted\n  // depending on emitClose.\n\n  this.closeEmitted = false // Crypto is kind of old and crusty.  Historically, its default string\n  // encoding is 'binary' so we have to make this configurable.\n  // Everything else in the universe uses 'utf8', though.\n\n  this.defaultEncoding = (options && options.defaultEncoding) || 'utf8' // Ref the piped dest which we need a drain event on it\n  // type: null | Writable | Set<Writable>.\n\n  this.awaitDrainWriters = null\n  this.multiAwaitDrain = false // If true, a maybeReadMore has been scheduled.\n\n  this.readingMore = false\n  this.dataEmitted = false\n  this.decoder = null\n  this.encoding = null\n\n  if (options && options.encoding) {\n    this.decoder = new StringDecoder(options.encoding)\n    this.encoding = options.encoding\n  }\n}\n\nfunction Readable(options) {\n  if (!(this instanceof Readable)) return new Readable(options) // Checking for a Stream.Duplex instance is faster here instead of inside\n  // the ReadableState constructor, at least with V8 6.5.\n\n  const isDuplex = this instanceof require('./duplex')\n\n  this._readableState = new ReadableState(options, this, isDuplex)\n\n  if (options) {\n    if (typeof options.read === 'function') this._read = options.read\n    if (typeof options.destroy === 'function') this._destroy = options.destroy\n    if (typeof options.construct === 'function') this._construct = options.construct\n    if (options.signal && !isDuplex) addAbortSignal(options.signal, this)\n  }\n\n  Stream.call(this, options)\n  destroyImpl.construct(this, () => {\n    if (this._readableState.needReadable) {\n      maybeReadMore(this, this._readableState)\n    }\n  })\n}\n\nReadable.prototype.destroy = destroyImpl.destroy\nReadable.prototype._undestroy = destroyImpl.undestroy\n\nReadable.prototype._destroy = function (err, cb) {\n  cb(err)\n}\n\nReadable.prototype[EE.captureRejectionSymbol] = function (err) {\n  this.destroy(err)\n} // Manually shove something into the read() buffer.\n// This returns true if the highWaterMark has not been hit yet,\n// similar to how Writable.write() returns true if you should\n// write() some more.\n\nReadable.prototype.push = function (chunk, encoding) {\n  return readableAddChunk(this, chunk, encoding, false)\n} // Unshift should *always* be something directly out of read().\n\nReadable.prototype.unshift = function (chunk, encoding) {\n  return readableAddChunk(this, chunk, encoding, true)\n}\n\nfunction readableAddChunk(stream, chunk, encoding, addToFront) {\n  debug('readableAddChunk', chunk)\n  const state = stream._readableState\n  let err\n\n  if (!state.objectMode) {\n    if (typeof chunk === 'string') {\n      encoding = encoding || state.defaultEncoding\n\n      if (state.encoding !== encoding) {\n        if (addToFront && state.encoding) {\n          // When unshifting, if state.encoding is set, we have to save\n          // the string in the BufferList with the state encoding.\n          chunk = Buffer.from(chunk, encoding).toString(state.encoding)\n        } else {\n          chunk = Buffer.from(chunk, encoding)\n          encoding = ''\n        }\n      }\n    } else if (chunk instanceof Buffer) {\n      encoding = ''\n    } else if (Stream._isUint8Array(chunk)) {\n      chunk = Stream._uint8ArrayToBuffer(chunk)\n      encoding = ''\n    } else if (chunk != null) {\n      err = new ERR_INVALID_ARG_TYPE('chunk', ['string', 'Buffer', 'Uint8Array'], chunk)\n    }\n  }\n\n  if (err) {\n    errorOrDestroy(stream, err)\n  } else if (chunk === null) {\n    state.reading = false\n    onEofChunk(stream, state)\n  } else if (state.objectMode || (chunk && chunk.length > 0)) {\n    if (addToFront) {\n      if (state.endEmitted) errorOrDestroy(stream, new ERR_STREAM_UNSHIFT_AFTER_END_EVENT())\n      else if (state.destroyed || state.errored) return false\n      else addChunk(stream, state, chunk, true)\n    } else if (state.ended) {\n      errorOrDestroy(stream, new ERR_STREAM_PUSH_AFTER_EOF())\n    } else if (state.destroyed || state.errored) {\n      return false\n    } else {\n      state.reading = false\n\n      if (state.decoder && !encoding) {\n        chunk = state.decoder.write(chunk)\n        if (state.objectMode || chunk.length !== 0) addChunk(stream, state, chunk, false)\n        else maybeReadMore(stream, state)\n      } else {\n        addChunk(stream, state, chunk, false)\n      }\n    }\n  } else if (!addToFront) {\n    state.reading = false\n    maybeReadMore(stream, state)\n  } // We can push more data if we are below the highWaterMark.\n  // Also, if we have no data yet, we can stand some more bytes.\n  // This is to work around cases where hwm=0, such as the repl.\n\n  return !state.ended && (state.length < state.highWaterMark || state.length === 0)\n}\n\nfunction addChunk(stream, state, chunk, addToFront) {\n  if (state.flowing && state.length === 0 && !state.sync && stream.listenerCount('data') > 0) {\n    // Use the guard to avoid creating `Set()` repeatedly\n    // when we have multiple pipes.\n    if (state.multiAwaitDrain) {\n      state.awaitDrainWriters.clear()\n    } else {\n      state.awaitDrainWriters = null\n    }\n\n    state.dataEmitted = true\n    stream.emit('data', chunk)\n  } else {\n    // Update the buffer info.\n    state.length += state.objectMode ? 1 : chunk.length\n    if (addToFront) state.buffer.unshift(chunk)\n    else state.buffer.push(chunk)\n    if (state.needReadable) emitReadable(stream)\n  }\n\n  maybeReadMore(stream, state)\n}\n\nReadable.prototype.isPaused = function () {\n  const state = this._readableState\n  return state[kPaused] === true || state.flowing === false\n} // Backwards compatibility.\n\nReadable.prototype.setEncoding = function (enc) {\n  const decoder = new StringDecoder(enc)\n  this._readableState.decoder = decoder // If setEncoding(null), decoder.encoding equals utf8.\n\n  this._readableState.encoding = this._readableState.decoder.encoding\n  const buffer = this._readableState.buffer // Iterate over current buffer to convert already stored Buffers:\n\n  let content = ''\n\n  for (const data of buffer) {\n    content += decoder.write(data)\n  }\n\n  buffer.clear()\n  if (content !== '') buffer.push(content)\n  this._readableState.length = content.length\n  return this\n} // Don't raise the hwm > 1GB.\n\nconst MAX_HWM = 0x40000000\n\nfunction computeNewHighWaterMark(n) {\n  if (n > MAX_HWM) {\n    throw new ERR_OUT_OF_RANGE('size', '<= 1GiB', n)\n  } else {\n    // Get the next highest power of 2 to prevent increasing hwm excessively in\n    // tiny amounts.\n    n--\n    n |= n >>> 1\n    n |= n >>> 2\n    n |= n >>> 4\n    n |= n >>> 8\n    n |= n >>> 16\n    n++\n  }\n\n  return n\n} // This function is designed to be inlinable, so please take care when making\n// changes to the function body.\n\nfunction howMuchToRead(n, state) {\n  if (n <= 0 || (state.length === 0 && state.ended)) return 0\n  if (state.objectMode) return 1\n\n  if (NumberIsNaN(n)) {\n    // Only flow one buffer at a time.\n    if (state.flowing && state.length) return state.buffer.first().length\n    return state.length\n  }\n\n  if (n <= state.length) return n\n  return state.ended ? state.length : 0\n} // You can override either this method, or the async _read(n) below.\n\nReadable.prototype.read = function (n) {\n  debug('read', n) // Same as parseInt(undefined, 10), however V8 7.3 performance regressed\n  // in this scenario, so we are doing it manually.\n\n  if (n === undefined) {\n    n = NaN\n  } else if (!NumberIsInteger(n)) {\n    n = NumberParseInt(n, 10)\n  }\n\n  const state = this._readableState\n  const nOrig = n // If we're asking for more than the current hwm, then raise the hwm.\n\n  if (n > state.highWaterMark) state.highWaterMark = computeNewHighWaterMark(n)\n  if (n !== 0) state.emittedReadable = false // If we're doing read(0) to trigger a readable event, but we\n  // already have a bunch of data in the buffer, then just trigger\n  // the 'readable' event and move on.\n\n  if (\n    n === 0 &&\n    state.needReadable &&\n    ((state.highWaterMark !== 0 ? state.length >= state.highWaterMark : state.length > 0) || state.ended)\n  ) {\n    debug('read: emitReadable', state.length, state.ended)\n    if (state.length === 0 && state.ended) endReadable(this)\n    else emitReadable(this)\n    return null\n  }\n\n  n = howMuchToRead(n, state) // If we've ended, and we're now clear, then finish it up.\n\n  if (n === 0 && state.ended) {\n    if (state.length === 0) endReadable(this)\n    return null\n  } // All the actual chunk generation logic needs to be\n  // *below* the call to _read.  The reason is that in certain\n  // synthetic stream cases, such as passthrough streams, _read\n  // may be a completely synchronous operation which may change\n  // the state of the read buffer, providing enough data when\n  // before there was *not* enough.\n  //\n  // So, the steps are:\n  // 1. Figure out what the state of things will be after we do\n  // a read from the buffer.\n  //\n  // 2. If that resulting state will trigger a _read, then call _read.\n  // Note that this may be asynchronous, or synchronous.  Yes, it is\n  // deeply ugly to write APIs this way, but that still doesn't mean\n  // that the Readable class should behave improperly, as streams are\n  // designed to be sync/async agnostic.\n  // Take note if the _read call is sync or async (ie, if the read call\n  // has returned yet), so that we know whether or not it's safe to emit\n  // 'readable' etc.\n  //\n  // 3. Actually pull the requested chunks out of the buffer and return.\n  // if we need a readable event, then we need to do some reading.\n\n  let doRead = state.needReadable\n  debug('need readable', doRead) // If we currently have less than the highWaterMark, then also read some.\n\n  if (state.length === 0 || state.length - n < state.highWaterMark) {\n    doRead = true\n    debug('length less than watermark', doRead)\n  } // However, if we've ended, then there's no point, if we're already\n  // reading, then it's unnecessary, if we're constructing we have to wait,\n  // and if we're destroyed or errored, then it's not allowed,\n\n  if (state.ended || state.reading || state.destroyed || state.errored || !state.constructed) {\n    doRead = false\n    debug('reading, ended or constructing', doRead)\n  } else if (doRead) {\n    debug('do read')\n    state.reading = true\n    state.sync = true // If the length is currently zero, then we *need* a readable event.\n\n    if (state.length === 0) state.needReadable = true // Call internal read method\n\n    try {\n      this._read(state.highWaterMark)\n    } catch (err) {\n      errorOrDestroy(this, err)\n    }\n\n    state.sync = false // If _read pushed data synchronously, then `reading` will be false,\n    // and we need to re-evaluate how much data we can return to the user.\n\n    if (!state.reading) n = howMuchToRead(nOrig, state)\n  }\n\n  let ret\n  if (n > 0) ret = fromList(n, state)\n  else ret = null\n\n  if (ret === null) {\n    state.needReadable = state.length <= state.highWaterMark\n    n = 0\n  } else {\n    state.length -= n\n\n    if (state.multiAwaitDrain) {\n      state.awaitDrainWriters.clear()\n    } else {\n      state.awaitDrainWriters = null\n    }\n  }\n\n  if (state.length === 0) {\n    // If we have nothing in the buffer, then we want to know\n    // as soon as we *do* get something into the buffer.\n    if (!state.ended) state.needReadable = true // If we tried to read() past the EOF, then emit end on the next tick.\n\n    if (nOrig !== n && state.ended) endReadable(this)\n  }\n\n  if (ret !== null && !state.errorEmitted && !state.closeEmitted) {\n    state.dataEmitted = true\n    this.emit('data', ret)\n  }\n\n  return ret\n}\n\nfunction onEofChunk(stream, state) {\n  debug('onEofChunk')\n  if (state.ended) return\n\n  if (state.decoder) {\n    const chunk = state.decoder.end()\n\n    if (chunk && chunk.length) {\n      state.buffer.push(chunk)\n      state.length += state.objectMode ? 1 : chunk.length\n    }\n  }\n\n  state.ended = true\n\n  if (state.sync) {\n    // If we are sync, wait until next tick to emit the data.\n    // Otherwise we risk emitting data in the flow()\n    // the readable code triggers during a read() call.\n    emitReadable(stream)\n  } else {\n    // Emit 'readable' now to make sure it gets picked up.\n    state.needReadable = false\n    state.emittedReadable = true // We have to emit readable now that we are EOF. Modules\n    // in the ecosystem (e.g. dicer) rely on this event being sync.\n\n    emitReadable_(stream)\n  }\n} // Don't emit readable right away in sync mode, because this can trigger\n// another read() call => stack overflow.  This way, it might trigger\n// a nextTick recursion warning, but that's not so bad.\n\nfunction emitReadable(stream) {\n  const state = stream._readableState\n  debug('emitReadable', state.needReadable, state.emittedReadable)\n  state.needReadable = false\n\n  if (!state.emittedReadable) {\n    debug('emitReadable', state.flowing)\n    state.emittedReadable = true\n    process.nextTick(emitReadable_, stream)\n  }\n}\n\nfunction emitReadable_(stream) {\n  const state = stream._readableState\n  debug('emitReadable_', state.destroyed, state.length, state.ended)\n\n  if (!state.destroyed && !state.errored && (state.length || state.ended)) {\n    stream.emit('readable')\n    state.emittedReadable = false\n  } // The stream needs another readable event if:\n  // 1. It is not flowing, as the flow mechanism will take\n  //    care of it.\n  // 2. It is not ended.\n  // 3. It is below the highWaterMark, so we can schedule\n  //    another readable later.\n\n  state.needReadable = !state.flowing && !state.ended && state.length <= state.highWaterMark\n  flow(stream)\n} // At this point, the user has presumably seen the 'readable' event,\n// and called read() to consume some data.  that may have triggered\n// in turn another _read(n) call, in which case reading = true if\n// it's in progress.\n// However, if we're not ended, or reading, and the length < hwm,\n// then go ahead and try to read some more preemptively.\n\nfunction maybeReadMore(stream, state) {\n  if (!state.readingMore && state.constructed) {\n    state.readingMore = true\n    process.nextTick(maybeReadMore_, stream, state)\n  }\n}\n\nfunction maybeReadMore_(stream, state) {\n  // Attempt to read more data if we should.\n  //\n  // The conditions for reading more data are (one of):\n  // - Not enough data buffered (state.length < state.highWaterMark). The loop\n  //   is responsible for filling the buffer with enough data if such data\n  //   is available. If highWaterMark is 0 and we are not in the flowing mode\n  //   we should _not_ attempt to buffer any extra data. We'll get more data\n  //   when the stream consumer calls read() instead.\n  // - No data in the buffer, and the stream is in flowing mode. In this mode\n  //   the loop below is responsible for ensuring read() is called. Failing to\n  //   call read here would abort the flow and there's no other mechanism for\n  //   continuing the flow if the stream consumer has just subscribed to the\n  //   'data' event.\n  //\n  // In addition to the above conditions to keep reading data, the following\n  // conditions prevent the data from being read:\n  // - The stream has ended (state.ended).\n  // - There is already a pending 'read' operation (state.reading). This is a\n  //   case where the stream has called the implementation defined _read()\n  //   method, but they are processing the call asynchronously and have _not_\n  //   called push() with new data. In this case we skip performing more\n  //   read()s. The execution ends in this method again after the _read() ends\n  //   up calling push() with more data.\n  while (\n    !state.reading &&\n    !state.ended &&\n    (state.length < state.highWaterMark || (state.flowing && state.length === 0))\n  ) {\n    const len = state.length\n    debug('maybeReadMore read 0')\n    stream.read(0)\n    if (len === state.length)\n      // Didn't get any data, stop spinning.\n      break\n  }\n\n  state.readingMore = false\n} // Abstract method.  to be overridden in specific implementation classes.\n// call cb(er, data) where data is <= n in length.\n// for virtual (non-string, non-buffer) streams, \"length\" is somewhat\n// arbitrary, and perhaps not very meaningful.\n\nReadable.prototype._read = function (n) {\n  throw new ERR_METHOD_NOT_IMPLEMENTED('_read()')\n}\n\nReadable.prototype.pipe = function (dest, pipeOpts) {\n  const src = this\n  const state = this._readableState\n\n  if (state.pipes.length === 1) {\n    if (!state.multiAwaitDrain) {\n      state.multiAwaitDrain = true\n      state.awaitDrainWriters = new SafeSet(state.awaitDrainWriters ? [state.awaitDrainWriters] : [])\n    }\n  }\n\n  state.pipes.push(dest)\n  debug('pipe count=%d opts=%j', state.pipes.length, pipeOpts)\n  const doEnd = (!pipeOpts || pipeOpts.end !== false) && dest !== process.stdout && dest !== process.stderr\n  const endFn = doEnd ? onend : unpipe\n  if (state.endEmitted) process.nextTick(endFn)\n  else src.once('end', endFn)\n  dest.on('unpipe', onunpipe)\n\n  function onunpipe(readable, unpipeInfo) {\n    debug('onunpipe')\n\n    if (readable === src) {\n      if (unpipeInfo && unpipeInfo.hasUnpiped === false) {\n        unpipeInfo.hasUnpiped = true\n        cleanup()\n      }\n    }\n  }\n\n  function onend() {\n    debug('onend')\n    dest.end()\n  }\n\n  let ondrain\n  let cleanedUp = false\n\n  function cleanup() {\n    debug('cleanup') // Cleanup event handlers once the pipe is broken.\n\n    dest.removeListener('close', onclose)\n    dest.removeListener('finish', onfinish)\n\n    if (ondrain) {\n      dest.removeListener('drain', ondrain)\n    }\n\n    dest.removeListener('error', onerror)\n    dest.removeListener('unpipe', onunpipe)\n    src.removeListener('end', onend)\n    src.removeListener('end', unpipe)\n    src.removeListener('data', ondata)\n    cleanedUp = true // If the reader is waiting for a drain event from this\n    // specific writer, then it would cause it to never start\n    // flowing again.\n    // So, if this is awaiting a drain, then we just call it now.\n    // If we don't know, then assume that we are waiting for one.\n\n    if (ondrain && state.awaitDrainWriters && (!dest._writableState || dest._writableState.needDrain)) ondrain()\n  }\n\n  function pause() {\n    // If the user unpiped during `dest.write()`, it is possible\n    // to get stuck in a permanently paused state if that write\n    // also returned false.\n    // => Check whether `dest` is still a piping destination.\n    if (!cleanedUp) {\n      if (state.pipes.length === 1 && state.pipes[0] === dest) {\n        debug('false write response, pause', 0)\n        state.awaitDrainWriters = dest\n        state.multiAwaitDrain = false\n      } else if (state.pipes.length > 1 && state.pipes.includes(dest)) {\n        debug('false write response, pause', state.awaitDrainWriters.size)\n        state.awaitDrainWriters.add(dest)\n      }\n\n      src.pause()\n    }\n\n    if (!ondrain) {\n      // When the dest drains, it reduces the awaitDrain counter\n      // on the source.  This would be more elegant with a .once()\n      // handler in flow(), but adding and removing repeatedly is\n      // too slow.\n      ondrain = pipeOnDrain(src, dest)\n      dest.on('drain', ondrain)\n    }\n  }\n\n  src.on('data', ondata)\n\n  function ondata(chunk) {\n    debug('ondata')\n    const ret = dest.write(chunk)\n    debug('dest.write', ret)\n\n    if (ret === false) {\n      pause()\n    }\n  } // If the dest has an error, then stop piping into it.\n  // However, don't suppress the throwing behavior for this.\n\n  function onerror(er) {\n    debug('onerror', er)\n    unpipe()\n    dest.removeListener('error', onerror)\n\n    if (dest.listenerCount('error') === 0) {\n      const s = dest._writableState || dest._readableState\n\n      if (s && !s.errorEmitted) {\n        // User incorrectly emitted 'error' directly on the stream.\n        errorOrDestroy(dest, er)\n      } else {\n        dest.emit('error', er)\n      }\n    }\n  } // Make sure our error handler is attached before userland ones.\n\n  prependListener(dest, 'error', onerror) // Both close and finish should trigger unpipe, but only once.\n\n  function onclose() {\n    dest.removeListener('finish', onfinish)\n    unpipe()\n  }\n\n  dest.once('close', onclose)\n\n  function onfinish() {\n    debug('onfinish')\n    dest.removeListener('close', onclose)\n    unpipe()\n  }\n\n  dest.once('finish', onfinish)\n\n  function unpipe() {\n    debug('unpipe')\n    src.unpipe(dest)\n  } // Tell the dest that it's being piped to.\n\n  dest.emit('pipe', src) // Start the flow if it hasn't been started already.\n\n  if (dest.writableNeedDrain === true) {\n    if (state.flowing) {\n      pause()\n    }\n  } else if (!state.flowing) {\n    debug('pipe resume')\n    src.resume()\n  }\n\n  return dest\n}\n\nfunction pipeOnDrain(src, dest) {\n  return function pipeOnDrainFunctionResult() {\n    const state = src._readableState // `ondrain` will call directly,\n    // `this` maybe not a reference to dest,\n    // so we use the real dest here.\n\n    if (state.awaitDrainWriters === dest) {\n      debug('pipeOnDrain', 1)\n      state.awaitDrainWriters = null\n    } else if (state.multiAwaitDrain) {\n      debug('pipeOnDrain', state.awaitDrainWriters.size)\n      state.awaitDrainWriters.delete(dest)\n    }\n\n    if ((!state.awaitDrainWriters || state.awaitDrainWriters.size === 0) && src.listenerCount('data')) {\n      src.resume()\n    }\n  }\n}\n\nReadable.prototype.unpipe = function (dest) {\n  const state = this._readableState\n  const unpipeInfo = {\n    hasUnpiped: false\n  } // If we're not piping anywhere, then do nothing.\n\n  if (state.pipes.length === 0) return this\n\n  if (!dest) {\n    // remove all.\n    const dests = state.pipes\n    state.pipes = []\n    this.pause()\n\n    for (let i = 0; i < dests.length; i++)\n      dests[i].emit('unpipe', this, {\n        hasUnpiped: false\n      })\n\n    return this\n  } // Try to find the right one.\n\n  const index = ArrayPrototypeIndexOf(state.pipes, dest)\n  if (index === -1) return this\n  state.pipes.splice(index, 1)\n  if (state.pipes.length === 0) this.pause()\n  dest.emit('unpipe', this, unpipeInfo)\n  return this\n} // Set up data events if they are asked for\n// Ensure readable listeners eventually get something.\n\nReadable.prototype.on = function (ev, fn) {\n  const res = Stream.prototype.on.call(this, ev, fn)\n  const state = this._readableState\n\n  if (ev === 'data') {\n    // Update readableListening so that resume() may be a no-op\n    // a few lines down. This is needed to support once('readable').\n    state.readableListening = this.listenerCount('readable') > 0 // Try start flowing on next tick if stream isn't explicitly paused.\n\n    if (state.flowing !== false) this.resume()\n  } else if (ev === 'readable') {\n    if (!state.endEmitted && !state.readableListening) {\n      state.readableListening = state.needReadable = true\n      state.flowing = false\n      state.emittedReadable = false\n      debug('on readable', state.length, state.reading)\n\n      if (state.length) {\n        emitReadable(this)\n      } else if (!state.reading) {\n        process.nextTick(nReadingNextTick, this)\n      }\n    }\n  }\n\n  return res\n}\n\nReadable.prototype.addListener = Readable.prototype.on\n\nReadable.prototype.removeListener = function (ev, fn) {\n  const res = Stream.prototype.removeListener.call(this, ev, fn)\n\n  if (ev === 'readable') {\n    // We need to check if there is someone still listening to\n    // readable and reset the state. However this needs to happen\n    // after readable has been emitted but before I/O (nextTick) to\n    // support once('readable', fn) cycles. This means that calling\n    // resume within the same tick will have no\n    // effect.\n    process.nextTick(updateReadableListening, this)\n  }\n\n  return res\n}\n\nReadable.prototype.off = Readable.prototype.removeListener\n\nReadable.prototype.removeAllListeners = function (ev) {\n  const res = Stream.prototype.removeAllListeners.apply(this, arguments)\n\n  if (ev === 'readable' || ev === undefined) {\n    // We need to check if there is someone still listening to\n    // readable and reset the state. However this needs to happen\n    // after readable has been emitted but before I/O (nextTick) to\n    // support once('readable', fn) cycles. This means that calling\n    // resume within the same tick will have no\n    // effect.\n    process.nextTick(updateReadableListening, this)\n  }\n\n  return res\n}\n\nfunction updateReadableListening(self) {\n  const state = self._readableState\n  state.readableListening = self.listenerCount('readable') > 0\n\n  if (state.resumeScheduled && state[kPaused] === false) {\n    // Flowing needs to be set to true now, otherwise\n    // the upcoming resume will not flow.\n    state.flowing = true // Crude way to check if we should resume.\n  } else if (self.listenerCount('data') > 0) {\n    self.resume()\n  } else if (!state.readableListening) {\n    state.flowing = null\n  }\n}\n\nfunction nReadingNextTick(self) {\n  debug('readable nexttick read 0')\n  self.read(0)\n} // pause() and resume() are remnants of the legacy readable stream API\n// If the user uses them, then switch into old mode.\n\nReadable.prototype.resume = function () {\n  const state = this._readableState\n\n  if (!state.flowing) {\n    debug('resume') // We flow only if there is no one listening\n    // for readable, but we still have to call\n    // resume().\n\n    state.flowing = !state.readableListening\n    resume(this, state)\n  }\n\n  state[kPaused] = false\n  return this\n}\n\nfunction resume(stream, state) {\n  if (!state.resumeScheduled) {\n    state.resumeScheduled = true\n    process.nextTick(resume_, stream, state)\n  }\n}\n\nfunction resume_(stream, state) {\n  debug('resume', state.reading)\n\n  if (!state.reading) {\n    stream.read(0)\n  }\n\n  state.resumeScheduled = false\n  stream.emit('resume')\n  flow(stream)\n  if (state.flowing && !state.reading) stream.read(0)\n}\n\nReadable.prototype.pause = function () {\n  debug('call pause flowing=%j', this._readableState.flowing)\n\n  if (this._readableState.flowing !== false) {\n    debug('pause')\n    this._readableState.flowing = false\n    this.emit('pause')\n  }\n\n  this._readableState[kPaused] = true\n  return this\n}\n\nfunction flow(stream) {\n  const state = stream._readableState\n  debug('flow', state.flowing)\n\n  while (state.flowing && stream.read() !== null);\n} // Wrap an old-style stream as the async data source.\n// This is *not* part of the readable stream interface.\n// It is an ugly unfortunate mess of history.\n\nReadable.prototype.wrap = function (stream) {\n  let paused = false // TODO (ronag): Should this.destroy(err) emit\n  // 'error' on the wrapped stream? Would require\n  // a static factory method, e.g. Readable.wrap(stream).\n\n  stream.on('data', (chunk) => {\n    if (!this.push(chunk) && stream.pause) {\n      paused = true\n      stream.pause()\n    }\n  })\n  stream.on('end', () => {\n    this.push(null)\n  })\n  stream.on('error', (err) => {\n    errorOrDestroy(this, err)\n  })\n  stream.on('close', () => {\n    this.destroy()\n  })\n  stream.on('destroy', () => {\n    this.destroy()\n  })\n\n  this._read = () => {\n    if (paused && stream.resume) {\n      paused = false\n      stream.resume()\n    }\n  } // Proxy all the other methods. Important when wrapping filters and duplexes.\n\n  const streamKeys = ObjectKeys(stream)\n\n  for (let j = 1; j < streamKeys.length; j++) {\n    const i = streamKeys[j]\n\n    if (this[i] === undefined && typeof stream[i] === 'function') {\n      this[i] = stream[i].bind(stream)\n    }\n  }\n\n  return this\n}\n\nReadable.prototype[SymbolAsyncIterator] = function () {\n  return streamToAsyncIterator(this)\n}\n\nReadable.prototype.iterator = function (options) {\n  if (options !== undefined) {\n    validateObject(options, 'options')\n  }\n\n  return streamToAsyncIterator(this, options)\n}\n\nfunction streamToAsyncIterator(stream, options) {\n  if (typeof stream.read !== 'function') {\n    stream = Readable.wrap(stream, {\n      objectMode: true\n    })\n  }\n\n  const iter = createAsyncIterator(stream, options)\n  iter.stream = stream\n  return iter\n}\n\nasync function* createAsyncIterator(stream, options) {\n  let callback = nop\n\n  function next(resolve) {\n    if (this === stream) {\n      callback()\n      callback = nop\n    } else {\n      callback = resolve\n    }\n  }\n\n  stream.on('readable', next)\n  let error\n  const cleanup = eos(\n    stream,\n    {\n      writable: false\n    },\n    (err) => {\n      error = err ? aggregateTwoErrors(error, err) : null\n      callback()\n      callback = nop\n    }\n  )\n\n  try {\n    while (true) {\n      const chunk = stream.destroyed ? null : stream.read()\n\n      if (chunk !== null) {\n        yield chunk\n      } else if (error) {\n        throw error\n      } else if (error === null) {\n        return\n      } else {\n        await new Promise(next)\n      }\n    }\n  } catch (err) {\n    error = aggregateTwoErrors(error, err)\n    throw error\n  } finally {\n    if (\n      (error || (options === null || options === undefined ? undefined : options.destroyOnReturn) !== false) &&\n      (error === undefined || stream._readableState.autoDestroy)\n    ) {\n      destroyImpl.destroyer(stream, null)\n    } else {\n      stream.off('readable', next)\n      cleanup()\n    }\n  }\n} // Making it explicit these properties are not enumerable\n// because otherwise some prototype manipulation in\n// userland will fail.\n\nObjectDefineProperties(Readable.prototype, {\n  readable: {\n    get() {\n      const r = this._readableState // r.readable === false means that this is part of a Duplex stream\n      // where the readable side was disabled upon construction.\n      // Compat. The user might manually disable readable side through\n      // deprecated setter.\n\n      return !!r && r.readable !== false && !r.destroyed && !r.errorEmitted && !r.endEmitted\n    },\n\n    set(val) {\n      // Backwards compat.\n      if (this._readableState) {\n        this._readableState.readable = !!val\n      }\n    }\n  },\n  readableDidRead: {\n    enumerable: false,\n    get: function () {\n      return this._readableState.dataEmitted\n    }\n  },\n  readableAborted: {\n    enumerable: false,\n    get: function () {\n      return !!(\n        this._readableState.readable !== false &&\n        (this._readableState.destroyed || this._readableState.errored) &&\n        !this._readableState.endEmitted\n      )\n    }\n  },\n  readableHighWaterMark: {\n    enumerable: false,\n    get: function () {\n      return this._readableState.highWaterMark\n    }\n  },\n  readableBuffer: {\n    enumerable: false,\n    get: function () {\n      return this._readableState && this._readableState.buffer\n    }\n  },\n  readableFlowing: {\n    enumerable: false,\n    get: function () {\n      return this._readableState.flowing\n    },\n    set: function (state) {\n      if (this._readableState) {\n        this._readableState.flowing = state\n      }\n    }\n  },\n  readableLength: {\n    enumerable: false,\n\n    get() {\n      return this._readableState.length\n    }\n  },\n  readableObjectMode: {\n    enumerable: false,\n\n    get() {\n      return this._readableState ? this._readableState.objectMode : false\n    }\n  },\n  readableEncoding: {\n    enumerable: false,\n\n    get() {\n      return this._readableState ? this._readableState.encoding : null\n    }\n  },\n  errored: {\n    enumerable: false,\n\n    get() {\n      return this._readableState ? this._readableState.errored : null\n    }\n  },\n  closed: {\n    get() {\n      return this._readableState ? this._readableState.closed : false\n    }\n  },\n  destroyed: {\n    enumerable: false,\n\n    get() {\n      return this._readableState ? this._readableState.destroyed : false\n    },\n\n    set(value) {\n      // We ignore the value if the stream\n      // has not been initialized yet.\n      if (!this._readableState) {\n        return\n      } // Backward compatibility, the user is explicitly\n      // managing destroyed.\n\n      this._readableState.destroyed = value\n    }\n  },\n  readableEnded: {\n    enumerable: false,\n\n    get() {\n      return this._readableState ? this._readableState.endEmitted : false\n    }\n  }\n})\nObjectDefineProperties(ReadableState.prototype, {\n  // Legacy getter for `pipesCount`.\n  pipesCount: {\n    get() {\n      return this.pipes.length\n    }\n  },\n  // Legacy property for `paused`.\n  paused: {\n    get() {\n      return this[kPaused] !== false\n    },\n\n    set(value) {\n      this[kPaused] = !!value\n    }\n  }\n}) // Exposed for testing purposes only.\n\nReadable._fromList = fromList // Pluck off n bytes from an array of buffers.\n// Length is the combined lengths of all the buffers in the list.\n// This function is designed to be inlinable, so please take care when making\n// changes to the function body.\n\nfunction fromList(n, state) {\n  // nothing buffered.\n  if (state.length === 0) return null\n  let ret\n  if (state.objectMode) ret = state.buffer.shift()\n  else if (!n || n >= state.length) {\n    // Read it all, truncate the list.\n    if (state.decoder) ret = state.buffer.join('')\n    else if (state.buffer.length === 1) ret = state.buffer.first()\n    else ret = state.buffer.concat(state.length)\n    state.buffer.clear()\n  } else {\n    // read part of list.\n    ret = state.buffer.consume(n, state.decoder)\n  }\n  return ret\n}\n\nfunction endReadable(stream) {\n  const state = stream._readableState\n  debug('endReadable', state.endEmitted)\n\n  if (!state.endEmitted) {\n    state.ended = true\n    process.nextTick(endReadableNT, state, stream)\n  }\n}\n\nfunction endReadableNT(state, stream) {\n  debug('endReadableNT', state.endEmitted, state.length) // Check that we didn't get one last unshift.\n\n  if (!state.errored && !state.closeEmitted && !state.endEmitted && state.length === 0) {\n    state.endEmitted = true\n    stream.emit('end')\n\n    if (stream.writable && stream.allowHalfOpen === false) {\n      process.nextTick(endWritableNT, stream)\n    } else if (state.autoDestroy) {\n      // In case of duplex streams we need a way to detect\n      // if the writable side is ready for autoDestroy as well.\n      const wState = stream._writableState\n      const autoDestroy =\n        !wState ||\n        (wState.autoDestroy && // We don't expect the writable to ever 'finish'\n          // if writable is explicitly set to false.\n          (wState.finished || wState.writable === false))\n\n      if (autoDestroy) {\n        stream.destroy()\n      }\n    }\n  }\n}\n\nfunction endWritableNT(stream) {\n  const writable = stream.writable && !stream.writableEnded && !stream.destroyed\n\n  if (writable) {\n    stream.end()\n  }\n}\n\nReadable.from = function (iterable, opts) {\n  return from(Readable, iterable, opts)\n}\n\nlet webStreamsAdapters // Lazy to avoid circular references\n\nfunction lazyWebStreams() {\n  if (webStreamsAdapters === undefined) webStreamsAdapters = {}\n  return webStreamsAdapters\n}\n\nReadable.fromWeb = function (readableStream, options) {\n  return lazyWebStreams().newStreamReadableFromReadableStream(readableStream, options)\n}\n\nReadable.toWeb = function (streamReadable) {\n  return lazyWebStreams().newReadableStreamFromStreamReadable(streamReadable)\n}\n\nReadable.wrap = function (src, options) {\n  var _ref, _src$readableObjectMo\n\n  return new Readable({\n    objectMode:\n      (_ref =\n        (_src$readableObjectMo = src.readableObjectMode) !== null && _src$readableObjectMo !== undefined\n          ? _src$readableObjectMo\n          : src.objectMode) !== null && _ref !== undefined\n        ? _ref\n        : true,\n    ...options,\n\n    destroy(err, callback) {\n      destroyImpl.destroyer(src, err)\n      callback(err)\n    }\n  }).wrap(src)\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/state.js",
    "content": "'use strict'\n\nconst { MathFloor, NumberIsInteger } = require('../../ours/primordials')\n\nconst { ERR_INVALID_ARG_VALUE } = require('../../ours/errors').codes\n\nfunction highWaterMarkFrom(options, isDuplex, duplexKey) {\n  return options.highWaterMark != null ? options.highWaterMark : isDuplex ? options[duplexKey] : null\n}\n\nfunction getDefaultHighWaterMark(objectMode) {\n  return objectMode ? 16 : 16 * 1024\n}\n\nfunction getHighWaterMark(state, options, duplexKey, isDuplex) {\n  const hwm = highWaterMarkFrom(options, isDuplex, duplexKey)\n\n  if (hwm != null) {\n    if (!NumberIsInteger(hwm) || hwm < 0) {\n      const name = isDuplex ? `options.${duplexKey}` : 'options.highWaterMark'\n      throw new ERR_INVALID_ARG_VALUE(name, hwm)\n    }\n\n    return MathFloor(hwm)\n  } // Default value\n\n  return getDefaultHighWaterMark(state.objectMode)\n}\n\nmodule.exports = {\n  getHighWaterMark,\n  getDefaultHighWaterMark\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/transform.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n// a transform stream is a readable/writable stream where you do\n// something with the data.  Sometimes it's called a \"filter\",\n// but that's not a great name for it, since that implies a thing where\n// some bits pass through, and others are simply ignored.  (That would\n// be a valid example of a transform, of course.)\n//\n// While the output is causally related to the input, it's not a\n// necessarily symmetric or synchronous transformation.  For example,\n// a zlib stream might take multiple plain-text writes(), and then\n// emit a single compressed chunk some time in the future.\n//\n// Here's how this works:\n//\n// The Transform stream has all the aspects of the readable and writable\n// stream classes.  When you write(chunk), that calls _write(chunk,cb)\n// internally, and returns false if there's a lot of pending writes\n// buffered up.  When you call read(), that calls _read(n) until\n// there's enough pending readable data buffered up.\n//\n// In a transform stream, the written data is placed in a buffer.  When\n// _read(n) is called, it transforms the queued up data, calling the\n// buffered _write cb's as it consumes chunks.  If consuming a single\n// written chunk would result in multiple output chunks, then the first\n// outputted bit calls the readcb, and subsequent chunks just go into\n// the read buffer, and will cause it to emit 'readable' if necessary.\n//\n// This way, back-pressure is actually determined by the reading side,\n// since _read has to be called to start processing a new chunk.  However,\n// a pathological inflate type of transform can cause excessive buffering\n// here.  For example, imagine a stream where every byte of input is\n// interpreted as an integer from 0-255, and then results in that many\n// bytes of output.  Writing the 4 bytes {ff,ff,ff,ff} would result in\n// 1kb of data being output.  In this case, you could write a very small\n// amount of input, and end up with a very large amount of output.  In\n// such a pathological inflating mechanism, there'd be no way to tell\n// the system to stop doing the transform.  A single 4MB write could\n// cause the system to run out of memory.\n//\n// However, even in such a pathological case, only a single written chunk\n// would be consumed, and then the rest would wait (un-transformed) until\n// the results of the previous transformed chunk were consumed.\n'use strict'\n\nconst { ObjectSetPrototypeOf, Symbol } = require('../../ours/primordials')\n\nmodule.exports = Transform\n\nconst { ERR_METHOD_NOT_IMPLEMENTED } = require('../../ours/errors').codes\n\nconst Duplex = require('./duplex')\n\nObjectSetPrototypeOf(Transform.prototype, Duplex.prototype)\nObjectSetPrototypeOf(Transform, Duplex)\nconst kCallback = Symbol('kCallback')\n\nfunction Transform(options) {\n  if (!(this instanceof Transform)) return new Transform(options)\n  Duplex.call(this, options) // We have implemented the _read method, and done the other things\n  // that Readable wants before the first _read call, so unset the\n  // sync guard flag.\n\n  this._readableState.sync = false\n  this[kCallback] = null\n\n  if (options) {\n    if (typeof options.transform === 'function') this._transform = options.transform\n    if (typeof options.flush === 'function') this._flush = options.flush\n  } // When the writable side finishes, then flush out anything remaining.\n  // Backwards compat. Some Transform streams incorrectly implement _final\n  // instead of or in addition to _flush. By using 'prefinish' instead of\n  // implementing _final we continue supporting this unfortunate use case.\n\n  this.on('prefinish', prefinish)\n}\n\nfunction final(cb) {\n  if (typeof this._flush === 'function' && !this.destroyed) {\n    this._flush((er, data) => {\n      if (er) {\n        if (cb) {\n          cb(er)\n        } else {\n          this.destroy(er)\n        }\n\n        return\n      }\n\n      if (data != null) {\n        this.push(data)\n      }\n\n      this.push(null)\n\n      if (cb) {\n        cb()\n      }\n    })\n  } else {\n    this.push(null)\n\n    if (cb) {\n      cb()\n    }\n  }\n}\n\nfunction prefinish() {\n  if (this._final !== final) {\n    final.call(this)\n  }\n}\n\nTransform.prototype._final = final\n\nTransform.prototype._transform = function (chunk, encoding, callback) {\n  throw new ERR_METHOD_NOT_IMPLEMENTED('_transform()')\n}\n\nTransform.prototype._write = function (chunk, encoding, callback) {\n  const rState = this._readableState\n  const wState = this._writableState\n  const length = rState.length\n\n  this._transform(chunk, encoding, (err, val) => {\n    if (err) {\n      callback(err)\n      return\n    }\n\n    if (val != null) {\n      this.push(val)\n    }\n\n    if (\n      wState.ended || // Backwards compat.\n      length === rState.length || // Backwards compat.\n      rState.length < rState.highWaterMark ||\n      rState.highWaterMark === 0 ||\n      rState.length === 0\n    ) {\n      callback()\n    } else {\n      this[kCallback] = callback\n    }\n  })\n}\n\nTransform.prototype._read = function () {\n  if (this[kCallback]) {\n    const callback = this[kCallback]\n    this[kCallback] = null\n    callback()\n  }\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/utils.js",
    "content": "'use strict'\n\nconst { Symbol, SymbolAsyncIterator, SymbolIterator } = require('../../ours/primordials')\n\nconst kDestroyed = Symbol('kDestroyed')\nconst kIsErrored = Symbol('kIsErrored')\nconst kIsReadable = Symbol('kIsReadable')\nconst kIsDisturbed = Symbol('kIsDisturbed')\n\nfunction isReadableNodeStream(obj, strict = false) {\n  var _obj$_readableState\n\n  return !!(\n    (\n      obj &&\n      typeof obj.pipe === 'function' &&\n      typeof obj.on === 'function' &&\n      (!strict || (typeof obj.pause === 'function' && typeof obj.resume === 'function')) &&\n      (!obj._writableState ||\n        ((_obj$_readableState = obj._readableState) === null || _obj$_readableState === undefined\n          ? undefined\n          : _obj$_readableState.readable) !== false) && // Duplex\n      (!obj._writableState || obj._readableState)\n    ) // Writable has .pipe.\n  )\n}\n\nfunction isWritableNodeStream(obj) {\n  var _obj$_writableState\n\n  return !!(\n    (\n      obj &&\n      typeof obj.write === 'function' &&\n      typeof obj.on === 'function' &&\n      (!obj._readableState ||\n        ((_obj$_writableState = obj._writableState) === null || _obj$_writableState === undefined\n          ? undefined\n          : _obj$_writableState.writable) !== false)\n    ) // Duplex\n  )\n}\n\nfunction isDuplexNodeStream(obj) {\n  return !!(\n    obj &&\n    typeof obj.pipe === 'function' &&\n    obj._readableState &&\n    typeof obj.on === 'function' &&\n    typeof obj.write === 'function'\n  )\n}\n\nfunction isNodeStream(obj) {\n  return (\n    obj &&\n    (obj._readableState ||\n      obj._writableState ||\n      (typeof obj.write === 'function' && typeof obj.on === 'function') ||\n      (typeof obj.pipe === 'function' && typeof obj.on === 'function'))\n  )\n}\n\nfunction isIterable(obj, isAsync) {\n  if (obj == null) return false\n  if (isAsync === true) return typeof obj[SymbolAsyncIterator] === 'function'\n  if (isAsync === false) return typeof obj[SymbolIterator] === 'function'\n  return typeof obj[SymbolAsyncIterator] === 'function' || typeof obj[SymbolIterator] === 'function'\n}\n\nfunction isDestroyed(stream) {\n  if (!isNodeStream(stream)) return null\n  const wState = stream._writableState\n  const rState = stream._readableState\n  const state = wState || rState\n  return !!(stream.destroyed || stream[kDestroyed] || (state !== null && state !== undefined && state.destroyed))\n} // Have been end():d.\n\nfunction isWritableEnded(stream) {\n  if (!isWritableNodeStream(stream)) return null\n  if (stream.writableEnded === true) return true\n  const wState = stream._writableState\n  if (wState !== null && wState !== undefined && wState.errored) return false\n  if (typeof (wState === null || wState === undefined ? undefined : wState.ended) !== 'boolean') return null\n  return wState.ended\n} // Have emitted 'finish'.\n\nfunction isWritableFinished(stream, strict) {\n  if (!isWritableNodeStream(stream)) return null\n  if (stream.writableFinished === true) return true\n  const wState = stream._writableState\n  if (wState !== null && wState !== undefined && wState.errored) return false\n  if (typeof (wState === null || wState === undefined ? undefined : wState.finished) !== 'boolean') return null\n  return !!(wState.finished || (strict === false && wState.ended === true && wState.length === 0))\n} // Have been push(null):d.\n\nfunction isReadableEnded(stream) {\n  if (!isReadableNodeStream(stream)) return null\n  if (stream.readableEnded === true) return true\n  const rState = stream._readableState\n  if (!rState || rState.errored) return false\n  if (typeof (rState === null || rState === undefined ? undefined : rState.ended) !== 'boolean') return null\n  return rState.ended\n} // Have emitted 'end'.\n\nfunction isReadableFinished(stream, strict) {\n  if (!isReadableNodeStream(stream)) return null\n  const rState = stream._readableState\n  if (rState !== null && rState !== undefined && rState.errored) return false\n  if (typeof (rState === null || rState === undefined ? undefined : rState.endEmitted) !== 'boolean') return null\n  return !!(rState.endEmitted || (strict === false && rState.ended === true && rState.length === 0))\n}\n\nfunction isReadable(stream) {\n  if (stream && stream[kIsReadable] != null) return stream[kIsReadable]\n  if (typeof (stream === null || stream === undefined ? undefined : stream.readable) !== 'boolean') return null\n  if (isDestroyed(stream)) return false\n  return isReadableNodeStream(stream) && stream.readable && !isReadableFinished(stream)\n}\n\nfunction isWritable(stream) {\n  if (typeof (stream === null || stream === undefined ? undefined : stream.writable) !== 'boolean') return null\n  if (isDestroyed(stream)) return false\n  return isWritableNodeStream(stream) && stream.writable && !isWritableEnded(stream)\n}\n\nfunction isFinished(stream, opts) {\n  if (!isNodeStream(stream)) {\n    return null\n  }\n\n  if (isDestroyed(stream)) {\n    return true\n  }\n\n  if ((opts === null || opts === undefined ? undefined : opts.readable) !== false && isReadable(stream)) {\n    return false\n  }\n\n  if ((opts === null || opts === undefined ? undefined : opts.writable) !== false && isWritable(stream)) {\n    return false\n  }\n\n  return true\n}\n\nfunction isWritableErrored(stream) {\n  var _stream$_writableStat, _stream$_writableStat2\n\n  if (!isNodeStream(stream)) {\n    return null\n  }\n\n  if (stream.writableErrored) {\n    return stream.writableErrored\n  }\n\n  return (_stream$_writableStat =\n    (_stream$_writableStat2 = stream._writableState) === null || _stream$_writableStat2 === undefined\n      ? undefined\n      : _stream$_writableStat2.errored) !== null && _stream$_writableStat !== undefined\n    ? _stream$_writableStat\n    : null\n}\n\nfunction isReadableErrored(stream) {\n  var _stream$_readableStat, _stream$_readableStat2\n\n  if (!isNodeStream(stream)) {\n    return null\n  }\n\n  if (stream.readableErrored) {\n    return stream.readableErrored\n  }\n\n  return (_stream$_readableStat =\n    (_stream$_readableStat2 = stream._readableState) === null || _stream$_readableStat2 === undefined\n      ? undefined\n      : _stream$_readableStat2.errored) !== null && _stream$_readableStat !== undefined\n    ? _stream$_readableStat\n    : null\n}\n\nfunction isClosed(stream) {\n  if (!isNodeStream(stream)) {\n    return null\n  }\n\n  if (typeof stream.closed === 'boolean') {\n    return stream.closed\n  }\n\n  const wState = stream._writableState\n  const rState = stream._readableState\n\n  if (\n    typeof (wState === null || wState === undefined ? undefined : wState.closed) === 'boolean' ||\n    typeof (rState === null || rState === undefined ? undefined : rState.closed) === 'boolean'\n  ) {\n    return (\n      (wState === null || wState === undefined ? undefined : wState.closed) ||\n      (rState === null || rState === undefined ? undefined : rState.closed)\n    )\n  }\n\n  if (typeof stream._closed === 'boolean' && isOutgoingMessage(stream)) {\n    return stream._closed\n  }\n\n  return null\n}\n\nfunction isOutgoingMessage(stream) {\n  return (\n    typeof stream._closed === 'boolean' &&\n    typeof stream._defaultKeepAlive === 'boolean' &&\n    typeof stream._removedConnection === 'boolean' &&\n    typeof stream._removedContLen === 'boolean'\n  )\n}\n\nfunction isServerResponse(stream) {\n  return typeof stream._sent100 === 'boolean' && isOutgoingMessage(stream)\n}\n\nfunction isServerRequest(stream) {\n  var _stream$req\n\n  return (\n    typeof stream._consuming === 'boolean' &&\n    typeof stream._dumped === 'boolean' &&\n    ((_stream$req = stream.req) === null || _stream$req === undefined ? undefined : _stream$req.upgradeOrConnect) ===\n      undefined\n  )\n}\n\nfunction willEmitClose(stream) {\n  if (!isNodeStream(stream)) return null\n  const wState = stream._writableState\n  const rState = stream._readableState\n  const state = wState || rState\n  return (\n    (!state && isServerResponse(stream)) || !!(state && state.autoDestroy && state.emitClose && state.closed === false)\n  )\n}\n\nfunction isDisturbed(stream) {\n  var _stream$kIsDisturbed\n\n  return !!(\n    stream &&\n    ((_stream$kIsDisturbed = stream[kIsDisturbed]) !== null && _stream$kIsDisturbed !== undefined\n      ? _stream$kIsDisturbed\n      : stream.readableDidRead || stream.readableAborted)\n  )\n}\n\nfunction isErrored(stream) {\n  var _ref,\n    _ref2,\n    _ref3,\n    _ref4,\n    _ref5,\n    _stream$kIsErrored,\n    _stream$_readableStat3,\n    _stream$_writableStat3,\n    _stream$_readableStat4,\n    _stream$_writableStat4\n\n  return !!(\n    stream &&\n    ((_ref =\n      (_ref2 =\n        (_ref3 =\n          (_ref4 =\n            (_ref5 =\n              (_stream$kIsErrored = stream[kIsErrored]) !== null && _stream$kIsErrored !== undefined\n                ? _stream$kIsErrored\n                : stream.readableErrored) !== null && _ref5 !== undefined\n              ? _ref5\n              : stream.writableErrored) !== null && _ref4 !== undefined\n            ? _ref4\n            : (_stream$_readableStat3 = stream._readableState) === null || _stream$_readableStat3 === undefined\n            ? undefined\n            : _stream$_readableStat3.errorEmitted) !== null && _ref3 !== undefined\n          ? _ref3\n          : (_stream$_writableStat3 = stream._writableState) === null || _stream$_writableStat3 === undefined\n          ? undefined\n          : _stream$_writableStat3.errorEmitted) !== null && _ref2 !== undefined\n        ? _ref2\n        : (_stream$_readableStat4 = stream._readableState) === null || _stream$_readableStat4 === undefined\n        ? undefined\n        : _stream$_readableStat4.errored) !== null && _ref !== undefined\n      ? _ref\n      : (_stream$_writableStat4 = stream._writableState) === null || _stream$_writableStat4 === undefined\n      ? undefined\n      : _stream$_writableStat4.errored)\n  )\n}\n\nmodule.exports = {\n  kDestroyed,\n  isDisturbed,\n  kIsDisturbed,\n  isErrored,\n  kIsErrored,\n  isReadable,\n  kIsReadable,\n  isClosed,\n  isDestroyed,\n  isDuplexNodeStream,\n  isFinished,\n  isIterable,\n  isReadableNodeStream,\n  isReadableEnded,\n  isReadableFinished,\n  isReadableErrored,\n  isNodeStream,\n  isWritable,\n  isWritableNodeStream,\n  isWritableEnded,\n  isWritableFinished,\n  isWritableErrored,\n  isServerRequest,\n  isServerResponse,\n  willEmitClose\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/streams/writable.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n// A bit simpler than readable streams.\n// Implement an async ._write(chunk, encoding, cb), and it'll handle all\n// the drain event emission and buffering.\n'use strict'\n\nconst {\n  ArrayPrototypeSlice,\n  Error,\n  FunctionPrototypeSymbolHasInstance,\n  ObjectDefineProperty,\n  ObjectDefineProperties,\n  ObjectSetPrototypeOf,\n  StringPrototypeToLowerCase,\n  Symbol,\n  SymbolHasInstance\n} = require('../../ours/primordials')\n\nmodule.exports = Writable\nWritable.WritableState = WritableState\n\nconst { EventEmitter: EE } = require('events')\n\nconst Stream = require('./legacy').Stream\n\nconst destroyImpl = require('./destroy')\n\nconst { addAbortSignal } = require('./add-abort-signal')\n\nconst { getHighWaterMark, getDefaultHighWaterMark } = require('./state')\n\nconst {\n  ERR_INVALID_ARG_TYPE,\n  ERR_METHOD_NOT_IMPLEMENTED,\n  ERR_MULTIPLE_CALLBACK,\n  ERR_STREAM_CANNOT_PIPE,\n  ERR_STREAM_DESTROYED,\n  ERR_STREAM_ALREADY_FINISHED,\n  ERR_STREAM_NULL_VALUES,\n  ERR_STREAM_WRITE_AFTER_END,\n  ERR_UNKNOWN_ENCODING\n} = require('../../ours/errors').codes\n\nconst { errorOrDestroy } = destroyImpl\nObjectSetPrototypeOf(Writable.prototype, Stream.prototype)\nObjectSetPrototypeOf(Writable, Stream)\n\nfunction nop() {}\n\nconst kOnFinished = Symbol('kOnFinished')\n\nfunction WritableState(options, stream, isDuplex) {\n  // Duplex streams are both readable and writable, but share\n  // the same options object.\n  // However, some cases require setting options to different\n  // values for the readable and the writable sides of the duplex stream,\n  // e.g. options.readableObjectMode vs. options.writableObjectMode, etc.\n  if (typeof isDuplex !== 'boolean') isDuplex = stream instanceof require('./duplex') // Object stream flag to indicate whether or not this stream\n  // contains buffers or objects.\n\n  this.objectMode = !!(options && options.objectMode)\n  if (isDuplex) this.objectMode = this.objectMode || !!(options && options.writableObjectMode) // The point at which write() starts returning false\n  // Note: 0 is a valid value, means that we always return false if\n  // the entire buffer is not flushed immediately on write().\n\n  this.highWaterMark = options\n    ? getHighWaterMark(this, options, 'writableHighWaterMark', isDuplex)\n    : getDefaultHighWaterMark(false) // if _final has been called.\n\n  this.finalCalled = false // drain event flag.\n\n  this.needDrain = false // At the start of calling end()\n\n  this.ending = false // When end() has been called, and returned.\n\n  this.ended = false // When 'finish' is emitted.\n\n  this.finished = false // Has it been destroyed\n\n  this.destroyed = false // Should we decode strings into buffers before passing to _write?\n  // this is here so that some node-core streams can optimize string\n  // handling at a lower level.\n\n  const noDecode = !!(options && options.decodeStrings === false)\n  this.decodeStrings = !noDecode // Crypto is kind of old and crusty.  Historically, its default string\n  // encoding is 'binary' so we have to make this configurable.\n  // Everything else in the universe uses 'utf8', though.\n\n  this.defaultEncoding = (options && options.defaultEncoding) || 'utf8' // Not an actual buffer we keep track of, but a measurement\n  // of how much we're waiting to get pushed to some underlying\n  // socket or file.\n\n  this.length = 0 // A flag to see when we're in the middle of a write.\n\n  this.writing = false // When true all writes will be buffered until .uncork() call.\n\n  this.corked = 0 // A flag to be able to tell if the onwrite cb is called immediately,\n  // or on a later tick.  We set this to true at first, because any\n  // actions that shouldn't happen until \"later\" should generally also\n  // not happen before the first write call.\n\n  this.sync = true // A flag to know if we're processing previously buffered items, which\n  // may call the _write() callback in the same tick, so that we don't\n  // end up in an overlapped onwrite situation.\n\n  this.bufferProcessing = false // The callback that's passed to _write(chunk, cb).\n\n  this.onwrite = onwrite.bind(undefined, stream) // The callback that the user supplies to write(chunk, encoding, cb).\n\n  this.writecb = null // The amount that is being written when _write is called.\n\n  this.writelen = 0 // Storage for data passed to the afterWrite() callback in case of\n  // synchronous _write() completion.\n\n  this.afterWriteTickInfo = null\n  resetBuffer(this) // Number of pending user-supplied write callbacks\n  // this must be 0 before 'finish' can be emitted.\n\n  this.pendingcb = 0 // Stream is still being constructed and cannot be\n  // destroyed until construction finished or failed.\n  // Async construction is opt in, therefore we start as\n  // constructed.\n\n  this.constructed = true // Emit prefinish if the only thing we're waiting for is _write cbs\n  // This is relevant for synchronous Transform streams.\n\n  this.prefinished = false // True if the error was already emitted and should not be thrown again.\n\n  this.errorEmitted = false // Should close be emitted on destroy. Defaults to true.\n\n  this.emitClose = !options || options.emitClose !== false // Should .destroy() be called after 'finish' (and potentially 'end').\n\n  this.autoDestroy = !options || options.autoDestroy !== false // Indicates whether the stream has errored. When true all write() calls\n  // should return false. This is needed since when autoDestroy\n  // is disabled we need a way to tell whether the stream has failed.\n\n  this.errored = null // Indicates whether the stream has finished destroying.\n\n  this.closed = false // True if close has been emitted or would have been emitted\n  // depending on emitClose.\n\n  this.closeEmitted = false\n  this[kOnFinished] = []\n}\n\nfunction resetBuffer(state) {\n  state.buffered = []\n  state.bufferedIndex = 0\n  state.allBuffers = true\n  state.allNoop = true\n}\n\nWritableState.prototype.getBuffer = function getBuffer() {\n  return ArrayPrototypeSlice(this.buffered, this.bufferedIndex)\n}\n\nObjectDefineProperty(WritableState.prototype, 'bufferedRequestCount', {\n  get() {\n    return this.buffered.length - this.bufferedIndex\n  }\n})\n\nfunction Writable(options) {\n  // Writable ctor is applied to Duplexes, too.\n  // `realHasInstance` is necessary because using plain `instanceof`\n  // would return false, as no `_writableState` property is attached.\n  // Trying to use the custom `instanceof` for Writable here will also break the\n  // Node.js LazyTransform implementation, which has a non-trivial getter for\n  // `_writableState` that would lead to infinite recursion.\n  // Checking for a Stream.Duplex instance is faster here instead of inside\n  // the WritableState constructor, at least with V8 6.5.\n  const isDuplex = this instanceof require('./duplex')\n\n  if (!isDuplex && !FunctionPrototypeSymbolHasInstance(Writable, this)) return new Writable(options)\n  this._writableState = new WritableState(options, this, isDuplex)\n\n  if (options) {\n    if (typeof options.write === 'function') this._write = options.write\n    if (typeof options.writev === 'function') this._writev = options.writev\n    if (typeof options.destroy === 'function') this._destroy = options.destroy\n    if (typeof options.final === 'function') this._final = options.final\n    if (typeof options.construct === 'function') this._construct = options.construct\n    if (options.signal) addAbortSignal(options.signal, this)\n  }\n\n  Stream.call(this, options)\n  destroyImpl.construct(this, () => {\n    const state = this._writableState\n\n    if (!state.writing) {\n      clearBuffer(this, state)\n    }\n\n    finishMaybe(this, state)\n  })\n}\n\nObjectDefineProperty(Writable, SymbolHasInstance, {\n  value: function (object) {\n    if (FunctionPrototypeSymbolHasInstance(this, object)) return true\n    if (this !== Writable) return false\n    return object && object._writableState instanceof WritableState\n  }\n}) // Otherwise people can pipe Writable streams, which is just wrong.\n\nWritable.prototype.pipe = function () {\n  errorOrDestroy(this, new ERR_STREAM_CANNOT_PIPE())\n}\n\nfunction _write(stream, chunk, encoding, cb) {\n  const state = stream._writableState\n\n  if (typeof encoding === 'function') {\n    cb = encoding\n    encoding = state.defaultEncoding\n  } else {\n    if (!encoding) encoding = state.defaultEncoding\n    else if (encoding !== 'buffer' && !Buffer.isEncoding(encoding)) throw new ERR_UNKNOWN_ENCODING(encoding)\n    if (typeof cb !== 'function') cb = nop\n  }\n\n  if (chunk === null) {\n    throw new ERR_STREAM_NULL_VALUES()\n  } else if (!state.objectMode) {\n    if (typeof chunk === 'string') {\n      if (state.decodeStrings !== false) {\n        chunk = Buffer.from(chunk, encoding)\n        encoding = 'buffer'\n      }\n    } else if (chunk instanceof Buffer) {\n      encoding = 'buffer'\n    } else if (Stream._isUint8Array(chunk)) {\n      chunk = Stream._uint8ArrayToBuffer(chunk)\n      encoding = 'buffer'\n    } else {\n      throw new ERR_INVALID_ARG_TYPE('chunk', ['string', 'Buffer', 'Uint8Array'], chunk)\n    }\n  }\n\n  let err\n\n  if (state.ending) {\n    err = new ERR_STREAM_WRITE_AFTER_END()\n  } else if (state.destroyed) {\n    err = new ERR_STREAM_DESTROYED('write')\n  }\n\n  if (err) {\n    process.nextTick(cb, err)\n    errorOrDestroy(stream, err, true)\n    return err\n  }\n\n  state.pendingcb++\n  return writeOrBuffer(stream, state, chunk, encoding, cb)\n}\n\nWritable.prototype.write = function (chunk, encoding, cb) {\n  return _write(this, chunk, encoding, cb) === true\n}\n\nWritable.prototype.cork = function () {\n  this._writableState.corked++\n}\n\nWritable.prototype.uncork = function () {\n  const state = this._writableState\n\n  if (state.corked) {\n    state.corked--\n    if (!state.writing) clearBuffer(this, state)\n  }\n}\n\nWritable.prototype.setDefaultEncoding = function setDefaultEncoding(encoding) {\n  // node::ParseEncoding() requires lower case.\n  if (typeof encoding === 'string') encoding = StringPrototypeToLowerCase(encoding)\n  if (!Buffer.isEncoding(encoding)) throw new ERR_UNKNOWN_ENCODING(encoding)\n  this._writableState.defaultEncoding = encoding\n  return this\n} // If we're already writing something, then just put this\n// in the queue, and wait our turn.  Otherwise, call _write\n// If we return false, then we need a drain event, so set that flag.\n\nfunction writeOrBuffer(stream, state, chunk, encoding, callback) {\n  const len = state.objectMode ? 1 : chunk.length\n  state.length += len // stream._write resets state.length\n\n  const ret = state.length < state.highWaterMark // We must ensure that previous needDrain will not be reset to false.\n\n  if (!ret) state.needDrain = true\n\n  if (state.writing || state.corked || state.errored || !state.constructed) {\n    state.buffered.push({\n      chunk,\n      encoding,\n      callback\n    })\n\n    if (state.allBuffers && encoding !== 'buffer') {\n      state.allBuffers = false\n    }\n\n    if (state.allNoop && callback !== nop) {\n      state.allNoop = false\n    }\n  } else {\n    state.writelen = len\n    state.writecb = callback\n    state.writing = true\n    state.sync = true\n\n    stream._write(chunk, encoding, state.onwrite)\n\n    state.sync = false\n  } // Return false if errored or destroyed in order to break\n  // any synchronous while(stream.write(data)) loops.\n\n  return ret && !state.errored && !state.destroyed\n}\n\nfunction doWrite(stream, state, writev, len, chunk, encoding, cb) {\n  state.writelen = len\n  state.writecb = cb\n  state.writing = true\n  state.sync = true\n  if (state.destroyed) state.onwrite(new ERR_STREAM_DESTROYED('write'))\n  else if (writev) stream._writev(chunk, state.onwrite)\n  else stream._write(chunk, encoding, state.onwrite)\n  state.sync = false\n}\n\nfunction onwriteError(stream, state, er, cb) {\n  --state.pendingcb\n  cb(er) // Ensure callbacks are invoked even when autoDestroy is\n  // not enabled. Passing `er` here doesn't make sense since\n  // it's related to one specific write, not to the buffered\n  // writes.\n\n  errorBuffer(state) // This can emit error, but error must always follow cb.\n\n  errorOrDestroy(stream, er)\n}\n\nfunction onwrite(stream, er) {\n  const state = stream._writableState\n  const sync = state.sync\n  const cb = state.writecb\n\n  if (typeof cb !== 'function') {\n    errorOrDestroy(stream, new ERR_MULTIPLE_CALLBACK())\n    return\n  }\n\n  state.writing = false\n  state.writecb = null\n  state.length -= state.writelen\n  state.writelen = 0\n\n  if (er) {\n    // Avoid V8 leak, https://github.com/nodejs/node/pull/34103#issuecomment-652002364\n    er.stack // eslint-disable-line no-unused-expressions\n\n    if (!state.errored) {\n      state.errored = er\n    } // In case of duplex streams we need to notify the readable side of the\n    // error.\n\n    if (stream._readableState && !stream._readableState.errored) {\n      stream._readableState.errored = er\n    }\n\n    if (sync) {\n      process.nextTick(onwriteError, stream, state, er, cb)\n    } else {\n      onwriteError(stream, state, er, cb)\n    }\n  } else {\n    if (state.buffered.length > state.bufferedIndex) {\n      clearBuffer(stream, state)\n    }\n\n    if (sync) {\n      // It is a common case that the callback passed to .write() is always\n      // the same. In that case, we do not schedule a new nextTick(), but\n      // rather just increase a counter, to improve performance and avoid\n      // memory allocations.\n      if (state.afterWriteTickInfo !== null && state.afterWriteTickInfo.cb === cb) {\n        state.afterWriteTickInfo.count++\n      } else {\n        state.afterWriteTickInfo = {\n          count: 1,\n          cb,\n          stream,\n          state\n        }\n        process.nextTick(afterWriteTick, state.afterWriteTickInfo)\n      }\n    } else {\n      afterWrite(stream, state, 1, cb)\n    }\n  }\n}\n\nfunction afterWriteTick({ stream, state, count, cb }) {\n  state.afterWriteTickInfo = null\n  return afterWrite(stream, state, count, cb)\n}\n\nfunction afterWrite(stream, state, count, cb) {\n  const needDrain = !state.ending && !stream.destroyed && state.length === 0 && state.needDrain\n\n  if (needDrain) {\n    state.needDrain = false\n    stream.emit('drain')\n  }\n\n  while (count-- > 0) {\n    state.pendingcb--\n    cb()\n  }\n\n  if (state.destroyed) {\n    errorBuffer(state)\n  }\n\n  finishMaybe(stream, state)\n} // If there's something in the buffer waiting, then invoke callbacks.\n\nfunction errorBuffer(state) {\n  if (state.writing) {\n    return\n  }\n\n  for (let n = state.bufferedIndex; n < state.buffered.length; ++n) {\n    var _state$errored\n\n    const { chunk, callback } = state.buffered[n]\n    const len = state.objectMode ? 1 : chunk.length\n    state.length -= len\n    callback(\n      (_state$errored = state.errored) !== null && _state$errored !== undefined\n        ? _state$errored\n        : new ERR_STREAM_DESTROYED('write')\n    )\n  }\n\n  const onfinishCallbacks = state[kOnFinished].splice(0)\n\n  for (let i = 0; i < onfinishCallbacks.length; i++) {\n    var _state$errored2\n\n    onfinishCallbacks[i](\n      (_state$errored2 = state.errored) !== null && _state$errored2 !== undefined\n        ? _state$errored2\n        : new ERR_STREAM_DESTROYED('end')\n    )\n  }\n\n  resetBuffer(state)\n} // If there's something in the buffer waiting, then process it.\n\nfunction clearBuffer(stream, state) {\n  if (state.corked || state.bufferProcessing || state.destroyed || !state.constructed) {\n    return\n  }\n\n  const { buffered, bufferedIndex, objectMode } = state\n  const bufferedLength = buffered.length - bufferedIndex\n\n  if (!bufferedLength) {\n    return\n  }\n\n  let i = bufferedIndex\n  state.bufferProcessing = true\n\n  if (bufferedLength > 1 && stream._writev) {\n    state.pendingcb -= bufferedLength - 1\n    const callback = state.allNoop\n      ? nop\n      : (err) => {\n          for (let n = i; n < buffered.length; ++n) {\n            buffered[n].callback(err)\n          }\n        } // Make a copy of `buffered` if it's going to be used by `callback` above,\n    // since `doWrite` will mutate the array.\n\n    const chunks = state.allNoop && i === 0 ? buffered : ArrayPrototypeSlice(buffered, i)\n    chunks.allBuffers = state.allBuffers\n    doWrite(stream, state, true, state.length, chunks, '', callback)\n    resetBuffer(state)\n  } else {\n    do {\n      const { chunk, encoding, callback } = buffered[i]\n      buffered[i++] = null\n      const len = objectMode ? 1 : chunk.length\n      doWrite(stream, state, false, len, chunk, encoding, callback)\n    } while (i < buffered.length && !state.writing)\n\n    if (i === buffered.length) {\n      resetBuffer(state)\n    } else if (i > 256) {\n      buffered.splice(0, i)\n      state.bufferedIndex = 0\n    } else {\n      state.bufferedIndex = i\n    }\n  }\n\n  state.bufferProcessing = false\n}\n\nWritable.prototype._write = function (chunk, encoding, cb) {\n  if (this._writev) {\n    this._writev(\n      [\n        {\n          chunk,\n          encoding\n        }\n      ],\n      cb\n    )\n  } else {\n    throw new ERR_METHOD_NOT_IMPLEMENTED('_write()')\n  }\n}\n\nWritable.prototype._writev = null\n\nWritable.prototype.end = function (chunk, encoding, cb) {\n  const state = this._writableState\n\n  if (typeof chunk === 'function') {\n    cb = chunk\n    chunk = null\n    encoding = null\n  } else if (typeof encoding === 'function') {\n    cb = encoding\n    encoding = null\n  }\n\n  let err\n\n  if (chunk !== null && chunk !== undefined) {\n    const ret = _write(this, chunk, encoding)\n\n    if (ret instanceof Error) {\n      err = ret\n    }\n  } // .end() fully uncorks.\n\n  if (state.corked) {\n    state.corked = 1\n    this.uncork()\n  }\n\n  if (err) {\n    // Do nothing...\n  } else if (!state.errored && !state.ending) {\n    // This is forgiving in terms of unnecessary calls to end() and can hide\n    // logic errors. However, usually such errors are harmless and causing a\n    // hard error can be disproportionately destructive. It is not always\n    // trivial for the user to determine whether end() needs to be called\n    // or not.\n    state.ending = true\n    finishMaybe(this, state, true)\n    state.ended = true\n  } else if (state.finished) {\n    err = new ERR_STREAM_ALREADY_FINISHED('end')\n  } else if (state.destroyed) {\n    err = new ERR_STREAM_DESTROYED('end')\n  }\n\n  if (typeof cb === 'function') {\n    if (err || state.finished) {\n      process.nextTick(cb, err)\n    } else {\n      state[kOnFinished].push(cb)\n    }\n  }\n\n  return this\n}\n\nfunction needFinish(state) {\n  return (\n    state.ending &&\n    !state.destroyed &&\n    state.constructed &&\n    state.length === 0 &&\n    !state.errored &&\n    state.buffered.length === 0 &&\n    !state.finished &&\n    !state.writing &&\n    !state.errorEmitted &&\n    !state.closeEmitted\n  )\n}\n\nfunction callFinal(stream, state) {\n  let called = false\n\n  function onFinish(err) {\n    if (called) {\n      errorOrDestroy(stream, err !== null && err !== undefined ? err : ERR_MULTIPLE_CALLBACK())\n      return\n    }\n\n    called = true\n    state.pendingcb--\n\n    if (err) {\n      const onfinishCallbacks = state[kOnFinished].splice(0)\n\n      for (let i = 0; i < onfinishCallbacks.length; i++) {\n        onfinishCallbacks[i](err)\n      }\n\n      errorOrDestroy(stream, err, state.sync)\n    } else if (needFinish(state)) {\n      state.prefinished = true\n      stream.emit('prefinish') // Backwards compat. Don't check state.sync here.\n      // Some streams assume 'finish' will be emitted\n      // asynchronously relative to _final callback.\n\n      state.pendingcb++\n      process.nextTick(finish, stream, state)\n    }\n  }\n\n  state.sync = true\n  state.pendingcb++\n\n  try {\n    stream._final(onFinish)\n  } catch (err) {\n    onFinish(err)\n  }\n\n  state.sync = false\n}\n\nfunction prefinish(stream, state) {\n  if (!state.prefinished && !state.finalCalled) {\n    if (typeof stream._final === 'function' && !state.destroyed) {\n      state.finalCalled = true\n      callFinal(stream, state)\n    } else {\n      state.prefinished = true\n      stream.emit('prefinish')\n    }\n  }\n}\n\nfunction finishMaybe(stream, state, sync) {\n  if (needFinish(state)) {\n    prefinish(stream, state)\n\n    if (state.pendingcb === 0) {\n      if (sync) {\n        state.pendingcb++\n        process.nextTick(\n          (stream, state) => {\n            if (needFinish(state)) {\n              finish(stream, state)\n            } else {\n              state.pendingcb--\n            }\n          },\n          stream,\n          state\n        )\n      } else if (needFinish(state)) {\n        state.pendingcb++\n        finish(stream, state)\n      }\n    }\n  }\n}\n\nfunction finish(stream, state) {\n  state.pendingcb--\n  state.finished = true\n  const onfinishCallbacks = state[kOnFinished].splice(0)\n\n  for (let i = 0; i < onfinishCallbacks.length; i++) {\n    onfinishCallbacks[i]()\n  }\n\n  stream.emit('finish')\n\n  if (state.autoDestroy) {\n    // In case of duplex streams we need a way to detect\n    // if the readable side is ready for autoDestroy as well.\n    const rState = stream._readableState\n    const autoDestroy =\n      !rState ||\n      (rState.autoDestroy && // We don't expect the readable to ever 'end'\n        // if readable is explicitly set to false.\n        (rState.endEmitted || rState.readable === false))\n\n    if (autoDestroy) {\n      stream.destroy()\n    }\n  }\n}\n\nObjectDefineProperties(Writable.prototype, {\n  closed: {\n    get() {\n      return this._writableState ? this._writableState.closed : false\n    }\n  },\n  destroyed: {\n    get() {\n      return this._writableState ? this._writableState.destroyed : false\n    },\n\n    set(value) {\n      // Backward compatibility, the user is explicitly managing destroyed.\n      if (this._writableState) {\n        this._writableState.destroyed = value\n      }\n    }\n  },\n  writable: {\n    get() {\n      const w = this._writableState // w.writable === false means that this is part of a Duplex stream\n      // where the writable side was disabled upon construction.\n      // Compat. The user might manually disable writable side through\n      // deprecated setter.\n\n      return !!w && w.writable !== false && !w.destroyed && !w.errored && !w.ending && !w.ended\n    },\n\n    set(val) {\n      // Backwards compatible.\n      if (this._writableState) {\n        this._writableState.writable = !!val\n      }\n    }\n  },\n  writableFinished: {\n    get() {\n      return this._writableState ? this._writableState.finished : false\n    }\n  },\n  writableObjectMode: {\n    get() {\n      return this._writableState ? this._writableState.objectMode : false\n    }\n  },\n  writableBuffer: {\n    get() {\n      return this._writableState && this._writableState.getBuffer()\n    }\n  },\n  writableEnded: {\n    get() {\n      return this._writableState ? this._writableState.ending : false\n    }\n  },\n  writableNeedDrain: {\n    get() {\n      const wState = this._writableState\n      if (!wState) return false\n      return !wState.destroyed && !wState.ending && wState.needDrain\n    }\n  },\n  writableHighWaterMark: {\n    get() {\n      return this._writableState && this._writableState.highWaterMark\n    }\n  },\n  writableCorked: {\n    get() {\n      return this._writableState ? this._writableState.corked : 0\n    }\n  },\n  writableLength: {\n    get() {\n      return this._writableState && this._writableState.length\n    }\n  },\n  errored: {\n    enumerable: false,\n\n    get() {\n      return this._writableState ? this._writableState.errored : null\n    }\n  },\n  writableAborted: {\n    enumerable: false,\n    get: function () {\n      return !!(\n        this._writableState.writable !== false &&\n        (this._writableState.destroyed || this._writableState.errored) &&\n        !this._writableState.finished\n      )\n    }\n  }\n})\nconst destroy = destroyImpl.destroy\n\nWritable.prototype.destroy = function (err, cb) {\n  const state = this._writableState // Invoke pending callbacks.\n\n  if (!state.destroyed && (state.bufferedIndex < state.buffered.length || state[kOnFinished].length)) {\n    process.nextTick(errorBuffer, state)\n  }\n\n  destroy.call(this, err, cb)\n  return this\n}\n\nWritable.prototype._undestroy = destroyImpl.undestroy\n\nWritable.prototype._destroy = function (err, cb) {\n  cb(err)\n}\n\nWritable.prototype[EE.captureRejectionSymbol] = function (err) {\n  this.destroy(err)\n}\n\nlet webStreamsAdapters // Lazy to avoid circular references\n\nfunction lazyWebStreams() {\n  if (webStreamsAdapters === undefined) webStreamsAdapters = {}\n  return webStreamsAdapters\n}\n\nWritable.fromWeb = function (writableStream, options) {\n  return lazyWebStreams().newStreamWritableFromWritableStream(writableStream, options)\n}\n\nWritable.toWeb = function (streamWritable) {\n  return lazyWebStreams().newWritableStreamFromStreamWritable(streamWritable)\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/internal/validators.js",
    "content": "'use strict'\n\nconst {\n  ArrayIsArray,\n  ArrayPrototypeIncludes,\n  ArrayPrototypeJoin,\n  ArrayPrototypeMap,\n  NumberIsInteger,\n  NumberMAX_SAFE_INTEGER,\n  NumberMIN_SAFE_INTEGER,\n  NumberParseInt,\n  RegExpPrototypeTest,\n  String,\n  StringPrototypeToUpperCase,\n  StringPrototypeTrim\n} = require('../ours/primordials')\n\nconst {\n  hideStackFrames,\n  codes: { ERR_SOCKET_BAD_PORT, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_OUT_OF_RANGE, ERR_UNKNOWN_SIGNAL }\n} = require('../ours/errors')\n\nconst { normalizeEncoding } = require('../ours/util')\n\nconst { isAsyncFunction, isArrayBufferView } = require('../ours/util').types\n\nconst signals = {}\n\nfunction isInt32(value) {\n  return value === (value | 0)\n}\n\nfunction isUint32(value) {\n  return value === value >>> 0\n}\n\nconst octalReg = /^[0-7]+$/\nconst modeDesc = 'must be a 32-bit unsigned integer or an octal string'\n/**\n * Parse and validate values that will be converted into mode_t (the S_*\n * constants). Only valid numbers and octal strings are allowed. They could be\n * converted to 32-bit unsigned integers or non-negative signed integers in the\n * C++ land, but any value higher than 0o777 will result in platform-specific\n * behaviors.\n *\n * @param {*} value Values to be validated\n * @param {string} name Name of the argument\n * @param {number} [def] If specified, will be returned for invalid values\n * @returns {number}\n */\n\nfunction parseFileMode(value, name, def) {\n  if (typeof value === 'undefined') {\n    value = def\n  }\n\n  if (typeof value === 'string') {\n    if (!RegExpPrototypeTest(octalReg, value)) {\n      throw new ERR_INVALID_ARG_VALUE(name, value, modeDesc)\n    }\n\n    value = NumberParseInt(value, 8)\n  }\n\n  validateInt32(value, name, 0, 2 ** 32 - 1)\n  return value\n}\n\nconst validateInteger = hideStackFrames((value, name, min = NumberMIN_SAFE_INTEGER, max = NumberMAX_SAFE_INTEGER) => {\n  if (typeof value !== 'number') throw new ERR_INVALID_ARG_TYPE(name, 'number', value)\n  if (!NumberIsInteger(value)) throw new ERR_OUT_OF_RANGE(name, 'an integer', value)\n  if (value < min || value > max) throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value)\n})\nconst validateInt32 = hideStackFrames((value, name, min = -2147483648, max = 2147483647) => {\n  // The defaults for min and max correspond to the limits of 32-bit integers.\n  if (typeof value !== 'number') {\n    throw new ERR_INVALID_ARG_TYPE(name, 'number', value)\n  }\n\n  if (!isInt32(value)) {\n    if (!NumberIsInteger(value)) {\n      throw new ERR_OUT_OF_RANGE(name, 'an integer', value)\n    }\n\n    throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value)\n  }\n\n  if (value < min || value > max) {\n    throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value)\n  }\n})\nconst validateUint32 = hideStackFrames((value, name, positive) => {\n  if (typeof value !== 'number') {\n    throw new ERR_INVALID_ARG_TYPE(name, 'number', value)\n  }\n\n  if (!isUint32(value)) {\n    if (!NumberIsInteger(value)) {\n      throw new ERR_OUT_OF_RANGE(name, 'an integer', value)\n    }\n\n    const min = positive ? 1 : 0 // 2 ** 32 === 4294967296\n\n    throw new ERR_OUT_OF_RANGE(name, `>= ${min} && < 4294967296`, value)\n  }\n\n  if (positive && value === 0) {\n    throw new ERR_OUT_OF_RANGE(name, '>= 1 && < 4294967296', value)\n  }\n})\n\nfunction validateString(value, name) {\n  if (typeof value !== 'string') throw new ERR_INVALID_ARG_TYPE(name, 'string', value)\n}\n\nfunction validateNumber(value, name) {\n  if (typeof value !== 'number') throw new ERR_INVALID_ARG_TYPE(name, 'number', value)\n}\n\nconst validateOneOf = hideStackFrames((value, name, oneOf) => {\n  if (!ArrayPrototypeIncludes(oneOf, value)) {\n    const allowed = ArrayPrototypeJoin(\n      ArrayPrototypeMap(oneOf, (v) => (typeof v === 'string' ? `'${v}'` : String(v))),\n      ', '\n    )\n    const reason = 'must be one of: ' + allowed\n    throw new ERR_INVALID_ARG_VALUE(name, value, reason)\n  }\n})\n\nfunction validateBoolean(value, name) {\n  if (typeof value !== 'boolean') throw new ERR_INVALID_ARG_TYPE(name, 'boolean', value)\n}\n/**\n * @param {unknown} value\n * @param {string} name\n * @param {{\n *   allowArray?: boolean,\n *   allowFunction?: boolean,\n *   nullable?: boolean\n * }} [options]\n */\n\nconst validateObject = hideStackFrames((value, name, options) => {\n  const useDefaultOptions = options == null\n  const allowArray = useDefaultOptions ? false : options.allowArray\n  const allowFunction = useDefaultOptions ? false : options.allowFunction\n  const nullable = useDefaultOptions ? false : options.nullable\n\n  if (\n    (!nullable && value === null) ||\n    (!allowArray && ArrayIsArray(value)) ||\n    (typeof value !== 'object' && (!allowFunction || typeof value !== 'function'))\n  ) {\n    throw new ERR_INVALID_ARG_TYPE(name, 'Object', value)\n  }\n})\nconst validateArray = hideStackFrames((value, name, minLength = 0) => {\n  if (!ArrayIsArray(value)) {\n    throw new ERR_INVALID_ARG_TYPE(name, 'Array', value)\n  }\n\n  if (value.length < minLength) {\n    const reason = `must be longer than ${minLength}`\n    throw new ERR_INVALID_ARG_VALUE(name, value, reason)\n  }\n})\n\nfunction validateSignalName(signal, name = 'signal') {\n  validateString(signal, name)\n\n  if (signals[signal] === undefined) {\n    if (signals[StringPrototypeToUpperCase(signal)] !== undefined) {\n      throw new ERR_UNKNOWN_SIGNAL(signal + ' (signals must use all capital letters)')\n    }\n\n    throw new ERR_UNKNOWN_SIGNAL(signal)\n  }\n}\n\nconst validateBuffer = hideStackFrames((buffer, name = 'buffer') => {\n  if (!isArrayBufferView(buffer)) {\n    throw new ERR_INVALID_ARG_TYPE(name, ['Buffer', 'TypedArray', 'DataView'], buffer)\n  }\n})\n\nfunction validateEncoding(data, encoding) {\n  const normalizedEncoding = normalizeEncoding(encoding)\n  const length = data.length\n\n  if (normalizedEncoding === 'hex' && length % 2 !== 0) {\n    throw new ERR_INVALID_ARG_VALUE('encoding', encoding, `is invalid for data of length ${length}`)\n  }\n} // Check that the port number is not NaN when coerced to a number,\n// is an integer and that it falls within the legal range of port numbers.\n\nfunction validatePort(port, name = 'Port', allowZero = true) {\n  if (\n    (typeof port !== 'number' && typeof port !== 'string') ||\n    (typeof port === 'string' && StringPrototypeTrim(port).length === 0) ||\n    +port !== +port >>> 0 ||\n    port > 0xffff ||\n    (port === 0 && !allowZero)\n  ) {\n    throw new ERR_SOCKET_BAD_PORT(name, port, allowZero)\n  }\n\n  return port | 0\n}\n\nconst validateAbortSignal = hideStackFrames((signal, name) => {\n  if (signal !== undefined && (signal === null || typeof signal !== 'object' || !('aborted' in signal))) {\n    throw new ERR_INVALID_ARG_TYPE(name, 'AbortSignal', signal)\n  }\n})\nconst validateFunction = hideStackFrames((value, name) => {\n  if (typeof value !== 'function') throw new ERR_INVALID_ARG_TYPE(name, 'Function', value)\n})\nconst validatePlainFunction = hideStackFrames((value, name) => {\n  if (typeof value !== 'function' || isAsyncFunction(value)) throw new ERR_INVALID_ARG_TYPE(name, 'Function', value)\n})\nconst validateUndefined = hideStackFrames((value, name) => {\n  if (value !== undefined) throw new ERR_INVALID_ARG_TYPE(name, 'undefined', value)\n})\nmodule.exports = {\n  isInt32,\n  isUint32,\n  parseFileMode,\n  validateArray,\n  validateBoolean,\n  validateBuffer,\n  validateEncoding,\n  validateFunction,\n  validateInt32,\n  validateInteger,\n  validateNumber,\n  validateObject,\n  validateOneOf,\n  validatePlainFunction,\n  validatePort,\n  validateSignalName,\n  validateString,\n  validateUint32,\n  validateUndefined,\n  validateAbortSignal\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/ours/browser.js",
    "content": "'use strict'\n\nconst CustomStream = require('../stream')\n\nconst promises = require('../stream/promises')\n\nconst originalDestroy = CustomStream.Readable.destroy\nmodule.exports = CustomStream.Readable // Explicit export naming is needed for ESM\n\nmodule.exports._uint8ArrayToBuffer = CustomStream._uint8ArrayToBuffer\nmodule.exports._isUint8Array = CustomStream._isUint8Array\nmodule.exports.isDisturbed = CustomStream.isDisturbed\nmodule.exports.isErrored = CustomStream.isErrored\nmodule.exports.isReadable = CustomStream.isReadable\nmodule.exports.Readable = CustomStream.Readable\nmodule.exports.Writable = CustomStream.Writable\nmodule.exports.Duplex = CustomStream.Duplex\nmodule.exports.Transform = CustomStream.Transform\nmodule.exports.PassThrough = CustomStream.PassThrough\nmodule.exports.addAbortSignal = CustomStream.addAbortSignal\nmodule.exports.finished = CustomStream.finished\nmodule.exports.destroy = CustomStream.destroy\nmodule.exports.destroy = originalDestroy\nmodule.exports.pipeline = CustomStream.pipeline\nmodule.exports.compose = CustomStream.compose\nObject.defineProperty(CustomStream, 'promises', {\n  configurable: true,\n  enumerable: true,\n\n  get() {\n    return promises\n  }\n})\nmodule.exports.Stream = CustomStream.Stream // Allow default importing\n\nmodule.exports.default = module.exports\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/ours/errors.js",
    "content": "'use strict'\n\nconst { format, inspect, AggregateError: CustomAggregateError } = require('./util')\n/*\n  This file is a reduced and adapted version of the main lib/internal/errors.js file defined at\n\n  https://github.com/nodejs/node/blob/master/lib/internal/errors.js\n\n  Don't try to replace with the original file and keep it up to date (starting from E(...) definitions)\n  with the upstream file.\n*/\n\nconst AggregateError = globalThis.AggregateError || CustomAggregateError\nconst kIsNodeError = Symbol('kIsNodeError')\nconst kTypes = [\n  'string',\n  'function',\n  'number',\n  'object', // Accept 'Function' and 'Object' as alternative to the lower cased version.\n  'Function',\n  'Object',\n  'boolean',\n  'bigint',\n  'symbol'\n]\nconst classRegExp = /^([A-Z][a-z0-9]*)+$/\nconst nodeInternalPrefix = '__node_internal_'\nconst codes = {}\n\nfunction assert(value, message) {\n  if (!value) {\n    throw new codes.ERR_INTERNAL_ASSERTION(message)\n  }\n} // Only use this for integers! Decimal numbers do not work with this function.\n\nfunction addNumericalSeparator(val) {\n  let res = ''\n  let i = val.length\n  const start = val[0] === '-' ? 1 : 0\n\n  for (; i >= start + 4; i -= 3) {\n    res = `_${val.slice(i - 3, i)}${res}`\n  }\n\n  return `${val.slice(0, i)}${res}`\n}\n\nfunction getMessage(key, msg, args) {\n  if (typeof msg === 'function') {\n    assert(\n      msg.length <= args.length, // Default options do not count.\n      `Code: ${key}; The provided arguments length (${args.length}) does not match the required ones (${msg.length}).`\n    )\n    return msg(...args)\n  }\n\n  const expectedLength = (msg.match(/%[dfijoOs]/g) || []).length\n  assert(\n    expectedLength === args.length,\n    `Code: ${key}; The provided arguments length (${args.length}) does not match the required ones (${expectedLength}).`\n  )\n\n  if (args.length === 0) {\n    return msg\n  }\n\n  return format(msg, ...args)\n}\n\nfunction E(code, message, Base) {\n  if (!Base) {\n    Base = Error\n  }\n\n  class NodeError extends Base {\n    constructor(...args) {\n      super(getMessage(code, message, args))\n    }\n\n    toString() {\n      return `${this.name} [${code}]: ${this.message}`\n    }\n  }\n\n  NodeError.prototype.name = Base.name\n  NodeError.prototype.code = code\n  NodeError.prototype[kIsNodeError] = true\n\n  NodeError.prototype.toString = function () {\n    return `${this.name} [${code}]: ${this.message}`\n  }\n\n  codes[code] = NodeError\n}\n\nfunction hideStackFrames(fn) {\n  // We rename the functions that will be hidden to cut off the stacktrace\n  // at the outermost one\n  const hidden = nodeInternalPrefix + fn.name\n  Object.defineProperty(fn, 'name', {\n    value: hidden\n  })\n  return fn\n}\n\nfunction aggregateTwoErrors(innerError, outerError) {\n  if (innerError && outerError && innerError !== outerError) {\n    if (Array.isArray(outerError.errors)) {\n      // If `outerError` is already an `AggregateError`.\n      outerError.errors.push(innerError)\n      return outerError\n    }\n\n    const err = new AggregateError([outerError, innerError], outerError.message)\n    err.code = outerError.code\n    return err\n  }\n\n  return innerError || outerError\n}\n\nclass AbortError extends Error {\n  constructor(message = 'The operation was aborted', options = undefined) {\n    if (options !== undefined && typeof options !== 'object') {\n      throw new codes.ERR_INVALID_ARG_TYPE('options', 'Object', options)\n    }\n\n    super(message, options)\n    this.code = 'ABORT_ERR'\n    this.name = 'AbortError'\n  }\n}\n\nE('ERR_ASSERTION', '%s', Error)\nE(\n  'ERR_INVALID_ARG_TYPE',\n  (name, expected, actual) => {\n    assert(typeof name === 'string', \"'name' must be a string\")\n\n    if (!Array.isArray(expected)) {\n      expected = [expected]\n    }\n\n    let msg = 'The '\n\n    if (name.endsWith(' argument')) {\n      // For cases like 'first argument'\n      msg += `${name} `\n    } else {\n      msg += `\"${name}\" ${name.includes('.') ? 'property' : 'argument'} `\n    }\n\n    msg += 'must be '\n    const types = []\n    const instances = []\n    const other = []\n\n    for (const value of expected) {\n      assert(typeof value === 'string', 'All expected entries have to be of type string')\n\n      if (kTypes.includes(value)) {\n        types.push(value.toLowerCase())\n      } else if (classRegExp.test(value)) {\n        instances.push(value)\n      } else {\n        assert(value !== 'object', 'The value \"object\" should be written as \"Object\"')\n        other.push(value)\n      }\n    } // Special handle `object` in case other instances are allowed to outline\n    // the differences between each other.\n\n    if (instances.length > 0) {\n      const pos = types.indexOf('object')\n\n      if (pos !== -1) {\n        types.splice(types, pos, 1)\n        instances.push('Object')\n      }\n    }\n\n    if (types.length > 0) {\n      switch (types.length) {\n        case 1:\n          msg += `of type ${types[0]}`\n          break\n\n        case 2:\n          msg += `one of type ${types[0]} or ${types[1]}`\n          break\n\n        default: {\n          const last = types.pop()\n          msg += `one of type ${types.join(', ')}, or ${last}`\n        }\n      }\n\n      if (instances.length > 0 || other.length > 0) {\n        msg += ' or '\n      }\n    }\n\n    if (instances.length > 0) {\n      switch (instances.length) {\n        case 1:\n          msg += `an instance of ${instances[0]}`\n          break\n\n        case 2:\n          msg += `an instance of ${instances[0]} or ${instances[1]}`\n          break\n\n        default: {\n          const last = instances.pop()\n          msg += `an instance of ${instances.join(', ')}, or ${last}`\n        }\n      }\n\n      if (other.length > 0) {\n        msg += ' or '\n      }\n    }\n\n    switch (other.length) {\n      case 0:\n        break\n\n      case 1:\n        if (other[0].toLowerCase() !== other[0]) {\n          msg += 'an '\n        }\n\n        msg += `${other[0]}`\n        break\n\n      case 2:\n        msg += `one of ${other[0]} or ${other[1]}`\n        break\n\n      default: {\n        const last = other.pop()\n        msg += `one of ${other.join(', ')}, or ${last}`\n      }\n    }\n\n    if (actual == null) {\n      msg += `. Received ${actual}`\n    } else if (typeof actual === 'function' && actual.name) {\n      msg += `. Received function ${actual.name}`\n    } else if (typeof actual === 'object') {\n      var _actual$constructor\n\n      if (\n        (_actual$constructor = actual.constructor) !== null &&\n        _actual$constructor !== undefined &&\n        _actual$constructor.name\n      ) {\n        msg += `. Received an instance of ${actual.constructor.name}`\n      } else {\n        const inspected = inspect(actual, {\n          depth: -1\n        })\n        msg += `. Received ${inspected}`\n      }\n    } else {\n      let inspected = inspect(actual, {\n        colors: false\n      })\n\n      if (inspected.length > 25) {\n        inspected = `${inspected.slice(0, 25)}...`\n      }\n\n      msg += `. Received type ${typeof actual} (${inspected})`\n    }\n\n    return msg\n  },\n  TypeError\n)\nE(\n  'ERR_INVALID_ARG_VALUE',\n  (name, value, reason = 'is invalid') => {\n    let inspected = inspect(value)\n\n    if (inspected.length > 128) {\n      inspected = inspected.slice(0, 128) + '...'\n    }\n\n    const type = name.includes('.') ? 'property' : 'argument'\n    return `The ${type} '${name}' ${reason}. Received ${inspected}`\n  },\n  TypeError\n)\nE(\n  'ERR_INVALID_RETURN_VALUE',\n  (input, name, value) => {\n    var _value$constructor\n\n    const type =\n      value !== null &&\n      value !== undefined &&\n      (_value$constructor = value.constructor) !== null &&\n      _value$constructor !== undefined &&\n      _value$constructor.name\n        ? `instance of ${value.constructor.name}`\n        : `type ${typeof value}`\n    return `Expected ${input} to be returned from the \"${name}\"` + ` function but got ${type}.`\n  },\n  TypeError\n)\nE(\n  'ERR_MISSING_ARGS',\n  (...args) => {\n    assert(args.length > 0, 'At least one arg needs to be specified')\n    let msg\n    const len = args.length\n    args = (Array.isArray(args) ? args : [args]).map((a) => `\"${a}\"`).join(' or ')\n\n    switch (len) {\n      case 1:\n        msg += `The ${args[0]} argument`\n        break\n\n      case 2:\n        msg += `The ${args[0]} and ${args[1]} arguments`\n        break\n\n      default:\n        {\n          const last = args.pop()\n          msg += `The ${args.join(', ')}, and ${last} arguments`\n        }\n        break\n    }\n\n    return `${msg} must be specified`\n  },\n  TypeError\n)\nE(\n  'ERR_OUT_OF_RANGE',\n  (str, range, input) => {\n    assert(range, 'Missing \"range\" argument')\n    let received\n\n    if (Number.isInteger(input) && Math.abs(input) > 2 ** 32) {\n      received = addNumericalSeparator(String(input))\n    } else if (typeof input === 'bigint') {\n      received = String(input)\n\n      if (input > 2n ** 32n || input < -(2n ** 32n)) {\n        received = addNumericalSeparator(received)\n      }\n\n      received += 'n'\n    } else {\n      received = inspect(input)\n    }\n\n    return `The value of \"${str}\" is out of range. It must be ${range}. Received ${received}`\n  },\n  RangeError\n)\nE('ERR_MULTIPLE_CALLBACK', 'Callback called multiple times', Error)\nE('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented', Error)\nE('ERR_STREAM_ALREADY_FINISHED', 'Cannot call %s after a stream was finished', Error)\nE('ERR_STREAM_CANNOT_PIPE', 'Cannot pipe, not readable', Error)\nE('ERR_STREAM_DESTROYED', 'Cannot call %s after a stream was destroyed', Error)\nE('ERR_STREAM_NULL_VALUES', 'May not write null values to stream', TypeError)\nE('ERR_STREAM_PREMATURE_CLOSE', 'Premature close', Error)\nE('ERR_STREAM_PUSH_AFTER_EOF', 'stream.push() after EOF', Error)\nE('ERR_STREAM_UNSHIFT_AFTER_END_EVENT', 'stream.unshift() after end event', Error)\nE('ERR_STREAM_WRITE_AFTER_END', 'write after end', Error)\nE('ERR_UNKNOWN_ENCODING', 'Unknown encoding: %s', TypeError)\nmodule.exports = {\n  AbortError,\n  aggregateTwoErrors: hideStackFrames(aggregateTwoErrors),\n  hideStackFrames,\n  codes\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/ours/primordials.js",
    "content": "'use strict'\n/*\n  This file is a reduced and adapted version of the main lib/internal/per_context/primordials.js file defined at\n\n  https://github.com/nodejs/node/blob/master/lib/internal/per_context/primordials.js\n\n  Don't try to replace with the original file and keep it up to date with the upstream file.\n*/\n\nmodule.exports = {\n  ArrayIsArray(self) {\n    return Array.isArray(self)\n  },\n\n  ArrayPrototypeIncludes(self, el) {\n    return self.includes(el)\n  },\n\n  ArrayPrototypeIndexOf(self, el) {\n    return self.indexOf(el)\n  },\n\n  ArrayPrototypeJoin(self, sep) {\n    return self.join(sep)\n  },\n\n  ArrayPrototypeMap(self, fn) {\n    return self.map(fn)\n  },\n\n  ArrayPrototypePop(self, el) {\n    return self.pop(el)\n  },\n\n  ArrayPrototypePush(self, el) {\n    return self.push(el)\n  },\n\n  ArrayPrototypeSlice(self, start, end) {\n    return self.slice(start, end)\n  },\n\n  Error,\n\n  FunctionPrototypeCall(fn, thisArgs, ...args) {\n    return fn.call(thisArgs, ...args)\n  },\n\n  FunctionPrototypeSymbolHasInstance(self, instance) {\n    return Function.prototype[Symbol.hasInstance].call(self, instance)\n  },\n\n  MathFloor: Math.floor,\n  Number,\n  NumberIsInteger: Number.isInteger,\n  NumberIsNaN: Number.isNaN,\n  NumberMAX_SAFE_INTEGER: Number.MAX_SAFE_INTEGER,\n  NumberMIN_SAFE_INTEGER: Number.MIN_SAFE_INTEGER,\n  NumberParseInt: Number.parseInt,\n\n  ObjectDefineProperties(self, props) {\n    return Object.defineProperties(self, props)\n  },\n\n  ObjectDefineProperty(self, name, prop) {\n    return Object.defineProperty(self, name, prop)\n  },\n\n  ObjectGetOwnPropertyDescriptor(self, name) {\n    return Object.getOwnPropertyDescriptor(self, name)\n  },\n\n  ObjectKeys(obj) {\n    return Object.keys(obj)\n  },\n\n  ObjectSetPrototypeOf(target, proto) {\n    return Object.setPrototypeOf(target, proto)\n  },\n\n  Promise,\n\n  PromisePrototypeCatch(self, fn) {\n    return self.catch(fn)\n  },\n\n  PromisePrototypeThen(self, thenFn, catchFn) {\n    return self.then(thenFn, catchFn)\n  },\n\n  PromiseReject(err) {\n    return Promise.reject(err)\n  },\n\n  ReflectApply: Reflect.apply,\n\n  RegExpPrototypeTest(self, value) {\n    return self.test(value)\n  },\n\n  SafeSet: Set,\n  String,\n\n  StringPrototypeSlice(self, start, end) {\n    return self.slice(start, end)\n  },\n\n  StringPrototypeToLowerCase(self) {\n    return self.toLowerCase()\n  },\n\n  StringPrototypeToUpperCase(self) {\n    return self.toUpperCase()\n  },\n\n  StringPrototypeTrim(self) {\n    return self.trim()\n  },\n\n  Symbol,\n  SymbolAsyncIterator: Symbol.asyncIterator,\n  SymbolHasInstance: Symbol.hasInstance,\n  SymbolIterator: Symbol.iterator,\n\n  TypedArrayPrototypeSet(self, buf, len) {\n    return self.set(buf, len)\n  },\n\n  Uint8Array\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/ours/util.js",
    "content": "'use strict'\n\nconst bufferModule = require('buffer')\n\nconst AsyncFunction = Object.getPrototypeOf(async function () {}).constructor\nconst Blob = globalThis.Blob || bufferModule.Blob\n/* eslint-disable indent */\n\nconst isBlob =\n  typeof Blob !== 'undefined'\n    ? function isBlob(b) {\n        // eslint-disable-next-line indent\n        return b instanceof Blob\n      }\n    : function isBlob(b) {\n        return false\n      }\n/* eslint-enable indent */\n// This is a simplified version of AggregateError\n\nclass AggregateError extends Error {\n  constructor(errors) {\n    if (!Array.isArray(errors)) {\n      throw new TypeError(`Expected input to be an Array, got ${typeof errors}`)\n    }\n\n    let message = ''\n\n    for (let i = 0; i < errors.length; i++) {\n      message += `    ${errors[i].stack}\\n`\n    }\n\n    super(message)\n    this.name = 'AggregateError'\n    this.errors = errors\n  }\n}\n\nmodule.exports = {\n  AggregateError,\n\n  once(callback) {\n    let called = false\n    return function (...args) {\n      if (called) {\n        return\n      }\n\n      called = true\n      callback.apply(this, args)\n    }\n  },\n\n  createDeferredPromise: function () {\n    let resolve\n    let reject // eslint-disable-next-line promise/param-names\n\n    const promise = new Promise((res, rej) => {\n      resolve = res\n      reject = rej\n    })\n    return {\n      promise,\n      resolve,\n      reject\n    }\n  },\n\n  promisify(fn) {\n    return new Promise((resolve, reject) => {\n      fn((err, ...args) => {\n        if (err) {\n          return reject(err)\n        }\n\n        return resolve(...args)\n      })\n    })\n  },\n\n  debuglog() {\n    return function () {}\n  },\n\n  format(format, ...args) {\n    // Simplified version of https://nodejs.org/api/util.html#utilformatformat-args\n    return format.replace(/%([sdifj])/g, function (...[_unused, type]) {\n      const replacement = args.shift()\n\n      if (type === 'f') {\n        return replacement.toFixed(6)\n      } else if (type === 'j') {\n        return JSON.stringify(replacement)\n      } else if (type === 's' && typeof replacement === 'object') {\n        const ctor = replacement.constructor !== Object ? replacement.constructor.name : ''\n        return `${ctor} {}`.trim()\n      } else {\n        return replacement.toString()\n      }\n    })\n  },\n\n  inspect(value) {\n    // Vastly simplified version of https://nodejs.org/api/util.html#utilinspectobject-options\n    switch (typeof value) {\n      case 'string':\n        if (value.includes(\"'\")) {\n          if (!value.includes('\"')) {\n            return `\"${value}\"`\n          } else if (!value.includes('`') && !value.includes('${')) {\n            return `\\`${value}\\``\n          }\n        }\n\n        return `'${value}'`\n\n      case 'number':\n        if (isNaN(value)) {\n          return 'NaN'\n        } else if (Object.is(value, -0)) {\n          return String(value)\n        }\n\n        return value\n\n      case 'bigint':\n        return `${String(value)}n`\n\n      case 'boolean':\n      case 'undefined':\n        return String(value)\n\n      case 'object':\n        return '{}'\n    }\n  },\n\n  types: {\n    isAsyncFunction(fn) {\n      return fn instanceof AsyncFunction\n    },\n\n    isArrayBufferView(arr) {\n      return ArrayBuffer.isView(arr)\n    }\n  },\n  isBlob\n}\nmodule.exports.promisify.custom = Symbol.for('nodejs.util.promisify.custom')\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/stream/promises.js",
    "content": "'use strict'\n\nconst { ArrayPrototypePop, Promise } = require('../ours/primordials')\n\nconst { isIterable, isNodeStream } = require('../internal/streams/utils')\n\nconst { pipelineImpl: pl } = require('../internal/streams/pipeline')\n\nconst { finished } = require('../internal/streams/end-of-stream')\n\nfunction pipeline(...streams) {\n  return new Promise((resolve, reject) => {\n    let signal\n    let end\n    const lastArg = streams[streams.length - 1]\n\n    if (lastArg && typeof lastArg === 'object' && !isNodeStream(lastArg) && !isIterable(lastArg)) {\n      const options = ArrayPrototypePop(streams)\n      signal = options.signal\n      end = options.end\n    }\n\n    pl(\n      streams,\n      (err, value) => {\n        if (err) {\n          reject(err)\n        } else {\n          resolve(value)\n        }\n      },\n      {\n        signal,\n        end\n      }\n    )\n  })\n}\n\nmodule.exports = {\n  finished,\n  pipeline\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/readableStream/stream.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n'use strict'\n\nconst { ObjectDefineProperty, ObjectKeys, ReflectApply } = require('./ours/primordials')\n\nconst {\n  promisify: { custom: customPromisify }\n} = require('./ours/util')\n\nconst { streamReturningOperators, promiseReturningOperators } = require('./internal/streams/operators')\n\nconst {\n  codes: { ERR_ILLEGAL_CONSTRUCTOR }\n} = require('./ours/errors')\n\nconst compose = require('./internal/streams/compose')\n\nconst { pipeline } = require('./internal/streams/pipeline')\n\nconst { destroyer } = require('./internal/streams/destroy')\n\nconst eos = require('./internal/streams/end-of-stream')\n\nconst internalBuffer = {}\n\nconst promises = require('./stream/promises')\n\nconst utils = require('./internal/streams/utils')\n\nconst Stream = (module.exports = require('./internal/streams/legacy').Stream)\n\nStream.isDisturbed = utils.isDisturbed\nStream.isErrored = utils.isErrored\nStream.isReadable = utils.isReadable\nStream.Readable = require('./internal/streams/readable')\n\nfor (const key of ObjectKeys(streamReturningOperators)) {\n  const op = streamReturningOperators[key]\n\n  function fn(...args) {\n    if (new.target) {\n      throw ERR_ILLEGAL_CONSTRUCTOR()\n    }\n\n    return Stream.Readable.from(ReflectApply(op, this, args))\n  }\n\n  ObjectDefineProperty(fn, 'name', {\n    value: op.name\n  })\n  ObjectDefineProperty(fn, 'length', {\n    value: op.length\n  })\n  ObjectDefineProperty(Stream.Readable.prototype, key, {\n    value: fn,\n    enumerable: false,\n    configurable: true,\n    writable: true\n  })\n}\n\nfor (const key of ObjectKeys(promiseReturningOperators)) {\n  const op = promiseReturningOperators[key]\n\n  function fn(...args) {\n    if (new.target) {\n      throw ERR_ILLEGAL_CONSTRUCTOR()\n    }\n\n    return ReflectApply(op, this, args)\n  }\n\n  ObjectDefineProperty(fn, 'name', {\n    value: op.name\n  })\n  ObjectDefineProperty(fn, 'length', {\n    value: op.length\n  })\n  ObjectDefineProperty(Stream.Readable.prototype, key, {\n    value: fn,\n    enumerable: false,\n    configurable: true,\n    writable: true\n  })\n}\n\nStream.Writable = require('./internal/streams/writable')\nStream.Duplex = require('./internal/streams/duplex')\nStream.Transform = require('./internal/streams/transform')\nStream.PassThrough = require('./internal/streams/passthrough')\nStream.pipeline = pipeline\n\nconst { addAbortSignal } = require('./internal/streams/add-abort-signal')\n\nStream.addAbortSignal = addAbortSignal\nStream.finished = eos\nStream.destroy = destroyer\nStream.compose = compose\nObjectDefineProperty(Stream, 'promises', {\n  configurable: true,\n  enumerable: true,\n\n  get() {\n    return promises\n  }\n})\nObjectDefineProperty(pipeline, customPromisify, {\n  enumerable: true,\n\n  get() {\n    return promises.pipeline\n  }\n})\nObjectDefineProperty(eos, customPromisify, {\n  enumerable: true,\n\n  get() {\n    return promises.finished\n  }\n}) // Backwards-compat with node 0.4.x\n\nStream.Stream = Stream\n\nStream._isUint8Array = function isUint8Array(value) {\n  return value instanceof Uint8Array\n}\n\nStream._uint8ArrayToBuffer = function _uint8ArrayToBuffer(chunk) {\n  return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)\n}\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/safeBuffer/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) Feross Aboukhadijeh\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\nall copies 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\nTHE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/safeBuffer/index.js",
    "content": "/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */\n/* eslint-disable node/no-deprecated-api, no-var */\nvar buffer = require('buffer')\nvar Buffer = buffer.Buffer\n\n// alternative to using Object.keys for old browsers\nfunction copyProps(src, dst) {\n  for (var key in src) {\n    dst[key] = src[key]\n  }\n}\nif (Buffer.from && Buffer.alloc && Buffer.allocUnsafe && Buffer.allocUnsafeSlow) {\n  module.exports = buffer\n} else {\n  // Copy properties from require('buffer')\n  copyProps(buffer, exports)\n  exports.Buffer = SafeBuffer\n}\n\nfunction SafeBuffer(arg, encodingOrOffset, length) {\n  return Buffer(arg, encodingOrOffset, length)\n}\n\nSafeBuffer.prototype = Object.create(Buffer.prototype)\n\n// Copy static methods from Buffer\ncopyProps(Buffer, SafeBuffer)\n\nSafeBuffer.from = function (arg, encodingOrOffset, length) {\n  if (typeof arg === 'number') {\n    throw new TypeError('Argument must not be a number')\n  }\n  return Buffer(arg, encodingOrOffset, length)\n}\n\nSafeBuffer.alloc = function (size, fill, encoding) {\n  if (typeof size !== 'number') {\n    throw new TypeError('Argument must be a number')\n  }\n  var buf = Buffer(size)\n  if (fill !== undefined) {\n    if (typeof encoding === 'string') {\n      buf.fill(fill, encoding)\n    } else {\n      buf.fill(fill)\n    }\n  } else {\n    buf.fill(0)\n  }\n  return buf\n}\n\nSafeBuffer.allocUnsafe = function (size) {\n  if (typeof size !== 'number') {\n    throw new TypeError('Argument must be a number')\n  }\n  return Buffer(size)\n}\n\nSafeBuffer.allocUnsafeSlow = function (size) {\n  if (typeof size !== 'number') {\n    throw new TypeError('Argument must be a number')\n  }\n  return buffer.SlowBuffer(size)\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/stringDecoder/LICENSE",
    "content": "Node.js is licensed for use as follows:\n\n\"\"\"\nCopyright Node.js contributors. All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to\ndeal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or\nsell copies 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\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE.\n\"\"\"\n\nThis license applies to parts of Node.js originating from the\nhttps://github.com/joyent/node repository:\n\n\"\"\"\nCopyright Joyent, Inc. and other Node contributors. All rights reserved.\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to\ndeal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or\nsell copies 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\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE.\n\"\"\"\n"
  },
  {
    "path": "server/libs/archiver/archiverUtils/stringDecoder/index.js",
    "content": "// Copyright Joyent, Inc. and other Node contributors.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a\n// copy of this software and associated documentation files (the\n// \"Software\"), to deal in the Software without restriction, including\n// without limitation the rights to use, copy, modify, merge, publish,\n// distribute, sublicense, and/or sell copies of the Software, and to permit\n// persons to whom the Software is furnished to do so, subject to the\n// following conditions:\n//\n// The above copyright notice and this permission notice shall be included\n// in all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\n// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\n// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN\n// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\n// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\n// USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n'use strict';\n\n/*<replacement>*/\nvar Buffer = require('../safeBuffer').Buffer;\n/*</replacement>*/\n\nvar isEncoding = Buffer.isEncoding || function (encoding) {\n  encoding = '' + encoding;\n  switch (encoding && encoding.toLowerCase()) {\n    case 'hex': case 'utf8': case 'utf-8': case 'ascii': case 'binary': case 'base64': case 'ucs2': case 'ucs-2': case 'utf16le': case 'utf-16le': case 'raw':\n      return true;\n    default:\n      return false;\n  }\n};\n\nfunction _normalizeEncoding(enc) {\n  if (!enc) return 'utf8';\n  var retried;\n  while (true) {\n    switch (enc) {\n      case 'utf8':\n      case 'utf-8':\n        return 'utf8';\n      case 'ucs2':\n      case 'ucs-2':\n      case 'utf16le':\n      case 'utf-16le':\n        return 'utf16le';\n      case 'latin1':\n      case 'binary':\n        return 'latin1';\n      case 'base64':\n      case 'ascii':\n      case 'hex':\n        return enc;\n      default:\n        if (retried) return; // undefined\n        enc = ('' + enc).toLowerCase();\n        retried = true;\n    }\n  }\n};\n\n// Do not cache `Buffer.isEncoding` when checking encoding names as some\n// modules monkey-patch it to support additional encodings\nfunction normalizeEncoding(enc) {\n  var nenc = _normalizeEncoding(enc);\n  if (typeof nenc !== 'string' && (Buffer.isEncoding === isEncoding || !isEncoding(enc))) throw new Error('Unknown encoding: ' + enc);\n  return nenc || enc;\n}\n\n// StringDecoder provides an interface for efficiently splitting a series of\n// buffers into a series of JS strings without breaking apart multi-byte\n// characters.\nexports.StringDecoder = StringDecoder;\nfunction StringDecoder(encoding) {\n  this.encoding = normalizeEncoding(encoding);\n  var nb;\n  switch (this.encoding) {\n    case 'utf16le':\n      this.text = utf16Text;\n      this.end = utf16End;\n      nb = 4;\n      break;\n    case 'utf8':\n      this.fillLast = utf8FillLast;\n      nb = 4;\n      break;\n    case 'base64':\n      this.text = base64Text;\n      this.end = base64End;\n      nb = 3;\n      break;\n    default:\n      this.write = simpleWrite;\n      this.end = simpleEnd;\n      return;\n  }\n  this.lastNeed = 0;\n  this.lastTotal = 0;\n  this.lastChar = Buffer.allocUnsafe(nb);\n}\n\nStringDecoder.prototype.write = function (buf) {\n  if (buf.length === 0) return '';\n  var r;\n  var i;\n  if (this.lastNeed) {\n    r = this.fillLast(buf);\n    if (r === undefined) return '';\n    i = this.lastNeed;\n    this.lastNeed = 0;\n  } else {\n    i = 0;\n  }\n  if (i < buf.length) return r ? r + this.text(buf, i) : this.text(buf, i);\n  return r || '';\n};\n\nStringDecoder.prototype.end = utf8End;\n\n// Returns only complete characters in a Buffer\nStringDecoder.prototype.text = utf8Text;\n\n// Attempts to complete a partial non-UTF-8 character using bytes from a Buffer\nStringDecoder.prototype.fillLast = function (buf) {\n  if (this.lastNeed <= buf.length) {\n    buf.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, this.lastNeed);\n    return this.lastChar.toString(this.encoding, 0, this.lastTotal);\n  }\n  buf.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, buf.length);\n  this.lastNeed -= buf.length;\n};\n\n// Checks the type of a UTF-8 byte, whether it's ASCII, a leading byte, or a\n// continuation byte. If an invalid byte is detected, -2 is returned.\nfunction utf8CheckByte(byte) {\n  if (byte <= 0x7F) return 0; else if (byte >> 5 === 0x06) return 2; else if (byte >> 4 === 0x0E) return 3; else if (byte >> 3 === 0x1E) return 4;\n  return byte >> 6 === 0x02 ? -1 : -2;\n}\n\n// Checks at most 3 bytes at the end of a Buffer in order to detect an\n// incomplete multi-byte UTF-8 character. The total number of bytes (2, 3, or 4)\n// needed to complete the UTF-8 character (if applicable) are returned.\nfunction utf8CheckIncomplete(self, buf, i) {\n  var j = buf.length - 1;\n  if (j < i) return 0;\n  var nb = utf8CheckByte(buf[j]);\n  if (nb >= 0) {\n    if (nb > 0) self.lastNeed = nb - 1;\n    return nb;\n  }\n  if (--j < i || nb === -2) return 0;\n  nb = utf8CheckByte(buf[j]);\n  if (nb >= 0) {\n    if (nb > 0) self.lastNeed = nb - 2;\n    return nb;\n  }\n  if (--j < i || nb === -2) return 0;\n  nb = utf8CheckByte(buf[j]);\n  if (nb >= 0) {\n    if (nb > 0) {\n      if (nb === 2) nb = 0; else self.lastNeed = nb - 3;\n    }\n    return nb;\n  }\n  return 0;\n}\n\n// Validates as many continuation bytes for a multi-byte UTF-8 character as\n// needed or are available. If we see a non-continuation byte where we expect\n// one, we \"replace\" the validated continuation bytes we've seen so far with\n// a single UTF-8 replacement character ('\\ufffd'), to match v8's UTF-8 decoding\n// behavior. The continuation byte check is included three times in the case\n// where all of the continuation bytes for a character exist in the same buffer.\n// It is also done this way as a slight performance increase instead of using a\n// loop.\nfunction utf8CheckExtraBytes(self, buf, p) {\n  if ((buf[0] & 0xC0) !== 0x80) {\n    self.lastNeed = 0;\n    return '\\ufffd';\n  }\n  if (self.lastNeed > 1 && buf.length > 1) {\n    if ((buf[1] & 0xC0) !== 0x80) {\n      self.lastNeed = 1;\n      return '\\ufffd';\n    }\n    if (self.lastNeed > 2 && buf.length > 2) {\n      if ((buf[2] & 0xC0) !== 0x80) {\n        self.lastNeed = 2;\n        return '\\ufffd';\n      }\n    }\n  }\n}\n\n// Attempts to complete a multi-byte UTF-8 character using bytes from a Buffer.\nfunction utf8FillLast(buf) {\n  var p = this.lastTotal - this.lastNeed;\n  var r = utf8CheckExtraBytes(this, buf, p);\n  if (r !== undefined) return r;\n  if (this.lastNeed <= buf.length) {\n    buf.copy(this.lastChar, p, 0, this.lastNeed);\n    return this.lastChar.toString(this.encoding, 0, this.lastTotal);\n  }\n  buf.copy(this.lastChar, p, 0, buf.length);\n  this.lastNeed -= buf.length;\n}\n\n// Returns all complete UTF-8 characters in a Buffer. If the Buffer ended on a\n// partial character, the character's bytes are buffered until the required\n// number of bytes are available.\nfunction utf8Text(buf, i) {\n  var total = utf8CheckIncomplete(this, buf, i);\n  if (!this.lastNeed) return buf.toString('utf8', i);\n  this.lastTotal = total;\n  var end = buf.length - (total - this.lastNeed);\n  buf.copy(this.lastChar, 0, end);\n  return buf.toString('utf8', i, end);\n}\n\n// For UTF-8, a replacement character is added when ending on a partial\n// character.\nfunction utf8End(buf) {\n  var r = buf && buf.length ? this.write(buf) : '';\n  if (this.lastNeed) return r + '\\ufffd';\n  return r;\n}\n\n// UTF-16LE typically needs two bytes per character, but even if we have an even\n// number of bytes available, we need to check if we end on a leading/high\n// surrogate. In that case, we need to wait for the next two bytes in order to\n// decode the last character properly.\nfunction utf16Text(buf, i) {\n  if ((buf.length - i) % 2 === 0) {\n    var r = buf.toString('utf16le', i);\n    if (r) {\n      var c = r.charCodeAt(r.length - 1);\n      if (c >= 0xD800 && c <= 0xDBFF) {\n        this.lastNeed = 2;\n        this.lastTotal = 4;\n        this.lastChar[0] = buf[buf.length - 2];\n        this.lastChar[1] = buf[buf.length - 1];\n        return r.slice(0, -1);\n      }\n    }\n    return r;\n  }\n  this.lastNeed = 1;\n  this.lastTotal = 2;\n  this.lastChar[0] = buf[buf.length - 1];\n  return buf.toString('utf16le', i, buf.length - 1);\n}\n\n// For UTF-16LE we do not explicitly append special replacement characters if we\n// end on a partial character, we simply let v8 handle that.\nfunction utf16End(buf) {\n  var r = buf && buf.length ? this.write(buf) : '';\n  if (this.lastNeed) {\n    var end = this.lastTotal - this.lastNeed;\n    return r + this.lastChar.toString('utf16le', 0, end);\n  }\n  return r;\n}\n\nfunction base64Text(buf, i) {\n  var n = (buf.length - i) % 3;\n  if (n === 0) return buf.toString('base64', i);\n  this.lastNeed = 3 - n;\n  this.lastTotal = 3;\n  if (n === 1) {\n    this.lastChar[0] = buf[buf.length - 1];\n  } else {\n    this.lastChar[0] = buf[buf.length - 2];\n    this.lastChar[1] = buf[buf.length - 1];\n  }\n  return buf.toString('base64', i, buf.length - n);\n}\n\nfunction base64End(buf) {\n  var r = buf && buf.length ? this.write(buf) : '';\n  if (this.lastNeed) return r + this.lastChar.toString('base64', 0, 3 - this.lastNeed);\n  return r;\n}\n\n// Pass bytes on through for single-byte encodings (e.g. ascii, latin1, hex)\nfunction simpleWrite(buf) {\n  return buf.toString(this.encoding);\n}\n\nfunction simpleEnd(buf) {\n  return buf && buf.length ? this.write(buf) : '';\n}"
  },
  {
    "path": "server/libs/archiver/archiverUtils/wrappy/LICENSE",
    "content": "The ISC License\n\nCopyright (c) Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/archiver/archiverUtils/wrappy/index.js",
    "content": "// Returns a wrapper function that returns a wrapped callback\n// The wrapper function should do some stuff, and return a\n// presumably different callback function.\n// This makes sure that own properties are retained, so that\n// decorations and such are not lost along the way.\nmodule.exports = wrappy\nfunction wrappy(fn, cb) {\n  if (fn && cb) return wrappy(fn)(cb)\n\n  if (typeof fn !== 'function')\n    throw new TypeError('need wrapper function')\n\n  Object.keys(fn).forEach(function (k) {\n    wrapper[k] = fn[k]\n  })\n\n  return wrapper\n\n  function wrapper() {\n    var ret = fn.apply(this, arguments)\n    var cb = arguments[arguments.length - 1]\n    if (typeof ret === 'function' && ret !== cb) {\n      Object.keys(cb).forEach(function (k) {\n        ret[k] = cb[k]\n      })\n    }\n    return ret\n  }\n}"
  },
  {
    "path": "server/libs/archiver/buffer-crc32/LICENSE",
    "content": "The MIT License\n\nCopyright (c) 2013 Brian J. Brennan\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 in \nthe Software without restriction, including without limitation the rights to use, \ncopy, modify, merge, publish, distribute, sublicense, and/or sell copies of the \nSoftware, and to permit persons to whom the Software is furnished to do so, \nsubject 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 IMPLIED, \nINCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR \nPURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE\nFOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/buffer-crc32/index.js",
    "content": "//\n// used in archiver\n// Source: https://github.com/brianloveswords/buffer-crc32\n//\n\nvar Buffer = require('buffer').Buffer;\n\nvar CRC_TABLE = [\n  0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419,\n  0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4,\n  0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07,\n  0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de,\n  0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856,\n  0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9,\n  0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4,\n  0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,\n  0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3,\n  0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a,\n  0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599,\n  0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,\n  0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190,\n  0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f,\n  0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e,\n  0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,\n  0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed,\n  0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,\n  0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3,\n  0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2,\n  0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a,\n  0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5,\n  0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010,\n  0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,\n  0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17,\n  0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6,\n  0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615,\n  0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,\n  0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344,\n  0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb,\n  0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a,\n  0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,\n  0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1,\n  0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c,\n  0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef,\n  0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,\n  0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe,\n  0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31,\n  0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c,\n  0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,\n  0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b,\n  0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,\n  0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1,\n  0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c,\n  0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278,\n  0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7,\n  0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66,\n  0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,\n  0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605,\n  0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8,\n  0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b,\n  0x2d02ef8d\n];\n\nif (typeof Int32Array !== 'undefined') {\n  CRC_TABLE = new Int32Array(CRC_TABLE);\n}\n\nfunction newEmptyBuffer(length) {\n  var buffer = new Buffer(length);\n  buffer.fill(0x00);\n  return buffer;\n}\n\nfunction ensureBuffer(input) {\n  if (Buffer.isBuffer(input)) {\n    return input;\n  }\n\n  var hasNewBufferAPI =\n    typeof Buffer.alloc === \"function\" &&\n    typeof Buffer.from === \"function\";\n\n  if (typeof input === \"number\") {\n    return hasNewBufferAPI ? Buffer.alloc(input) : newEmptyBuffer(input);\n  }\n  else if (typeof input === \"string\") {\n    return hasNewBufferAPI ? Buffer.from(input) : new Buffer(input);\n  }\n  else {\n    throw new Error(\"input must be buffer, number, or string, received \" +\n      typeof input);\n  }\n}\n\nfunction bufferizeInt(num) {\n  var tmp = ensureBuffer(4);\n  tmp.writeInt32BE(num, 0);\n  return tmp;\n}\n\nfunction _crc32(buf, previous) {\n  buf = ensureBuffer(buf);\n  if (Buffer.isBuffer(previous)) {\n    previous = previous.readUInt32BE(0);\n  }\n  var crc = ~~previous ^ -1;\n  for (var n = 0; n < buf.length; n++) {\n    crc = CRC_TABLE[(crc ^ buf[n]) & 0xff] ^ (crc >>> 8);\n  }\n  return (crc ^ -1);\n}\n\nfunction crc32() {\n  return bufferizeInt(_crc32.apply(null, arguments));\n}\ncrc32.signed = function () {\n  return _crc32.apply(null, arguments);\n};\ncrc32.unsigned = function () {\n  return _crc32.apply(null, arguments) >>> 0;\n};\n\nmodule.exports = crc32;"
  },
  {
    "path": "server/libs/archiver/compress-commons/LICENSE",
    "content": "Copyright (c) 2014 Chris Talkington, contributors.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/archive-entry.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar ArchiveEntry = module.exports = function() {};\n\nArchiveEntry.prototype.getName = function() {};\n\nArchiveEntry.prototype.getSize = function() {};\n\nArchiveEntry.prototype.getLastModifiedDate = function() {};\n\nArchiveEntry.prototype.isDirectory = function() {};"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/archive-output-stream.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar inherits = require('util').inherits;\nvar Transform = require('../../archiverUtils/readableStream').Transform;\n\nvar ArchiveEntry = require('./archive-entry');\nvar util = require('../util');\n\nvar ArchiveOutputStream = module.exports = function (options) {\n  if (!(this instanceof ArchiveOutputStream)) {\n    return new ArchiveOutputStream(options);\n  }\n\n  Transform.call(this, options);\n\n  this.offset = 0;\n  this._archive = {\n    finish: false,\n    finished: false,\n    processing: false\n  };\n};\n\ninherits(ArchiveOutputStream, Transform);\n\nArchiveOutputStream.prototype._appendBuffer = function (zae, source, callback) {\n  // scaffold only\n};\n\nArchiveOutputStream.prototype._appendStream = function (zae, source, callback) {\n  // scaffold only\n};\n\nArchiveOutputStream.prototype._emitErrorCallback = function (err) {\n  if (err) {\n    this.emit('error', err);\n  }\n};\n\nArchiveOutputStream.prototype._finish = function (ae) {\n  // scaffold only\n};\n\nArchiveOutputStream.prototype._normalizeEntry = function (ae) {\n  // scaffold only\n};\n\nArchiveOutputStream.prototype._transform = function (chunk, encoding, callback) {\n  callback(null, chunk);\n};\n\nArchiveOutputStream.prototype.entry = function (ae, source, callback) {\n  source = source || null;\n\n  if (typeof callback !== 'function') {\n    callback = this._emitErrorCallback.bind(this);\n  }\n\n  if (!(ae instanceof ArchiveEntry)) {\n    callback(new Error('not a valid instance of ArchiveEntry'));\n    return;\n  }\n\n  if (this._archive.finish || this._archive.finished) {\n    callback(new Error('unacceptable entry after finish'));\n    return;\n  }\n\n  if (this._archive.processing) {\n    callback(new Error('already processing an entry'));\n    return;\n  }\n\n  this._archive.processing = true;\n  this._normalizeEntry(ae);\n  this._entry = ae;\n\n  source = util.normalizeInputSource(source);\n\n  if (Buffer.isBuffer(source)) {\n    this._appendBuffer(ae, source, callback);\n  } else if (util.isStream(source)) {\n    this._appendStream(ae, source, callback);\n  } else {\n    this._archive.processing = false;\n    callback(new Error('input source must be valid Stream or Buffer instance'));\n    return;\n  }\n\n  return this;\n};\n\nArchiveOutputStream.prototype.finish = function () {\n  if (this._archive.processing) {\n    this._archive.finish = true;\n    return;\n  }\n\n  this._finish();\n};\n\nArchiveOutputStream.prototype.getBytesWritten = function () {\n  return this.offset;\n};\n\nArchiveOutputStream.prototype.write = function (chunk, cb) {\n  if (chunk) {\n    this.offset += chunk.length;\n  }\n\n  return Transform.prototype.write.call(this, chunk, cb);\n};"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/zip/constants.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nmodule.exports = {\n  WORD: 4,\n  DWORD: 8,\n  EMPTY: Buffer.alloc(0),\n\n  SHORT: 2,\n  SHORT_MASK: 0xffff,\n  SHORT_SHIFT: 16,\n  SHORT_ZERO: Buffer.from(Array(2)),\n  LONG: 4,\n  LONG_ZERO: Buffer.from(Array(4)),\n\n  MIN_VERSION_INITIAL: 10,\n  MIN_VERSION_DATA_DESCRIPTOR: 20,\n  MIN_VERSION_ZIP64: 45,\n  VERSION_MADEBY: 45,\n\n  METHOD_STORED: 0,\n  METHOD_DEFLATED: 8,\n\n  PLATFORM_UNIX: 3,\n  PLATFORM_FAT: 0,\n\n  SIG_LFH: 0x04034b50,\n  SIG_DD: 0x08074b50,\n  SIG_CFH: 0x02014b50,\n  SIG_EOCD: 0x06054b50,\n  SIG_ZIP64_EOCD: 0x06064B50,\n  SIG_ZIP64_EOCD_LOC: 0x07064B50,\n\n  ZIP64_MAGIC_SHORT: 0xffff,\n  ZIP64_MAGIC: 0xffffffff,\n  ZIP64_EXTRA_ID: 0x0001,\n\n  ZLIB_NO_COMPRESSION: 0,\n  ZLIB_BEST_SPEED: 1,\n  ZLIB_BEST_COMPRESSION: 9,\n  ZLIB_DEFAULT_COMPRESSION: -1,\n\n  MODE_MASK: 0xFFF,\n  DEFAULT_FILE_MODE: 33188, // 010644 = -rw-r--r-- = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH\n  DEFAULT_DIR_MODE: 16877,  // 040755 = drwxr-xr-x = S_IFDIR | S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH\n\n  EXT_FILE_ATTR_DIR: 1106051088,  // 010173200020 = drwxr-xr-x = (((S_IFDIR | 0755) << 16) | S_DOS_D)\n  EXT_FILE_ATTR_FILE: 2175008800, // 020151000040 = -rw-r--r-- = (((S_IFREG | 0644) << 16) | S_DOS_A) >>> 0\n\n  // Unix file types\n  S_IFMT: 61440,   // 0170000 type of file mask\n  S_IFIFO: 4096,   // 010000 named pipe (fifo)\n  S_IFCHR: 8192,   // 020000 character special\n  S_IFDIR: 16384,  // 040000 directory\n  S_IFBLK: 24576,  // 060000 block special\n  S_IFREG: 32768,  // 0100000 regular\n  S_IFLNK: 40960,  // 0120000 symbolic link\n  S_IFSOCK: 49152, // 0140000 socket\n\n  // DOS file type flags\n  S_DOS_A: 32, // 040 Archive\n  S_DOS_D: 16, // 020 Directory\n  S_DOS_V: 8,  // 010 Volume\n  S_DOS_S: 4,  // 04 System\n  S_DOS_H: 2,  // 02 Hidden\n  S_DOS_R: 1   // 01 Read Only\n};\n"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/zip/general-purpose-bit.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar zipUtil = require('./util');\n\nvar DATA_DESCRIPTOR_FLAG = 1 << 3;\nvar ENCRYPTION_FLAG = 1 << 0;\nvar NUMBER_OF_SHANNON_FANO_TREES_FLAG = 1 << 2;\nvar SLIDING_DICTIONARY_SIZE_FLAG = 1 << 1;\nvar STRONG_ENCRYPTION_FLAG = 1 << 6;\nvar UFT8_NAMES_FLAG = 1 << 11;\n\nvar GeneralPurposeBit = module.exports = function() {\n  if (!(this instanceof GeneralPurposeBit)) {\n    return new GeneralPurposeBit();\n  }\n\n  this.descriptor = false;\n  this.encryption = false;\n  this.utf8 = false;\n  this.numberOfShannonFanoTrees = 0;\n  this.strongEncryption = false;\n  this.slidingDictionarySize = 0;\n\n  return this;\n};\n\nGeneralPurposeBit.prototype.encode = function() {\n  return zipUtil.getShortBytes(\n    (this.descriptor ? DATA_DESCRIPTOR_FLAG : 0) |\n    (this.utf8 ? UFT8_NAMES_FLAG : 0) |\n    (this.encryption ? ENCRYPTION_FLAG : 0) |\n    (this.strongEncryption ? STRONG_ENCRYPTION_FLAG : 0)\n  );\n};\n\nGeneralPurposeBit.prototype.parse = function(buf, offset) {\n  var flag = zipUtil.getShortBytesValue(buf, offset);\n  var gbp = new GeneralPurposeBit();\n\n  gbp.useDataDescriptor((flag & DATA_DESCRIPTOR_FLAG) !== 0);\n  gbp.useUTF8ForNames((flag & UFT8_NAMES_FLAG) !== 0);\n  gbp.useStrongEncryption((flag & STRONG_ENCRYPTION_FLAG) !== 0);\n  gbp.useEncryption((flag & ENCRYPTION_FLAG) !== 0);\n  gbp.setSlidingDictionarySize((flag & SLIDING_DICTIONARY_SIZE_FLAG) !== 0 ? 8192 : 4096);\n  gbp.setNumberOfShannonFanoTrees((flag & NUMBER_OF_SHANNON_FANO_TREES_FLAG) !== 0 ? 3 : 2);\n\n  return gbp;\n};\n\nGeneralPurposeBit.prototype.setNumberOfShannonFanoTrees = function(n) {\n  this.numberOfShannonFanoTrees = n;\n};\n\nGeneralPurposeBit.prototype.getNumberOfShannonFanoTrees = function() {\n  return this.numberOfShannonFanoTrees;\n};\n\nGeneralPurposeBit.prototype.setSlidingDictionarySize = function(n) {\n  this.slidingDictionarySize = n;\n};\n\nGeneralPurposeBit.prototype.getSlidingDictionarySize = function() {\n  return this.slidingDictionarySize;\n};\n\nGeneralPurposeBit.prototype.useDataDescriptor = function(b) {\n  this.descriptor = b;\n};\n\nGeneralPurposeBit.prototype.usesDataDescriptor = function() {\n  return this.descriptor;\n};\n\nGeneralPurposeBit.prototype.useEncryption = function(b) {\n  this.encryption = b;\n};\n\nGeneralPurposeBit.prototype.usesEncryption = function() {\n  return this.encryption;\n};\n\nGeneralPurposeBit.prototype.useStrongEncryption = function(b) {\n  this.strongEncryption = b;\n};\n\nGeneralPurposeBit.prototype.usesStrongEncryption = function() {\n  return this.strongEncryption;\n};\n\nGeneralPurposeBit.prototype.useUTF8ForNames = function(b) {\n  this.utf8 = b;\n};\n\nGeneralPurposeBit.prototype.usesUTF8ForNames = function() {\n  return this.utf8;\n};"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/zip/unix-stat.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nmodule.exports = {\n    /**\n     * Bits used for permissions (and sticky bit)\n     */\n    PERM_MASK: 4095, // 07777\n\n    /**\n     * Bits used to indicate the filesystem object type.\n     */\n    FILE_TYPE_FLAG: 61440, // 0170000\n\n    /**\n     * Indicates symbolic links.\n     */\n    LINK_FLAG: 40960, // 0120000\n\n    /**\n     * Indicates plain files.\n     */\n    FILE_FLAG: 32768, // 0100000\n\n    /**\n     * Indicates directories.\n     */\n    DIR_FLAG: 16384, // 040000\n\n    // ----------------------------------------------------------\n    // somewhat arbitrary choices that are quite common for shared\n    // installations\n    // -----------------------------------------------------------\n\n    /**\n     * Default permissions for symbolic links.\n     */\n    DEFAULT_LINK_PERM: 511, // 0777\n\n    /**\n     * Default permissions for directories.\n     */\n    DEFAULT_DIR_PERM: 493, // 0755\n\n    /**\n     * Default permissions for plain files.\n     */\n    DEFAULT_FILE_PERM: 420 // 0644\n};"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/zip/util.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar util = module.exports = {};\n\nutil.dateToDos = function(d, forceLocalTime) {\n  forceLocalTime = forceLocalTime || false;\n\n  var year = forceLocalTime ? d.getFullYear() : d.getUTCFullYear();\n\n  if (year < 1980) {\n    return 2162688; // 1980-1-1 00:00:00\n  } else if (year >= 2044) {\n    return 2141175677; // 2043-12-31 23:59:58\n  }\n\n  var val = {\n    year: year,\n    month: forceLocalTime ? d.getMonth() : d.getUTCMonth(),\n    date: forceLocalTime ? d.getDate() : d.getUTCDate(),\n    hours: forceLocalTime ? d.getHours() : d.getUTCHours(),\n    minutes: forceLocalTime ? d.getMinutes() : d.getUTCMinutes(),\n    seconds: forceLocalTime ? d.getSeconds() : d.getUTCSeconds()\n  };\n\n  return ((val.year - 1980) << 25) | ((val.month + 1) << 21) | (val.date << 16) |\n    (val.hours << 11) | (val.minutes << 5) | (val.seconds / 2);\n};\n\nutil.dosToDate = function(dos) {\n  return new Date(((dos >> 25) & 0x7f) + 1980, ((dos >> 21) & 0x0f) - 1, (dos >> 16) & 0x1f, (dos >> 11) & 0x1f, (dos >> 5) & 0x3f, (dos & 0x1f) << 1);\n};\n\nutil.fromDosTime = function(buf) {\n  return util.dosToDate(buf.readUInt32LE(0));\n};\n\nutil.getEightBytes = function(v) {\n  var buf = Buffer.alloc(8);\n  buf.writeUInt32LE(v % 0x0100000000, 0);\n  buf.writeUInt32LE((v / 0x0100000000) | 0, 4);\n\n  return buf;\n};\n\nutil.getShortBytes = function(v) {\n  var buf = Buffer.alloc(2);\n  buf.writeUInt16LE((v & 0xFFFF) >>> 0, 0);\n\n  return buf;\n};\n\nutil.getShortBytesValue = function(buf, offset) {\n  return buf.readUInt16LE(offset);\n};\n\nutil.getLongBytes = function(v) {\n  var buf = Buffer.alloc(4);\n  buf.writeUInt32LE((v & 0xFFFFFFFF) >>> 0, 0);\n\n  return buf;\n};\n\nutil.getLongBytesValue = function(buf, offset) {\n  return buf.readUInt32LE(offset);\n};\n\nutil.toDosTime = function(d) {\n  return util.getLongBytes(util.dateToDos(d));\n};"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/zip/zip-archive-entry.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar inherits = require('util').inherits;\nvar normalizePath = require('../../../normalize-path');\n\nvar ArchiveEntry = require('../archive-entry');\nvar GeneralPurposeBit = require('./general-purpose-bit');\nvar UnixStat = require('./unix-stat');\n\nvar constants = require('./constants');\nvar zipUtil = require('./util');\n\nvar ZipArchiveEntry = module.exports = function (name) {\n  if (!(this instanceof ZipArchiveEntry)) {\n    return new ZipArchiveEntry(name);\n  }\n\n  ArchiveEntry.call(this);\n\n  this.platform = constants.PLATFORM_FAT;\n  this.method = -1;\n\n  this.name = null;\n  this.size = 0;\n  this.csize = 0;\n  this.gpb = new GeneralPurposeBit();\n  this.crc = 0;\n  this.time = -1;\n\n  this.minver = constants.MIN_VERSION_INITIAL;\n  this.mode = -1;\n  this.extra = null;\n  this.exattr = 0;\n  this.inattr = 0;\n  this.comment = null;\n\n  if (name) {\n    this.setName(name);\n  }\n};\n\ninherits(ZipArchiveEntry, ArchiveEntry);\n\n/**\n * Returns the extra fields related to the entry.\n *\n * @returns {Buffer}\n */\nZipArchiveEntry.prototype.getCentralDirectoryExtra = function () {\n  return this.getExtra();\n};\n\n/**\n * Returns the comment set for the entry.\n *\n * @returns {string}\n */\nZipArchiveEntry.prototype.getComment = function () {\n  return this.comment !== null ? this.comment : '';\n};\n\n/**\n * Returns the compressed size of the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getCompressedSize = function () {\n  return this.csize;\n};\n\n/**\n * Returns the CRC32 digest for the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getCrc = function () {\n  return this.crc;\n};\n\n/**\n * Returns the external file attributes for the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getExternalAttributes = function () {\n  return this.exattr;\n};\n\n/**\n * Returns the extra fields related to the entry.\n *\n * @returns {Buffer}\n */\nZipArchiveEntry.prototype.getExtra = function () {\n  return this.extra !== null ? this.extra : constants.EMPTY;\n};\n\n/**\n * Returns the general purpose bits related to the entry.\n *\n * @returns {GeneralPurposeBit}\n */\nZipArchiveEntry.prototype.getGeneralPurposeBit = function () {\n  return this.gpb;\n};\n\n/**\n * Returns the internal file attributes for the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getInternalAttributes = function () {\n  return this.inattr;\n};\n\n/**\n * Returns the last modified date of the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getLastModifiedDate = function () {\n  return this.getTime();\n};\n\n/**\n * Returns the extra fields related to the entry.\n *\n * @returns {Buffer}\n */\nZipArchiveEntry.prototype.getLocalFileDataExtra = function () {\n  return this.getExtra();\n};\n\n/**\n * Returns the compression method used on the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getMethod = function () {\n  return this.method;\n};\n\n/**\n * Returns the filename of the entry.\n *\n * @returns {string}\n */\nZipArchiveEntry.prototype.getName = function () {\n  return this.name;\n};\n\n/**\n * Returns the platform on which the entry was made.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getPlatform = function () {\n  return this.platform;\n};\n\n/**\n * Returns the size of the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getSize = function () {\n  return this.size;\n};\n\n/**\n * Returns a date object representing the last modified date of the entry.\n *\n * @returns {number|Date}\n */\nZipArchiveEntry.prototype.getTime = function () {\n  return this.time !== -1 ? zipUtil.dosToDate(this.time) : -1;\n};\n\n/**\n * Returns the DOS timestamp for the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getTimeDos = function () {\n  return this.time !== -1 ? this.time : 0;\n};\n\n/**\n * Returns the UNIX file permissions for the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getUnixMode = function () {\n  return this.platform !== constants.PLATFORM_UNIX ? 0 : ((this.getExternalAttributes() >> constants.SHORT_SHIFT) & constants.SHORT_MASK);\n};\n\n/**\n * Returns the version of ZIP needed to extract the entry.\n *\n * @returns {number}\n */\nZipArchiveEntry.prototype.getVersionNeededToExtract = function () {\n  return this.minver;\n};\n\n/**\n * Sets the comment of the entry.\n *\n * @param comment\n */\nZipArchiveEntry.prototype.setComment = function (comment) {\n  if (Buffer.byteLength(comment) !== comment.length) {\n    this.getGeneralPurposeBit().useUTF8ForNames(true);\n  }\n\n  this.comment = comment;\n};\n\n/**\n * Sets the compressed size of the entry.\n *\n * @param size\n */\nZipArchiveEntry.prototype.setCompressedSize = function (size) {\n  if (size < 0) {\n    throw new Error('invalid entry compressed size');\n  }\n\n  this.csize = size;\n};\n\n/**\n * Sets the checksum of the entry.\n *\n * @param crc\n */\nZipArchiveEntry.prototype.setCrc = function (crc) {\n  if (crc < 0) {\n    throw new Error('invalid entry crc32');\n  }\n\n  this.crc = crc;\n};\n\n/**\n * Sets the external file attributes of the entry.\n *\n * @param attr\n */\nZipArchiveEntry.prototype.setExternalAttributes = function (attr) {\n  this.exattr = attr >>> 0;\n};\n\n/**\n * Sets the extra fields related to the entry.\n *\n * @param extra\n */\nZipArchiveEntry.prototype.setExtra = function (extra) {\n  this.extra = extra;\n};\n\n/**\n * Sets the general purpose bits related to the entry.\n *\n * @param gpb\n */\nZipArchiveEntry.prototype.setGeneralPurposeBit = function (gpb) {\n  if (!(gpb instanceof GeneralPurposeBit)) {\n    throw new Error('invalid entry GeneralPurposeBit');\n  }\n\n  this.gpb = gpb;\n};\n\n/**\n * Sets the internal file attributes of the entry.\n *\n * @param attr\n */\nZipArchiveEntry.prototype.setInternalAttributes = function (attr) {\n  this.inattr = attr;\n};\n\n/**\n * Sets the compression method of the entry.\n *\n * @param method\n */\nZipArchiveEntry.prototype.setMethod = function (method) {\n  if (method < 0) {\n    throw new Error('invalid entry compression method');\n  }\n\n  this.method = method;\n};\n\n/**\n * Sets the name of the entry.\n *\n * @param name\n * @param prependSlash\n */\nZipArchiveEntry.prototype.setName = function (name, prependSlash = false) {\n  name = normalizePath(name, false)\n    .replace(/^\\w+:/, '')\n    .replace(/^(\\.\\.\\/|\\/)+/, '');\n\n  if (prependSlash) {\n    name = `/${name}`;\n  }\n\n  if (Buffer.byteLength(name) !== name.length) {\n    this.getGeneralPurposeBit().useUTF8ForNames(true);\n  }\n\n  this.name = name;\n};\n\n/**\n * Sets the platform on which the entry was made.\n *\n * @param platform\n */\nZipArchiveEntry.prototype.setPlatform = function (platform) {\n  this.platform = platform;\n};\n\n/**\n * Sets the size of the entry.\n *\n * @param size\n */\nZipArchiveEntry.prototype.setSize = function (size) {\n  if (size < 0) {\n    throw new Error('invalid entry size');\n  }\n\n  this.size = size;\n};\n\n/**\n * Sets the time of the entry.\n *\n * @param time\n * @param forceLocalTime\n */\nZipArchiveEntry.prototype.setTime = function (time, forceLocalTime) {\n  if (!(time instanceof Date)) {\n    throw new Error('invalid entry time');\n  }\n\n  this.time = zipUtil.dateToDos(time, forceLocalTime);\n};\n\n/**\n * Sets the UNIX file permissions for the entry.\n *\n * @param mode\n */\nZipArchiveEntry.prototype.setUnixMode = function (mode) {\n  mode |= this.isDirectory() ? constants.S_IFDIR : constants.S_IFREG;\n\n  var extattr = 0;\n  extattr |= (mode << constants.SHORT_SHIFT) | (this.isDirectory() ? constants.S_DOS_D : constants.S_DOS_A);\n\n  this.setExternalAttributes(extattr);\n  this.mode = mode & constants.MODE_MASK;\n  this.platform = constants.PLATFORM_UNIX;\n};\n\n/**\n * Sets the version of ZIP needed to extract this entry.\n *\n * @param minver\n */\nZipArchiveEntry.prototype.setVersionNeededToExtract = function (minver) {\n  this.minver = minver;\n};\n\n/**\n * Returns true if this entry represents a directory.\n *\n * @returns {boolean}\n */\nZipArchiveEntry.prototype.isDirectory = function () {\n  return this.getName().slice(-1) === '/';\n};\n\n/**\n * Returns true if this entry represents a unix symlink,\n * in which case the entry's content contains the target path\n * for the symlink.\n *\n * @returns {boolean}\n */\nZipArchiveEntry.prototype.isUnixSymlink = function () {\n  return (this.getUnixMode() & UnixStat.FILE_TYPE_FLAG) === UnixStat.LINK_FLAG;\n};\n\n/**\n * Returns true if this entry is using the ZIP64 extension of ZIP.\n *\n * @returns {boolean}\n */\nZipArchiveEntry.prototype.isZip64 = function () {\n  return this.csize > constants.ZIP64_MAGIC || this.size > constants.ZIP64_MAGIC;\n};\n"
  },
  {
    "path": "server/libs/archiver/compress-commons/archivers/zip/zip-archive-output-stream.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar inherits = require('util').inherits;\nvar crc32 = require('../../../buffer-crc32');\nvar { CRC32Stream } = require('../../../crc32-stream');\nvar { DeflateCRC32Stream } = require('../../../crc32-stream');\n\nvar ArchiveOutputStream = require('../archive-output-stream');\n\nvar constants = require('./constants');\nvar zipUtil = require('./util');\n\nvar ZipArchiveOutputStream = module.exports = function (options) {\n  if (!(this instanceof ZipArchiveOutputStream)) {\n    return new ZipArchiveOutputStream(options);\n  }\n\n  options = this.options = this._defaults(options);\n\n  ArchiveOutputStream.call(this, options);\n\n  this._entry = null;\n  this._entries = [];\n  this._archive = {\n    centralLength: 0,\n    centralOffset: 0,\n    comment: '',\n    finish: false,\n    finished: false,\n    processing: false,\n    forceZip64: options.forceZip64,\n    forceLocalTime: options.forceLocalTime\n  };\n};\n\ninherits(ZipArchiveOutputStream, ArchiveOutputStream);\n\nZipArchiveOutputStream.prototype._afterAppend = function (ae) {\n  this._entries.push(ae);\n\n  if (ae.getGeneralPurposeBit().usesDataDescriptor()) {\n    this._writeDataDescriptor(ae);\n  }\n\n  this._archive.processing = false;\n  this._entry = null;\n\n  if (this._archive.finish && !this._archive.finished) {\n    this._finish();\n  }\n};\n\nZipArchiveOutputStream.prototype._appendBuffer = function (ae, source, callback) {\n  if (source.length === 0) {\n    ae.setMethod(constants.METHOD_STORED);\n  }\n\n  var method = ae.getMethod();\n\n  if (method === constants.METHOD_STORED) {\n    ae.setSize(source.length);\n    ae.setCompressedSize(source.length);\n    ae.setCrc(crc32.unsigned(source));\n  }\n\n  this._writeLocalFileHeader(ae);\n\n  if (method === constants.METHOD_STORED) {\n    this.write(source);\n    this._afterAppend(ae);\n    callback(null, ae);\n    return;\n  } else if (method === constants.METHOD_DEFLATED) {\n    this._smartStream(ae, callback).end(source);\n    return;\n  } else {\n    callback(new Error('compression method ' + method + ' not implemented'));\n    return;\n  }\n};\n\nZipArchiveOutputStream.prototype._appendStream = function (ae, source, callback) {\n  ae.getGeneralPurposeBit().useDataDescriptor(true);\n  ae.setVersionNeededToExtract(constants.MIN_VERSION_DATA_DESCRIPTOR);\n\n  this._writeLocalFileHeader(ae);\n\n  var smart = this._smartStream(ae, callback);\n  source.once('error', function (err) {\n    smart.emit('error', err);\n    smart.end();\n  })\n  source.pipe(smart);\n};\n\nZipArchiveOutputStream.prototype._defaults = function (o) {\n  if (typeof o !== 'object') {\n    o = {};\n  }\n\n  if (typeof o.zlib !== 'object') {\n    o.zlib = {};\n  }\n\n  if (typeof o.zlib.level !== 'number') {\n    o.zlib.level = constants.ZLIB_BEST_SPEED;\n  }\n\n  o.forceZip64 = !!o.forceZip64;\n  o.forceLocalTime = !!o.forceLocalTime;\n\n  return o;\n};\n\nZipArchiveOutputStream.prototype._finish = function () {\n  this._archive.centralOffset = this.offset;\n\n  this._entries.forEach(function (ae) {\n    this._writeCentralFileHeader(ae);\n  }.bind(this));\n\n  this._archive.centralLength = this.offset - this._archive.centralOffset;\n\n  if (this.isZip64()) {\n    this._writeCentralDirectoryZip64();\n  }\n\n  this._writeCentralDirectoryEnd();\n\n  this._archive.processing = false;\n  this._archive.finish = true;\n  this._archive.finished = true;\n  this.end();\n};\n\nZipArchiveOutputStream.prototype._normalizeEntry = function (ae) {\n  if (ae.getMethod() === -1) {\n    ae.setMethod(constants.METHOD_DEFLATED);\n  }\n\n  if (ae.getMethod() === constants.METHOD_DEFLATED) {\n    ae.getGeneralPurposeBit().useDataDescriptor(true);\n    ae.setVersionNeededToExtract(constants.MIN_VERSION_DATA_DESCRIPTOR);\n  }\n\n  if (ae.getTime() === -1) {\n    ae.setTime(new Date(), this._archive.forceLocalTime);\n  }\n\n  ae._offsets = {\n    file: 0,\n    data: 0,\n    contents: 0,\n  };\n};\n\nZipArchiveOutputStream.prototype._smartStream = function (ae, callback) {\n  var deflate = ae.getMethod() === constants.METHOD_DEFLATED;\n  var process = deflate ? new DeflateCRC32Stream(this.options.zlib) : new CRC32Stream();\n  var error = null;\n\n  function handleStuff() {\n    var digest = process.digest().readUInt32BE(0);\n    ae.setCrc(digest);\n    ae.setSize(process.size());\n    ae.setCompressedSize(process.size(true));\n    this._afterAppend(ae);\n    callback(error, ae);\n  }\n\n  process.once('end', handleStuff.bind(this));\n  process.once('error', function (err) {\n    error = err;\n  });\n\n  process.pipe(this, { end: false });\n\n  return process;\n};\n\nZipArchiveOutputStream.prototype._writeCentralDirectoryEnd = function () {\n  var records = this._entries.length;\n  var size = this._archive.centralLength;\n  var offset = this._archive.centralOffset;\n\n  if (this.isZip64()) {\n    records = constants.ZIP64_MAGIC_SHORT;\n    size = constants.ZIP64_MAGIC;\n    offset = constants.ZIP64_MAGIC;\n  }\n\n  // signature\n  this.write(zipUtil.getLongBytes(constants.SIG_EOCD));\n\n  // disk numbers\n  this.write(constants.SHORT_ZERO);\n  this.write(constants.SHORT_ZERO);\n\n  // number of entries\n  this.write(zipUtil.getShortBytes(records));\n  this.write(zipUtil.getShortBytes(records));\n\n  // length and location of CD\n  this.write(zipUtil.getLongBytes(size));\n  this.write(zipUtil.getLongBytes(offset));\n\n  // archive comment\n  var comment = this.getComment();\n  var commentLength = Buffer.byteLength(comment);\n  this.write(zipUtil.getShortBytes(commentLength));\n  this.write(comment);\n};\n\nZipArchiveOutputStream.prototype._writeCentralDirectoryZip64 = function () {\n  // signature\n  this.write(zipUtil.getLongBytes(constants.SIG_ZIP64_EOCD));\n\n  // size of the ZIP64 EOCD record\n  this.write(zipUtil.getEightBytes(44));\n\n  // version made by\n  this.write(zipUtil.getShortBytes(constants.MIN_VERSION_ZIP64));\n\n  // version to extract\n  this.write(zipUtil.getShortBytes(constants.MIN_VERSION_ZIP64));\n\n  // disk numbers\n  this.write(constants.LONG_ZERO);\n  this.write(constants.LONG_ZERO);\n\n  // number of entries\n  this.write(zipUtil.getEightBytes(this._entries.length));\n  this.write(zipUtil.getEightBytes(this._entries.length));\n\n  // length and location of CD\n  this.write(zipUtil.getEightBytes(this._archive.centralLength));\n  this.write(zipUtil.getEightBytes(this._archive.centralOffset));\n\n  // extensible data sector\n  // not implemented at this time\n\n  // end of central directory locator\n  this.write(zipUtil.getLongBytes(constants.SIG_ZIP64_EOCD_LOC));\n\n  // disk number holding the ZIP64 EOCD record\n  this.write(constants.LONG_ZERO);\n\n  // relative offset of the ZIP64 EOCD record\n  this.write(zipUtil.getEightBytes(this._archive.centralOffset + this._archive.centralLength));\n\n  // total number of disks\n  this.write(zipUtil.getLongBytes(1));\n};\n\nZipArchiveOutputStream.prototype._writeCentralFileHeader = function (ae) {\n  var gpb = ae.getGeneralPurposeBit();\n  var method = ae.getMethod();\n  var offsets = ae._offsets;\n\n  var size = ae.getSize();\n  var compressedSize = ae.getCompressedSize();\n\n  if (ae.isZip64() || offsets.file > constants.ZIP64_MAGIC) {\n    size = constants.ZIP64_MAGIC;\n    compressedSize = constants.ZIP64_MAGIC;\n\n    ae.setVersionNeededToExtract(constants.MIN_VERSION_ZIP64);\n\n    var extraBuf = Buffer.concat([\n      zipUtil.getShortBytes(constants.ZIP64_EXTRA_ID),\n      zipUtil.getShortBytes(24),\n      zipUtil.getEightBytes(ae.getSize()),\n      zipUtil.getEightBytes(ae.getCompressedSize()),\n      zipUtil.getEightBytes(offsets.file)\n    ], 28);\n\n    ae.setExtra(extraBuf);\n  }\n\n  // signature\n  this.write(zipUtil.getLongBytes(constants.SIG_CFH));\n\n  // version made by\n  this.write(zipUtil.getShortBytes((ae.getPlatform() << 8) | constants.VERSION_MADEBY));\n\n  // version to extract and general bit flag\n  this.write(zipUtil.getShortBytes(ae.getVersionNeededToExtract()));\n  this.write(gpb.encode());\n\n  // compression method\n  this.write(zipUtil.getShortBytes(method));\n\n  // datetime\n  this.write(zipUtil.getLongBytes(ae.getTimeDos()));\n\n  // crc32 checksum\n  this.write(zipUtil.getLongBytes(ae.getCrc()));\n\n  // sizes\n  this.write(zipUtil.getLongBytes(compressedSize));\n  this.write(zipUtil.getLongBytes(size));\n\n  var name = ae.getName();\n  var comment = ae.getComment();\n  var extra = ae.getCentralDirectoryExtra();\n\n  if (gpb.usesUTF8ForNames()) {\n    name = Buffer.from(name);\n    comment = Buffer.from(comment);\n  }\n\n  // name length\n  this.write(zipUtil.getShortBytes(name.length));\n\n  // extra length\n  this.write(zipUtil.getShortBytes(extra.length));\n\n  // comments length\n  this.write(zipUtil.getShortBytes(comment.length));\n\n  // disk number start\n  this.write(constants.SHORT_ZERO);\n\n  // internal attributes\n  this.write(zipUtil.getShortBytes(ae.getInternalAttributes()));\n\n  // external attributes\n  this.write(zipUtil.getLongBytes(ae.getExternalAttributes()));\n\n  // relative offset of LFH\n  if (offsets.file > constants.ZIP64_MAGIC) {\n    this.write(zipUtil.getLongBytes(constants.ZIP64_MAGIC));\n  } else {\n    this.write(zipUtil.getLongBytes(offsets.file));\n  }\n\n  // name\n  this.write(name);\n\n  // extra\n  this.write(extra);\n\n  // comment\n  this.write(comment);\n};\n\nZipArchiveOutputStream.prototype._writeDataDescriptor = function (ae) {\n  // signature\n  this.write(zipUtil.getLongBytes(constants.SIG_DD));\n\n  // crc32 checksum\n  this.write(zipUtil.getLongBytes(ae.getCrc()));\n\n  // sizes\n  if (ae.isZip64()) {\n    this.write(zipUtil.getEightBytes(ae.getCompressedSize()));\n    this.write(zipUtil.getEightBytes(ae.getSize()));\n  } else {\n    this.write(zipUtil.getLongBytes(ae.getCompressedSize()));\n    this.write(zipUtil.getLongBytes(ae.getSize()));\n  }\n};\n\nZipArchiveOutputStream.prototype._writeLocalFileHeader = function (ae) {\n  var gpb = ae.getGeneralPurposeBit();\n  var method = ae.getMethod();\n  var name = ae.getName();\n  var extra = ae.getLocalFileDataExtra();\n\n  if (ae.isZip64()) {\n    gpb.useDataDescriptor(true);\n    ae.setVersionNeededToExtract(constants.MIN_VERSION_ZIP64);\n  }\n\n  if (gpb.usesUTF8ForNames()) {\n    name = Buffer.from(name);\n  }\n\n  ae._offsets.file = this.offset;\n\n  // signature\n  this.write(zipUtil.getLongBytes(constants.SIG_LFH));\n\n  // version to extract and general bit flag\n  this.write(zipUtil.getShortBytes(ae.getVersionNeededToExtract()));\n  this.write(gpb.encode());\n\n  // compression method\n  this.write(zipUtil.getShortBytes(method));\n\n  // datetime\n  this.write(zipUtil.getLongBytes(ae.getTimeDos()));\n\n  ae._offsets.data = this.offset;\n\n  // crc32 checksum and sizes\n  if (gpb.usesDataDescriptor()) {\n    this.write(constants.LONG_ZERO);\n    this.write(constants.LONG_ZERO);\n    this.write(constants.LONG_ZERO);\n  } else {\n    this.write(zipUtil.getLongBytes(ae.getCrc()));\n    this.write(zipUtil.getLongBytes(ae.getCompressedSize()));\n    this.write(zipUtil.getLongBytes(ae.getSize()));\n  }\n\n  // name length\n  this.write(zipUtil.getShortBytes(name.length));\n\n  // extra length\n  this.write(zipUtil.getShortBytes(extra.length));\n\n  // name\n  this.write(name);\n\n  // extra\n  this.write(extra);\n\n  ae._offsets.contents = this.offset;\n};\n\nZipArchiveOutputStream.prototype.getComment = function (comment) {\n  return this._archive.comment !== null ? this._archive.comment : '';\n};\n\nZipArchiveOutputStream.prototype.isZip64 = function () {\n  return this._archive.forceZip64 || this._entries.length > constants.ZIP64_MAGIC_SHORT || this._archive.centralLength > constants.ZIP64_MAGIC || this._archive.centralOffset > constants.ZIP64_MAGIC;\n};\n\nZipArchiveOutputStream.prototype.setComment = function (comment) {\n  this._archive.comment = comment;\n};\n"
  },
  {
    "path": "server/libs/archiver/compress-commons/index.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nmodule.exports = {\n  ArchiveEntry: require('./archivers/archive-entry'),\n  ZipArchiveEntry: require('./archivers/zip/zip-archive-entry'),\n  ArchiveOutputStream: require('./archivers/archive-output-stream'),\n  ZipArchiveOutputStream: require('./archivers/zip/zip-archive-output-stream')\n};"
  },
  {
    "path": "server/libs/archiver/compress-commons/util/index.js",
    "content": "/**\n * node-compress-commons\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-compress-commons/blob/master/LICENSE-MIT\n */\nvar Stream = require('stream').Stream;\nvar PassThrough = require('../../archiverUtils/readableStream').PassThrough;\n\nvar util = module.exports = {};\n\nutil.isStream = function (source) {\n  return source instanceof Stream;\n};\n\nutil.normalizeInputSource = function (source) {\n  if (source === null) {\n    return Buffer.alloc(0);\n  } else if (typeof source === 'string') {\n    return Buffer.from(source);\n  } else if (util.isStream(source) && !source._readableState) {\n    var normalized = new PassThrough();\n    source.pipe(normalized);\n\n    return normalized;\n  }\n\n  return source;\n};"
  },
  {
    "path": "server/libs/archiver/crc32/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright (C) 2014-present   SheetJS LLC\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "server/libs/archiver/crc32/index.js",
    "content": "/*! crc32.js (C) 2014-present SheetJS -- http://sheetjs.com */\n/* vim: set ts=2: */\n/*exported CRC32 */\nvar CRC32;\n(function (factory) {\n\t/*jshint ignore:start */\n\t/*eslint-disable */\n\tif(typeof DO_NOT_EXPORT_CRC === 'undefined') {\n\t\tif('object' === typeof exports) {\n\t\t\tfactory(exports);\n\t\t} else if ('function' === typeof define && define.amd) {\n\t\t\tdefine(function () {\n\t\t\t\tvar module = {};\n\t\t\t\tfactory(module);\n\t\t\t\treturn module;\n\t\t\t});\n\t\t} else {\n\t\t\tfactory(CRC32 = {});\n\t\t}\n\t} else {\n\t\tfactory(CRC32 = {});\n\t}\n\t/*eslint-enable */\n\t/*jshint ignore:end */\n}(function(CRC32) {\nCRC32.version = '1.2.2';\n/*global Int32Array */\nfunction signed_crc_table() {\n\tvar c = 0, table = new Array(256);\n\n\tfor(var n =0; n != 256; ++n){\n\t\tc = n;\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\tc = ((c&1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1));\n\t\ttable[n] = c;\n\t}\n\n\treturn typeof Int32Array !== 'undefined' ? new Int32Array(table) : table;\n}\n\nvar T0 = signed_crc_table();\nfunction slice_by_16_tables(T) {\n\tvar c = 0, v = 0, n = 0, table = typeof Int32Array !== 'undefined' ? new Int32Array(4096) : new Array(4096) ;\n\n\tfor(n = 0; n != 256; ++n) table[n] = T[n];\n\tfor(n = 0; n != 256; ++n) {\n\t\tv = T[n];\n\t\tfor(c = 256 + n; c < 4096; c += 256) v = table[c] = (v >>> 8) ^ T[v & 0xFF];\n\t}\n\tvar out = [];\n\tfor(n = 1; n != 16; ++n) out[n - 1] = typeof Int32Array !== 'undefined' ? table.subarray(n * 256, n * 256 + 256) : table.slice(n * 256, n * 256 + 256);\n\treturn out;\n}\nvar TT = slice_by_16_tables(T0);\nvar T1 = TT[0],  T2 = TT[1],  T3 = TT[2],  T4 = TT[3],  T5 = TT[4];\nvar T6 = TT[5],  T7 = TT[6],  T8 = TT[7],  T9 = TT[8],  Ta = TT[9];\nvar Tb = TT[10], Tc = TT[11], Td = TT[12], Te = TT[13], Tf = TT[14];\nfunction crc32_bstr(bstr, seed) {\n\tvar C = seed ^ -1;\n\tfor(var i = 0, L = bstr.length; i < L;) C = (C>>>8) ^ T0[(C^bstr.charCodeAt(i++))&0xFF];\n\treturn ~C;\n}\n\nfunction crc32_buf(B, seed) {\n\tvar C = seed ^ -1, L = B.length - 15, i = 0;\n\tfor(; i < L;) C =\n\t\tTf[B[i++] ^ (C & 255)] ^\n\t\tTe[B[i++] ^ ((C >> 8) & 255)] ^\n\t\tTd[B[i++] ^ ((C >> 16) & 255)] ^\n\t\tTc[B[i++] ^ (C >>> 24)] ^\n\t\tTb[B[i++]] ^ Ta[B[i++]] ^ T9[B[i++]] ^ T8[B[i++]] ^\n\t\tT7[B[i++]] ^ T6[B[i++]] ^ T5[B[i++]] ^ T4[B[i++]] ^\n\t\tT3[B[i++]] ^ T2[B[i++]] ^ T1[B[i++]] ^ T0[B[i++]];\n\tL += 15;\n\twhile(i < L) C = (C>>>8) ^ T0[(C^B[i++])&0xFF];\n\treturn ~C;\n}\n\nfunction crc32_str(str, seed) {\n\tvar C = seed ^ -1;\n\tfor(var i = 0, L = str.length, c = 0, d = 0; i < L;) {\n\t\tc = str.charCodeAt(i++);\n\t\tif(c < 0x80) {\n\t\t\tC = (C>>>8) ^ T0[(C^c)&0xFF];\n\t\t} else if(c < 0x800) {\n\t\t\tC = (C>>>8) ^ T0[(C ^ (192|((c>>6)&31)))&0xFF];\n\t\t\tC = (C>>>8) ^ T0[(C ^ (128|(c&63)))&0xFF];\n\t\t} else if(c >= 0xD800 && c < 0xE000) {\n\t\t\tc = (c&1023)+64; d = str.charCodeAt(i++)&1023;\n\t\t\tC = (C>>>8) ^ T0[(C ^ (240|((c>>8)&7)))&0xFF];\n\t\t\tC = (C>>>8) ^ T0[(C ^ (128|((c>>2)&63)))&0xFF];\n\t\t\tC = (C>>>8) ^ T0[(C ^ (128|((d>>6)&15)|((c&3)<<4)))&0xFF];\n\t\t\tC = (C>>>8) ^ T0[(C ^ (128|(d&63)))&0xFF];\n\t\t} else {\n\t\t\tC = (C>>>8) ^ T0[(C ^ (224|((c>>12)&15)))&0xFF];\n\t\t\tC = (C>>>8) ^ T0[(C ^ (128|((c>>6)&63)))&0xFF];\n\t\t\tC = (C>>>8) ^ T0[(C ^ (128|(c&63)))&0xFF];\n\t\t}\n\t}\n\treturn ~C;\n}\nCRC32.table = T0;\n// $FlowIgnore\nCRC32.bstr = crc32_bstr;\n// $FlowIgnore\nCRC32.buf = crc32_buf;\n// $FlowIgnore\nCRC32.str = crc32_str;\n}));"
  },
  {
    "path": "server/libs/archiver/crc32-stream/LICENSE",
    "content": "Copyright (c) 2014 Chris Talkington, contributors.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/crc32-stream/crc32-stream.js",
    "content": "/**\n * node-crc32-stream\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-crc32-stream/blob/master/LICENSE-MIT\n */\n\n'use strict';\n\nconst { Transform } = require('../archiverUtils/readableStream');\nconst crc32 = require('../crc32');\n\nclass CRC32Stream extends Transform {\n  constructor(options) {\n    super(options);\n    this.checksum = Buffer.allocUnsafe(4);\n    this.checksum.writeInt32BE(0, 0);\n\n    this.rawSize = 0;\n  }\n\n  _transform(chunk, encoding, callback) {\n    if (chunk) {\n      this.checksum = crc32.buf(chunk, this.checksum) >>> 0;\n      this.rawSize += chunk.length;\n    }\n\n    callback(null, chunk);\n  }\n\n  digest(encoding) {\n    const checksum = Buffer.allocUnsafe(4);\n    checksum.writeUInt32BE(this.checksum >>> 0, 0);\n    return encoding ? checksum.toString(encoding) : checksum;\n  }\n\n  hex() {\n    return this.digest('hex').toUpperCase();\n  }\n\n  size() {\n    return this.rawSize;\n  }\n}\n\nmodule.exports = CRC32Stream;\n"
  },
  {
    "path": "server/libs/archiver/crc32-stream/deflate-crc32-stream.js",
    "content": "/**\n * node-crc32-stream\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-crc32-stream/blob/master/LICENSE-MIT\n */\n\n'use strict';\n\nconst { DeflateRaw } = require('zlib');\n\nconst crc32 = require('../crc32');\n\nclass DeflateCRC32Stream extends DeflateRaw {\n  constructor(options) {\n    super(options);\n\n    this.checksum = Buffer.allocUnsafe(4);\n    this.checksum.writeInt32BE(0, 0);\n\n    this.rawSize = 0;\n    this.compressedSize = 0;\n  }\n\n  push(chunk, encoding) {\n    if (chunk) {\n      this.compressedSize += chunk.length;\n    }\n\n    return super.push(chunk, encoding);\n  }\n\n  _transform(chunk, encoding, callback) {\n    if (chunk) {\n      this.checksum = crc32.buf(chunk, this.checksum) >>> 0;\n      this.rawSize += chunk.length;\n    }\n\n    super._transform(chunk, encoding, callback)\n  }\n\n  digest(encoding) {\n    const checksum = Buffer.allocUnsafe(4);\n    checksum.writeUInt32BE(this.checksum >>> 0, 0);\n    return encoding ? checksum.toString(encoding) : checksum;\n  }\n\n  hex() {\n    return this.digest('hex').toUpperCase();\n  }\n\n  size(compressed = false) {\n    if (compressed) {\n      return this.compressedSize;\n    } else {\n      return this.rawSize;\n    }\n  }\n}\n\nmodule.exports = DeflateCRC32Stream;\n"
  },
  {
    "path": "server/libs/archiver/crc32-stream/index.js",
    "content": "/**\n * node-crc32-stream\n *\n * Copyright (c) 2014 Chris Talkington, contributors.\n * Licensed under the MIT license.\n * https://github.com/archiverjs/node-crc32-stream/blob/master/LICENSE-MIT\n */\n\n'use strict';\n\nmodule.exports = {\n  CRC32Stream: require('./crc32-stream'),\n  DeflateCRC32Stream: require('./deflate-crc32-stream')\n}\n"
  },
  {
    "path": "server/libs/archiver/index.js",
    "content": "/**\n * Archiver Vending\n *\n * @ignore\n * @license [MIT]{@link https://github.com/archiverjs/node-archiver/blob/master/LICENSE}\n * @copyright (c) 2012-2014 Chris Talkington, contributors.\n */\nvar Archiver = require('./lib/core');\n\nvar formats = {};\n\n/**\n * Dispenses a new Archiver instance.\n *\n * @constructor\n * @param  {String} format The archive format to use.\n * @param  {Object} options See [Archiver]{@link Archiver}\n * @return {Archiver}\n */\nvar vending = function (format, options) {\n  return vending.create(format, options);\n};\n\n/**\n * Creates a new Archiver instance.\n *\n * @param  {String} format The archive format to use.\n * @param  {Object} options See [Archiver]{@link Archiver}\n * @return {Archiver}\n */\nvending.create = function (format, options) {\n  if (formats[format]) {\n    var instance = new Archiver(format, options);\n    instance.setFormat(format);\n    instance.setModule(new formats[format](options));\n\n    return instance;\n  } else {\n    throw new Error('create(' + format + '): format not registered');\n  }\n};\n\n/**\n * Registers a format for use with archiver.\n *\n * @param  {String} format The name of the format.\n * @param  {Function} module The function for archiver to interact with.\n * @return void\n */\nvending.registerFormat = function (format, module) {\n  if (formats[format]) {\n    throw new Error('register(' + format + '): format already registered');\n  }\n\n  if (typeof module !== 'function') {\n    throw new Error('register(' + format + '): format module invalid');\n  }\n\n  if (typeof module.prototype.append !== 'function' || typeof module.prototype.finalize !== 'function') {\n    throw new Error('register(' + format + '): format module missing methods');\n  }\n\n  formats[format] = module;\n};\n\n/**\n * Check if the format is already registered.\n * \n * @param {String} format the name of the format.\n * @return boolean\n */\nvending.isRegisteredFormat = function (format) {\n  if (formats[format]) {\n    return true;\n  }\n\n  return false;\n};\n\nvending.registerFormat('zip', require('./lib/plugins/zip'));\nvending.registerFormat('json', require('./lib/plugins/json'));\n\nmodule.exports = vending;"
  },
  {
    "path": "server/libs/archiver/lib/core.js",
    "content": "/**\n * Archiver Core\n *\n * @ignore\n * @license [MIT]{@link https://github.com/archiverjs/node-archiver/blob/master/LICENSE}\n * @copyright (c) 2012-2014 Chris Talkington, contributors.\n */\nvar fs = require('fs');\nvar glob = require('../readdir-glob');\nvar async = require('../../async');\nvar path = require('path');\nvar util = require('../archiverUtils');\n\nvar inherits = require('util').inherits;\nvar ArchiverError = require('./error');\nvar Transform = require('../archiverUtils/readableStream').Transform;\n\nvar win32 = process.platform === 'win32';\n\n/**\n * @constructor\n * @param {String} format The archive format to use.\n * @param {(CoreOptions|TransformOptions)} options See also {@link ZipOptions} and {@link TarOptions}.\n */\nvar Archiver = function (format, options) {\n  if (!(this instanceof Archiver)) {\n    return new Archiver(format, options);\n  }\n\n  if (typeof format !== 'string') {\n    options = format;\n    format = 'zip';\n  }\n\n  options = this.options = util.defaults(options, {\n    highWaterMark: 1024 * 1024,\n    statConcurrency: 4\n  });\n\n  Transform.call(this, options);\n\n  this._format = false;\n  this._module = false;\n  this._pending = 0;\n  this._pointer = 0;\n\n  this._entriesCount = 0;\n  this._entriesProcessedCount = 0;\n  this._fsEntriesTotalBytes = 0;\n  this._fsEntriesProcessedBytes = 0;\n\n  this._queue = async.queue(this._onQueueTask.bind(this), 1);\n  this._queue.drain(this._onQueueDrain.bind(this));\n\n  this._statQueue = async.queue(this._onStatQueueTask.bind(this), options.statConcurrency);\n  this._statQueue.drain(this._onQueueDrain.bind(this));\n\n  this._state = {\n    aborted: false,\n    finalize: false,\n    finalizing: false,\n    finalized: false,\n    modulePiped: false\n  };\n\n  this._streams = [];\n};\n\ninherits(Archiver, Transform);\n\n/**\n * Internal logic for `abort`.\n *\n * @private\n * @return void\n */\nArchiver.prototype._abort = function () {\n  this._state.aborted = true;\n  this._queue.kill();\n  this._statQueue.kill();\n\n  if (this._queue.idle()) {\n    this._shutdown();\n  }\n};\n\n/**\n * Internal helper for appending files.\n *\n * @private\n * @param  {String} filepath The source filepath.\n * @param  {EntryData} data The entry data.\n * @return void\n */\nArchiver.prototype._append = function (filepath, data) {\n  data = data || {};\n\n  var task = {\n    source: null,\n    filepath: filepath\n  };\n\n  if (!data.name) {\n    data.name = filepath;\n  }\n\n  data.sourcePath = filepath;\n  task.data = data;\n  this._entriesCount++;\n\n  if (data.stats && data.stats instanceof fs.Stats) {\n    task = this._updateQueueTaskWithStats(task, data.stats);\n    if (task) {\n      if (data.stats.size) {\n        this._fsEntriesTotalBytes += data.stats.size;\n      }\n\n      this._queue.push(task);\n    }\n  } else {\n    this._statQueue.push(task);\n  }\n};\n\n/**\n * Internal logic for `finalize`.\n *\n * @private\n * @return void\n */\nArchiver.prototype._finalize = function () {\n  if (this._state.finalizing || this._state.finalized || this._state.aborted) {\n    return;\n  }\n\n  this._state.finalizing = true;\n\n  this._moduleFinalize();\n\n  this._state.finalizing = false;\n  this._state.finalized = true;\n};\n\n/**\n * Checks the various state variables to determine if we can `finalize`.\n *\n * @private\n * @return {Boolean}\n */\nArchiver.prototype._maybeFinalize = function () {\n  if (this._state.finalizing || this._state.finalized || this._state.aborted) {\n    return false;\n  }\n\n  if (this._state.finalize && this._pending === 0 && this._queue.idle() && this._statQueue.idle()) {\n    this._finalize();\n    return true;\n  }\n\n  return false;\n};\n\n/**\n * Appends an entry to the module.\n *\n * @private\n * @fires  Archiver#entry\n * @param  {(Buffer|Stream)} source\n * @param  {EntryData} data\n * @param  {Function} callback\n * @return void\n */\nArchiver.prototype._moduleAppend = function (source, data, callback) {\n  if (this._state.aborted) {\n    callback();\n    return;\n  }\n\n  this._module.append(source, data, function (err) {\n    this._task = null;\n\n    if (this._state.aborted) {\n      this._shutdown();\n      return;\n    }\n\n    if (err) {\n      this.emit('error', err);\n      setImmediate(callback);\n      return;\n    }\n\n    /**\n     * Fires when the entry's input has been processed and appended to the archive.\n     *\n     * @event Archiver#entry\n     * @type {EntryData}\n     */\n    this.emit('entry', data);\n    this._entriesProcessedCount++;\n\n    if (data.stats && data.stats.size) {\n      this._fsEntriesProcessedBytes += data.stats.size;\n    }\n\n    /**\n     * @event Archiver#progress\n     * @type {ProgressData}\n     */\n    this.emit('progress', {\n      entries: {\n        total: this._entriesCount,\n        processed: this._entriesProcessedCount\n      },\n      fs: {\n        totalBytes: this._fsEntriesTotalBytes,\n        processedBytes: this._fsEntriesProcessedBytes\n      }\n    });\n\n    setImmediate(callback);\n  }.bind(this));\n};\n\n/**\n * Finalizes the module.\n *\n * @private\n * @return void\n */\nArchiver.prototype._moduleFinalize = function () {\n  if (typeof this._module.finalize === 'function') {\n    this._module.finalize();\n  } else if (typeof this._module.end === 'function') {\n    this._module.end();\n  } else {\n    this.emit('error', new ArchiverError('NOENDMETHOD'));\n  }\n};\n\n/**\n * Pipes the module to our internal stream with error bubbling.\n *\n * @private\n * @return void\n */\nArchiver.prototype._modulePipe = function () {\n  this._module.on('error', this._onModuleError.bind(this));\n  this._module.pipe(this);\n  this._state.modulePiped = true;\n};\n\n/**\n * Determines if the current module supports a defined feature.\n *\n * @private\n * @param  {String} key\n * @return {Boolean}\n */\nArchiver.prototype._moduleSupports = function (key) {\n  if (!this._module.supports || !this._module.supports[key]) {\n    return false;\n  }\n\n  return this._module.supports[key];\n};\n\n/**\n * Unpipes the module from our internal stream.\n *\n * @private\n * @return void\n */\nArchiver.prototype._moduleUnpipe = function () {\n  this._module.unpipe(this);\n  this._state.modulePiped = false;\n};\n\n/**\n * Normalizes entry data with fallbacks for key properties.\n *\n * @private\n * @param  {Object} data\n * @param  {fs.Stats} stats\n * @return {Object}\n */\nArchiver.prototype._normalizeEntryData = function (data, stats) {\n  data = util.defaults(data, {\n    type: 'file',\n    name: null,\n    date: null,\n    mode: null,\n    prefix: null,\n    sourcePath: null,\n    stats: false\n  });\n\n  if (stats && data.stats === false) {\n    data.stats = stats;\n  }\n\n  var isDir = data.type === 'directory';\n\n  if (data.name) {\n    if (typeof data.prefix === 'string' && '' !== data.prefix) {\n      data.name = data.prefix + '/' + data.name;\n      data.prefix = null;\n    }\n\n    data.name = util.sanitizePath(data.name);\n\n    if (data.type !== 'symlink' && data.name.slice(-1) === '/') {\n      isDir = true;\n      data.type = 'directory';\n    } else if (isDir) {\n      data.name += '/';\n    }\n  }\n\n  // 511 === 0777; 493 === 0755; 438 === 0666; 420 === 0644\n  if (typeof data.mode === 'number') {\n    if (win32) {\n      data.mode &= 511;\n    } else {\n      data.mode &= 4095\n    }\n  } else if (data.stats && data.mode === null) {\n    if (win32) {\n      data.mode = data.stats.mode & 511;\n    } else {\n      data.mode = data.stats.mode & 4095;\n    }\n\n    // stat isn't reliable on windows; force 0755 for dir\n    if (win32 && isDir) {\n      data.mode = 493;\n    }\n  } else if (data.mode === null) {\n    data.mode = isDir ? 493 : 420;\n  }\n\n  if (data.stats && data.date === null) {\n    data.date = data.stats.mtime;\n  } else {\n    data.date = util.dateify(data.date);\n  }\n\n  return data;\n};\n\n/**\n * Error listener that re-emits error on to our internal stream.\n *\n * @private\n * @param  {Error} err\n * @return void\n */\nArchiver.prototype._onModuleError = function (err) {\n  /**\n   * @event Archiver#error\n   * @type {ErrorData}\n   */\n  this.emit('error', err);\n};\n\n/**\n * Checks the various state variables after queue has drained to determine if\n * we need to `finalize`.\n *\n * @private\n * @return void\n */\nArchiver.prototype._onQueueDrain = function () {\n  if (this._state.finalizing || this._state.finalized || this._state.aborted) {\n    return;\n  }\n\n  if (this._state.finalize && this._pending === 0 && this._queue.idle() && this._statQueue.idle()) {\n    this._finalize();\n  }\n};\n\n/**\n * Appends each queue task to the module.\n *\n * @private\n * @param  {Object} task\n * @param  {Function} callback\n * @return void\n */\nArchiver.prototype._onQueueTask = function (task, callback) {\n  var fullCallback = () => {\n    if (task.data.callback) {\n      task.data.callback();\n    }\n    callback();\n  }\n\n  if (this._state.finalizing || this._state.finalized || this._state.aborted) {\n    fullCallback();\n    return;\n  }\n\n  this._task = task;\n  this._moduleAppend(task.source, task.data, fullCallback);\n};\n\n/**\n * Performs a file stat and reinjects the task back into the queue.\n *\n * @private\n * @param  {Object} task\n * @param  {Function} callback\n * @return void\n */\nArchiver.prototype._onStatQueueTask = function (task, callback) {\n  if (this._state.finalizing || this._state.finalized || this._state.aborted) {\n    callback();\n    return;\n  }\n\n  fs.lstat(task.filepath, function (err, stats) {\n    if (this._state.aborted) {\n      setImmediate(callback);\n      return;\n    }\n\n    if (err) {\n      this._entriesCount--;\n\n      /**\n       * @event Archiver#warning\n       * @type {ErrorData}\n       */\n      this.emit('warning', err);\n      setImmediate(callback);\n      return;\n    }\n\n    task = this._updateQueueTaskWithStats(task, stats);\n\n    if (task) {\n      if (stats.size) {\n        this._fsEntriesTotalBytes += stats.size;\n      }\n\n      this._queue.push(task);\n    }\n\n    setImmediate(callback);\n  }.bind(this));\n};\n\n/**\n * Unpipes the module and ends our internal stream.\n *\n * @private\n * @return void\n */\nArchiver.prototype._shutdown = function () {\n  this._moduleUnpipe();\n  this.end();\n};\n\n/**\n * Tracks the bytes emitted by our internal stream.\n *\n * @private\n * @param  {Buffer} chunk\n * @param  {String} encoding\n * @param  {Function} callback\n * @return void\n */\nArchiver.prototype._transform = function (chunk, encoding, callback) {\n  if (chunk) {\n    this._pointer += chunk.length;\n  }\n\n  callback(null, chunk);\n};\n\n/**\n * Updates and normalizes a queue task using stats data.\n *\n * @private\n * @param  {Object} task\n * @param  {fs.Stats} stats\n * @return {Object}\n */\nArchiver.prototype._updateQueueTaskWithStats = function (task, stats) {\n  if (stats.isFile()) {\n    task.data.type = 'file';\n    task.data.sourceType = 'stream';\n    task.source = util.lazyReadStream(task.filepath);\n  } else if (stats.isDirectory() && this._moduleSupports('directory')) {\n    task.data.name = util.trailingSlashIt(task.data.name);\n    task.data.type = 'directory';\n    task.data.sourcePath = util.trailingSlashIt(task.filepath);\n    task.data.sourceType = 'buffer';\n    task.source = Buffer.concat([]);\n  } else if (stats.isSymbolicLink() && this._moduleSupports('symlink')) {\n    var linkPath = fs.readlinkSync(task.filepath);\n    var dirName = path.dirname(task.filepath);\n    task.data.type = 'symlink';\n    task.data.linkname = path.relative(dirName, path.resolve(dirName, linkPath));\n    task.data.sourceType = 'buffer';\n    task.source = Buffer.concat([]);\n  } else {\n    if (stats.isDirectory()) {\n      this.emit('warning', new ArchiverError('DIRECTORYNOTSUPPORTED', task.data));\n    } else if (stats.isSymbolicLink()) {\n      this.emit('warning', new ArchiverError('SYMLINKNOTSUPPORTED', task.data));\n    } else {\n      this.emit('warning', new ArchiverError('ENTRYNOTSUPPORTED', task.data));\n    }\n\n    return null;\n  }\n\n  task.data = this._normalizeEntryData(task.data, stats);\n\n  return task;\n};\n\n/**\n * Aborts the archiving process, taking a best-effort approach, by:\n *\n * - removing any pending queue tasks\n * - allowing any active queue workers to finish\n * - detaching internal module pipes\n * - ending both sides of the Transform stream\n *\n * It will NOT drain any remaining sources.\n *\n * @return {this}\n */\nArchiver.prototype.abort = function () {\n  if (this._state.aborted || this._state.finalized) {\n    return this;\n  }\n\n  this._abort();\n\n  return this;\n};\n\n/**\n * Appends an input source (text string, buffer, or stream) to the instance.\n *\n * When the instance has received, processed, and emitted the input, the `entry`\n * event is fired.\n *\n * @fires  Archiver#entry\n * @param  {(Buffer|Stream|String)} source The input source.\n * @param  {EntryData} data See also {@link ZipEntryData} and {@link TarEntryData}.\n * @return {this}\n */\nArchiver.prototype.append = function (source, data) {\n  if (this._state.finalize || this._state.aborted) {\n    this.emit('error', new ArchiverError('QUEUECLOSED'));\n    return this;\n  }\n\n  data = this._normalizeEntryData(data);\n\n  if (typeof data.name !== 'string' || data.name.length === 0) {\n    this.emit('error', new ArchiverError('ENTRYNAMEREQUIRED'));\n    return this;\n  }\n\n  if (data.type === 'directory' && !this._moduleSupports('directory')) {\n    this.emit('error', new ArchiverError('DIRECTORYNOTSUPPORTED', { name: data.name }));\n    return this;\n  }\n\n  source = util.normalizeInputSource(source);\n\n  if (Buffer.isBuffer(source)) {\n    data.sourceType = 'buffer';\n  } else if (util.isStream(source)) {\n    data.sourceType = 'stream';\n  } else {\n    this.emit('error', new ArchiverError('INPUTSTEAMBUFFERREQUIRED', { name: data.name }));\n    return this;\n  }\n\n  this._entriesCount++;\n  this._queue.push({\n    data: data,\n    source: source\n  });\n\n  return this;\n};\n\n/**\n * Appends a directory and its files, recursively, given its dirpath.\n *\n * @param  {String} dirpath The source directory path.\n * @param  {String} destpath The destination path within the archive.\n * @param  {(EntryData|Function)} data See also [ZipEntryData]{@link ZipEntryData} and\n * [TarEntryData]{@link TarEntryData}.\n * @return {this}\n */\nArchiver.prototype.directory = function (dirpath, destpath, data) {\n  if (this._state.finalize || this._state.aborted) {\n    this.emit('error', new ArchiverError('QUEUECLOSED'));\n    return this;\n  }\n\n  if (typeof dirpath !== 'string' || dirpath.length === 0) {\n    this.emit('error', new ArchiverError('DIRECTORYDIRPATHREQUIRED'));\n    return this;\n  }\n\n  this._pending++;\n\n  if (destpath === false) {\n    destpath = '';\n  } else if (typeof destpath !== 'string') {\n    destpath = dirpath;\n  }\n\n  var dataFunction = false;\n  if (typeof data === 'function') {\n    dataFunction = data;\n    data = {};\n  } else if (typeof data !== 'object') {\n    data = {};\n  }\n\n  var globOptions = {\n    stat: true,\n    dot: true\n  };\n\n  function onGlobEnd() {\n    this._pending--;\n    this._maybeFinalize();\n  }\n\n  function onGlobError(err) {\n    this.emit('error', err);\n  }\n\n  function onGlobMatch(match) {\n    globber.pause();\n\n    var ignoreMatch = false;\n    var entryData = Object.assign({}, data);\n    entryData.name = match.relative;\n    entryData.prefix = destpath;\n    entryData.stats = match.stat;\n    entryData.callback = globber.resume.bind(globber);\n\n    try {\n      if (dataFunction) {\n        entryData = dataFunction(entryData);\n\n        if (entryData === false) {\n          ignoreMatch = true;\n        } else if (typeof entryData !== 'object') {\n          throw new ArchiverError('DIRECTORYFUNCTIONINVALIDDATA', { dirpath: dirpath });\n        }\n      }\n    } catch (e) {\n      this.emit('error', e);\n      return;\n    }\n\n    if (ignoreMatch) {\n      globber.resume();\n      return;\n    }\n\n    this._append(match.absolute, entryData);\n  }\n\n  var globber = glob(dirpath, globOptions);\n  globber.on('error', onGlobError.bind(this));\n  globber.on('match', onGlobMatch.bind(this));\n  globber.on('end', onGlobEnd.bind(this));\n\n  return this;\n};\n\n/**\n * Appends a file given its filepath using a\n * [lazystream]{@link https://github.com/jpommerening/node-lazystream} wrapper to\n * prevent issues with open file limits.\n *\n * When the instance has received, processed, and emitted the file, the `entry`\n * event is fired.\n *\n * @param  {String} filepath The source filepath.\n * @param  {EntryData} data See also [ZipEntryData]{@link ZipEntryData} and\n * [TarEntryData]{@link TarEntryData}.\n * @return {this}\n */\nArchiver.prototype.file = function (filepath, data) {\n  if (this._state.finalize || this._state.aborted) {\n    this.emit('error', new ArchiverError('QUEUECLOSED'));\n    return this;\n  }\n\n  if (typeof filepath !== 'string' || filepath.length === 0) {\n    this.emit('error', new ArchiverError('FILEFILEPATHREQUIRED'));\n    return this;\n  }\n\n  this._append(filepath, data);\n\n  return this;\n};\n\n/**\n * Appends multiple files that match a glob pattern.\n *\n * @param  {String} pattern The [glob pattern]{@link https://github.com/isaacs/minimatch} to match.\n * @param  {Object} options See [node-readdir-glob]{@link https://github.com/yqnn/node-readdir-glob#options}.\n * @param  {EntryData} data See also [ZipEntryData]{@link ZipEntryData} and\n * [TarEntryData]{@link TarEntryData}.\n * @return {this}\n */\nArchiver.prototype.glob = function (pattern, options, data) {\n  this._pending++;\n\n  options = util.defaults(options, {\n    stat: true,\n    pattern: pattern\n  });\n\n  function onGlobEnd() {\n    this._pending--;\n    this._maybeFinalize();\n  }\n\n  function onGlobError(err) {\n    this.emit('error', err);\n  }\n\n  function onGlobMatch(match) {\n    globber.pause();\n    var entryData = Object.assign({}, data);\n    entryData.callback = globber.resume.bind(globber);\n    entryData.stats = match.stat;\n    entryData.name = match.relative;\n\n    this._append(match.absolute, entryData);\n  }\n\n  var globber = glob(options.cwd || '.', options);\n  globber.on('error', onGlobError.bind(this));\n  globber.on('match', onGlobMatch.bind(this));\n  globber.on('end', onGlobEnd.bind(this));\n\n  return this;\n};\n\n/**\n * Finalizes the instance and prevents further appending to the archive\n * structure (queue will continue til drained).\n *\n * The `end`, `close` or `finish` events on the destination stream may fire\n * right after calling this method so you should set listeners beforehand to\n * properly detect stream completion.\n *\n * @return {Promise}\n */\nArchiver.prototype.finalize = function () {\n  if (this._state.aborted) {\n    var abortedError = new ArchiverError('ABORTED');\n    this.emit('error', abortedError);\n    return Promise.reject(abortedError);\n  }\n\n  if (this._state.finalize) {\n    var finalizingError = new ArchiverError('FINALIZING');\n    this.emit('error', finalizingError);\n    return Promise.reject(finalizingError);\n  }\n\n  this._state.finalize = true;\n\n  if (this._pending === 0 && this._queue.idle() && this._statQueue.idle()) {\n    this._finalize();\n  }\n\n  var self = this;\n\n  return new Promise(function (resolve, reject) {\n    var errored;\n\n    self._module.on('end', function () {\n      if (!errored) {\n        resolve();\n      }\n    })\n\n    self._module.on('error', function (err) {\n      errored = true;\n      reject(err);\n    })\n  })\n};\n\n/**\n * Sets the module format name used for archiving.\n *\n * @param {String} format The name of the format.\n * @return {this}\n */\nArchiver.prototype.setFormat = function (format) {\n  if (this._format) {\n    this.emit('error', new ArchiverError('FORMATSET'));\n    return this;\n  }\n\n  this._format = format;\n\n  return this;\n};\n\n/**\n * Sets the module used for archiving.\n *\n * @param {Function} module The function for archiver to interact with.\n * @return {this}\n */\nArchiver.prototype.setModule = function (module) {\n  if (this._state.aborted) {\n    this.emit('error', new ArchiverError('ABORTED'));\n    return this;\n  }\n\n  if (this._state.module) {\n    this.emit('error', new ArchiverError('MODULESET'));\n    return this;\n  }\n\n  this._module = module;\n  this._modulePipe();\n\n  return this;\n};\n\n/**\n * Appends a symlink to the instance.\n *\n * This does NOT interact with filesystem and is used for programmatically creating symlinks.\n *\n * @param  {String} filepath The symlink path (within archive).\n * @param  {String} target The target path (within archive).\n * @param  {Number} mode Sets the entry permissions.\n * @return {this}\n */\nArchiver.prototype.symlink = function (filepath, target, mode) {\n  if (this._state.finalize || this._state.aborted) {\n    this.emit('error', new ArchiverError('QUEUECLOSED'));\n    return this;\n  }\n\n  if (typeof filepath !== 'string' || filepath.length === 0) {\n    this.emit('error', new ArchiverError('SYMLINKFILEPATHREQUIRED'));\n    return this;\n  }\n\n  if (typeof target !== 'string' || target.length === 0) {\n    this.emit('error', new ArchiverError('SYMLINKTARGETREQUIRED', { filepath: filepath }));\n    return this;\n  }\n\n  if (!this._moduleSupports('symlink')) {\n    this.emit('error', new ArchiverError('SYMLINKNOTSUPPORTED', { filepath: filepath }));\n    return this;\n  }\n\n  var data = {};\n  data.type = 'symlink';\n  data.name = filepath.replace(/\\\\/g, '/');\n  data.linkname = target.replace(/\\\\/g, '/');\n  data.sourceType = 'buffer';\n\n  if (typeof mode === \"number\") {\n    data.mode = mode;\n  }\n\n  this._entriesCount++;\n  this._queue.push({\n    data: data,\n    source: Buffer.concat([])\n  });\n\n  return this;\n};\n\n/**\n * Returns the current length (in bytes) that has been emitted.\n *\n * @return {Number}\n */\nArchiver.prototype.pointer = function () {\n  return this._pointer;\n};\n\n/**\n * Middleware-like helper that has yet to be fully implemented.\n *\n * @private\n * @param  {Function} plugin\n * @return {this}\n */\nArchiver.prototype.use = function (plugin) {\n  this._streams.push(plugin);\n  return this;\n};\n\nmodule.exports = Archiver;\n\n/**\n * @typedef {Object} CoreOptions\n * @global\n * @property {Number} [statConcurrency=4] Sets the number of workers used to\n * process the internal fs stat queue.\n */\n\n/**\n * @typedef {Object} TransformOptions\n * @property {Boolean} [allowHalfOpen=true] If set to false, then the stream\n * will automatically end the readable side when the writable side ends and vice\n * versa.\n * @property {Boolean} [readableObjectMode=false] Sets objectMode for readable\n * side of the stream. Has no effect if objectMode is true.\n * @property {Boolean} [writableObjectMode=false] Sets objectMode for writable\n * side of the stream. Has no effect if objectMode is true.\n * @property {Boolean} [decodeStrings=true] Whether or not to decode strings\n * into Buffers before passing them to _write(). `Writable`\n * @property {String} [encoding=NULL] If specified, then buffers will be decoded\n * to strings using the specified encoding. `Readable`\n * @property {Number} [highWaterMark=16kb] The maximum number of bytes to store\n * in the internal buffer before ceasing to read from the underlying resource.\n * `Readable` `Writable`\n * @property {Boolean} [objectMode=false] Whether this stream should behave as a\n * stream of objects. Meaning that stream.read(n) returns a single value instead\n * of a Buffer of size n. `Readable` `Writable`\n */\n\n/**\n * @typedef {Object} EntryData\n * @property {String} name Sets the entry name including internal path.\n * @property {(String|Date)} [date=NOW()] Sets the entry date.\n * @property {Number} [mode=D:0755/F:0644] Sets the entry permissions.\n * @property {String} [prefix] Sets a path prefix for the entry name. Useful\n * when working with methods like `directory` or `glob`.\n * @property {fs.Stats} [stats] Sets the fs stat data for this entry allowing\n * for reduction of fs stat calls when stat data is already known.\n */\n\n/**\n * @typedef {Object} ErrorData\n * @property {String} message The message of the error.\n * @property {String} code The error code assigned to this error.\n * @property {String} data Additional data provided for reporting or debugging (where available).\n */\n\n/**\n * @typedef {Object} ProgressData\n * @property {Object} entries\n * @property {Number} entries.total Number of entries that have been appended.\n * @property {Number} entries.processed Number of entries that have been processed.\n * @property {Object} fs\n * @property {Number} fs.totalBytes Number of bytes that have been appended. Calculated asynchronously and might not be accurate: it growth while entries are added. (based on fs.Stats)\n * @property {Number} fs.processedBytes Number of bytes that have been processed. (based on fs.Stats)\n */\n"
  },
  {
    "path": "server/libs/archiver/lib/error.js",
    "content": "/**\n * Archiver Core\n *\n * @ignore\n * @license [MIT]{@link https://github.com/archiverjs/node-archiver/blob/master/LICENSE}\n * @copyright (c) 2012-2014 Chris Talkington, contributors.\n */\n\nvar util = require('util');\n\nconst ERROR_CODES = {\n  'ABORTED': 'archive was aborted',\n  'DIRECTORYDIRPATHREQUIRED': 'diretory dirpath argument must be a non-empty string value',\n  'DIRECTORYFUNCTIONINVALIDDATA': 'invalid data returned by directory custom data function',\n  'ENTRYNAMEREQUIRED': 'entry name must be a non-empty string value',\n  'FILEFILEPATHREQUIRED': 'file filepath argument must be a non-empty string value',\n  'FINALIZING': 'archive already finalizing',\n  'QUEUECLOSED': 'queue closed',\n  'NOENDMETHOD': 'no suitable finalize/end method defined by module',\n  'DIRECTORYNOTSUPPORTED': 'support for directory entries not defined by module',\n  'FORMATSET': 'archive format already set',\n  'INPUTSTEAMBUFFERREQUIRED': 'input source must be valid Stream or Buffer instance',\n  'MODULESET': 'module already set',\n  'SYMLINKNOTSUPPORTED': 'support for symlink entries not defined by module',\n  'SYMLINKFILEPATHREQUIRED': 'symlink filepath argument must be a non-empty string value',\n  'SYMLINKTARGETREQUIRED': 'symlink target argument must be a non-empty string value',\n  'ENTRYNOTSUPPORTED': 'entry not supported'\n};\n\nfunction ArchiverError(code, data) {\n  Error.captureStackTrace(this, this.constructor);\n  //this.name = this.constructor.name;\n  this.message = ERROR_CODES[code] || code;\n  this.code = code;\n  this.data = data;\n}\n\nutil.inherits(ArchiverError, Error);\n\nexports = module.exports = ArchiverError;"
  },
  {
    "path": "server/libs/archiver/lib/plugins/json.js",
    "content": "/**\n * JSON Format Plugin\n *\n * @module plugins/json\n * @license [MIT]{@link https://github.com/archiverjs/node-archiver/blob/master/LICENSE}\n * @copyright (c) 2012-2014 Chris Talkington, contributors.\n */\nvar inherits = require('util').inherits;\nvar Transform = require('../../archiverUtils/readableStream').Transform;\n\nvar crc32 = require('../../buffer-crc32');\nvar util = require('../../archiverUtils');\n\n/**\n * @constructor\n * @param {(JsonOptions|TransformOptions)} options\n */\nvar Json = function (options) {\n  if (!(this instanceof Json)) {\n    return new Json(options);\n  }\n\n  options = this.options = util.defaults(options, {});\n\n  Transform.call(this, options);\n\n  this.supports = {\n    directory: true,\n    symlink: true\n  };\n\n  this.files = [];\n};\n\ninherits(Json, Transform);\n\n/**\n * [_transform description]\n *\n * @private\n * @param  {Buffer}   chunk\n * @param  {String}   encoding\n * @param  {Function} callback\n * @return void\n */\nJson.prototype._transform = function (chunk, encoding, callback) {\n  callback(null, chunk);\n};\n\n/**\n * [_writeStringified description]\n *\n * @private\n * @return void\n */\nJson.prototype._writeStringified = function () {\n  var fileString = JSON.stringify(this.files);\n  this.write(fileString);\n};\n\n/**\n * [append description]\n *\n * @param  {(Buffer|Stream)}   source\n * @param  {EntryData}   data\n * @param  {Function} callback\n * @return void\n */\nJson.prototype.append = function (source, data, callback) {\n  var self = this;\n\n  data.crc32 = 0;\n\n  function onend(err, sourceBuffer) {\n    if (err) {\n      callback(err);\n      return;\n    }\n\n    data.size = sourceBuffer.length || 0;\n    data.crc32 = crc32.unsigned(sourceBuffer);\n\n    self.files.push(data);\n\n    callback(null, data);\n  }\n\n  if (data.sourceType === 'buffer') {\n    onend(null, source);\n  } else if (data.sourceType === 'stream') {\n    util.collectStream(source, onend);\n  }\n};\n\n/**\n * [finalize description]\n *\n * @return void\n */\nJson.prototype.finalize = function () {\n  this._writeStringified();\n  this.end();\n};\n\nmodule.exports = Json;\n\n/**\n * @typedef {Object} JsonOptions\n * @global\n */\n"
  },
  {
    "path": "server/libs/archiver/lib/plugins/zip.js",
    "content": "/**\n * ZIP Format Plugin\n *\n * @module plugins/zip\n * @license [MIT]{@link https://github.com/archiverjs/node-archiver/blob/master/LICENSE}\n * @copyright (c) 2012-2014 Chris Talkington, contributors.\n */\nvar engine = require('../../zip-stream');\nvar util = require('../../archiverUtils');\n\n/**\n * @constructor\n * @param {ZipOptions} [options]\n * @param {String} [options.comment] Sets the zip archive comment.\n * @param {Boolean} [options.forceLocalTime=false] Forces the archive to contain local file times instead of UTC.\n * @param {Boolean} [options.forceZip64=false] Forces the archive to contain ZIP64 headers.\n * @param {Boolean} [options.namePrependSlash=false] Prepends a forward slash to archive file paths.\n * @param {Boolean} [options.store=false] Sets the compression method to STORE.\n * @param {Object} [options.zlib] Passed to [zlib]{@link https://nodejs.org/api/zlib.html#zlib_class_options}\n */\nvar Zip = function (options) {\n  if (!(this instanceof Zip)) {\n    return new Zip(options);\n  }\n\n  options = this.options = util.defaults(options, {\n    comment: '',\n    forceUTC: false,\n    namePrependSlash: false,\n    store: false\n  });\n\n  this.supports = {\n    directory: true,\n    symlink: true\n  };\n\n  this.engine = new engine(options);\n};\n\n/**\n * @param  {(Buffer|Stream)} source\n * @param  {ZipEntryData} data\n * @param  {String} data.name Sets the entry name including internal path.\n * @param  {(String|Date)} [data.date=NOW()] Sets the entry date.\n * @param  {Number} [data.mode=D:0755/F:0644] Sets the entry permissions.\n * @param  {String} [data.prefix] Sets a path prefix for the entry name. Useful\n * when working with methods like `directory` or `glob`.\n * @param  {fs.Stats} [data.stats] Sets the fs stat data for this entry allowing\n * for reduction of fs stat calls when stat data is already known.\n * @param  {Boolean} [data.store=ZipOptions.store] Sets the compression method to STORE.\n * @param  {Function} callback\n * @return void\n */\nZip.prototype.append = function (source, data, callback) {\n  this.engine.entry(source, data, callback);\n};\n\n/**\n * @return void\n */\nZip.prototype.finalize = function () {\n  this.engine.finalize();\n};\n\n/**\n * @return this.engine\n */\nZip.prototype.on = function () {\n  return this.engine.on.apply(this.engine, arguments);\n};\n\n/**\n * @return this.engine\n */\nZip.prototype.pipe = function () {\n  return this.engine.pipe.apply(this.engine, arguments);\n};\n\n/**\n * @return this.engine\n */\nZip.prototype.unpipe = function () {\n  return this.engine.unpipe.apply(this.engine, arguments);\n};\n\nmodule.exports = Zip;\n\n/**\n * @typedef {Object} ZipOptions\n * @global\n * @property {String} [comment] Sets the zip archive comment.\n * @property {Boolean} [forceLocalTime=false] Forces the archive to contain local file times instead of UTC.\n * @property {Boolean} [forceZip64=false] Forces the archive to contain ZIP64 headers.\n * @prpperty {Boolean} [namePrependSlash=false] Prepends a forward slash to archive file paths.\n * @property {Boolean} [store=false] Sets the compression method to STORE.\n * @property {Object} [zlib] Passed to [zlib]{@link https://nodejs.org/api/zlib.html#zlib_class_options}\n * to control compression.\n * @property {*} [*] See [zip-stream]{@link https://archiverjs.com/zip-stream/ZipStream.html} documentation for current list of properties.\n */\n\n/**\n * @typedef {Object} ZipEntryData\n * @global\n * @property {String} name Sets the entry name including internal path.\n * @property {(String|Date)} [date=NOW()] Sets the entry date.\n * @property {Number} [mode=D:0755/F:0644] Sets the entry permissions.\n * @property {Boolean} [namePrependSlash=ZipOptions.namePrependSlash] Prepends a forward slash to archive file paths.\n * @property {String} [prefix] Sets a path prefix for the entry name. Useful\n * when working with methods like `directory` or `glob`.\n * @property {fs.Stats} [stats] Sets the fs stat data for this entry allowing\n * for reduction of fs stat calls when stat data is already known.\n * @property {Boolean} [store=ZipOptions.store] Sets the compression method to STORE.\n */\n\n/**\n * ZipStream Module\n * @external ZipStream\n * @see {@link https://www.archiverjs.com/zip-stream/ZipStream.html}\n */\n"
  },
  {
    "path": "server/libs/archiver/normalize-path/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2014-2018, Jon Schlinkert.\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\nall copies 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\nTHE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/normalize-path/index.js",
    "content": "/*!\n * normalize-path <https://github.com/jonschlinkert/normalize-path>\n *\n * Copyright (c) 2014-2018, Jon Schlinkert.\n * Released under the MIT License.\n */\n\nmodule.exports = function (path, stripTrailing) {\n  if (typeof path !== 'string') {\n    throw new TypeError('expected path to be a string');\n  }\n\n  if (path === '\\\\' || path === '/') return '/';\n\n  var len = path.length;\n  if (len <= 1) return path;\n\n  // ensure that win32 namespaces has two leading slashes, so that the path is\n  // handled properly by the win32 version of path.parse() after being normalized\n  // https://msdn.microsoft.com/library/windows/desktop/aa365247(v=vs.85).aspx#namespaces\n  var prefix = '';\n  if (len > 4 && path[3] === '\\\\') {\n    var ch = path[2];\n    if ((ch === '?' || ch === '.') && path.slice(0, 2) === '\\\\\\\\') {\n      path = path.slice(2);\n      prefix = '//';\n    }\n  }\n\n  var segs = path.split(/[/\\\\]+/);\n  if (stripTrailing !== false && segs[segs.length - 1] === '') {\n    segs.pop();\n  }\n  return prefix + segs.join('/');\n};"
  },
  {
    "path": "server/libs/archiver/readdir-glob/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2020 Yann Armelin\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "server/libs/archiver/readdir-glob/index.js",
    "content": "//\n// used by archiver\n// Source: https://github.com/Yqnn/node-readdir-glob\n//\n\nmodule.exports = readdirGlob;\n\nconst fs = require('fs');\nconst { EventEmitter } = require('events');\nconst { Minimatch } = require('../archiverUtils/minimatch');\nconst { resolve } = require('path');\n\nfunction readdir(dir, strict) {\n  return new Promise((resolve, reject) => {\n    fs.readdir(dir, { withFileTypes: true }, (err, files) => {\n      if (err) {\n        switch (err.code) {\n          case 'ENOTDIR':      // Not a directory\n            if (strict) {\n              reject(err);\n            } else {\n              resolve([]);\n            }\n            break;\n          case 'ENOTSUP':      // Operation not supported\n          case 'ENOENT':       // No such file or directory\n          case 'ENAMETOOLONG': // Filename too long\n          case 'UNKNOWN':\n            resolve([]);\n            break;\n          case 'ELOOP':        // Too many levels of symbolic links\n          default:\n            reject(err);\n            break;\n        }\n      } else {\n        resolve(files);\n      }\n    });\n  });\n}\nfunction stat(file, followSymlinks) {\n  return new Promise((resolve, reject) => {\n    const statFunc = followSymlinks ? fs.stat : fs.lstat;\n    statFunc(file, (err, stats) => {\n      if (err) {\n        switch (err.code) {\n          case 'ENOENT':\n            if (followSymlinks) {\n              // Fallback to lstat to handle broken links as files\n              resolve(stat(file, false));\n            } else {\n              resolve(null);\n            }\n            break;\n          default:\n            resolve(null);\n            break;\n        }\n      } else {\n        resolve(stats);\n      }\n    });\n  });\n}\n\nasync function* exploreWalkAsync(dir, path, followSymlinks, useStat, shouldSkip, strict) {\n  let files = await readdir(path + dir, strict);\n  for (const file of files) {\n    let name = file.name;\n    if (name === undefined) {\n      // undefined file.name means the `withFileTypes` options is not supported by node\n      // we have to call the stat function to know if file is directory or not.\n      name = file;\n      useStat = true;\n    }\n    const filename = dir + '/' + name;\n    const relative = filename.slice(1); // Remove the leading /\n    const absolute = path + '/' + relative;\n    let stats = null;\n    if (useStat || followSymlinks) {\n      stats = await stat(absolute, followSymlinks);\n    }\n    if (!stats && file.name !== undefined) {\n      stats = file;\n    }\n    if (stats === null) {\n      stats = { isDirectory: () => false };\n    }\n\n    if (stats.isDirectory()) {\n      if (!shouldSkip(relative)) {\n        yield { relative, absolute, stats };\n        yield* exploreWalkAsync(filename, path, followSymlinks, useStat, shouldSkip, false);\n      }\n    } else {\n      yield { relative, absolute, stats };\n    }\n  }\n}\nasync function* explore(path, followSymlinks, useStat, shouldSkip) {\n  yield* exploreWalkAsync('', path, followSymlinks, useStat, shouldSkip, true);\n}\n\n\nfunction readOptions(options) {\n  return {\n    pattern: options.pattern,\n    dot: !!options.dot,\n    noglobstar: !!options.noglobstar,\n    matchBase: !!options.matchBase,\n    nocase: !!options.nocase,\n    ignore: options.ignore,\n    skip: options.skip,\n\n    follow: !!options.follow,\n    stat: !!options.stat,\n    nodir: !!options.nodir,\n    mark: !!options.mark,\n    silent: !!options.silent,\n    absolute: !!options.absolute\n  };\n}\n\nclass ReaddirGlob extends EventEmitter {\n  constructor(cwd, options, cb) {\n    super();\n    if (typeof options === 'function') {\n      cb = options;\n      options = null;\n    }\n\n    this.options = readOptions(options || {});\n\n    this.matchers = [];\n    if (this.options.pattern) {\n      const matchers = Array.isArray(this.options.pattern) ? this.options.pattern : [this.options.pattern];\n      this.matchers = matchers.map(m =>\n        new Minimatch(m, {\n          dot: this.options.dot,\n          noglobstar: this.options.noglobstar,\n          matchBase: this.options.matchBase,\n          nocase: this.options.nocase\n        })\n      );\n    }\n\n    this.ignoreMatchers = [];\n    if (this.options.ignore) {\n      const ignorePatterns = Array.isArray(this.options.ignore) ? this.options.ignore : [this.options.ignore];\n      this.ignoreMatchers = ignorePatterns.map(ignore =>\n        new Minimatch(ignore, { dot: true })\n      );\n    }\n\n    this.skipMatchers = [];\n    if (this.options.skip) {\n      const skipPatterns = Array.isArray(this.options.skip) ? this.options.skip : [this.options.skip];\n      this.skipMatchers = skipPatterns.map(skip =>\n        new Minimatch(skip, { dot: true })\n      );\n    }\n\n    this.iterator = explore(resolve(cwd || '.'), this.options.follow, this.options.stat, this._shouldSkipDirectory.bind(this));\n    this.paused = false;\n    this.inactive = false;\n    this.aborted = false;\n\n    if (cb) {\n      this._matches = [];\n      this.on('match', match => this._matches.push(this.options.absolute ? match.absolute : match.relative));\n      this.on('error', err => cb(err));\n      this.on('end', () => cb(null, this._matches));\n    }\n\n    setTimeout(() => this._next(), 0);\n  }\n\n  _shouldSkipDirectory(relative) {\n    //console.log(relative, this.skipMatchers.some(m => m.match(relative)));\n    return this.skipMatchers.some(m => m.match(relative));\n  }\n\n  _fileMatches(relative, isDirectory) {\n    const file = relative + (isDirectory ? '/' : '');\n    return (this.matchers.length === 0 || this.matchers.some(m => m.match(file)))\n      && !this.ignoreMatchers.some(m => m.match(file))\n      && (!this.options.nodir || !isDirectory);\n  }\n\n  _next() {\n    if (!this.paused && !this.aborted) {\n      this.iterator.next()\n        .then((obj) => {\n          if (!obj.done) {\n            const isDirectory = obj.value.stats.isDirectory();\n            if (this._fileMatches(obj.value.relative, isDirectory)) {\n              let relative = obj.value.relative;\n              let absolute = obj.value.absolute;\n              if (this.options.mark && isDirectory) {\n                relative += '/';\n                absolute += '/';\n              }\n              if (this.options.stat) {\n                this.emit('match', { relative, absolute, stat: obj.value.stats });\n              } else {\n                this.emit('match', { relative, absolute });\n              }\n            }\n            this._next(this.iterator);\n          } else {\n            this.emit('end');\n          }\n        })\n        .catch((err) => {\n          this.abort();\n          this.emit('error', err);\n          if (!err.code && !this.options.silent) {\n            console.error(err);\n          }\n        });\n    } else {\n      this.inactive = true;\n    }\n  }\n\n  abort() {\n    this.aborted = true;\n  }\n\n  pause() {\n    this.paused = true;\n  }\n\n  resume() {\n    this.paused = false;\n    if (this.inactive) {\n      this.inactive = false;\n      this._next();\n    }\n  }\n}\n\n\nfunction readdirGlob(pattern, options, cb) {\n  return new ReaddirGlob(pattern, options, cb);\n}\nreaddirGlob.ReaddirGlob = ReaddirGlob;"
  },
  {
    "path": "server/libs/archiver/zip-stream/LICENSE",
    "content": "Copyright (c) 2014 Chris Talkington, contributors.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/archiver/zip-stream/index.js",
    "content": "/**\n * ZipStream\n *\n * @ignore\n * @license [MIT]{@link https://github.com/archiverjs/node-zip-stream/blob/master/LICENSE}\n * @copyright (c) 2014 Chris Talkington, contributors.\n */\nvar inherits = require('util').inherits;\n\nvar ZipArchiveOutputStream = require('../compress-commons').ZipArchiveOutputStream;\nvar ZipArchiveEntry = require('../compress-commons').ZipArchiveEntry;\n\nvar util = require('../archiverUtils');\n\n/**\n * @constructor\n * @extends external:ZipArchiveOutputStream\n * @param {Object} [options]\n * @param {String} [options.comment] Sets the zip archive comment.\n * @param {Boolean} [options.forceLocalTime=false] Forces the archive to contain local file times instead of UTC.\n * @param {Boolean} [options.forceZip64=false] Forces the archive to contain ZIP64 headers.\n * @param {Boolean} [options.store=false] Sets the compression method to STORE.\n * @param {Object} [options.zlib] Passed to [zlib]{@link https://nodejs.org/api/zlib.html#zlib_class_options}\n * to control compression.\n */\nvar ZipStream = module.exports = function (options) {\n  if (!(this instanceof ZipStream)) {\n    return new ZipStream(options);\n  }\n\n  options = this.options = options || {};\n  options.zlib = options.zlib || {};\n\n  ZipArchiveOutputStream.call(this, options);\n\n  if (typeof options.level === 'number' && options.level >= 0) {\n    options.zlib.level = options.level;\n    delete options.level;\n  }\n\n  if (!options.forceZip64 && typeof options.zlib.level === 'number' && options.zlib.level === 0) {\n    options.store = true;\n  }\n\n  options.namePrependSlash = options.namePrependSlash || false;\n\n  if (options.comment && options.comment.length > 0) {\n    this.setComment(options.comment);\n  }\n};\n\ninherits(ZipStream, ZipArchiveOutputStream);\n\n/**\n * Normalizes entry data with fallbacks for key properties.\n *\n * @private\n * @param  {Object} data\n * @return {Object}\n */\nZipStream.prototype._normalizeFileData = function (data) {\n  data = util.defaults(data, {\n    type: 'file',\n    name: null,\n    namePrependSlash: this.options.namePrependSlash,\n    linkname: null,\n    date: null,\n    mode: null,\n    store: this.options.store,\n    comment: ''\n  });\n\n  var isDir = data.type === 'directory';\n  var isSymlink = data.type === 'symlink';\n\n  if (data.name) {\n    data.name = util.sanitizePath(data.name);\n\n    if (!isSymlink && data.name.slice(-1) === '/') {\n      isDir = true;\n      data.type = 'directory';\n    } else if (isDir) {\n      data.name += '/';\n    }\n  }\n\n  if (isDir || isSymlink) {\n    data.store = true;\n  }\n\n  data.date = util.dateify(data.date);\n\n  return data;\n};\n\n/**\n * Appends an entry given an input source (text string, buffer, or stream).\n *\n * @param  {(Buffer|Stream|String)} source The input source.\n * @param  {Object} data\n * @param  {String} data.name Sets the entry name including internal path.\n * @param  {String} [data.comment] Sets the entry comment.\n * @param  {(String|Date)} [data.date=NOW()] Sets the entry date.\n * @param  {Number} [data.mode=D:0755/F:0644] Sets the entry permissions.\n * @param  {Boolean} [data.store=options.store] Sets the compression method to STORE.\n * @param  {String} [data.type=file] Sets the entry type. Defaults to `directory`\n * if name ends with trailing slash.\n * @param  {Function} callback\n * @return this\n */\nZipStream.prototype.entry = function (source, data, callback) {\n  if (typeof callback !== 'function') {\n    callback = this._emitErrorCallback.bind(this);\n  }\n\n  data = this._normalizeFileData(data);\n\n  if (data.type !== 'file' && data.type !== 'directory' && data.type !== 'symlink') {\n    callback(new Error(data.type + ' entries not currently supported'));\n    return;\n  }\n\n  if (typeof data.name !== 'string' || data.name.length === 0) {\n    callback(new Error('entry name must be a non-empty string value'));\n    return;\n  }\n\n  if (data.type === 'symlink' && typeof data.linkname !== 'string') {\n    callback(new Error('entry linkname must be a non-empty string value when type equals symlink'));\n    return;\n  }\n\n  var entry = new ZipArchiveEntry(data.name);\n  entry.setTime(data.date, this.options.forceLocalTime);\n\n  if (data.namePrependSlash) {\n    entry.setName(data.name, true);\n  }\n\n  if (data.store) {\n    entry.setMethod(0);\n  }\n\n  if (data.comment.length > 0) {\n    entry.setComment(data.comment);\n  }\n\n  if (data.type === 'symlink' && typeof data.mode !== 'number') {\n    data.mode = 40960; // 0120000\n  }\n\n  if (typeof data.mode === 'number') {\n    if (data.type === 'symlink') {\n      data.mode |= 40960;\n    }\n\n    entry.setUnixMode(data.mode);\n  }\n\n  if (data.type === 'symlink' && typeof data.linkname === 'string') {\n    source = Buffer.from(data.linkname);\n  }\n\n  return ZipArchiveOutputStream.prototype.entry.call(this, entry, source, callback);\n};\n\n/**\n * Finalizes the instance and prevents further appending to the archive\n * structure (queue will continue til drained).\n *\n * @return void\n */\nZipStream.prototype.finalize = function () {\n  this.finish();\n};\n\n/**\n * Returns the current number of bytes written to this stream.\n * @function ZipStream#getBytesWritten\n * @returns {Number}\n */\n\n/**\n * Compress Commons ZipArchiveOutputStream\n * @external ZipArchiveOutputStream\n * @see {@link https://github.com/archiverjs/node-compress-commons}\n */"
  },
  {
    "path": "server/libs/async/LICENSE",
    "content": "Copyright (c) 2010-2018 Caolan McMahon\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\nall copies 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\nTHE SOFTWARE."
  },
  {
    "path": "server/libs/async/index.js",
    "content": "//\n// Used by fluentFfmpeg\n// Source: https://github.com/caolan/async\n//\n\n(function(e,t){\"object\"==typeof exports&&\"undefined\"!=typeof module?t(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],t):t(e.async={})})(this,function(e){'use strict';function t(e,...t){return(...n)=>e(...t,...n)}function n(e){return function(...t){var n=t.pop();return e.call(this,t,n)}}function a(e){setTimeout(e,0)}function i(e){return(t,...n)=>e(()=>t(...n))}function r(e){return u(e)?function(...t){const n=t.pop(),a=e.apply(this,t);return s(a,n)}:n(function(t,n){var a;try{a=e.apply(this,t)}catch(t){return n(t)}return a&&\"function\"==typeof a.then?s(a,n):void n(null,a)})}function s(e,t){return e.then(e=>{l(t,null,e)},e=>{l(t,e&&e.message?e:new Error(e))})}function l(e,t,n){try{e(t,n)}catch(e){_e(t=>{throw t},e)}}function u(e){return\"AsyncFunction\"===e[Symbol.toStringTag]}function d(e){return\"AsyncGenerator\"===e[Symbol.toStringTag]}function p(e){return\"function\"==typeof e[Symbol.asyncIterator]}function c(e){if(\"function\"!=typeof e)throw new Error(\"expected a function\");return u(e)?r(e):e}function o(e,t=e.length){if(!t)throw new Error(\"arity is undefined\");return function(...n){return\"function\"==typeof n[t-1]?e.apply(this,n):new Promise((a,i)=>{n[t-1]=(e,...t)=>e?i(e):void a(1<t.length?t:t[0]),e.apply(this,n)})}}function h(e){return function(t,...n){const a=o(function(a){var i=this;return e(t,(e,t)=>{c(e).apply(i,n.concat(t))},a)});return a}}function f(e,t,n,a){t=t||[];var i=[],r=0,s=c(n);return e(t,(e,t,n)=>{var a=r++;s(e,(e,t)=>{i[a]=t,n(e)})},e=>{a(e,i)})}function y(e){return e&&\"number\"==typeof e.length&&0<=e.length&&0==e.length%1}function m(e){function t(...t){if(null!==e){var n=e;e=null,n.apply(this,t)}}return Object.assign(t,e),t}function g(e){return e[Symbol.iterator]&&e[Symbol.iterator]()}function k(e){var t=-1,n=e.length;return function(){return++t<n?{value:e[t],key:t}:null}}function v(e){var t=-1;return function(){var n=e.next();return n.done?null:(t++,{value:n.value,key:t})}}function S(e){var t=e?Object.keys(e):[],n=-1,a=t.length;return function i(){var r=t[++n];return\"__proto__\"===r?i():n<a?{value:e[r],key:r}:null}}function x(e){if(y(e))return k(e);var t=g(e);return t?v(t):S(e)}function L(e){return function(...t){if(null===e)throw new Error(\"Callback was already called.\");var n=e;e=null,n.apply(this,t)}}function E(e,t,n,a){function i(){p>=t||d||l||(d=!0,e.next().then(({value:e,done:t})=>{if(!(u||l))return d=!1,t?(l=!0,void(0>=p&&a(null))):void(p++,n(e,c,r),c++,i())}).catch(s))}function r(e,t){return p-=1,u?void 0:e?s(e):!1===e?(l=!0,void(u=!0)):t===be||l&&0>=p?(l=!0,a(null)):void i()}function s(e){u||(d=!1,l=!0,a(e))}let l=!1,u=!1,d=!1,p=0,c=0;i()}function O(e,t,n){function a(e,t){!1===e&&(l=!0);!0===l||(e?n(e):(++r===s||t===be)&&n(null))}n=m(n);var i=0,r=0,{length:s}=e,l=!1;for(0===s&&n(null);i<s;i++)t(e[i],i,L(a))}function _(e,t,n){return Ie(e,1/0,t,n)}function b(){function e(e,...a){return e?n(e):void t(1<a.length?a:a[0])}let t,n;return e[Ce]=new Promise((e,a)=>{t=e,n=a}),e}function A(e,t,n){function a(e,t){g.push(()=>l(e,t))}function i(){if(!h){if(0===g.length&&0===o)return n(null,p);for(;g.length&&o<t;){var e=g.shift();e()}}}function r(e,t){var n=y[e];n||(n=y[e]=[]),n.push(t)}function s(e){var t=y[e]||[];t.forEach(e=>e()),i()}function l(e,t){if(!f){var a=L((t,...a)=>{if(o--,!1===t)return void(h=!0);if(2>a.length&&([a]=a),t){var i={};if(Object.keys(p).forEach(e=>{i[e]=p[e]}),i[e]=a,f=!0,y=Object.create(null),h)return;n(t,i)}else p[e]=a,s(e)});o++;var i=c(t[t.length-1]);1<t.length?i(p,a):i(a)}}function u(t){var n=[];return Object.keys(e).forEach(a=>{const i=e[a];Array.isArray(i)&&0<=i.indexOf(t)&&n.push(a)}),n}\"number\"!=typeof t&&(n=t,t=null),n=m(n||b());var d=Object.keys(e).length;if(!d)return n(null);t||(t=d);var p={},o=0,h=!1,f=!1,y=Object.create(null),g=[],k=[],v={};return Object.keys(e).forEach(t=>{var n=e[t];if(!Array.isArray(n))return a(t,[n]),void k.push(t);var i=n.slice(0,n.length-1),s=i.length;return 0===s?(a(t,n),void k.push(t)):void(v[t]=s,i.forEach(l=>{if(!e[l])throw new Error(\"async.auto task `\"+t+\"` has a non-existent dependency `\"+l+\"` in \"+i.join(\", \"));r(l,()=>{s--,0===s&&a(t,n)})}))}),function(){for(var e,t=0;k.length;)e=k.pop(),t++,u(e).forEach(e=>{0==--v[e]&&k.push(e)});if(t!==d)throw new Error(\"async.auto cannot execute tasks due to a recursive dependency\")}(),i(),n[Ce]}function I(e){let t=\"\",n=0,a=e.indexOf(\"*/\");for(;n<e.length;)if(\"/\"===e[n]&&\"/\"===e[n+1]){let t=e.indexOf(\"\\n\",n);n=-1===t?e.length:t}else if(-1!==a&&\"/\"===e[n]&&\"*\"===e[n+1]){let i=e.indexOf(\"*/\",n);-1===i?(t+=e[n],n++):(n=i+2,a=e.indexOf(\"*/\",n))}else t+=e[n],n++;return t}function M(e){const t=I(e.toString());let n=t.match(Pe);if(n||(n=t.match(Re)),!n)throw new Error(\"could not parse args in autoInject\\nSource:\\n\"+t);let[,a]=n;return a.replace(/\\s/g,\"\").split(ze).map(e=>e.replace(Ne,\"\").trim())}function j(e,t){var n={};return Object.keys(e).forEach(t=>{function a(e,t){var n=i.map(t=>e[t]);n.push(t),c(r)(...n)}var i,r=e[t],s=u(r),l=!s&&1===r.length||s&&0===r.length;if(Array.isArray(r))i=[...r],r=i.pop(),n[t]=i.concat(0<i.length?a:r);else if(l)n[t]=r;else{if(i=M(r),0===r.length&&!s&&0===i.length)throw new Error(\"autoInject task functions require explicit parameters.\");s||i.pop(),n[t]=i.concat(a)}}),A(n,t)}function w(e,t){e.length=1,e.head=e.tail=t}function B(e,t,n){function a(e,t){f[e].push(t)}function i(e,t){const n=(...a)=>{r(e,n),t(...a)};f[e].push(n)}function r(e,t){return e?t?void(f[e]=f[e].filter(e=>e!==t)):f[e]=[]:Object.keys(f).forEach(e=>f[e]=[])}function s(e,...t){f[e].forEach(e=>e(...t))}function l(e,t,n,a){function i(e,...t){return e?n?s(e):r():1>=t.length?r(t[0]):void r(t)}if(null!=a&&\"function\"!=typeof a)throw new Error(\"task callback must be a function\");k.started=!0;var r,s,l=k._createTaskItem(e,n?i:a||i);if(t?k._tasks.unshift(l):k._tasks.push(l),y||(y=!0,_e(()=>{y=!1,k.process()})),n||!a)return new Promise((e,t)=>{r=e,s=t})}function u(e){return function(t,...n){o-=1;for(var a=0,r=e.length;a<r;a++){var l=e[a],u=h.indexOf(l);0===u?h.shift():0<u&&h.splice(u,1),l.callback(t,...n),null!=t&&s(\"error\",t,l.data)}o<=k.concurrency-k.buffer&&s(\"unsaturated\"),k.idle()&&s(\"drain\"),k.process()}}function d(e){return!!(0===e.length&&k.idle())&&(_e(()=>s(\"drain\")),!0)}if(null==t)t=1;else if(0===t)throw new RangeError(\"Concurrency must not be zero\");var p=c(e),o=0,h=[];const f={error:[],drain:[],saturated:[],unsaturated:[],empty:[]};var y=!1;const m=e=>t=>t?void(r(e),a(e,t)):new Promise((t,n)=>{i(e,(e,a)=>e?n(e):void t(a))});var g=!1,k={_tasks:new Ve,_createTaskItem(e,t){return{data:e,callback:t}},*[Symbol.iterator](){yield*k._tasks[Symbol.iterator]()},concurrency:t,payload:n,buffer:t/4,started:!1,paused:!1,push(e,t){return Array.isArray(e)?d(e)?void 0:e.map(e=>l(e,!1,!1,t)):l(e,!1,!1,t)},pushAsync(e,t){return Array.isArray(e)?d(e)?void 0:e.map(e=>l(e,!1,!0,t)):l(e,!1,!0,t)},kill(){r(),k._tasks.empty()},unshift(e,t){return Array.isArray(e)?d(e)?void 0:e.map(e=>l(e,!0,!1,t)):l(e,!0,!1,t)},unshiftAsync(e,t){return Array.isArray(e)?d(e)?void 0:e.map(e=>l(e,!0,!0,t)):l(e,!0,!0,t)},remove(e){k._tasks.remove(e)},process(){var e=Math.min;if(!g){for(g=!0;!k.paused&&o<k.concurrency&&k._tasks.length;){var t=[],n=[],a=k._tasks.length;k.payload&&(a=e(a,k.payload));for(var r,d=0;d<a;d++)r=k._tasks.shift(),t.push(r),h.push(r),n.push(r.data);o+=1,0===k._tasks.length&&s(\"empty\"),o===k.concurrency&&s(\"saturated\");var c=L(u(t));p(n,c)}g=!1}},length(){return k._tasks.length},running(){return o},workersList(){return h},idle(){return 0===k._tasks.length+o},pause(){k.paused=!0},resume(){!1===k.paused||(k.paused=!1,_e(k.process))}};return Object.defineProperties(k,{saturated:{writable:!1,value:m(\"saturated\")},unsaturated:{writable:!1,value:m(\"unsaturated\")},empty:{writable:!1,value:m(\"empty\")},drain:{writable:!1,value:m(\"drain\")},error:{writable:!1,value:m(\"error\")}}),k}function T(e,t){return B(e,1,t)}function F(e,t,n){return B(e,t,n)}function C(...e){var t=e.map(c);return function(...e){var n=this,a=e[e.length-1];return\"function\"==typeof a?e.pop():a=b(),qe(t,e,(e,t,a)=>{t.apply(n,e.concat((e,...t)=>{a(e,t)}))},(e,t)=>a(e,...t)),a[Ce]}}function P(...e){return C(...e.reverse())}function R(...e){return function(...t){var n=t.pop();return n(null,...e)}}function z(e,t){return(n,a,i,r)=>{var s,l=!1;const u=c(i);n(a,(n,a,i)=>{u(n,(a,r)=>a||!1===a?i(a):e(r)&&!s?(l=!0,s=t(!0,n),i(null,be)):void i())},e=>e?r(e):void r(null,l?s:t(!1)))}}function N(e){return(t,...n)=>c(t)(...n,(t,...n)=>{\"object\"==typeof console&&(t?console.error&&console.error(t):console[e]&&n.forEach(t=>console[e](t)))})}function V(e,t,n){const a=c(t);return Xe(e,(...e)=>{const t=e.pop();a(...e,(e,n)=>t(e,!n))},n)}function Y(e){return(t,n,a)=>e(t,a)}function q(e){return u(e)?e:function(...t){var n=t.pop(),a=!0;t.push((...e)=>{a?_e(()=>n(...e)):n(...e)}),e.apply(this,t),a=!1}}function D(e,t,n,a){var r=Array(t.length);e(t,(e,t,a)=>{n(e,(e,n)=>{r[t]=!!n,a(e)})},e=>{if(e)return a(e);for(var n=[],s=0;s<t.length;s++)r[s]&&n.push(t[s]);a(null,n)})}function Q(e,t,n,a){var i=[];e(t,(e,t,a)=>{n(e,(n,r)=>n?a(n):void(r&&i.push({index:t,value:e}),a(n)))},e=>e?a(e):void a(null,i.sort((e,t)=>e.index-t.index).map(e=>e.value)))}function U(e,t,n,a){var i=y(t)?D:Q;return i(e,t,c(n),a)}function G(e,t,n){return ut(e,1/0,t,n)}function W(e,t,n){return ut(e,1,t,n)}function H(e,t,n){return pt(e,1/0,t,n)}function J(e,t,n){return pt(e,1,t,n)}function K(e,t=e=>e){var a=Object.create(null),r=Object.create(null),s=c(e),l=n((e,n)=>{var u=t(...e);u in a?_e(()=>n(null,...a[u])):u in r?r[u].push(n):(r[u]=[n],s(...e,(e,...t)=>{e||(a[u]=t);var n=r[u];delete r[u];for(var s=0,d=n.length;s<d;s++)n[s](e,...t)}))});return l.memo=a,l.unmemoized=e,l}function X(e,t){return ot(Me,e,t)}function Z(e,t,n){return ot(Ae(t),e,n)}function $(e,t){var n=c(e);return B((e,t)=>{n(e[0],t)},t,1)}function ee(e){return(e<<1)+1}function te(e){return(e+1>>1)-1}function ne(e,t){return e.priority===t.priority?e.pushCount<t.pushCount:e.priority<t.priority}function ae(e,t){function n(e,t){return Array.isArray(e)?e.map(e=>({data:e,priority:t})):{data:e,priority:t}}var a=$(e,t),{push:i,pushAsync:r}=a;return a._tasks=new ht,a._createTaskItem=({data:e,priority:t},n)=>({data:e,priority:t,callback:n}),a.push=function(e,t=0,a){return i(n(e,t),a)},a.pushAsync=function(e,t=0,a){return r(n(e,t),a)},delete a.unshift,delete a.unshiftAsync,a}function ie(e,t,n,a){var i=[...e].reverse();return qe(i,t,n,a)}function re(e){var t=c(e);return n(function(e,n){return e.push((e,...t)=>{let a={};if(e&&(a.error=e),0<t.length){var i=t;1>=t.length&&([i]=t),a.value=i}n(null,a)}),t.apply(this,e)})}function se(e){var t;return Array.isArray(e)?t=e.map(re):(t={},Object.keys(e).forEach(n=>{t[n]=re.call(this,e[n])})),t}function le(e,t,n,a){const i=c(n);return U(e,t,(e,t)=>{i(e,(e,n)=>{t(e,!n)})},a)}function ue(e){return function(){return e}}function de(e,t,n){function a(){r((e,...t)=>{!1===e||(e&&s++<i.times&&(\"function\"!=typeof i.errorFilter||i.errorFilter(e))?setTimeout(a,i.intervalFunc(s-1)):n(e,...t))})}var i={times:kt,intervalFunc:ue(vt)};if(3>arguments.length&&\"function\"==typeof e?(n=t||b(),t=e):(pe(i,e),n=n||b()),\"function\"!=typeof t)throw new Error(\"Invalid arguments for async.retry\");var r=c(t),s=1;return a(),n[Ce]}function pe(e,n){if(\"object\"==typeof n)e.times=+n.times||kt,e.intervalFunc=\"function\"==typeof n.interval?n.interval:ue(+n.interval||vt),e.errorFilter=n.errorFilter;else if(\"number\"==typeof n||\"string\"==typeof n)e.times=+n||kt;else throw new Error(\"Invalid arguments for async.retry\")}function ce(e,t){t||(t=e,e=null);let a=e&&e.arity||t.length;u(t)&&(a+=1);var i=c(t);return n((t,n)=>{function r(e){i(...t,e)}return(t.length<a-1||null==n)&&(t.push(n),n=b()),e?de(e,r,n):de(r,n),n[Ce]})}function oe(e,t){return ot(Be,e,t)}function he(e,t,a){var i=c(e);return n((n,r)=>{var s,l=!1;n.push((...e)=>{l||(r(...e),clearTimeout(s))}),s=setTimeout(function(){var t=e.name||\"anonymous\",n=new Error(\"Callback function \\\"\"+t+\"\\\" timed out.\");n.code=\"ETIMEDOUT\",a&&(n.info=a),l=!0,r(n)},t),i(...n)})}function fe(e){for(var t=Array(e);e--;)t[e]=e;return t}function ye(e,t,n,a){var i=c(n);return De(fe(e),t,i,a)}function me(e,t,n){return ye(e,1/0,t,n)}function ge(e,t,n){return ye(e,1,t,n)}function ke(e,t,n,a){3>=arguments.length&&\"function\"==typeof t&&(a=n,n=t,t=Array.isArray(e)?[]:{}),a=m(a||b());var i=c(n);return Me(e,(e,n,a)=>{i(t,e,n,a)},e=>a(e,t)),a[Ce]}function ve(e){return(...t)=>(e.unmemoized||e)(...t)}function Se(e,t,n){const a=c(e);return _t(e=>a((t,n)=>e(t,!n)),t,n)}var xe,Le=\"function\"==typeof queueMicrotask&&queueMicrotask,Ee=\"function\"==typeof setImmediate&&setImmediate,Oe=\"object\"==typeof process&&\"function\"==typeof process.nextTick;xe=Le?queueMicrotask:Ee?setImmediate:Oe?process.nextTick:a;var _e=i(xe);const be={};var Ae=e=>(t,n,a)=>{function i(e,t){if(!u)if(c-=1,e)l=!0,a(e);else if(!1===e)l=!0,u=!0;else{if(t===be||l&&0>=c)return l=!0,a(null);o||r()}}function r(){for(o=!0;c<e&&!l;){var t=s();if(null===t)return l=!0,void(0>=c&&a(null));c+=1,n(t.value,t.key,L(i))}o=!1}if(a=m(a),0>=e)throw new RangeError(\"concurrency limit cannot be less than 1\");if(!t)return a(null);if(d(t))return E(t,e,n,a);if(p(t))return E(t[Symbol.asyncIterator](),e,n,a);var s=x(t),l=!1,u=!1,c=0,o=!1;r()},Ie=o(function(e,t,n,a){return Ae(t)(e,c(n),a)},4),Me=o(function(e,t,n){var a=y(e)?O:_;return a(e,c(t),n)},3),je=o(function(e,t,n){return f(Me,e,t,n)},3),we=h(je),Be=o(function(e,t,n){return Ie(e,1,t,n)},3),Te=o(function(e,t,n){return f(Be,e,t,n)},3),Fe=h(Te);const Ce=Symbol(\"promiseCallback\");var Pe=/^(?:async\\s+)?(?:function)?\\s*\\w*\\s*\\(\\s*([^)]+)\\s*\\)(?:\\s*{)/,Re=/^(?:async\\s+)?\\(?\\s*([^)=]+)\\s*\\)?(?:\\s*=>)/,ze=/,/,Ne=/(=.+)?(\\s*)$/;class Ve{constructor(){this.head=this.tail=null,this.length=0}removeLink(e){return e.prev?e.prev.next=e.next:this.head=e.next,e.next?e.next.prev=e.prev:this.tail=e.prev,e.prev=e.next=null,this.length-=1,e}empty(){for(;this.head;)this.shift();return this}insertAfter(e,t){t.prev=e,t.next=e.next,e.next?e.next.prev=t:this.tail=t,e.next=t,this.length+=1}insertBefore(e,t){t.prev=e.prev,t.next=e,e.prev?e.prev.next=t:this.head=t,e.prev=t,this.length+=1}unshift(e){this.head?this.insertBefore(this.head,e):w(this,e)}push(e){this.tail?this.insertAfter(this.tail,e):w(this,e)}shift(){return this.head&&this.removeLink(this.head)}pop(){return this.tail&&this.removeLink(this.tail)}toArray(){return[...this]}*[Symbol.iterator](){for(var e=this.head;e;)yield e.data,e=e.next}remove(e){for(var t=this.head;t;){var{next:n}=t;e(t)&&this.removeLink(t),t=n}return this}}var Ye,qe=o(function(e,t,n,a){a=m(a);var r=c(n);return Be(e,(e,n,a)=>{r(t,e,(e,n)=>{t=n,a(e)})},e=>a(e,t))},4),De=o(function(e,t,n,a){return f(Ae(t),e,n,a)},4),Qe=o(function(e,t,n,a){var i=c(n);return De(e,t,(e,t)=>{i(e,(e,...n)=>e?t(e):t(e,n))},(e,t)=>{for(var n=[],r=0;r<t.length;r++)t[r]&&(n=n.concat(...t[r]));return a(e,n)})},4),Ue=o(function(e,t,n){return Qe(e,1/0,t,n)},3),Ge=o(function(e,t,n){return Qe(e,1,t,n)},3),We=o(function(e,t,n){return z(e=>e,(e,t)=>t)(Me,e,t,n)},3),He=o(function(e,t,n,a){return z(e=>e,(e,t)=>t)(Ae(t),e,n,a)},4),Je=o(function(e,t,n){return z(e=>e,(e,t)=>t)(Ae(1),e,t,n)},3),Ke=N(\"dir\"),Xe=o(function(e,t,n){function a(e,...t){return e?n(e):void(!1===e||(r=t,l(...t,i)))}function i(e,t){return e?n(e):!1===e?void 0:t?void s(a):n(null,...r)}n=L(n);var r,s=c(e),l=c(t);return i(null,!0)},3),Ze=o(function(e,t,n){return Me(e,Y(c(t)),n)},3),$e=o(function(e,t,n,a){return Ae(t)(e,Y(c(n)),a)},4),et=o(function(e,t,n){return $e(e,1,t,n)},3),tt=o(function(e,t,n){return z(e=>!e,e=>!e)(Me,e,t,n)},3),nt=o(function(e,t,n,a){return z(e=>!e,e=>!e)(Ae(t),e,n,a)},4),at=o(function(e,t,n){return z(e=>!e,e=>!e)(Be,e,t,n)},3),it=o(function(e,t,n){return U(Me,e,t,n)},3),rt=o(function(e,t,n,a){return U(Ae(t),e,n,a)},4),st=o(function(e,t,n){return U(Be,e,t,n)},3),lt=o(function(e,t){function n(e){return e?a(e):void(!1===e||i(n))}var a=L(t),i=c(q(e));return n()},2),ut=o(function(e,t,n,a){var i=c(n);return De(e,t,(e,t)=>{i(e,(n,a)=>n?t(n):t(n,{key:a,val:e}))},(e,t)=>{for(var n={},{hasOwnProperty:r}=Object.prototype,s=0;s<t.length;s++)if(t[s]){var{key:l}=t[s],{val:u}=t[s];r.call(n,l)?n[l].push(u):n[l]=[u]}return a(e,n)})},4),dt=N(\"log\"),pt=o(function(e,t,n,a){a=m(a);var i={},r=c(n);return Ae(t)(e,(e,t,n)=>{r(e,t,(e,a)=>e?n(e):void(i[t]=a,n(e)))},e=>a(e,i))},4);Ye=Oe?process.nextTick:Ee?setImmediate:a;var ct=i(Ye),ot=o((e,t,n)=>{var a=y(t)?[]:{};e(t,(e,t,n)=>{c(e)((e,...i)=>{2>i.length&&([i]=i),a[t]=i,n(e)})},e=>n(e,a))},3);class ht{constructor(){this.heap=[],this.pushCount=Number.MIN_SAFE_INTEGER}get length(){return this.heap.length}empty(){return this.heap=[],this}percUp(e){for(let n;0<e&&ne(this.heap[e],this.heap[n=te(e)]);){let a=this.heap[e];this.heap[e]=this.heap[n],this.heap[n]=a,e=n}}percDown(e){for(let n,a;(n=ee(e))<this.heap.length&&(n+1<this.heap.length&&ne(this.heap[n+1],this.heap[n])&&++n,!ne(this.heap[e],this.heap[n]));)a=this.heap[e],this.heap[e]=this.heap[n],this.heap[n]=a,e=n}push(e){e.pushCount=++this.pushCount,this.heap.push(e),this.percUp(this.heap.length-1)}unshift(e){return this.heap.push(e)}shift(){let[e]=this.heap;return this.heap[0]=this.heap[this.heap.length-1],this.heap.pop(),this.percDown(0),e}toArray(){return[...this]}*[Symbol.iterator](){for(let e=0;e<this.heap.length;e++)yield this.heap[e].data}remove(e){let t=0;for(let n=0;n<this.heap.length;n++)e(this.heap[n])||(this.heap[t]=this.heap[n],t++);this.heap.splice(t);for(let t=te(this.heap.length-1);0<=t;t--)this.percDown(t);return this}}var ft=o(function(e,t){if(t=m(t),!Array.isArray(e))return t(new TypeError(\"First argument to race must be an array of functions\"));if(!e.length)return t();for(var n=0,a=e.length;n<a;n++)c(e[n])(t)},2),yt=o(function(e,t,n){return le(Me,e,t,n)},3),mt=o(function(e,t,n,a){return le(Ae(t),e,n,a)},4),gt=o(function(e,t,n){return le(Be,e,t,n)},3);const kt=5,vt=0;var St=o(function(e,t,n){return z(Boolean,e=>e)(Me,e,t,n)},3),xt=o(function(e,t,n,a){return z(Boolean,e=>e)(Ae(t),e,n,a)},4),Lt=o(function(e,t,n){return z(Boolean,e=>e)(Be,e,t,n)},3),Et=o(function(e,t,n){function a(e,t){var n=e.criteria,a=t.criteria;return n<a?-1:n>a?1:0}var i=c(t);return je(e,(e,t)=>{i(e,(n,a)=>n?t(n):void t(n,{value:e,criteria:a}))},(e,t)=>e?n(e):void n(null,t.sort(a).map(e=>e.value)))},3),Ot=o(function(e,t){var n,a=null;return et(e,(e,t)=>{c(e)((e,...i)=>!1===e?t(e):void(2>i.length?[n]=i:n=i,a=e,t(e?null:{})))},()=>t(a,n))}),_t=o(function(e,t,n){function a(e,...t){if(e)return n(e);l=t;!1===e||s(i)}function i(e,t){return e?n(e):!1===e?void 0:t?void r(a):n(null,...l)}n=L(n);var r=c(t),s=c(e),l=[];return s(i)},3),bt=o(function(e,t){function n(t){var n=c(e[i++]);n(...t,L(a))}function a(a,...r){return!1===a?void 0:a||i===e.length?t(a,...r):void n(r)}if(t=m(t),!Array.isArray(e))return t(new Error(\"First argument to waterfall must be an array of functions\"));if(!e.length)return t();var i=0;n([])});e.default={apply:t,applyEach:we,applyEachSeries:Fe,asyncify:r,auto:A,autoInject:j,cargo:T,cargoQueue:F,compose:P,concat:Ue,concatLimit:Qe,concatSeries:Ge,constant:R,detect:We,detectLimit:He,detectSeries:Je,dir:Ke,doUntil:V,doWhilst:Xe,each:Ze,eachLimit:$e,eachOf:Me,eachOfLimit:Ie,eachOfSeries:Be,eachSeries:et,ensureAsync:q,every:tt,everyLimit:nt,everySeries:at,filter:it,filterLimit:rt,filterSeries:st,forever:lt,groupBy:G,groupByLimit:ut,groupBySeries:W,log:dt,map:je,mapLimit:De,mapSeries:Te,mapValues:H,mapValuesLimit:pt,mapValuesSeries:J,memoize:K,nextTick:ct,parallel:X,parallelLimit:Z,priorityQueue:ae,queue:$,race:ft,reduce:qe,reduceRight:ie,reflect:re,reflectAll:se,reject:yt,rejectLimit:mt,rejectSeries:gt,retry:de,retryable:ce,seq:C,series:oe,setImmediate:_e,some:St,someLimit:xt,someSeries:Lt,sortBy:Et,timeout:he,times:me,timesLimit:ye,timesSeries:ge,transform:ke,tryEach:Ot,unmemoize:ve,until:Se,waterfall:bt,whilst:_t,all:tt,allLimit:nt,allSeries:at,any:St,anyLimit:xt,anySeries:Lt,find:We,findLimit:He,findSeries:Je,flatMap:Ue,flatMapLimit:Qe,flatMapSeries:Ge,forEach:Ze,forEachSeries:et,forEachLimit:$e,forEachOf:Me,forEachOfSeries:Be,forEachOfLimit:Ie,inject:qe,foldl:qe,foldr:ie,select:it,selectLimit:rt,selectSeries:st,wrapSync:r,during:_t,doDuring:Xe},e.apply=t,e.applyEach=we,e.applyEachSeries=Fe,e.asyncify=r,e.auto=A,e.autoInject=j,e.cargo=T,e.cargoQueue=F,e.compose=P,e.concat=Ue,e.concatLimit=Qe,e.concatSeries=Ge,e.constant=R,e.detect=We,e.detectLimit=He,e.detectSeries=Je,e.dir=Ke,e.doUntil=V,e.doWhilst=Xe,e.each=Ze,e.eachLimit=$e,e.eachOf=Me,e.eachOfLimit=Ie,e.eachOfSeries=Be,e.eachSeries=et,e.ensureAsync=q,e.every=tt,e.everyLimit=nt,e.everySeries=at,e.filter=it,e.filterLimit=rt,e.filterSeries=st,e.forever=lt,e.groupBy=G,e.groupByLimit=ut,e.groupBySeries=W,e.log=dt,e.map=je,e.mapLimit=De,e.mapSeries=Te,e.mapValues=H,e.mapValuesLimit=pt,e.mapValuesSeries=J,e.memoize=K,e.nextTick=ct,e.parallel=X,e.parallelLimit=Z,e.priorityQueue=ae,e.queue=$,e.race=ft,e.reduce=qe,e.reduceRight=ie,e.reflect=re,e.reflectAll=se,e.reject=yt,e.rejectLimit=mt,e.rejectSeries=gt,e.retry=de,e.retryable=ce,e.seq=C,e.series=oe,e.setImmediate=_e,e.some=St,e.someLimit=xt,e.someSeries=Lt,e.sortBy=Et,e.timeout=he,e.times=me,e.timesLimit=ye,e.timesSeries=ge,e.transform=ke,e.tryEach=Ot,e.unmemoize=ve,e.until=Se,e.waterfall=bt,e.whilst=_t,e.all=tt,e.allLimit=nt,e.allSeries=at,e.any=St,e.anyLimit=xt,e.anySeries=Lt,e.find=We,e.findLimit=He,e.findSeries=Je,e.flatMap=Ue,e.flatMapLimit=Qe,e.flatMapSeries=Ge,e.forEach=Ze,e.forEachSeries=et,e.forEachLimit=$e,e.forEachOf=Me,e.forEachOfSeries=Be,e.forEachOfLimit=Ie,e.inject=qe,e.foldl=qe,e.foldr=ie,e.select=it,e.selectLimit=rt,e.selectSeries=st,e.wrapSync=r,e.during=_t,e.doDuring=Xe,Object.defineProperty(e,\"__esModule\",{value:!0})});"
  },
  {
    "path": "server/libs/bcryptjs/LICENSE",
    "content": "bcrypt.js\n---------\nCopyright (c) 2012 Nevins Bartolomeo <nevins.bartolomeo@gmail.com>\nCopyright (c) 2012 Shane Girish <shaneGirish@gmail.com>\nCopyright (c) 2014 Daniel Wirtz <dcode@dcode.io>\n \nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n1. Redistributions of source code must retain the above copyright\n   notice, this list of conditions and the following disclaimer.\n2. Redistributions in binary form must reproduce the above copyright\n   notice, this list of conditions and the following disclaimer in the\n   documentation and/or other materials provided with the distribution.\n3. The name of the author may not be used to endorse or promote products\n   derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR\nIMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES\nOF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.\nIN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,\nINCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT\nNOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF\nTHIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nisaac.js\n--------\nCopyright (c) 2012 Yves-Marie K. Rinquin\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/bcryptjs/index.js",
    "content": "/*\n bcrypt.js (c) 2013 Daniel Wirtz <dcode@dcode.io>\n Released under the Apache License, Version 2.0\n see: https://github.com/dcodeIO/bcrypt.js for details\n*/\nvar $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||\"function\"==typeof Object.defineProperties?Object.defineProperty:function(a,g,p){a!=Array.prototype&&a!=Object.prototype&&(a[g]=p.value)};$jscomp.getGlobal=function(a){return\"undefined\"!=typeof window&&window===a?a:\"undefined\"!=typeof global&&null!=global?global:a};$jscomp.global=$jscomp.getGlobal(this);$jscomp.SYMBOL_PREFIX=\"jscomp_symbol_\";\n$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){};$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)};$jscomp.Symbol=function(){var a=0;return function(g){return $jscomp.SYMBOL_PREFIX+(g||\"\")+a++}}();\n$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var a=$jscomp.global.Symbol.iterator;a||(a=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol(\"iterator\"));\"function\"!=typeof Array.prototype[a]&&$jscomp.defineProperty(Array.prototype,a,{configurable:!0,writable:!0,value:function(){return $jscomp.arrayIterator(this)}});$jscomp.initSymbolIterator=function(){}};$jscomp.arrayIterator=function(a){var g=0;return $jscomp.iteratorPrototype(function(){return g<a.length?{done:!1,value:a[g++]}:{done:!0}})};\n$jscomp.iteratorPrototype=function(a){$jscomp.initSymbolIterator();a={next:a};a[$jscomp.global.Symbol.iterator]=function(){return this};return a};$jscomp.makeIterator=function(a){$jscomp.initSymbolIterator();var g=a[Symbol.iterator];return g?g.call(a):$jscomp.arrayIterator(a)};\n$jscomp.polyfill=function(a,g,p,q){if(g){p=$jscomp.global;a=a.split(\".\");for(q=0;q<a.length-1;q++){var h=a[q];h in p||(p[h]={});p=p[h]}a=a[a.length-1];q=p[a];g=g(q);g!=q&&null!=g&&$jscomp.defineProperty(p,a,{configurable:!0,writable:!0,value:g})}};$jscomp.FORCE_POLYFILL_PROMISE=!1;\n$jscomp.polyfill(\"Promise\",function(a){function g(){this.batch_=null}function p(b){return b instanceof h?b:new h(function(a,h){a(b)})}if(a&&!$jscomp.FORCE_POLYFILL_PROMISE)return a;g.prototype.asyncExecute=function(b){null==this.batch_&&(this.batch_=[],this.asyncExecuteBatch_());this.batch_.push(b);return this};g.prototype.asyncExecuteBatch_=function(){var b=this;this.asyncExecuteFunction(function(){b.executeBatch_()})};var q=$jscomp.global.setTimeout;g.prototype.asyncExecuteFunction=function(b){q(b,\n0)};g.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var b=this.batch_;this.batch_=[];for(var a=0;a<b.length;++a){var h=b[a];delete b[a];try{h()}catch(t){this.asyncThrow_(t)}}}this.batch_=null};g.prototype.asyncThrow_=function(b){this.asyncExecuteFunction(function(){throw b;})};var h=function(b){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];var a=this.createResolveAndReject_();try{b(a.resolve,a.reject)}catch(w){a.reject(w)}};h.prototype.createResolveAndReject_=\nfunction(){function b(b){return function(A){h||(h=!0,b.call(a,A))}}var a=this,h=!1;return{resolve:b(this.resolveTo_),reject:b(this.reject_)}};h.prototype.resolveTo_=function(b){if(b===this)this.reject_(new TypeError(\"A Promise cannot resolve to itself\"));else if(b instanceof h)this.settleSameAsPromise_(b);else{a:switch(typeof b){case \"object\":var a=null!=b;break a;case \"function\":a=!0;break a;default:a=!1}a?this.resolveToNonPromiseObj_(b):this.fulfill_(b)}};h.prototype.resolveToNonPromiseObj_=function(b){var a=\nvoid 0;try{a=b.then}catch(w){this.reject_(w);return}\"function\"==typeof a?this.settleSameAsThenable_(a,b):this.fulfill_(b)};h.prototype.reject_=function(a){this.settle_(2,a)};h.prototype.fulfill_=function(a){this.settle_(1,a)};h.prototype.settle_=function(a,h){if(0!=this.state_)throw Error(\"Cannot settle(\"+a+\", \"+h|\"): Promise already settled in state\"+this.state_);this.state_=a;this.result_=h;this.executeOnSettledCallbacks_()};h.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var a=\nthis.onSettledCallbacks_,h=0;h<a.length;++h)a[h].call(),a[h]=null;this.onSettledCallbacks_=null}};var v=new g;h.prototype.settleSameAsPromise_=function(a){var b=this.createResolveAndReject_();a.callWhenSettled_(b.resolve,b.reject)};h.prototype.settleSameAsThenable_=function(a,h){var b=this.createResolveAndReject_();try{a.call(h,b.resolve,b.reject)}catch(t){b.reject(t)}};h.prototype.then=function(a,g){function b(a,b){return\"function\"==typeof a?function(b){try{t(a(b))}catch(u){m(u)}}:b}var t,m,p=new h(function(a,\nb){t=a;m=b});this.callWhenSettled_(b(a,t),b(g,m));return p};h.prototype.catch=function(a){return this.then(void 0,a)};h.prototype.callWhenSettled_=function(a,h){function b(){switch(g.state_){case 1:a(g.result_);break;case 2:h(g.result_);break;default:throw Error(\"Unexpected state: \"+g.state_);}}var g=this;null==this.onSettledCallbacks_?v.asyncExecute(b):this.onSettledCallbacks_.push(function(){v.asyncExecute(b)})};h.resolve=p;h.reject=function(a){return new h(function(b,h){h(a)})};h.race=function(a){return new h(function(b,\nh){for(var g=$jscomp.makeIterator(a),m=g.next();!m.done;m=g.next())p(m.value).callWhenSettled_(b,h)})};h.all=function(a){var b=$jscomp.makeIterator(a),g=b.next();return g.done?p([]):new h(function(a,h){function m(b){return function(h){r[b]=h;q--;0==q&&a(r)}}var r=[],q=0;do r.push(void 0),q++,p(g.value).callWhenSettled_(m(r.length-1),h),g=b.next();while(!g.done)})};return h},\"es6\",\"es3\");\n(function(a,g){\"function\"===typeof define&&define.amd?define([],g):\"function\"===typeof require&&\"object\"===typeof module&&module&&module.exports?module.exports=g():(a.dcodeIO=a.dcodeIO||{}).bcrypt=g()})(this,function(){function a(n){if(\"undefined\"!==typeof module&&module&&module.exports)try{return require(\"crypto\").randomBytes(n)}catch(l){}try{var k;(self.crypto||self.msCrypto).getRandomValues(k=new Uint32Array(n));return Array.prototype.slice.call(k)}catch(l){}if(!z)throw Error(\"Neither WebCryptoAPI nor a crypto module is available. Use bcrypt.setRandomFallback to set an alternative\");\nreturn z(n)}function g(n,k){for(var a=n.length^k.length,c=0;c<n.length;++c)a|=n.charCodeAt(c)^k.charCodeAt(c);return 0===a}function p(n,k){var a=0,c=[];if(0>=k||k>n.length)throw Error(\"Illegal len: \"+k);for(;a<k;){var e=n[a++]&255;c.push(x[e>>2&63]);e=(e&3)<<4;if(a>=k){c.push(x[e&63]);break}var f=n[a++]&255;e|=f>>4&15;c.push(x[e&63]);e=(f&15)<<2;if(a>=k){c.push(x[e&63]);break}f=n[a++]&255;e|=f>>6&3;c.push(x[e&63]);c.push(x[f&63])}return c.join(\"\")}function q(a,k){var n=0,c=a.length,e=0,f=[];if(0>=\nk)throw Error(\"Illegal len: \"+k);for(;n<c-1&&e<k;){var d=a.charCodeAt(n++);var b=d<u.length?u[d]:-1;d=a.charCodeAt(n++);var h=d<u.length?u[d]:-1;if(-1==b||-1==h)break;d=b<<2>>>0;d|=(h&48)>>4;f.push(B(d));if(++e>=k||n>=c)break;d=a.charCodeAt(n++);b=d<u.length?u[d]:-1;if(-1==b)break;d=(h&15)<<4>>>0;d|=(b&60)>>2;f.push(B(d));if(++e>=k||n>=c)break;d=a.charCodeAt(n++);h=d<u.length?u[d]:-1;d=(b&3)<<6>>>0;d|=h;f.push(B(d));++e}a=[];for(n=0;n<e;n++)a.push(f[n].charCodeAt(0));return a}function h(a,k,l,c){var e=\na[k],f=a[k+1];e^=l[0];var d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[1];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[2];d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[3];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[4];d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[5];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[6];d=c[e>>>24];d+=\nc[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[7];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[8];d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[9];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[10];d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[11];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[12];d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];\nd+=c[768|e&255];f^=d^l[13];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[14];d=c[e>>>24];d+=c[256|e>>16&255];d^=c[512|e>>8&255];d+=c[768|e&255];f^=d^l[15];d=c[f>>>24];d+=c[256|f>>16&255];d^=c[512|f>>8&255];d+=c[768|f&255];e^=d^l[16];a[k]=f^l[17];a[k+1]=e;return a}function v(a,k){for(var n=0,c=0;4>n;++n)c=c<<8|a[k]&255,k=(k+1)%a.length;return{key:c,offp:k}}function b(a,k,l){for(var c=0,e=[0,0],f=k.length,d=l.length,n,b=0;b<f;b++)n=v(a,c),c=n.offp,k[b]^=n.key;for(b=0;b<f;b+=\n2)e=h(e,0,k,l),k[b]=e[0],k[b+1]=e[1];for(b=0;b<d;b+=2)e=h(e,0,k,l),l[b]=e[0],l[b+1]=e[1]}function A(a,k,b,c){for(var e=0,f=[0,0],d=b.length,n=c.length,l,g=0;g<d;g++)l=v(k,e),e=l.offp,b[g]^=l.key;for(g=e=0;g<d;g+=2)l=v(a,e),e=l.offp,f[0]^=l.key,l=v(a,e),e=l.offp,f[1]^=l.key,f=h(f,0,b,c),b[g]=f[0],b[g+1]=f[1];for(g=0;g<n;g+=2)l=v(a,e),e=l.offp,f[0]^=l.key,l=v(a,e),e=l.offp,f[1]^=l.key,f=h(f,0,b,c),c[g]=f[0],c[g+1]=f[1]}function w(a,k,l,c,e){function f(){e&&e(m/l);if(m<l)for(var g=Date.now();m<l&&!(m+=\n1,b(a,q,y),b(k,q,y),100<Date.now()-g););else{for(m=0;64>m;m++)for(p=0;p<n>>1;p++)h(d,p<<1,q,y);g=[];for(m=0;m<n;m++)g.push((d[m]>>24&255)>>>0),g.push((d[m]>>16&255)>>>0),g.push((d[m]>>8&255)>>>0),g.push((d[m]&255)>>>0);if(c){c(null,g);return}return g}c&&r(f)}var d=C.slice(),n=d.length;if(4>l||31<l){var g=Error(\"Illegal number of rounds (4-31): \"+l);if(c){r(c.bind(this,g));return}throw g;}if(16!==k.length){g=Error(\"Illegal salt length: \"+k.length+\" != 16\");if(c){r(c.bind(this,g));return}throw g;}l=\n1<<l>>>0;var m=0,p;if(Int32Array){var q=new Int32Array(D);var y=new Int32Array(E)}else q=D.slice(),y=E.slice();A(k,a,q,y);if(\"undefined\"!==typeof c)f();else for(;;)if(\"undefined\"!==typeof(g=f()))return g||[]}function t(a,k,b,c){function e(a){var c=[];c.push(\"$2\");\"a\"<=f&&c.push(f);c.push(\"$\");10>l&&c.push(\"0\");c.push(l.toString());c.push(\"$\");c.push(p(h,h.length));c.push(p(a,4*C.length-1));return c.join(\"\")}if(\"string\"!==typeof a||\"string\"!==typeof k){c=Error(\"Invalid string / salt: Not a string\");\nif(b){r(b.bind(this,c));return}throw c;}if(\"$\"!==k.charAt(0)||\"2\"!==k.charAt(1)){c=Error(\"Invalid salt version: \"+k.substring(0,2));if(b){r(b.bind(this,c));return}throw c;}if(\"$\"===k.charAt(2)){var f=String.fromCharCode(0);var d=3}else{f=k.charAt(2);if(\"a\"!==f&&\"b\"!==f&&\"y\"!==f||\"$\"!==k.charAt(3)){c=Error(\"Invalid salt revision: \"+k.substring(2,4));if(b){r(b.bind(this,c));return}throw c;}d=4}if(\"$\"<k.charAt(d+2)){c=Error(\"Missing salt rounds\");if(b){r(b.bind(this,c));return}throw c;}var n=10*parseInt(k.substring(d,\nd+1),10),g=parseInt(k.substring(d+1,d+2),10),l=n+g;k=k.substring(d+3,d+25);a=F(a+(\"a\"<=f?\"\\x00\":\"\"));var h=q(k,16);if(\"undefined\"==typeof b)return e(w(a,h,l));w(a,h,l,function(c,a){c?b(c,null):b(null,e(a))},c)}var m={},z=null;try{a(1)}catch(n){}z=null;m.setRandomFallback=function(a){z=a};m.genSaltSync=function(b,k){b=b||10;if(\"number\"!==typeof b)throw Error(\"Illegal arguments: \"+typeof b+\", \"+typeof k);4>b?b=4:31<b&&(b=31);k=[];k.push(\"$2a$\");10>b&&k.push(\"0\");k.push(b.toString());k.push(\"$\");k.push(p(a(16),\n16));return k.join(\"\")};m.genSalt=function(a,b,g){function c(c){r(function(){try{c(null,m.genSaltSync(a))}catch(f){c(f)}})}\"function\"===typeof b&&(g=b,b=void 0);\"function\"===typeof a&&(g=a,a=void 0);if(\"undefined\"===typeof a)a=10;else if(\"number\"!==typeof a)throw Error(\"illegal arguments: \"+typeof a);if(g){if(\"function\"!==typeof g)throw Error(\"Illegal callback: \"+typeof g);c(g)}else return new Promise(function(a,b){c(function(c,f){c?b(c):a(f)})})};m.hashSync=function(a,b){\"undefined\"===typeof b&&\n(b=10);\"number\"===typeof b&&(b=m.genSaltSync(b));if(\"string\"!==typeof a||\"string\"!==typeof b)throw Error(\"Illegal arguments: \"+typeof a+\", \"+typeof b);return t(a,b)};m.hash=function(a,b,g,c){function e(f){\"string\"===typeof a&&\"number\"===typeof b?m.genSalt(b,function(b,e){t(a,e,f,c)}):\"string\"===typeof a&&\"string\"===typeof b?t(a,b,f,c):r(f.bind(this,Error(\"Illegal arguments: \"+typeof a+\", \"+typeof b)))}if(g){if(\"function\"!==typeof g)throw Error(\"Illegal callback: \"+typeof g);e(g)}else return new Promise(function(a,\nc){e(function(b,d){b?c(b):a(d)})})};m.compareSync=function(a,b){if(\"string\"!==typeof a||\"string\"!==typeof b)throw Error(\"Illegal arguments: \"+typeof a+\", \"+typeof b);return 60!==b.length?!1:g(m.hashSync(a,b.substr(0,b.length-31)),b)};m.compare=function(a,b,h,c){function e(f){\"string\"!==typeof a||\"string\"!==typeof b?r(f.bind(this,Error(\"Illegal arguments: \"+typeof a+\", \"+typeof b))):60!==b.length?r(f.bind(this,null,!1)):m.hash(a,b.substr(0,29),function(a,c){a?f(a):f(null,g(c,b))},c)}if(h){if(\"function\"!==\ntypeof h)throw Error(\"Illegal callback: \"+typeof h);e(h)}else return new Promise(function(a,c){e(function(b,d){b?c(b):a(d)})})};m.getRounds=function(a){if(\"string\"!==typeof a)throw Error(\"Illegal arguments: \"+typeof a);return parseInt(a.split(\"$\")[2],10)};m.getSalt=function(a){if(\"string\"!==typeof a)throw Error(\"Illegal arguments: \"+typeof a);if(60!==a.length)throw Error(\"Illegal hash length: \"+a.length+\" != 60\");return a.substring(0,29)};var r=\"undefined\"!==typeof process&&process&&\"function\"===\ntypeof process.nextTick?\"function\"===typeof setImmediate?setImmediate:process.nextTick:setTimeout,F=function(a){var b=0,g;var c=Array;for(var e=0,f,d=0;d<a.length;++d)f=a.charCodeAt(d),128>f?e+=1:2048>f?e+=2:55296===(f&64512)&&56320===(a.charCodeAt(d+1)&64512)?(++d,e+=4):e+=3;e=new c(e);f=0;for(d=a.length;f<d;++f)c=a.charCodeAt(f),128>c?e[b++]=c:(2048>c?e[b++]=c>>6|192:(55296===(c&64512)&&56320===((g=a.charCodeAt(f+1))&64512)?(c=65536+((c&1023)<<10)+(g&1023),++f,e[b++]=c>>18|240,e[b++]=c>>12&63|128):\ne[b++]=c>>12|224,e[b++]=c>>6&63|128),e[b++]=c&63|128);return e},x=\"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\".split(\"\"),u=[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,1,54,55,56,57,58,59,60,61,62,63,-1,-1,-1,-1,-1,-1,-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,-1,-1,-1,-1,-1,-1,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,\n53,-1,-1,-1,-1,-1],B=String.fromCharCode;Date.now=Date.now||function(){return+new Date};var D=[608135816,2242054355,320440878,57701188,2752067618,698298832,137296536,3964562569,1160258022,953160567,3193202383,887688300,3232508343,3380367581,1065670069,3041331479,2450970073,2306472731],E=[3509652390,2564797868,805139163,3491422135,3101798381,1780907670,3128725573,4046225305,614570311,3012652279,134345442,2240740374,1667834072,1901547113,2757295779,4103290238,227898511,1921955416,1904987480,2182433518,\n2069144605,3260701109,2620446009,720527379,3318853667,677414384,3393288472,3101374703,2390351024,1614419982,1822297739,2954791486,3608508353,3174124327,2024746970,1432378464,3864339955,2857741204,1464375394,1676153920,1439316330,715854006,3033291828,289532110,2706671279,2087905683,3018724369,1668267050,732546397,1947742710,3462151702,2609353502,2950085171,1814351708,2050118529,680887927,999245976,1800124847,3300911131,1713906067,1641548236,4213287313,1216130144,1575780402,4018429277,3917837745,3693486850,\n3949271944,596196993,3549867205,258830323,2213823033,772490370,2760122372,1774776394,2652871518,566650946,4142492826,1728879713,2882767088,1783734482,3629395816,2517608232,2874225571,1861159788,326777828,3124490320,2130389656,2716951837,967770486,1724537150,2185432712,2364442137,1164943284,2105845187,998989502,3765401048,2244026483,1075463327,1455516326,1322494562,910128902,469688178,1117454909,936433444,3490320968,3675253459,1240580251,122909385,2157517691,634681816,4142456567,3825094682,3061402683,\n2540495037,79693498,3249098678,1084186820,1583128258,426386531,1761308591,1047286709,322548459,995290223,1845252383,2603652396,3431023940,2942221577,3202600964,3727903485,1712269319,422464435,3234572375,1170764815,3523960633,3117677531,1434042557,442511882,3600875718,1076654713,1738483198,4213154764,2393238008,3677496056,1014306527,4251020053,793779912,2902807211,842905082,4246964064,1395751752,1040244610,2656851899,3396308128,445077038,3742853595,3577915638,679411651,2892444358,2354009459,1767581616,\n3150600392,3791627101,3102740896,284835224,4246832056,1258075500,768725851,2589189241,3069724005,3532540348,1274779536,3789419226,2764799539,1660621633,3471099624,4011903706,913787905,3497959166,737222580,2514213453,2928710040,3937242737,1804850592,3499020752,2949064160,2386320175,2390070455,2415321851,4061277028,2290661394,2416832540,1336762016,1754252060,3520065937,3014181293,791618072,3188594551,3933548030,2332172193,3852520463,3043980520,413987798,3465142937,3030929376,4245938359,2093235073,3534596313,\n375366246,2157278981,2479649556,555357303,3870105701,2008414854,3344188149,4221384143,3956125452,2067696032,3594591187,2921233993,2428461,544322398,577241275,1471733935,610547355,4027169054,1432588573,1507829418,2025931657,3646575487,545086370,48609733,2200306550,1653985193,298326376,1316178497,3007786442,2064951626,458293330,2589141269,3591329599,3164325604,727753846,2179363840,146436021,1461446943,4069977195,705550613,3059967265,3887724982,4281599278,3313849956,1404054877,2845806497,146425753,1854211946,\n1266315497,3048417604,3681880366,3289982499,290971E4,1235738493,2632868024,2414719590,3970600049,1771706367,1449415276,3266420449,422970021,1963543593,2690192192,3826793022,1062508698,1531092325,1804592342,2583117782,2714934279,4024971509,1294809318,4028980673,1289560198,2221992742,1669523910,35572830,157838143,1052438473,1016535060,1802137761,1753167236,1386275462,3080475397,2857371447,1040679964,2145300060,2390574316,1461121720,2956646967,4031777805,4028374788,33600511,2920084762,1018524850,629373528,\n3691585981,3515945977,2091462646,2486323059,586499841,988145025,935516892,3367335476,2599673255,2839830854,265290510,3972581182,2759138881,3795373465,1005194799,847297441,406762289,1314163512,1332590856,1866599683,4127851711,750260880,613907577,1450815602,3165620655,3734664991,3650291728,3012275730,3704569646,1427272223,778793252,1343938022,2676280711,2052605720,1946737175,3164576444,3914038668,3967478842,3682934266,1661551462,3294938066,4011595847,840292616,3712170807,616741398,312560963,711312465,\n1351876610,322626781,1910503582,271666773,2175563734,1594956187,70604529,3617834859,1007753275,1495573769,4069517037,2549218298,2663038764,504708206,2263041392,3941167025,2249088522,1514023603,1998579484,1312622330,694541497,2582060303,2151582166,1382467621,776784248,2618340202,3323268794,2497899128,2784771155,503983604,4076293799,907881277,423175695,432175456,1378068232,4145222326,3954048622,3938656102,3820766613,2793130115,2977904593,26017576,3274890735,3194772133,1700274565,1756076034,4006520079,\n3677328699,720338349,1533947780,354530856,688349552,3973924725,1637815568,332179504,3949051286,53804574,2852348879,3044236432,1282449977,3583942155,3416972820,4006381244,1617046695,2628476075,3002303598,1686838959,431878346,2686675385,1700445008,1080580658,1009431731,832498133,3223435511,2605976345,2271191193,2516031870,1648197032,4164389018,2548247927,300782431,375919233,238389289,3353747414,2531188641,2019080857,1475708069,455242339,2609103871,448939670,3451063019,1395535956,2413381860,1841049896,\n1491858159,885456874,4264095073,4001119347,1565136089,3898914787,1108368660,540939232,1173283510,2745871338,3681308437,4207628240,3343053890,4016749493,1699691293,1103962373,3625875870,2256883143,3830138730,1031889488,3479347698,1535977030,4236805024,3251091107,2132092099,1774941330,1199868427,1452454533,157007616,2904115357,342012276,595725824,1480756522,206960106,497939518,591360097,863170706,2375253569,3596610801,1814182875,2094937945,3421402208,1082520231,3463918190,2785509508,435703966,3908032597,\n1641649973,2842273706,3305899714,1510255612,2148256476,2655287854,3276092548,4258621189,236887753,3681803219,274041037,1734335097,3815195456,3317970021,1899903192,1026095262,4050517792,356393447,2410691914,3873677099,3682840055,3913112168,2491498743,4132185628,2489919796,1091903735,1979897079,3170134830,3567386728,3557303409,857797738,1136121015,1342202287,507115054,2535736646,337727348,3213592640,1301675037,2528481711,1895095763,1721773893,3216771564,62756741,2142006736,835421444,2531993523,1442658625,\n3659876326,2882144922,676362277,1392781812,170690266,3921047035,1759253602,3611846912,1745797284,664899054,1329594018,3901205900,3045908486,2062866102,2865634940,3543621612,3464012697,1080764994,553557557,3656615353,3996768171,991055499,499776247,1265440854,648242737,3940784050,980351604,3713745714,1749149687,3396870395,4211799374,3640570775,1161844396,3125318951,1431517754,545492359,4268468663,3499529547,1437099964,2702547544,3433638243,2581715763,2787789398,1060185593,1593081372,2418618748,4260947970,\n69676912,2159744348,86519011,2512459080,3838209314,1220612927,3339683548,133810670,1090789135,1078426020,1569222167,845107691,3583754449,4072456591,1091646820,628848692,1613405280,3757631651,526609435,236106946,48312990,2942717905,3402727701,1797494240,859738849,992217954,4005476642,2243076622,3870952857,3732016268,765654824,3490871365,2511836413,1685915746,3888969200,1414112111,2273134842,3281911079,4080962846,172450625,2569994100,980381355,4109958455,2819808352,2716589560,2568741196,3681446669,\n3329971472,1835478071,660984891,3704678404,4045999559,3422617507,3040415634,1762651403,1719377915,3470491036,2693910283,3642056355,3138596744,1364962596,2073328063,1983633131,926494387,3423689081,2150032023,4096667949,1749200295,3328846651,309677260,2016342300,1779581495,3079819751,111262694,1274766160,443224088,298511866,1025883608,3806446537,1145181785,168956806,3641502830,3584813610,1689216846,3666258015,3200248200,1692713982,2646376535,4042768518,1618508792,1610833997,3523052358,4130873264,2001055236,\n3610705100,2202168115,4028541809,2961195399,1006657119,2006996926,3186142756,1430667929,3210227297,1314452623,4074634658,4101304120,2273951170,1399257539,3367210612,3027628629,1190975929,2062231137,2333990788,2221543033,2438960610,1181637006,548689776,2362791313,3372408396,3104550113,3145860560,296247880,1970579870,3078560182,3769228297,1714227617,3291629107,3898220290,166772364,1251581989,493813264,448347421,195405023,2709975567,677966185,3703036547,1463355134,2715995803,1338867538,1343315457,2802222074,\n2684532164,233230375,2599980071,2000651841,3277868038,1638401717,4028070440,3237316320,6314154,819756386,300326615,590932579,1405279636,3267499572,3150704214,2428286686,3959192993,3461946742,1862657033,1266418056,963775037,2089974820,2263052895,1917689273,448879540,3550394620,3981727096,150775221,3627908307,1303187396,508620638,2975983352,2726630617,1817252668,1876281319,1457606340,908771278,3720792119,3617206836,2455994898,1729034894,1080033504,976866871,3556439503,2881648439,1522871579,1555064734,\n1336096578,3548522304,2579274686,3574697629,3205460757,3593280638,3338716283,3079412587,564236357,2993598910,1781952180,1464380207,3163844217,3332601554,1699332808,1393555694,1183702653,3581086237,1288719814,691649499,2847557200,2895455976,3193889540,2717570544,1781354906,1676643554,2592534050,3230253752,1126444790,2770207658,2633158820,2210423226,2615765581,2414155088,3127139286,673620729,2805611233,1269405062,4015350505,3341807571,4149409754,1057255273,2012875353,2162469141,2276492801,2601117357,\n993977747,3918593370,2654263191,753973209,36408145,2530585658,25011837,3520020182,2088578344,530523599,2918365339,1524020338,1518925132,3760827505,3759777254,1202760957,3985898139,3906192525,674977740,4174734889,2031300136,2019492241,3983892565,4153806404,3822280332,352677332,2297720250,60907813,90501309,3286998549,1016092578,2535922412,2839152426,457141659,509813237,4120667899,652014361,1966332200,2975202805,55981186,2327461051,676427537,3255491064,2882294119,3433927263,1307055953,942726286,933058658,\n2468411793,3933900994,4215176142,1361170020,2001714738,2830558078,3274259782,1222529897,1679025792,2729314320,3714953764,1770335741,151462246,3013232138,1682292957,1483529935,471910574,1539241949,458788160,3436315007,1807016891,3718408830,978976581,1043663428,3165965781,1927990952,4200891579,2372276910,3208408903,3533431907,1412390302,2931980059,4132332400,1947078029,3881505623,4168226417,2941484381,1077988104,1320477388,886195818,18198404,3786409E3,2509781533,112762804,3463356488,1866414978,891333506,\n18488651,661792760,1628790961,3885187036,3141171499,876946877,2693282273,1372485963,791857591,2686433993,3759982718,3167212022,3472953795,2716379847,445679433,3561995674,3504004811,3574258232,54117162,3331405415,2381918588,3769707343,4154350007,1140177722,4074052095,668550556,3214352940,367459370,261225585,2610173221,4209349473,3468074219,3265815641,314222801,3066103646,3808782860,282218597,3406013506,3773591054,379116347,1285071038,846784868,2669647154,3771962079,3550491691,2305946142,453669953,\n1268987020,3317592352,3279303384,3744833421,2610507566,3859509063,266596637,3847019092,517658769,3462560207,3443424879,370717030,4247526661,2224018117,4143653529,4112773975,2788324899,2477274417,1456262402,2901442914,1517677493,1846949527,2295493580,3734397586,2176403920,1280348187,1908823572,3871786941,846861322,1172426758,3287448474,3383383037,1655181056,3139813346,901632758,1897031941,2986607138,3066810236,3447102507,1393639104,373351379,950779232,625454576,3124240540,4148612726,2007998917,544563296,\n2244738638,2330496472,2058025392,1291430526,424198748,50039436,29584100,3605783033,2429876329,2791104160,1057563949,3255363231,3075367218,3463963227,1469046755,985887462],C=[1332899944,1700884034,1701343084,1684370003,1668446532,1869963892];m.encodeBase64=p;m.decodeBase64=q;return m});"
  },
  {
    "path": "server/libs/busboy/LICENSE",
    "content": "Copyright Brian White. All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to\ndeal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or\nsell copies 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\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE."
  },
  {
    "path": "server/libs/busboy/index.js",
    "content": "'use strict';\n\n//\n// used by expressFileUpload\n// Source: https://github.com/mscdex/busboy\n//\n\nconst { parseContentType } = require('./utils.js');\n\nfunction getInstance(cfg) {\n  const headers = cfg.headers;\n  const conType = parseContentType(headers['content-type']);\n  if (!conType)\n    throw new Error('Malformed content type');\n\n  for (const type of TYPES) {\n    const matched = type.detect(conType);\n    if (!matched)\n      continue;\n\n    const instanceCfg = {\n      limits: cfg.limits,\n      headers,\n      conType,\n      highWaterMark: undefined,\n      fileHwm: undefined,\n      defCharset: undefined,\n      defParamCharset: undefined,\n      preservePath: false,\n    };\n    if (cfg.highWaterMark)\n      instanceCfg.highWaterMark = cfg.highWaterMark;\n    if (cfg.fileHwm)\n      instanceCfg.fileHwm = cfg.fileHwm;\n    instanceCfg.defCharset = cfg.defCharset;\n    instanceCfg.defParamCharset = cfg.defParamCharset;\n    instanceCfg.preservePath = cfg.preservePath;\n    return new type(instanceCfg);\n  }\n\n  throw new Error(`Unsupported content type: ${headers['content-type']}`);\n}\n\n// Note: types are explicitly listed here for easier bundling\n// See: https://github.com/mscdex/busboy/issues/121\nconst TYPES = [\n  require('./types/multipart'),\n  require('./types/urlencoded'),\n].filter(function (typemod) { return typeof typemod.detect === 'function'; });\n\nmodule.exports = (cfg) => {\n  if (typeof cfg !== 'object' || cfg === null)\n    cfg = {};\n\n  if (typeof cfg.headers !== 'object'\n    || cfg.headers === null\n    || typeof cfg.headers['content-type'] !== 'string') {\n    throw new Error('Missing Content-Type');\n  }\n\n  return getInstance(cfg);\n};\n"
  },
  {
    "path": "server/libs/busboy/types/multipart.js",
    "content": "'use strict';\n\nconst { Readable, Writable } = require('stream');\n\nconst StreamSearch = require('../../streamsearch');\n\nconst {\n  basename,\n  convertToUTF8,\n  getDecoder,\n  parseContentType,\n  parseDisposition,\n} = require('../utils.js');\n\nconst BUF_CRLF = Buffer.from('\\r\\n');\nconst BUF_CR = Buffer.from('\\r');\nconst BUF_DASH = Buffer.from('-');\n\nfunction noop() { }\n\nconst MAX_HEADER_PAIRS = 2000; // From node\nconst MAX_HEADER_SIZE = 16 * 1024; // From node (its default value)\n\nconst HPARSER_NAME = 0;\nconst HPARSER_PRE_OWS = 1;\nconst HPARSER_VALUE = 2;\nclass HeaderParser {\n  constructor(cb) {\n    this.header = Object.create(null);\n    this.pairCount = 0;\n    this.byteCount = 0;\n    this.state = HPARSER_NAME;\n    this.name = '';\n    this.value = '';\n    this.crlf = 0;\n    this.cb = cb;\n  }\n\n  reset() {\n    this.header = Object.create(null);\n    this.pairCount = 0;\n    this.byteCount = 0;\n    this.state = HPARSER_NAME;\n    this.name = '';\n    this.value = '';\n    this.crlf = 0;\n  }\n\n  push(chunk, pos, end) {\n    let start = pos;\n    while (pos < end) {\n      switch (this.state) {\n        case HPARSER_NAME: {\n          let done = false;\n          for (; pos < end; ++pos) {\n            if (this.byteCount === MAX_HEADER_SIZE)\n              return -1;\n            ++this.byteCount;\n            const code = chunk[pos];\n            if (TOKEN[code] !== 1) {\n              if (code !== 58/* ':' */)\n                return -1;\n              this.name += chunk.latin1Slice(start, pos);\n              if (this.name.length === 0)\n                return -1;\n              ++pos;\n              done = true;\n              this.state = HPARSER_PRE_OWS;\n              break;\n            }\n          }\n          if (!done) {\n            this.name += chunk.latin1Slice(start, pos);\n            break;\n          }\n          // FALLTHROUGH\n        }\n        case HPARSER_PRE_OWS: {\n          // Skip optional whitespace\n          let done = false;\n          for (; pos < end; ++pos) {\n            if (this.byteCount === MAX_HEADER_SIZE)\n              return -1;\n            ++this.byteCount;\n            const code = chunk[pos];\n            if (code !== 32/* ' ' */ && code !== 9/* '\\t' */) {\n              start = pos;\n              done = true;\n              this.state = HPARSER_VALUE;\n              break;\n            }\n          }\n          if (!done)\n            break;\n          // FALLTHROUGH\n        }\n        case HPARSER_VALUE:\n          switch (this.crlf) {\n            case 0: // Nothing yet\n              for (; pos < end; ++pos) {\n                if (this.byteCount === MAX_HEADER_SIZE)\n                  return -1;\n                ++this.byteCount;\n                const code = chunk[pos];\n                if (FIELD_VCHAR[code] !== 1) {\n                  if (code !== 13/* '\\r' */)\n                    return -1;\n                  ++this.crlf;\n                  break;\n                }\n              }\n              this.value += chunk.latin1Slice(start, pos++);\n              break;\n            case 1: // Received CR\n              if (this.byteCount === MAX_HEADER_SIZE)\n                return -1;\n              ++this.byteCount;\n              if (chunk[pos++] !== 10/* '\\n' */)\n                return -1;\n              ++this.crlf;\n              break;\n            case 2: { // Received CR LF\n              if (this.byteCount === MAX_HEADER_SIZE)\n                return -1;\n              ++this.byteCount;\n              const code = chunk[pos];\n              if (code === 32/* ' ' */ || code === 9/* '\\t' */) {\n                // Folded value\n                start = pos;\n                this.crlf = 0;\n              } else {\n                if (++this.pairCount < MAX_HEADER_PAIRS) {\n                  this.name = this.name.toLowerCase();\n                  if (this.header[this.name] === undefined)\n                    this.header[this.name] = [this.value];\n                  else\n                    this.header[this.name].push(this.value);\n                }\n                if (code === 13/* '\\r' */) {\n                  ++this.crlf;\n                  ++pos;\n                } else {\n                  // Assume start of next header field name\n                  start = pos;\n                  this.crlf = 0;\n                  this.state = HPARSER_NAME;\n                  this.name = '';\n                  this.value = '';\n                }\n              }\n              break;\n            }\n            case 3: { // Received CR LF CR\n              if (this.byteCount === MAX_HEADER_SIZE)\n                return -1;\n              ++this.byteCount;\n              if (chunk[pos++] !== 10/* '\\n' */)\n                return -1;\n              // End of header\n              const header = this.header;\n              this.reset();\n              this.cb(header);\n              return pos;\n            }\n          }\n          break;\n      }\n    }\n\n    return pos;\n  }\n}\n\nclass FileStream extends Readable {\n  constructor(opts, owner) {\n    super(opts);\n    this.truncated = false;\n    this._readcb = null;\n    this.once('end', () => {\n      // We need to make sure that we call any outstanding _writecb() that is\n      // associated with this file so that processing of the rest of the form\n      // can continue. This may not happen if the file stream ends right after\n      // backpressure kicks in, so we force it here.\n      this._read();\n      if (--owner._fileEndsLeft === 0 && owner._finalcb) {\n        const cb = owner._finalcb;\n        owner._finalcb = null;\n        // Make sure other 'end' event handlers get a chance to be executed\n        // before busboy's 'finish' event is emitted\n        process.nextTick(cb);\n      }\n    });\n  }\n  _read(n) {\n    const cb = this._readcb;\n    if (cb) {\n      this._readcb = null;\n      cb();\n    }\n  }\n}\n\nconst ignoreData = {\n  push: (chunk, pos) => { },\n  destroy: () => { },\n};\n\nfunction callAndUnsetCb(self, err) {\n  const cb = self._writecb;\n  self._writecb = null;\n  if (err)\n    self.destroy(err);\n  else if (cb)\n    cb();\n}\n\nfunction nullDecoder(val, hint) {\n  return val;\n}\n\nclass Multipart extends Writable {\n  constructor(cfg) {\n    const streamOpts = {\n      autoDestroy: true,\n      emitClose: true,\n      highWaterMark: (typeof cfg.highWaterMark === 'number'\n        ? cfg.highWaterMark\n        : undefined),\n    };\n    super(streamOpts);\n\n    if (!cfg.conType.params || typeof cfg.conType.params.boundary !== 'string')\n      throw new Error('Multipart: Boundary not found');\n\n    const boundary = cfg.conType.params.boundary;\n    const paramDecoder = (typeof cfg.defParamCharset === 'string'\n      && cfg.defParamCharset\n      ? getDecoder(cfg.defParamCharset)\n      : nullDecoder);\n    const defCharset = (cfg.defCharset || 'utf8');\n    const preservePath = cfg.preservePath;\n    const fileOpts = {\n      autoDestroy: true,\n      emitClose: true,\n      highWaterMark: (typeof cfg.fileHwm === 'number'\n        ? cfg.fileHwm\n        : undefined),\n    };\n\n    const limits = cfg.limits;\n    const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'\n      ? limits.fieldSize\n      : 1 * 1024 * 1024);\n    const fileSizeLimit = (limits && typeof limits.fileSize === 'number'\n      ? limits.fileSize\n      : Infinity);\n    const filesLimit = (limits && typeof limits.files === 'number'\n      ? limits.files\n      : Infinity);\n    const fieldsLimit = (limits && typeof limits.fields === 'number'\n      ? limits.fields\n      : Infinity);\n    const partsLimit = (limits && typeof limits.parts === 'number'\n      ? limits.parts\n      : Infinity);\n\n    let parts = -1; // Account for initial boundary\n    let fields = 0;\n    let files = 0;\n    let skipPart = false;\n\n    this._fileEndsLeft = 0;\n    this._fileStream = undefined;\n    this._complete = false;\n    let fileSize = 0;\n\n    let field;\n    let fieldSize = 0;\n    let partCharset;\n    let partEncoding;\n    let partType;\n    let partName;\n    let partTruncated = false;\n\n    let hitFilesLimit = false;\n    let hitFieldsLimit = false;\n\n    this._hparser = null;\n    const hparser = new HeaderParser((header) => {\n      this._hparser = null;\n      skipPart = false;\n\n      partType = 'text/plain';\n      partCharset = defCharset;\n      partEncoding = '7bit';\n      partName = undefined;\n      partTruncated = false;\n\n      let filename;\n      if (!header['content-disposition']) {\n        skipPart = true;\n        return;\n      }\n\n      const disp = parseDisposition(header['content-disposition'][0],\n        paramDecoder);\n      if (!disp || disp.type !== 'form-data') {\n        skipPart = true;\n        return;\n      }\n\n      if (disp.params) {\n        if (disp.params.name)\n          partName = disp.params.name;\n\n        if (disp.params['filename*'])\n          filename = disp.params['filename*'];\n        else if (disp.params.filename)\n          filename = disp.params.filename;\n\n        if (filename !== undefined && !preservePath)\n          filename = basename(filename);\n      }\n\n      if (header['content-type']) {\n        const conType = parseContentType(header['content-type'][0]);\n        if (conType) {\n          partType = `${conType.type}/${conType.subtype}`;\n          if (conType.params && typeof conType.params.charset === 'string')\n            partCharset = conType.params.charset.toLowerCase();\n        }\n      }\n\n      if (header['content-transfer-encoding'])\n        partEncoding = header['content-transfer-encoding'][0].toLowerCase();\n\n      if (partType === 'application/octet-stream' || filename !== undefined) {\n        // File\n\n        if (files === filesLimit) {\n          if (!hitFilesLimit) {\n            hitFilesLimit = true;\n            this.emit('filesLimit');\n          }\n          skipPart = true;\n          return;\n        }\n        ++files;\n\n        if (this.listenerCount('file') === 0) {\n          skipPart = true;\n          return;\n        }\n\n        fileSize = 0;\n        this._fileStream = new FileStream(fileOpts, this);\n        ++this._fileEndsLeft;\n        this.emit(\n          'file',\n          partName,\n          this._fileStream,\n          {\n            filename,\n            encoding: partEncoding,\n            mimeType: partType\n          }\n        );\n      } else {\n        // Non-file\n\n        if (fields === fieldsLimit) {\n          if (!hitFieldsLimit) {\n            hitFieldsLimit = true;\n            this.emit('fieldsLimit');\n          }\n          skipPart = true;\n          return;\n        }\n        ++fields;\n\n        if (this.listenerCount('field') === 0) {\n          skipPart = true;\n          return;\n        }\n\n        field = [];\n        fieldSize = 0;\n      }\n    });\n\n    let matchPostBoundary = 0;\n    const ssCb = (isMatch, data, start, end, isDataSafe) => {\n      retrydata:\n      while (data) {\n        if (this._hparser !== null) {\n          const ret = this._hparser.push(data, start, end);\n          if (ret === -1) {\n            this._hparser = null;\n            hparser.reset();\n            this.emit('error', new Error('Malformed part header'));\n            break;\n          }\n          start = ret;\n        }\n\n        if (start === end)\n          break;\n\n        if (matchPostBoundary !== 0) {\n          if (matchPostBoundary === 1) {\n            switch (data[start]) {\n              case 45: // '-'\n                // Try matching '--' after boundary\n                matchPostBoundary = 2;\n                ++start;\n                break;\n              case 13: // '\\r'\n                // Try matching CR LF before header\n                matchPostBoundary = 3;\n                ++start;\n                break;\n              default:\n                matchPostBoundary = 0;\n            }\n            if (start === end)\n              return;\n          }\n\n          if (matchPostBoundary === 2) {\n            matchPostBoundary = 0;\n            if (data[start] === 45/* '-' */) {\n              // End of multipart data\n              this._complete = true;\n              this._bparser = ignoreData;\n              return;\n            }\n            // We saw something other than '-', so put the dash we consumed\n            // \"back\"\n            const writecb = this._writecb;\n            this._writecb = noop;\n            ssCb(false, BUF_DASH, 0, 1, false);\n            this._writecb = writecb;\n          } else if (matchPostBoundary === 3) {\n            matchPostBoundary = 0;\n            if (data[start] === 10/* '\\n' */) {\n              ++start;\n              if (parts >= partsLimit)\n                break;\n              // Prepare the header parser\n              this._hparser = hparser;\n              if (start === end)\n                break;\n              // Process the remaining data as a header\n              continue retrydata;\n            } else {\n              // We saw something other than LF, so put the CR we consumed\n              // \"back\"\n              const writecb = this._writecb;\n              this._writecb = noop;\n              ssCb(false, BUF_CR, 0, 1, false);\n              this._writecb = writecb;\n            }\n          }\n        }\n\n        if (!skipPart) {\n          if (this._fileStream) {\n            let chunk;\n            const actualLen = Math.min(end - start, fileSizeLimit - fileSize);\n            if (!isDataSafe) {\n              chunk = Buffer.allocUnsafe(actualLen);\n              data.copy(chunk, 0, start, start + actualLen);\n            } else {\n              chunk = data.slice(start, start + actualLen);\n            }\n\n            fileSize += chunk.length;\n            if (fileSize === fileSizeLimit) {\n              if (chunk.length > 0)\n                this._fileStream.push(chunk);\n              this._fileStream.emit('limit');\n              this._fileStream.truncated = true;\n              skipPart = true;\n            } else if (!this._fileStream.push(chunk)) {\n              if (this._writecb)\n                this._fileStream._readcb = this._writecb;\n              this._writecb = null;\n            }\n          } else if (field !== undefined) {\n            let chunk;\n            const actualLen = Math.min(\n              end - start,\n              fieldSizeLimit - fieldSize\n            );\n            if (!isDataSafe) {\n              chunk = Buffer.allocUnsafe(actualLen);\n              data.copy(chunk, 0, start, start + actualLen);\n            } else {\n              chunk = data.slice(start, start + actualLen);\n            }\n\n            fieldSize += actualLen;\n            field.push(chunk);\n            if (fieldSize === fieldSizeLimit) {\n              skipPart = true;\n              partTruncated = true;\n            }\n          }\n        }\n\n        break;\n      }\n\n      if (isMatch) {\n        matchPostBoundary = 1;\n\n        if (this._fileStream) {\n          // End the active file stream if the previous part was a file\n          this._fileStream.push(null);\n          this._fileStream = null;\n        } else if (field !== undefined) {\n          let data;\n          switch (field.length) {\n            case 0:\n              data = '';\n              break;\n            case 1:\n              data = convertToUTF8(field[0], partCharset, 0);\n              break;\n            default:\n              data = convertToUTF8(\n                Buffer.concat(field, fieldSize),\n                partCharset,\n                0\n              );\n          }\n          field = undefined;\n          fieldSize = 0;\n          this.emit(\n            'field',\n            partName,\n            data,\n            {\n              nameTruncated: false,\n              valueTruncated: partTruncated,\n              encoding: partEncoding,\n              mimeType: partType\n            }\n          );\n        }\n\n        if (++parts === partsLimit)\n          this.emit('partsLimit');\n      }\n    };\n    this._bparser = new StreamSearch(`\\r\\n--${boundary}`, ssCb);\n\n    this._writecb = null;\n    this._finalcb = null;\n\n    // Just in case there is no preamble\n    this.write(BUF_CRLF);\n  }\n\n  static detect(conType) {\n    return (conType.type === 'multipart' && conType.subtype === 'form-data');\n  }\n\n  _write(chunk, enc, cb) {\n    this._writecb = cb;\n    this._bparser.push(chunk, 0);\n    if (this._writecb)\n      callAndUnsetCb(this);\n  }\n\n  _destroy(err, cb) {\n    this._hparser = null;\n    this._bparser = ignoreData;\n    if (!err)\n      err = checkEndState(this);\n    const fileStream = this._fileStream;\n    if (fileStream) {\n      this._fileStream = null;\n      fileStream.destroy(err);\n    }\n    cb(err);\n  }\n\n  _final(cb) {\n    this._bparser.destroy();\n    if (!this._complete)\n      return cb(new Error('Unexpected end of form'));\n    if (this._fileEndsLeft)\n      this._finalcb = finalcb.bind(null, this, cb);\n    else\n      finalcb(this, cb);\n  }\n}\n\nfunction finalcb(self, cb, err) {\n  if (err)\n    return cb(err);\n  err = checkEndState(self);\n  cb(err);\n}\n\nfunction checkEndState(self) {\n  if (self._hparser)\n    return new Error('Malformed part header');\n  const fileStream = self._fileStream;\n  if (fileStream) {\n    self._fileStream = null;\n    fileStream.destroy(new Error('Unexpected end of file'));\n  }\n  if (!self._complete)\n    return new Error('Unexpected end of form');\n}\n\nconst TOKEN = [\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,\n  0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n];\n\nconst FIELD_VCHAR = [\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n];\n\nmodule.exports = Multipart;\n"
  },
  {
    "path": "server/libs/busboy/types/urlencoded.js",
    "content": "'use strict';\n\nconst { Writable } = require('stream');\n\nconst { getDecoder } = require('../utils.js');\n\nclass URLEncoded extends Writable {\n  constructor(cfg) {\n    const streamOpts = {\n      autoDestroy: true,\n      emitClose: true,\n      highWaterMark: (typeof cfg.highWaterMark === 'number'\n                      ? cfg.highWaterMark\n                      : undefined),\n    };\n    super(streamOpts);\n\n    let charset = (cfg.defCharset || 'utf8');\n    if (cfg.conType.params && typeof cfg.conType.params.charset === 'string')\n      charset = cfg.conType.params.charset;\n\n    this.charset = charset;\n\n    const limits = cfg.limits;\n    this.fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'\n                           ? limits.fieldSize\n                           : 1 * 1024 * 1024);\n    this.fieldsLimit = (limits && typeof limits.fields === 'number'\n                        ? limits.fields\n                        : Infinity);\n    this.fieldNameSizeLimit = (\n      limits && typeof limits.fieldNameSize === 'number'\n      ? limits.fieldNameSize\n      : 100\n    );\n\n    this._inKey = true;\n    this._keyTrunc = false;\n    this._valTrunc = false;\n    this._bytesKey = 0;\n    this._bytesVal = 0;\n    this._fields = 0;\n    this._key = '';\n    this._val = '';\n    this._byte = -2;\n    this._lastPos = 0;\n    this._encode = 0;\n    this._decoder = getDecoder(charset);\n  }\n\n  static detect(conType) {\n    return (conType.type === 'application'\n            && conType.subtype === 'x-www-form-urlencoded');\n  }\n\n  _write(chunk, enc, cb) {\n    if (this._fields >= this.fieldsLimit)\n      return cb();\n\n    let i = 0;\n    const len = chunk.length;\n    this._lastPos = 0;\n\n    // Check if we last ended mid-percent-encoded byte\n    if (this._byte !== -2) {\n      i = readPctEnc(this, chunk, i, len);\n      if (i === -1)\n        return cb(new Error('Malformed urlencoded form'));\n      if (i >= len)\n        return cb();\n      if (this._inKey)\n        ++this._bytesKey;\n      else\n        ++this._bytesVal;\n    }\n\nmain:\n    while (i < len) {\n      if (this._inKey) {\n        // Parsing key\n\n        i = skipKeyBytes(this, chunk, i, len);\n\n        while (i < len) {\n          switch (chunk[i]) {\n            case 61: // '='\n              if (this._lastPos < i)\n                this._key += chunk.latin1Slice(this._lastPos, i);\n              this._lastPos = ++i;\n              this._key = this._decoder(this._key, this._encode);\n              this._encode = 0;\n              this._inKey = false;\n              continue main;\n            case 38: // '&'\n              if (this._lastPos < i)\n                this._key += chunk.latin1Slice(this._lastPos, i);\n              this._lastPos = ++i;\n              this._key = this._decoder(this._key, this._encode);\n              this._encode = 0;\n              if (this._bytesKey > 0) {\n                this.emit(\n                  'field',\n                  this._key,\n                  '',\n                  { nameTruncated: this._keyTrunc,\n                    valueTruncated: false,\n                    encoding: this.charset,\n                    mimeType: 'text/plain' }\n                );\n              }\n              this._key = '';\n              this._val = '';\n              this._keyTrunc = false;\n              this._valTrunc = false;\n              this._bytesKey = 0;\n              this._bytesVal = 0;\n              if (++this._fields >= this.fieldsLimit) {\n                this.emit('fieldsLimit');\n                return cb();\n              }\n              continue;\n            case 43: // '+'\n              if (this._lastPos < i)\n                this._key += chunk.latin1Slice(this._lastPos, i);\n              this._key += ' ';\n              this._lastPos = i + 1;\n              break;\n            case 37: // '%'\n              if (this._encode === 0)\n                this._encode = 1;\n              if (this._lastPos < i)\n                this._key += chunk.latin1Slice(this._lastPos, i);\n              this._lastPos = i + 1;\n              this._byte = -1;\n              i = readPctEnc(this, chunk, i + 1, len);\n              if (i === -1)\n                return cb(new Error('Malformed urlencoded form'));\n              if (i >= len)\n                return cb();\n              ++this._bytesKey;\n              i = skipKeyBytes(this, chunk, i, len);\n              continue;\n          }\n          ++i;\n          ++this._bytesKey;\n          i = skipKeyBytes(this, chunk, i, len);\n        }\n        if (this._lastPos < i)\n          this._key += chunk.latin1Slice(this._lastPos, i);\n      } else {\n        // Parsing value\n\n        i = skipValBytes(this, chunk, i, len);\n\n        while (i < len) {\n          switch (chunk[i]) {\n            case 38: // '&'\n              if (this._lastPos < i)\n                this._val += chunk.latin1Slice(this._lastPos, i);\n              this._lastPos = ++i;\n              this._inKey = true;\n              this._val = this._decoder(this._val, this._encode);\n              this._encode = 0;\n              if (this._bytesKey > 0 || this._bytesVal > 0) {\n                this.emit(\n                  'field',\n                  this._key,\n                  this._val,\n                  { nameTruncated: this._keyTrunc,\n                    valueTruncated: this._valTrunc,\n                    encoding: this.charset,\n                    mimeType: 'text/plain' }\n                );\n              }\n              this._key = '';\n              this._val = '';\n              this._keyTrunc = false;\n              this._valTrunc = false;\n              this._bytesKey = 0;\n              this._bytesVal = 0;\n              if (++this._fields >= this.fieldsLimit) {\n                this.emit('fieldsLimit');\n                return cb();\n              }\n              continue main;\n            case 43: // '+'\n              if (this._lastPos < i)\n                this._val += chunk.latin1Slice(this._lastPos, i);\n              this._val += ' ';\n              this._lastPos = i + 1;\n              break;\n            case 37: // '%'\n              if (this._encode === 0)\n                this._encode = 1;\n              if (this._lastPos < i)\n                this._val += chunk.latin1Slice(this._lastPos, i);\n              this._lastPos = i + 1;\n              this._byte = -1;\n              i = readPctEnc(this, chunk, i + 1, len);\n              if (i === -1)\n                return cb(new Error('Malformed urlencoded form'));\n              if (i >= len)\n                return cb();\n              ++this._bytesVal;\n              i = skipValBytes(this, chunk, i, len);\n              continue;\n          }\n          ++i;\n          ++this._bytesVal;\n          i = skipValBytes(this, chunk, i, len);\n        }\n        if (this._lastPos < i)\n          this._val += chunk.latin1Slice(this._lastPos, i);\n      }\n    }\n\n    cb();\n  }\n\n  _final(cb) {\n    if (this._byte !== -2)\n      return cb(new Error('Malformed urlencoded form'));\n    if (!this._inKey || this._bytesKey > 0 || this._bytesVal > 0) {\n      if (this._inKey)\n        this._key = this._decoder(this._key, this._encode);\n      else\n        this._val = this._decoder(this._val, this._encode);\n      this.emit(\n        'field',\n        this._key,\n        this._val,\n        { nameTruncated: this._keyTrunc,\n          valueTruncated: this._valTrunc,\n          encoding: this.charset,\n          mimeType: 'text/plain' }\n      );\n    }\n    cb();\n  }\n}\n\nfunction readPctEnc(self, chunk, pos, len) {\n  if (pos >= len)\n    return len;\n\n  if (self._byte === -1) {\n    // We saw a '%' but no hex characters yet\n    const hexUpper = HEX_VALUES[chunk[pos++]];\n    if (hexUpper === -1)\n      return -1;\n\n    if (hexUpper >= 8)\n      self._encode = 2; // Indicate high bits detected\n\n    if (pos < len) {\n      // Both hex characters are in this chunk\n      const hexLower = HEX_VALUES[chunk[pos++]];\n      if (hexLower === -1)\n        return -1;\n\n      if (self._inKey)\n        self._key += String.fromCharCode((hexUpper << 4) + hexLower);\n      else\n        self._val += String.fromCharCode((hexUpper << 4) + hexLower);\n\n      self._byte = -2;\n      self._lastPos = pos;\n    } else {\n      // Only one hex character was available in this chunk\n      self._byte = hexUpper;\n    }\n  } else {\n    // We saw only one hex character so far\n    const hexLower = HEX_VALUES[chunk[pos++]];\n    if (hexLower === -1)\n      return -1;\n\n    if (self._inKey)\n      self._key += String.fromCharCode((self._byte << 4) + hexLower);\n    else\n      self._val += String.fromCharCode((self._byte << 4) + hexLower);\n\n    self._byte = -2;\n    self._lastPos = pos;\n  }\n\n  return pos;\n}\n\nfunction skipKeyBytes(self, chunk, pos, len) {\n  // Skip bytes if we've truncated\n  if (self._bytesKey > self.fieldNameSizeLimit) {\n    if (!self._keyTrunc) {\n      if (self._lastPos < pos)\n        self._key += chunk.latin1Slice(self._lastPos, pos - 1);\n    }\n    self._keyTrunc = true;\n    for (; pos < len; ++pos) {\n      const code = chunk[pos];\n      if (code === 61/* '=' */ || code === 38/* '&' */)\n        break;\n      ++self._bytesKey;\n    }\n    self._lastPos = pos;\n  }\n\n  return pos;\n}\n\nfunction skipValBytes(self, chunk, pos, len) {\n  // Skip bytes if we've truncated\n  if (self._bytesVal > self.fieldSizeLimit) {\n    if (!self._valTrunc) {\n      if (self._lastPos < pos)\n        self._val += chunk.latin1Slice(self._lastPos, pos - 1);\n    }\n    self._valTrunc = true;\n    for (; pos < len; ++pos) {\n      if (chunk[pos] === 38/* '&' */)\n        break;\n      ++self._bytesVal;\n    }\n    self._lastPos = pos;\n  }\n\n  return pos;\n}\n\n/* eslint-disable no-multi-spaces */\nconst HEX_VALUES = [\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n   0,  1,  2,  3,  4,  5,  6,  7,  8,  9, -1, -1, -1, -1, -1, -1,\n  -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n];\n/* eslint-enable no-multi-spaces */\n\nmodule.exports = URLEncoded;\n"
  },
  {
    "path": "server/libs/busboy/utils.js",
    "content": "'use strict';\n\nfunction parseContentType(str) {\n  if (str.length === 0)\n    return;\n\n  const params = Object.create(null);\n  let i = 0;\n\n  // Parse type\n  for (; i < str.length; ++i) {\n    const code = str.charCodeAt(i);\n    if (TOKEN[code] !== 1) {\n      if (code !== 47/* '/' */ || i === 0)\n        return;\n      break;\n    }\n  }\n  // Check for type without subtype\n  if (i === str.length)\n    return;\n\n  const type = str.slice(0, i).toLowerCase();\n\n  // Parse subtype\n  const subtypeStart = ++i;\n  for (; i < str.length; ++i) {\n    const code = str.charCodeAt(i);\n    if (TOKEN[code] !== 1) {\n      // Make sure we have a subtype\n      if (i === subtypeStart)\n        return;\n\n      if (parseContentTypeParams(str, i, params) === undefined)\n        return;\n      break;\n    }\n  }\n  // Make sure we have a subtype\n  if (i === subtypeStart)\n    return;\n\n  const subtype = str.slice(subtypeStart, i).toLowerCase();\n\n  return { type, subtype, params };\n}\n\nfunction parseContentTypeParams(str, i, params) {\n  while (i < str.length) {\n    // Consume whitespace\n    for (; i < str.length; ++i) {\n      const code = str.charCodeAt(i);\n      if (code !== 32/* ' ' */ && code !== 9/* '\\t' */)\n        break;\n    }\n\n    // Ended on whitespace\n    if (i === str.length)\n      break;\n\n    // Check for malformed parameter\n    if (str.charCodeAt(i++) !== 59/* ';' */)\n      return;\n\n    // Consume whitespace\n    for (; i < str.length; ++i) {\n      const code = str.charCodeAt(i);\n      if (code !== 32/* ' ' */ && code !== 9/* '\\t' */)\n        break;\n    }\n\n    // Ended on whitespace (malformed)\n    if (i === str.length)\n      return;\n\n    let name;\n    const nameStart = i;\n    // Parse parameter name\n    for (; i < str.length; ++i) {\n      const code = str.charCodeAt(i);\n      if (TOKEN[code] !== 1) {\n        if (code !== 61/* '=' */)\n          return;\n        break;\n      }\n    }\n\n    // No value (malformed)\n    if (i === str.length)\n      return;\n\n    name = str.slice(nameStart, i);\n    ++i; // Skip over '='\n\n    // No value (malformed)\n    if (i === str.length)\n      return;\n\n    let value = '';\n    let valueStart;\n    if (str.charCodeAt(i) === 34/* '\"' */) {\n      valueStart = ++i;\n      let escaping = false;\n      // Parse quoted value\n      for (; i < str.length; ++i) {\n        const code = str.charCodeAt(i);\n        if (code === 92/* '\\\\' */) {\n          if (escaping) {\n            valueStart = i;\n            escaping = false;\n          } else {\n            value += str.slice(valueStart, i);\n            escaping = true;\n          }\n          continue;\n        }\n        if (code === 34/* '\"' */) {\n          if (escaping) {\n            valueStart = i;\n            escaping = false;\n            continue;\n          }\n          value += str.slice(valueStart, i);\n          break;\n        }\n        if (escaping) {\n          valueStart = i - 1;\n          escaping = false;\n        }\n        // Invalid unescaped quoted character (malformed)\n        if (QDTEXT[code] !== 1)\n          return;\n      }\n\n      // No end quote (malformed)\n      if (i === str.length)\n        return;\n\n      ++i; // Skip over double quote\n    } else {\n      valueStart = i;\n      // Parse unquoted value\n      for (; i < str.length; ++i) {\n        const code = str.charCodeAt(i);\n        if (TOKEN[code] !== 1) {\n          // No value (malformed)\n          if (i === valueStart)\n            return;\n          break;\n        }\n      }\n      value = str.slice(valueStart, i);\n    }\n\n    name = name.toLowerCase();\n    if (params[name] === undefined)\n      params[name] = value;\n  }\n\n  return params;\n}\n\nfunction parseDisposition(str, defDecoder) {\n  if (str.length === 0)\n    return;\n\n  const params = Object.create(null);\n  let i = 0;\n\n  for (; i < str.length; ++i) {\n    const code = str.charCodeAt(i);\n    if (TOKEN[code] !== 1) {\n      if (parseDispositionParams(str, i, params, defDecoder) === undefined)\n        return;\n      break;\n    }\n  }\n\n  const type = str.slice(0, i).toLowerCase();\n\n  return { type, params };\n}\n\nfunction parseDispositionParams(str, i, params, defDecoder) {\n  while (i < str.length) {\n    // Consume whitespace\n    for (; i < str.length; ++i) {\n      const code = str.charCodeAt(i);\n      if (code !== 32/* ' ' */ && code !== 9/* '\\t' */)\n        break;\n    }\n\n    // Ended on whitespace\n    if (i === str.length)\n      break;\n\n    // Check for malformed parameter\n    if (str.charCodeAt(i++) !== 59/* ';' */)\n      return;\n\n    // Consume whitespace\n    for (; i < str.length; ++i) {\n      const code = str.charCodeAt(i);\n      if (code !== 32/* ' ' */ && code !== 9/* '\\t' */)\n        break;\n    }\n\n    // Ended on whitespace (malformed)\n    if (i === str.length)\n      return;\n\n    let name;\n    const nameStart = i;\n    // Parse parameter name\n    for (; i < str.length; ++i) {\n      const code = str.charCodeAt(i);\n      if (TOKEN[code] !== 1) {\n        if (code === 61/* '=' */)\n          break;\n        return;\n      }\n    }\n\n    // No value (malformed)\n    if (i === str.length)\n      return;\n\n    let value = '';\n    let valueStart;\n    let charset;\n    //~ let lang;\n    name = str.slice(nameStart, i);\n    if (name.charCodeAt(name.length - 1) === 42/* '*' */) {\n      // Extended value\n\n      const charsetStart = ++i;\n      // Parse charset name\n      for (; i < str.length; ++i) {\n        const code = str.charCodeAt(i);\n        if (CHARSET[code] !== 1) {\n          if (code !== 39/* '\\'' */)\n            return;\n          break;\n        }\n      }\n\n      // Incomplete charset (malformed)\n      if (i === str.length)\n        return;\n\n      charset = str.slice(charsetStart, i);\n      ++i; // Skip over the '\\''\n\n      //~ const langStart = ++i;\n      // Parse language name\n      for (; i < str.length; ++i) {\n        const code = str.charCodeAt(i);\n        if (code === 39/* '\\'' */)\n          break;\n      }\n\n      // Incomplete language (malformed)\n      if (i === str.length)\n        return;\n\n      //~ lang = str.slice(langStart, i);\n      ++i; // Skip over the '\\''\n\n      // No value (malformed)\n      if (i === str.length)\n        return;\n\n      valueStart = i;\n\n      let encode = 0;\n      // Parse value\n      for (; i < str.length; ++i) {\n        const code = str.charCodeAt(i);\n        if (EXTENDED_VALUE[code] !== 1) {\n          if (code === 37/* '%' */) {\n            let hexUpper;\n            let hexLower;\n            if (i + 2 < str.length\n                && (hexUpper = HEX_VALUES[str.charCodeAt(i + 1)]) !== -1\n                && (hexLower = HEX_VALUES[str.charCodeAt(i + 2)]) !== -1) {\n              const byteVal = (hexUpper << 4) + hexLower;\n              value += str.slice(valueStart, i);\n              value += String.fromCharCode(byteVal);\n              i += 2;\n              valueStart = i + 1;\n              if (byteVal >= 128)\n                encode = 2;\n              else if (encode === 0)\n                encode = 1;\n              continue;\n            }\n            // '%' disallowed in non-percent encoded contexts (malformed)\n            return;\n          }\n          break;\n        }\n      }\n\n      value += str.slice(valueStart, i);\n      value = convertToUTF8(value, charset, encode);\n      if (value === undefined)\n        return;\n    } else {\n      // Non-extended value\n\n      ++i; // Skip over '='\n\n      // No value (malformed)\n      if (i === str.length)\n        return;\n\n      if (str.charCodeAt(i) === 34/* '\"' */) {\n        valueStart = ++i;\n        let escaping = false;\n        // Parse quoted value\n        for (; i < str.length; ++i) {\n          const code = str.charCodeAt(i);\n          if (code === 92/* '\\\\' */) {\n            if (escaping) {\n              valueStart = i;\n              escaping = false;\n            } else {\n              value += str.slice(valueStart, i);\n              escaping = true;\n            }\n            continue;\n          }\n          if (code === 34/* '\"' */) {\n            if (escaping) {\n              valueStart = i;\n              escaping = false;\n              continue;\n            }\n            value += str.slice(valueStart, i);\n            break;\n          }\n          if (escaping) {\n            valueStart = i - 1;\n            escaping = false;\n          }\n          // Invalid unescaped quoted character (malformed)\n          if (QDTEXT[code] !== 1)\n            return;\n        }\n\n        // No end quote (malformed)\n        if (i === str.length)\n          return;\n\n        ++i; // Skip over double quote\n      } else {\n        valueStart = i;\n        // Parse unquoted value\n        for (; i < str.length; ++i) {\n          const code = str.charCodeAt(i);\n          if (TOKEN[code] !== 1) {\n            // No value (malformed)\n            if (i === valueStart)\n              return;\n            break;\n          }\n        }\n        value = str.slice(valueStart, i);\n      }\n\n      value = defDecoder(value, 2);\n      if (value === undefined)\n        return;\n    }\n\n    name = name.toLowerCase();\n    if (params[name] === undefined)\n      params[name] = value;\n  }\n\n  return params;\n}\n\nfunction getDecoder(charset) {\n  let lc;\n  while (true) {\n    switch (charset) {\n      case 'utf-8':\n      case 'utf8':\n        return decoders.utf8;\n      case 'latin1':\n      case 'ascii': // TODO: Make these a separate, strict decoder?\n      case 'us-ascii':\n      case 'iso-8859-1':\n      case 'iso8859-1':\n      case 'iso88591':\n      case 'iso_8859-1':\n      case 'windows-1252':\n      case 'iso_8859-1:1987':\n      case 'cp1252':\n      case 'x-cp1252':\n        return decoders.latin1;\n      case 'utf16le':\n      case 'utf-16le':\n      case 'ucs2':\n      case 'ucs-2':\n        return decoders.utf16le;\n      case 'base64':\n        return decoders.base64;\n      default:\n        if (lc === undefined) {\n          lc = true;\n          charset = charset.toLowerCase();\n          continue;\n        }\n        return decoders.other.bind(charset);\n    }\n  }\n}\n\nconst decoders = {\n  utf8: (data, hint) => {\n    if (data.length === 0)\n      return '';\n    if (typeof data === 'string') {\n      // If `data` never had any percent-encoded bytes or never had any that\n      // were outside of the ASCII range, then we can safely just return the\n      // input since UTF-8 is ASCII compatible\n      if (hint < 2)\n        return data;\n\n      data = Buffer.from(data, 'latin1');\n    }\n    return data.utf8Slice(0, data.length);\n  },\n\n  latin1: (data, hint) => {\n    if (data.length === 0)\n      return '';\n    if (typeof data === 'string')\n      return data;\n    return data.latin1Slice(0, data.length);\n  },\n\n  utf16le: (data, hint) => {\n    if (data.length === 0)\n      return '';\n    if (typeof data === 'string')\n      data = Buffer.from(data, 'latin1');\n    return data.ucs2Slice(0, data.length);\n  },\n\n  base64: (data, hint) => {\n    if (data.length === 0)\n      return '';\n    if (typeof data === 'string')\n      data = Buffer.from(data, 'latin1');\n    return data.base64Slice(0, data.length);\n  },\n\n  other: (data, hint) => {\n    if (data.length === 0)\n      return '';\n    if (typeof data === 'string')\n      data = Buffer.from(data, 'latin1');\n    try {\n      const decoder = new TextDecoder(this);\n      return decoder.decode(data);\n    } catch {}\n  },\n};\n\nfunction convertToUTF8(data, charset, hint) {\n  const decode = getDecoder(charset);\n  if (decode)\n    return decode(data, hint);\n}\n\nfunction basename(path) {\n  if (typeof path !== 'string')\n    return '';\n  for (let i = path.length - 1; i >= 0; --i) {\n    switch (path.charCodeAt(i)) {\n      case 0x2F: // '/'\n      case 0x5C: // '\\'\n        path = path.slice(i + 1);\n        return (path === '..' || path === '.' ? '' : path);\n    }\n  }\n  return (path === '..' || path === '.' ? '' : path);\n}\n\nconst TOKEN = [\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,\n  0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n];\n\nconst QDTEXT = [\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n];\n\nconst CHARSET = [\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,\n  0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n];\n\nconst EXTENDED_VALUE = [\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,\n  0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,\n  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,\n];\n\n/* eslint-disable no-multi-spaces */\nconst HEX_VALUES = [\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n   0,  1,  2,  3,  4,  5,  6,  7,  8,  9, -1, -1, -1, -1, -1, -1,\n  -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,\n];\n/* eslint-enable no-multi-spaces */\n\nmodule.exports = {\n  basename,\n  convertToUTF8,\n  getDecoder,\n  parseContentType,\n  parseDisposition,\n};\n"
  },
  {
    "path": "server/libs/commandLineArgs/index.js",
    "content": "'use strict';\n\n//\n// modified for use in audiobookshelf (removed camelCase opt)\n// Source: https://github.com/75lb/command-line-args\n//\n\n/**\n * Takes any input and guarantees an array back.\n *\n * - Converts array-like objects (e.g. `arguments`, `Set`) to a real array.\n * - Converts `undefined` to an empty array.\n * - Converts any another other, singular value (including `null`, objects and iterables other than `Set`) into an array containing that value.\n * - Ignores input which is already an array.\n *\n * @module array-back\n * @example\n * > const arrayify = require('array-back')\n *\n * > arrayify(undefined)\n * []\n *\n * > arrayify(null)\n * [ null ]\n *\n * > arrayify(0)\n * [ 0 ]\n *\n * > arrayify([ 1, 2 ])\n * [ 1, 2 ]\n *\n * > arrayify(new Set([ 1, 2 ]))\n * [ 1, 2 ]\n *\n * > function f(){ return arrayify(arguments); }\n * > f(1,2,3)\n * [ 1, 2, 3 ]\n */\n\nfunction isObject(input) {\n  return typeof input === 'object' && input !== null\n}\n\nfunction isArrayLike(input) {\n  return isObject(input) && typeof input.length === 'number'\n}\n\n/**\n * @param {*} - The input value to convert to an array\n * @returns {Array}\n * @alias module:array-back\n */\nfunction arrayify(input) {\n  if (Array.isArray(input)) {\n    return input\n  }\n\n  if (input === undefined) {\n    return []\n  }\n\n  if (isArrayLike(input) || input instanceof Set) {\n    return Array.from(input)\n  }\n\n  return [input]\n}\n\n/**\n * Takes any input and guarantees an array back.\n *\n * - converts array-like objects (e.g. `arguments`) to a real array\n * - converts `undefined` to an empty array\n * - converts any another other, singular value (including `null`) into an array containing that value\n * - ignores input which is already an array\n *\n * @module array-back\n * @example\n * > const arrayify = require('array-back')\n *\n * > arrayify(undefined)\n * []\n *\n * > arrayify(null)\n * [ null ]\n *\n * > arrayify(0)\n * [ 0 ]\n *\n * > arrayify([ 1, 2 ])\n * [ 1, 2 ]\n *\n * > function f(){ return arrayify(arguments); }\n * > f(1,2,3)\n * [ 1, 2, 3 ]\n */\n\nfunction isObject$1(input) {\n  return typeof input === 'object' && input !== null\n}\n\nfunction isArrayLike$1(input) {\n  return isObject$1(input) && typeof input.length === 'number'\n}\n\n/**\n * @param {*} - the input value to convert to an array\n * @returns {Array}\n * @alias module:array-back\n */\nfunction arrayify$1(input) {\n  if (Array.isArray(input)) {\n    return input\n  } else {\n    if (input === undefined) {\n      return []\n    } else if (isArrayLike$1(input)) {\n      return Array.prototype.slice.call(input)\n    } else {\n      return [input]\n    }\n  }\n}\n\n/**\n * Find and either replace or remove items in an array.\n *\n * @module find-replace\n * @example\n * > const findReplace = require('find-replace')\n * > const numbers = [ 1, 2, 3]\n *\n * > findReplace(numbers, n => n === 2, 'two')\n * [ 1, 'two', 3 ]\n *\n * > findReplace(numbers, n => n === 2, [ 'two', 'zwei' ])\n * [ 1, [ 'two', 'zwei' ], 3 ]\n *\n * > findReplace(numbers, n => n === 2, 'two', 'zwei')\n * [ 1, 'two', 'zwei', 3 ]\n *\n * > findReplace(numbers, n => n === 2) // no replacement, so remove\n * [ 1, 3 ]\n */\n\n/**\n * @param {array} - The input array\n * @param {testFn} - A predicate function which, if returning `true` causes the current item to be operated on.\n * @param [replaceWith] {...any} - If specified, found values will be replaced with these values, else removed.\n * @returns {array}\n * @alias module:find-replace\n */\nfunction findReplace(array, testFn) {\n  const found = [];\n  const replaceWiths = arrayify$1(arguments);\n  replaceWiths.splice(0, 2);\n\n  arrayify$1(array).forEach((value, index) => {\n    let expanded = [];\n    replaceWiths.forEach(replaceWith => {\n      if (typeof replaceWith === 'function') {\n        expanded = expanded.concat(replaceWith(value));\n      } else {\n        expanded.push(replaceWith);\n      }\n    });\n\n    if (testFn(value)) {\n      found.push({\n        index: index,\n        replaceWithValue: expanded\n      });\n    }\n  });\n\n  found.reverse().forEach(item => {\n    const spliceArgs = [item.index, 1].concat(item.replaceWithValue);\n    array.splice.apply(array, spliceArgs);\n  });\n\n  return array\n}\n\n/**\n * Some useful tools for working with `process.argv`.\n *\n * @module argv-tools\n * @typicalName argvTools\n * @example\n * const argvTools = require('argv-tools')\n */\n\n/**\n * Regular expressions for matching option formats.\n * @static\n */\nconst re = {\n  short: /^-([^\\d-])$/,\n  long: /^--(\\S+)/,\n  combinedShort: /^-[^\\d-]{2,}$/,\n  optEquals: /^(--\\S+?)=(.*)/\n};\n\n/**\n * Array subclass encapsulating common operations on `process.argv`.\n * @static\n */\nclass ArgvArray extends Array {\n  /**\n   * Clears the array has loads the supplied input.\n   * @param {string[]} argv - The argv list to load. Defaults to `process.argv`.\n   */\n  load(argv) {\n    this.clear();\n    if (argv && argv !== process.argv) {\n      argv = arrayify(argv);\n    } else {\n      /* if no argv supplied, assume we are parsing process.argv */\n      argv = process.argv.slice(0);\n      const deleteCount = process.execArgv.some(isExecArg) ? 1 : 2;\n      argv.splice(0, deleteCount);\n    }\n    argv.forEach(arg => this.push(String(arg)));\n  }\n\n  /**\n   * Clear the array.\n   */\n  clear() {\n    this.length = 0;\n  }\n\n  /**\n   * expand ``--option=value` style args.\n   */\n  expandOptionEqualsNotation() {\n    if (this.some(arg => re.optEquals.test(arg))) {\n      const expandedArgs = [];\n      this.forEach(arg => {\n        const matches = arg.match(re.optEquals);\n        if (matches) {\n          expandedArgs.push(matches[1], matches[2]);\n        } else {\n          expandedArgs.push(arg);\n        }\n      });\n      this.clear();\n      this.load(expandedArgs);\n    }\n  }\n\n  /**\n   * expand getopt-style combinedShort options.\n   */\n  expandGetoptNotation() {\n    if (this.hasCombinedShortOptions()) {\n      findReplace(this, re.combinedShort, expandCombinedShortArg);\n    }\n  }\n\n  /**\n   * Returns true if the array contains combined short options (e.g. `-ab`).\n   * @returns {boolean}\n   */\n  hasCombinedShortOptions() {\n    return this.some(arg => re.combinedShort.test(arg))\n  }\n\n  static from(argv) {\n    const result = new this();\n    result.load(argv);\n    return result\n  }\n}\n\n/**\n * Expand a combined short option.\n * @param {string} - the string to expand, e.g. `-ab`\n * @returns {string[]}\n * @static\n */\nfunction expandCombinedShortArg(arg) {\n  /* remove initial hypen */\n  arg = arg.slice(1);\n  return arg.split('').map(letter => '-' + letter)\n}\n\n/**\n * Returns true if the supplied arg matches `--option=value` notation.\n * @param {string} - the arg to test, e.g. `--one=something`\n * @returns {boolean}\n * @static\n */\nfunction isOptionEqualsNotation(arg) {\n  return re.optEquals.test(arg)\n}\n\n/**\n * Returns true if the supplied arg is in either long (`--one`) or short (`-o`) format.\n * @param {string} - the arg to test, e.g. `--one`\n * @returns {boolean}\n * @static\n */\nfunction isOption(arg) {\n  return (re.short.test(arg) || re.long.test(arg)) && !re.optEquals.test(arg)\n}\n\n/**\n * Returns true if the supplied arg is in long (`--one`) format.\n * @param {string} - the arg to test, e.g. `--one`\n * @returns {boolean}\n * @static\n */\nfunction isLongOption(arg) {\n  return re.long.test(arg) && !isOptionEqualsNotation(arg)\n}\n\n/**\n * Returns the name from a long, short or `--options=value` arg.\n * @param {string} - the arg to inspect, e.g. `--one`\n * @returns {string}\n * @static\n */\nfunction getOptionName(arg) {\n  if (re.short.test(arg)) {\n    return arg.match(re.short)[1]\n  } else if (isLongOption(arg)) {\n    return arg.match(re.long)[1]\n  } else if (isOptionEqualsNotation(arg)) {\n    return arg.match(re.optEquals)[1].replace(/^--/, '')\n  } else {\n    return null\n  }\n}\n\nfunction isValue(arg) {\n  return !(isOption(arg) || re.combinedShort.test(arg) || re.optEquals.test(arg))\n}\n\nfunction isExecArg(arg) {\n  return ['--eval', '-e'].indexOf(arg) > -1 || arg.startsWith('--eval=')\n}\n\n/**\n * For type-checking Javascript values.\n * @module typical\n * @typicalname t\n * @example\n * const t = require('typical')\n */\n\n/**\n * Returns true if input is a number\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n * @example\n * > t.isNumber(0)\n * true\n * > t.isNumber(1)\n * true\n * > t.isNumber(1.1)\n * true\n * > t.isNumber(0xff)\n * true\n * > t.isNumber(0644)\n * true\n * > t.isNumber(6.2e5)\n * true\n * > t.isNumber(NaN)\n * false\n * > t.isNumber(Infinity)\n * false\n */\nfunction isNumber(n) {\n  return !isNaN(parseFloat(n)) && isFinite(n)\n}\n\n/**\n * A plain object is a simple object literal, it is not an instance of a class. Returns true if the input `typeof` is `object` and directly decends from `Object`.\n *\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n * @example\n * > t.isPlainObject({ something: 'one' })\n * true\n * > t.isPlainObject(new Date())\n * false\n * > t.isPlainObject([ 0, 1 ])\n * false\n * > t.isPlainObject(/test/)\n * false\n * > t.isPlainObject(1)\n * false\n * > t.isPlainObject('one')\n * false\n * > t.isPlainObject(null)\n * false\n * > t.isPlainObject((function * () {})())\n * false\n * > t.isPlainObject(function * () {})\n * false\n */\nfunction isPlainObject(input) {\n  return input !== null && typeof input === 'object' && input.constructor === Object\n}\n\n/**\n * An array-like value has all the properties of an array, but is not an array instance. Examples in the `arguments` object. Returns true if the input value is an object, not null and has a `length` property with a numeric value.\n *\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n * @example\n * function sum(x, y){\n *     console.log(t.isArrayLike(arguments))\n *     // prints `true`\n * }\n */\nfunction isArrayLike$2(input) {\n  return isObject$2(input) && typeof input.length === 'number'\n}\n\n/**\n * returns true if the typeof input is `'object'`, but not null!\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isObject$2(input) {\n  return typeof input === 'object' && input !== null\n}\n\n/**\n * Returns true if the input value is defined\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isDefined(input) {\n  return typeof input !== 'undefined'\n}\n\n/**\n * Returns true if the input value is a string\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isString(input) {\n  return typeof input === 'string'\n}\n\n/**\n * Returns true if the input value is a boolean\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isBoolean(input) {\n  return typeof input === 'boolean'\n}\n\n/**\n * Returns true if the input value is a function\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isFunction(input) {\n  return typeof input === 'function'\n}\n\n/**\n * Returns true if the input value is an es2015 `class`.\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isClass(input) {\n  if (isFunction(input)) {\n    return /^class /.test(Function.prototype.toString.call(input))\n  } else {\n    return false\n  }\n}\n\n/**\n * Returns true if the input is a string, number, symbol, boolean, null or undefined value.\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isPrimitive(input) {\n  if (input === null) return true\n  switch (typeof input) {\n    case 'string':\n    case 'number':\n    case 'symbol':\n    case 'undefined':\n    case 'boolean':\n      return true\n    default:\n      return false\n  }\n}\n\n/**\n * Returns true if the input is a Promise.\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n */\nfunction isPromise(input) {\n  if (input) {\n    const isPromise = isDefined(Promise) && input instanceof Promise;\n    const isThenable = input.then && typeof input.then === 'function';\n    return !!(isPromise || isThenable)\n  } else {\n    return false\n  }\n}\n\n/**\n * Returns true if the input is an iterable (`Map`, `Set`, `Array`, Generator etc.).\n * @param {*} - the input to test\n * @returns {boolean}\n * @static\n * @example\n * > t.isIterable('string')\n * true\n * > t.isIterable(new Map())\n * true\n * > t.isIterable([])\n * true\n * > t.isIterable((function * () {})())\n * true\n * > t.isIterable(Promise.resolve())\n * false\n * > t.isIterable(Promise)\n * false\n * > t.isIterable(true)\n * false\n * > t.isIterable({})\n * false\n * > t.isIterable(0)\n * false\n * > t.isIterable(1.1)\n * false\n * > t.isIterable(NaN)\n * false\n * > t.isIterable(Infinity)\n * false\n * > t.isIterable(function () {})\n * false\n * > t.isIterable(Date)\n * false\n * > t.isIterable()\n * false\n * > t.isIterable({ then: function () {} })\n * false\n */\nfunction isIterable(input) {\n  if (input === null || !isDefined(input)) {\n    return false\n  } else {\n    return (\n      typeof input[Symbol.iterator] === 'function' ||\n      typeof input[Symbol.asyncIterator] === 'function'\n    )\n  }\n}\n\nvar t = {\n  isNumber,\n  isString,\n  isBoolean,\n  isPlainObject,\n  isArrayLike: isArrayLike$2,\n  isObject: isObject$2,\n  isDefined,\n  isFunction,\n  isClass,\n  isPrimitive,\n  isPromise,\n  isIterable\n};\n\n/**\n * @module option-definition\n */\n\n/**\n * Describes a command-line option. Additionally, if generating a usage guide with [command-line-usage](https://github.com/75lb/command-line-usage) you could optionally add `description` and `typeLabel` properties to each definition.\n *\n * @alias module:option-definition\n * @typicalname option\n */\nclass OptionDefinition {\n  constructor(definition) {\n    /**\n    * The only required definition property is `name`, so the simplest working example is\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'file' },\n    *   { name: 'depth' }\n    * ]\n    * ```\n    *\n    * Where a `type` property is not specified it will default to `String`.\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | -------------------- | ------------ |\n    * | 1   | `--file` | `{ file: null }` |\n    * | 2   | `--file lib.js` | `{ file: 'lib.js' }` |\n    * | 3   | `--depth 2` | `{ depth: '2' }` |\n    *\n    * Unicode option names and aliases are valid, for example:\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'один' },\n    *   { name: '两' },\n    *   { name: 'три', alias: 'т' }\n    * ]\n    * ```\n    * @type {string}\n    */\n    this.name = definition.name;\n\n    /**\n    * The `type` value is a setter function (you receive the output from this), enabling you to be specific about the type and value received.\n    *\n    * The most common values used are `String` (the default), `Number` and `Boolean` but you can use a custom function, for example:\n    *\n    * ```js\n    * const fs = require('fs')\n    *\n    * class FileDetails {\n    *   constructor (filename) {\n    *     this.filename = filename\n    *     this.exists = fs.existsSync(filename)\n    *   }\n    * }\n    *\n    * const cli = commandLineArgs([\n    *   { name: 'file', type: filename => new FileDetails(filename) },\n    *   { name: 'depth', type: Number }\n    * ])\n    * ```\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | ----------------- | ------------ |\n    * | 1   | `--file asdf.txt` | `{ file: { filename: 'asdf.txt', exists: false } }` |\n    *\n    * The `--depth` option expects a `Number`. If no value was set, you will receive `null`.\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | ----------------- | ------------ |\n    * | 2   | `--depth` | `{ depth: null }` |\n    * | 3   | `--depth 2` | `{ depth: 2 }` |\n    *\n    * @type {function}\n    * @default String\n    */\n    this.type = definition.type || String;\n\n    /**\n    * getopt-style short option names. Can be any single character (unicode included) except a digit or hyphen.\n    *\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'hot', alias: 'h', type: Boolean },\n    *   { name: 'discount', alias: 'd', type: Boolean },\n    *   { name: 'courses', alias: 'c' , type: Number }\n    * ]\n    * ```\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | ------------ | ------------ |\n    * | 1   | `-hcd` | `{ hot: true, courses: null, discount: true }` |\n    * | 2   | `-hdc 3` | `{ hot: true, discount: true, courses: 3 }` |\n    *\n    * @type {string}\n    */\n    this.alias = definition.alias;\n\n    /**\n    * Set this flag if the option takes a list of values. You will receive an array of values, each passed through the `type` function (if specified).\n    *\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'files', type: String, multiple: true }\n    * ]\n    * ```\n    *\n    * Note, examples 1 and 3 below demonstrate \"greedy\" parsing which can be disabled by using `lazyMultiple`.\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | ------------ | ------------ |\n    * | 1   | `--files one.js two.js` | `{ files: [ 'one.js', 'two.js' ] }` |\n    * | 2   | `--files one.js --files two.js` | `{ files: [ 'one.js', 'two.js' ] }` |\n    * | 3   | `--files *` | `{ files: [ 'one.js', 'two.js' ] }` |\n    *\n    * @type {boolean}\n    */\n    this.multiple = definition.multiple;\n\n    /**\n     * Identical to `multiple` but with greedy parsing disabled.\n     *\n     * ```js\n     * const optionDefinitions = [\n     *   { name: 'files', lazyMultiple: true },\n     *   { name: 'verbose', alias: 'v', type: Boolean, lazyMultiple: true }\n     * ]\n     * ```\n     *\n     * | #   | argv input | commandLineArgs() output |\n     * | --- | ------------ | ------------ |\n     * | 1   | `--files one.js --files two.js` | `{ files: [ 'one.js', 'two.js' ] }` |\n     * | 2   | `-vvv` | `{ verbose: [ true, true, true ] }` |\n     *\n     * @type {boolean}\n     */\n    this.lazyMultiple = definition.lazyMultiple;\n\n    /**\n    * Any values unaccounted for by an option definition will be set on the `defaultOption`. This flag is typically set on the most commonly-used option to make for more concise usage (i.e. `$ example *.js` instead of `$ example --files *.js`).\n    *\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'files', multiple: true, defaultOption: true }\n    * ]\n    * ```\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | ------------ | ------------ |\n    * | 1   | `--files one.js two.js` | `{ files: [ 'one.js', 'two.js' ] }` |\n    * | 2   | `one.js two.js` | `{ files: [ 'one.js', 'two.js' ] }` |\n    * | 3   | `*` | `{ files: [ 'one.js', 'two.js' ] }` |\n    *\n    * @type {boolean}\n    */\n    this.defaultOption = definition.defaultOption;\n\n    /**\n    * An initial value for the option.\n    *\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'files', multiple: true, defaultValue: [ 'one.js' ] },\n    *   { name: 'max', type: Number, defaultValue: 3 }\n    * ]\n    * ```\n    *\n    * | #   | argv input | commandLineArgs() output |\n    * | --- | ------------ | ------------ |\n    * | 1   |  | `{ files: [ 'one.js' ], max: 3 }` |\n    * | 2   | `--files two.js` | `{ files: [ 'two.js' ], max: 3 }` |\n    * | 3   | `--max 4` | `{ files: [ 'one.js' ], max: 4 }` |\n    *\n    * @type {*}\n    */\n    this.defaultValue = definition.defaultValue;\n\n    /**\n    * When your app has a large amount of options it makes sense to organise them in groups.\n    *\n    * There are two automatic groups: `_all` (contains all options) and `_none` (contains options without a `group` specified in their definition).\n    *\n    * ```js\n    * const optionDefinitions = [\n    *   { name: 'verbose', group: 'standard' },\n    *   { name: 'help', group: [ 'standard', 'main' ] },\n    *   { name: 'compress', group: [ 'server', 'main' ] },\n    *   { name: 'static', group: 'server' },\n    *   { name: 'debug' }\n    * ]\n    * ```\n    *\n    *<table>\n    *  <tr>\n    *    <th>#</th><th>Command Line</th><th>commandLineArgs() output</th>\n    *  </tr>\n    *  <tr>\n    *    <td>1</td><td><code>--verbose</code></td><td><pre><code>\n    *{\n    *  _all: { verbose: true },\n    *  standard: { verbose: true }\n    *}\n    *</code></pre></td>\n    *  </tr>\n    *  <tr>\n    *    <td>2</td><td><code>--debug</code></td><td><pre><code>\n    *{\n    *  _all: { debug: true },\n    *  _none: { debug: true }\n    *}\n    *</code></pre></td>\n    *  </tr>\n    *  <tr>\n    *    <td>3</td><td><code>--verbose --debug --compress</code></td><td><pre><code>\n    *{\n    *  _all: {\n    *    verbose: true,\n    *    debug: true,\n    *    compress: true\n    *  },\n    *  standard: { verbose: true },\n    *  server: { compress: true },\n    *  main: { compress: true },\n    *  _none: { debug: true }\n    *}\n    *</code></pre></td>\n    *  </tr>\n    *  <tr>\n    *    <td>4</td><td><code>--compress</code></td><td><pre><code>\n    *{\n    *  _all: { compress: true },\n    *  server: { compress: true },\n    *  main: { compress: true }\n    *}\n    *</code></pre></td>\n    *  </tr>\n    *</table>\n    *\n    * @type {string|string[]}\n    */\n    this.group = definition.group;\n\n    /* pick up any remaining properties */\n    for (const prop in definition) {\n      if (!this[prop]) this[prop] = definition[prop];\n    }\n  }\n\n  isBoolean() {\n    return this.type === Boolean || (t.isFunction(this.type) && this.type.name === 'Boolean')\n  }\n\n  isMultiple() {\n    return this.multiple || this.lazyMultiple\n  }\n\n  static create(def) {\n    const result = new this(def);\n    return result\n  }\n}\n\n/**\n * @module option-definitions\n */\n\n/**\n * @alias module:option-definitions\n */\nclass Definitions extends Array {\n  /**\n   * validate option definitions\n   * @param {boolean} [caseInsensitive=false] - whether arguments will be parsed in a case insensitive manner\n   * @returns {string}\n   */\n  validate(caseInsensitive) {\n    const someHaveNoName = this.some(def => !def.name);\n    if (someHaveNoName) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Invalid option definitions: the `name` property is required on each definition'\n      );\n    }\n\n    const someDontHaveFunctionType = this.some(def => def.type && typeof def.type !== 'function');\n    if (someDontHaveFunctionType) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Invalid option definitions: the `type` property must be a setter fuction (default: `Boolean`)'\n      );\n    }\n\n    let invalidOption;\n\n    const numericAlias = this.some(def => {\n      invalidOption = def;\n      return t.isDefined(def.alias) && t.isNumber(def.alias)\n    });\n    if (numericAlias) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Invalid option definition: to avoid ambiguity an alias cannot be numeric [--' + invalidOption.name + ' alias is -' + invalidOption.alias + ']'\n      );\n    }\n\n    const multiCharacterAlias = this.some(def => {\n      invalidOption = def;\n      return t.isDefined(def.alias) && def.alias.length !== 1\n    });\n    if (multiCharacterAlias) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Invalid option definition: an alias must be a single character'\n      );\n    }\n\n    const hypenAlias = this.some(def => {\n      invalidOption = def;\n      return def.alias === '-'\n    });\n    if (hypenAlias) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Invalid option definition: an alias cannot be \"-\"'\n      );\n    }\n\n    const duplicateName = hasDuplicates(this.map(def => caseInsensitive ? def.name.toLowerCase() : def.name));\n    if (duplicateName) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Two or more option definitions have the same name'\n      );\n    }\n\n    const duplicateAlias = hasDuplicates(this.map(def => caseInsensitive && t.isDefined(def.alias) ? def.alias.toLowerCase() : def.alias));\n    if (duplicateAlias) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Two or more option definitions have the same alias'\n      );\n    }\n\n    const duplicateDefaultOption = this.filter(def => def.defaultOption === true).length > 1;\n    if (duplicateDefaultOption) {\n      halt(\n        'INVALID_DEFINITIONS',\n        'Only one option definition can be the defaultOption'\n      );\n    }\n\n    const defaultBoolean = this.some(def => {\n      invalidOption = def;\n      return def.isBoolean() && def.defaultOption\n    });\n    if (defaultBoolean) {\n      halt(\n        'INVALID_DEFINITIONS',\n        `A boolean option [\"${invalidOption.name}\"] can not also be the defaultOption.`\n      );\n    }\n  }\n\n  /**\n   * Get definition by option arg (e.g. `--one` or `-o`)\n   * @param {string} [arg] the argument name to get the definition for\n   * @param {boolean} [caseInsensitive] whether to use case insensitive comparisons when finding the appropriate definition\n   * @returns {Definition}\n   */\n  get(arg, caseInsensitive) {\n    if (isOption(arg)) {\n      if (re.short.test(arg)) {\n        const shortOptionName = getOptionName(arg);\n        if (caseInsensitive) {\n          const lowercaseShortOptionName = shortOptionName.toLowerCase();\n          return this.find(def => t.isDefined(def.alias) && def.alias.toLowerCase() === lowercaseShortOptionName)\n        } else {\n          return this.find(def => def.alias === shortOptionName)\n        }\n      } else {\n        const optionName = getOptionName(arg);\n        if (caseInsensitive) {\n          const lowercaseOptionName = optionName.toLowerCase();\n          return this.find(def => def.name.toLowerCase() === lowercaseOptionName)\n        } else {\n          return this.find(def => def.name === optionName)\n        }\n      }\n    } else {\n      return this.find(def => def.name === arg)\n    }\n  }\n\n  getDefault() {\n    return this.find(def => def.defaultOption === true)\n  }\n\n  isGrouped() {\n    return this.some(def => def.group)\n  }\n\n  whereGrouped() {\n    return this.filter(containsValidGroup)\n  }\n\n  whereNotGrouped() {\n    return this.filter(def => !containsValidGroup(def))\n  }\n\n  whereDefaultValueSet() {\n    return this.filter(def => t.isDefined(def.defaultValue))\n  }\n\n  static from(definitions, caseInsensitive) {\n    if (definitions instanceof this) return definitions\n    const result = super.from(arrayify(definitions), def => OptionDefinition.create(def));\n    result.validate(caseInsensitive);\n    return result\n  }\n}\n\nfunction halt(name, message) {\n  const err = new Error(message);\n  err.name = name;\n  throw err\n}\n\nfunction containsValidGroup(def) {\n  return arrayify(def.group).some(group => group)\n}\n\nfunction hasDuplicates(array) {\n  const items = {};\n  for (let i = 0; i < array.length; i++) {\n    const value = array[i];\n    if (items[value]) {\n      return true\n    } else {\n      if (t.isDefined(value)) items[value] = true;\n    }\n  }\n}\n\n/**\n * @module argv-parser\n */\n\n/**\n * @alias module:argv-parser\n */\nclass ArgvParser {\n  /**\n   * @param {OptionDefinitions} - Definitions array\n   * @param {object} [options] - Options\n   * @param {string[]} [options.argv] - Overrides `process.argv`\n   * @param {boolean} [options.stopAtFirstUnknown] -\n   * @param {boolean} [options.caseInsensitive] - Arguments will be parsed in a case insensitive manner. Defaults to false.\n   */\n  constructor(definitions, options) {\n    this.options = Object.assign({}, options);\n    /**\n     * Option Definitions\n     */\n    this.definitions = Definitions.from(definitions, this.options.caseInsensitive);\n\n    /**\n     * Argv\n     */\n    this.argv = ArgvArray.from(this.options.argv);\n    if (this.argv.hasCombinedShortOptions()) {\n      findReplace(this.argv, re.combinedShort.test.bind(re.combinedShort), arg => {\n        arg = arg.slice(1);\n        return arg.split('').map(letter => ({ origArg: `-${arg}`, arg: '-' + letter }))\n      });\n    }\n  }\n\n  /**\n   * Yields one `{ event, name, value, arg, def }` argInfo object for each arg in `process.argv` (or `options.argv`).\n   */\n  *[Symbol.iterator]() {\n    const definitions = this.definitions;\n\n    let def;\n    let value;\n    let name;\n    let event;\n    let singularDefaultSet = false;\n    let unknownFound = false;\n    let origArg;\n\n    for (let arg of this.argv) {\n      if (t.isPlainObject(arg)) {\n        origArg = arg.origArg;\n        arg = arg.arg;\n      }\n\n      if (unknownFound && this.options.stopAtFirstUnknown) {\n        yield { event: 'unknown_value', arg, name: '_unknown', value: undefined };\n        continue\n      }\n\n      /* handle long or short option */\n      if (isOption(arg)) {\n        def = definitions.get(arg, this.options.caseInsensitive);\n        value = undefined;\n        if (def) {\n          value = def.isBoolean() ? true : null;\n          event = 'set';\n        } else {\n          event = 'unknown_option';\n        }\n\n        /* handle --option-value notation */\n      } else if (isOptionEqualsNotation(arg)) {\n        const matches = arg.match(re.optEquals);\n        def = definitions.get(matches[1], this.options.caseInsensitive);\n        if (def) {\n          if (def.isBoolean()) {\n            yield { event: 'unknown_value', arg, name: '_unknown', value, def };\n            event = 'set';\n            value = true;\n          } else {\n            event = 'set';\n            value = matches[2];\n          }\n        } else {\n          event = 'unknown_option';\n        }\n\n        /* handle value */\n      } else if (isValue(arg)) {\n        if (def) {\n          value = arg;\n          event = 'set';\n        } else {\n          /* get the defaultOption */\n          def = this.definitions.getDefault();\n          if (def && !singularDefaultSet) {\n            value = arg;\n            event = 'set';\n          } else {\n            event = 'unknown_value';\n            def = undefined;\n          }\n        }\n      }\n\n      name = def ? def.name : '_unknown';\n      const argInfo = { event, arg, name, value, def };\n      if (origArg) {\n        argInfo.subArg = arg;\n        argInfo.arg = origArg;\n      }\n      yield argInfo;\n\n      /* unknownFound logic */\n      if (name === '_unknown') unknownFound = true;\n\n      /* singularDefaultSet logic */\n      if (def && def.defaultOption && !def.isMultiple() && event === 'set') singularDefaultSet = true;\n\n      /* reset values once consumed and yielded */\n      if (def && def.isBoolean()) def = undefined;\n      /* reset the def if it's a singular which has been set */\n      if (def && !def.multiple && t.isDefined(value) && value !== null) {\n        def = undefined;\n      }\n      value = undefined;\n      event = undefined;\n      name = undefined;\n      origArg = undefined;\n    }\n  }\n}\n\nconst _value = new WeakMap();\n\n/**\n * Encapsulates behaviour (defined by an OptionDefinition) when setting values\n */\nclass Option {\n  constructor(definition) {\n    this.definition = new OptionDefinition(definition);\n    this.state = null; /* set or default */\n    this.resetToDefault();\n  }\n\n  get() {\n    return _value.get(this)\n  }\n\n  set(val) {\n    this._set(val, 'set');\n  }\n\n  _set(val, state) {\n    const def = this.definition;\n    if (def.isMultiple()) {\n      /* don't add null or undefined to a multiple */\n      if (val !== null && val !== undefined) {\n        const arr = this.get();\n        if (this.state === 'default') arr.length = 0;\n        arr.push(def.type(val));\n        this.state = state;\n      }\n    } else {\n      /* throw if already set on a singlar defaultOption */\n      if (!def.isMultiple() && this.state === 'set') {\n        const err = new Error(`Singular option already set [${this.definition.name}=${this.get()}]`);\n        err.name = 'ALREADY_SET';\n        err.value = val;\n        err.optionName = def.name;\n        throw err\n      } else if (val === null || val === undefined) {\n        _value.set(this, val);\n        // /* required to make 'partial: defaultOption with value equal to defaultValue 2' pass */\n        // if (!(def.defaultOption && !def.isMultiple())) {\n        //   this.state = state\n        // }\n      } else {\n        _value.set(this, def.type(val));\n        this.state = state;\n      }\n    }\n  }\n\n  resetToDefault() {\n    if (t.isDefined(this.definition.defaultValue)) {\n      if (this.definition.isMultiple()) {\n        _value.set(this, arrayify(this.definition.defaultValue).slice());\n      } else {\n        _value.set(this, this.definition.defaultValue);\n      }\n    } else {\n      if (this.definition.isMultiple()) {\n        _value.set(this, []);\n      } else {\n        _value.set(this, null);\n      }\n    }\n    this.state = 'default';\n  }\n\n  static create(definition) {\n    definition = new OptionDefinition(definition);\n    if (definition.isBoolean()) {\n      return FlagOption.create(definition)\n    } else {\n      return new this(definition)\n    }\n  }\n}\n\nclass FlagOption extends Option {\n  set(val) {\n    super.set(true);\n  }\n\n  static create(def) {\n    return new this(def)\n  }\n}\n\n/**\n * A map of { DefinitionNameString: Option }. By default, an Output has an `_unknown` property and any options with defaultValues.\n */\nclass Output extends Map {\n  constructor(definitions) {\n    super();\n    /**\n     * @type {OptionDefinitions}\n     */\n    this.definitions = Definitions.from(definitions);\n\n    /* by default, an Output has an `_unknown` property and any options with defaultValues */\n    this.set('_unknown', Option.create({ name: '_unknown', multiple: true }));\n    for (const def of this.definitions.whereDefaultValueSet()) {\n      this.set(def.name, Option.create(def));\n    }\n  }\n\n  toObject(options) {\n    options = options || {};\n    const output = {};\n    for (const item of this) {\n      const name = item[0];\n      const option = item[1];\n      if (name === '_unknown' && !option.get().length) continue\n      output[name] = option.get();\n    }\n\n    if (options.skipUnknown) delete output._unknown;\n    return output\n  }\n}\n\nclass GroupedOutput extends Output {\n  toObject(options) {\n    const superOutputNoCamel = super.toObject({ skipUnknown: options.skipUnknown });\n    const superOutput = super.toObject(options);\n    const unknown = superOutput._unknown;\n    delete superOutput._unknown;\n    const grouped = {\n      _all: superOutput\n    };\n    if (unknown && unknown.length) grouped._unknown = unknown;\n\n    this.definitions.whereGrouped().forEach(def => {\n      const name = def.name;\n      const outputValue = superOutputNoCamel[def.name];\n      for (const groupName of arrayify(def.group)) {\n        grouped[groupName] = grouped[groupName] || {};\n        if (t.isDefined(outputValue)) {\n          grouped[groupName][name] = outputValue;\n        }\n      }\n    });\n\n    this.definitions.whereNotGrouped().forEach(def => {\n      const name = def.name;\n      const outputValue = superOutputNoCamel[def.name];\n      if (t.isDefined(outputValue)) {\n        if (!grouped._none) grouped._none = {};\n        grouped._none[name] = outputValue;\n      }\n    });\n    return grouped\n  }\n}\n\n/**\n * @module command-line-args\n */\n\n/**\n * Returns an object containing all option values set on the command line. By default it parses the global  [`process.argv`](https://nodejs.org/api/process.html#process_process_argv) array.\n *\n * Parsing is strict by default - an exception is thrown if the user sets a singular option more than once or sets an unknown value or option (one without a valid [definition](https://github.com/75lb/command-line-args/blob/master/doc/option-definition.md)). To be more permissive, enabling [partial](https://github.com/75lb/command-line-args/wiki/Partial-mode-example) or [stopAtFirstUnknown](https://github.com/75lb/command-line-args/wiki/stopAtFirstUnknown) modes will return known options in the usual manner while collecting unknown arguments in a separate `_unknown` property.\n *\n * @param {Array<OptionDefinition>} - An array of [OptionDefinition](https://github.com/75lb/command-line-args/blob/master/doc/option-definition.md) objects\n * @param {object} [options] - Options.\n * @param {string[]} [options.argv] - An array of strings which, if present will be parsed instead  of `process.argv`.\n * @param {boolean} [options.partial] - If `true`, an array of unknown arguments is returned in the `_unknown` property of the output.\n * @param {boolean} [options.stopAtFirstUnknown] - If `true`, parsing will stop at the first unknown argument and the remaining arguments returned in `_unknown`. When set, `partial: true` is also implied.\n * @param {boolean} [options.caseInsensitive] - If `true`, the case of each option name or alias parsed is insignificant. In other words, both `--Verbose` and `--verbose`, `-V` and `-v` would be equivalent. Defaults to false.\n * @returns {object}\n * @throws `UNKNOWN_OPTION` If `options.partial` is false and the user set an undefined option. The `err.optionName` property contains the arg that specified an unknown option, e.g. `--one`.\n * @throws `UNKNOWN_VALUE` If `options.partial` is false and the user set a value unaccounted for by an option definition. The `err.value` property contains the unknown value, e.g. `5`.\n * @throws `ALREADY_SET` If a user sets a singular, non-multiple option more than once. The `err.optionName` property contains the option name that has already been set, e.g. `one`.\n * @throws `INVALID_DEFINITIONS`\n *   - If an option definition is missing the required `name` property\n *   - If an option definition has a `type` value that's not a function\n *   - If an alias is numeric, a hyphen or a length other than 1\n *   - If an option definition name was used more than once\n *   - If an option definition alias was used more than once\n *   - If more than one option definition has `defaultOption: true`\n *   - If a `Boolean` option is also set as the `defaultOption`.\n * @alias module:command-line-args\n */\nfunction commandLineArgs(optionDefinitions, options) {\n  options = options || {};\n  if (options.stopAtFirstUnknown) options.partial = true;\n  optionDefinitions = Definitions.from(optionDefinitions, options.caseInsensitive);\n\n  const parser = new ArgvParser(optionDefinitions, {\n    argv: options.argv,\n    stopAtFirstUnknown: options.stopAtFirstUnknown,\n    caseInsensitive: options.caseInsensitive\n  });\n\n  const OutputClass = optionDefinitions.isGrouped() ? GroupedOutput : Output;\n  const output = new OutputClass(optionDefinitions);\n\n  /* Iterate the parser setting each known value to the output. Optionally, throw on unknowns. */\n  for (const argInfo of parser) {\n    const arg = argInfo.subArg || argInfo.arg;\n    if (!options.partial) {\n      if (argInfo.event === 'unknown_value') {\n        const err = new Error(`Unknown value: ${arg}`);\n        err.name = 'UNKNOWN_VALUE';\n        err.value = arg;\n        throw err\n      } else if (argInfo.event === 'unknown_option') {\n        const err = new Error(`Unknown option: ${arg}`);\n        err.name = 'UNKNOWN_OPTION';\n        err.optionName = arg;\n        throw err\n      }\n    }\n\n    let option;\n    if (output.has(argInfo.name)) {\n      option = output.get(argInfo.name);\n    } else {\n      option = Option.create(argInfo.def);\n      output.set(argInfo.name, option);\n    }\n\n    if (argInfo.name === '_unknown') {\n      option.set(arg);\n    } else {\n      option.set(argInfo.value);\n    }\n  }\n\n  return output.toObject({ skipUnknown: !options.partial })\n}\n\nmodule.exports = commandLineArgs;"
  },
  {
    "path": "server/libs/dateAndTime/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015 KNOWLEDGECODE\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.\n"
  },
  {
    "path": "server/libs/dateAndTime/index.js",
    "content": "/*\n date-and-time (c) KNOWLEDGECODE | MIT\n*/\n'use strict';(function(p,m){\"object\"===typeof exports&&\"undefined\"!==typeof module?module.exports=m():\"function\"===typeof define&&define.amd?define(m):(p=\"undefined\"!==typeof globalThis?globalThis:p||self,p.date=m())})(this,function(){var p={},m={},r=\"en\",t={MMMM:\"January February March April May June July August September October November December\".split(\" \"),MMM:\"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec\".split(\" \"),dddd:\"Sunday Monday Tuesday Wednesday Thursday Friday Saturday\".split(\" \"),\nddd:\"Sun Mon Tue Wed Thu Fri Sat\".split(\" \"),dd:\"Su Mo Tu We Th Fr Sa\".split(\" \"),A:[\"AM\",\"PM\"]},w={YYYY:function(a){return(\"000\"+a.getFullYear()).slice(-4)},YY:function(a){return(\"0\"+a.getFullYear()).slice(-2)},Y:function(a){return\"\"+a.getFullYear()},MMMM:function(a){return this.res.MMMM[a.getMonth()]},MMM:function(a){return this.res.MMM[a.getMonth()]},MM:function(a){return(\"0\"+(a.getMonth()+1)).slice(-2)},M:function(a){return\"\"+(a.getMonth()+1)},DD:function(a){return(\"0\"+a.getDate()).slice(-2)},\nD:function(a){return\"\"+a.getDate()},HH:function(a){return(\"0\"+a.getHours()).slice(-2)},H:function(a){return\"\"+a.getHours()},A:function(a){return this.res.A[11<a.getHours()|0]},hh:function(a){return(\"0\"+(a.getHours()%12||12)).slice(-2)},h:function(a){return\"\"+(a.getHours()%12||12)},mm:function(a){return(\"0\"+a.getMinutes()).slice(-2)},m:function(a){return\"\"+a.getMinutes()},ss:function(a){return(\"0\"+a.getSeconds()).slice(-2)},s:function(a){return\"\"+a.getSeconds()},SSS:function(a){return(\"00\"+a.getMilliseconds()).slice(-3)},\nSS:function(a){return(\"0\"+(a.getMilliseconds()/10|0)).slice(-2)},S:function(a){return\"\"+(a.getMilliseconds()/100|0)},dddd:function(a){return this.res.dddd[a.getDay()]},ddd:function(a){return this.res.ddd[a.getDay()]},dd:function(a){return this.res.dd[a.getDay()]},Z:function(a){a=a.getTimezoneOffset()/.6|0;return(0<a?\"-\":\"+\")+(\"000\"+Math.abs(a-(a%100*.4|0))).slice(-4)},ZZ:function(a){a=a.getTimezoneOffset();var b=Math.abs(a);return(0<a?\"-\":\"+\")+(\"0\"+(b/60|0)).slice(-2)+\":\"+(\"0\"+b%60).slice(-2)},post:function(a){return a},\nres:t},x={YYYY:function(a){return this.exec(/^\\d{4}/,a)},Y:function(a){return this.exec(/^\\d{1,4}/,a)},MMMM:function(a){a=this.find(this.res.MMMM,a);a.value++;return a},MMM:function(a){a=this.find(this.res.MMM,a);a.value++;return a},MM:function(a){return this.exec(/^\\d\\d/,a)},M:function(a){return this.exec(/^\\d\\d?/,a)},DD:function(a){return this.exec(/^\\d\\d/,a)},D:function(a){return this.exec(/^\\d\\d?/,a)},HH:function(a){return this.exec(/^\\d\\d/,a)},H:function(a){return this.exec(/^\\d\\d?/,a)},A:function(a){return this.find(this.res.A,\na)},hh:function(a){return this.exec(/^\\d\\d/,a)},h:function(a){return this.exec(/^\\d\\d?/,a)},mm:function(a){return this.exec(/^\\d\\d/,a)},m:function(a){return this.exec(/^\\d\\d?/,a)},ss:function(a){return this.exec(/^\\d\\d/,a)},s:function(a){return this.exec(/^\\d\\d?/,a)},SSS:function(a){return this.exec(/^\\d{1,3}/,a)},SS:function(a){a=this.exec(/^\\d\\d?/,a);a.value*=10;return a},S:function(a){a=this.exec(/^\\d/,a);a.value*=100;return a},Z:function(a){a=this.exec(/^[\\+-]\\d{2}[0-5]\\d/,a);a.value=-60*(a.value/\n100|0)-a.value%100;return a},ZZ:function(a){a=/^([\\+-])(\\d{2}):([0-5]\\d)/.exec(a)||[\"\",\"\",\"\",\"\"];return{value:-(60*(a[1]+a[2]|0)+(a[1]+a[3]|0)),length:a[0].length}},h12:function(a,b){return(12===a?0:a)+12*b},exec:function(a,b){a=(a.exec(b)||[\"\"])[0];return{value:a|0,length:a.length}},find:function(a,b){for(var d=-1,c=0,h=0,g=a.length,l;h<g;h++)l=a[h],!b.indexOf(l)&&l.length>c&&(d=h,c=l.length);return{value:d,length:c}},pre:function(a){return a},res:t},n=function(a,b,d,c){var h={},g;for(g in a)h[g]=\na[g];for(g in b||{})!!d^!!h[g]||(h[g]=b[g]);c&&(h.res=c);return h},f={_formatter:w,_parser:x};f.compile=function(a){for(var b=/\\[([^\\[\\]]|\\[[^\\[\\]]*])*]|([A-Za-z])\\2+|\\.{3}|./g,d,c=[a];d=b.exec(a);)c[c.length]=d[0];return c};f.format=function(a,b,d){var c=this||e;b=\"string\"===typeof b?c.compile(b):b;var h=a.getTimezoneOffset();a=c.addMinutes(a,d?h:0);c=c._formatter;var g=\"\";a.getTimezoneOffset=function(){return d?0:h};for(var l=1,u=b.length,k;l<u;l++)k=b[l],g+=c[k]?c.post(c[k](a,b[0])):k.replace(/\\[(.*)]/,\n\"$1\");return g};f.preparse=function(a,b){var d=this||e;b=\"string\"===typeof b?d.compile(b):b;var c={Y:1970,M:1,D:1,H:0,A:0,h:0,m:0,s:0,S:0,Z:0,_index:0,_length:0,_match:0},h=/\\[(.*)]/;d=d._parser;var g=0;a=d.pre(a);for(var l=1,u=b.length,k,q;l<u;l++)if(k=b[l],d[k]){q=d[k](a.slice(g),b[0]);if(!q.length)break;g+=q.length;c[q.token||k.charAt(0)]=q.value;c._match++}else if(k===a.charAt(g)||\" \"===k)g++;else if(h.test(k)&&!a.slice(g).indexOf(h.exec(k)[1]))g+=k.length-2;else{\"...\"===k&&(g=a.length);break}c.H=\nc.H||d.h12(c.h,c.A);c._index=g;c._length=a.length;return c};f.parse=function(a,b,d){var c=this||e;b=\"string\"===typeof b?c.compile(b):b;a=c.preparse(a,b);return c.isValid(a)?(a.M-=100>a.Y?22801:1,d||~c._parser.find(b,\"ZZ\").value?new Date(Date.UTC(a.Y,a.M,a.D,a.H,a.m+a.Z,a.s,a.S)):new Date(a.Y,a.M,a.D,a.H,a.m,a.s,a.S)):new Date(NaN)};f.isValid=function(a,b){var d=this||e;a=\"string\"===typeof a?d.preparse(a,b):a;d=[31,28+d.isLeapYear(a.Y)|0,31,30,31,30,31,31,30,31,30,31][a.M-1];return!(1>a._index||1>\na._length||a._index-a._length||1>a._match||1>a.Y||9999<a.Y||1>a.M||12<a.M||1>a.D||a.D>d||0>a.H||23<a.H||0>a.m||59<a.m||0>a.s||59<a.s||0>a.S||999<a.S||-840>a.Z||720<a.Z)};f.transform=function(a,b,d,c){let h=this||e;return h.format(h.parse(a,b),d,c)};f.addYears=function(a,b){return(this||e).addMonths(a,12*b)};f.addMonths=function(a,b){a=new Date(a.getTime());a.setMonth(a.getMonth()+b);return a};f.addDays=function(a,b){a=new Date(a.getTime());a.setDate(a.getDate()+b);return a};f.addHours=function(a,\nb){return(this||e).addMinutes(a,60*b)};f.addMinutes=function(a,b){return(this||e).addSeconds(a,60*b)};f.addSeconds=function(a,b){return(this||e).addMilliseconds(a,1E3*b)};f.addMilliseconds=function(a,b){return new Date(a.getTime()+b)};f.subtract=function(a,b){var d=a.getTime()-b.getTime();return{toMilliseconds:function(){return d},toSeconds:function(){return d/1E3},toMinutes:function(){return d/6E4},toHours:function(){return d/36E5},toDays:function(){return d/864E5}}};f.isLeapYear=function(a){return!(a%\n4)&&!!(a%100)||!(a%400)};f.isSameDay=function(a,b){return a.toDateString()===b.toDateString()};f.locale=function(a,b){p[a]||(p[a]=b)};f.plugin=function(a,b){m[a]||(m[a]=b)};var v=n(f);var e=n(f);e.locale=function(a){a=\"function\"===typeof a?a:e.locale[a];if(!a)return r;r=a(f);var b=p[r]||{},d=n(t,b.res,!0);a=n(w,b.formatter,!0,d);b=n(x,b.parser,!0,d);e._formatter=v._formatter=a;e._parser=v._parser=b;for(var c in m)e.extend(m[c]);return r};e.extend=function(a){var b=n(e._parser.res,a.res),d=a.extender||\n{};e._formatter=n(e._formatter,a.formatter,!1,b);e._parser=n(e._parser,a.parser,!1,b);for(var c in d)e[c]||(e[c]=d[c])};e.plugin=function(a){(a=\"function\"===typeof a?a:e.plugin[a])&&e.extend(m[a(f,v)]||{})};return e})"
  },
  {
    "path": "server/libs/expressFileupload/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015 Richard Girges\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.\n"
  },
  {
    "path": "server/libs/expressFileupload/fileFactory.js",
    "content": "'use strict';\n\nconst {\n  isFunc,\n  debugLog,\n  moveFile,\n  promiseCallback,\n  checkAndMakeDir,\n  saveBufferToFile\n} = require('./utilities');\n\n/**\n * Returns Local function that moves the file to a different location on the filesystem\n * which takes two function arguments to make it compatible w/ Promise or Callback APIs\n * @param {String} filePath - destination file path.\n * @param {Object} options - file factory options.\n * @param {Object} fileUploadOptions - middleware options.\n * @returns {Function}\n */\nconst moveFromTemp = (filePath, options, fileUploadOptions) => (resolve, reject) => {\n  debugLog(fileUploadOptions, `Moving temporary file ${options.tempFilePath} to ${filePath}`);\n  moveFile(options.tempFilePath, filePath, promiseCallback(resolve, reject));\n};\n\n/**\n * Returns Local function that moves the file from buffer to a different location on the filesystem\n * which takes two function arguments to make it compatible w/ Promise or Callback APIs\n * @param {String} filePath - destination file path.\n * @param {Object} options - file factory options.\n * @param {Object} fileUploadOptions - middleware options.\n * @returns {Function}\n */\nconst moveFromBuffer = (filePath, options, fileUploadOptions) => (resolve, reject) => {\n  debugLog(fileUploadOptions, `Moving uploaded buffer to ${filePath}`);\n  saveBufferToFile(options.buffer, filePath, promiseCallback(resolve, reject));\n};\n\nmodule.exports = (options, fileUploadOptions = {}) => {\n  // see: https://github.com/richardgirges/express-fileupload/issues/14\n  // firefox uploads empty file in case of cache miss when f5ing page.\n  // resulting in unexpected behavior. if there is no file data, the file is invalid.\n  // if (!fileUploadOptions.useTempFiles && !options.buffer.length) return;\n  \n  // Create and return file object.\n  return {\n    name: options.name,\n    data: options.buffer,\n    size: options.size,\n    encoding: options.encoding,\n    tempFilePath: options.tempFilePath,\n    truncated: options.truncated,\n    mimetype: options.mimetype,\n    md5: options.hash,\n    mv: (filePath, callback) => {\n      // Define a propper move function.\n      const moveFunc = fileUploadOptions.useTempFiles\n        ? moveFromTemp(filePath, options, fileUploadOptions)\n        : moveFromBuffer(filePath, options, fileUploadOptions);\n      // Create a folder for a file.\n      checkAndMakeDir(fileUploadOptions, filePath);\n      // If callback is passed in, use the callback API, otherwise return a promise.\n      return isFunc(callback) ? moveFunc(callback) : new Promise(moveFunc);\n    }\n  };\n};\n"
  },
  {
    "path": "server/libs/expressFileupload/index.js",
    "content": "'use strict';\n\nconst path = require('path');\nconst processMultipart = require('./processMultipart');\nconst isEligibleRequest = require('./isEligibleRequest');\nconst { buildOptions, debugLog } = require('./utilities');\nconst busboy = require('../busboy'); // eslint-disable-line no-unused-vars\n\nconst DEFAULT_OPTIONS = {\n  debug: false,\n  uploadTimeout: 60000,\n  fileHandler: false,\n  uriDecodeFileNames: false,\n  safeFileNames: false,\n  preserveExtension: false,\n  abortOnLimit: false,\n  responseOnLimit: 'File size limit has been reached',\n  limitHandler: false,\n  createParentPath: false,\n  parseNested: false,\n  useTempFiles: false,\n  tempFileDir: path.join(process.cwd(), 'tmp')\n};\n\n/**\n * Expose the file upload middleware\n * @param {DEFAULT_OPTIONS & busboy.BusboyConfig} options - Middleware options.\n * @returns {Function} - express-fileupload middleware.\n */\nmodule.exports = (options) => {\n  const uploadOptions = buildOptions(DEFAULT_OPTIONS, options);\n  return (req, res, next) => {\n    if (!isEligibleRequest(req)) {\n      debugLog(uploadOptions, 'Request is not eligible for file upload!');\n      return next();\n    }\n    processMultipart(uploadOptions, req, res, next);\n  };\n};\n"
  },
  {
    "path": "server/libs/expressFileupload/isEligibleRequest.js",
    "content": "const ACCEPTABLE_CONTENT_TYPE = /^(multipart\\/.+);(.*)$/i;\nconst UNACCEPTABLE_METHODS = ['GET', 'HEAD'];\n\n/**\n * Ensures the request contains a content body\n * @param  {Object}  req Express req object\n * @returns {Boolean}\n */\nconst hasBody = (req) => {\n  return ('transfer-encoding' in req.headers) ||\n    ('content-length' in req.headers && req.headers['content-length'] !== '0');\n};\n\n/**\n * Ensures the request is not using a non-compliant multipart method\n * such as GET or HEAD\n * @param  {Object}  req Express req object\n * @returns {Boolean}\n */\nconst hasAcceptableMethod = req => !UNACCEPTABLE_METHODS.includes(req.method);\n\n/**\n * Ensures that only multipart requests are processed by express-fileupload\n * @param  {Object}  req Express req object\n * @returns {Boolean}\n */\nconst hasAcceptableContentType = req => ACCEPTABLE_CONTENT_TYPE.test(req.headers['content-type']);\n\n/**\n * Ensures that the request in question is eligible for file uploads\n * @param {Object} req Express req object\n * @returns {Boolean}\n */\nmodule.exports = req => hasBody(req) && hasAcceptableMethod(req) && hasAcceptableContentType(req);\n"
  },
  {
    "path": "server/libs/expressFileupload/memHandler.js",
    "content": "const crypto = require('crypto');\nconst { debugLog } = require('./utilities');\n\n/**\n * memHandler - In memory upload handler\n * @param {Object} options\n * @param {String} fieldname\n * @param {String} filename\n * @returns {Object}\n */\nmodule.exports = (options, fieldname, filename) => {\n  const buffers = [];\n  const hash = crypto.createHash('md5');\n  let fileSize = 0;\n  let completed = false;\n\n  const getBuffer = () => Buffer.concat(buffers, fileSize);\n\n  return {\n    dataHandler: (data) => {\n      if (completed === true) {\n        debugLog(options, `Error: got ${fieldname}->${filename} data chunk for completed upload!`);\n        return;\n      }\n      buffers.push(data);\n      hash.update(data);\n      fileSize += data.length;\n      debugLog(options, `Uploading ${fieldname}->${filename}, bytes:${fileSize}...`);\n    },\n    getBuffer: getBuffer,\n    getFilePath: () => '',\n    getFileSize: () => fileSize,\n    getHash: () => hash.digest('hex'),\n    complete: () => {\n      debugLog(options, `Upload ${fieldname}->${filename} completed, bytes:${fileSize}.`);\n      completed = true;\n      return getBuffer();\n    },\n    cleanup: () => { completed = true; },\n    getWritePromise: () => Promise.resolve()\n  };\n};\n"
  },
  {
    "path": "server/libs/expressFileupload/processMultipart.js",
    "content": "const Busboy = require('../busboy');\nconst UploadTimer = require('./uploadtimer');\nconst fileFactory = require('./fileFactory');\nconst memHandler = require('./memHandler');\nconst tempFileHandler = require('./tempFileHandler');\nconst processNested = require('./processNested');\nconst {\n  isFunc,\n  debugLog,\n  buildFields,\n  buildOptions,\n  parseFileName\n} = require('./utilities');\n\nconst waitFlushProperty = Symbol('wait flush property symbol');\n\n/**\n * Processes multipart request\n * Builds a req.body object for fields\n * Builds a req.files object for files\n * @param  {Object}   options expressFileupload and Busboy options\n * @param  {Object}   req     Express request object\n * @param  {Object}   res     Express response object\n * @param  {Function} next    Express next method\n * @return {void}\n */\nmodule.exports = (options, req, res, next) => {\n  req.files = null;\n\n  // Build busboy options and init busboy instance.\n  const busboyOptions = buildOptions(options, { headers: req.headers });\n  const busboy = Busboy(busboyOptions);\n\n  // Close connection with specified reason and http code, default: 400 Bad Request.\n  const closeConnection = (code, reason) => {\n    req.unpipe(busboy);\n    res.writeHead(code || 400, { Connection: 'close' });\n    res.end(reason || 'Bad Request');\n  };\n\n  // Express proxies sometimes attach multipart data to a buffer\n  if (req.body instanceof Buffer) {\n    req.body = Object.create(null);\n  }\n  // Build multipart req.body fields\n  busboy.on('field', (field, val) => req.body = buildFields(req.body, field, val));\n\n  // Build req.files fields\n  busboy.on('file', (field, file, info) => {\n    // Parse file name(cutting huge names, decoding, etc..).\n    const { filename: name, encoding, mimeType: mime } = info;\n    const filename = parseFileName(options, name);\n    // Define methods and handlers for upload process.\n    const {\n      dataHandler,\n      getFilePath,\n      getFileSize,\n      getHash,\n      complete,\n      cleanup,\n      getWritePromise\n    } = options.useTempFiles\n        ? tempFileHandler(options, field, filename) // Upload into temporary file.\n        : memHandler(options, field, filename);     // Upload into RAM.\n\n    const writePromise = options.useTempFiles\n      ? getWritePromise().catch(err => {\n        req.unpipe(busboy);\n        req.resume();\n        cleanup();\n        next(err);\n      }) : getWritePromise();\n\n    // Define upload timer.\n    const uploadTimer = new UploadTimer(options.uploadTimeout, () => {\n      file.removeAllListeners('data');\n      file.resume();\n      // After destroy an error event will be emitted and file clean up will be done.\n      file.destroy(new Error(`Upload timeout ${field}->${filename}, bytes:${getFileSize()}`));\n    });\n\n    file.on('limit', () => {\n      debugLog(options, `Size limit reached for ${field}->${filename}, bytes:${getFileSize()}`);\n      // Reset upload timer in case of file limit reached.\n      uploadTimer.clear();\n      // Run a user defined limit handler if it has been set.\n      if (isFunc(options.limitHandler)) return options.limitHandler(req, res, next);\n      // Close connection with 413 code and do cleanup if abortOnLimit set(default: false).\n      if (options.abortOnLimit) {\n        debugLog(options, `Aborting upload because of size limit ${field}->${filename}.`);\n        !isFunc(options.limitHandler) ? closeConnection(413, options.responseOnLimit) : '';\n        cleanup();\n      }\n    });\n\n    file.on('data', (data) => {\n      uploadTimer.set(); // Refresh upload timer each time new data chunk came.\n      dataHandler(data); // Handle new piece of data.\n    });\n\n    file.on('end', () => {\n      const size = getFileSize();\n      // Debug logging for file upload ending.\n      debugLog(options, `Upload finished ${field}->${filename}, bytes:${size}`);\n      // Reset upload timer in case of end event.\n      uploadTimer.clear();\n      // See https://github.com/richardgirges/express-fileupload/issues/191\n      // Do not add file instance to the req.files if original name and size are empty.\n      // Empty name and zero size indicates empty file field in the posted form.\n      if (!name && size === 0) {\n        if (options.useTempFiles) {\n          cleanup();\n          debugLog(options, `Removing the empty file ${field}->${filename}`);\n        }\n        return debugLog(options, `Don't add file instance if original name and size are empty`);\n      }\n      req.files = buildFields(req.files, field, fileFactory({\n        buffer: complete(),\n        name: filename,\n        tempFilePath: getFilePath(),\n        hash: getHash(),\n        size,\n        encoding,\n        truncated: file.truncated,\n        mimetype: mime\n      }, options));\n\n      if (!req[waitFlushProperty]) {\n        req[waitFlushProperty] = [];\n      }\n      req[waitFlushProperty].push(writePromise);\n    });\n\n    file.on('error', (err) => {\n      uploadTimer.clear(); // Reset upload timer in case of errors.\n      debugLog(options, err);\n      cleanup();\n      next();\n    });\n\n    // Debug logging for a new file upload.\n    debugLog(options, `New upload started ${field}->${filename}, bytes:${getFileSize()}`);\n    // Set new upload timeout for a new file.\n    uploadTimer.set();\n  });\n\n  busboy.on('finish', () => {\n    debugLog(options, `Busboy finished parsing request.`);\n    if (options.parseNested) {\n      req.body = processNested(req.body);\n      req.files = processNested(req.files);\n    }\n\n    if (!req[waitFlushProperty]) return next();\n    Promise.all(req[waitFlushProperty])\n      .then(() => {\n        delete req[waitFlushProperty];\n        next();\n      });\n  });\n\n  busboy.on('error', (err) => {\n    debugLog(options, `Busboy error`);\n    next(err);\n  });\n\n  req.pipe(busboy);\n};\n"
  },
  {
    "path": "server/libs/expressFileupload/processNested.js",
    "content": "const { isSafeFromPollution } = require(\"./utilities\");\n\nmodule.exports = function(data){\n  if (!data || data.length < 1) return Object.create(null);\n\n  let d = Object.create(null),\n    keys = Object.keys(data);\n\n  for (let i = 0; i < keys.length; i++) {\n    let key = keys[i],\n      value = data[key],\n      current = d,\n      keyParts = key\n        .replace(new RegExp(/\\[/g), '.')\n        .replace(new RegExp(/\\]/g), '')\n        .split('.');\n\n    for (let index = 0; index < keyParts.length; index++){\n      let k = keyParts[index];\n\n      // Ensure we don't allow prototype pollution\n      if (!isSafeFromPollution(current, k)) {\n        continue;\n      }\n\n      if (index >= keyParts.length - 1){\n        current[k] = value;\n      } else {\n        if (!current[k]) current[k] = !isNaN(keyParts[index + 1]) ? [] : Object.create(null);\n        current = current[k];\n      }\n    }\n  }\n  return d;\n};\n"
  },
  {
    "path": "server/libs/expressFileupload/tempFileHandler.js",
    "content": "const fs = require('fs');\nconst path = require('path');\nconst crypto = require('crypto');\nconst {\n  debugLog,\n  checkAndMakeDir,\n  getTempFilename,\n  deleteFile\n} = require('./utilities');\n\nmodule.exports = (options, fieldname, filename) => {\n  const dir = path.normalize(options.tempFileDir);\n  const tempFilePath = path.join(dir, getTempFilename());\n  checkAndMakeDir({ createParentPath: true }, tempFilePath);\n\n  debugLog(options, `Temporary file path is ${tempFilePath}`);\n \n  const hash = crypto.createHash('md5');\n  let fileSize = 0;\n  let completed = false;\n\n  debugLog(options, `Opening write stream for ${fieldname}->${filename}...`);\n  const writeStream = fs.createWriteStream(tempFilePath);\n  const writePromise = new Promise((resolve, reject) => {\n    writeStream.on('finish', () => resolve());\n    writeStream.on('error', (err) => {\n      debugLog(options, `Error write temp file: ${err}`);\n      reject(err);\n    });\n  });\n\n  return {\n    dataHandler: (data) => {\n      if (completed === true) {\n        debugLog(options, `Error: got ${fieldname}->${filename} data chunk for completed upload!`);\n        return;\n      }\n      writeStream.write(data);\n      hash.update(data);\n      fileSize += data.length;\n      debugLog(options, `Uploading ${fieldname}->${filename}, bytes:${fileSize}...`);\n    },\n    getFilePath: () => tempFilePath,\n    getFileSize: () => fileSize,\n    getHash: () => hash.digest('hex'),\n    complete: () => {\n      completed = true;\n      debugLog(options, `Upload ${fieldname}->${filename} completed, bytes:${fileSize}.`);\n      if (writeStream !== false) writeStream.end();\n      // Return empty buff since data was uploaded into a temp file.\n      return Buffer.concat([]);\n    },\n    cleanup: () => {\n      completed = true;\n      debugLog(options, `Cleaning up temporary file ${tempFilePath}...`);\n      writeStream.end();\n      deleteFile(tempFilePath, err => (err \n        ? debugLog(options, `Cleaning up temporary file ${tempFilePath} failed: ${err}`)\n        : debugLog(options, `Cleaning up temporary file ${tempFilePath} done.`)\n      ));\n    },\n    getWritePromise: () => writePromise\n  };\n};\n"
  },
  {
    "path": "server/libs/expressFileupload/uploadtimer.js",
    "content": "class UploadTimer {\n  /**\n   * @constructor\n   * @param {number} timeout - timer timeout in msecs. \n   * @param {Function} callback - callback to run when timeout reached.\n   */\n  constructor(timeout = 0, callback = () => {}) {\n    this.timeout = timeout;\n    this.callback = callback;\n    this.timer = null;\n  }\n\n  clear() {\n    clearTimeout(this.timer);\n  }\n\n  set() {\n    // Do not start a timer if zero timeout or it hasn't been set. \n    if (!this.timeout) return false;\n    this.clear();\n    this.timer = setTimeout(this.callback, this.timeout);\n    return true;\n  }\n}\n\nmodule.exports = UploadTimer;\n"
  },
  {
    "path": "server/libs/expressFileupload/utilities.js",
    "content": "'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\nconst { Readable } = require('stream');\n\n// Parameters for safe file name parsing.\nconst SAFE_FILE_NAME_REGEX = /[^\\w-]/g;\nconst MAX_EXTENSION_LENGTH = 3;\n\n// Parameters to generate unique temporary file names:\nconst TEMP_COUNTER_MAX = 65536;\nconst TEMP_PREFIX = 'tmp';\nlet tempCounter = 0;\n\n/**\n * Logs message to console if debug option set to true.\n * @param {Object} options - options object.\n * @param {string} msg - message to log.\n * @returns {boolean} - false if debug is off.\n */\nconst debugLog = (options, msg) => {\n  const opts = options || {};\n  if (!opts.debug) return false;\n  console.log(`Express-file-upload: ${msg}`); // eslint-disable-line\n  return true;\n};\n\n/**\n * Generates unique temporary file name. e.g. tmp-5000-156788789789.\n * @param {string} prefix - a prefix for generated unique file name.\n * @returns {string}\n */\nconst getTempFilename = (prefix = TEMP_PREFIX) => {\n  tempCounter = tempCounter >= TEMP_COUNTER_MAX ? 1 : tempCounter + 1;\n  return `${prefix}-${tempCounter}-${Date.now()}`;\n};\n\n/**\n * isFunc: Checks if argument is a function.\n * @returns {boolean} - Returns true if argument is a function.\n */\nconst isFunc = func => func && func.constructor && func.call && func.apply ? true: false;\n\n/**\n * Set errorFunc to the same value as successFunc for callback mode.\n * @returns {Function}\n */\nconst errorFunc = (resolve, reject) => isFunc(reject) ? reject : resolve;\n\n/**\n * Return a callback function for promise resole/reject args.\n * Ensures that callback is called only once.\n * @returns {Function}\n */\nconst promiseCallback = (resolve, reject) => {\n  let hasFired = false;\n  return (err) => {\n    if (hasFired) {\n      return;\n    }\n\n    hasFired = true;\n    return err ? errorFunc(resolve, reject)(err) : resolve();\n  };\n};\n\n/**\n * Builds instance options from arguments objects(can't be arrow function).\n * @returns {Object} - result options.\n */\nconst buildOptions = function() {\n  const result = {};\n  [...arguments].forEach(options => {\n    if (!options || typeof options !== 'object') return;\n    Object.keys(options).forEach(i => result[i] = options[i]);\n  });\n  return result;\n};\n\n// The default prototypes for both objects and arrays.\n// Used by isSafeFromPollution\nconst OBJECT_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype);\nconst ARRAY_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Array.prototype);\n\n/**\n * Determines whether a key insertion into an object could result in a prototype pollution\n * @param {Object} base - The object whose insertion we are checking\n * @param {string} key - The key that will be inserted\n */\nconst isSafeFromPollution = (base, key) => {\n  // We perform an instanceof check instead of Array.isArray as the former is more\n  // permissive for cases in which the object as an Array prototype but was not constructed\n  // via an Array constructor or literal.\n  const TOUCHES_ARRAY_PROTOTYPE = (base instanceof Array) && ARRAY_PROTOTYPE_KEYS.includes(key);\n  const TOUCHES_OBJECT_PROTOTYPE = OBJECT_PROTOTYPE_KEYS.includes(key);\n\n  return !TOUCHES_ARRAY_PROTOTYPE && !TOUCHES_OBJECT_PROTOTYPE;\n};\n\n/**\n * Builds request fields (using to build req.body and req.files)\n * @param {Object} instance - request object.\n * @param {string} field - field name.\n * @param {any} value - field value.\n * @returns {Object}\n */\nconst buildFields = (instance, field, value) => {\n  // Do nothing if value is not set.\n  if (value === null || value === undefined) return instance;\n  instance = instance || Object.create(null);\n\n  if (!isSafeFromPollution(instance, field)) {\n    return instance;\n  }\n  // Non-array fields\n  if (!instance[field]) {\n    instance[field] = value;\n    return instance;\n  }\n  // Array fields\n  if (instance[field] instanceof Array) {\n    instance[field].push(value);\n  } else {\n    instance[field] = [instance[field], value];\n  }\n  return instance;\n};\n\n/**\n * Creates a folder for file specified in the path variable\n * @param {Object} fileUploadOptions\n * @param {string} filePath\n * @returns {boolean}\n */\nconst checkAndMakeDir = (fileUploadOptions, filePath) => {\n  // Check upload options were set.\n  if (!fileUploadOptions) return false;\n  if (!fileUploadOptions.createParentPath) return false;\n  // Check whether folder for the file exists.\n  if (!filePath) return false;\n  const parentPath = path.dirname(filePath);\n  // Create folder if it doesn't exist.\n  if (!fs.existsSync(parentPath)) fs.mkdirSync(parentPath, { recursive: true });\n  // Checks folder again and return a results.\n  return fs.existsSync(parentPath);\n};\n\n/**\n * Deletes a file.\n * @param {string} file - Path to the file to delete.\n * @param {Function} callback\n */\nconst deleteFile = (file, callback) => fs.unlink(file, callback);\n\n/**\n * Copy file via streams\n * @param {string} src - Path to the source file\n * @param {string} dst - Path to the destination file.\n */\nconst copyFile = (src, dst, callback) => {\n  // cbCalled flag and runCb helps to run cb only once.\n  let cbCalled = false;\n  let runCb = (err) => {\n    if (cbCalled) return;\n    cbCalled = true;\n    callback(err);\n  };\n  // Create read stream\n  let readable = fs.createReadStream(src);\n  readable.on('error', runCb);\n  // Create write stream\n  let writable = fs.createWriteStream(dst);\n  writable.on('error', (err)=>{\n    readable.destroy();\n    runCb(err);\n  });\n  writable.on('close', () => runCb());\n  // Copy file via piping streams.\n  readable.pipe(writable);\n};\n\n/**\n * moveFile: moves the file from src to dst.\n * Firstly trying to rename the file if no luck copying it to dst and then deleteing src.\n * @param {string} src - Path to the source file\n * @param {string} dst - Path to the destination file.\n * @param {Function} callback - A callback function.\n */\nconst moveFile = (src, dst, callback) => fs.rename(src, dst, err => (err\n  ? copyFile(src, dst, err => err ? callback(err) : deleteFile(src, callback))\n  : callback()\n));\n\n/**\n * Save buffer data to a file.\n * @param {Buffer} buffer - buffer to save to a file.\n * @param {string} filePath - path to a file.\n */\nconst saveBufferToFile = (buffer, filePath, callback) => {\n  if (!Buffer.isBuffer(buffer)) {\n    return callback(new Error('buffer variable should be type of Buffer!'));\n  }\n  // Setup readable stream from buffer.\n  let streamData = buffer;\n  let readStream = Readable();\n  readStream._read = () => {\n    readStream.push(streamData);\n    streamData = null;\n  };\n  // Setup file system writable stream.\n  let fstream = fs.createWriteStream(filePath);\n  // console.log(\"Calling saveBuffer\");\n  fstream.on('error', err => {\n    // console.log(\"err cb\")\n    callback(err);\n  });\n  fstream.on('close', () => {\n    // console.log(\"close cb\");\n    callback();\n  });\n  // Copy file via piping streams.\n  readStream.pipe(fstream);\n};\n\n/**\n * Decodes uriEncoded file names.\n * @param fileName {String} - file name to decode.\n * @returns {String}\n */\nconst uriDecodeFileName = (opts, fileName) => {\n  return opts.uriDecodeFileNames ? decodeURIComponent(fileName) : fileName;\n};\n\n/**\n * Parses filename and extension and returns object {name, extension}.\n * @param {boolean|integer} preserveExtension - true/false or number of characters for extension.\n * @param {string} fileName - file name to parse.\n * @returns {Object} - { name, extension }.\n */\nconst parseFileNameExtension = (preserveExtension, fileName) => {\n  const preserveExtensionLength = parseInt(preserveExtension);\n  const result = {name: fileName, extension: ''};\n  if (!preserveExtension && preserveExtensionLength !== 0) return result;\n  // Define maximum extension length\n  const maxExtLength = isNaN(preserveExtensionLength)\n    ? MAX_EXTENSION_LENGTH\n    : Math.abs(preserveExtensionLength);\n\n  const nameParts = fileName.split('.');\n  if (nameParts.length < 2) return result;\n\n  let extension = nameParts.pop();\n  if (\n    extension.length > maxExtLength &&\n    maxExtLength > 0\n  ) {\n    nameParts[nameParts.length - 1] +=\n      '.' +\n      extension.substr(0, extension.length - maxExtLength);\n    extension = extension.substr(-maxExtLength);\n  }\n\n  result.extension = maxExtLength ? extension : '';\n  result.name = nameParts.join('.');\n  return result;\n};\n\n/**\n * Parse file name and extension.\n * @param {Object} opts - middleware options.\n * @param {string} fileName - Uploaded file name.\n * @returns {string}\n */\nconst parseFileName = (opts, fileName) => {\n  // Check fileName argument\n  if (!fileName || typeof fileName !== 'string') return getTempFilename();\n  // Cut off file name if it's lenght more then 255.\n  let parsedName = fileName.length <= 255 ? fileName : fileName.substr(0, 255);\n  // Decode file name if uriDecodeFileNames option set true.\n  parsedName = uriDecodeFileName(opts, parsedName);\n  // Stop parsing file name if safeFileNames options hasn't been set.\n  if (!opts.safeFileNames) return parsedName;\n  // Set regular expression for the file name.\n  const nameRegex = typeof opts.safeFileNames === 'object' && opts.safeFileNames instanceof RegExp\n    ? opts.safeFileNames\n    : SAFE_FILE_NAME_REGEX;\n  // Parse file name extension.\n  let {name, extension} = parseFileNameExtension(opts.preserveExtension, parsedName);\n  if (extension.length) extension = '.' + extension.replace(nameRegex, '');\n\n  return name.replace(nameRegex, '').concat(extension);\n};\n\nmodule.exports = {\n  isFunc,\n  debugLog,\n  copyFile, // For testing purpose.\n  moveFile,\n  errorFunc,\n  deleteFile, // For testing purpose.\n  buildFields,\n  buildOptions,\n  parseFileName,\n  getTempFilename,\n  promiseCallback,\n  checkAndMakeDir,\n  saveBufferToFile,\n  uriDecodeFileName,\n  isSafeFromPollution\n};\n"
  },
  {
    "path": "server/libs/fastSort/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Stefan Novaković\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": "server/libs/fastSort/index.js",
    "content": "// SOURCE: https://github.com/snovakovic/fast-sort\n// LICENSE: https://github.com/snovakovic/fast-sort/blob/master/LICENSE\n\n(function (global, factory) {\n    typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :\n        typeof define === 'function' && define.amd ? define(['exports'], factory) :\n            (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));\n}(this, (function (exports) {\n    'use strict';\n\n    // >>> INTERFACES <<<\n    // >>> HELPERS <<<\n    var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };\n    var throwInvalidConfigErrorIfTrue = function (condition, context) {\n        if (condition)\n            throw Error(\"Invalid sort config: \" + context);\n    };\n    var unpackObjectSorter = function (sortByObj) {\n        var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;\n        var order = asc ? 1 : -1;\n        var sortBy = (asc || desc);\n        // Validate object config\n        throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');\n        throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');\n        var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);\n        return { order: order, sortBy: sortBy, comparer: comparer };\n    };\n    // >>> SORTERS <<<\n    var multiPropertySorterProvider = function (defaultComparer) {\n        return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {\n            var valA;\n            var valB;\n            if (typeof sortBy === 'string') {\n                valA = a[sortBy];\n                valB = b[sortBy];\n            }\n            else if (typeof sortBy === 'function') {\n                valA = sortBy(a);\n                valB = sortBy(b);\n            }\n            else {\n                var objectSorterConfig = unpackObjectSorter(sortBy);\n                return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);\n            }\n            var equality = comparer(valA, valB, order);\n            if ((equality === 0 || (valA == null && valB == null)) &&\n                sortByArr.length > depth) {\n                return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);\n            }\n            return equality;\n        };\n    };\n    function getSortStrategy(sortBy, comparer, order) {\n        // Flat array sorter\n        if (sortBy === undefined || sortBy === true) {\n            return function (a, b) { return comparer(a, b, order); };\n        }\n        // Sort list of objects by single object key\n        if (typeof sortBy === 'string') {\n            throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');\n            return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };\n        }\n        // Sort list of objects by single function sorter\n        if (typeof sortBy === 'function') {\n            return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };\n        }\n        // Sort by multiple properties\n        if (Array.isArray(sortBy)) {\n            var multiPropSorter_1 = multiPropertySorterProvider(comparer);\n            return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };\n        }\n        // Unpack object config to get actual sorter strategy\n        var objectSorterConfig = unpackObjectSorter(sortBy);\n        return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);\n    }\n    var sortArray = function (order, ctx, sortBy, comparer) {\n        var _a;\n        if (!Array.isArray(ctx)) {\n            return ctx;\n        }\n        // Unwrap sortBy if array with only 1 value to get faster sort strategy\n        if (Array.isArray(sortBy) && sortBy.length < 2) {\n            _a = sortBy, sortBy = _a[0];\n        }\n        return ctx.sort(getSortStrategy(sortBy, comparer, order));\n    };\n    function createNewSortInstance(opts) {\n        var comparer = castComparer(opts.comparer);\n        return function (_ctx) {\n            var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting\n                ? _ctx.slice()\n                : _ctx;\n            return {\n                asc: function (sortBy) {\n                    return sortArray(1, ctx, sortBy, comparer);\n                },\n                desc: function (sortBy) {\n                    return sortArray(-1, ctx, sortBy, comparer);\n                },\n                by: function (sortBy) {\n                    return sortArray(1, ctx, sortBy, comparer);\n                },\n            };\n        };\n    }\n    var defaultComparer = function (a, b, order) {\n        if (a == null)\n            return order;\n        if (b == null)\n            return -order;\n        if (a < b)\n            return -1;\n        if (a > b)\n            return 1;\n        return 0;\n    };\n    var sort = createNewSortInstance({\n        comparer: defaultComparer,\n    });\n    var inPlaceSort = createNewSortInstance({\n        comparer: defaultComparer,\n        inPlaceSorting: true,\n    });\n\n    exports.createNewSortInstance = createNewSortInstance;\n    exports.inPlaceSort = inPlaceSort;\n    exports.sort = sort;\n\n    Object.defineProperty(exports, '__esModule', { value: true });\n\n})));\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/LICENSE",
    "content": "(The MIT License)\n\nCopyright (c) 2011-2015 The fluent-ffmpeg contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/fluentFfmpeg/capabilities.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar fs = require('fs');\nvar path = require('path');\nvar async = require('../async');\nvar utils = require('./utils');\n\n/*\n *! Capability helpers\n */\n\nvar avCodecRegexp = /^\\s*([D ])([E ])([VAS])([S ])([D ])([T ]) ([^ ]+) +(.*)$/;\nvar ffCodecRegexp = /^\\s*([D\\.])([E\\.])([VAS])([I\\.])([L\\.])([S\\.]) ([^ ]+) +(.*)$/;\nvar ffEncodersRegexp = /\\(encoders:([^\\)]+)\\)/;\nvar ffDecodersRegexp = /\\(decoders:([^\\)]+)\\)/;\nvar encodersRegexp = /^\\s*([VAS\\.])([F\\.])([S\\.])([X\\.])([B\\.])([D\\.]) ([^ ]+) +(.*)$/;\nvar formatRegexp = /^\\s*([D ])([E ])\\s+([^ ]+)\\s+(.*)$/;\nvar lineBreakRegexp = /\\r\\n|\\r|\\n/;\nvar filterRegexp = /^(?: [T\\.][S\\.][C\\.] )?([^ ]+) +(AA?|VV?|\\|)->(AA?|VV?|\\|) +(.*)$/;\n\nvar cache = {};\n\nmodule.exports = function (proto) {\n  /**\n   * Manually define the ffmpeg binary full path.\n   *\n   * @method FfmpegCommand#setFfmpegPath\n   *\n   * @param {String} ffmpegPath The full path to the ffmpeg binary.\n   * @return FfmpegCommand\n   */\n  proto.setFfmpegPath = function (ffmpegPath) {\n    cache.ffmpegPath = ffmpegPath;\n    return this;\n  };\n\n  /**\n   * Manually define the ffprobe binary full path.\n   *\n   * @method FfmpegCommand#setFfprobePath\n   *\n   * @param {String} ffprobePath The full path to the ffprobe binary.\n   * @return FfmpegCommand\n   */\n  proto.setFfprobePath = function (ffprobePath) {\n    cache.ffprobePath = ffprobePath;\n    return this;\n  };\n\n  /**\n   * Manually define the flvtool2/flvmeta binary full path.\n   *\n   * @method FfmpegCommand#setFlvtoolPath\n   *\n   * @param {String} flvtool The full path to the flvtool2 or flvmeta binary.\n   * @return FfmpegCommand\n   */\n  proto.setFlvtoolPath = function (flvtool) {\n    cache.flvtoolPath = flvtool;\n    return this;\n  };\n\n  /**\n   * Forget executable paths\n   *\n   * (only used for testing purposes)\n   *\n   * @method FfmpegCommand#_forgetPaths\n   * @private\n   */\n  proto._forgetPaths = function () {\n    delete cache.ffmpegPath;\n    delete cache.ffprobePath;\n    delete cache.flvtoolPath;\n  };\n\n  /**\n   * Check for ffmpeg availability\n   *\n   * If the FFMPEG_PATH environment variable is set, try to use it.\n   * If it is unset or incorrect, try to find ffmpeg in the PATH instead.\n   *\n   * @method FfmpegCommand#_getFfmpegPath\n   * @param {Function} callback callback with signature (err, path)\n   * @private\n   */\n  proto._getFfmpegPath = function (callback) {\n    if ('ffmpegPath' in cache) {\n      return callback(null, cache.ffmpegPath);\n    }\n\n    async.waterfall([\n      // Try FFMPEG_PATH\n      function (cb) {\n        if (process.env.FFMPEG_PATH) {\n          fs.exists(process.env.FFMPEG_PATH, function (exists) {\n            if (exists) {\n              cb(null, process.env.FFMPEG_PATH);\n            } else {\n              cb(null, '');\n            }\n          });\n        } else {\n          cb(null, '');\n        }\n      },\n\n      // Search in the PATH\n      function (ffmpeg, cb) {\n        if (ffmpeg.length) {\n          return cb(null, ffmpeg);\n        }\n\n        utils.which('ffmpeg', function (err, ffmpeg) {\n          cb(err, ffmpeg);\n        });\n      }\n    ], function (err, ffmpeg) {\n      if (err) {\n        callback(err);\n      } else {\n        callback(null, cache.ffmpegPath = (ffmpeg || ''));\n      }\n    });\n  };\n\n\n  /**\n   * Check for ffprobe availability\n   *\n   * If the FFPROBE_PATH environment variable is set, try to use it.\n   * If it is unset or incorrect, try to find ffprobe in the PATH instead.\n   * If this still fails, try to find ffprobe in the same directory as ffmpeg.\n   *\n   * @method FfmpegCommand#_getFfprobePath\n   * @param {Function} callback callback with signature (err, path)\n   * @private\n   */\n  proto._getFfprobePath = function (callback) {\n    var self = this;\n\n    if ('ffprobePath' in cache) {\n      return callback(null, cache.ffprobePath);\n    }\n\n    async.waterfall([\n      // Try FFPROBE_PATH\n      function (cb) {\n        if (process.env.FFPROBE_PATH) {\n          fs.exists(process.env.FFPROBE_PATH, function (exists) {\n            cb(null, exists ? process.env.FFPROBE_PATH : '');\n          });\n        } else {\n          cb(null, '');\n        }\n      },\n\n      // Search in the PATH\n      function (ffprobe, cb) {\n        if (ffprobe.length) {\n          return cb(null, ffprobe);\n        }\n\n        utils.which('ffprobe', function (err, ffprobe) {\n          cb(err, ffprobe);\n        });\n      },\n\n      // Search in the same directory as ffmpeg\n      function (ffprobe, cb) {\n        if (ffprobe.length) {\n          return cb(null, ffprobe);\n        }\n\n        self._getFfmpegPath(function (err, ffmpeg) {\n          if (err) {\n            cb(err);\n          } else if (ffmpeg.length) {\n            var name = utils.isWindows ? 'ffprobe.exe' : 'ffprobe';\n            var ffprobe = path.join(path.dirname(ffmpeg), name);\n            fs.exists(ffprobe, function (exists) {\n              cb(null, exists ? ffprobe : '');\n            });\n          } else {\n            cb(null, '');\n          }\n        });\n      }\n    ], function (err, ffprobe) {\n      if (err) {\n        callback(err);\n      } else {\n        callback(null, cache.ffprobePath = (ffprobe || ''));\n      }\n    });\n  };\n\n\n  /**\n   * Check for flvtool2/flvmeta availability\n   *\n   * If the FLVTOOL2_PATH or FLVMETA_PATH environment variable are set, try to use them.\n   * If both are either unset or incorrect, try to find flvtool2 or flvmeta in the PATH instead.\n   *\n   * @method FfmpegCommand#_getFlvtoolPath\n   * @param {Function} callback callback with signature (err, path)\n   * @private\n   */\n  proto._getFlvtoolPath = function (callback) {\n    if ('flvtoolPath' in cache) {\n      return callback(null, cache.flvtoolPath);\n    }\n\n    async.waterfall([\n      // Try FLVMETA_PATH\n      function (cb) {\n        if (process.env.FLVMETA_PATH) {\n          fs.exists(process.env.FLVMETA_PATH, function (exists) {\n            cb(null, exists ? process.env.FLVMETA_PATH : '');\n          });\n        } else {\n          cb(null, '');\n        }\n      },\n\n      // Try FLVTOOL2_PATH\n      function (flvtool, cb) {\n        if (flvtool.length) {\n          return cb(null, flvtool);\n        }\n\n        if (process.env.FLVTOOL2_PATH) {\n          fs.exists(process.env.FLVTOOL2_PATH, function (exists) {\n            cb(null, exists ? process.env.FLVTOOL2_PATH : '');\n          });\n        } else {\n          cb(null, '');\n        }\n      },\n\n      // Search for flvmeta in the PATH\n      function (flvtool, cb) {\n        if (flvtool.length) {\n          return cb(null, flvtool);\n        }\n\n        utils.which('flvmeta', function (err, flvmeta) {\n          cb(err, flvmeta);\n        });\n      },\n\n      // Search for flvtool2 in the PATH\n      function (flvtool, cb) {\n        if (flvtool.length) {\n          return cb(null, flvtool);\n        }\n\n        utils.which('flvtool2', function (err, flvtool2) {\n          cb(err, flvtool2);\n        });\n      },\n    ], function (err, flvtool) {\n      if (err) {\n        callback(err);\n      } else {\n        callback(null, cache.flvtoolPath = (flvtool || ''));\n      }\n    });\n  };\n\n\n  /**\n   * A callback passed to {@link FfmpegCommand#availableFilters}.\n   *\n   * @callback FfmpegCommand~filterCallback\n   * @param {Error|null} err error object or null if no error happened\n   * @param {Object} filters filter object with filter names as keys and the following\n   *   properties for each filter:\n   * @param {String} filters.description filter description\n   * @param {String} filters.input input type, one of 'audio', 'video' and 'none'\n   * @param {Boolean} filters.multipleInputs whether the filter supports multiple inputs\n   * @param {String} filters.output output type, one of 'audio', 'video' and 'none'\n   * @param {Boolean} filters.multipleOutputs whether the filter supports multiple outputs\n   */\n\n  /**\n   * Query ffmpeg for available filters\n   *\n   * @method FfmpegCommand#availableFilters\n   * @category Capabilities\n   * @aliases getAvailableFilters\n   *\n   * @param {FfmpegCommand~filterCallback} callback callback function\n   */\n  proto.availableFilters =\n    proto.getAvailableFilters = function (callback) {\n      if ('filters' in cache) {\n        return callback(null, cache.filters);\n      }\n\n      this._spawnFfmpeg(['-filters'], { captureStdout: true, stdoutLines: 0 }, function (err, stdoutRing) {\n        if (err) {\n          return callback(err);\n        }\n\n        var stdout = stdoutRing.get();\n        var lines = stdout.split('\\n');\n        var data = {};\n        var types = { A: 'audio', V: 'video', '|': 'none' };\n\n        lines.forEach(function (line) {\n          var match = line.match(filterRegexp);\n          if (match) {\n            data[match[1]] = {\n              description: match[4],\n              input: types[match[2].charAt(0)],\n              multipleInputs: match[2].length > 1,\n              output: types[match[3].charAt(0)],\n              multipleOutputs: match[3].length > 1\n            };\n          }\n        });\n\n        callback(null, cache.filters = data);\n      });\n    };\n\n\n  /**\n   * A callback passed to {@link FfmpegCommand#availableCodecs}.\n   *\n   * @callback FfmpegCommand~codecCallback\n   * @param {Error|null} err error object or null if no error happened\n   * @param {Object} codecs codec object with codec names as keys and the following\n   *   properties for each codec (more properties may be available depending on the\n   *   ffmpeg version used):\n   * @param {String} codecs.description codec description\n   * @param {Boolean} codecs.canDecode whether the codec is able to decode streams\n   * @param {Boolean} codecs.canEncode whether the codec is able to encode streams\n   */\n\n  /**\n   * Query ffmpeg for available codecs\n   *\n   * @method FfmpegCommand#availableCodecs\n   * @category Capabilities\n   * @aliases getAvailableCodecs\n   *\n   * @param {FfmpegCommand~codecCallback} callback callback function\n   */\n  proto.availableCodecs =\n    proto.getAvailableCodecs = function (callback) {\n      if ('codecs' in cache) {\n        return callback(null, cache.codecs);\n      }\n\n      this._spawnFfmpeg(['-codecs'], { captureStdout: true, stdoutLines: 0 }, function (err, stdoutRing) {\n        if (err) {\n          return callback(err);\n        }\n\n        var stdout = stdoutRing.get();\n        var lines = stdout.split(lineBreakRegexp);\n        var data = {};\n\n        lines.forEach(function (line) {\n          var match = line.match(avCodecRegexp);\n          if (match && match[7] !== '=') {\n            data[match[7]] = {\n              type: { 'V': 'video', 'A': 'audio', 'S': 'subtitle' }[match[3]],\n              description: match[8],\n              canDecode: match[1] === 'D',\n              canEncode: match[2] === 'E',\n              drawHorizBand: match[4] === 'S',\n              directRendering: match[5] === 'D',\n              weirdFrameTruncation: match[6] === 'T'\n            };\n          }\n\n          match = line.match(ffCodecRegexp);\n          if (match && match[7] !== '=') {\n            var codecData = data[match[7]] = {\n              type: { 'V': 'video', 'A': 'audio', 'S': 'subtitle' }[match[3]],\n              description: match[8],\n              canDecode: match[1] === 'D',\n              canEncode: match[2] === 'E',\n              intraFrameOnly: match[4] === 'I',\n              isLossy: match[5] === 'L',\n              isLossless: match[6] === 'S'\n            };\n\n            var encoders = codecData.description.match(ffEncodersRegexp);\n            encoders = encoders ? encoders[1].trim().split(' ') : [];\n\n            var decoders = codecData.description.match(ffDecodersRegexp);\n            decoders = decoders ? decoders[1].trim().split(' ') : [];\n\n            if (encoders.length || decoders.length) {\n              var coderData = {};\n              utils.copy(codecData, coderData);\n              delete coderData.canEncode;\n              delete coderData.canDecode;\n\n              encoders.forEach(function (name) {\n                data[name] = {};\n                utils.copy(coderData, data[name]);\n                data[name].canEncode = true;\n              });\n\n              decoders.forEach(function (name) {\n                if (name in data) {\n                  data[name].canDecode = true;\n                } else {\n                  data[name] = {};\n                  utils.copy(coderData, data[name]);\n                  data[name].canDecode = true;\n                }\n              });\n            }\n          }\n        });\n\n        callback(null, cache.codecs = data);\n      });\n    };\n\n\n  /**\n   * A callback passed to {@link FfmpegCommand#availableEncoders}.\n   *\n   * @callback FfmpegCommand~encodersCallback\n   * @param {Error|null} err error object or null if no error happened\n   * @param {Object} encoders encoders object with encoder names as keys and the following\n   *   properties for each encoder:\n   * @param {String} encoders.description codec description\n   * @param {Boolean} encoders.type \"audio\", \"video\" or \"subtitle\"\n   * @param {Boolean} encoders.frameMT whether the encoder is able to do frame-level multithreading\n   * @param {Boolean} encoders.sliceMT whether the encoder is able to do slice-level multithreading\n   * @param {Boolean} encoders.experimental whether the encoder is experimental\n   * @param {Boolean} encoders.drawHorizBand whether the encoder supports draw_horiz_band\n   * @param {Boolean} encoders.directRendering whether the encoder supports direct encoding method 1\n   */\n\n  /**\n   * Query ffmpeg for available encoders\n   *\n   * @method FfmpegCommand#availableEncoders\n   * @category Capabilities\n   * @aliases getAvailableEncoders\n   *\n   * @param {FfmpegCommand~encodersCallback} callback callback function\n   */\n  proto.availableEncoders =\n    proto.getAvailableEncoders = function (callback) {\n      if ('encoders' in cache) {\n        return callback(null, cache.encoders);\n      }\n\n      this._spawnFfmpeg(['-encoders'], { captureStdout: true, stdoutLines: 0 }, function (err, stdoutRing) {\n        if (err) {\n          return callback(err);\n        }\n\n        var stdout = stdoutRing.get();\n        var lines = stdout.split(lineBreakRegexp);\n        var data = {};\n\n        lines.forEach(function (line) {\n          var match = line.match(encodersRegexp);\n          if (match && match[7] !== '=') {\n            data[match[7]] = {\n              type: { 'V': 'video', 'A': 'audio', 'S': 'subtitle' }[match[1]],\n              description: match[8],\n              frameMT: match[2] === 'F',\n              sliceMT: match[3] === 'S',\n              experimental: match[4] === 'X',\n              drawHorizBand: match[5] === 'B',\n              directRendering: match[6] === 'D'\n            };\n          }\n        });\n\n        callback(null, cache.encoders = data);\n      });\n    };\n\n\n  /**\n   * A callback passed to {@link FfmpegCommand#availableFormats}.\n   *\n   * @callback FfmpegCommand~formatCallback\n   * @param {Error|null} err error object or null if no error happened\n   * @param {Object} formats format object with format names as keys and the following\n   *   properties for each format:\n   * @param {String} formats.description format description\n   * @param {Boolean} formats.canDemux whether the format is able to demux streams from an input file\n   * @param {Boolean} formats.canMux whether the format is able to mux streams into an output file\n   */\n\n  /**\n   * Query ffmpeg for available formats\n   *\n   * @method FfmpegCommand#availableFormats\n   * @category Capabilities\n   * @aliases getAvailableFormats\n   *\n   * @param {FfmpegCommand~formatCallback} callback callback function\n   */\n  proto.availableFormats =\n    proto.getAvailableFormats = function (callback) {\n      if ('formats' in cache) {\n        return callback(null, cache.formats);\n      }\n\n      // Run ffmpeg -formats\n      this._spawnFfmpeg(['-formats'], { captureStdout: true, stdoutLines: 0 }, function (err, stdoutRing) {\n        if (err) {\n          return callback(err);\n        }\n\n        // Parse output\n        var stdout = stdoutRing.get();\n        var lines = stdout.split(lineBreakRegexp);\n        var data = {};\n\n        lines.forEach(function (line) {\n          var match = line.match(formatRegexp);\n          if (match) {\n            match[3].split(',').forEach(function (format) {\n              if (!(format in data)) {\n                data[format] = {\n                  description: match[4],\n                  canDemux: false,\n                  canMux: false\n                };\n              }\n\n              if (match[1] === 'D') {\n                data[format].canDemux = true;\n              }\n              if (match[2] === 'E') {\n                data[format].canMux = true;\n              }\n            });\n          }\n        });\n\n        callback(null, cache.formats = data);\n      });\n    };\n\n\n  /**\n   * Check capabilities before executing a command\n   *\n   * Checks whether all used codecs and formats are indeed available\n   *\n   * @method FfmpegCommand#_checkCapabilities\n   * @param {Function} callback callback with signature (err)\n   * @private\n   */\n  proto._checkCapabilities = function (callback) {\n    var self = this;\n    async.waterfall([\n      // Get available formats\n      function (cb) {\n        self.availableFormats(cb);\n      },\n\n      // Check whether specified formats are available\n      function (formats, cb) {\n        var unavailable;\n\n        // Output format(s)\n        unavailable = self._outputs\n          .reduce(function (fmts, output) {\n            var format = output.options.find('-f', 1);\n            if (format) {\n              if (!(format[0] in formats) || !(formats[format[0]].canMux)) {\n                fmts.push(format);\n              }\n            }\n\n            return fmts;\n          }, []);\n\n        if (unavailable.length === 1) {\n          return cb(new Error('Output format ' + unavailable[0] + ' is not available'));\n        } else if (unavailable.length > 1) {\n          return cb(new Error('Output formats ' + unavailable.join(', ') + ' are not available'));\n        }\n\n        // Input format(s)\n        unavailable = self._inputs\n          .reduce(function (fmts, input) {\n            var format = input.options.find('-f', 1);\n            if (format) {\n              if (!(format[0] in formats) || !(formats[format[0]].canDemux)) {\n                fmts.push(format[0]);\n              }\n            }\n\n            return fmts;\n          }, []);\n\n        if (unavailable.length === 1) {\n          return cb(new Error('Input format ' + unavailable[0] + ' is not available'));\n        } else if (unavailable.length > 1) {\n          return cb(new Error('Input formats ' + unavailable.join(', ') + ' are not available'));\n        }\n\n        cb();\n      },\n\n      // Get available codecs\n      function (cb) {\n        self.availableEncoders(cb);\n      },\n\n      // Check whether specified codecs are available and add strict experimental options if needed\n      function (encoders, cb) {\n        var unavailable;\n\n        // Audio codec(s)\n        unavailable = self._outputs.reduce(function (cdcs, output) {\n          var acodec = output.audio.find('-acodec', 1);\n          if (acodec && acodec[0] !== 'copy') {\n            if (!(acodec[0] in encoders) || encoders[acodec[0]].type !== 'audio') {\n              cdcs.push(acodec[0]);\n            }\n          }\n\n          return cdcs;\n        }, []);\n\n        if (unavailable.length === 1) {\n          return cb(new Error('Audio codec ' + unavailable[0] + ' is not available'));\n        } else if (unavailable.length > 1) {\n          return cb(new Error('Audio codecs ' + unavailable.join(', ') + ' are not available'));\n        }\n\n        // Video codec(s)\n        unavailable = self._outputs.reduce(function (cdcs, output) {\n          var vcodec = output.video.find('-vcodec', 1);\n          if (vcodec && vcodec[0] !== 'copy') {\n            if (!(vcodec[0] in encoders) || encoders[vcodec[0]].type !== 'video') {\n              cdcs.push(vcodec[0]);\n            }\n          }\n\n          return cdcs;\n        }, []);\n\n        if (unavailable.length === 1) {\n          return cb(new Error('Video codec ' + unavailable[0] + ' is not available'));\n        } else if (unavailable.length > 1) {\n          return cb(new Error('Video codecs ' + unavailable.join(', ') + ' are not available'));\n        }\n\n        cb();\n      }\n    ], callback);\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/ffprobe.js",
    "content": "/*jshint node:true, laxcomma:true*/\n'use strict';\n\nvar spawn = require('child_process').spawn;\n\n\nfunction legacyTag(key) { return key.match(/^TAG:/); }\nfunction legacyDisposition(key) { return key.match(/^DISPOSITION:/); }\n\nfunction parseFfprobeOutput(out) {\n  var lines = out.split(/\\r\\n|\\r|\\n/);\n\n  lines = lines.filter(function (line) {\n    return line.length > 0;\n  });\n\n  var data = {\n    streams: [],\n    format: {},\n    chapters: []\n  };\n\n  function parseBlock(name) {\n    var data = {};\n\n    var line = lines.shift();\n    while (typeof line !== 'undefined') {\n      if (line.toLowerCase() == '[/'+name+']') {\n        return data;\n      } else if (line.match(/^\\[/)) {\n        line = lines.shift();\n        continue;\n      }\n\n      var kv = line.match(/^([^=]+)=(.*)$/);\n      if (kv) {\n        if (!(kv[1].match(/^TAG:/)) && kv[2].match(/^[0-9]+(\\.[0-9]+)?$/)) {\n          data[kv[1]] = Number(kv[2]);\n        } else {\n          data[kv[1]] = kv[2];\n        }\n      }\n\n      line = lines.shift();\n    }\n\n    return data;\n  }\n\n  var line = lines.shift();\n  while (typeof line !== 'undefined') {\n    if (line.match(/^\\[stream/i)) {\n      var stream = parseBlock('stream');\n      data.streams.push(stream);\n    } else if (line.match(/^\\[chapter/i)) {\n      var chapter = parseBlock('chapter');\n      data.chapters.push(chapter);\n    } else if (line.toLowerCase() === '[format]') {\n      data.format = parseBlock('format');\n    }\n\n    line = lines.shift();\n  }\n\n  return data;\n}\n\n\n\nmodule.exports = function(proto) {\n  /**\n   * A callback passed to the {@link FfmpegCommand#ffprobe} method.\n   *\n   * @callback FfmpegCommand~ffprobeCallback\n   *\n   * @param {Error|null} err error object or null if no error happened\n   * @param {Object} ffprobeData ffprobe output data; this object\n   *   has the same format as what the following command returns:\n   *\n   *     `ffprobe -print_format json -show_streams -show_format INPUTFILE`\n   * @param {Array} ffprobeData.streams stream information\n   * @param {Object} ffprobeData.format format information\n   */\n\n  /**\n   * Run ffprobe on last specified input\n   *\n   * @method FfmpegCommand#ffprobe\n   * @category Metadata\n   *\n   * @param {?Number} [index] 0-based index of input to probe (defaults to last input)\n   * @param {?String[]} [options] array of output options to return\n   * @param {FfmpegCommand~ffprobeCallback} callback callback function\n   *\n   */\n  proto.ffprobe = function() {\n    var input, index = null, options = [], callback;\n\n    // the last argument should be the callback\n    var callback = arguments[arguments.length - 1];\n\n    var ended = false\n    function handleCallback(err, data) {\n      if (!ended) {\n        ended = true;\n        callback(err, data);\n      }\n    };\n\n    // map the arguments to the correct variable names\n    switch (arguments.length) {\n      case 3:\n        index = arguments[0];\n        options = arguments[1];\n        break;\n      case 2:\n        if (typeof arguments[0] === 'number') {\n          index = arguments[0];\n        } else if (Array.isArray(arguments[0])) {\n          options = arguments[0];\n        }\n        break;\n    }\n\n\n    if (index === null) {\n      if (!this._currentInput) {\n        return handleCallback(new Error('No input specified'));\n      }\n\n      input = this._currentInput;\n    } else {\n      input = this._inputs[index];\n\n      if (!input) {\n        return handleCallback(new Error('Invalid input index'));\n      }\n    }\n\n    // Find ffprobe\n    this._getFfprobePath(function(err, path) {\n      if (err) {\n        return handleCallback(err);\n      } else if (!path) {\n        return handleCallback(new Error('Cannot find ffprobe'));\n      }\n\n      var stdout = '';\n      var stdoutClosed = false;\n      var stderr = '';\n      var stderrClosed = false;\n\n      // Spawn ffprobe\n      var src = input.isStream ? 'pipe:0' : input.source;\n      var ffprobe = spawn(path, ['-show_streams', '-show_format'].concat(options, src), {windowsHide: true});\n\n      if (input.isStream) {\n        // Skip errors on stdin. These get thrown when ffprobe is complete and\n        // there seems to be no way hook in and close stdin before it throws.\n        ffprobe.stdin.on('error', function(err) {\n          if (['ECONNRESET', 'EPIPE', 'EOF'].indexOf(err.code) >= 0) { return; }\n          handleCallback(err);\n        });\n\n        // Once ffprobe's input stream closes, we need no more data from the\n        // input\n        ffprobe.stdin.on('close', function() {\n            input.source.pause();\n            input.source.unpipe(ffprobe.stdin);\n        });\n\n        input.source.pipe(ffprobe.stdin);\n      }\n\n      ffprobe.on('error', callback);\n\n      // Ensure we wait for captured streams to end before calling callback\n      var exitError = null;\n      function handleExit(err) {\n        if (err) {\n          exitError = err;\n        }\n\n        if (processExited && stdoutClosed && stderrClosed) {\n          if (exitError) {\n            if (stderr) {\n              exitError.message += '\\n' + stderr;\n            }\n\n            return handleCallback(exitError);\n          }\n\n          // Process output\n          var data = parseFfprobeOutput(stdout);\n\n          // Handle legacy output with \"TAG:x\" and \"DISPOSITION:x\" keys\n          [data.format].concat(data.streams).forEach(function(target) {\n            if (target) {\n              var legacyTagKeys = Object.keys(target).filter(legacyTag);\n\n              if (legacyTagKeys.length) {\n                target.tags = target.tags || {};\n\n                legacyTagKeys.forEach(function(tagKey) {\n                  target.tags[tagKey.substr(4)] = target[tagKey];\n                  delete target[tagKey];\n                });\n              }\n\n              var legacyDispositionKeys = Object.keys(target).filter(legacyDisposition);\n\n              if (legacyDispositionKeys.length) {\n                target.disposition = target.disposition || {};\n\n                legacyDispositionKeys.forEach(function(dispositionKey) {\n                  target.disposition[dispositionKey.substr(12)] = target[dispositionKey];\n                  delete target[dispositionKey];\n                });\n              }\n            }\n          });\n\n          handleCallback(null, data);\n        }\n      }\n\n      // Handle ffprobe exit\n      var processExited = false;\n      ffprobe.on('exit', function(code, signal) {\n        processExited = true;\n\n        if (code) {\n          handleExit(new Error('ffprobe exited with code ' + code));\n        } else if (signal) {\n          handleExit(new Error('ffprobe was killed with signal ' + signal));\n        } else {\n          handleExit();\n        }\n      });\n\n      // Handle stdout/stderr streams\n      ffprobe.stdout.on('data', function(data) {\n        stdout += data;\n      });\n\n      ffprobe.stdout.on('close', function() {\n        stdoutClosed = true;\n        handleExit();\n      });\n\n      ffprobe.stderr.on('data', function(data) {\n        stderr += data;\n      });\n\n      ffprobe.stderr.on('close', function() {\n        stderrClosed = true;\n        handleExit();\n      });\n    });\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/index.d.ts",
    "content": "/// <reference types=\"node\" />\n\nimport * as events from \"events\";\nimport * as stream from \"stream\";\n\ndeclare namespace Ffmpeg {\n    interface FfmpegCommandLogger {\n        error(...data: any[]): void;\n        warn(...data: any[]): void;\n        info(...data: any[]): void;\n        debug(...data: any[]): void;\n    }\n\n    interface FfmpegCommandOptions {\n        logger?: FfmpegCommandLogger | undefined;\n        niceness?: number | undefined;\n        priority?: number | undefined;\n        presets?: string | undefined;\n        preset?: string | undefined;\n        stdoutLines?: number | undefined;\n        timeout?: number | undefined;\n        source?: string | stream.Readable | undefined;\n        cwd?: string | undefined;\n    }\n\n    interface FilterSpecification {\n        filter: string;\n        inputs?: string | string[] | undefined;\n        outputs?: string | string[] | undefined;\n        options?: any | string | any[] | undefined;\n    }\n\n    type PresetFunction = (command: FfmpegCommand) => void;\n\n    interface Filter {\n        description: string;\n        input: string;\n        multipleInputs: boolean;\n        output: string;\n        multipleOutputs: boolean;\n    }\n    interface Filters {\n        [key: string]: Filter;\n    }\n    type FiltersCallback = (err: Error, filters: Filters) => void;\n\n    interface Codec {\n        type: string;\n        description: string;\n        canDecode: boolean;\n        canEncode: boolean;\n        drawHorizBand?: boolean | undefined;\n        directRendering?: boolean | undefined;\n        weirdFrameTruncation?: boolean | undefined;\n        intraFrameOnly?: boolean | undefined;\n        isLossy?: boolean | undefined;\n        isLossless?: boolean | undefined;\n    }\n    interface Codecs {\n        [key: string]: Codec;\n    }\n    type CodecsCallback = (err: Error, codecs: Codecs) => void;\n\n    interface Encoder {\n        type: string;\n        description: string;\n        frameMT: boolean;\n        sliceMT: boolean;\n        experimental: boolean;\n        drawHorizBand: boolean;\n        directRendering: boolean;\n    }\n    interface Encoders {\n        [key: string]: Encoder;\n    }\n    type EncodersCallback = (err: Error, encoders: Encoders) => void;\n\n    interface Format {\n        description: string;\n        canDemux: boolean;\n        canMux: boolean;\n    }\n    interface Formats {\n        [key: string]: Format;\n    }\n    type FormatsCallback = (err: Error, formats: Formats) => void;\n\n    interface FfprobeData {\n        streams: FfprobeStream[];\n        format: FfprobeFormat;\n        chapters: any[];\n    }\n\n    interface FfprobeStream {\n        [key: string]: any;\n        index: number;\n        codec_name?: string | undefined;\n        codec_long_name?: string | undefined;\n        profile?: number | undefined;\n        codec_type?: string | undefined;\n        codec_time_base?: string | undefined;\n        codec_tag_string?: string | undefined;\n        codec_tag?: string | undefined;\n        width?: number | undefined;\n        height?: number | undefined;\n        coded_width?: number | undefined;\n        coded_height?: number | undefined;\n        has_b_frames?: number | undefined;\n        sample_aspect_ratio?: string | undefined;\n        display_aspect_ratio?: string | undefined;\n        pix_fmt?: string | undefined;\n        level?: string | undefined;\n        color_range?: string | undefined;\n        color_space?: string | undefined;\n        color_transfer?: string | undefined;\n        color_primaries?: string | undefined;\n        chroma_location?: string | undefined;\n        field_order?: string | undefined;\n        timecode?: string | undefined;\n        refs?: number | undefined;\n        id?: string | undefined;\n        r_frame_rate?: string | undefined;\n        avg_frame_rate?: string | undefined;\n        time_base?: string | undefined;\n        start_pts?: number | undefined;\n        start_time?: number | undefined;\n        duration_ts?: string | undefined;\n        duration?: string | undefined;\n        bit_rate?: string | undefined;\n        max_bit_rate?: string | undefined;\n        bits_per_raw_sample?: string | undefined;\n        nb_frames?: string | undefined;\n        nb_read_frames?: string | undefined;\n        nb_read_packets?: string | undefined;\n        sample_fmt?: string | undefined;\n        sample_rate?: number | undefined;\n        channels?: number | undefined;\n        channel_layout?: string | undefined;\n        bits_per_sample?: number | undefined;\n        disposition?: FfprobeStreamDisposition | undefined;\n        rotation?: string | number | undefined;\n    }\n\n    interface FfprobeStreamDisposition {\n        [key: string]: any;\n        default?: number | undefined;\n        dub?: number | undefined;\n        original?: number | undefined;\n        comment?: number | undefined;\n        lyrics?: number | undefined;\n        karaoke?: number | undefined;\n        forced?: number | undefined;\n        hearing_impaired?: number | undefined;\n        visual_impaired?: number | undefined;\n        clean_effects?: number | undefined;\n        attached_pic?: number | undefined;\n        timed_thumbnails?: number | undefined;\n    }\n\n    interface FfprobeFormat {\n        [key: string]: any;\n        filename?: string | undefined;\n        nb_streams?: number | undefined;\n        nb_programs?: number | undefined;\n        format_name?: string | undefined;\n        format_long_name?: string | undefined;\n        start_time?: number | undefined;\n        duration?: number | undefined;\n        size?: number | undefined;\n        bit_rate?: number | undefined;\n        probe_score?: number | undefined;\n        tags?: Record<string, string | number> | undefined;\n    }\n\n    interface ScreenshotsConfig {\n        count?: number | undefined;\n        folder?: string | undefined;\n        filename?: string | undefined;\n        timemarks?: number[] | string[] | undefined;\n        timestamps?: number[] | string[] | undefined;\n        fastSeek?: boolean | undefined;\n        size?: string | undefined;\n    }\n\n    interface AudioVideoFilter {\n        filter: string;\n        options: string | string[] | {};\n    }\n\n    // static methods\n    function setFfmpegPath(path: string): FfmpegCommand;\n    function setFfprobePath(path: string): FfmpegCommand;\n    function setFlvtoolPath(path: string): FfmpegCommand;\n    function availableFilters(callback: FiltersCallback): void;\n    function getAvailableFilters(callback: FiltersCallback): void;\n    function availableCodecs(callback: CodecsCallback): void;\n    function getAvailableCodecs(callback: CodecsCallback): void;\n    function availableEncoders(callback: EncodersCallback): void;\n    function getAvailableEncoders(callback: EncodersCallback): void;\n    function availableFormats(callback: FormatsCallback): void;\n    function getAvailableFormats(callback: FormatsCallback): void;\n\n    class FfmpegCommand extends events.EventEmitter {\n        constructor(options?: FfmpegCommandOptions);\n        constructor(input?: string | stream.Readable, options?: FfmpegCommandOptions);\n\n        // options/inputs\n        mergeAdd(source: string | stream.Readable): FfmpegCommand;\n        addInput(source: string | stream.Readable): FfmpegCommand;\n        input(source: string | stream.Readable): FfmpegCommand;\n        withInputFormat(format: string): FfmpegCommand;\n        inputFormat(format: string): FfmpegCommand;\n        fromFormat(format: string): FfmpegCommand;\n        withInputFps(fps: number): FfmpegCommand;\n        withInputFPS(fps: number): FfmpegCommand;\n        withFpsInput(fps: number): FfmpegCommand;\n        withFPSInput(fps: number): FfmpegCommand;\n        inputFPS(fps: number): FfmpegCommand;\n        inputFps(fps: number): FfmpegCommand;\n        fpsInput(fps: number): FfmpegCommand;\n        FPSInput(fps: number): FfmpegCommand;\n        nativeFramerate(): FfmpegCommand;\n        withNativeFramerate(): FfmpegCommand;\n        native(): FfmpegCommand;\n        setStartTime(seek: string | number): FfmpegCommand;\n        seekInput(seek: string | number): FfmpegCommand;\n        loop(duration?: string | number): FfmpegCommand;\n\n        // options/audio\n        withNoAudio(): FfmpegCommand;\n        noAudio(): FfmpegCommand;\n        withAudioCodec(codec: string): FfmpegCommand;\n        audioCodec(codec: string): FfmpegCommand;\n        withAudioBitrate(bitrate: string | number): FfmpegCommand;\n        audioBitrate(bitrate: string | number): FfmpegCommand;\n        withAudioChannels(channels: number): FfmpegCommand;\n        audioChannels(channels: number): FfmpegCommand;\n        withAudioFrequency(freq: number): FfmpegCommand;\n        audioFrequency(freq: number): FfmpegCommand;\n        withAudioQuality(quality: number): FfmpegCommand;\n        audioQuality(quality: number): FfmpegCommand;\n        withAudioFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        withAudioFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        audioFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        audioFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n\n        // options/video;\n        withNoVideo(): FfmpegCommand;\n        noVideo(): FfmpegCommand;\n        withVideoCodec(codec: string): FfmpegCommand;\n        videoCodec(codec: string): FfmpegCommand;\n        withVideoBitrate(bitrate: string | number, constant?: boolean): FfmpegCommand;\n        videoBitrate(bitrate: string | number, constant?: boolean): FfmpegCommand;\n        withVideoFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        withVideoFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        videoFilter(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        videoFilters(filters: string | string[] | AudioVideoFilter[]): FfmpegCommand;\n        withOutputFps(fps: number): FfmpegCommand;\n        withOutputFPS(fps: number): FfmpegCommand;\n        withFpsOutput(fps: number): FfmpegCommand;\n        withFPSOutput(fps: number): FfmpegCommand;\n        withFps(fps: number): FfmpegCommand;\n        withFPS(fps: number): FfmpegCommand;\n        outputFPS(fps: number): FfmpegCommand;\n        outputFps(fps: number): FfmpegCommand;\n        fpsOutput(fps: number): FfmpegCommand;\n        FPSOutput(fps: number): FfmpegCommand;\n        fps(fps: number): FfmpegCommand;\n        FPS(fps: number): FfmpegCommand;\n        takeFrames(frames: number): FfmpegCommand;\n        withFrames(frames: number): FfmpegCommand;\n        frames(frames: number): FfmpegCommand;\n\n        // options/videosize\n        keepPixelAspect(): FfmpegCommand;\n        keepDisplayAspect(): FfmpegCommand;\n        keepDisplayAspectRatio(): FfmpegCommand;\n        keepDAR(): FfmpegCommand;\n        withSize(size: string): FfmpegCommand;\n        setSize(size: string): FfmpegCommand;\n        size(size: string): FfmpegCommand;\n        withAspect(aspect: string | number): FfmpegCommand;\n        withAspectRatio(aspect: string | number): FfmpegCommand;\n        setAspect(aspect: string | number): FfmpegCommand;\n        setAspectRatio(aspect: string | number): FfmpegCommand;\n        aspect(aspect: string | number): FfmpegCommand;\n        aspectRatio(aspect: string | number): FfmpegCommand;\n        applyAutopadding(pad?: boolean, color?: string): FfmpegCommand;\n        applyAutoPadding(pad?: boolean, color?: string): FfmpegCommand;\n        applyAutopad(pad?: boolean, color?: string): FfmpegCommand;\n        applyAutoPad(pad?: boolean, color?: string): FfmpegCommand;\n        withAutopadding(pad?: boolean, color?: string): FfmpegCommand;\n        withAutoPadding(pad?: boolean, color?: string): FfmpegCommand;\n        withAutopad(pad?: boolean, color?: string): FfmpegCommand;\n        withAutoPad(pad?: boolean, color?: string): FfmpegCommand;\n        autoPad(pad?: boolean, color?: string): FfmpegCommand;\n        autopad(pad?: boolean, color?: string): FfmpegCommand;\n\n        // options/output\n        addOutput(target: string | stream.Writable, pipeopts?: { end?: boolean | undefined }): FfmpegCommand;\n        output(target: string | stream.Writable, pipeopts?: { end?: boolean | undefined }): FfmpegCommand;\n        seekOutput(seek: string | number): FfmpegCommand;\n        seek(seek: string | number): FfmpegCommand;\n        withDuration(duration: string | number): FfmpegCommand;\n        setDuration(duration: string | number): FfmpegCommand;\n        duration(duration: string | number): FfmpegCommand;\n        toFormat(format: string): FfmpegCommand;\n        withOutputFormat(format: string): FfmpegCommand;\n        outputFormat(format: string): FfmpegCommand;\n        format(format: string): FfmpegCommand;\n        map(spec: string): FfmpegCommand;\n        updateFlvMetadata(): FfmpegCommand;\n        flvmeta(): FfmpegCommand;\n\n        // options/custom\n        addInputOption(options: string[]): FfmpegCommand;\n        addInputOption(...options: string[]): FfmpegCommand;\n        addInputOptions(options: string[]): FfmpegCommand;\n        addInputOptions(...options: string[]): FfmpegCommand;\n        withInputOption(options: string[]): FfmpegCommand;\n        withInputOption(...options: string[]): FfmpegCommand;\n        withInputOptions(options: string[]): FfmpegCommand;\n        withInputOptions(...options: string[]): FfmpegCommand;\n        inputOption(options: string[]): FfmpegCommand;\n        inputOption(...options: string[]): FfmpegCommand;\n        inputOptions(options: string[]): FfmpegCommand;\n        inputOptions(...options: string[]): FfmpegCommand;\n        addOutputOption(options: string[]): FfmpegCommand;\n        addOutputOption(...options: string[]): FfmpegCommand;\n        addOutputOptions(options: string[]): FfmpegCommand;\n        addOutputOptions(...options: string[]): FfmpegCommand;\n        addOption(options: string[]): FfmpegCommand;\n        addOption(...options: string[]): FfmpegCommand;\n        addOptions(options: string[]): FfmpegCommand;\n        addOptions(...options: string[]): FfmpegCommand;\n        withOutputOption(options: string[]): FfmpegCommand;\n        withOutputOption(...options: string[]): FfmpegCommand;\n        withOutputOptions(options: string[]): FfmpegCommand;\n        withOutputOptions(...options: string[]): FfmpegCommand;\n        withOption(options: string[]): FfmpegCommand;\n        withOption(...options: string[]): FfmpegCommand;\n        withOptions(options: string[]): FfmpegCommand;\n        withOptions(...options: string[]): FfmpegCommand;\n        outputOption(options: string[]): FfmpegCommand;\n        outputOption(...options: string[]): FfmpegCommand;\n        outputOptions(options: string[]): FfmpegCommand;\n        outputOptions(...options: string[]): FfmpegCommand;\n        filterGraph(\n            spec: string | FilterSpecification | Array<string | FilterSpecification>,\n            map?: string[] | string,\n        ): FfmpegCommand;\n        complexFilter(\n            spec: string | FilterSpecification | Array<string | FilterSpecification>,\n            map?: string[] | string,\n        ): FfmpegCommand;\n\n        // options/misc\n        usingPreset(preset: string | PresetFunction): FfmpegCommand;\n        preset(preset: string | PresetFunction): FfmpegCommand;\n\n        // processor\n        renice(niceness: number): FfmpegCommand;\n        kill(signal: string): FfmpegCommand;\n        _getArguments(): string[];\n\n        // capabilities\n        setFfmpegPath(path: string): FfmpegCommand;\n        setFfprobePath(path: string): FfmpegCommand;\n        setFlvtoolPath(path: string): FfmpegCommand;\n        availableFilters(callback: FiltersCallback): void;\n        getAvailableFilters(callback: FiltersCallback): void;\n        availableCodecs(callback: CodecsCallback): void;\n        getAvailableCodecs(callback: CodecsCallback): void;\n        availableEncoders(callback: EncodersCallback): void;\n        getAvailableEncoders(callback: EncodersCallback): void;\n        availableFormats(callback: FormatsCallback): void;\n        getAvailableFormats(callback: FormatsCallback): void;\n\n        // ffprobe\n        ffprobe(callback: (err: any, data: FfprobeData) => void): void;\n        ffprobe(index: number, callback: (err: any, data: FfprobeData) => void): void;\n        ffprobe(options: string[], callback: (err: any, data: FfprobeData) => void): void; // tslint:disable-line unified-signatures\n        ffprobe(index: number, options: string[], callback: (err: any, data: FfprobeData) => void): void;\n\n        // event listeners\n        /**\n         * Emitted just after ffmpeg has been spawned.\n         *\n         * @event FfmpegCommand#start\n         * @param {String} command ffmpeg command line\n         */\n        on(event: \"start\", listener: (command: string) => void): this;\n\n        /**\n         * Emitted when ffmpeg reports progress information\n         *\n         * @event FfmpegCommand#progress\n         * @param {Object} progress progress object\n         * @param {Number} progress.frames number of frames transcoded\n         * @param {Number} progress.currentFps current processing speed in frames per second\n         * @param {Number} progress.currentKbps current output generation speed in kilobytes per second\n         * @param {Number} progress.targetSize current output file size\n         * @param {String} progress.timemark current video timemark\n         * @param {Number} [progress.percent] processing progress (may not be available depending on input)\n         */\n        on(\n            event: \"progress\",\n            listener: (progress: {\n                frames: number;\n                currentFps: number;\n                currentKbps: number;\n                targetSize: number;\n                timemark: string;\n                percent?: number | undefined;\n            }) => void,\n        ): this;\n\n        /**\n         * Emitted when ffmpeg outputs to stderr\n         *\n         * @event FfmpegCommand#stderr\n         * @param {String} line stderr output line\n         */\n        on(event: \"stderr\", listener: (line: string) => void): this;\n\n        /**\n         * Emitted when ffmpeg reports input codec data\n         *\n         * @event FfmpegCommand#codecData\n         * @param {Object} codecData codec data object\n         * @param {String} codecData.format input format name\n         * @param {String} codecData.audio input audio codec name\n         * @param {String} codecData.audio_details input audio codec parameters\n         * @param {String} codecData.video input video codec name\n         * @param {String} codecData.video_details input video codec parameters\n         */\n        on(\n            event: \"codecData\",\n            listener: (codecData: {\n                format: string;\n                audio: string;\n                audio_details: string;\n                video: string;\n                video_details: string;\n            }) => void,\n        ): this;\n\n        /**\n         * Emitted when an error happens when preparing or running a command\n         *\n         * @event FfmpegCommand#error\n         * @param {Error} error error object, with optional properties 'inputStreamError' / 'outputStreamError' for errors on their respective streams\n         * @param {String|null} stdout ffmpeg stdout, unless outputting to a stream\n         * @param {String|null} stderr ffmpeg stderr\n         */\n        on(event: \"error\", listener: (error: Error, stdout: string | null, stderr: string | null) => void): this;\n\n        /**\n         * Emitted when a command finishes processing\n         *\n         * @event FfmpegCommand#end\n         * @param {Array|String|null} [filenames|stdout] generated filenames when taking screenshots, ffmpeg stdout when not outputting to a stream, null otherwise\n         * @param {String|null} stderr ffmpeg stderr\n         */\n        on(event: \"end\", listener: (filenames: string[] | string | null, stderr: string | null) => void): this;\n\n        // recipes\n        saveToFile(output: string): FfmpegCommand;\n        save(output: string): FfmpegCommand;\n        writeToStream(stream: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable;\n        pipe(stream?: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable | stream.PassThrough;\n        stream(stream: stream.Writable, options?: { end?: boolean | undefined }): stream.Writable;\n        takeScreenshots(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;\n        thumbnail(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;\n        thumbnails(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;\n        screenshot(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;\n        screenshots(config: number | ScreenshotsConfig, folder?: string): FfmpegCommand;\n        mergeToFile(target: string | stream.Writable, tmpFolder: string): FfmpegCommand;\n        concatenate(target: string | stream.Writable, options?: { end?: boolean | undefined }): FfmpegCommand;\n        concat(target: string | stream.Writable, options?: { end?: boolean | undefined }): FfmpegCommand;\n        clone(): FfmpegCommand;\n        run(): void;\n    }\n\n    function ffprobe(file: string, callback: (err: any, data: FfprobeData) => void): void;\n    function ffprobe(file: string, index: number, callback: (err: any, data: FfprobeData) => void): void;\n    function ffprobe(file: string, options: string[], callback: (err: any, data: FfprobeData) => void): void; // tslint:disable-line unified-signatures\n    function ffprobe(\n        file: string,\n        index: number,\n        options: string[],\n        callback: (err: any, data: FfprobeData) => void,\n    ): void;\n}\ndeclare function Ffmpeg(options?: Ffmpeg.FfmpegCommandOptions): Ffmpeg.FfmpegCommand;\ndeclare function Ffmpeg(input?: string | stream.Readable, options?: Ffmpeg.FfmpegCommandOptions): Ffmpeg.FfmpegCommand;\n\nexport = Ffmpeg;\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/index.js",
    "content": "/*jshint node:true*/\n'use strict';\n\n//\n// modified for use with audiobookshelf\n// Source: https://github.com/fluent-ffmpeg/node-fluent-ffmpeg\n//\n\nvar path = require('path');\nvar util = require('util');\nvar EventEmitter = require('events').EventEmitter;\n\nvar utils = require('./utils');\nvar ARGLISTS = ['_global', '_audio', '_audioFilters', '_video', '_videoFilters', '_sizeFilters', '_complexFilters'];\n\n\n/**\n * Create an ffmpeg command\n *\n * Can be called with or without the 'new' operator, and the 'input' parameter\n * may be specified as 'options.source' instead (or passed later with the\n * addInput method).\n *\n * @constructor\n * @param {String|ReadableStream} [input] input file path or readable stream\n * @param {Object} [options] command options\n * @param {Object} [options.logger=<no logging>] logger object with 'error', 'warning', 'info' and 'debug' methods\n * @param {Number} [options.niceness=0] ffmpeg process niceness, ignored on Windows\n * @param {Number} [options.priority=0] alias for `niceness`\n * @param {String} [options.presets=\"fluent-ffmpeg/lib/presets\"] directory to load presets from\n * @param {String} [options.preset=\"fluent-ffmpeg/lib/presets\"] alias for `presets`\n * @param {String} [options.stdoutLines=100] maximum lines of ffmpeg output to keep in memory, use 0 for unlimited\n * @param {Number} [options.timeout=<no timeout>] ffmpeg processing timeout in seconds\n * @param {String|ReadableStream} [options.source=<no input>] alias for the `input` parameter\n */\nfunction FfmpegCommand(input, options) {\n  // Make 'new' optional\n  if (!(this instanceof FfmpegCommand)) {\n    return new FfmpegCommand(input, options);\n  }\n\n  EventEmitter.call(this);\n\n  if (typeof input === 'object' && !('readable' in input)) {\n    // Options object passed directly\n    options = input;\n  } else {\n    // Input passed first\n    options = options || {};\n    options.source = input;\n  }\n\n  // Add input if present\n  this._inputs = [];\n  if (options.source) {\n    this.input(options.source);\n  }\n\n  // Add target-less output for backwards compatibility\n  this._outputs = [];\n  this.output();\n\n  // Create argument lists\n  var self = this;\n  ['_global', '_complexFilters'].forEach(function (prop) {\n    self[prop] = utils.args();\n  });\n\n  // Set default option values\n  options.stdoutLines = 'stdoutLines' in options ? options.stdoutLines : 100;\n  options.presets = options.presets || options.preset || path.join(__dirname, 'presets');\n  options.niceness = options.niceness || options.priority || 0;\n\n  // Save options\n  this.options = options;\n\n  // Setup logger\n  this.logger = options.logger || {\n    debug: function () { },\n    info: function () { },\n    warn: function () { },\n    error: function () { }\n  };\n}\nutil.inherits(FfmpegCommand, EventEmitter);\nmodule.exports = FfmpegCommand;\n\n\n/**\n * Clone an ffmpeg command\n *\n * This method is useful when you want to process the same input multiple times.\n * It returns a new FfmpegCommand instance with the exact same options.\n *\n * All options set _after_ the clone() call will only be applied to the instance\n * it has been called on.\n *\n * @example\n *   var command = ffmpeg('/path/to/source.avi')\n *     .audioCodec('libfaac')\n *     .videoCodec('libx264')\n *     .format('mp4');\n *\n *   command.clone()\n *     .size('320x200')\n *     .save('/path/to/output-small.mp4');\n *\n *   command.clone()\n *     .size('640x400')\n *     .save('/path/to/output-medium.mp4');\n *\n *   command.save('/path/to/output-original-size.mp4');\n *\n * @method FfmpegCommand#clone\n * @return FfmpegCommand\n */\nFfmpegCommand.prototype.clone = function () {\n  var clone = new FfmpegCommand();\n  var self = this;\n\n  // Clone options and logger\n  clone.options = this.options;\n  clone.logger = this.logger;\n\n  // Clone inputs\n  clone._inputs = this._inputs.map(function (input) {\n    return {\n      source: input.source,\n      options: input.options.clone()\n    };\n  });\n\n  // Create first output\n  if ('target' in this._outputs[0]) {\n    // We have outputs set, don't clone them and create first output\n    clone._outputs = [];\n    clone.output();\n  } else {\n    // No outputs set, clone first output options\n    clone._outputs = [\n      clone._currentOutput = {\n        flags: {}\n      }\n    ];\n\n    ['audio', 'audioFilters', 'video', 'videoFilters', 'sizeFilters', 'options'].forEach(function (key) {\n      clone._currentOutput[key] = self._currentOutput[key].clone();\n    });\n\n    if (this._currentOutput.sizeData) {\n      clone._currentOutput.sizeData = {};\n      utils.copy(this._currentOutput.sizeData, clone._currentOutput.sizeData);\n    }\n\n    utils.copy(this._currentOutput.flags, clone._currentOutput.flags);\n  }\n\n  // Clone argument lists\n  ['_global', '_complexFilters'].forEach(function (prop) {\n    clone[prop] = self[prop].clone();\n  });\n\n  return clone;\n};\n\n\n/* Add methods from options submodules */\n\nrequire('./options/inputs')(FfmpegCommand.prototype);\nrequire('./options/audio')(FfmpegCommand.prototype);\nrequire('./options/video')(FfmpegCommand.prototype);\nrequire('./options/videosize')(FfmpegCommand.prototype);\nrequire('./options/output')(FfmpegCommand.prototype);\nrequire('./options/custom')(FfmpegCommand.prototype);\nrequire('./options/misc')(FfmpegCommand.prototype);\n\n\n/* Add processor methods */\n\nrequire('./processor')(FfmpegCommand.prototype);\n\n\n/* Add capabilities methods */\n\nrequire('./capabilities')(FfmpegCommand.prototype);\n\nFfmpegCommand.setFfmpegPath = function (path) {\n  (new FfmpegCommand()).setFfmpegPath(path);\n};\n\nFfmpegCommand.setFfprobePath = function (path) {\n  (new FfmpegCommand()).setFfprobePath(path);\n};\n\nFfmpegCommand.setFlvtoolPath = function (path) {\n  (new FfmpegCommand()).setFlvtoolPath(path);\n};\n\nFfmpegCommand.availableFilters =\n  FfmpegCommand.getAvailableFilters = function (callback) {\n    (new FfmpegCommand()).availableFilters(callback);\n  };\n\nFfmpegCommand.availableCodecs =\n  FfmpegCommand.getAvailableCodecs = function (callback) {\n    (new FfmpegCommand()).availableCodecs(callback);\n  };\n\nFfmpegCommand.availableFormats =\n  FfmpegCommand.getAvailableFormats = function (callback) {\n    (new FfmpegCommand()).availableFormats(callback);\n  };\n\nFfmpegCommand.availableEncoders =\n  FfmpegCommand.getAvailableEncoders = function (callback) {\n    (new FfmpegCommand()).availableEncoders(callback);\n  };\n\n\n/* Add ffprobe methods */\n\nrequire('./ffprobe')(FfmpegCommand.prototype);\n\nFfmpegCommand.ffprobe = function (file) {\n  var instance = new FfmpegCommand(file);\n  instance.ffprobe.apply(instance, Array.prototype.slice.call(arguments, 1));\n};\n\n/* Add processing recipes */\n\nrequire('./recipes')(FfmpegCommand.prototype);\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/audio.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar utils = require('../utils');\n\n\n/*\n *! Audio-related methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Disable audio in the output\n   *\n   * @method FfmpegCommand#noAudio\n   * @category Audio\n   * @aliases withNoAudio\n   * @return FfmpegCommand\n   */\n  proto.withNoAudio =\n  proto.noAudio = function() {\n    this._currentOutput.audio.clear();\n    this._currentOutput.audioFilters.clear();\n    this._currentOutput.audio('-an');\n\n    return this;\n  };\n\n\n  /**\n   * Specify audio codec\n   *\n   * @method FfmpegCommand#audioCodec\n   * @category Audio\n   * @aliases withAudioCodec\n   *\n   * @param {String} codec audio codec name\n   * @return FfmpegCommand\n   */\n  proto.withAudioCodec =\n  proto.audioCodec = function(codec) {\n    this._currentOutput.audio('-acodec', codec);\n\n    return this;\n  };\n\n\n  /**\n   * Specify audio bitrate\n   *\n   * @method FfmpegCommand#audioBitrate\n   * @category Audio\n   * @aliases withAudioBitrate\n   *\n   * @param {String|Number} bitrate audio bitrate in kbps (with an optional 'k' suffix)\n   * @return FfmpegCommand\n   */\n  proto.withAudioBitrate =\n  proto.audioBitrate = function(bitrate) {\n    this._currentOutput.audio('-b:a', ('' + bitrate).replace(/k?$/, 'k'));\n    return this;\n  };\n\n\n  /**\n   * Specify audio channel count\n   *\n   * @method FfmpegCommand#audioChannels\n   * @category Audio\n   * @aliases withAudioChannels\n   *\n   * @param {Number} channels channel count\n   * @return FfmpegCommand\n   */\n  proto.withAudioChannels =\n  proto.audioChannels = function(channels) {\n    this._currentOutput.audio('-ac', channels);\n    return this;\n  };\n\n\n  /**\n   * Specify audio frequency\n   *\n   * @method FfmpegCommand#audioFrequency\n   * @category Audio\n   * @aliases withAudioFrequency\n   *\n   * @param {Number} freq audio frequency in Hz\n   * @return FfmpegCommand\n   */\n  proto.withAudioFrequency =\n  proto.audioFrequency = function(freq) {\n    this._currentOutput.audio('-ar', freq);\n    return this;\n  };\n\n\n  /**\n   * Specify audio quality\n   *\n   * @method FfmpegCommand#audioQuality\n   * @category Audio\n   * @aliases withAudioQuality\n   *\n   * @param {Number} quality audio quality factor\n   * @return FfmpegCommand\n   */\n  proto.withAudioQuality =\n  proto.audioQuality = function(quality) {\n    this._currentOutput.audio('-aq', quality);\n    return this;\n  };\n\n\n  /**\n   * Specify custom audio filter(s)\n   *\n   * Can be called both with one or many filters, or a filter array.\n   *\n   * @example\n   * command.audioFilters('filter1');\n   *\n   * @example\n   * command.audioFilters('filter1', 'filter2=param1=value1:param2=value2');\n   *\n   * @example\n   * command.audioFilters(['filter1', 'filter2']);\n   *\n   * @example\n   * command.audioFilters([\n   *   {\n   *     filter: 'filter1'\n   *   },\n   *   {\n   *     filter: 'filter2',\n   *     options: 'param=value:param=value'\n   *   }\n   * ]);\n   *\n   * @example\n   * command.audioFilters(\n   *   {\n   *     filter: 'filter1',\n   *     options: ['value1', 'value2']\n   *   },\n   *   {\n   *     filter: 'filter2',\n   *     options: { param1: 'value1', param2: 'value2' }\n   *   }\n   * );\n   *\n   * @method FfmpegCommand#audioFilters\n   * @aliases withAudioFilter,withAudioFilters,audioFilter\n   * @category Audio\n   *\n   * @param {...String|String[]|Object[]} filters audio filter strings, string array or\n   *   filter specification array, each with the following properties:\n   * @param {String} filters.filter filter name\n   * @param {String|String[]|Object} [filters.options] filter option string, array, or object\n   * @return FfmpegCommand\n   */\n  proto.withAudioFilter =\n  proto.withAudioFilters =\n  proto.audioFilter =\n  proto.audioFilters = function(filters) {\n    if (arguments.length > 1) {\n      filters = [].slice.call(arguments);\n    }\n\n    if (!Array.isArray(filters)) {\n      filters = [filters];\n    }\n\n    this._currentOutput.audioFilters(utils.makeFilterStrings(filters));\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/custom.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar utils = require('../utils');\n\n\n/*\n *! Custom options methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Add custom input option(s)\n   *\n   * When passing a single string or an array, each string containing two\n   * words is split (eg. inputOptions('-option value') is supported) for\n   * compatibility reasons.  This is not the case when passing more than\n   * one argument.\n   *\n   * @example\n   * command.inputOptions('option1');\n   *\n   * @example\n   * command.inputOptions('option1', 'option2');\n   *\n   * @example\n   * command.inputOptions(['option1', 'option2']);\n   *\n   * @method FfmpegCommand#inputOptions\n   * @category Custom options\n   * @aliases addInputOption,addInputOptions,withInputOption,withInputOptions,inputOption\n   *\n   * @param {...String} options option string(s) or string array\n   * @return FfmpegCommand\n   */\n  proto.addInputOption =\n  proto.addInputOptions =\n  proto.withInputOption =\n  proto.withInputOptions =\n  proto.inputOption =\n  proto.inputOptions = function(options) {\n    if (!this._currentInput) {\n      throw new Error('No input specified');\n    }\n\n    var doSplit = true;\n\n    if (arguments.length > 1) {\n      options = [].slice.call(arguments);\n      doSplit = false;\n    }\n\n    if (!Array.isArray(options)) {\n      options = [options];\n    }\n\n    this._currentInput.options(options.reduce(function(options, option) {\n      var split = String(option).split(' ');\n\n      if (doSplit && split.length === 2) {\n        options.push(split[0], split[1]);\n      } else {\n        options.push(option);\n      }\n\n      return options;\n    }, []));\n    return this;\n  };\n\n\n  /**\n   * Add custom output option(s)\n   *\n   * @example\n   * command.outputOptions('option1');\n   *\n   * @example\n   * command.outputOptions('option1', 'option2');\n   *\n   * @example\n   * command.outputOptions(['option1', 'option2']);\n   *\n   * @method FfmpegCommand#outputOptions\n   * @category Custom options\n   * @aliases addOutputOption,addOutputOptions,addOption,addOptions,withOutputOption,withOutputOptions,withOption,withOptions,outputOption\n   *\n   * @param {...String} options option string(s) or string array\n   * @return FfmpegCommand\n   */\n  proto.addOutputOption =\n  proto.addOutputOptions =\n  proto.addOption =\n  proto.addOptions =\n  proto.withOutputOption =\n  proto.withOutputOptions =\n  proto.withOption =\n  proto.withOptions =\n  proto.outputOption =\n  proto.outputOptions = function(options) {\n    var doSplit = true;\n\n    if (arguments.length > 1) {\n      options = [].slice.call(arguments);\n      doSplit = false;\n    }\n\n    if (!Array.isArray(options)) {\n      options = [options];\n    }\n\n    this._currentOutput.options(options.reduce(function(options, option) {\n      var split = String(option).split(' ');\n\n      if (doSplit && split.length === 2) {\n        options.push(split[0], split[1]);\n      } else {\n        options.push(option);\n      }\n\n      return options;\n    }, []));\n    return this;\n  };\n\n\n  /**\n   * Specify a complex filtergraph\n   *\n   * Calling this method will override any previously set filtergraph, but you can set\n   * as many filters as needed in one call.\n   *\n   * @example <caption>Overlay an image over a video (using a filtergraph string)</caption>\n   *   ffmpeg()\n   *     .input('video.avi')\n   *     .input('image.png')\n   *     .complexFilter('[0:v][1:v]overlay[out]', ['out']);\n   *\n   * @example <caption>Overlay an image over a video (using a filter array)</caption>\n   *   ffmpeg()\n   *     .input('video.avi')\n   *     .input('image.png')\n   *     .complexFilter([{\n   *       filter: 'overlay',\n   *       inputs: ['0:v', '1:v'],\n   *       outputs: ['out']\n   *     }], ['out']);\n   *\n   * @example <caption>Split video into RGB channels and output a 3x1 video with channels side to side</caption>\n   *  ffmpeg()\n   *    .input('video.avi')\n   *    .complexFilter([\n   *      // Duplicate video stream 3 times into streams a, b, and c\n   *      { filter: 'split', options: '3', outputs: ['a', 'b', 'c'] },\n   *\n   *      // Create stream 'red' by cancelling green and blue channels from stream 'a'\n   *      { filter: 'lutrgb', options: { g: 0, b: 0 }, inputs: 'a', outputs: 'red' },\n   *\n   *      // Create stream 'green' by cancelling red and blue channels from stream 'b'\n   *      { filter: 'lutrgb', options: { r: 0, b: 0 }, inputs: 'b', outputs: 'green' },\n   *\n   *      // Create stream 'blue' by cancelling red and green channels from stream 'c'\n   *      { filter: 'lutrgb', options: { r: 0, g: 0 }, inputs: 'c', outputs: 'blue' },\n   *\n   *      // Pad stream 'red' to 3x width, keeping the video on the left, and name output 'padded'\n   *      { filter: 'pad', options: { w: 'iw*3', h: 'ih' }, inputs: 'red', outputs: 'padded' },\n   *\n   *      // Overlay 'green' onto 'padded', moving it to the center, and name output 'redgreen'\n   *      { filter: 'overlay', options: { x: 'w', y: 0 }, inputs: ['padded', 'green'], outputs: 'redgreen'},\n   *\n   *      // Overlay 'blue' onto 'redgreen', moving it to the right\n   *      { filter: 'overlay', options: { x: '2*w', y: 0 }, inputs: ['redgreen', 'blue']},\n   *    ]);\n   *\n   * @method FfmpegCommand#complexFilter\n   * @category Custom options\n   * @aliases filterGraph\n   *\n   * @param {String|Array} spec filtergraph string or array of filter specification\n   *   objects, each having the following properties:\n   * @param {String} spec.filter filter name\n   * @param {String|Array} [spec.inputs] (array of) input stream specifier(s) for the filter,\n   *   defaults to ffmpeg automatically choosing the first unused matching streams\n   * @param {String|Array} [spec.outputs] (array of) output stream specifier(s) for the filter,\n   *   defaults to ffmpeg automatically assigning the output to the output file\n   * @param {Object|String|Array} [spec.options] filter options, can be omitted to not set any options\n   * @param {Array} [map] (array of) stream specifier(s) from the graph to include in\n   *   ffmpeg output, defaults to ffmpeg automatically choosing the first matching streams.\n   * @return FfmpegCommand\n   */\n  proto.filterGraph =\n  proto.complexFilter = function(spec, map) {\n    this._complexFilters.clear();\n\n    if (!Array.isArray(spec)) {\n      spec = [spec];\n    }\n\n    this._complexFilters('-filter_complex', utils.makeFilterStrings(spec).join(';'));\n\n    if (Array.isArray(map)) {\n      var self = this;\n      map.forEach(function(streamSpec) {\n        self._complexFilters('-map', streamSpec.replace(utils.streamRegexp, '[$1]'));\n      });\n    } else if (typeof map === 'string') {\n      this._complexFilters('-map', map.replace(utils.streamRegexp, '[$1]'));\n    }\n\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/inputs.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar utils = require('../utils');\n\n/*\n *! Input-related methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Add an input to command\n   *\n   * Also switches \"current input\", that is the input that will be affected\n   * by subsequent input-related methods.\n   *\n   * Note: only one stream input is supported for now.\n   *\n   * @method FfmpegCommand#input\n   * @category Input\n   * @aliases mergeAdd,addInput\n   *\n   * @param {String|Readable} source input file path or readable stream\n   * @return FfmpegCommand\n   */\n  proto.mergeAdd =\n  proto.addInput =\n  proto.input = function(source) {\n    var isFile = false;\n    var isStream = false;\n\n    if (typeof source !== 'string') {\n      if (!('readable' in source) || !(source.readable)) {\n        throw new Error('Invalid input');\n      }\n\n      var hasInputStream = this._inputs.some(function(input) {\n        return input.isStream;\n      });\n\n      if (hasInputStream) {\n        throw new Error('Only one input stream is supported');\n      }\n\n      isStream = true;\n      source.pause();\n    } else {\n      var protocol = source.match(/^([a-z]{2,}):/i);\n      isFile = !protocol || protocol[0] === 'file';\n    }\n\n    this._inputs.push(this._currentInput = {\n      source: source,\n      isFile: isFile,\n      isStream: isStream,\n      options: utils.args()\n    });\n\n    return this;\n  };\n\n\n  /**\n   * Specify input format for the last specified input\n   *\n   * @method FfmpegCommand#inputFormat\n   * @category Input\n   * @aliases withInputFormat,fromFormat\n   *\n   * @param {String} format input format\n   * @return FfmpegCommand\n   */\n  proto.withInputFormat =\n  proto.inputFormat =\n  proto.fromFormat = function(format) {\n    if (!this._currentInput) {\n      throw new Error('No input specified');\n    }\n\n    this._currentInput.options('-f', format);\n    return this;\n  };\n\n\n  /**\n   * Specify input FPS for the last specified input\n   * (only valid for raw video formats)\n   *\n   * @method FfmpegCommand#inputFps\n   * @category Input\n   * @aliases withInputFps,withInputFPS,withFpsInput,withFPSInput,inputFPS,inputFps,fpsInput\n   *\n   * @param {Number} fps input FPS\n   * @return FfmpegCommand\n   */\n  proto.withInputFps =\n  proto.withInputFPS =\n  proto.withFpsInput =\n  proto.withFPSInput =\n  proto.inputFPS =\n  proto.inputFps =\n  proto.fpsInput =\n  proto.FPSInput = function(fps) {\n    if (!this._currentInput) {\n      throw new Error('No input specified');\n    }\n\n    this._currentInput.options('-r', fps);\n    return this;\n  };\n\n\n  /**\n   * Use native framerate for the last specified input\n   *\n   * @method FfmpegCommand#native\n   * @category Input\n   * @aliases nativeFramerate,withNativeFramerate\n   *\n   * @return FfmmegCommand\n   */\n  proto.nativeFramerate =\n  proto.withNativeFramerate =\n  proto.native = function() {\n    if (!this._currentInput) {\n      throw new Error('No input specified');\n    }\n\n    this._currentInput.options('-re');\n    return this;\n  };\n\n\n  /**\n   * Specify input seek time for the last specified input\n   *\n   * @method FfmpegCommand#seekInput\n   * @category Input\n   * @aliases setStartTime,seekTo\n   *\n   * @param {String|Number} seek seek time in seconds or as a '[hh:[mm:]]ss[.xxx]' string\n   * @return FfmpegCommand\n   */\n  proto.setStartTime =\n  proto.seekInput = function(seek) {\n    if (!this._currentInput) {\n      throw new Error('No input specified');\n    }\n\n    this._currentInput.options('-ss', seek);\n\n    return this;\n  };\n\n\n  /**\n   * Loop over the last specified input\n   *\n   * @method FfmpegCommand#loop\n   * @category Input\n   *\n   * @param {String|Number} [duration] loop duration in seconds or as a '[[hh:]mm:]ss[.xxx]' string\n   * @return FfmpegCommand\n   */\n  proto.loop = function(duration) {\n    if (!this._currentInput) {\n      throw new Error('No input specified');\n    }\n\n    this._currentInput.options('-loop', '1');\n\n    if (typeof duration !== 'undefined') {\n      this.duration(duration);\n    }\n\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/misc.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar path = require('path');\n\n/*\n *! Miscellaneous methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Use preset\n   *\n   * @method FfmpegCommand#preset\n   * @category Miscellaneous\n   * @aliases usingPreset\n   *\n   * @param {String|Function} preset preset name or preset function\n   */\n  proto.usingPreset =\n  proto.preset = function(preset) {\n    if (typeof preset === 'function') {\n      preset(this);\n    } else {\n      try {\n        var modulePath = path.join(this.options.presets, preset);\n        var module = require(modulePath);\n\n        if (typeof module.load === 'function') {\n          module.load(this);\n        } else {\n          throw new Error('preset ' + modulePath + ' has no load() function');\n        }\n      } catch (err) {\n        throw new Error('preset ' + modulePath + ' could not be loaded: ' + err.message);\n      }\n    }\n\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/output.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar utils = require('../utils');\n\n\n/*\n *! Output-related methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Add output\n   *\n   * @method FfmpegCommand#output\n   * @category Output\n   * @aliases addOutput\n   *\n   * @param {String|Writable} target target file path or writable stream\n   * @param {Object} [pipeopts={}] pipe options (only applies to streams)\n   * @return FfmpegCommand\n   */\n  proto.addOutput =\n  proto.output = function(target, pipeopts) {\n    var isFile = false;\n\n    if (!target && this._currentOutput) {\n      // No target is only allowed when called from constructor\n      throw new Error('Invalid output');\n    }\n\n    if (target && typeof target !== 'string') {\n      if (!('writable' in target) || !(target.writable)) {\n        throw new Error('Invalid output');\n      }\n    } else if (typeof target === 'string') {\n      var protocol = target.match(/^([a-z]{2,}):/i);\n      isFile = !protocol || protocol[0] === 'file';\n    }\n\n    if (target && !('target' in this._currentOutput)) {\n      // For backwards compatibility, set target for first output\n      this._currentOutput.target = target;\n      this._currentOutput.isFile = isFile;\n      this._currentOutput.pipeopts = pipeopts || {};\n    } else {\n      if (target && typeof target !== 'string') {\n        var hasOutputStream = this._outputs.some(function(output) {\n          return typeof output.target !== 'string';\n        });\n\n        if (hasOutputStream) {\n          throw new Error('Only one output stream is supported');\n        }\n      }\n\n      this._outputs.push(this._currentOutput = {\n        target: target,\n        isFile: isFile,\n        flags: {},\n        pipeopts: pipeopts || {}\n      });\n\n      var self = this;\n      ['audio', 'audioFilters', 'video', 'videoFilters', 'sizeFilters', 'options'].forEach(function(key) {\n        self._currentOutput[key] = utils.args();\n      });\n\n      if (!target) {\n        // Call from constructor: remove target key\n        delete this._currentOutput.target;\n      }\n    }\n\n    return this;\n  };\n\n\n  /**\n   * Specify output seek time\n   *\n   * @method FfmpegCommand#seek\n   * @category Input\n   * @aliases seekOutput\n   *\n   * @param {String|Number} seek seek time in seconds or as a '[hh:[mm:]]ss[.xxx]' string\n   * @return FfmpegCommand\n   */\n  proto.seekOutput =\n  proto.seek = function(seek) {\n    this._currentOutput.options('-ss', seek);\n    return this;\n  };\n\n\n  /**\n   * Set output duration\n   *\n   * @method FfmpegCommand#duration\n   * @category Output\n   * @aliases withDuration,setDuration\n   *\n   * @param {String|Number} duration duration in seconds or as a '[[hh:]mm:]ss[.xxx]' string\n   * @return FfmpegCommand\n   */\n  proto.withDuration =\n  proto.setDuration =\n  proto.duration = function(duration) {\n    this._currentOutput.options('-t', duration);\n    return this;\n  };\n\n\n  /**\n   * Set output format\n   *\n   * @method FfmpegCommand#format\n   * @category Output\n   * @aliases toFormat,withOutputFormat,outputFormat\n   *\n   * @param {String} format output format name\n   * @return FfmpegCommand\n   */\n  proto.toFormat =\n  proto.withOutputFormat =\n  proto.outputFormat =\n  proto.format = function(format) {\n    this._currentOutput.options('-f', format);\n    return this;\n  };\n\n\n  /**\n   * Add stream mapping to output\n   *\n   * @method FfmpegCommand#map\n   * @category Output\n   *\n   * @param {String} spec stream specification string, with optional square brackets\n   * @return FfmpegCommand\n   */\n  proto.map = function(spec) {\n    this._currentOutput.options('-map', spec.replace(utils.streamRegexp, '[$1]'));\n    return this;\n  };\n\n\n  /**\n   * Run flvtool2/flvmeta on output\n   *\n   * @method FfmpegCommand#flvmeta\n   * @category Output\n   * @aliases updateFlvMetadata\n   *\n   * @return FfmpegCommand\n   */\n  proto.updateFlvMetadata =\n  proto.flvmeta = function() {\n    this._currentOutput.flags.flvmeta = true;\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/video.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar utils = require('../utils');\n\n\n/*\n *! Video-related methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Disable video in the output\n   *\n   * @method FfmpegCommand#noVideo\n   * @category Video\n   * @aliases withNoVideo\n   *\n   * @return FfmpegCommand\n   */\n  proto.withNoVideo =\n  proto.noVideo = function() {\n    this._currentOutput.video.clear();\n    this._currentOutput.videoFilters.clear();\n    this._currentOutput.video('-vn');\n\n    return this;\n  };\n\n\n  /**\n   * Specify video codec\n   *\n   * @method FfmpegCommand#videoCodec\n   * @category Video\n   * @aliases withVideoCodec\n   *\n   * @param {String} codec video codec name\n   * @return FfmpegCommand\n   */\n  proto.withVideoCodec =\n  proto.videoCodec = function(codec) {\n    this._currentOutput.video('-vcodec', codec);\n    return this;\n  };\n\n\n  /**\n   * Specify video bitrate\n   *\n   * @method FfmpegCommand#videoBitrate\n   * @category Video\n   * @aliases withVideoBitrate\n   *\n   * @param {String|Number} bitrate video bitrate in kbps (with an optional 'k' suffix)\n   * @param {Boolean} [constant=false] enforce constant bitrate\n   * @return FfmpegCommand\n   */\n  proto.withVideoBitrate =\n  proto.videoBitrate = function(bitrate, constant) {\n    bitrate = ('' + bitrate).replace(/k?$/, 'k');\n\n    this._currentOutput.video('-b:v', bitrate);\n    if (constant) {\n      this._currentOutput.video(\n        '-maxrate', bitrate,\n        '-minrate', bitrate,\n        '-bufsize', '3M'\n      );\n    }\n\n    return this;\n  };\n\n\n  /**\n   * Specify custom video filter(s)\n   *\n   * Can be called both with one or many filters, or a filter array.\n   *\n   * @example\n   * command.videoFilters('filter1');\n   *\n   * @example\n   * command.videoFilters('filter1', 'filter2=param1=value1:param2=value2');\n   *\n   * @example\n   * command.videoFilters(['filter1', 'filter2']);\n   *\n   * @example\n   * command.videoFilters([\n   *   {\n   *     filter: 'filter1'\n   *   },\n   *   {\n   *     filter: 'filter2',\n   *     options: 'param=value:param=value'\n   *   }\n   * ]);\n   *\n   * @example\n   * command.videoFilters(\n   *   {\n   *     filter: 'filter1',\n   *     options: ['value1', 'value2']\n   *   },\n   *   {\n   *     filter: 'filter2',\n   *     options: { param1: 'value1', param2: 'value2' }\n   *   }\n   * );\n   *\n   * @method FfmpegCommand#videoFilters\n   * @category Video\n   * @aliases withVideoFilter,withVideoFilters,videoFilter\n   *\n   * @param {...String|String[]|Object[]} filters video filter strings, string array or\n   *   filter specification array, each with the following properties:\n   * @param {String} filters.filter filter name\n   * @param {String|String[]|Object} [filters.options] filter option string, array, or object\n   * @return FfmpegCommand\n   */\n  proto.withVideoFilter =\n  proto.withVideoFilters =\n  proto.videoFilter =\n  proto.videoFilters = function(filters) {\n    if (arguments.length > 1) {\n      filters = [].slice.call(arguments);\n    }\n\n    if (!Array.isArray(filters)) {\n      filters = [filters];\n    }\n\n    this._currentOutput.videoFilters(utils.makeFilterStrings(filters));\n\n    return this;\n  };\n\n\n  /**\n   * Specify output FPS\n   *\n   * @method FfmpegCommand#fps\n   * @category Video\n   * @aliases withOutputFps,withOutputFPS,withFpsOutput,withFPSOutput,withFps,withFPS,outputFPS,outputFps,fpsOutput,FPSOutput,FPS\n   *\n   * @param {Number} fps output FPS\n   * @return FfmpegCommand\n   */\n  proto.withOutputFps =\n  proto.withOutputFPS =\n  proto.withFpsOutput =\n  proto.withFPSOutput =\n  proto.withFps =\n  proto.withFPS =\n  proto.outputFPS =\n  proto.outputFps =\n  proto.fpsOutput =\n  proto.FPSOutput =\n  proto.fps =\n  proto.FPS = function(fps) {\n    this._currentOutput.video('-r', fps);\n    return this;\n  };\n\n\n  /**\n   * Only transcode a certain number of frames\n   *\n   * @method FfmpegCommand#frames\n   * @category Video\n   * @aliases takeFrames,withFrames\n   *\n   * @param {Number} frames frame count\n   * @return FfmpegCommand\n   */\n  proto.takeFrames =\n  proto.withFrames =\n  proto.frames = function(frames) {\n    this._currentOutput.video('-vframes', frames);\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/options/videosize.js",
    "content": "/*jshint node:true*/\n'use strict';\n\n/*\n *! Size helpers\n */\n\n\n/**\n * Return filters to pad video to width*height,\n *\n * @param {Number} width output width\n * @param {Number} height output height\n * @param {Number} aspect video aspect ratio (without padding)\n * @param {Number} color padding color\n * @return scale/pad filters\n * @private\n */\nfunction getScalePadFilters(width, height, aspect, color) {\n  /*\n    let a be the input aspect ratio, A be the requested aspect ratio\n\n    if a > A, padding is done on top and bottom\n    if a < A, padding is done on left and right\n   */\n\n  return [\n    /*\n      In both cases, we first have to scale the input to match the requested size.\n      When using computed width/height, we truncate them to multiples of 2\n     */\n    {\n      filter: 'scale',\n      options: {\n        w: 'if(gt(a,' + aspect + '),' + width + ',trunc(' + height + '*a/2)*2)',\n        h: 'if(lt(a,' + aspect + '),' + height + ',trunc(' + width + '/a/2)*2)'\n      }\n    },\n\n    /*\n      Then we pad the scaled input to match the target size\n      (here iw and ih refer to the padding input, i.e the scaled output)\n     */\n\n    {\n      filter: 'pad',\n      options: {\n        w: width,\n        h: height,\n        x: 'if(gt(a,' + aspect + '),0,(' + width + '-iw)/2)',\n        y: 'if(lt(a,' + aspect + '),0,(' + height + '-ih)/2)',\n        color: color\n      }\n    }\n  ];\n}\n\n\n/**\n * Recompute size filters\n *\n * @param {Object} output\n * @param {String} key newly-added parameter name ('size', 'aspect' or 'pad')\n * @param {String} value newly-added parameter value\n * @return filter string array\n * @private\n */\nfunction createSizeFilters(output, key, value) {\n  // Store parameters\n  var data = output.sizeData = output.sizeData || {};\n  data[key] = value;\n\n  if (!('size' in data)) {\n    // No size requested, keep original size\n    return [];\n  }\n\n  // Try to match the different size string formats\n  var fixedSize = data.size.match(/([0-9]+)x([0-9]+)/);\n  var fixedWidth = data.size.match(/([0-9]+)x\\?/);\n  var fixedHeight = data.size.match(/\\?x([0-9]+)/);\n  var percentRatio = data.size.match(/\\b([0-9]{1,3})%/);\n  var width, height, aspect;\n\n  if (percentRatio) {\n    var ratio = Number(percentRatio[1]) / 100;\n    return [{\n      filter: 'scale',\n      options: {\n        w: 'trunc(iw*' + ratio + '/2)*2',\n        h: 'trunc(ih*' + ratio + '/2)*2'\n      }\n    }];\n  } else if (fixedSize) {\n    // Round target size to multiples of 2\n    width = Math.round(Number(fixedSize[1]) / 2) * 2;\n    height = Math.round(Number(fixedSize[2]) / 2) * 2;\n\n    aspect = width / height;\n\n    if (data.pad) {\n      return getScalePadFilters(width, height, aspect, data.pad);\n    } else {\n      // No autopad requested, rescale to target size\n      return [{ filter: 'scale', options: { w: width, h: height }}];\n    }\n  } else if (fixedWidth || fixedHeight) {\n    if ('aspect' in data) {\n      // Specified aspect ratio\n      width = fixedWidth ? fixedWidth[1] : Math.round(Number(fixedHeight[1]) * data.aspect);\n      height = fixedHeight ? fixedHeight[1] : Math.round(Number(fixedWidth[1]) / data.aspect);\n\n      // Round to multiples of 2\n      width = Math.round(width / 2) * 2;\n      height = Math.round(height / 2) * 2;\n\n      if (data.pad) {\n        return getScalePadFilters(width, height, data.aspect, data.pad);\n      } else {\n        // No autopad requested, rescale to target size\n        return [{ filter: 'scale', options: { w: width, h: height }}];\n      }\n    } else {\n      // Keep input aspect ratio\n\n      if (fixedWidth) {\n        return [{\n          filter: 'scale',\n          options: {\n            w: Math.round(Number(fixedWidth[1]) / 2) * 2,\n            h: 'trunc(ow/a/2)*2'\n          }\n        }];\n      } else {\n        return [{\n          filter: 'scale',\n          options: {\n            w: 'trunc(oh*a/2)*2',\n            h: Math.round(Number(fixedHeight[1]) / 2) * 2\n          }\n        }];\n      }\n    }\n  } else {\n    throw new Error('Invalid size specified: ' + data.size);\n  }\n}\n\n\n/*\n *! Video size-related methods\n */\n\nmodule.exports = function(proto) {\n  /**\n   * Keep display aspect ratio\n   *\n   * This method is useful when converting an input with non-square pixels to an output format\n   * that does not support non-square pixels.  It rescales the input so that the display aspect\n   * ratio is the same.\n   *\n   * @method FfmpegCommand#keepDAR\n   * @category Video size\n   * @aliases keepPixelAspect,keepDisplayAspect,keepDisplayAspectRatio\n   *\n   * @return FfmpegCommand\n   */\n  proto.keepPixelAspect = // Only for compatibility, this is not about keeping _pixel_ aspect ratio\n  proto.keepDisplayAspect =\n  proto.keepDisplayAspectRatio =\n  proto.keepDAR = function() {\n    return this.videoFilters([\n      {\n        filter: 'scale',\n        options: {\n          w: 'if(gt(sar,1),iw*sar,iw)',\n          h: 'if(lt(sar,1),ih/sar,ih)'\n        }\n      },\n      {\n        filter: 'setsar',\n        options: '1'\n      }\n    ]);\n  };\n\n\n  /**\n   * Set output size\n   *\n   * The 'size' parameter can have one of 4 forms:\n   * - 'X%': rescale to xx % of the original size\n   * - 'WxH': specify width and height\n   * - 'Wx?': specify width and compute height from input aspect ratio\n   * - '?xH': specify height and compute width from input aspect ratio\n   *\n   * Note: both dimensions will be truncated to multiples of 2.\n   *\n   * @method FfmpegCommand#size\n   * @category Video size\n   * @aliases withSize,setSize\n   *\n   * @param {String} size size string, eg. '33%', '320x240', '320x?', '?x240'\n   * @return FfmpegCommand\n   */\n  proto.withSize =\n  proto.setSize =\n  proto.size = function(size) {\n    var filters = createSizeFilters(this._currentOutput, 'size', size);\n\n    this._currentOutput.sizeFilters.clear();\n    this._currentOutput.sizeFilters(filters);\n\n    return this;\n  };\n\n\n  /**\n   * Set output aspect ratio\n   *\n   * @method FfmpegCommand#aspect\n   * @category Video size\n   * @aliases withAspect,withAspectRatio,setAspect,setAspectRatio,aspectRatio\n   *\n   * @param {String|Number} aspect aspect ratio (number or 'X:Y' string)\n   * @return FfmpegCommand\n   */\n  proto.withAspect =\n  proto.withAspectRatio =\n  proto.setAspect =\n  proto.setAspectRatio =\n  proto.aspect =\n  proto.aspectRatio = function(aspect) {\n    var a = Number(aspect);\n    if (isNaN(a)) {\n      var match = aspect.match(/^(\\d+):(\\d+)$/);\n      if (match) {\n        a = Number(match[1]) / Number(match[2]);\n      } else {\n        throw new Error('Invalid aspect ratio: ' + aspect);\n      }\n    }\n\n    var filters = createSizeFilters(this._currentOutput, 'aspect', a);\n\n    this._currentOutput.sizeFilters.clear();\n    this._currentOutput.sizeFilters(filters);\n\n    return this;\n  };\n\n\n  /**\n   * Enable auto-padding the output\n   *\n   * @method FfmpegCommand#autopad\n   * @category Video size\n   * @aliases applyAutopadding,applyAutoPadding,applyAutopad,applyAutoPad,withAutopadding,withAutoPadding,withAutopad,withAutoPad,autoPad\n   *\n   * @param {Boolean} [pad=true] enable/disable auto-padding\n   * @param {String} [color='black'] pad color\n   */\n  proto.applyAutopadding =\n  proto.applyAutoPadding =\n  proto.applyAutopad =\n  proto.applyAutoPad =\n  proto.withAutopadding =\n  proto.withAutoPadding =\n  proto.withAutopad =\n  proto.withAutoPad =\n  proto.autoPad =\n  proto.autopad = function(pad, color) {\n    // Allow autopad(color)\n    if (typeof pad === 'string') {\n      color = pad;\n      pad = true;\n    }\n\n    // Allow autopad() and autopad(undefined, color)\n    if (typeof pad === 'undefined') {\n      pad = true;\n    }\n\n    var filters = createSizeFilters(this._currentOutput, 'pad', pad ? color || 'black' : false);\n\n    this._currentOutput.sizeFilters.clear();\n    this._currentOutput.sizeFilters(filters);\n\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/presets/divx.js",
    "content": "/*jshint node:true */\n'use strict';\n\nexports.load = function(ffmpeg) {\n  ffmpeg\n    .format('avi')\n    .videoBitrate('1024k')\n    .videoCodec('mpeg4')\n    .size('720x?')\n    .audioBitrate('128k')\n    .audioChannels(2)\n    .audioCodec('libmp3lame')\n    .outputOptions(['-vtag DIVX']);\n};"
  },
  {
    "path": "server/libs/fluentFfmpeg/presets/flashvideo.js",
    "content": "/*jshint node:true */\n'use strict';\n\nexports.load = function(ffmpeg) {\n  ffmpeg\n    .format('flv')\n    .flvmeta()\n    .size('320x?')\n    .videoBitrate('512k')\n    .videoCodec('libx264')\n    .fps(24)\n    .audioBitrate('96k')\n    .audioCodec('aac')\n    .audioFrequency(22050)\n    .audioChannels(2);\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/presets/podcast.js",
    "content": "/*jshint node:true */\n'use strict';\n\nexports.load = function(ffmpeg) {\n  ffmpeg\n    .format('m4v')\n    .videoBitrate('512k')\n    .videoCodec('libx264')\n    .size('320x176')\n    .audioBitrate('128k')\n    .audioCodec('aac')\n    .audioChannels(1)\n    .outputOptions(['-flags', '+loop', '-cmp', '+chroma', '-partitions','+parti4x4+partp8x8+partb8x8', '-flags2',\n      '+mixed_refs', '-me_method umh', '-subq 5', '-bufsize 2M', '-rc_eq \\'blurCplx^(1-qComp)\\'',\n      '-qcomp 0.6', '-qmin 10', '-qmax 51', '-qdiff 4', '-level 13' ]);\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/processor.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar spawn = require('child_process').spawn;\nvar async = require('../async');\nvar utils = require('./utils');\n\n/*\n *! Processor methods\n */\n\n\n/**\n * Run ffprobe asynchronously and store data in command\n *\n * @param {FfmpegCommand} command\n * @private\n */\nfunction runFfprobe(command) {\n  const inputProbeIndex = 0;\n  if (command._inputs[inputProbeIndex].isStream) {\n    // Don't probe input streams as this will consume them\n    return;\n  }\n  command.ffprobe(inputProbeIndex, function (err, data) {\n    command._ffprobeData = data;\n  });\n}\n\n\nmodule.exports = function (proto) {\n  /**\n   * Emitted just after ffmpeg has been spawned.\n   *\n   * @event FfmpegCommand#start\n   * @param {String} command ffmpeg command line\n   */\n\n  /**\n   * Emitted when ffmpeg reports progress information\n   *\n   * @event FfmpegCommand#progress\n   * @param {Object} progress progress object\n   * @param {Number} progress.frames number of frames transcoded\n   * @param {Number} progress.currentFps current processing speed in frames per second\n   * @param {Number} progress.currentKbps current output generation speed in kilobytes per second\n   * @param {Number} progress.targetSize current output file size\n   * @param {String} progress.timemark current video timemark\n   * @param {Number} [progress.percent] processing progress (may not be available depending on input)\n   */\n\n  /**\n   * Emitted when ffmpeg outputs to stderr\n   *\n   * @event FfmpegCommand#stderr\n   * @param {String} line stderr output line\n   */\n\n  /**\n   * Emitted when ffmpeg reports input codec data\n   *\n   * @event FfmpegCommand#codecData\n   * @param {Object} codecData codec data object\n   * @param {String} codecData.format input format name\n   * @param {String} codecData.audio input audio codec name\n   * @param {String} codecData.audio_details input audio codec parameters\n   * @param {String} codecData.video input video codec name\n   * @param {String} codecData.video_details input video codec parameters\n   */\n\n  /**\n   * Emitted when an error happens when preparing or running a command\n   *\n   * @event FfmpegCommand#error\n   * @param {Error} error error object, with optional properties 'inputStreamError' / 'outputStreamError' for errors on their respective streams\n   * @param {String|null} stdout ffmpeg stdout, unless outputting to a stream\n   * @param {String|null} stderr ffmpeg stderr\n   */\n\n  /**\n   * Emitted when a command finishes processing\n   *\n   * @event FfmpegCommand#end\n   * @param {Array|String|null} [filenames|stdout] generated filenames when taking screenshots, ffmpeg stdout when not outputting to a stream, null otherwise\n   * @param {String|null} stderr ffmpeg stderr\n   */\n\n\n  /**\n   * Spawn an ffmpeg process\n   *\n   * The 'options' argument may contain the following keys:\n   * - 'niceness': specify process niceness, ignored on Windows (default: 0)\n   * - `cwd`: change working directory\n   * - 'captureStdout': capture stdout and pass it to 'endCB' as its 2nd argument (default: false)\n   * - 'stdoutLines': override command limit (default: use command limit)\n   *\n   * The 'processCB' callback, if present, is called as soon as the process is created and\n   * receives a nodejs ChildProcess object.  It may not be called at all if an error happens\n   * before spawning the process.\n   *\n   * The 'endCB' callback is called either when an error occurs or when the ffmpeg process finishes.\n   *\n   * @method FfmpegCommand#_spawnFfmpeg\n   * @param {Array} args ffmpeg command line argument list\n   * @param {Object} [options] spawn options (see above)\n   * @param {Function} [processCB] callback called with process object and stdout/stderr ring buffers when process has been created\n   * @param {Function} endCB callback called with error (if applicable) and stdout/stderr ring buffers when process finished\n   * @private\n   */\n  proto._spawnFfmpeg = function (args, options, processCB, endCB) {\n    // Enable omitting options\n    if (typeof options === 'function') {\n      endCB = processCB;\n      processCB = options;\n      options = {};\n    }\n\n    // Enable omitting processCB\n    if (typeof endCB === 'undefined') {\n      endCB = processCB;\n      processCB = function () { };\n    }\n\n    var maxLines = 'stdoutLines' in options ? options.stdoutLines : this.options.stdoutLines;\n\n    // Find ffmpeg\n    this._getFfmpegPath(function (err, command) {\n      if (err) {\n        return endCB(err);\n      } else if (!command || command.length === 0) {\n        return endCB(new Error('Cannot find ffmpeg'));\n      }\n\n      // Apply niceness\n      if (options.niceness && options.niceness !== 0 && !utils.isWindows) {\n        args.unshift('-n', options.niceness, command);\n        command = 'nice';\n      }\n\n      var stdoutRing = utils.linesRing(maxLines);\n      var stdoutClosed = false;\n\n      var stderrRing = utils.linesRing(maxLines);\n      var stderrClosed = false;\n\n      // Spawn process\n      var ffmpegProc = spawn(command, args, options);\n\n      if (ffmpegProc.stderr) {\n        ffmpegProc.stderr.setEncoding('utf8');\n      }\n\n      ffmpegProc.on('error', function (err) {\n        endCB(err);\n      });\n\n      // Ensure we wait for captured streams to end before calling endCB\n      var exitError = null;\n      function handleExit(err) {\n        if (err) {\n          exitError = err;\n        }\n\n        if (processExited && (stdoutClosed || !options.captureStdout) && stderrClosed) {\n          endCB(exitError, stdoutRing, stderrRing);\n        }\n      }\n\n      // Handle process exit\n      var processExited = false;\n      ffmpegProc.on('exit', function (code, signal) {\n        processExited = true;\n\n        if (signal) {\n          handleExit(new Error('ffmpeg was killed with signal ' + signal));\n        } else if (code) {\n          handleExit(new Error('ffmpeg exited with code ' + code));\n        } else {\n          handleExit();\n        }\n      });\n\n      // Capture stdout if specified\n      if (options.captureStdout) {\n        ffmpegProc.stdout.on('data', function (data) {\n          stdoutRing.append(data);\n        });\n\n        ffmpegProc.stdout.on('close', function () {\n          stdoutRing.close();\n          stdoutClosed = true;\n          handleExit();\n        });\n      }\n\n      // Capture stderr if specified\n      ffmpegProc.stderr.on('data', function (data) {\n        stderrRing.append(data);\n      });\n\n      ffmpegProc.stderr.on('close', function () {\n        stderrRing.close();\n        stderrClosed = true;\n        handleExit();\n      });\n\n      // Call process callback\n      processCB(ffmpegProc, stdoutRing, stderrRing);\n    });\n  };\n\n\n  /**\n   * Build the argument list for an ffmpeg command\n   *\n   * @method FfmpegCommand#_getArguments\n   * @return argument list\n   * @private\n   */\n  proto._getArguments = function () {\n    var complexFilters = this._complexFilters.get();\n\n    var fileOutput = this._outputs.some(function (output) {\n      return output.isFile;\n    });\n\n    return [].concat(\n      // Inputs and input options\n      this._inputs.reduce(function (args, input) {\n        var source = (typeof input.source === 'string') ? input.source : 'pipe:0';\n\n        // For each input, add input options, then '-i <source>'\n        return args.concat(\n          input.options.get(),\n          ['-i', source]\n        );\n      }, []),\n\n      // Global options\n      this._global.get(),\n\n      // Overwrite if we have file outputs\n      fileOutput ? ['-y'] : [],\n\n      // Complex filters\n      complexFilters,\n\n      // Outputs, filters and output options\n      this._outputs.reduce(function (args, output) {\n        var sizeFilters = utils.makeFilterStrings(output.sizeFilters.get());\n        var audioFilters = output.audioFilters.get();\n        var videoFilters = output.videoFilters.get().concat(sizeFilters);\n        var outputArg;\n\n        if (!output.target) {\n          outputArg = [];\n        } else if (typeof output.target === 'string') {\n          outputArg = [output.target];\n        } else {\n          outputArg = ['pipe:1'];\n        }\n\n        return args.concat(\n          output.audio.get(),\n          audioFilters.length ? ['-filter:a', audioFilters.join(',')] : [],\n          output.video.get(),\n          videoFilters.length ? ['-filter:v', videoFilters.join(',')] : [],\n          output.options.get(),\n          outputArg\n        );\n      }, [])\n    );\n  };\n\n\n  /**\n   * Prepare execution of an ffmpeg command\n   *\n   * Checks prerequisites for the execution of the command (codec/format availability, flvtool...),\n   * then builds the argument list for ffmpeg and pass them to 'callback'.\n   *\n   * @method FfmpegCommand#_prepare\n   * @param {Function} callback callback with signature (err, args)\n   * @param {Boolean} [readMetadata=false] read metadata before processing\n   * @private\n   */\n  proto._prepare = function (callback, readMetadata) {\n    var self = this;\n\n    async.waterfall([\n      // Check codecs and formats\n      function (cb) {\n        self._checkCapabilities(cb);\n      },\n\n      // Read metadata if required\n      function (cb) {\n        if (!readMetadata) {\n          return cb();\n        }\n\n        self.ffprobe(0, function (err, data) {\n          if (!err) {\n            self._ffprobeData = data;\n          }\n\n          cb();\n        });\n      },\n\n      // Check for flvtool2/flvmeta if necessary\n      function (cb) {\n        var flvmeta = self._outputs.some(function (output) {\n          // Remove flvmeta flag on non-file output\n          if (output.flags.flvmeta && !output.isFile) {\n            self.logger.warn('Updating flv metadata is only supported for files');\n            output.flags.flvmeta = false;\n          }\n\n          return output.flags.flvmeta;\n        });\n\n        if (flvmeta) {\n          self._getFlvtoolPath(function (err) {\n            cb(err);\n          });\n        } else {\n          cb();\n        }\n      },\n\n      // Build argument list\n      function (cb) {\n        var args;\n        try {\n          args = self._getArguments();\n        } catch (e) {\n          return cb(e);\n        }\n\n        cb(null, args);\n      },\n\n      // Add \"-strict experimental\" option where needed\n      function (args, cb) {\n        self.availableEncoders(function (err, encoders) {\n          for (var i = 0; i < args.length; i++) {\n            if (args[i] === '-acodec' || args[i] === '-vcodec') {\n              i++;\n\n              if ((args[i] in encoders) && encoders[args[i]].experimental) {\n                args.splice(i + 1, 0, '-strict', 'experimental');\n                i += 2;\n              }\n            }\n          }\n\n          cb(null, args);\n        });\n      }\n    ], callback);\n\n    if (!readMetadata) {\n      // Read metadata as soon as 'progress' listeners are added\n\n      if (this.listeners('progress').length > 0) {\n        // Read metadata in parallel\n        runFfprobe(this);\n      } else {\n        // Read metadata as soon as the first 'progress' listener is added\n        this.once('newListener', function (event) {\n          if (event === 'progress') {\n            runFfprobe(this);\n          }\n        });\n      }\n    }\n  };\n\n\n  /**\n   * Run ffmpeg command\n   *\n   * @method FfmpegCommand#run\n   * @category Processing\n   * @aliases exec,execute\n   */\n  proto.exec =\n    proto.execute =\n    proto.run = function () {\n      var self = this;\n\n      // Check if at least one output is present\n      var outputPresent = this._outputs.some(function (output) {\n        return 'target' in output;\n      });\n\n      if (!outputPresent) {\n        throw new Error('No output specified');\n      }\n\n      // Get output stream if any\n      var outputStream = this._outputs.filter(function (output) {\n        return typeof output.target !== 'string';\n      })[0];\n\n      // Get input stream if any\n      var inputStream = this._inputs.filter(function (input) {\n        return typeof input.source !== 'string';\n      })[0];\n\n      // Ensure we send 'end' or 'error' only once\n      var ended = false;\n      function emitEnd(err, stdout, stderr) {\n        if (!ended) {\n          ended = true;\n\n          if (err) {\n            self.emit('error', err, stdout, stderr);\n          } else {\n            self.emit('end', stdout, stderr);\n          }\n        }\n      }\n\n      self._prepare(function (err, args) {\n        if (err) {\n          return emitEnd(err);\n        }\n\n        // Run ffmpeg\n        self._spawnFfmpeg(\n          args,\n          {\n            captureStdout: !outputStream,\n            niceness: self.options.niceness,\n            cwd: self.options.cwd,\n            windowsHide: true\n          },\n\n          function processCB(ffmpegProc, stdoutRing, stderrRing) {\n            self.ffmpegProc = ffmpegProc;\n            self.emit('start', 'ffmpeg ' + args.join(' '));\n\n            // Pipe input stream if any\n            if (inputStream) {\n              inputStream.source.on('error', function (err) {\n                var reportingErr = new Error('Input stream error: ' + err.message);\n                reportingErr.inputStreamError = err;\n                emitEnd(reportingErr);\n                ffmpegProc.kill();\n              });\n\n              inputStream.source.resume();\n              inputStream.source.pipe(ffmpegProc.stdin);\n\n              // Set stdin error handler on ffmpeg (prevents nodejs catching the error, but\n              // ffmpeg will fail anyway, so no need to actually handle anything)\n              ffmpegProc.stdin.on('error', function () { });\n            }\n\n            // Setup timeout if requested\n            if (self.options.timeout) {\n              self.processTimer = setTimeout(function () {\n                var msg = 'process ran into a timeout (' + self.options.timeout + 's)';\n\n                emitEnd(new Error(msg), stdoutRing.get(), stderrRing.get());\n                ffmpegProc.kill();\n              }, self.options.timeout * 1000);\n            }\n\n\n            if (outputStream) {\n              // Pipe ffmpeg stdout to output stream\n              ffmpegProc.stdout.pipe(outputStream.target, outputStream.pipeopts);\n\n              // Handle output stream events\n              outputStream.target.on('close', function () {\n                self.logger.debug('Output stream closed, scheduling kill for ffmpeg process');\n\n                // Don't kill process yet, to give a chance to ffmpeg to\n                // terminate successfully first  This is necessary because\n                // under load, the process 'exit' event sometimes happens\n                // after the output stream 'close' event.\n                setTimeout(function () {\n                  emitEnd(new Error('Output stream closed'));\n                  ffmpegProc.kill();\n                }, 20);\n              });\n\n              outputStream.target.on('error', function (err) {\n                self.logger.debug('Output stream error, killing ffmpeg process');\n                var reportingErr = new Error('Output stream error: ' + err.message);\n                reportingErr.outputStreamError = err;\n                emitEnd(reportingErr, stdoutRing.get(), stderrRing.get());\n                ffmpegProc.kill('SIGKILL');\n              });\n            }\n\n            // Setup stderr handling\n            if (stderrRing) {\n\n              // 'stderr' event\n              if (self.listeners('stderr').length) {\n                stderrRing.callback(function (line) {\n                  self.emit('stderr', line);\n                });\n              }\n\n              // 'codecData' event\n              if (self.listeners('codecData').length) {\n                var codecDataSent = false;\n                var codecObject = {};\n\n                stderrRing.callback(function (line) {\n                  if (!codecDataSent)\n                    codecDataSent = utils.extractCodecData(self, line, codecObject);\n                });\n              }\n\n              // 'progress' event\n              if (self.listeners('progress').length) {\n                stderrRing.callback(function (line) {\n                  utils.extractProgress(self, line);\n                });\n              }\n            }\n          },\n\n          function endCB(err, stdoutRing, stderrRing) {\n            clearTimeout(self.processTimer);\n            delete self.ffmpegProc;\n\n            if (err) {\n              if (err.message.match(/ffmpeg exited with code/)) {\n                // Add ffmpeg error message\n                err.message += ': ' + utils.extractError(stderrRing.get());\n              }\n\n              emitEnd(err, stdoutRing.get(), stderrRing.get());\n            } else {\n              // Find out which outputs need flv metadata\n              var flvmeta = self._outputs.filter(function (output) {\n                return output.flags.flvmeta;\n              });\n\n              if (flvmeta.length) {\n                self._getFlvtoolPath(function (err, flvtool) {\n                  if (err) {\n                    return emitEnd(err);\n                  }\n\n                  async.each(\n                    flvmeta,\n                    function (output, cb) {\n                      spawn(flvtool, ['-U', output.target], { windowsHide: true })\n                        .on('error', function (err) {\n                          cb(new Error('Error running ' + flvtool + ' on ' + output.target + ': ' + err.message));\n                        })\n                        .on('exit', function (code, signal) {\n                          if (code !== 0 || signal) {\n                            cb(\n                              new Error(flvtool + ' ' +\n                                (signal ? 'received signal ' + signal\n                                  : 'exited with code ' + code)) +\n                              ' when running on ' + output.target\n                            );\n                          } else {\n                            cb();\n                          }\n                        });\n                    },\n                    function (err) {\n                      if (err) {\n                        emitEnd(err);\n                      } else {\n                        emitEnd(null, stdoutRing.get(), stderrRing.get());\n                      }\n                    }\n                  );\n                });\n              } else {\n                emitEnd(null, stdoutRing.get(), stderrRing.get());\n              }\n            }\n          }\n        );\n      });\n\n      return this;\n    };\n\n\n  /**\n   * Renice current and/or future ffmpeg processes\n   *\n   * Ignored on Windows platforms.\n   *\n   * @method FfmpegCommand#renice\n   * @category Processing\n   *\n   * @param {Number} [niceness=0] niceness value between -20 (highest priority) and 20 (lowest priority)\n   * @return FfmpegCommand\n   */\n  proto.renice = function (niceness) {\n    if (!utils.isWindows) {\n      niceness = niceness || 0;\n\n      if (niceness < -20 || niceness > 20) {\n        this.logger.warn('Invalid niceness value: ' + niceness + ', must be between -20 and 20');\n      }\n\n      niceness = Math.min(20, Math.max(-20, niceness));\n      this.options.niceness = niceness;\n\n      if (this.ffmpegProc) {\n        var logger = this.logger;\n        var pid = this.ffmpegProc.pid;\n        var renice = spawn('renice', [niceness, '-p', pid], { windowsHide: true });\n\n        renice.on('error', function (err) {\n          logger.warn('could not renice process ' + pid + ': ' + err.message);\n        });\n\n        renice.on('exit', function (code, signal) {\n          if (signal) {\n            logger.warn('could not renice process ' + pid + ': renice was killed by signal ' + signal);\n          } else if (code) {\n            logger.warn('could not renice process ' + pid + ': renice exited with ' + code);\n          } else {\n            logger.info('successfully reniced process ' + pid + ' to ' + niceness + ' niceness');\n          }\n        });\n      }\n    }\n\n    return this;\n  };\n\n\n  /**\n   * Kill current ffmpeg process, if any\n   *\n   * @method FfmpegCommand#kill\n   * @category Processing\n   *\n   * @param {String} [signal=SIGKILL] signal name\n   * @return FfmpegCommand\n   */\n  proto.kill = function (signal) {\n    if (!this.ffmpegProc) {\n      this.logger.warn('No running ffmpeg process, cannot send signal');\n    } else {\n      this.ffmpegProc.kill(signal || 'SIGKILL');\n    }\n\n    return this;\n  };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/recipes.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar fs = require('fs');\nvar path = require('path');\nvar PassThrough = require('stream').PassThrough;\nvar async = require('../async');\nvar utils = require('./utils');\n\n\n/*\n * Useful recipes for commands\n */\n\nmodule.exports = function recipes(proto) {\n  /**\n   * Execute ffmpeg command and save output to a file\n   *\n   * @method FfmpegCommand#save\n   * @category Processing\n   * @aliases saveToFile\n   *\n   * @param {String} output file path\n   * @return FfmpegCommand\n   */\n  proto.saveToFile =\n    proto.save = function (output) {\n      this.output(output).run();\n      return this;\n    };\n\n\n  /**\n   * Execute ffmpeg command and save output to a stream\n   *\n   * If 'stream' is not specified, a PassThrough stream is created and returned.\n   * 'options' will be used when piping ffmpeg output to the output stream\n   * (@see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options)\n   *\n   * @method FfmpegCommand#pipe\n   * @category Processing\n   * @aliases stream,writeToStream\n   *\n   * @param {stream.Writable} [stream] output stream\n   * @param {Object} [options={}] pipe options\n   * @return Output stream\n   */\n  proto.writeToStream =\n    proto.pipe =\n    proto.stream = function (stream, options) {\n      if (stream && !('writable' in stream)) {\n        options = stream;\n        stream = undefined;\n      }\n\n      if (!stream) {\n        if (process.version.match(/v0\\.8\\./)) {\n          throw new Error('PassThrough stream is not supported on node v0.8');\n        }\n\n        stream = new PassThrough();\n      }\n\n      this.output(stream, options).run();\n      return stream;\n    };\n\n\n  /**\n   * Generate images from a video\n   *\n   * Note: this method makes the command emit a 'filenames' event with an array of\n   * the generated image filenames.\n   *\n   * @method FfmpegCommand#screenshots\n   * @category Processing\n   * @aliases takeScreenshots,thumbnail,thumbnails,screenshot\n   *\n   * @param {Number|Object} [config=1] screenshot count or configuration object with\n   *   the following keys:\n   * @param {Number} [config.count] number of screenshots to take; using this option\n   *   takes screenshots at regular intervals (eg. count=4 would take screens at 20%, 40%,\n   *   60% and 80% of the video length).\n   * @param {String} [config.folder='.'] output folder\n   * @param {String} [config.filename='tn.png'] output filename pattern, may contain the following\n   *   tokens:\n   *   - '%s': offset in seconds\n   *   - '%w': screenshot width\n   *   - '%h': screenshot height\n   *   - '%r': screenshot resolution (same as '%wx%h')\n   *   - '%f': input filename\n   *   - '%b': input basename (filename w/o extension)\n   *   - '%i': index of screenshot in timemark array (can be zero-padded by using it like `%000i`)\n   * @param {Number[]|String[]} [config.timemarks] array of timemarks to take screenshots\n   *   at; each timemark may be a number of seconds, a '[[hh:]mm:]ss[.xxx]' string or a\n   *   'XX%' string.  Overrides 'count' if present.\n   * @param {Number[]|String[]} [config.timestamps] alias for 'timemarks'\n   * @param {Boolean} [config.fastSeek] use fast seek (less accurate)\n   * @param {String} [config.size] screenshot size, with the same syntax as {@link FfmpegCommand#size}\n   * @param {String} [folder] output folder (legacy alias for 'config.folder')\n   * @return FfmpegCommand\n   */\n  proto.takeScreenshots =\n    proto.thumbnail =\n    proto.thumbnails =\n    proto.screenshot =\n    proto.screenshots = function (config, folder) {\n      var self = this;\n      var source = this._currentInput.source;\n      config = config || { count: 1 };\n\n      // Accept a number of screenshots instead of a config object\n      if (typeof config === 'number') {\n        config = {\n          count: config\n        };\n      }\n\n      // Accept a second 'folder' parameter instead of config.folder\n      if (!('folder' in config)) {\n        config.folder = folder || '.';\n      }\n\n      // Accept 'timestamps' instead of 'timemarks'\n      if ('timestamps' in config) {\n        config.timemarks = config.timestamps;\n      }\n\n      // Compute timemarks from count if not present\n      if (!('timemarks' in config)) {\n        if (!config.count) {\n          throw new Error('Cannot take screenshots: neither a count nor a timemark list are specified');\n        }\n\n        var interval = 100 / (1 + config.count);\n        config.timemarks = [];\n        for (var i = 0; i < config.count; i++) {\n          config.timemarks.push((interval * (i + 1)) + '%');\n        }\n      }\n\n      // Parse size option\n      if ('size' in config) {\n        var fixedSize = config.size.match(/^(\\d+)x(\\d+)$/);\n        var fixedWidth = config.size.match(/^(\\d+)x\\?$/);\n        var fixedHeight = config.size.match(/^\\?x(\\d+)$/);\n        var percentSize = config.size.match(/^(\\d+)%$/);\n\n        if (!fixedSize && !fixedWidth && !fixedHeight && !percentSize) {\n          throw new Error('Invalid size parameter: ' + config.size);\n        }\n      }\n\n      // Metadata helper\n      var metadata;\n      function getMetadata(cb) {\n        if (metadata) {\n          cb(null, metadata);\n        } else {\n          self.ffprobe(function (err, meta) {\n            metadata = meta;\n            cb(err, meta);\n          });\n        }\n      }\n\n      async.waterfall([\n        // Compute percent timemarks if any\n        function computeTimemarks(next) {\n          if (config.timemarks.some(function (t) { return ('' + t).match(/^[\\d.]+%$/); })) {\n            if (typeof source !== 'string') {\n              return next(new Error('Cannot compute screenshot timemarks with an input stream, please specify fixed timemarks'));\n            }\n\n            getMetadata(function (err, meta) {\n              if (err) {\n                next(err);\n              } else {\n                // Select video stream with the highest resolution\n                var vstream = meta.streams.reduce(function (biggest, stream) {\n                  if (stream.codec_type === 'video' && stream.width * stream.height > biggest.width * biggest.height) {\n                    return stream;\n                  } else {\n                    return biggest;\n                  }\n                }, { width: 0, height: 0 });\n\n                if (vstream.width === 0) {\n                  return next(new Error('No video stream in input, cannot take screenshots'));\n                }\n\n                var duration = Number(vstream.duration);\n                if (isNaN(duration)) {\n                  duration = Number(meta.format.duration);\n                }\n\n                if (isNaN(duration)) {\n                  return next(new Error('Could not get input duration, please specify fixed timemarks'));\n                }\n\n                config.timemarks = config.timemarks.map(function (mark) {\n                  if (('' + mark).match(/^([\\d.]+)%$/)) {\n                    return duration * parseFloat(mark) / 100;\n                  } else {\n                    return mark;\n                  }\n                });\n\n                next();\n              }\n            });\n          } else {\n            next();\n          }\n        },\n\n        // Turn all timemarks into numbers and sort them\n        function normalizeTimemarks(next) {\n          config.timemarks = config.timemarks.map(function (mark) {\n            return utils.timemarkToSeconds(mark);\n          }).sort(function (a, b) { return a - b; });\n\n          next();\n        },\n\n        // Add '_%i' to pattern when requesting multiple screenshots and no variable token is present\n        function fixPattern(next) {\n          var pattern = config.filename || 'tn.png';\n\n          if (pattern.indexOf('.') === -1) {\n            pattern += '.png';\n          }\n\n          if (config.timemarks.length > 1 && !pattern.match(/%(s|0*i)/)) {\n            var ext = path.extname(pattern);\n            pattern = path.join(path.dirname(pattern), path.basename(pattern, ext) + '_%i' + ext);\n          }\n\n          next(null, pattern);\n        },\n\n        // Replace filename tokens (%f, %b) in pattern\n        function replaceFilenameTokens(pattern, next) {\n          if (pattern.match(/%[bf]/)) {\n            if (typeof source !== 'string') {\n              return next(new Error('Cannot replace %f or %b when using an input stream'));\n            }\n\n            pattern = pattern\n              .replace(/%f/g, path.basename(source))\n              .replace(/%b/g, path.basename(source, path.extname(source)));\n          }\n\n          next(null, pattern);\n        },\n\n        // Compute size if needed\n        function getSize(pattern, next) {\n          if (pattern.match(/%[whr]/)) {\n            if (fixedSize) {\n              return next(null, pattern, fixedSize[1], fixedSize[2]);\n            }\n\n            getMetadata(function (err, meta) {\n              if (err) {\n                return next(new Error('Could not determine video resolution to replace %w, %h or %r'));\n              }\n\n              var vstream = meta.streams.reduce(function (biggest, stream) {\n                if (stream.codec_type === 'video' && stream.width * stream.height > biggest.width * biggest.height) {\n                  return stream;\n                } else {\n                  return biggest;\n                }\n              }, { width: 0, height: 0 });\n\n              if (vstream.width === 0) {\n                return next(new Error('No video stream in input, cannot replace %w, %h or %r'));\n              }\n\n              var width = vstream.width;\n              var height = vstream.height;\n\n              if (fixedWidth) {\n                height = height * Number(fixedWidth[1]) / width;\n                width = Number(fixedWidth[1]);\n              } else if (fixedHeight) {\n                width = width * Number(fixedHeight[1]) / height;\n                height = Number(fixedHeight[1]);\n              } else if (percentSize) {\n                width = width * Number(percentSize[1]) / 100;\n                height = height * Number(percentSize[1]) / 100;\n              }\n\n              next(null, pattern, Math.round(width / 2) * 2, Math.round(height / 2) * 2);\n            });\n          } else {\n            next(null, pattern, -1, -1);\n          }\n        },\n\n        // Replace size tokens (%w, %h, %r) in pattern\n        function replaceSizeTokens(pattern, width, height, next) {\n          pattern = pattern\n            .replace(/%r/g, '%wx%h')\n            .replace(/%w/g, width)\n            .replace(/%h/g, height);\n\n          next(null, pattern);\n        },\n\n        // Replace variable tokens in pattern (%s, %i) and generate filename list\n        function replaceVariableTokens(pattern, next) {\n          var filenames = config.timemarks.map(function (t, i) {\n            return pattern\n              .replace(/%s/g, utils.timemarkToSeconds(t))\n              .replace(/%(0*)i/g, function (match, padding) {\n                var idx = '' + (i + 1);\n                return padding.substr(0, Math.max(0, padding.length + 1 - idx.length)) + idx;\n              });\n          });\n\n          self.emit('filenames', filenames);\n          next(null, filenames);\n        },\n\n        // Create output directory\n        function createDirectory(filenames, next) {\n          fs.exists(config.folder, function (exists) {\n            if (!exists) {\n              fs.mkdir(config.folder, function (err) {\n                if (err) {\n                  next(err);\n                } else {\n                  next(null, filenames);\n                }\n              });\n            } else {\n              next(null, filenames);\n            }\n          });\n        }\n      ], function runCommand(err, filenames) {\n        if (err) {\n          return self.emit('error', err);\n        }\n\n        var count = config.timemarks.length;\n        var split;\n        var filters = [split = {\n          filter: 'split',\n          options: count,\n          outputs: []\n        }];\n\n        if ('size' in config) {\n          // Set size to generate size filters\n          self.size(config.size);\n\n          // Get size filters and chain them with 'sizeN' stream names\n          var sizeFilters = self._currentOutput.sizeFilters.get().map(function (f, i) {\n            if (i > 0) {\n              f.inputs = 'size' + (i - 1);\n            }\n\n            f.outputs = 'size' + i;\n\n            return f;\n          });\n\n          // Input last size filter output into split filter\n          split.inputs = 'size' + (sizeFilters.length - 1);\n\n          // Add size filters in front of split filter\n          filters = sizeFilters.concat(filters);\n\n          // Remove size filters\n          self._currentOutput.sizeFilters.clear();\n        }\n\n        var first = 0;\n        for (var i = 0; i < count; i++) {\n          var stream = 'screen' + i;\n          split.outputs.push(stream);\n\n          if (i === 0) {\n            first = config.timemarks[i];\n            self.seekInput(first);\n          }\n\n          self.output(path.join(config.folder, filenames[i]))\n            .frames(1)\n            .map(stream);\n\n          if (i > 0) {\n            self.seek(config.timemarks[i] - first);\n          }\n        }\n\n        self.complexFilter(filters);\n        self.run();\n      });\n\n      return this;\n    };\n\n\n  /**\n   * Merge (concatenate) inputs to a single file\n   *\n   * @method FfmpegCommand#concat\n   * @category Processing\n   * @aliases concatenate,mergeToFile\n   *\n   * @param {String|Writable} target output file or writable stream\n   * @param {Object} [options] pipe options (only used when outputting to a writable stream)\n   * @return FfmpegCommand\n   */\n  proto.mergeToFile =\n    proto.concatenate =\n    proto.concat = function (target, options) {\n      // Find out which streams are present in the first non-stream input\n      var fileInput = this._inputs.filter(function (input) {\n        return !input.isStream;\n      })[0];\n\n      var self = this;\n      this.ffprobe(this._inputs.indexOf(fileInput), function (err, data) {\n        if (err) {\n          return self.emit('error', err);\n        }\n\n        var hasAudioStreams = data.streams.some(function (stream) {\n          return stream.codec_type === 'audio';\n        });\n\n        var hasVideoStreams = data.streams.some(function (stream) {\n          return stream.codec_type === 'video';\n        });\n\n        // Setup concat filter and start processing\n        self.output(target, options)\n          .complexFilter({\n            filter: 'concat',\n            options: {\n              n: self._inputs.length,\n              v: hasVideoStreams ? 1 : 0,\n              a: hasAudioStreams ? 1 : 0\n            }\n          })\n          .run();\n      });\n\n      return this;\n    };\n};\n"
  },
  {
    "path": "server/libs/fluentFfmpeg/utils.js",
    "content": "/*jshint node:true*/\n'use strict';\n\nvar isWindows = require('os').platform().match(/win(32|64)/);\nvar which = require('../which');\n\nvar nlRegexp = /\\r\\n|\\r|\\n/g;\nvar streamRegexp = /^\\[?(.*?)\\]?$/;\nvar filterEscapeRegexp = /[,]/;\nvar whichCache = {};\n\n/**\n * Parse progress line from ffmpeg stderr\n *\n * @param {String} line progress line\n * @return progress object\n * @private\n */\nfunction parseProgressLine(line) {\n  var progress = {};\n\n  // Remove all spaces after = and trim\n  line = line.replace(/=\\s+/g, '=').trim();\n  var progressParts = line.split(' ');\n\n  // Split every progress part by \"=\" to get key and value\n  for (var i = 0; i < progressParts.length; i++) {\n    var progressSplit = progressParts[i].split('=', 2);\n    var key = progressSplit[0];\n    var value = progressSplit[1];\n\n    // This is not a progress line\n    if (typeof value === 'undefined')\n      return null;\n\n    progress[key] = value;\n  }\n\n  return progress;\n}\n\n\nvar utils = module.exports = {\n  isWindows: isWindows,\n  streamRegexp: streamRegexp,\n\n\n  /**\n   * Copy an object keys into another one\n   *\n   * @param {Object} source source object\n   * @param {Object} dest destination object\n   * @private\n   */\n  copy: function (source, dest) {\n    Object.keys(source).forEach(function (key) {\n      dest[key] = source[key];\n    });\n  },\n\n\n  /**\n   * Create an argument list\n   *\n   * Returns a function that adds new arguments to the list.\n   * It also has the following methods:\n   * - clear() empties the argument list\n   * - get() returns the argument list\n   * - find(arg, count) finds 'arg' in the list and return the following 'count' items, or undefined if not found\n   * - remove(arg, count) remove 'arg' in the list as well as the following 'count' items\n   *\n   * @private\n   */\n  args: function () {\n    var list = [];\n\n    // Append argument(s) to the list\n    var argfunc = function () {\n      if (arguments.length === 1 && Array.isArray(arguments[0])) {\n        list = list.concat(arguments[0]);\n      } else {\n        list = list.concat([].slice.call(arguments));\n      }\n    };\n\n    // Clear argument list\n    argfunc.clear = function () {\n      list = [];\n    };\n\n    // Return argument list\n    argfunc.get = function () {\n      return list;\n    };\n\n    // Find argument 'arg' in list, and if found, return an array of the 'count' items that follow it\n    argfunc.find = function (arg, count) {\n      var index = list.indexOf(arg);\n      if (index !== -1) {\n        return list.slice(index + 1, index + 1 + (count || 0));\n      }\n    };\n\n    // Find argument 'arg' in list, and if found, remove it as well as the 'count' items that follow it\n    argfunc.remove = function (arg, count) {\n      var index = list.indexOf(arg);\n      if (index !== -1) {\n        list.splice(index, (count || 0) + 1);\n      }\n    };\n\n    // Clone argument list\n    argfunc.clone = function () {\n      var cloned = utils.args();\n      cloned(list);\n      return cloned;\n    };\n\n    return argfunc;\n  },\n\n\n  /**\n   * Generate filter strings\n   *\n   * @param {String[]|Object[]} filters filter specifications. When using objects,\n   *   each must have the following properties:\n   * @param {String} filters.filter filter name\n   * @param {String|Array} [filters.inputs] (array of) input stream specifier(s) for the filter,\n   *   defaults to ffmpeg automatically choosing the first unused matching streams\n   * @param {String|Array} [filters.outputs] (array of) output stream specifier(s) for the filter,\n   *   defaults to ffmpeg automatically assigning the output to the output file\n   * @param {Object|String|Array} [filters.options] filter options, can be omitted to not set any options\n   * @return String[]\n   * @private\n   */\n  makeFilterStrings: function (filters) {\n    return filters.map(function (filterSpec) {\n      if (typeof filterSpec === 'string') {\n        return filterSpec;\n      }\n\n      var filterString = '';\n\n      // Filter string format is:\n      // [input1][input2]...filter[output1][output2]...\n      // The 'filter' part can optionaly have arguments:\n      //   filter=arg1:arg2:arg3\n      //   filter=arg1=v1:arg2=v2:arg3=v3\n\n      // Add inputs\n      if (Array.isArray(filterSpec.inputs)) {\n        filterString += filterSpec.inputs.map(function (streamSpec) {\n          return streamSpec.replace(streamRegexp, '[$1]');\n        }).join('');\n      } else if (typeof filterSpec.inputs === 'string') {\n        filterString += filterSpec.inputs.replace(streamRegexp, '[$1]');\n      }\n\n      // Add filter\n      filterString += filterSpec.filter;\n\n      // Add options\n      if (filterSpec.options) {\n        if (typeof filterSpec.options === 'string' || typeof filterSpec.options === 'number') {\n          // Option string\n          filterString += '=' + filterSpec.options;\n        } else if (Array.isArray(filterSpec.options)) {\n          // Option array (unnamed options)\n          filterString += '=' + filterSpec.options.map(function (option) {\n            if (typeof option === 'string' && option.match(filterEscapeRegexp)) {\n              return '\\'' + option + '\\'';\n            } else {\n              return option;\n            }\n          }).join(':');\n        } else if (Object.keys(filterSpec.options).length) {\n          // Option object (named options)\n          filterString += '=' + Object.keys(filterSpec.options).map(function (option) {\n            var value = filterSpec.options[option];\n\n            if (typeof value === 'string' && value.match(filterEscapeRegexp)) {\n              value = '\\'' + value + '\\'';\n            }\n\n            return option + '=' + value;\n          }).join(':');\n        }\n      }\n\n      // Add outputs\n      if (Array.isArray(filterSpec.outputs)) {\n        filterString += filterSpec.outputs.map(function (streamSpec) {\n          return streamSpec.replace(streamRegexp, '[$1]');\n        }).join('');\n      } else if (typeof filterSpec.outputs === 'string') {\n        filterString += filterSpec.outputs.replace(streamRegexp, '[$1]');\n      }\n\n      return filterString;\n    });\n  },\n\n\n  /**\n   * Search for an executable\n   *\n   * Uses 'which' or 'where' depending on platform\n   *\n   * @param {String} name executable name\n   * @param {Function} callback callback with signature (err, path)\n   * @private\n   */\n  which: function (name, callback) {\n    if (name in whichCache) {\n      return callback(null, whichCache[name]);\n    }\n\n    which(name, function (err, result) {\n      if (err) {\n        // Treat errors as not found\n        return callback(null, whichCache[name] = '');\n      }\n      callback(null, whichCache[name] = result);\n    });\n  },\n\n\n  /**\n   * Convert a [[hh:]mm:]ss[.xxx] timemark into seconds\n   *\n   * @param {String} timemark timemark string\n   * @return Number\n   * @private\n   */\n  timemarkToSeconds: function (timemark) {\n    if (typeof timemark === 'number') {\n      return timemark;\n    }\n\n    if (timemark.indexOf(':') === -1 && timemark.indexOf('.') >= 0) {\n      return Number(timemark);\n    }\n\n    var parts = timemark.split(':');\n\n    // add seconds\n    var secs = Number(parts.pop());\n\n    if (parts.length) {\n      // add minutes\n      secs += Number(parts.pop()) * 60;\n    }\n\n    if (parts.length) {\n      // add hours\n      secs += Number(parts.pop()) * 3600;\n    }\n\n    return secs;\n  },\n\n\n  /**\n   * Extract codec data from ffmpeg stderr and emit 'codecData' event if appropriate\n   * Call it with an initially empty codec object once with each line of stderr output until it returns true\n   *\n   * @param {FfmpegCommand} command event emitter\n   * @param {String} stderrLine ffmpeg stderr output line\n   * @param {Object} codecObject object used to accumulate codec data between calls\n   * @return {Boolean} true if codec data is complete (and event was emitted), false otherwise\n   * @private\n   */\n  extractCodecData: function (command, stderrLine, codecsObject) {\n    var inputPattern = /Input #[0-9]+, ([^ ]+),/;\n    var durPattern = /Duration\\: ([^,]+)/;\n    var audioPattern = /Audio\\: (.*)/;\n    var videoPattern = /Video\\: (.*)/;\n\n    if (!('inputStack' in codecsObject)) {\n      codecsObject.inputStack = [];\n      codecsObject.inputIndex = -1;\n      codecsObject.inInput = false;\n    }\n\n    var inputStack = codecsObject.inputStack;\n    var inputIndex = codecsObject.inputIndex;\n    var inInput = codecsObject.inInput;\n\n    var format, dur, audio, video;\n\n    if (format = stderrLine.match(inputPattern)) {\n      inInput = codecsObject.inInput = true;\n      inputIndex = codecsObject.inputIndex = codecsObject.inputIndex + 1;\n\n      inputStack[inputIndex] = { format: format[1], audio: '', video: '', duration: '' };\n    } else if (inInput && (dur = stderrLine.match(durPattern))) {\n      inputStack[inputIndex].duration = dur[1];\n    } else if (inInput && (audio = stderrLine.match(audioPattern))) {\n      audio = audio[1].split(', ');\n      inputStack[inputIndex].audio = audio[0];\n      inputStack[inputIndex].audio_details = audio;\n    } else if (inInput && (video = stderrLine.match(videoPattern))) {\n      video = video[1].split(', ');\n      inputStack[inputIndex].video = video[0];\n      inputStack[inputIndex].video_details = video;\n    } else if (/Output #\\d+/.test(stderrLine)) {\n      inInput = codecsObject.inInput = false;\n    } else if (/Stream mapping:|Press (\\[q\\]|ctrl-c) to stop/.test(stderrLine)) {\n      command.emit.apply(command, ['codecData'].concat(inputStack));\n      return true;\n    }\n\n    return false;\n  },\n\n\n  /**\n   * Extract progress data from ffmpeg stderr and emit 'progress' event if appropriate\n   *\n   * @param {FfmpegCommand} command event emitter\n   * @param {String} stderrLine ffmpeg stderr data\n   * @private\n   */\n  extractProgress: function (command, stderrLine) {\n    var progress = parseProgressLine(stderrLine);\n\n    if (progress) {\n      // build progress report object\n      var ret = {\n        frames: parseInt(progress.frame, 10),\n        currentFps: parseInt(progress.fps, 10),\n        currentKbps: progress.bitrate ? parseFloat(progress.bitrate.replace('kbits/s', '')) : 0,\n        targetSize: parseInt(progress.size || progress.Lsize, 10),\n        timemark: progress.time\n      };\n\n      // calculate percent progress using duration\n      if (command._ffprobeData && command._ffprobeData.format && command._ffprobeData.format.duration) {\n        var duration = Number(command._ffprobeData.format.duration);\n        if (!isNaN(duration))\n          ret.percent = (utils.timemarkToSeconds(ret.timemark) / duration) * 100;\n      }\n      command.emit('progress', ret);\n    }\n  },\n\n\n  /**\n   * Extract error message(s) from ffmpeg stderr\n   *\n   * @param {String} stderr ffmpeg stderr data\n   * @return {String}\n   * @private\n   */\n  extractError: function (stderr) {\n    // Only return the last stderr lines that don't start with a space or a square bracket\n    return stderr.split(nlRegexp).reduce(function (messages, message) {\n      if (message.charAt(0) === ' ' || message.charAt(0) === '[') {\n        return [];\n      } else {\n        messages.push(message);\n        return messages;\n      }\n    }, []).join('\\n');\n  },\n\n\n  /**\n   * Creates a line ring buffer object with the following methods:\n   * - append(str) : appends a string or buffer\n   * - get() : returns the whole string\n   * - close() : prevents further append() calls and does a last call to callbacks\n   * - callback(cb) : calls cb for each line (incl. those already in the ring)\n   *\n   * @param {Numebr} maxLines maximum number of lines to store (<= 0 for unlimited)\n   */\n  linesRing: function (maxLines) {\n    var cbs = [];\n    var lines = [];\n    var current = null;\n    var closed = false\n    var max = maxLines - 1;\n\n    function emit(line) {\n      cbs.forEach(function (cb) { cb(line); });\n    }\n\n    return {\n      callback: function (cb) {\n        lines.forEach(function (l) { cb(l); });\n        cbs.push(cb);\n      },\n\n      append: function (str) {\n        if (closed) return;\n        if (str instanceof Buffer) str = '' + str;\n        if (!str || str.length === 0) return;\n\n        var newLines = str.split(nlRegexp);\n\n        if (newLines.length === 1) {\n          if (current !== null) {\n            current = current + newLines.shift();\n          } else {\n            current = newLines.shift();\n          }\n        } else {\n          if (current !== null) {\n            current = current + newLines.shift();\n            emit(current);\n            lines.push(current);\n          }\n\n          current = newLines.pop();\n\n          newLines.forEach(function (l) {\n            emit(l);\n            lines.push(l);\n          });\n\n          if (max > -1 && lines.length > max) {\n            lines.splice(0, lines.length - max);\n          }\n        }\n      },\n\n      get: function () {\n        if (current !== null) {\n          return lines.concat([current]).join('\\n');\n        } else {\n          return lines.join('\\n');\n        }\n      },\n\n      close: function () {\n        if (closed) return;\n\n        if (current !== null) {\n          emit(current);\n          lines.push(current);\n\n          if (max > -1 && lines.length > max) {\n            lines.shift();\n          }\n\n          current = null;\n        }\n\n        closed = true;\n      }\n    };\n  }\n};\n"
  },
  {
    "path": "server/libs/fsExtra/LICENSE",
    "content": "(The MIT License)\n\nCopyright (c) 2011-2017 JP Richardson\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files\n(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,\n merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\nWARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS\nOR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\n ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/fsExtra/copy/copy-sync.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\nconst path = require('path')\nconst mkdirsSync = require('../mkdirs').mkdirsSync\nconst utimesMillisSync = require('../util/utimes').utimesMillisSync\nconst stat = require('../util/stat')\n\nfunction copySync (src, dest, opts) {\n  if (typeof opts === 'function') {\n    opts = { filter: opts }\n  }\n\n  opts = opts || {}\n  opts.clobber = 'clobber' in opts ? !!opts.clobber : true // default to true for now\n  opts.overwrite = 'overwrite' in opts ? !!opts.overwrite : opts.clobber // overwrite falls back to clobber\n\n  // Warn about using preserveTimestamps on 32-bit node\n  if (opts.preserveTimestamps && process.arch === 'ia32') {\n    process.emitWarning(\n      'Using the preserveTimestamps option in 32-bit node is not recommended;\\n\\n' +\n      '\\tsee https://github.com/jprichardson/node-fs-extra/issues/269',\n      'Warning', 'fs-extra-WARN0002'\n    )\n  }\n\n  const { srcStat, destStat } = stat.checkPathsSync(src, dest, 'copy', opts)\n  stat.checkParentPathsSync(src, srcStat, dest, 'copy')\n  return handleFilterAndCopy(destStat, src, dest, opts)\n}\n\nfunction handleFilterAndCopy (destStat, src, dest, opts) {\n  if (opts.filter && !opts.filter(src, dest)) return\n  const destParent = path.dirname(dest)\n  if (!fs.existsSync(destParent)) mkdirsSync(destParent)\n  return getStats(destStat, src, dest, opts)\n}\n\nfunction startCopy (destStat, src, dest, opts) {\n  if (opts.filter && !opts.filter(src, dest)) return\n  return getStats(destStat, src, dest, opts)\n}\n\nfunction getStats (destStat, src, dest, opts) {\n  const statSync = opts.dereference ? fs.statSync : fs.lstatSync\n  const srcStat = statSync(src)\n\n  if (srcStat.isDirectory()) return onDir(srcStat, destStat, src, dest, opts)\n  else if (srcStat.isFile() ||\n           srcStat.isCharacterDevice() ||\n           srcStat.isBlockDevice()) return onFile(srcStat, destStat, src, dest, opts)\n  else if (srcStat.isSymbolicLink()) return onLink(destStat, src, dest, opts)\n  else if (srcStat.isSocket()) throw new Error(`Cannot copy a socket file: ${src}`)\n  else if (srcStat.isFIFO()) throw new Error(`Cannot copy a FIFO pipe: ${src}`)\n  throw new Error(`Unknown file: ${src}`)\n}\n\nfunction onFile (srcStat, destStat, src, dest, opts) {\n  if (!destStat) return copyFile(srcStat, src, dest, opts)\n  return mayCopyFile(srcStat, src, dest, opts)\n}\n\nfunction mayCopyFile (srcStat, src, dest, opts) {\n  if (opts.overwrite) {\n    fs.unlinkSync(dest)\n    return copyFile(srcStat, src, dest, opts)\n  } else if (opts.errorOnExist) {\n    throw new Error(`'${dest}' already exists`)\n  }\n}\n\nfunction copyFile (srcStat, src, dest, opts) {\n  fs.copyFileSync(src, dest)\n  if (opts.preserveTimestamps) handleTimestamps(srcStat.mode, src, dest)\n  return setDestMode(dest, srcStat.mode)\n}\n\nfunction handleTimestamps (srcMode, src, dest) {\n  // Make sure the file is writable before setting the timestamp\n  // otherwise open fails with EPERM when invoked with 'r+'\n  // (through utimes call)\n  if (fileIsNotWritable(srcMode)) makeFileWritable(dest, srcMode)\n  return setDestTimestamps(src, dest)\n}\n\nfunction fileIsNotWritable (srcMode) {\n  return (srcMode & 0o200) === 0\n}\n\nfunction makeFileWritable (dest, srcMode) {\n  return setDestMode(dest, srcMode | 0o200)\n}\n\nfunction setDestMode (dest, srcMode) {\n  return fs.chmodSync(dest, srcMode)\n}\n\nfunction setDestTimestamps (src, dest) {\n  // The initial srcStat.atime cannot be trusted\n  // because it is modified by the read(2) system call\n  // (See https://nodejs.org/api/fs.html#fs_stat_time_values)\n  const updatedSrcStat = fs.statSync(src)\n  return utimesMillisSync(dest, updatedSrcStat.atime, updatedSrcStat.mtime)\n}\n\nfunction onDir (srcStat, destStat, src, dest, opts) {\n  if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts)\n  return copyDir(src, dest, opts)\n}\n\nfunction mkDirAndCopy (srcMode, src, dest, opts) {\n  fs.mkdirSync(dest)\n  copyDir(src, dest, opts)\n  return setDestMode(dest, srcMode)\n}\n\nfunction copyDir (src, dest, opts) {\n  fs.readdirSync(src).forEach(item => copyDirItem(item, src, dest, opts))\n}\n\nfunction copyDirItem (item, src, dest, opts) {\n  const srcItem = path.join(src, item)\n  const destItem = path.join(dest, item)\n  const { destStat } = stat.checkPathsSync(srcItem, destItem, 'copy', opts)\n  return startCopy(destStat, srcItem, destItem, opts)\n}\n\nfunction onLink (destStat, src, dest, opts) {\n  let resolvedSrc = fs.readlinkSync(src)\n  if (opts.dereference) {\n    resolvedSrc = path.resolve(process.cwd(), resolvedSrc)\n  }\n\n  if (!destStat) {\n    return fs.symlinkSync(resolvedSrc, dest)\n  } else {\n    let resolvedDest\n    try {\n      resolvedDest = fs.readlinkSync(dest)\n    } catch (err) {\n      // dest exists and is a regular file or directory,\n      // Windows may throw UNKNOWN error. If dest already exists,\n      // fs throws error anyway, so no need to guard against it here.\n      if (err.code === 'EINVAL' || err.code === 'UNKNOWN') return fs.symlinkSync(resolvedSrc, dest)\n      throw err\n    }\n    if (opts.dereference) {\n      resolvedDest = path.resolve(process.cwd(), resolvedDest)\n    }\n    if (stat.isSrcSubdir(resolvedSrc, resolvedDest)) {\n      throw new Error(`Cannot copy '${resolvedSrc}' to a subdirectory of itself, '${resolvedDest}'.`)\n    }\n\n    // prevent copy if src is a subdir of dest since unlinking\n    // dest in this case would result in removing src contents\n    // and therefore a broken symlink would be created.\n    if (fs.statSync(dest).isDirectory() && stat.isSrcSubdir(resolvedDest, resolvedSrc)) {\n      throw new Error(`Cannot overwrite '${resolvedDest}' with '${resolvedSrc}'.`)\n    }\n    return copyLink(resolvedSrc, dest)\n  }\n}\n\nfunction copyLink (resolvedSrc, dest) {\n  fs.unlinkSync(dest)\n  return fs.symlinkSync(resolvedSrc, dest)\n}\n\nmodule.exports = copySync\n"
  },
  {
    "path": "server/libs/fsExtra/copy/copy.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\nconst path = require('path')\nconst mkdirs = require('../mkdirs').mkdirs\nconst pathExists = require('../path-exists').pathExists\nconst utimesMillis = require('../util/utimes').utimesMillis\nconst stat = require('../util/stat')\n\nfunction copy (src, dest, opts, cb) {\n  if (typeof opts === 'function' && !cb) {\n    cb = opts\n    opts = {}\n  } else if (typeof opts === 'function') {\n    opts = { filter: opts }\n  }\n\n  cb = cb || function () {}\n  opts = opts || {}\n\n  opts.clobber = 'clobber' in opts ? !!opts.clobber : true // default to true for now\n  opts.overwrite = 'overwrite' in opts ? !!opts.overwrite : opts.clobber // overwrite falls back to clobber\n\n  // Warn about using preserveTimestamps on 32-bit node\n  if (opts.preserveTimestamps && process.arch === 'ia32') {\n    process.emitWarning(\n      'Using the preserveTimestamps option in 32-bit node is not recommended;\\n\\n' +\n      '\\tsee https://github.com/jprichardson/node-fs-extra/issues/269',\n      'Warning', 'fs-extra-WARN0001'\n    )\n  }\n\n  stat.checkPaths(src, dest, 'copy', opts, (err, stats) => {\n    if (err) return cb(err)\n    const { srcStat, destStat } = stats\n    stat.checkParentPaths(src, srcStat, dest, 'copy', err => {\n      if (err) return cb(err)\n      if (opts.filter) return handleFilter(checkParentDir, destStat, src, dest, opts, cb)\n      return checkParentDir(destStat, src, dest, opts, cb)\n    })\n  })\n}\n\nfunction checkParentDir (destStat, src, dest, opts, cb) {\n  const destParent = path.dirname(dest)\n  pathExists(destParent, (err, dirExists) => {\n    if (err) return cb(err)\n    if (dirExists) return getStats(destStat, src, dest, opts, cb)\n    mkdirs(destParent, err => {\n      if (err) return cb(err)\n      return getStats(destStat, src, dest, opts, cb)\n    })\n  })\n}\n\nfunction handleFilter (onInclude, destStat, src, dest, opts, cb) {\n  Promise.resolve(opts.filter(src, dest)).then(include => {\n    if (include) return onInclude(destStat, src, dest, opts, cb)\n    return cb()\n  }, error => cb(error))\n}\n\nfunction startCopy (destStat, src, dest, opts, cb) {\n  if (opts.filter) return handleFilter(getStats, destStat, src, dest, opts, cb)\n  return getStats(destStat, src, dest, opts, cb)\n}\n\nfunction getStats (destStat, src, dest, opts, cb) {\n  const stat = opts.dereference ? fs.stat : fs.lstat\n  stat(src, (err, srcStat) => {\n    if (err) return cb(err)\n\n    if (srcStat.isDirectory()) return onDir(srcStat, destStat, src, dest, opts, cb)\n    else if (srcStat.isFile() ||\n             srcStat.isCharacterDevice() ||\n             srcStat.isBlockDevice()) return onFile(srcStat, destStat, src, dest, opts, cb)\n    else if (srcStat.isSymbolicLink()) return onLink(destStat, src, dest, opts, cb)\n    else if (srcStat.isSocket()) return cb(new Error(`Cannot copy a socket file: ${src}`))\n    else if (srcStat.isFIFO()) return cb(new Error(`Cannot copy a FIFO pipe: ${src}`))\n    return cb(new Error(`Unknown file: ${src}`))\n  })\n}\n\nfunction onFile (srcStat, destStat, src, dest, opts, cb) {\n  if (!destStat) return copyFile(srcStat, src, dest, opts, cb)\n  return mayCopyFile(srcStat, src, dest, opts, cb)\n}\n\nfunction mayCopyFile (srcStat, src, dest, opts, cb) {\n  if (opts.overwrite) {\n    fs.unlink(dest, err => {\n      if (err) return cb(err)\n      return copyFile(srcStat, src, dest, opts, cb)\n    })\n  } else if (opts.errorOnExist) {\n    return cb(new Error(`'${dest}' already exists`))\n  } else return cb()\n}\n\nfunction copyFile (srcStat, src, dest, opts, cb) {\n  fs.copyFile(src, dest, err => {\n    if (err) return cb(err)\n    if (opts.preserveTimestamps) return handleTimestampsAndMode(srcStat.mode, src, dest, cb)\n    return setDestMode(dest, srcStat.mode, cb)\n  })\n}\n\nfunction handleTimestampsAndMode (srcMode, src, dest, cb) {\n  // Make sure the file is writable before setting the timestamp\n  // otherwise open fails with EPERM when invoked with 'r+'\n  // (through utimes call)\n  if (fileIsNotWritable(srcMode)) {\n    return makeFileWritable(dest, srcMode, err => {\n      if (err) return cb(err)\n      return setDestTimestampsAndMode(srcMode, src, dest, cb)\n    })\n  }\n  return setDestTimestampsAndMode(srcMode, src, dest, cb)\n}\n\nfunction fileIsNotWritable (srcMode) {\n  return (srcMode & 0o200) === 0\n}\n\nfunction makeFileWritable (dest, srcMode, cb) {\n  return setDestMode(dest, srcMode | 0o200, cb)\n}\n\nfunction setDestTimestampsAndMode (srcMode, src, dest, cb) {\n  setDestTimestamps(src, dest, err => {\n    if (err) return cb(err)\n    return setDestMode(dest, srcMode, cb)\n  })\n}\n\nfunction setDestMode (dest, srcMode, cb) {\n  return fs.chmod(dest, srcMode, cb)\n}\n\nfunction setDestTimestamps (src, dest, cb) {\n  // The initial srcStat.atime cannot be trusted\n  // because it is modified by the read(2) system call\n  // (See https://nodejs.org/api/fs.html#fs_stat_time_values)\n  fs.stat(src, (err, updatedSrcStat) => {\n    if (err) return cb(err)\n    return utimesMillis(dest, updatedSrcStat.atime, updatedSrcStat.mtime, cb)\n  })\n}\n\nfunction onDir (srcStat, destStat, src, dest, opts, cb) {\n  if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts, cb)\n  return copyDir(src, dest, opts, cb)\n}\n\nfunction mkDirAndCopy (srcMode, src, dest, opts, cb) {\n  fs.mkdir(dest, err => {\n    if (err) return cb(err)\n    copyDir(src, dest, opts, err => {\n      if (err) return cb(err)\n      return setDestMode(dest, srcMode, cb)\n    })\n  })\n}\n\nfunction copyDir (src, dest, opts, cb) {\n  fs.readdir(src, (err, items) => {\n    if (err) return cb(err)\n    return copyDirItems(items, src, dest, opts, cb)\n  })\n}\n\nfunction copyDirItems (items, src, dest, opts, cb) {\n  const item = items.pop()\n  if (!item) return cb()\n  return copyDirItem(items, item, src, dest, opts, cb)\n}\n\nfunction copyDirItem (items, item, src, dest, opts, cb) {\n  const srcItem = path.join(src, item)\n  const destItem = path.join(dest, item)\n  stat.checkPaths(srcItem, destItem, 'copy', opts, (err, stats) => {\n    if (err) return cb(err)\n    const { destStat } = stats\n    startCopy(destStat, srcItem, destItem, opts, err => {\n      if (err) return cb(err)\n      return copyDirItems(items, src, dest, opts, cb)\n    })\n  })\n}\n\nfunction onLink (destStat, src, dest, opts, cb) {\n  fs.readlink(src, (err, resolvedSrc) => {\n    if (err) return cb(err)\n    if (opts.dereference) {\n      resolvedSrc = path.resolve(process.cwd(), resolvedSrc)\n    }\n\n    if (!destStat) {\n      return fs.symlink(resolvedSrc, dest, cb)\n    } else {\n      fs.readlink(dest, (err, resolvedDest) => {\n        if (err) {\n          // dest exists and is a regular file or directory,\n          // Windows may throw UNKNOWN error. If dest already exists,\n          // fs throws error anyway, so no need to guard against it here.\n          if (err.code === 'EINVAL' || err.code === 'UNKNOWN') return fs.symlink(resolvedSrc, dest, cb)\n          return cb(err)\n        }\n        if (opts.dereference) {\n          resolvedDest = path.resolve(process.cwd(), resolvedDest)\n        }\n        if (stat.isSrcSubdir(resolvedSrc, resolvedDest)) {\n          return cb(new Error(`Cannot copy '${resolvedSrc}' to a subdirectory of itself, '${resolvedDest}'.`))\n        }\n\n        // do not copy if src is a subdir of dest since unlinking\n        // dest in this case would result in removing src contents\n        // and therefore a broken symlink would be created.\n        if (destStat.isDirectory() && stat.isSrcSubdir(resolvedDest, resolvedSrc)) {\n          return cb(new Error(`Cannot overwrite '${resolvedDest}' with '${resolvedSrc}'.`))\n        }\n        return copyLink(resolvedSrc, dest, cb)\n      })\n    }\n  })\n}\n\nfunction copyLink (resolvedSrc, dest, cb) {\n  fs.unlink(dest, err => {\n    if (err) return cb(err)\n    return fs.symlink(resolvedSrc, dest, cb)\n  })\n}\n\nmodule.exports = copy\n"
  },
  {
    "path": "server/libs/fsExtra/copy/index.js",
    "content": "'use strict'\n\nconst u = require('../../universalify').fromCallback\nmodule.exports = {\n  copy: u(require('./copy')),\n  copySync: require('./copy-sync')\n}\n"
  },
  {
    "path": "server/libs/fsExtra/empty/index.js",
    "content": "'use strict'\n\nconst u = require('../../universalify').fromPromise\nconst fs = require('../fs')\nconst path = require('path')\nconst mkdir = require('../mkdirs')\nconst remove = require('../remove')\n\nconst emptyDir = u(async function emptyDir(dir) {\n  let items\n  try {\n    items = await fs.readdir(dir)\n  } catch {\n    return mkdir.mkdirs(dir)\n  }\n\n  return Promise.all(items.map(item => remove.remove(path.join(dir, item))))\n})\n\nfunction emptyDirSync(dir) {\n  let items\n  try {\n    items = fs.readdirSync(dir)\n  } catch {\n    return mkdir.mkdirsSync(dir)\n  }\n\n  items.forEach(item => {\n    item = path.join(dir, item)\n    remove.removeSync(item)\n  })\n}\n\nmodule.exports = {\n  emptyDirSync,\n  emptydirSync: emptyDirSync,\n  emptyDir,\n  emptydir: emptyDir\n}\n"
  },
  {
    "path": "server/libs/fsExtra/ensure/file.js",
    "content": "'use strict'\n\nconst u = require('../../universalify').fromCallback\nconst path = require('path')\nconst fs = require('graceful-fs')\nconst mkdir = require('../mkdirs')\n\nfunction createFile(file, callback) {\n  function makeFile() {\n    fs.writeFile(file, '', err => {\n      if (err) return callback(err)\n      callback()\n    })\n  }\n\n  fs.stat(file, (err, stats) => { // eslint-disable-line handle-callback-err\n    if (!err && stats.isFile()) return callback()\n    const dir = path.dirname(file)\n    fs.stat(dir, (err, stats) => {\n      if (err) {\n        // if the directory doesn't exist, make it\n        if (err.code === 'ENOENT') {\n          return mkdir.mkdirs(dir, err => {\n            if (err) return callback(err)\n            makeFile()\n          })\n        }\n        return callback(err)\n      }\n\n      if (stats.isDirectory()) makeFile()\n      else {\n        // parent is not a directory\n        // This is just to cause an internal ENOTDIR error to be thrown\n        fs.readdir(dir, err => {\n          if (err) return callback(err)\n        })\n      }\n    })\n  })\n}\n\nfunction createFileSync(file) {\n  let stats\n  try {\n    stats = fs.statSync(file)\n  } catch { }\n  if (stats && stats.isFile()) return\n\n  const dir = path.dirname(file)\n  try {\n    if (!fs.statSync(dir).isDirectory()) {\n      // parent is not a directory\n      // This is just to cause an internal ENOTDIR error to be thrown\n      fs.readdirSync(dir)\n    }\n  } catch (err) {\n    // If the stat call above failed because the directory doesn't exist, create it\n    if (err && err.code === 'ENOENT') mkdir.mkdirsSync(dir)\n    else throw err\n  }\n\n  fs.writeFileSync(file, '')\n}\n\nmodule.exports = {\n  createFile: u(createFile),\n  createFileSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/ensure/index.js",
    "content": "'use strict'\n\nconst { createFile, createFileSync } = require('./file')\nconst { createLink, createLinkSync } = require('./link')\nconst { createSymlink, createSymlinkSync } = require('./symlink')\n\nmodule.exports = {\n  // file\n  createFile,\n  createFileSync,\n  ensureFile: createFile,\n  ensureFileSync: createFileSync,\n  // link\n  createLink,\n  createLinkSync,\n  ensureLink: createLink,\n  ensureLinkSync: createLinkSync,\n  // symlink\n  createSymlink,\n  createSymlinkSync,\n  ensureSymlink: createSymlink,\n  ensureSymlinkSync: createSymlinkSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/ensure/link.js",
    "content": "'use strict'\n\nconst u = require('../../universalify').fromCallback\nconst path = require('path')\nconst fs = require('graceful-fs')\nconst mkdir = require('../mkdirs')\nconst pathExists = require('../path-exists').pathExists\nconst { areIdentical } = require('../util/stat')\n\nfunction createLink(srcpath, dstpath, callback) {\n  function makeLink(srcpath, dstpath) {\n    fs.link(srcpath, dstpath, err => {\n      if (err) return callback(err)\n      callback(null)\n    })\n  }\n\n  fs.lstat(dstpath, (_, dstStat) => {\n    fs.lstat(srcpath, (err, srcStat) => {\n      if (err) {\n        err.message = err.message.replace('lstat', 'ensureLink')\n        return callback(err)\n      }\n      if (dstStat && areIdentical(srcStat, dstStat)) return callback(null)\n\n      const dir = path.dirname(dstpath)\n      pathExists(dir, (err, dirExists) => {\n        if (err) return callback(err)\n        if (dirExists) return makeLink(srcpath, dstpath)\n        mkdir.mkdirs(dir, err => {\n          if (err) return callback(err)\n          makeLink(srcpath, dstpath)\n        })\n      })\n    })\n  })\n}\n\nfunction createLinkSync(srcpath, dstpath) {\n  let dstStat\n  try {\n    dstStat = fs.lstatSync(dstpath)\n  } catch { }\n\n  try {\n    const srcStat = fs.lstatSync(srcpath)\n    if (dstStat && areIdentical(srcStat, dstStat)) return\n  } catch (err) {\n    err.message = err.message.replace('lstat', 'ensureLink')\n    throw err\n  }\n\n  const dir = path.dirname(dstpath)\n  const dirExists = fs.existsSync(dir)\n  if (dirExists) return fs.linkSync(srcpath, dstpath)\n  mkdir.mkdirsSync(dir)\n\n  return fs.linkSync(srcpath, dstpath)\n}\n\nmodule.exports = {\n  createLink: u(createLink),\n  createLinkSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/ensure/symlink-paths.js",
    "content": "'use strict'\n\nconst path = require('path')\nconst fs = require('graceful-fs')\nconst pathExists = require('../path-exists').pathExists\n\n/**\n * Function that returns two types of paths, one relative to symlink, and one\n * relative to the current working directory. Checks if path is absolute or\n * relative. If the path is relative, this function checks if the path is\n * relative to symlink or relative to current working directory. This is an\n * initiative to find a smarter `srcpath` to supply when building symlinks.\n * This allows you to determine which path to use out of one of three possible\n * types of source paths. The first is an absolute path. This is detected by\n * `path.isAbsolute()`. When an absolute path is provided, it is checked to\n * see if it exists. If it does it's used, if not an error is returned\n * (callback)/ thrown (sync). The other two options for `srcpath` are a\n * relative url. By default Node's `fs.symlink` works by creating a symlink\n * using `dstpath` and expects the `srcpath` to be relative to the newly\n * created symlink. If you provide a `srcpath` that does not exist on the file\n * system it results in a broken symlink. To minimize this, the function\n * checks to see if the 'relative to symlink' source file exists, and if it\n * does it will use it. If it does not, it checks if there's a file that\n * exists that is relative to the current working directory, if does its used.\n * This preserves the expectations of the original fs.symlink spec and adds\n * the ability to pass in `relative to current working direcotry` paths.\n */\n\nfunction symlinkPaths (srcpath, dstpath, callback) {\n  if (path.isAbsolute(srcpath)) {\n    return fs.lstat(srcpath, (err) => {\n      if (err) {\n        err.message = err.message.replace('lstat', 'ensureSymlink')\n        return callback(err)\n      }\n      return callback(null, {\n        toCwd: srcpath,\n        toDst: srcpath\n      })\n    })\n  } else {\n    const dstdir = path.dirname(dstpath)\n    const relativeToDst = path.join(dstdir, srcpath)\n    return pathExists(relativeToDst, (err, exists) => {\n      if (err) return callback(err)\n      if (exists) {\n        return callback(null, {\n          toCwd: relativeToDst,\n          toDst: srcpath\n        })\n      } else {\n        return fs.lstat(srcpath, (err) => {\n          if (err) {\n            err.message = err.message.replace('lstat', 'ensureSymlink')\n            return callback(err)\n          }\n          return callback(null, {\n            toCwd: srcpath,\n            toDst: path.relative(dstdir, srcpath)\n          })\n        })\n      }\n    })\n  }\n}\n\nfunction symlinkPathsSync (srcpath, dstpath) {\n  let exists\n  if (path.isAbsolute(srcpath)) {\n    exists = fs.existsSync(srcpath)\n    if (!exists) throw new Error('absolute srcpath does not exist')\n    return {\n      toCwd: srcpath,\n      toDst: srcpath\n    }\n  } else {\n    const dstdir = path.dirname(dstpath)\n    const relativeToDst = path.join(dstdir, srcpath)\n    exists = fs.existsSync(relativeToDst)\n    if (exists) {\n      return {\n        toCwd: relativeToDst,\n        toDst: srcpath\n      }\n    } else {\n      exists = fs.existsSync(srcpath)\n      if (!exists) throw new Error('relative srcpath does not exist')\n      return {\n        toCwd: srcpath,\n        toDst: path.relative(dstdir, srcpath)\n      }\n    }\n  }\n}\n\nmodule.exports = {\n  symlinkPaths,\n  symlinkPathsSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/ensure/symlink-type.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\n\nfunction symlinkType (srcpath, type, callback) {\n  callback = (typeof type === 'function') ? type : callback\n  type = (typeof type === 'function') ? false : type\n  if (type) return callback(null, type)\n  fs.lstat(srcpath, (err, stats) => {\n    if (err) return callback(null, 'file')\n    type = (stats && stats.isDirectory()) ? 'dir' : 'file'\n    callback(null, type)\n  })\n}\n\nfunction symlinkTypeSync (srcpath, type) {\n  let stats\n\n  if (type) return type\n  try {\n    stats = fs.lstatSync(srcpath)\n  } catch {\n    return 'file'\n  }\n  return (stats && stats.isDirectory()) ? 'dir' : 'file'\n}\n\nmodule.exports = {\n  symlinkType,\n  symlinkTypeSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/ensure/symlink.js",
    "content": "'use strict'\n\nconst u = require('../../universalify').fromCallback\nconst path = require('path')\nconst fs = require('../fs')\nconst _mkdirs = require('../mkdirs')\nconst mkdirs = _mkdirs.mkdirs\nconst mkdirsSync = _mkdirs.mkdirsSync\n\nconst _symlinkPaths = require('./symlink-paths')\nconst symlinkPaths = _symlinkPaths.symlinkPaths\nconst symlinkPathsSync = _symlinkPaths.symlinkPathsSync\n\nconst _symlinkType = require('./symlink-type')\nconst symlinkType = _symlinkType.symlinkType\nconst symlinkTypeSync = _symlinkType.symlinkTypeSync\n\nconst pathExists = require('../path-exists').pathExists\n\nconst { areIdentical } = require('../util/stat')\n\nfunction createSymlink(srcpath, dstpath, type, callback) {\n  callback = (typeof type === 'function') ? type : callback\n  type = (typeof type === 'function') ? false : type\n\n  fs.lstat(dstpath, (err, stats) => {\n    if (!err && stats.isSymbolicLink()) {\n      Promise.all([\n        fs.stat(srcpath),\n        fs.stat(dstpath)\n      ]).then(([srcStat, dstStat]) => {\n        if (areIdentical(srcStat, dstStat)) return callback(null)\n        _createSymlink(srcpath, dstpath, type, callback)\n      })\n    } else _createSymlink(srcpath, dstpath, type, callback)\n  })\n}\n\nfunction _createSymlink(srcpath, dstpath, type, callback) {\n  symlinkPaths(srcpath, dstpath, (err, relative) => {\n    if (err) return callback(err)\n    srcpath = relative.toDst\n    symlinkType(relative.toCwd, type, (err, type) => {\n      if (err) return callback(err)\n      const dir = path.dirname(dstpath)\n      pathExists(dir, (err, dirExists) => {\n        if (err) return callback(err)\n        if (dirExists) return fs.symlink(srcpath, dstpath, type, callback)\n        mkdirs(dir, err => {\n          if (err) return callback(err)\n          fs.symlink(srcpath, dstpath, type, callback)\n        })\n      })\n    })\n  })\n}\n\nfunction createSymlinkSync(srcpath, dstpath, type) {\n  let stats\n  try {\n    stats = fs.lstatSync(dstpath)\n  } catch { }\n  if (stats && stats.isSymbolicLink()) {\n    const srcStat = fs.statSync(srcpath)\n    const dstStat = fs.statSync(dstpath)\n    if (areIdentical(srcStat, dstStat)) return\n  }\n\n  const relative = symlinkPathsSync(srcpath, dstpath)\n  srcpath = relative.toDst\n  type = symlinkTypeSync(relative.toCwd, type)\n  const dir = path.dirname(dstpath)\n  const exists = fs.existsSync(dir)\n  if (exists) return fs.symlinkSync(srcpath, dstpath, type)\n  mkdirsSync(dir)\n  return fs.symlinkSync(srcpath, dstpath, type)\n}\n\nmodule.exports = {\n  createSymlink: u(createSymlink),\n  createSymlinkSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/fs/index.js",
    "content": "'use strict'\n// This is adapted from https://github.com/normalize/mz\n// Copyright (c) 2014-2016 Jonathan Ong me@jongleberry.com and Contributors\nconst u = require('../../universalify').fromCallback\nconst fs = require('graceful-fs')\n\nconst api = [\n  'access',\n  'appendFile',\n  'chmod',\n  'chown',\n  'close',\n  'copyFile',\n  'fchmod',\n  'fchown',\n  'fdatasync',\n  'fstat',\n  'fsync',\n  'ftruncate',\n  'futimes',\n  'lchmod',\n  'lchown',\n  'link',\n  'lstat',\n  'mkdir',\n  'mkdtemp',\n  'open',\n  'opendir',\n  'readdir',\n  'readFile',\n  'readlink',\n  'realpath',\n  'rename',\n  'rm',\n  'rmdir',\n  'stat',\n  'symlink',\n  'truncate',\n  'unlink',\n  'utimes',\n  'writeFile'\n].filter(key => {\n  // Some commands are not available on some systems. Ex:\n  // fs.opendir was added in Node.js v12.12.0\n  // fs.rm was added in Node.js v14.14.0\n  // fs.lchown is not available on at least some Linux\n  return typeof fs[key] === 'function'\n})\n\n// Export cloned fs:\nObject.assign(exports, fs)\n\n// Universalify async methods:\napi.forEach(method => {\n  exports[method] = u(fs[method])\n})\n\n// We differ from mz/fs in that we still ship the old, broken, fs.exists()\n// since we are a drop-in replacement for the native module\nexports.exists = function (filename, callback) {\n  if (typeof callback === 'function') {\n    return fs.exists(filename, callback)\n  }\n  return new Promise(resolve => {\n    return fs.exists(filename, resolve)\n  })\n}\n\n// fs.read(), fs.write(), & fs.writev() need special treatment due to multiple callback args\n\nexports.read = function (fd, buffer, offset, length, position, callback) {\n  if (typeof callback === 'function') {\n    return fs.read(fd, buffer, offset, length, position, callback)\n  }\n  return new Promise((resolve, reject) => {\n    fs.read(fd, buffer, offset, length, position, (err, bytesRead, buffer) => {\n      if (err) return reject(err)\n      resolve({ bytesRead, buffer })\n    })\n  })\n}\n\n// Function signature can be\n// fs.write(fd, buffer[, offset[, length[, position]]], callback)\n// OR\n// fs.write(fd, string[, position[, encoding]], callback)\n// We need to handle both cases, so we use ...args\nexports.write = function (fd, buffer, ...args) {\n  if (typeof args[args.length - 1] === 'function') {\n    return fs.write(fd, buffer, ...args)\n  }\n\n  return new Promise((resolve, reject) => {\n    fs.write(fd, buffer, ...args, (err, bytesWritten, buffer) => {\n      if (err) return reject(err)\n      resolve({ bytesWritten, buffer })\n    })\n  })\n}\n\n// fs.writev only available in Node v12.9.0+\nif (typeof fs.writev === 'function') {\n  // Function signature is\n  // s.writev(fd, buffers[, position], callback)\n  // We need to handle the optional arg, so we use ...args\n  exports.writev = function (fd, buffers, ...args) {\n    if (typeof args[args.length - 1] === 'function') {\n      return fs.writev(fd, buffers, ...args)\n    }\n\n    return new Promise((resolve, reject) => {\n      fs.writev(fd, buffers, ...args, (err, bytesWritten, buffers) => {\n        if (err) return reject(err)\n        resolve({ bytesWritten, buffers })\n      })\n    })\n  }\n}\n\n// fs.realpath.native sometimes not available if fs is monkey-patched\nif (typeof fs.realpath.native === 'function') {\n  exports.realpath.native = u(fs.realpath.native)\n} else {\n  process.emitWarning(\n    'fs.realpath.native is not a function. Is fs being monkey-patched?',\n    'Warning', 'fs-extra-WARN0003'\n  )\n}\n"
  },
  {
    "path": "server/libs/fsExtra/index.js",
    "content": "'use strict'\n\nmodule.exports = {\n  // Export promiseified graceful-fs:\n  ...require('./fs'),\n  // Export extra methods:\n  ...require('./copy'),\n  ...require('./empty'),\n  ...require('./ensure'),\n  ...require('./mkdirs'),\n  ...require('./move'),\n  ...require('./path-exists'),\n  ...require('./remove')\n}\n"
  },
  {
    "path": "server/libs/fsExtra/mkdirs/index.js",
    "content": "'use strict'\nconst u = require('../../universalify').fromPromise\nconst { makeDir: _makeDir, makeDirSync } = require('./make-dir')\nconst makeDir = u(_makeDir)\n\nmodule.exports = {\n  mkdirs: makeDir,\n  mkdirsSync: makeDirSync,\n  // alias\n  mkdirp: makeDir,\n  mkdirpSync: makeDirSync,\n  ensureDir: makeDir,\n  ensureDirSync: makeDirSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/mkdirs/make-dir.js",
    "content": "'use strict'\nconst fs = require('../fs')\nconst { checkPath } = require('./utils')\n\nconst getMode = options => {\n  const defaults = { mode: 0o777 }\n  if (typeof options === 'number') return options\n  return ({ ...defaults, ...options }).mode\n}\n\nmodule.exports.makeDir = async (dir, options) => {\n  checkPath(dir)\n\n  return fs.mkdir(dir, {\n    mode: getMode(options),\n    recursive: true\n  })\n}\n\nmodule.exports.makeDirSync = (dir, options) => {\n  checkPath(dir)\n\n  return fs.mkdirSync(dir, {\n    mode: getMode(options),\n    recursive: true\n  })\n}\n"
  },
  {
    "path": "server/libs/fsExtra/mkdirs/utils.js",
    "content": "// Adapted from https://github.com/sindresorhus/make-dir\n// Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)\n// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n'use strict'\nconst path = require('path')\n\n// https://github.com/nodejs/node/issues/8987\n// https://github.com/libuv/libuv/pull/1088\nmodule.exports.checkPath = function checkPath (pth) {\n  if (process.platform === 'win32') {\n    const pathHasInvalidWinCharacters = /[<>:\"|?*]/.test(pth.replace(path.parse(pth).root, ''))\n\n    if (pathHasInvalidWinCharacters) {\n      const error = new Error(`Path contains invalid characters: ${pth}`)\n      error.code = 'EINVAL'\n      throw error\n    }\n  }\n}\n"
  },
  {
    "path": "server/libs/fsExtra/move/index.js",
    "content": "'use strict'\n\nconst u = require('../../universalify').fromCallback\nmodule.exports = {\n  move: u(require('./move')),\n  moveSync: require('./move-sync')\n}\n"
  },
  {
    "path": "server/libs/fsExtra/move/move-sync.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\nconst path = require('path')\nconst copySync = require('../copy').copySync\nconst removeSync = require('../remove').removeSync\nconst mkdirpSync = require('../mkdirs').mkdirpSync\nconst stat = require('../util/stat')\n\nfunction moveSync (src, dest, opts) {\n  opts = opts || {}\n  const overwrite = opts.overwrite || opts.clobber || false\n\n  const { srcStat, isChangingCase = false } = stat.checkPathsSync(src, dest, 'move', opts)\n  stat.checkParentPathsSync(src, srcStat, dest, 'move')\n  if (!isParentRoot(dest)) mkdirpSync(path.dirname(dest))\n  return doRename(src, dest, overwrite, isChangingCase)\n}\n\nfunction isParentRoot (dest) {\n  const parent = path.dirname(dest)\n  const parsedPath = path.parse(parent)\n  return parsedPath.root === parent\n}\n\nfunction doRename (src, dest, overwrite, isChangingCase) {\n  if (isChangingCase) return rename(src, dest, overwrite)\n  if (overwrite) {\n    removeSync(dest)\n    return rename(src, dest, overwrite)\n  }\n  if (fs.existsSync(dest)) throw new Error('dest already exists.')\n  return rename(src, dest, overwrite)\n}\n\nfunction rename (src, dest, overwrite) {\n  try {\n    fs.renameSync(src, dest)\n  } catch (err) {\n    if (err.code !== 'EXDEV') throw err\n    return moveAcrossDevice(src, dest, overwrite)\n  }\n}\n\nfunction moveAcrossDevice (src, dest, overwrite) {\n  const opts = {\n    overwrite,\n    errorOnExist: true\n  }\n  copySync(src, dest, opts)\n  return removeSync(src)\n}\n\nmodule.exports = moveSync\n"
  },
  {
    "path": "server/libs/fsExtra/move/move.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\nconst path = require('path')\nconst copy = require('../copy').copy\nconst remove = require('../remove').remove\nconst mkdirp = require('../mkdirs').mkdirp\nconst pathExists = require('../path-exists').pathExists\nconst stat = require('../util/stat')\n\nfunction move (src, dest, opts, cb) {\n  if (typeof opts === 'function') {\n    cb = opts\n    opts = {}\n  }\n\n  opts = opts || {}\n\n  const overwrite = opts.overwrite || opts.clobber || false\n\n  stat.checkPaths(src, dest, 'move', opts, (err, stats) => {\n    if (err) return cb(err)\n    const { srcStat, isChangingCase = false } = stats\n    stat.checkParentPaths(src, srcStat, dest, 'move', err => {\n      if (err) return cb(err)\n      if (isParentRoot(dest)) return doRename(src, dest, overwrite, isChangingCase, cb)\n      mkdirp(path.dirname(dest), err => {\n        if (err) return cb(err)\n        return doRename(src, dest, overwrite, isChangingCase, cb)\n      })\n    })\n  })\n}\n\nfunction isParentRoot (dest) {\n  const parent = path.dirname(dest)\n  const parsedPath = path.parse(parent)\n  return parsedPath.root === parent\n}\n\nfunction doRename (src, dest, overwrite, isChangingCase, cb) {\n  if (isChangingCase) return rename(src, dest, overwrite, cb)\n  if (overwrite) {\n    return remove(dest, err => {\n      if (err) return cb(err)\n      return rename(src, dest, overwrite, cb)\n    })\n  }\n  pathExists(dest, (err, destExists) => {\n    if (err) return cb(err)\n    if (destExists) return cb(new Error('dest already exists.'))\n    return rename(src, dest, overwrite, cb)\n  })\n}\n\nfunction rename (src, dest, overwrite, cb) {\n  fs.rename(src, dest, err => {\n    if (!err) return cb()\n    if (err.code !== 'EXDEV') return cb(err)\n    return moveAcrossDevice(src, dest, overwrite, cb)\n  })\n}\n\nfunction moveAcrossDevice (src, dest, overwrite, cb) {\n  const opts = {\n    overwrite,\n    errorOnExist: true\n  }\n  copy(src, dest, opts, err => {\n    if (err) return cb(err)\n    return remove(src, cb)\n  })\n}\n\nmodule.exports = move\n"
  },
  {
    "path": "server/libs/fsExtra/path-exists/index.js",
    "content": "'use strict'\nconst u = require('../../universalify').fromPromise\nconst fs = require('../fs')\n\nfunction pathExists(path) {\n  return fs.access(path).then(() => true).catch(() => false)\n}\n\nmodule.exports = {\n  pathExists: u(pathExists),\n  pathExistsSync: fs.existsSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/remove/index.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\nconst u = require('../../universalify').fromCallback\nconst rimraf = require('./rimraf')\n\nfunction remove(path, callback) {\n  // Node 14.14.0+\n  if (fs.rm) return fs.rm(path, { recursive: true, force: true }, callback)\n  rimraf(path, callback)\n}\n\nfunction removeSync(path) {\n  // Node 14.14.0+\n  if (fs.rmSync) return fs.rmSync(path, { recursive: true, force: true })\n  rimraf.sync(path)\n}\n\nmodule.exports = {\n  remove: u(remove),\n  removeSync\n}\n"
  },
  {
    "path": "server/libs/fsExtra/remove/rimraf.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\nconst path = require('path')\nconst assert = require('assert')\n\nconst isWindows = (process.platform === 'win32')\n\nfunction defaults (options) {\n  const methods = [\n    'unlink',\n    'chmod',\n    'stat',\n    'lstat',\n    'rmdir',\n    'readdir'\n  ]\n  methods.forEach(m => {\n    options[m] = options[m] || fs[m]\n    m = m + 'Sync'\n    options[m] = options[m] || fs[m]\n  })\n\n  options.maxBusyTries = options.maxBusyTries || 3\n}\n\nfunction rimraf (p, options, cb) {\n  let busyTries = 0\n\n  if (typeof options === 'function') {\n    cb = options\n    options = {}\n  }\n\n  assert(p, 'rimraf: missing path')\n  assert.strictEqual(typeof p, 'string', 'rimraf: path should be a string')\n  assert.strictEqual(typeof cb, 'function', 'rimraf: callback function required')\n  assert(options, 'rimraf: invalid options argument provided')\n  assert.strictEqual(typeof options, 'object', 'rimraf: options should be object')\n\n  defaults(options)\n\n  rimraf_(p, options, function CB (er) {\n    if (er) {\n      if ((er.code === 'EBUSY' || er.code === 'ENOTEMPTY' || er.code === 'EPERM') &&\n          busyTries < options.maxBusyTries) {\n        busyTries++\n        const time = busyTries * 100\n        // try again, with the same exact callback as this one.\n        return setTimeout(() => rimraf_(p, options, CB), time)\n      }\n\n      // already gone\n      if (er.code === 'ENOENT') er = null\n    }\n\n    cb(er)\n  })\n}\n\n// Two possible strategies.\n// 1. Assume it's a file.  unlink it, then do the dir stuff on EPERM or EISDIR\n// 2. Assume it's a directory.  readdir, then do the file stuff on ENOTDIR\n//\n// Both result in an extra syscall when you guess wrong.  However, there\n// are likely far more normal files in the world than directories.  This\n// is based on the assumption that a the average number of files per\n// directory is >= 1.\n//\n// If anyone ever complains about this, then I guess the strategy could\n// be made configurable somehow.  But until then, YAGNI.\nfunction rimraf_ (p, options, cb) {\n  assert(p)\n  assert(options)\n  assert(typeof cb === 'function')\n\n  // sunos lets the root user unlink directories, which is... weird.\n  // so we have to lstat here and make sure it's not a dir.\n  options.lstat(p, (er, st) => {\n    if (er && er.code === 'ENOENT') {\n      return cb(null)\n    }\n\n    // Windows can EPERM on stat.  Life is suffering.\n    if (er && er.code === 'EPERM' && isWindows) {\n      return fixWinEPERM(p, options, er, cb)\n    }\n\n    if (st && st.isDirectory()) {\n      return rmdir(p, options, er, cb)\n    }\n\n    options.unlink(p, er => {\n      if (er) {\n        if (er.code === 'ENOENT') {\n          return cb(null)\n        }\n        if (er.code === 'EPERM') {\n          return (isWindows)\n            ? fixWinEPERM(p, options, er, cb)\n            : rmdir(p, options, er, cb)\n        }\n        if (er.code === 'EISDIR') {\n          return rmdir(p, options, er, cb)\n        }\n      }\n      return cb(er)\n    })\n  })\n}\n\nfunction fixWinEPERM (p, options, er, cb) {\n  assert(p)\n  assert(options)\n  assert(typeof cb === 'function')\n\n  options.chmod(p, 0o666, er2 => {\n    if (er2) {\n      cb(er2.code === 'ENOENT' ? null : er)\n    } else {\n      options.stat(p, (er3, stats) => {\n        if (er3) {\n          cb(er3.code === 'ENOENT' ? null : er)\n        } else if (stats.isDirectory()) {\n          rmdir(p, options, er, cb)\n        } else {\n          options.unlink(p, cb)\n        }\n      })\n    }\n  })\n}\n\nfunction fixWinEPERMSync (p, options, er) {\n  let stats\n\n  assert(p)\n  assert(options)\n\n  try {\n    options.chmodSync(p, 0o666)\n  } catch (er2) {\n    if (er2.code === 'ENOENT') {\n      return\n    } else {\n      throw er\n    }\n  }\n\n  try {\n    stats = options.statSync(p)\n  } catch (er3) {\n    if (er3.code === 'ENOENT') {\n      return\n    } else {\n      throw er\n    }\n  }\n\n  if (stats.isDirectory()) {\n    rmdirSync(p, options, er)\n  } else {\n    options.unlinkSync(p)\n  }\n}\n\nfunction rmdir (p, options, originalEr, cb) {\n  assert(p)\n  assert(options)\n  assert(typeof cb === 'function')\n\n  // try to rmdir first, and only readdir on ENOTEMPTY or EEXIST (SunOS)\n  // if we guessed wrong, and it's not a directory, then\n  // raise the original error.\n  options.rmdir(p, er => {\n    if (er && (er.code === 'ENOTEMPTY' || er.code === 'EEXIST' || er.code === 'EPERM')) {\n      rmkids(p, options, cb)\n    } else if (er && er.code === 'ENOTDIR') {\n      cb(originalEr)\n    } else {\n      cb(er)\n    }\n  })\n}\n\nfunction rmkids (p, options, cb) {\n  assert(p)\n  assert(options)\n  assert(typeof cb === 'function')\n\n  options.readdir(p, (er, files) => {\n    if (er) return cb(er)\n\n    let n = files.length\n    let errState\n\n    if (n === 0) return options.rmdir(p, cb)\n\n    files.forEach(f => {\n      rimraf(path.join(p, f), options, er => {\n        if (errState) {\n          return\n        }\n        if (er) return cb(errState = er)\n        if (--n === 0) {\n          options.rmdir(p, cb)\n        }\n      })\n    })\n  })\n}\n\n// this looks simpler, and is strictly *faster*, but will\n// tie up the JavaScript thread and fail on excessively\n// deep directory trees.\nfunction rimrafSync (p, options) {\n  let st\n\n  options = options || {}\n  defaults(options)\n\n  assert(p, 'rimraf: missing path')\n  assert.strictEqual(typeof p, 'string', 'rimraf: path should be a string')\n  assert(options, 'rimraf: missing options')\n  assert.strictEqual(typeof options, 'object', 'rimraf: options should be object')\n\n  try {\n    st = options.lstatSync(p)\n  } catch (er) {\n    if (er.code === 'ENOENT') {\n      return\n    }\n\n    // Windows can EPERM on stat.  Life is suffering.\n    if (er.code === 'EPERM' && isWindows) {\n      fixWinEPERMSync(p, options, er)\n    }\n  }\n\n  try {\n    // sunos lets the root user unlink directories, which is... weird.\n    if (st && st.isDirectory()) {\n      rmdirSync(p, options, null)\n    } else {\n      options.unlinkSync(p)\n    }\n  } catch (er) {\n    if (er.code === 'ENOENT') {\n      return\n    } else if (er.code === 'EPERM') {\n      return isWindows ? fixWinEPERMSync(p, options, er) : rmdirSync(p, options, er)\n    } else if (er.code !== 'EISDIR') {\n      throw er\n    }\n    rmdirSync(p, options, er)\n  }\n}\n\nfunction rmdirSync (p, options, originalEr) {\n  assert(p)\n  assert(options)\n\n  try {\n    options.rmdirSync(p)\n  } catch (er) {\n    if (er.code === 'ENOTDIR') {\n      throw originalEr\n    } else if (er.code === 'ENOTEMPTY' || er.code === 'EEXIST' || er.code === 'EPERM') {\n      rmkidsSync(p, options)\n    } else if (er.code !== 'ENOENT') {\n      throw er\n    }\n  }\n}\n\nfunction rmkidsSync (p, options) {\n  assert(p)\n  assert(options)\n  options.readdirSync(p).forEach(f => rimrafSync(path.join(p, f), options))\n\n  if (isWindows) {\n    // We only end up here once we got ENOTEMPTY at least once, and\n    // at this point, we are guaranteed to have removed all the kids.\n    // So, we know that it won't be ENOENT or ENOTDIR or anything else.\n    // try really hard to delete stuff on windows, because it has a\n    // PROFOUNDLY annoying habit of not closing handles promptly when\n    // files are deleted, resulting in spurious ENOTEMPTY errors.\n    const startTime = Date.now()\n    do {\n      try {\n        const ret = options.rmdirSync(p, options)\n        return ret\n      } catch {}\n    } while (Date.now() - startTime < 500) // give up after 500ms\n  } else {\n    const ret = options.rmdirSync(p, options)\n    return ret\n  }\n}\n\nmodule.exports = rimraf\nrimraf.sync = rimrafSync\n"
  },
  {
    "path": "server/libs/fsExtra/util/stat.js",
    "content": "'use strict'\n\nconst fs = require('../fs')\nconst path = require('path')\nconst util = require('util')\n\nfunction getStats (src, dest, opts) {\n  const statFunc = opts.dereference\n    ? (file) => fs.stat(file, { bigint: true })\n    : (file) => fs.lstat(file, { bigint: true })\n  return Promise.all([\n    statFunc(src),\n    statFunc(dest).catch(err => {\n      if (err.code === 'ENOENT') return null\n      throw err\n    })\n  ]).then(([srcStat, destStat]) => ({ srcStat, destStat }))\n}\n\nfunction getStatsSync (src, dest, opts) {\n  let destStat\n  const statFunc = opts.dereference\n    ? (file) => fs.statSync(file, { bigint: true })\n    : (file) => fs.lstatSync(file, { bigint: true })\n  const srcStat = statFunc(src)\n  try {\n    destStat = statFunc(dest)\n  } catch (err) {\n    if (err.code === 'ENOENT') return { srcStat, destStat: null }\n    throw err\n  }\n  return { srcStat, destStat }\n}\n\nfunction checkPaths (src, dest, funcName, opts, cb) {\n  util.callbackify(getStats)(src, dest, opts, (err, stats) => {\n    if (err) return cb(err)\n    const { srcStat, destStat } = stats\n\n    if (destStat) {\n      if (areIdentical(srcStat, destStat)) {\n        const srcBaseName = path.basename(src)\n        const destBaseName = path.basename(dest)\n        if (funcName === 'move' &&\n          srcBaseName !== destBaseName &&\n          srcBaseName.toLowerCase() === destBaseName.toLowerCase()) {\n          return cb(null, { srcStat, destStat, isChangingCase: true })\n        }\n        return cb(new Error('Source and destination must not be the same.'))\n      }\n      if (srcStat.isDirectory() && !destStat.isDirectory()) {\n        return cb(new Error(`Cannot overwrite non-directory '${dest}' with directory '${src}'.`))\n      }\n      if (!srcStat.isDirectory() && destStat.isDirectory()) {\n        return cb(new Error(`Cannot overwrite directory '${dest}' with non-directory '${src}'.`))\n      }\n    }\n\n    if (srcStat.isDirectory() && isSrcSubdir(src, dest)) {\n      return cb(new Error(errMsg(src, dest, funcName)))\n    }\n    return cb(null, { srcStat, destStat })\n  })\n}\n\nfunction checkPathsSync (src, dest, funcName, opts) {\n  const { srcStat, destStat } = getStatsSync(src, dest, opts)\n\n  if (destStat) {\n    if (areIdentical(srcStat, destStat)) {\n      const srcBaseName = path.basename(src)\n      const destBaseName = path.basename(dest)\n      if (funcName === 'move' &&\n        srcBaseName !== destBaseName &&\n        srcBaseName.toLowerCase() === destBaseName.toLowerCase()) {\n        return { srcStat, destStat, isChangingCase: true }\n      }\n      throw new Error('Source and destination must not be the same.')\n    }\n    if (srcStat.isDirectory() && !destStat.isDirectory()) {\n      throw new Error(`Cannot overwrite non-directory '${dest}' with directory '${src}'.`)\n    }\n    if (!srcStat.isDirectory() && destStat.isDirectory()) {\n      throw new Error(`Cannot overwrite directory '${dest}' with non-directory '${src}'.`)\n    }\n  }\n\n  if (srcStat.isDirectory() && isSrcSubdir(src, dest)) {\n    throw new Error(errMsg(src, dest, funcName))\n  }\n  return { srcStat, destStat }\n}\n\n// recursively check if dest parent is a subdirectory of src.\n// It works for all file types including symlinks since it\n// checks the src and dest inodes. It starts from the deepest\n// parent and stops once it reaches the src parent or the root path.\nfunction checkParentPaths (src, srcStat, dest, funcName, cb) {\n  const srcParent = path.resolve(path.dirname(src))\n  const destParent = path.resolve(path.dirname(dest))\n  if (destParent === srcParent || destParent === path.parse(destParent).root) return cb()\n  fs.stat(destParent, { bigint: true }, (err, destStat) => {\n    if (err) {\n      if (err.code === 'ENOENT') return cb()\n      return cb(err)\n    }\n    if (areIdentical(srcStat, destStat)) {\n      return cb(new Error(errMsg(src, dest, funcName)))\n    }\n    return checkParentPaths(src, srcStat, destParent, funcName, cb)\n  })\n}\n\nfunction checkParentPathsSync (src, srcStat, dest, funcName) {\n  const srcParent = path.resolve(path.dirname(src))\n  const destParent = path.resolve(path.dirname(dest))\n  if (destParent === srcParent || destParent === path.parse(destParent).root) return\n  let destStat\n  try {\n    destStat = fs.statSync(destParent, { bigint: true })\n  } catch (err) {\n    if (err.code === 'ENOENT') return\n    throw err\n  }\n  if (areIdentical(srcStat, destStat)) {\n    throw new Error(errMsg(src, dest, funcName))\n  }\n  return checkParentPathsSync(src, srcStat, destParent, funcName)\n}\n\nfunction areIdentical (srcStat, destStat) {\n  return destStat.ino && destStat.dev && destStat.ino === srcStat.ino && destStat.dev === srcStat.dev\n}\n\n// return true if dest is a subdir of src, otherwise false.\n// It only checks the path strings.\nfunction isSrcSubdir (src, dest) {\n  const srcArr = path.resolve(src).split(path.sep).filter(i => i)\n  const destArr = path.resolve(dest).split(path.sep).filter(i => i)\n  return srcArr.reduce((acc, cur, i) => acc && destArr[i] === cur, true)\n}\n\nfunction errMsg (src, dest, funcName) {\n  return `Cannot ${funcName} '${src}' to a subdirectory of itself, '${dest}'.`\n}\n\nmodule.exports = {\n  checkPaths,\n  checkPathsSync,\n  checkParentPaths,\n  checkParentPathsSync,\n  isSrcSubdir,\n  areIdentical\n}\n"
  },
  {
    "path": "server/libs/fsExtra/util/utimes.js",
    "content": "'use strict'\n\nconst fs = require('graceful-fs')\n\nfunction utimesMillis (path, atime, mtime, callback) {\n  // if (!HAS_MILLIS_RES) return fs.utimes(path, atime, mtime, callback)\n  fs.open(path, 'r+', (err, fd) => {\n    if (err) return callback(err)\n    fs.futimes(fd, atime, mtime, futimesErr => {\n      fs.close(fd, closeErr => {\n        if (callback) callback(futimesErr || closeErr)\n      })\n    })\n  })\n}\n\nfunction utimesMillisSync (path, atime, mtime) {\n  const fd = fs.openSync(path, 'r+')\n  fs.futimesSync(fd, atime, mtime)\n  return fs.closeSync(fd)\n}\n\nmodule.exports = {\n  utimesMillis,\n  utimesMillisSync\n}\n"
  },
  {
    "path": "server/libs/fusejs/index.js",
    "content": "/**\n * Source: https://github.com/krisk/Fuse/blob/main/dist/fuse.basic.min.js\n */\n\n/**\n * Fuse.js v7.1.0 - Lightweight fuzzy-search (http://fusejs.io)\n *\n * Copyright (c) 2025 Kiro Risk (http://kiro.me)\n * All Rights Reserved. Apache Software License 2.0\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n */\nvar e,t;e=this,t=function(){\"use strict\";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;n<arguments.length;n++){var r=null!=arguments[n]?arguments[n]:{};n%2?e(Object(r),!0).forEach((function(e){o(t,e,r[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(r)):e(Object(r)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(r,e))}))}return t}function n(e){return n=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e},n(e)}function r(e,t){if(!(e instanceof t))throw new TypeError(\"Cannot call a class as a function\")}function u(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,\"value\"in r&&(r.writable=!0),Object.defineProperty(e,s(r.key),r)}}function i(e,t,n){return t&&u(e.prototype,t),n&&u(e,n),Object.defineProperty(e,\"prototype\",{writable:!1}),e}function o(e,t,n){return(t=s(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function a(e){return function(e){if(Array.isArray(e))return c(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e[\"@@iterator\"])return Array.from(e)}(e)||function(e,t){if(e){if(\"string\"==typeof e)return c(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return\"Object\"===n&&e.constructor&&(n=e.constructor.name),\"Map\"===n||\"Set\"===n?Array.from(e):\"Arguments\"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?c(e,t):void 0}}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function c(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function s(e){var t=function(e,t){if(\"object\"!=typeof e||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||\"default\");if(\"object\"!=typeof r)return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(\"string\"===t?String:Number)(e)}(e,\"string\");return\"symbol\"==typeof t?t:String(t)}function h(e){return Array.isArray?Array.isArray(e):\"[object Array]\"===p(e)}var l=1/0;function f(e){return null==e?\"\":function(e){if(\"string\"==typeof e)return e;var t=e+\"\";return\"0\"==t&&1/e==-l?\"-0\":t}(e)}function d(e){return\"string\"==typeof e}function v(e){return\"number\"==typeof e}function g(e){return!0===e||!1===e||function(e){return function(e){return\"object\"===n(e)}(e)&&null!==e}(e)&&\"[object Boolean]\"==p(e)}function A(e){return null!=e}function y(e){return!e.trim().length}function p(e){return null==e?void 0===e?\"[object Undefined]\":\"[object Null]\":Object.prototype.toString.call(e)}var m=function(e){return\"Missing \".concat(e,\" property in key\")},C=function(e){return\"Property 'weight' in key '\".concat(e,\"' must be a positive integer\")},F=Object.prototype.hasOwnProperty,E=function(){function e(t){var n=this;r(this,e),this._keys=[],this._keyMap={};var u=0;t.forEach((function(e){var t=B(e);n._keys.push(t),n._keyMap[t.id]=t,u+=t.weight})),this._keys.forEach((function(e){e.weight/=u}))}return i(e,[{key:\"get\",value:function(e){return this._keyMap[e]}},{key:\"keys\",value:function(){return this._keys}},{key:\"toJSON\",value:function(){return JSON.stringify(this._keys)}}]),e}();function B(e){var t=null,n=null,r=null,u=1,i=null;if(d(e)||h(e))r=e,t=D(e),n=b(e);else{if(!F.call(e,\"name\"))throw new Error(m(\"name\"));var o=e.name;if(r=o,F.call(e,\"weight\")&&(u=e.weight)<=0)throw new Error(C(o));t=D(o),n=b(o),i=e.getFn}return{path:t,id:n,weight:u,src:r,getFn:i}}function D(e){return h(e)?e:e.split(\".\")}function b(e){return h(e)?e.join(\".\"):e}var k={useExtendedSearch:!1,getFn:function(e,t){var n=[],r=!1;return function e(t,u,i){if(A(t))if(u[i]){var o=t[u[i]];if(!A(o))return;if(i===u.length-1&&(d(o)||v(o)||g(o)))n.push(f(o));else if(h(o)){r=!0;for(var a=0,c=o.length;a<c;a+=1)e(o[a],u,i+1)}else u.length&&e(o,u,i+1)}else n.push(t)}(e,d(t)?t.split(\".\"):t,0),r?n:n[0]},ignoreLocation:!1,ignoreFieldNorm:!1,fieldNormWeight:1},M=t(t(t(t({},{isCaseSensitive:!1,ignoreDiacritics:!1,includeScore:!1,keys:[],shouldSort:!0,sortFn:function(e,t){return e.score===t.score?e.idx<t.idx?-1:1:e.score<t.score?-1:1}}),{includeMatches:!1,findAllMatches:!1,minMatchCharLength:1}),{location:0,threshold:.6,distance:100}),k),w=/[^ ]+/g,x=function(){function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=t.getFn,u=void 0===n?M.getFn:n,i=t.fieldNormWeight,o=void 0===i?M.fieldNormWeight:i;r(this,e),this.norm=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:3,n=new Map,r=Math.pow(10,t);return{get:function(t){var u=t.match(w).length;if(n.has(u))return n.get(u);var i=1/Math.pow(u,.5*e),o=parseFloat(Math.round(i*r)/r);return n.set(u,o),o},clear:function(){n.clear()}}}(o,3),this.getFn=u,this.isCreated=!1,this.setIndexRecords()}return i(e,[{key:\"setSources\",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.docs=e}},{key:\"setIndexRecords\",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.records=e}},{key:\"setKeys\",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.keys=t,this._keysMap={},t.forEach((function(t,n){e._keysMap[t.id]=n}))}},{key:\"create\",value:function(){var e=this;!this.isCreated&&this.docs.length&&(this.isCreated=!0,d(this.docs[0])?this.docs.forEach((function(t,n){e._addString(t,n)})):this.docs.forEach((function(t,n){e._addObject(t,n)})),this.norm.clear())}},{key:\"add\",value:function(e){var t=this.size();d(e)?this._addString(e,t):this._addObject(e,t)}},{key:\"removeAt\",value:function(e){this.records.splice(e,1);for(var t=e,n=this.size();t<n;t+=1)this.records[t].i-=1}},{key:\"getValueForItemAtKeyId\",value:function(e,t){return e[this._keysMap[t]]}},{key:\"size\",value:function(){return this.records.length}},{key:\"_addString\",value:function(e,t){if(A(e)&&!y(e)){var n={v:e,i:t,n:this.norm.get(e)};this.records.push(n)}}},{key:\"_addObject\",value:function(e,t){var n=this,r={i:t,$:{}};this.keys.forEach((function(t,u){var i=t.getFn?t.getFn(e):n.getFn(e,t.path);if(A(i))if(h(i)){for(var o=[],a=[{nestedArrIndex:-1,value:i}];a.length;){var c=a.pop(),s=c.nestedArrIndex,l=c.value;if(A(l))if(d(l)&&!y(l)){var f={v:l,i:s,n:n.norm.get(l)};o.push(f)}else h(l)&&l.forEach((function(e,t){a.push({nestedArrIndex:t,value:e})}))}r.$[u]=o}else if(d(i)&&!y(i)){var v={v:i,n:n.norm.get(i)};r.$[u]=v}})),this.records.push(r)}},{key:\"toJSON\",value:function(){return{keys:this.keys,records:this.records}}}]),e}();function L(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.getFn,u=void 0===r?M.getFn:r,i=n.fieldNormWeight,o=void 0===i?M.fieldNormWeight:i,a=new x({getFn:u,fieldNormWeight:o});return a.setKeys(e.map(B)),a.setSources(t),a.create(),a}function S(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.errors,r=void 0===n?0:n,u=t.currentLocation,i=void 0===u?0:u,o=t.expectedLocation,a=void 0===o?0:o,c=t.distance,s=void 0===c?M.distance:c,h=t.ignoreLocation,l=void 0===h?M.ignoreLocation:h,f=r/e.length;if(l)return f;var d=Math.abs(a-i);return s?f+d/s:d?1:f}var _=32;function O(e,t,n){var r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},u=r.location,i=void 0===u?M.location:u,o=r.distance,a=void 0===o?M.distance:o,c=r.threshold,s=void 0===c?M.threshold:c,h=r.findAllMatches,l=void 0===h?M.findAllMatches:h,f=r.minMatchCharLength,d=void 0===f?M.minMatchCharLength:f,v=r.includeMatches,g=void 0===v?M.includeMatches:v,A=r.ignoreLocation,y=void 0===A?M.ignoreLocation:A;if(t.length>_)throw new Error(\"Pattern length exceeds max of \".concat(_,\".\"));for(var p,m=t.length,C=e.length,F=Math.max(0,Math.min(i,C)),E=s,B=F,D=d>1||g,b=D?Array(C):[];(p=e.indexOf(t,B))>-1;){var k=S(t,{currentLocation:p,expectedLocation:F,distance:a,ignoreLocation:y});if(E=Math.min(k,E),B=p+m,D)for(var w=0;w<m;)b[p+w]=1,w+=1}B=-1;for(var x=[],L=1,O=m+C,j=1<<m-1,I=0;I<m;I+=1){for(var N=0,P=O;N<P;)S(t,{errors:I,currentLocation:F+P,expectedLocation:F,distance:a,ignoreLocation:y})<=E?N=P:O=P,P=Math.floor((O-N)/2+N);O=P;var W=Math.max(1,F-P+1),z=l?C:Math.min(F+P,C)+m,T=Array(z+2);T[z+1]=(1<<I)-1;for(var $=z;$>=W;$-=1){var K=$-1,J=n[e.charAt(K)];if(D&&(b[K]=+!!J),T[$]=(T[$+1]<<1|1)&J,I&&(T[$]|=(x[$+1]|x[$])<<1|1|x[$+1]),T[$]&j&&(L=S(t,{errors:I,currentLocation:K,expectedLocation:F,distance:a,ignoreLocation:y}))<=E){if(E=L,(B=K)<=F)break;W=Math.max(1,2*F-B)}}if(S(t,{errors:I+1,currentLocation:F,expectedLocation:F,distance:a,ignoreLocation:y})>E)break;x=T}var R={isMatch:B>=0,score:Math.max(.001,L)};if(D){var U=function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:M.minMatchCharLength,n=[],r=-1,u=-1,i=0,o=e.length;i<o;i+=1){var a=e[i];a&&-1===r?r=i:a||-1===r||((u=i-1)-r+1>=t&&n.push([r,u]),r=-1)}return e[i-1]&&i-r>=t&&n.push([r,i-1]),n}(b,d);U.length?g&&(R.indices=U):R.isMatch=!1}return R}function j(e){for(var t={},n=0,r=e.length;n<r;n+=1){var u=e.charAt(n);t[u]=(t[u]||0)|1<<r-n-1}return t}var I=String.prototype.normalize?function(e){return e.normalize(\"NFD\").replace(/[\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u05C5\\u05C7\\u0610-\\u061A\\u064B-\\u065F\\u0670\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED\\u0711\\u0730-\\u074A\\u07A6-\\u07B0\\u07EB-\\u07F3\\u07FD\\u0816-\\u0819\\u081B-\\u0823\\u0825-\\u0827\\u0829-\\u082D\\u0859-\\u085B\\u08D3-\\u08E1\\u08E3-\\u0903\\u093A-\\u093C\\u093E-\\u094F\\u0951-\\u0957\\u0962\\u0963\\u0981-\\u0983\\u09BC\\u09BE-\\u09C4\\u09C7\\u09C8\\u09CB-\\u09CD\\u09D7\\u09E2\\u09E3\\u09FE\\u0A01-\\u0A03\\u0A3C\\u0A3E-\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A51\\u0A70\\u0A71\\u0A75\\u0A81-\\u0A83\\u0ABC\\u0ABE-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0AE2\\u0AE3\\u0AFA-\\u0AFF\\u0B01-\\u0B03\\u0B3C\\u0B3E-\\u0B44\\u0B47\\u0B48\\u0B4B-\\u0B4D\\u0B56\\u0B57\\u0B62\\u0B63\\u0B82\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD7\\u0C00-\\u0C04\\u0C3E-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C62\\u0C63\\u0C81-\\u0C83\\u0CBC\\u0CBE-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5\\u0CD6\\u0CE2\\u0CE3\\u0D00-\\u0D03\\u0D3B\\u0D3C\\u0D3E-\\u0D44\\u0D46-\\u0D48\\u0D4A-\\u0D4D\\u0D57\\u0D62\\u0D63\\u0D82\\u0D83\\u0DCA\\u0DCF-\\u0DD4\\u0DD6\\u0DD8-\\u0DDF\\u0DF2\\u0DF3\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EB9\\u0EBB\\u0EBC\\u0EC8-\\u0ECD\\u0F18\\u0F19\\u0F35\\u0F37\\u0F39\\u0F3E\\u0F3F\\u0F71-\\u0F84\\u0F86\\u0F87\\u0F8D-\\u0F97\\u0F99-\\u0FBC\\u0FC6\\u102B-\\u103E\\u1056-\\u1059\\u105E-\\u1060\\u1062-\\u1064\\u1067-\\u106D\\u1071-\\u1074\\u1082-\\u108D\\u108F\\u109A-\\u109D\\u135D-\\u135F\\u1712-\\u1714\\u1732-\\u1734\\u1752\\u1753\\u1772\\u1773\\u17B4-\\u17D3\\u17DD\\u180B-\\u180D\\u1885\\u1886\\u18A9\\u1920-\\u192B\\u1930-\\u193B\\u1A17-\\u1A1B\\u1A55-\\u1A5E\\u1A60-\\u1A7C\\u1A7F\\u1AB0-\\u1ABE\\u1B00-\\u1B04\\u1B34-\\u1B44\\u1B6B-\\u1B73\\u1B80-\\u1B82\\u1BA1-\\u1BAD\\u1BE6-\\u1BF3\\u1C24-\\u1C37\\u1CD0-\\u1CD2\\u1CD4-\\u1CE8\\u1CED\\u1CF2-\\u1CF4\\u1CF7-\\u1CF9\\u1DC0-\\u1DF9\\u1DFB-\\u1DFF\\u20D0-\\u20F0\\u2CEF-\\u2CF1\\u2D7F\\u2DE0-\\u2DFF\\u302A-\\u302F\\u3099\\u309A\\uA66F-\\uA672\\uA674-\\uA67D\\uA69E\\uA69F\\uA6F0\\uA6F1\\uA802\\uA806\\uA80B\\uA823-\\uA827\\uA880\\uA881\\uA8B4-\\uA8C5\\uA8E0-\\uA8F1\\uA8FF\\uA926-\\uA92D\\uA947-\\uA953\\uA980-\\uA983\\uA9B3-\\uA9C0\\uA9E5\\uAA29-\\uAA36\\uAA43\\uAA4C\\uAA4D\\uAA7B-\\uAA7D\\uAAB0\\uAAB2-\\uAAB4\\uAAB7\\uAAB8\\uAABE\\uAABF\\uAAC1\\uAAEB-\\uAAEF\\uAAF5\\uAAF6\\uABE3-\\uABEA\\uABEC\\uABED\\uFB1E\\uFE00-\\uFE0F\\uFE20-\\uFE2F]/g,\"\")}:function(e){return e},N=function(){function e(t){var n=this,u=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=u.location,o=void 0===i?M.location:i,a=u.threshold,c=void 0===a?M.threshold:a,s=u.distance,h=void 0===s?M.distance:s,l=u.includeMatches,f=void 0===l?M.includeMatches:l,d=u.findAllMatches,v=void 0===d?M.findAllMatches:d,g=u.minMatchCharLength,A=void 0===g?M.minMatchCharLength:g,y=u.isCaseSensitive,p=void 0===y?M.isCaseSensitive:y,m=u.ignoreDiacritics,C=void 0===m?M.ignoreDiacritics:m,F=u.ignoreLocation,E=void 0===F?M.ignoreLocation:F;if(r(this,e),this.options={location:o,threshold:c,distance:h,includeMatches:f,findAllMatches:v,minMatchCharLength:A,isCaseSensitive:p,ignoreDiacritics:C,ignoreLocation:E},t=p?t:t.toLowerCase(),t=C?I(t):t,this.pattern=t,this.chunks=[],this.pattern.length){var B=function(e,t){n.chunks.push({pattern:e,alphabet:j(e),startIndex:t})},D=this.pattern.length;if(D>_){for(var b=0,k=D%_,w=D-k;b<w;)B(this.pattern.substr(b,_),b),b+=_;if(k){var x=D-_;B(this.pattern.substr(x),x)}}else B(this.pattern,0)}}return i(e,[{key:\"searchIn\",value:function(e){var t=this.options,n=t.isCaseSensitive,r=t.ignoreDiacritics,u=t.includeMatches;if(e=n?e:e.toLowerCase(),e=r?I(e):e,this.pattern===e){var i={isMatch:!0,score:0};return u&&(i.indices=[[0,e.length-1]]),i}var o=this.options,c=o.location,s=o.distance,h=o.threshold,l=o.findAllMatches,f=o.minMatchCharLength,d=o.ignoreLocation,v=[],g=0,A=!1;this.chunks.forEach((function(t){var n=t.pattern,r=t.alphabet,i=t.startIndex,o=O(e,n,r,{location:c+i,distance:s,threshold:h,findAllMatches:l,minMatchCharLength:f,includeMatches:u,ignoreLocation:d}),y=o.isMatch,p=o.score,m=o.indices;y&&(A=!0),g+=p,y&&m&&(v=[].concat(a(v),a(m)))}));var y={isMatch:A,score:A?g/this.chunks.length:1};return A&&u&&(y.indices=v),y}}]),e}(),P=[];function W(e,t){for(var n=0,r=P.length;n<r;n+=1){var u=P[n];if(u.condition(e,t))return new u(e,t)}return new N(e,t)}function z(e,t){var n=e.matches;t.matches=[],A(n)&&n.forEach((function(e){if(A(e.indices)&&e.indices.length){var n={indices:e.indices,value:e.value};e.key&&(n.key=e.key.src),e.idx>-1&&(n.refIndex=e.idx),t.matches.push(n)}}))}function T(e,t){t.score=e.score}var $=function(){function e(n){var u=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2?arguments[2]:void 0;if(r(this,e),this.options=t(t({},M),u),this.options.useExtendedSearch)throw new Error(\"Extended search is not available\");this._keyStore=new E(this.options.keys),this.setCollection(n,i)}return i(e,[{key:\"setCollection\",value:function(e,t){if(this._docs=e,t&&!(t instanceof x))throw new Error(\"Incorrect 'index' type\");this._myIndex=t||L(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}},{key:\"add\",value:function(e){A(e)&&(this._docs.push(e),this._myIndex.add(e))}},{key:\"remove\",value:function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!1},t=[],n=0,r=this._docs.length;n<r;n+=1){var u=this._docs[n];e(u,n)&&(this.removeAt(n),n-=1,r-=1,t.push(u))}return t}},{key:\"removeAt\",value:function(e){this._docs.splice(e,1),this._myIndex.removeAt(e)}},{key:\"getIndex\",value:function(){return this._myIndex}},{key:\"search\",value:function(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).limit,n=void 0===t?-1:t,r=this.options,u=r.includeMatches,i=r.includeScore,o=r.shouldSort,a=r.sortFn,c=r.ignoreFieldNorm,s=d(e)?d(this._docs[0])?this._searchStringList(e):this._searchObjectList(e):this._searchLogical(e);return function(e,t){var n=t.ignoreFieldNorm,r=void 0===n?M.ignoreFieldNorm:n;e.forEach((function(e){var t=1;e.matches.forEach((function(e){var n=e.key,u=e.norm,i=e.score,o=n?n.weight:null;t*=Math.pow(0===i&&o?Number.EPSILON:i,(o||1)*(r?1:u))})),e.score=t}))}(s,{ignoreFieldNorm:c}),o&&s.sort(a),v(n)&&n>-1&&(s=s.slice(0,n)),function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.includeMatches,u=void 0===r?M.includeMatches:r,i=n.includeScore,o=void 0===i?M.includeScore:i,a=[];return u&&a.push(z),o&&a.push(T),e.map((function(e){var n=e.idx,r={item:t[n],refIndex:n};return a.length&&a.forEach((function(t){t(e,r)})),r}))}(s,this._docs,{includeMatches:u,includeScore:i})}},{key:\"_searchStringList\",value:function(e){var t=W(e,this.options),n=this._myIndex.records,r=[];return n.forEach((function(e){var n=e.v,u=e.i,i=e.n;if(A(n)){var o=t.searchIn(n),a=o.isMatch,c=o.score,s=o.indices;a&&r.push({item:n,idx:u,matches:[{score:c,value:n,norm:i,indices:s}]})}})),r}},{key:\"_searchLogical\",value:function(e){throw new Error(\"Logical search is not available\")}},{key:\"_searchObjectList\",value:function(e){var t=this,n=W(e,this.options),r=this._myIndex,u=r.keys,i=r.records,o=[];return i.forEach((function(e){var r=e.$,i=e.i;if(A(r)){var c=[];u.forEach((function(e,u){c.push.apply(c,a(t._findMatches({key:e,value:r[u],searcher:n})))})),c.length&&o.push({idx:i,item:r,matches:c})}})),o}},{key:\"_findMatches\",value:function(e){var t=e.key,n=e.value,r=e.searcher;if(!A(n))return[];var u=[];if(h(n))n.forEach((function(e){var n=e.v,i=e.i,o=e.n;if(A(n)){var a=r.searchIn(n),c=a.isMatch,s=a.score,h=a.indices;c&&u.push({score:s,key:t,value:n,idx:i,norm:o,indices:h})}}));else{var i=n.v,o=n.n,a=r.searchIn(i),c=a.isMatch,s=a.score,l=a.indices;c&&u.push({score:s,key:t,value:i,norm:o,indices:l})}return u}}]),e}();return $.version=\"7.1.0\",$.createIndex=L,$.parseIndex=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.getFn,r=void 0===n?M.getFn:n,u=t.fieldNormWeight,i=void 0===u?M.fieldNormWeight:u,o=e.keys,a=e.records,c=new x({getFn:r,fieldNormWeight:i});return c.setKeys(o),c.setIndexRecords(a),c},$.config=M,$},\"object\"==typeof exports&&\"undefined\"!=typeof module?module.exports=t():\"function\"==typeof define&&define.amd?define(t):(e=\"undefined\"!=typeof globalThis?globalThis:e||self).Fuse=t();"
  },
  {
    "path": "server/libs/imageType/LICENSE",
    "content": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/imageType/fileType.js",
    "content": "'use strict';\nconst toBytes = s => [...s].map(c => c.charCodeAt(0));\nconst xpiZipFilename = toBytes('META-INF/mozilla.rsa');\nconst oxmlContentTypes = toBytes('[Content_Types].xml');\nconst oxmlRels = toBytes('_rels/.rels');\n\nfunction readUInt64LE(buf, offset = 0) {\n  let n = buf[offset];\n  let mul = 1;\n  let i = 0;\n  while (++i < 8) {\n    mul *= 0x100;\n    n += buf[offset + i] * mul;\n  }\n\n  return n;\n}\n\nconst fileType = input => {\n  if (!(input instanceof Uint8Array || input instanceof ArrayBuffer || Buffer.isBuffer(input))) {\n    throw new TypeError(`Expected the \\`input\\` argument to be of type \\`Uint8Array\\` or \\`Buffer\\` or \\`ArrayBuffer\\`, got \\`${typeof input}\\``);\n  }\n\n  const buf = input instanceof Uint8Array ? input : new Uint8Array(input);\n\n  if (!(buf && buf.length > 1)) {\n    return null;\n  }\n\n  const check = (header, options) => {\n    options = Object.assign({\n      offset: 0\n    }, options);\n\n    for (let i = 0; i < header.length; i++) {\n      // If a bitmask is set\n      if (options.mask) {\n        // If header doesn't equal `buf` with bits masked off\n        if (header[i] !== (options.mask[i] & buf[i + options.offset])) {\n          return false;\n        }\n      } else if (header[i] !== buf[i + options.offset]) {\n        return false;\n      }\n    }\n\n    return true;\n  };\n\n  const checkString = (header, options) => check(toBytes(header), options);\n\n  if (check([0xFF, 0xD8, 0xFF])) {\n    return {\n      ext: 'jpg',\n      mime: 'image/jpeg'\n    };\n  }\n\n  if (check([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) {\n    return {\n      ext: 'png',\n      mime: 'image/png'\n    };\n  }\n\n  if (check([0x47, 0x49, 0x46])) {\n    return {\n      ext: 'gif',\n      mime: 'image/gif'\n    };\n  }\n\n  if (check([0x57, 0x45, 0x42, 0x50], { offset: 8 })) {\n    return {\n      ext: 'webp',\n      mime: 'image/webp'\n    };\n  }\n\n  if (check([0x46, 0x4C, 0x49, 0x46])) {\n    return {\n      ext: 'flif',\n      mime: 'image/flif'\n    };\n  }\n\n  // Needs to be before `tif` check\n  if (\n    (check([0x49, 0x49, 0x2A, 0x0]) || check([0x4D, 0x4D, 0x0, 0x2A])) &&\n    check([0x43, 0x52], { offset: 8 })\n  ) {\n    return {\n      ext: 'cr2',\n      mime: 'image/x-canon-cr2'\n    };\n  }\n\n  if (\n    check([0x49, 0x49, 0x2A, 0x0]) ||\n    check([0x4D, 0x4D, 0x0, 0x2A])\n  ) {\n    return {\n      ext: 'tif',\n      mime: 'image/tiff'\n    };\n  }\n\n  if (check([0x42, 0x4D])) {\n    return {\n      ext: 'bmp',\n      mime: 'image/bmp'\n    };\n  }\n\n  if (check([0x49, 0x49, 0xBC])) {\n    return {\n      ext: 'jxr',\n      mime: 'image/vnd.ms-photo'\n    };\n  }\n\n  if (check([0x38, 0x42, 0x50, 0x53])) {\n    return {\n      ext: 'psd',\n      mime: 'image/vnd.adobe.photoshop'\n    };\n  }\n\n  // Zip-based file formats\n  // Need to be before the `zip` check\n  if (check([0x50, 0x4B, 0x3, 0x4])) {\n    if (\n      check([0x6D, 0x69, 0x6D, 0x65, 0x74, 0x79, 0x70, 0x65, 0x61, 0x70, 0x70, 0x6C, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6F, 0x6E, 0x2F, 0x65, 0x70, 0x75, 0x62, 0x2B, 0x7A, 0x69, 0x70], { offset: 30 })\n    ) {\n      return {\n        ext: 'epub',\n        mime: 'application/epub+zip'\n      };\n    }\n\n    // Assumes signed `.xpi` from addons.mozilla.org\n    if (check(xpiZipFilename, { offset: 30 })) {\n      return {\n        ext: 'xpi',\n        mime: 'application/x-xpinstall'\n      };\n    }\n\n    if (checkString('mimetypeapplication/vnd.oasis.opendocument.text', { offset: 30 })) {\n      return {\n        ext: 'odt',\n        mime: 'application/vnd.oasis.opendocument.text'\n      };\n    }\n\n    if (checkString('mimetypeapplication/vnd.oasis.opendocument.spreadsheet', { offset: 30 })) {\n      return {\n        ext: 'ods',\n        mime: 'application/vnd.oasis.opendocument.spreadsheet'\n      };\n    }\n\n    if (checkString('mimetypeapplication/vnd.oasis.opendocument.presentation', { offset: 30 })) {\n      return {\n        ext: 'odp',\n        mime: 'application/vnd.oasis.opendocument.presentation'\n      };\n    }\n\n    // The docx, xlsx and pptx file types extend the Office Open XML file format:\n    // https://en.wikipedia.org/wiki/Office_Open_XML_file_formats\n    // We look for:\n    // - one entry named '[Content_Types].xml' or '_rels/.rels',\n    // - one entry indicating specific type of file.\n    // MS Office, OpenOffice and LibreOffice may put the parts in different order, so the check should not rely on it.\n    const findNextZipHeaderIndex = (arr, startAt = 0) => arr.findIndex((el, i, arr) => i >= startAt && arr[i] === 0x50 && arr[i + 1] === 0x4B && arr[i + 2] === 0x3 && arr[i + 3] === 0x4);\n\n    let zipHeaderIndex = 0; // The first zip header was already found at index 0\n    let oxmlFound = false;\n    let type = null;\n\n    do {\n      const offset = zipHeaderIndex + 30;\n\n      if (!oxmlFound) {\n        oxmlFound = (check(oxmlContentTypes, { offset }) || check(oxmlRels, { offset }));\n      }\n\n      if (!type) {\n        if (checkString('word/', { offset })) {\n          type = {\n            ext: 'docx',\n            mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n          };\n        } else if (checkString('ppt/', { offset })) {\n          type = {\n            ext: 'pptx',\n            mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'\n          };\n        } else if (checkString('xl/', { offset })) {\n          type = {\n            ext: 'xlsx',\n            mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n          };\n        }\n      }\n\n      if (oxmlFound && type) {\n        return type;\n      }\n\n      zipHeaderIndex = findNextZipHeaderIndex(buf, offset);\n    } while (zipHeaderIndex >= 0);\n\n    // No more zip parts available in the buffer, but maybe we are almost certain about the type?\n    if (type) {\n      return type;\n    }\n  }\n\n  if (\n    check([0x50, 0x4B]) &&\n    (buf[2] === 0x3 || buf[2] === 0x5 || buf[2] === 0x7) &&\n    (buf[3] === 0x4 || buf[3] === 0x6 || buf[3] === 0x8)\n  ) {\n    return {\n      ext: 'zip',\n      mime: 'application/zip'\n    };\n  }\n\n  if (check([0x75, 0x73, 0x74, 0x61, 0x72], { offset: 257 })) {\n    return {\n      ext: 'tar',\n      mime: 'application/x-tar'\n    };\n  }\n\n  if (\n    check([0x52, 0x61, 0x72, 0x21, 0x1A, 0x7]) &&\n    (buf[6] === 0x0 || buf[6] === 0x1)\n  ) {\n    return {\n      ext: 'rar',\n      mime: 'application/x-rar-compressed'\n    };\n  }\n\n  if (check([0x1F, 0x8B, 0x8])) {\n    return {\n      ext: 'gz',\n      mime: 'application/gzip'\n    };\n  }\n\n  if (check([0x42, 0x5A, 0x68])) {\n    return {\n      ext: 'bz2',\n      mime: 'application/x-bzip2'\n    };\n  }\n\n  if (check([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])) {\n    return {\n      ext: '7z',\n      mime: 'application/x-7z-compressed'\n    };\n  }\n\n  if (check([0x78, 0x01])) {\n    return {\n      ext: 'dmg',\n      mime: 'application/x-apple-diskimage'\n    };\n  }\n\n  if (check([0x33, 0x67, 0x70, 0x35]) || // 3gp5\n    (\n      check([0x0, 0x0, 0x0]) && check([0x66, 0x74, 0x79, 0x70], { offset: 4 }) &&\n      (\n        check([0x6D, 0x70, 0x34, 0x31], { offset: 8 }) || // MP41\n        check([0x6D, 0x70, 0x34, 0x32], { offset: 8 }) || // MP42\n        check([0x69, 0x73, 0x6F, 0x6D], { offset: 8 }) || // ISOM\n        check([0x69, 0x73, 0x6F, 0x32], { offset: 8 }) || // ISO2\n        check([0x6D, 0x6D, 0x70, 0x34], { offset: 8 }) || // MMP4\n        check([0x4D, 0x34, 0x56], { offset: 8 }) || // M4V\n        check([0x64, 0x61, 0x73, 0x68], { offset: 8 }) // DASH\n      )\n    )) {\n    return {\n      ext: 'mp4',\n      mime: 'video/mp4'\n    };\n  }\n\n  if (check([0x4D, 0x54, 0x68, 0x64])) {\n    return {\n      ext: 'mid',\n      mime: 'audio/midi'\n    };\n  }\n\n  // https://github.com/threatstack/libmagic/blob/master/magic/Magdir/matroska\n  if (check([0x1A, 0x45, 0xDF, 0xA3])) {\n    const sliced = buf.subarray(4, 4 + 4096);\n    const idPos = sliced.findIndex((el, i, arr) => arr[i] === 0x42 && arr[i + 1] === 0x82);\n\n    if (idPos !== -1) {\n      const docTypePos = idPos + 3;\n      const findDocType = type => [...type].every((c, i) => sliced[docTypePos + i] === c.charCodeAt(0));\n\n      if (findDocType('matroska')) {\n        return {\n          ext: 'mkv',\n          mime: 'video/x-matroska'\n        };\n      }\n\n      if (findDocType('webm')) {\n        return {\n          ext: 'webm',\n          mime: 'video/webm'\n        };\n      }\n    }\n  }\n\n  if (check([0x0, 0x0, 0x0, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20]) ||\n    check([0x66, 0x72, 0x65, 0x65], { offset: 4 }) || // Type: `free`\n    check([0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20], { offset: 4 }) ||\n    check([0x6D, 0x64, 0x61, 0x74], { offset: 4 }) || // MJPEG\n    check([0x6D, 0x6F, 0x6F, 0x76], { offset: 4 }) || // Type: `moov`\n    check([0x77, 0x69, 0x64, 0x65], { offset: 4 })) {\n    return {\n      ext: 'mov',\n      mime: 'video/quicktime'\n    };\n  }\n\n  // RIFF file format which might be AVI, WAV, QCP, etc\n  if (check([0x52, 0x49, 0x46, 0x46])) {\n    if (check([0x41, 0x56, 0x49], { offset: 8 })) {\n      return {\n        ext: 'avi',\n        mime: 'video/vnd.avi'\n      };\n    }\n\n    if (check([0x57, 0x41, 0x56, 0x45], { offset: 8 })) {\n      return {\n        ext: 'wav',\n        mime: 'audio/vnd.wave'\n      };\n    }\n\n    // QLCM, QCP file\n    if (check([0x51, 0x4C, 0x43, 0x4D], { offset: 8 })) {\n      return {\n        ext: 'qcp',\n        mime: 'audio/qcelp'\n      };\n    }\n  }\n\n  // ASF_Header_Object first 80 bytes\n  if (check([0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, 0xA6, 0xD9])) {\n    // Search for header should be in first 1KB of file.\n\n    let offset = 30;\n    do {\n      const objectSize = readUInt64LE(buf, offset + 16);\n      if (check([0x91, 0x07, 0xDC, 0xB7, 0xB7, 0xA9, 0xCF, 0x11, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65], { offset })) {\n        // Sync on Stream-Properties-Object (B7DC0791-A9B7-11CF-8EE6-00C00C205365)\n        if (check([0x40, 0x9E, 0x69, 0xF8, 0x4D, 0x5B, 0xCF, 0x11, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B], { offset: offset + 24 })) {\n          // Found audio:\n          return {\n            ext: 'wma',\n            mime: 'audio/x-ms-wma'\n          };\n        }\n\n        if (check([0xC0, 0xEF, 0x19, 0xBC, 0x4D, 0x5B, 0xCF, 0x11, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B], { offset: offset + 24 })) {\n          // Found video:\n          return {\n            ext: 'wmv',\n            mime: 'video/x-ms-asf'\n          };\n        }\n\n        break;\n      }\n\n      offset += objectSize;\n    } while (offset + 24 <= buf.length);\n\n    // Default to ASF generic extension\n    return {\n      ext: 'asf',\n      mime: 'application/vnd.ms-asf'\n    };\n  }\n\n  if (\n    check([0x0, 0x0, 0x1, 0xBA]) ||\n    check([0x0, 0x0, 0x1, 0xB3])\n  ) {\n    return {\n      ext: 'mpg',\n      mime: 'video/mpeg'\n    };\n  }\n\n  if (check([0x66, 0x74, 0x79, 0x70, 0x33, 0x67], { offset: 4 })) {\n    return {\n      ext: '3gp',\n      mime: 'video/3gpp'\n    };\n  }\n\n  // Check for MPEG header at different starting offsets\n  for (let start = 0; start < 2 && start < (buf.length - 16); start++) {\n    if (\n      check([0x49, 0x44, 0x33], { offset: start }) || // ID3 header\n      check([0xFF, 0xE2], { offset: start, mask: [0xFF, 0xE2] }) // MPEG 1 or 2 Layer 3 header\n    ) {\n      return {\n        ext: 'mp3',\n        mime: 'audio/mpeg'\n      };\n    }\n\n    if (\n      check([0xFF, 0xE4], { offset: start, mask: [0xFF, 0xE4] }) // MPEG 1 or 2 Layer 2 header\n    ) {\n      return {\n        ext: 'mp2',\n        mime: 'audio/mpeg'\n      };\n    }\n\n    if (\n      check([0xFF, 0xF8], { offset: start, mask: [0xFF, 0xFC] }) // MPEG 2 layer 0 using ADTS\n    ) {\n      return {\n        ext: 'mp2',\n        mime: 'audio/mpeg'\n      };\n    }\n\n    if (\n      check([0xFF, 0xF0], { offset: start, mask: [0xFF, 0xFC] }) // MPEG 4 layer 0 using ADTS\n    ) {\n      return {\n        ext: 'mp4',\n        mime: 'audio/mpeg'\n      };\n    }\n  }\n\n  if (\n    check([0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41], { offset: 4 })\n  ) {\n    return { // MPEG-4 layer 3 (audio)\n      ext: 'm4a',\n      mime: 'audio/mp4' // RFC 4337\n    };\n  }\n\n  // Needs to be before `ogg` check\n  if (check([0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], { offset: 28 })) {\n    return {\n      ext: 'opus',\n      mime: 'audio/opus'\n    };\n  }\n\n  // If 'OggS' in first  bytes, then OGG container\n  if (check([0x4F, 0x67, 0x67, 0x53])) {\n    // This is a OGG container\n\n    // If ' theora' in header.\n    if (check([0x80, 0x74, 0x68, 0x65, 0x6F, 0x72, 0x61], { offset: 28 })) {\n      return {\n        ext: 'ogv',\n        mime: 'video/ogg'\n      };\n    }\n\n    // If '\\x01video' in header.\n    if (check([0x01, 0x76, 0x69, 0x64, 0x65, 0x6F, 0x00], { offset: 28 })) {\n      return {\n        ext: 'ogm',\n        mime: 'video/ogg'\n      };\n    }\n\n    // If ' FLAC' in header  https://xiph.org/flac/faq.html\n    if (check([0x7F, 0x46, 0x4C, 0x41, 0x43], { offset: 28 })) {\n      return {\n        ext: 'oga',\n        mime: 'audio/ogg'\n      };\n    }\n\n    // 'Speex  ' in header https://en.wikipedia.org/wiki/Speex\n    if (check([0x53, 0x70, 0x65, 0x65, 0x78, 0x20, 0x20], { offset: 28 })) {\n      return {\n        ext: 'spx',\n        mime: 'audio/ogg'\n      };\n    }\n\n    // If '\\x01vorbis' in header\n    if (check([0x01, 0x76, 0x6F, 0x72, 0x62, 0x69, 0x73], { offset: 28 })) {\n      return {\n        ext: 'ogg',\n        mime: 'audio/ogg'\n      };\n    }\n\n    // Default OGG container https://www.iana.org/assignments/media-types/application/ogg\n    return {\n      ext: 'ogx',\n      mime: 'application/ogg'\n    };\n  }\n\n  if (check([0x66, 0x4C, 0x61, 0x43])) {\n    return {\n      ext: 'flac',\n      mime: 'audio/x-flac'\n    };\n  }\n\n  if (check([0x4D, 0x41, 0x43, 0x20])) { // 'MAC '\n    return {\n      ext: 'ape',\n      mime: 'audio/ape'\n    };\n  }\n\n  if (check([0x77, 0x76, 0x70, 0x6B])) { // 'wvpk'\n    return {\n      ext: 'wv',\n      mime: 'audio/wavpack'\n    };\n  }\n\n  if (check([0x23, 0x21, 0x41, 0x4D, 0x52, 0x0A])) {\n    return {\n      ext: 'amr',\n      mime: 'audio/amr'\n    };\n  }\n\n  if (check([0x25, 0x50, 0x44, 0x46])) {\n    return {\n      ext: 'pdf',\n      mime: 'application/pdf'\n    };\n  }\n\n  if (check([0x4D, 0x5A])) {\n    return {\n      ext: 'exe',\n      mime: 'application/x-msdownload'\n    };\n  }\n\n  if (\n    (buf[0] === 0x43 || buf[0] === 0x46) &&\n    check([0x57, 0x53], { offset: 1 })\n  ) {\n    return {\n      ext: 'swf',\n      mime: 'application/x-shockwave-flash'\n    };\n  }\n\n  if (check([0x7B, 0x5C, 0x72, 0x74, 0x66])) {\n    return {\n      ext: 'rtf',\n      mime: 'application/rtf'\n    };\n  }\n\n  if (check([0x00, 0x61, 0x73, 0x6D])) {\n    return {\n      ext: 'wasm',\n      mime: 'application/wasm'\n    };\n  }\n\n  if (\n    check([0x77, 0x4F, 0x46, 0x46]) &&\n    (\n      check([0x00, 0x01, 0x00, 0x00], { offset: 4 }) ||\n      check([0x4F, 0x54, 0x54, 0x4F], { offset: 4 })\n    )\n  ) {\n    return {\n      ext: 'woff',\n      mime: 'font/woff'\n    };\n  }\n\n  if (\n    check([0x77, 0x4F, 0x46, 0x32]) &&\n    (\n      check([0x00, 0x01, 0x00, 0x00], { offset: 4 }) ||\n      check([0x4F, 0x54, 0x54, 0x4F], { offset: 4 })\n    )\n  ) {\n    return {\n      ext: 'woff2',\n      mime: 'font/woff2'\n    };\n  }\n\n  if (\n    check([0x4C, 0x50], { offset: 34 }) &&\n    (\n      check([0x00, 0x00, 0x01], { offset: 8 }) ||\n      check([0x01, 0x00, 0x02], { offset: 8 }) ||\n      check([0x02, 0x00, 0x02], { offset: 8 })\n    )\n  ) {\n    return {\n      ext: 'eot',\n      mime: 'application/vnd.ms-fontobject'\n    };\n  }\n\n  if (check([0x00, 0x01, 0x00, 0x00, 0x00])) {\n    return {\n      ext: 'ttf',\n      mime: 'font/ttf'\n    };\n  }\n\n  if (check([0x4F, 0x54, 0x54, 0x4F, 0x00])) {\n    return {\n      ext: 'otf',\n      mime: 'font/otf'\n    };\n  }\n\n  if (check([0x00, 0x00, 0x01, 0x00])) {\n    return {\n      ext: 'ico',\n      mime: 'image/x-icon'\n    };\n  }\n\n  if (check([0x00, 0x00, 0x02, 0x00])) {\n    return {\n      ext: 'cur',\n      mime: 'image/x-icon'\n    };\n  }\n\n  if (check([0x46, 0x4C, 0x56, 0x01])) {\n    return {\n      ext: 'flv',\n      mime: 'video/x-flv'\n    };\n  }\n\n  if (check([0x25, 0x21])) {\n    return {\n      ext: 'ps',\n      mime: 'application/postscript'\n    };\n  }\n\n  if (check([0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00])) {\n    return {\n      ext: 'xz',\n      mime: 'application/x-xz'\n    };\n  }\n\n  if (check([0x53, 0x51, 0x4C, 0x69])) {\n    return {\n      ext: 'sqlite',\n      mime: 'application/x-sqlite3'\n    };\n  }\n\n  if (check([0x4E, 0x45, 0x53, 0x1A])) {\n    return {\n      ext: 'nes',\n      mime: 'application/x-nintendo-nes-rom'\n    };\n  }\n\n  if (check([0x43, 0x72, 0x32, 0x34])) {\n    return {\n      ext: 'crx',\n      mime: 'application/x-google-chrome-extension'\n    };\n  }\n\n  if (\n    check([0x4D, 0x53, 0x43, 0x46]) ||\n    check([0x49, 0x53, 0x63, 0x28])\n  ) {\n    return {\n      ext: 'cab',\n      mime: 'application/vnd.ms-cab-compressed'\n    };\n  }\n\n  // Needs to be before `ar` check\n  if (check([0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A, 0x64, 0x65, 0x62, 0x69, 0x61, 0x6E, 0x2D, 0x62, 0x69, 0x6E, 0x61, 0x72, 0x79])) {\n    return {\n      ext: 'deb',\n      mime: 'application/x-deb'\n    };\n  }\n\n  if (check([0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E])) {\n    return {\n      ext: 'ar',\n      mime: 'application/x-unix-archive'\n    };\n  }\n\n  if (check([0xED, 0xAB, 0xEE, 0xDB])) {\n    return {\n      ext: 'rpm',\n      mime: 'application/x-rpm'\n    };\n  }\n\n  if (\n    check([0x1F, 0xA0]) ||\n    check([0x1F, 0x9D])\n  ) {\n    return {\n      ext: 'Z',\n      mime: 'application/x-compress'\n    };\n  }\n\n  if (check([0x4C, 0x5A, 0x49, 0x50])) {\n    return {\n      ext: 'lz',\n      mime: 'application/x-lzip'\n    };\n  }\n\n  if (check([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])) {\n    return {\n      ext: 'msi',\n      mime: 'application/x-msi'\n    };\n  }\n\n  if (check([0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x02])) {\n    return {\n      ext: 'mxf',\n      mime: 'application/mxf'\n    };\n  }\n\n  if (check([0x47], { offset: 4 }) && (check([0x47], { offset: 192 }) || check([0x47], { offset: 196 }))) {\n    return {\n      ext: 'mts',\n      mime: 'video/mp2t'\n    };\n  }\n\n  if (check([0x42, 0x4C, 0x45, 0x4E, 0x44, 0x45, 0x52])) {\n    return {\n      ext: 'blend',\n      mime: 'application/x-blender'\n    };\n  }\n\n  if (check([0x42, 0x50, 0x47, 0xFB])) {\n    return {\n      ext: 'bpg',\n      mime: 'image/bpg'\n    };\n  }\n\n  if (check([0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20, 0x0D, 0x0A, 0x87, 0x0A])) {\n    // JPEG-2000 family\n\n    if (check([0x6A, 0x70, 0x32, 0x20], { offset: 20 })) {\n      return {\n        ext: 'jp2',\n        mime: 'image/jp2'\n      };\n    }\n\n    if (check([0x6A, 0x70, 0x78, 0x20], { offset: 20 })) {\n      return {\n        ext: 'jpx',\n        mime: 'image/jpx'\n      };\n    }\n\n    if (check([0x6A, 0x70, 0x6D, 0x20], { offset: 20 })) {\n      return {\n        ext: 'jpm',\n        mime: 'image/jpm'\n      };\n    }\n\n    if (check([0x6D, 0x6A, 0x70, 0x32], { offset: 20 })) {\n      return {\n        ext: 'mj2',\n        mime: 'image/mj2'\n      };\n    }\n  }\n\n  if (check([0x46, 0x4F, 0x52, 0x4D])) {\n    return {\n      ext: 'aif',\n      mime: 'audio/aiff'\n    };\n  }\n\n  if (checkString('<?xml ')) {\n    return {\n      ext: 'xml',\n      mime: 'application/xml'\n    };\n  }\n\n  if (check([0x42, 0x4F, 0x4F, 0x4B, 0x4D, 0x4F, 0x42, 0x49], { offset: 60 })) {\n    return {\n      ext: 'mobi',\n      mime: 'application/x-mobipocket-ebook'\n    };\n  }\n\n  // File Type Box (https://en.wikipedia.org/wiki/ISO_base_media_file_format)\n  if (check([0x66, 0x74, 0x79, 0x70], { offset: 4 })) {\n    if (check([0x6D, 0x69, 0x66, 0x31], { offset: 8 })) {\n      return {\n        ext: 'heic',\n        mime: 'image/heif'\n      };\n    }\n\n    if (check([0x6D, 0x73, 0x66, 0x31], { offset: 8 })) {\n      return {\n        ext: 'heic',\n        mime: 'image/heif-sequence'\n      };\n    }\n\n    if (check([0x68, 0x65, 0x69, 0x63], { offset: 8 }) || check([0x68, 0x65, 0x69, 0x78], { offset: 8 })) {\n      return {\n        ext: 'heic',\n        mime: 'image/heic'\n      };\n    }\n\n    if (check([0x68, 0x65, 0x76, 0x63], { offset: 8 }) || check([0x68, 0x65, 0x76, 0x78], { offset: 8 })) {\n      return {\n        ext: 'heic',\n        mime: 'image/heic-sequence'\n      };\n    }\n  }\n\n  if (check([0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A])) {\n    return {\n      ext: 'ktx',\n      mime: 'image/ktx'\n    };\n  }\n\n  if (check([0x44, 0x49, 0x43, 0x4D], { offset: 128 })) {\n    return {\n      ext: 'dcm',\n      mime: 'application/dicom'\n    };\n  }\n\n  // Musepack, SV7\n  if (check([0x4D, 0x50, 0x2B])) {\n    return {\n      ext: 'mpc',\n      mime: 'audio/x-musepack'\n    };\n  }\n\n  // Musepack, SV8\n  if (check([0x4D, 0x50, 0x43, 0x4B])) {\n    return {\n      ext: 'mpc',\n      mime: 'audio/x-musepack'\n    };\n  }\n\n  if (check([0x42, 0x45, 0x47, 0x49, 0x4E, 0x3A])) {\n    return {\n      ext: 'ics',\n      mime: 'text/calendar'\n    };\n  }\n\n  if (check([0x67, 0x6C, 0x54, 0x46, 0x02, 0x00, 0x00, 0x00])) {\n    return {\n      ext: 'glb',\n      mime: 'model/gltf-binary'\n    };\n  }\n\n  if (check([0xD4, 0xC3, 0xB2, 0xA1]) || check([0xA1, 0xB2, 0xC3, 0xD4])) {\n    return {\n      ext: 'pcap',\n      mime: 'application/vnd.tcpdump.pcap'\n    };\n  }\n\n  return null;\n};\n\nmodule.exports = fileType;\n// TODO: Remove this for the next major release\nmodule.exports.default = fileType;\n\nObject.defineProperty(fileType, 'minimumBytes', { value: 4100 });\n\nmodule.exports.stream = readableStream => new Promise((resolve, reject) => {\n  // Using `eval` to work around issues when bundling with Webpack\n  const stream = eval('require')('stream'); // eslint-disable-line no-eval\n\n  readableStream.once('readable', () => {\n    const pass = new stream.PassThrough();\n    const chunk = readableStream.read(module.exports.minimumBytes) || readableStream.read();\n    try {\n      pass.fileType = fileType(chunk);\n    } catch (error) {\n      reject(error);\n    }\n\n    readableStream.unshift(chunk);\n\n    if (stream.pipeline) {\n      resolve(stream.pipeline(readableStream, pass, () => { }));\n    } else {\n      resolve(readableStream.pipe(pass));\n    }\n  });\n});\n"
  },
  {
    "path": "server/libs/imageType/index.js",
    "content": "'use strict';\nconst fileType = require('./fileType');\n\nconst imageExts = new Set([\n  'jpg',\n  'png',\n  'gif',\n  'webp',\n  'flif',\n  'cr2',\n  'tif',\n  'bmp',\n  'jxr',\n  'psd',\n  'ico',\n  'bpg',\n  'jp2',\n  'jpm',\n  'jpx',\n  'heic',\n  'cur',\n  'dcm'\n]);\n\nconst imageType = input => {\n  const ret = fileType(input);\n  return imageExts.has(ret && ret.ext) ? ret : null;\n};\n\nmodule.exports = imageType;\n// TODO: Remove this for the next major release\nmodule.exports.default = imageType;\n\nObject.defineProperty(imageType, 'minimumBytes', { value: fileType.minimumBytes });\n"
  },
  {
    "path": "server/libs/isexe/LICENSE",
    "content": "The ISC License\n\nCopyright (c) 2016-2022 Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/isexe/index.js",
    "content": "//\n// used by async\n// SOURCE: https://github.com/isaacs/isexe\n//\n\nvar core\nif (process.platform === 'win32' || global.TESTING_WINDOWS) {\n  core = require('./windows.js')\n} else {\n  core = require('./mode.js')\n}\n\nmodule.exports = isexe\nisexe.sync = sync\n\nfunction isexe(path, options, cb) {\n  if (typeof options === 'function') {\n    cb = options\n    options = {}\n  }\n\n  if (!cb) {\n    if (typeof Promise !== 'function') {\n      throw new TypeError('callback not provided')\n    }\n\n    return new Promise(function (resolve, reject) {\n      isexe(path, options || {}, function (er, is) {\n        if (er) {\n          reject(er)\n        } else {\n          resolve(is)\n        }\n      })\n    })\n  }\n\n  core(path, options || {}, function (er, is) {\n    // ignore EACCES because that just means we aren't allowed to run it\n    if (er) {\n      if (er.code === 'EACCES' || options && options.ignoreErrors) {\n        er = null\n        is = false\n      }\n    }\n    cb(er, is)\n  })\n}\n\nfunction sync(path, options) {\n  // my kingdom for a filtered catch\n  try {\n    return core.sync(path, options || {})\n  } catch (er) {\n    if (options && options.ignoreErrors || er.code === 'EACCES') {\n      return false\n    } else {\n      throw er\n    }\n  }\n}"
  },
  {
    "path": "server/libs/isexe/mode.js",
    "content": "module.exports = isexe\nisexe.sync = sync\n\nvar fs = require('fs')\n\nfunction isexe(path, options, cb) {\n  fs.stat(path, function (er, stat) {\n    cb(er, er ? false : checkStat(stat, options))\n  })\n}\n\nfunction sync(path, options) {\n  return checkStat(fs.statSync(path), options)\n}\n\nfunction checkStat(stat, options) {\n  return stat.isFile() && checkMode(stat, options)\n}\n\nfunction checkMode(stat, options) {\n  var mod = stat.mode\n  var uid = stat.uid\n  var gid = stat.gid\n\n  var myUid = options.uid !== undefined ?\n    options.uid : process.getuid && process.getuid()\n  var myGid = options.gid !== undefined ?\n    options.gid : process.getgid && process.getgid()\n\n  var u = parseInt('100', 8)\n  var g = parseInt('010', 8)\n  var o = parseInt('001', 8)\n  var ug = u | g\n\n  var ret = (mod & o) ||\n    (mod & g) && gid === myGid ||\n    (mod & u) && uid === myUid ||\n    (mod & ug) && myUid === 0\n\n  return ret\n}"
  },
  {
    "path": "server/libs/isexe/windows.js",
    "content": "module.exports = isexe\nisexe.sync = sync\n\nvar fs = require('fs')\n\nfunction checkPathExt(path, options) {\n  var pathext = options.pathExt !== undefined ?\n    options.pathExt : process.env.PATHEXT\n\n  if (!pathext) {\n    return true\n  }\n\n  pathext = pathext.split(';')\n  if (pathext.indexOf('') !== -1) {\n    return true\n  }\n  for (var i = 0; i < pathext.length; i++) {\n    var p = pathext[i].toLowerCase()\n    if (p && path.substr(-p.length).toLowerCase() === p) {\n      return true\n    }\n  }\n  return false\n}\n\nfunction checkStat(stat, path, options) {\n  if (!stat.isSymbolicLink() && !stat.isFile()) {\n    return false\n  }\n  return checkPathExt(path, options)\n}\n\nfunction isexe(path, options, cb) {\n  fs.stat(path, function (er, stat) {\n    cb(er, er ? false : checkStat(stat, path, options))\n  })\n}\n\nfunction sync(path, options) {\n  return checkStat(fs.statSync(path), path, options)\n}"
  },
  {
    "path": "server/libs/jsonwebtoken/LICENSE",
    "content": "The MIT License (MIT)\n \nCopyright (c) 2015 Auth0, Inc. <support@auth0.com> (http://auth0.com)\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.\n"
  },
  {
    "path": "server/libs/jsonwebtoken/decode.js",
    "content": "var jws = require('../jws');\n\nmodule.exports = function (jwt, options) {\n  options = options || {};\n  var decoded = jws.decode(jwt, options);\n  if (!decoded) { return null; }\n  var payload = decoded.payload;\n\n  //try parse the payload\n  if (typeof payload === 'string') {\n    try {\n      var obj = JSON.parse(payload);\n      if (obj !== null && typeof obj === 'object') {\n        payload = obj;\n      }\n    } catch (e) { }\n  }\n\n  //return header if `complete` option is enabled.  header includes claims\n  //such as `kid` and `alg` used to select the key within a JWKS needed to\n  //verify the signature\n  if (options.complete === true) {\n    return {\n      header: decoded.header,\n      payload: payload,\n      signature: decoded.signature\n    };\n  }\n  return payload;\n};\n"
  },
  {
    "path": "server/libs/jsonwebtoken/index.js",
    "content": "//\n// modified for use in audiobookshelf\n// Source: https://github.com/auth0/node-jsonwebtoken\n//\n\nmodule.exports = {\n  verify: require('./verify'),\n  sign: require('./sign'),\n  JsonWebTokenError: require('./lib/JsonWebTokenError'),\n  NotBeforeError: require('./lib/NotBeforeError'),\n  TokenExpiredError: require('./lib/TokenExpiredError'),\n};\n\nObject.defineProperty(module.exports, 'decode', {\n  enumerable: false,\n  value: require('./decode'),\n});\n"
  },
  {
    "path": "server/libs/jsonwebtoken/lib/JsonWebTokenError.js",
    "content": "var JsonWebTokenError = function (message, error) {\n  Error.call(this, message);\n  if(Error.captureStackTrace) {\n    Error.captureStackTrace(this, this.constructor);\n  }\n  this.name = 'JsonWebTokenError';\n  this.message = message;\n  if (error) this.inner = error;\n};\n\nJsonWebTokenError.prototype = Object.create(Error.prototype);\nJsonWebTokenError.prototype.constructor = JsonWebTokenError;\n\nmodule.exports = JsonWebTokenError;\n"
  },
  {
    "path": "server/libs/jsonwebtoken/lib/NotBeforeError.js",
    "content": "var JsonWebTokenError = require('./JsonWebTokenError');\n\nvar NotBeforeError = function (message, date) {\n  JsonWebTokenError.call(this, message);\n  this.name = 'NotBeforeError';\n  this.date = date;\n};\n\nNotBeforeError.prototype = Object.create(JsonWebTokenError.prototype);\n\nNotBeforeError.prototype.constructor = NotBeforeError;\n\nmodule.exports = NotBeforeError;"
  },
  {
    "path": "server/libs/jsonwebtoken/lib/TokenExpiredError.js",
    "content": "var JsonWebTokenError = require('./JsonWebTokenError');\n\nvar TokenExpiredError = function (message, expiredAt) {\n  JsonWebTokenError.call(this, message);\n  this.name = 'TokenExpiredError';\n  this.expiredAt = expiredAt;\n};\n\nTokenExpiredError.prototype = Object.create(JsonWebTokenError.prototype);\n\nTokenExpiredError.prototype.constructor = TokenExpiredError;\n\nmodule.exports = TokenExpiredError;"
  },
  {
    "path": "server/libs/jsonwebtoken/lib/timespan.js",
    "content": "const ms = require('ms')\n\nmodule.exports = function (time, iat) {\n  var timestamp = iat || Math.floor(Date.now() / 1000)\n\n  if (typeof time === 'string') {\n    var milliseconds = ms(time)\n    if (typeof milliseconds === 'undefined') {\n      return\n    }\n    return Math.floor(timestamp + milliseconds / 1000)\n  } else if (typeof time === 'number') {\n    return timestamp + time\n  } else {\n    return\n  }\n}\n"
  },
  {
    "path": "server/libs/jsonwebtoken/sign.js",
    "content": "var timespan = require('./lib/timespan');\nvar PS_SUPPORTED = true\nvar jws = require('../jws');\nvar once = require('../lodash.once');\n\nvar SUPPORTED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none'];\nif (PS_SUPPORTED) {\n  SUPPORTED_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');\n}\n\nfunction isPlainObject(value) {\n  var type = typeof value;\n  return !!value && type == 'object' && !Array.isArray(value);\n}\nfunction isInteger(val) {\n  return !isNaN(val) && val !== null && !String(val).includes('.')\n}\nfunction isNumber(val) {\n  return !isNaN(val) && val !== null\n}\nfunction isString(val) {\n  return typeof val == 'string'\n}\n\nvar sign_options_schema = {\n  expiresIn: { isValid: function (value) { return isInteger(value) || (isString(value) && value); }, message: '\"expiresIn\" should be a number of seconds or string representing a timespan' },\n  notBefore: { isValid: function (value) { return isInteger(value) || (isString(value) && value); }, message: '\"notBefore\" should be a number of seconds or string representing a timespan' },\n  audience: { isValid: function (value) { return isString(value) || Array.isArray(value); }, message: '\"audience\" must be a string or array' },\n  algorithm: { isValid: function (value) { return SUPPORTED_ALGS.includes(value); }, message: '\"algorithm\" must be a valid string enum value' },\n  header: { isValid: isPlainObject, message: '\"header\" must be an object' },\n  encoding: { isValid: isString, message: '\"encoding\" must be a string' },\n  issuer: { isValid: isString, message: '\"issuer\" must be a string' },\n  subject: { isValid: isString, message: '\"subject\" must be a string' },\n  jwtid: { isValid: isString, message: '\"jwtid\" must be a string' },\n  noTimestamp: { isValid: function (value) { return value === true || value === false; }, message: '\"noTimestamp\" must be a boolean' },\n  keyid: { isValid: isString, message: '\"keyid\" must be a string' },\n  mutatePayload: { isValid: function (value) { return value === true || value === false; }, message: '\"mutatePayload\" must be a boolean' }\n};\n\nvar registered_claims_schema = {\n  iat: { isValid: isNumber, message: '\"iat\" should be a number of seconds' },\n  exp: { isValid: isNumber, message: '\"exp\" should be a number of seconds' },\n  nbf: { isValid: isNumber, message: '\"nbf\" should be a number of seconds' }\n};\n\nfunction validate(schema, allowUnknown, object, parameterName) {\n  if (!isPlainObject(object)) {\n    throw new Error('Expected \"' + parameterName + '\" to be a plain object.');\n  }\n  Object.keys(object)\n    .forEach(function (key) {\n      var validator = schema[key];\n      if (!validator) {\n        if (!allowUnknown) {\n          throw new Error('\"' + key + '\" is not allowed in \"' + parameterName + '\"');\n        }\n        return;\n      }\n      if (!validator.isValid(object[key])) {\n        throw new Error(validator.message);\n      }\n    });\n}\n\nfunction validateOptions(options) {\n  return validate(sign_options_schema, false, options, 'options');\n}\n\nfunction validatePayload(payload) {\n  return validate(registered_claims_schema, true, payload, 'payload');\n}\n\nvar options_to_payload = {\n  'audience': 'aud',\n  'issuer': 'iss',\n  'subject': 'sub',\n  'jwtid': 'jti'\n};\n\nvar options_for_objects = [\n  'expiresIn',\n  'notBefore',\n  'noTimestamp',\n  'audience',\n  'issuer',\n  'subject',\n  'jwtid',\n];\n\nmodule.exports = function (payload, secretOrPrivateKey, options, callback) {\n  if (typeof options === 'function') {\n    callback = options;\n    options = {};\n  } else {\n    options = options || {};\n  }\n\n  var isObjectPayload = typeof payload === 'object' &&\n    !Buffer.isBuffer(payload);\n\n  var header = Object.assign({\n    alg: options.algorithm || 'HS256',\n    typ: isObjectPayload ? 'JWT' : undefined,\n    kid: options.keyid\n  }, options.header);\n\n  function failure(err) {\n    if (callback) {\n      return callback(err);\n    }\n    throw err;\n  }\n\n  if (!secretOrPrivateKey && options.algorithm !== 'none') {\n    return failure(new Error('secretOrPrivateKey must have a value'));\n  }\n\n  if (typeof payload === 'undefined') {\n    return failure(new Error('payload is required'));\n  } else if (isObjectPayload) {\n    try {\n      validatePayload(payload);\n    }\n    catch (error) {\n      return failure(error);\n    }\n    if (!options.mutatePayload) {\n      payload = Object.assign({}, payload);\n    }\n  } else {\n    var invalid_options = options_for_objects.filter(function (opt) {\n      return typeof options[opt] !== 'undefined';\n    });\n\n    if (invalid_options.length > 0) {\n      return failure(new Error('invalid ' + invalid_options.join(',') + ' option for ' + (typeof payload) + ' payload'));\n    }\n  }\n\n  if (typeof payload.exp !== 'undefined' && typeof options.expiresIn !== 'undefined') {\n    return failure(new Error('Bad \"options.expiresIn\" option the payload already has an \"exp\" property.'));\n  }\n\n  if (typeof payload.nbf !== 'undefined' && typeof options.notBefore !== 'undefined') {\n    return failure(new Error('Bad \"options.notBefore\" option the payload already has an \"nbf\" property.'));\n  }\n\n  try {\n    validateOptions(options);\n  }\n  catch (error) {\n    return failure(error);\n  }\n\n  var timestamp = payload.iat || Math.floor(Date.now() / 1000);\n\n  if (options.noTimestamp) {\n    delete payload.iat;\n  } else if (isObjectPayload) {\n    payload.iat = timestamp;\n  }\n\n  if (typeof options.notBefore !== 'undefined') {\n    try {\n      payload.nbf = timespan(options.notBefore, timestamp);\n    }\n    catch (err) {\n      return failure(err);\n    }\n    if (typeof payload.nbf === 'undefined') {\n      return failure(new Error('\"notBefore\" should be a number of seconds or string representing a timespan eg: \"1d\", \"20h\", 60'));\n    }\n  }\n\n  if (typeof options.expiresIn !== 'undefined' && typeof payload === 'object') {\n    try {\n      payload.exp = timespan(options.expiresIn, timestamp);\n    }\n    catch (err) {\n      return failure(err);\n    }\n    if (typeof payload.exp === 'undefined') {\n      return failure(new Error('\"expiresIn\" should be a number of seconds or string representing a timespan eg: \"1d\", \"20h\", 60'));\n    }\n  }\n\n  Object.keys(options_to_payload).forEach(function (key) {\n    var claim = options_to_payload[key];\n    if (typeof options[key] !== 'undefined') {\n      if (typeof payload[claim] !== 'undefined') {\n        return failure(new Error('Bad \"options.' + key + '\" option. The payload already has an \"' + claim + '\" property.'));\n      }\n      payload[claim] = options[key];\n    }\n  });\n\n  var encoding = options.encoding || 'utf8';\n\n  if (typeof callback === 'function') {\n    callback = callback && once(callback);\n\n    jws.createSign({\n      header: header,\n      privateKey: secretOrPrivateKey,\n      payload: payload,\n      encoding: encoding\n    }).once('error', callback)\n      .once('done', function (signature) {\n        callback(null, signature);\n      });\n  } else {\n    return jws.sign({ header: header, payload: payload, secret: secretOrPrivateKey, encoding: encoding });\n  }\n};\n"
  },
  {
    "path": "server/libs/jsonwebtoken/verify.js",
    "content": "var JsonWebTokenError = require('./lib/JsonWebTokenError');\nvar NotBeforeError = require('./lib/NotBeforeError');\nvar TokenExpiredError = require('./lib/TokenExpiredError');\nvar decode = require('./decode');\nvar timespan = require('./lib/timespan');\nvar PS_SUPPORTED = true\nvar jws = require('../jws');\n\nvar PUB_KEY_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'];\nvar RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512'];\nvar HS_ALGS = ['HS256', 'HS384', 'HS512'];\n\nif (PS_SUPPORTED) {\n  PUB_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');\n  RSA_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512');\n}\n\nmodule.exports = function (jwtString, secretOrPublicKey, options, callback) {\n  if ((typeof options === 'function') && !callback) {\n    callback = options;\n    options = {};\n  }\n\n  if (!options) {\n    options = {};\n  }\n\n  //clone this object since we are going to mutate it.\n  options = Object.assign({}, options);\n\n  var done;\n\n  if (callback) {\n    done = callback;\n  } else {\n    done = function (err, data) {\n      if (err) throw err;\n      return data;\n    };\n  }\n\n  if (options.clockTimestamp && typeof options.clockTimestamp !== 'number') {\n    return done(new JsonWebTokenError('clockTimestamp must be a number'));\n  }\n\n  if (options.nonce !== undefined && (typeof options.nonce !== 'string' || options.nonce.trim() === '')) {\n    return done(new JsonWebTokenError('nonce must be a non-empty string'));\n  }\n\n  var clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000);\n\n  if (!jwtString) {\n    return done(new JsonWebTokenError('jwt must be provided'));\n  }\n\n  if (typeof jwtString !== 'string') {\n    return done(new JsonWebTokenError('jwt must be a string'));\n  }\n\n  var parts = jwtString.split('.');\n\n  if (parts.length !== 3) {\n    return done(new JsonWebTokenError('jwt malformed'));\n  }\n\n  var decodedToken;\n\n  try {\n    decodedToken = decode(jwtString, { complete: true });\n  } catch (err) {\n    return done(err);\n  }\n\n  if (!decodedToken) {\n    return done(new JsonWebTokenError('invalid token'));\n  }\n\n  var header = decodedToken.header;\n  var getSecret;\n\n  if (typeof secretOrPublicKey === 'function') {\n    if (!callback) {\n      return done(new JsonWebTokenError('verify must be called asynchronous if secret or public key is provided as a callback'));\n    }\n\n    getSecret = secretOrPublicKey;\n  }\n  else {\n    getSecret = function (header, secretCallback) {\n      return secretCallback(null, secretOrPublicKey);\n    };\n  }\n\n  return getSecret(header, function (err, secretOrPublicKey) {\n    if (err) {\n      return done(new JsonWebTokenError('error in secret or public key callback: ' + err.message));\n    }\n\n    var hasSignature = parts[2].trim() !== '';\n\n    if (!hasSignature && secretOrPublicKey) {\n      return done(new JsonWebTokenError('jwt signature is required'));\n    }\n\n    if (hasSignature && !secretOrPublicKey) {\n      return done(new JsonWebTokenError('secret or public key must be provided'));\n    }\n\n    if (!hasSignature && !options.algorithms) {\n      options.algorithms = ['none'];\n    }\n\n    if (!options.algorithms) {\n      options.algorithms = secretOrPublicKey.toString().includes('BEGIN CERTIFICATE') ||\n        secretOrPublicKey.toString().includes('BEGIN PUBLIC KEY') ? PUB_KEY_ALGS :\n        secretOrPublicKey.toString().includes('BEGIN RSA PUBLIC KEY') ? RSA_KEY_ALGS : HS_ALGS;\n\n    }\n\n    if (!~options.algorithms.indexOf(decodedToken.header.alg)) {\n      return done(new JsonWebTokenError('invalid algorithm'));\n    }\n\n    var valid;\n\n    try {\n      valid = jws.verify(jwtString, decodedToken.header.alg, secretOrPublicKey);\n    } catch (e) {\n      return done(e);\n    }\n\n    if (!valid) {\n      return done(new JsonWebTokenError('invalid signature'));\n    }\n\n    var payload = decodedToken.payload;\n\n    if (typeof payload.nbf !== 'undefined' && !options.ignoreNotBefore) {\n      if (typeof payload.nbf !== 'number') {\n        return done(new JsonWebTokenError('invalid nbf value'));\n      }\n      if (payload.nbf > clockTimestamp + (options.clockTolerance || 0)) {\n        return done(new NotBeforeError('jwt not active', new Date(payload.nbf * 1000)));\n      }\n    }\n\n    if (typeof payload.exp !== 'undefined' && !options.ignoreExpiration) {\n      if (typeof payload.exp !== 'number') {\n        return done(new JsonWebTokenError('invalid exp value'));\n      }\n      if (clockTimestamp >= payload.exp + (options.clockTolerance || 0)) {\n        return done(new TokenExpiredError('jwt expired', new Date(payload.exp * 1000)));\n      }\n    }\n\n    if (options.audience) {\n      var audiences = Array.isArray(options.audience) ? options.audience : [options.audience];\n      var target = Array.isArray(payload.aud) ? payload.aud : [payload.aud];\n\n      var match = target.some(function (targetAudience) {\n        return audiences.some(function (audience) {\n          return audience instanceof RegExp ? audience.test(targetAudience) : audience === targetAudience;\n        });\n      });\n\n      if (!match) {\n        return done(new JsonWebTokenError('jwt audience invalid. expected: ' + audiences.join(' or ')));\n      }\n    }\n\n    if (options.issuer) {\n      var invalid_issuer =\n        (typeof options.issuer === 'string' && payload.iss !== options.issuer) ||\n        (Array.isArray(options.issuer) && options.issuer.indexOf(payload.iss) === -1);\n\n      if (invalid_issuer) {\n        return done(new JsonWebTokenError('jwt issuer invalid. expected: ' + options.issuer));\n      }\n    }\n\n    if (options.subject) {\n      if (payload.sub !== options.subject) {\n        return done(new JsonWebTokenError('jwt subject invalid. expected: ' + options.subject));\n      }\n    }\n\n    if (options.jwtid) {\n      if (payload.jti !== options.jwtid) {\n        return done(new JsonWebTokenError('jwt jwtid invalid. expected: ' + options.jwtid));\n      }\n    }\n\n    if (options.nonce) {\n      if (payload.nonce !== options.nonce) {\n        return done(new JsonWebTokenError('jwt nonce invalid. expected: ' + options.nonce));\n      }\n    }\n\n    if (options.maxAge) {\n      if (typeof payload.iat !== 'number') {\n        return done(new JsonWebTokenError('iat required when maxAge is specified'));\n      }\n\n      var maxAgeTimestamp = timespan(options.maxAge, payload.iat);\n      if (typeof maxAgeTimestamp === 'undefined') {\n        return done(new JsonWebTokenError('\"maxAge\" should be a number of seconds or string representing a timespan eg: \"1d\", \"20h\", 60'));\n      }\n      if (clockTimestamp >= maxAgeTimestamp + (options.clockTolerance || 0)) {\n        return done(new TokenExpiredError('maxAge exceeded', new Date(maxAgeTimestamp * 1000)));\n      }\n    }\n\n    if (options.complete === true) {\n      var signature = decodedToken.signature;\n\n      return done(null, {\n        header: header,\n        payload: payload,\n        signature: signature\n      });\n    }\n\n    return done(null, payload);\n  });\n};\n"
  },
  {
    "path": "server/libs/jwa/LICENSE",
    "content": "Copyright (c) 2013 Brian J. Brennan\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 in \nthe Software without restriction, including without limitation the rights to use, \ncopy, modify, merge, publish, distribute, sublicense, and/or sell copies of the \nSoftware, and to permit persons to whom the Software is furnished to do so, \nsubject 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 IMPLIED, \nINCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR \nPURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE\nFOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/jwa/buffer-equal-constant-time/LICENSE",
    "content": "Copyright (c) 2013, GoInstant Inc., a salesforce.com company\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\n\n* Neither the name of salesforce.com, nor GoInstant, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "server/libs/jwa/buffer-equal-constant-time/index.js",
    "content": "/*jshint node:true */\n'use strict';\nvar Buffer = require('buffer').Buffer; // browserify\nvar SlowBuffer = require('buffer').SlowBuffer;\n\nmodule.exports = bufferEq;\n\nfunction bufferEq(a, b) {\n\n  // shortcutting on type is necessary for correctness\n  if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) {\n    return false;\n  }\n\n  // buffer sizes should be well-known information, so despite this\n  // shortcutting, it doesn't leak any information about the *contents* of the\n  // buffers.\n  if (a.length !== b.length) {\n    return false;\n  }\n\n  var c = 0;\n  for (var i = 0; i < a.length; i++) {\n    /*jshint bitwise:false */\n    c |= a[i] ^ b[i]; // XOR\n  }\n  return c === 0;\n}\n\nbufferEq.install = function () {\n  Buffer.prototype.equal = SlowBuffer.prototype.equal = function equal(that) {\n    return bufferEq(this, that);\n  };\n};\n\nvar origBufEqual = Buffer.prototype.equal;\nvar origSlowBufEqual = SlowBuffer.prototype.equal;\nbufferEq.restore = function () {\n  Buffer.prototype.equal = origBufEqual;\n  SlowBuffer.prototype.equal = origSlowBufEqual;\n};\n"
  },
  {
    "path": "server/libs/jwa/ecdsa-sig-formatter/LICENSE",
    "content": "Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2015 D2L Corporation\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "server/libs/jwa/ecdsa-sig-formatter/index.js",
    "content": "'use strict';\n\nvar Buffer = require('safe-buffer').Buffer;\n\nvar getParamBytesForAlg = require('./param-bytes-for-alg');\n\nvar MAX_OCTET = 0x80,\n  CLASS_UNIVERSAL = 0,\n  PRIMITIVE_BIT = 0x20,\n  TAG_SEQ = 0x10,\n  TAG_INT = 0x02,\n  ENCODED_TAG_SEQ = (TAG_SEQ | PRIMITIVE_BIT) | (CLASS_UNIVERSAL << 6),\n  ENCODED_TAG_INT = TAG_INT | (CLASS_UNIVERSAL << 6);\n\nfunction base64Url(base64) {\n  return base64\n    .replace(/=/g, '')\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_');\n}\n\nfunction signatureAsBuffer(signature) {\n  if (Buffer.isBuffer(signature)) {\n    return signature;\n  } else if ('string' === typeof signature) {\n    return Buffer.from(signature, 'base64');\n  }\n\n  throw new TypeError('ECDSA signature must be a Base64 string or a Buffer');\n}\n\nfunction derToJose(signature, alg) {\n  signature = signatureAsBuffer(signature);\n  var paramBytes = getParamBytesForAlg(alg);\n\n  // the DER encoded param should at most be the param size, plus a padding\n  // zero, since due to being a signed integer\n  var maxEncodedParamLength = paramBytes + 1;\n\n  var inputLength = signature.length;\n\n  var offset = 0;\n  if (signature[offset++] !== ENCODED_TAG_SEQ) {\n    throw new Error('Could not find expected \"seq\"');\n  }\n\n  var seqLength = signature[offset++];\n  if (seqLength === (MAX_OCTET | 1)) {\n    seqLength = signature[offset++];\n  }\n\n  if (inputLength - offset < seqLength) {\n    throw new Error('\"seq\" specified length of \"' + seqLength + '\", only \"' + (inputLength - offset) + '\" remaining');\n  }\n\n  if (signature[offset++] !== ENCODED_TAG_INT) {\n    throw new Error('Could not find expected \"int\" for \"r\"');\n  }\n\n  var rLength = signature[offset++];\n\n  if (inputLength - offset - 2 < rLength) {\n    throw new Error('\"r\" specified length of \"' + rLength + '\", only \"' + (inputLength - offset - 2) + '\" available');\n  }\n\n  if (maxEncodedParamLength < rLength) {\n    throw new Error('\"r\" specified length of \"' + rLength + '\", max of \"' + maxEncodedParamLength + '\" is acceptable');\n  }\n\n  var rOffset = offset;\n  offset += rLength;\n\n  if (signature[offset++] !== ENCODED_TAG_INT) {\n    throw new Error('Could not find expected \"int\" for \"s\"');\n  }\n\n  var sLength = signature[offset++];\n\n  if (inputLength - offset !== sLength) {\n    throw new Error('\"s\" specified length of \"' + sLength + '\", expected \"' + (inputLength - offset) + '\"');\n  }\n\n  if (maxEncodedParamLength < sLength) {\n    throw new Error('\"s\" specified length of \"' + sLength + '\", max of \"' + maxEncodedParamLength + '\" is acceptable');\n  }\n\n  var sOffset = offset;\n  offset += sLength;\n\n  if (offset !== inputLength) {\n    throw new Error('Expected to consume entire buffer, but \"' + (inputLength - offset) + '\" bytes remain');\n  }\n\n  var rPadding = paramBytes - rLength,\n    sPadding = paramBytes - sLength;\n\n  var dst = Buffer.allocUnsafe(rPadding + rLength + sPadding + sLength);\n\n  for (offset = 0; offset < rPadding; ++offset) {\n    dst[offset] = 0;\n  }\n  signature.copy(dst, offset, rOffset + Math.max(-rPadding, 0), rOffset + rLength);\n\n  offset = paramBytes;\n\n  for (var o = offset; offset < o + sPadding; ++offset) {\n    dst[offset] = 0;\n  }\n  signature.copy(dst, offset, sOffset + Math.max(-sPadding, 0), sOffset + sLength);\n\n  dst = dst.toString('base64');\n  dst = base64Url(dst);\n\n  return dst;\n}\n\nfunction countPadding(buf, start, stop) {\n  var padding = 0;\n  while (start + padding < stop && buf[start + padding] === 0) {\n    ++padding;\n  }\n\n  var needsSign = buf[start + padding] >= MAX_OCTET;\n  if (needsSign) {\n    --padding;\n  }\n\n  return padding;\n}\n\nfunction joseToDer(signature, alg) {\n  signature = signatureAsBuffer(signature);\n  var paramBytes = getParamBytesForAlg(alg);\n\n  var signatureBytes = signature.length;\n  if (signatureBytes !== paramBytes * 2) {\n    throw new TypeError('\"' + alg + '\" signatures must be \"' + paramBytes * 2 + '\" bytes, saw \"' + signatureBytes + '\"');\n  }\n\n  var rPadding = countPadding(signature, 0, paramBytes);\n  var sPadding = countPadding(signature, paramBytes, signature.length);\n  var rLength = paramBytes - rPadding;\n  var sLength = paramBytes - sPadding;\n\n  var rsBytes = 1 + 1 + rLength + 1 + 1 + sLength;\n\n  var shortLength = rsBytes < MAX_OCTET;\n\n  var dst = Buffer.allocUnsafe((shortLength ? 2 : 3) + rsBytes);\n\n  var offset = 0;\n  dst[offset++] = ENCODED_TAG_SEQ;\n  if (shortLength) {\n    // Bit 8 has value \"0\"\n    // bits 7-1 give the length.\n    dst[offset++] = rsBytes;\n  } else {\n    // Bit 8 of first octet has value \"1\"\n    // bits 7-1 give the number of additional length octets.\n    dst[offset++] = MAX_OCTET | 1;\n    // length, base 256\n    dst[offset++] = rsBytes & 0xff;\n  }\n  dst[offset++] = ENCODED_TAG_INT;\n  dst[offset++] = rLength;\n  if (rPadding < 0) {\n    dst[offset++] = 0;\n    offset += signature.copy(dst, offset, 0, paramBytes);\n  } else {\n    offset += signature.copy(dst, offset, rPadding, paramBytes);\n  }\n  dst[offset++] = ENCODED_TAG_INT;\n  dst[offset++] = sLength;\n  if (sPadding < 0) {\n    dst[offset++] = 0;\n    signature.copy(dst, offset, paramBytes);\n  } else {\n    signature.copy(dst, offset, paramBytes + sPadding);\n  }\n\n  return dst;\n}\n\nmodule.exports = {\n  derToJose: derToJose,\n  joseToDer: joseToDer\n};"
  },
  {
    "path": "server/libs/jwa/ecdsa-sig-formatter/param-bytes-for-alg.js",
    "content": "'use strict';\n\nfunction getParamSize(keySize) {\n  var result = ((keySize / 8) | 0) + (keySize % 8 === 0 ? 0 : 1);\n  return result;\n}\n\nvar paramBytesForAlg = {\n  ES256: getParamSize(256),\n  ES384: getParamSize(384),\n  ES512: getParamSize(521)\n};\n\nfunction getParamBytesForAlg(alg) {\n  var paramBytes = paramBytesForAlg[alg];\n  if (paramBytes) {\n    return paramBytes;\n  }\n\n  throw new Error('Unknown algorithm \"' + alg + '\"');\n}\n\nmodule.exports = getParamBytesForAlg;"
  },
  {
    "path": "server/libs/jwa/index.js",
    "content": "//\n// used by jws\n// Source: https://github.com/auth0/node-jwa\n//\n\nvar bufferEqual = require('./buffer-equal-constant-time');\nvar Buffer = require('safe-buffer').Buffer;\nvar crypto = require('crypto');\nvar formatEcdsa = require('./ecdsa-sig-formatter');\nvar util = require('util');\n\nvar MSG_INVALID_ALGORITHM = '\"%s\" is not a valid algorithm.\\n  Supported algorithms are:\\n  \"HS256\", \"HS384\", \"HS512\", \"RS256\", \"RS384\", \"RS512\", \"PS256\", \"PS384\", \"PS512\", \"ES256\", \"ES384\", \"ES512\" and \"none\".'\nvar MSG_INVALID_SECRET = 'secret must be a string or buffer';\nvar MSG_INVALID_VERIFIER_KEY = 'key must be a string or a buffer';\nvar MSG_INVALID_SIGNER_KEY = 'key must be a string, a buffer or an object';\n\nvar supportsKeyObjects = typeof crypto.createPublicKey === 'function';\nif (supportsKeyObjects) {\n  MSG_INVALID_VERIFIER_KEY += ' or a KeyObject';\n  MSG_INVALID_SECRET += 'or a KeyObject';\n}\n\nfunction checkIsPublicKey(key) {\n  if (Buffer.isBuffer(key)) {\n    return;\n  }\n\n  if (typeof key === 'string') {\n    return;\n  }\n\n  if (!supportsKeyObjects) {\n    throw typeError(MSG_INVALID_VERIFIER_KEY);\n  }\n\n  if (typeof key !== 'object') {\n    throw typeError(MSG_INVALID_VERIFIER_KEY);\n  }\n\n  if (typeof key.type !== 'string') {\n    throw typeError(MSG_INVALID_VERIFIER_KEY);\n  }\n\n  if (typeof key.asymmetricKeyType !== 'string') {\n    throw typeError(MSG_INVALID_VERIFIER_KEY);\n  }\n\n  if (typeof key.export !== 'function') {\n    throw typeError(MSG_INVALID_VERIFIER_KEY);\n  }\n};\n\nfunction checkIsPrivateKey(key) {\n  if (Buffer.isBuffer(key)) {\n    return;\n  }\n\n  if (typeof key === 'string') {\n    return;\n  }\n\n  if (typeof key === 'object') {\n    return;\n  }\n\n  throw typeError(MSG_INVALID_SIGNER_KEY);\n};\n\nfunction checkIsSecretKey(key) {\n  if (Buffer.isBuffer(key)) {\n    return;\n  }\n\n  if (typeof key === 'string') {\n    return key;\n  }\n\n  if (!supportsKeyObjects) {\n    throw typeError(MSG_INVALID_SECRET);\n  }\n\n  if (typeof key !== 'object') {\n    throw typeError(MSG_INVALID_SECRET);\n  }\n\n  if (key.type !== 'secret') {\n    throw typeError(MSG_INVALID_SECRET);\n  }\n\n  if (typeof key.export !== 'function') {\n    throw typeError(MSG_INVALID_SECRET);\n  }\n}\n\nfunction fromBase64(base64) {\n  return base64\n    .replace(/=/g, '')\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_');\n}\n\nfunction toBase64(base64url) {\n  base64url = base64url.toString();\n\n  var padding = 4 - base64url.length % 4;\n  if (padding !== 4) {\n    for (var i = 0; i < padding; ++i) {\n      base64url += '=';\n    }\n  }\n\n  return base64url\n    .replace(/\\-/g, '+')\n    .replace(/_/g, '/');\n}\n\nfunction typeError(template) {\n  var args = [].slice.call(arguments, 1);\n  var errMsg = util.format.bind(util, template).apply(null, args);\n  return new TypeError(errMsg);\n}\n\nfunction bufferOrString(obj) {\n  return Buffer.isBuffer(obj) || typeof obj === 'string';\n}\n\nfunction normalizeInput(thing) {\n  if (!bufferOrString(thing))\n    thing = JSON.stringify(thing);\n  return thing;\n}\n\nfunction createHmacSigner(bits) {\n  return function sign(thing, secret) {\n    checkIsSecretKey(secret);\n    thing = normalizeInput(thing);\n    var hmac = crypto.createHmac('sha' + bits, secret);\n    var sig = (hmac.update(thing), hmac.digest('base64'))\n    return fromBase64(sig);\n  }\n}\n\nfunction createHmacVerifier(bits) {\n  return function verify(thing, signature, secret) {\n    var computedSig = createHmacSigner(bits)(thing, secret);\n    return bufferEqual(Buffer.from(signature), Buffer.from(computedSig));\n  }\n}\n\nfunction createKeySigner(bits) {\n  return function sign(thing, privateKey) {\n    checkIsPrivateKey(privateKey);\n    thing = normalizeInput(thing);\n    // Even though we are specifying \"RSA\" here, this works with ECDSA\n    // keys as well.\n    var signer = crypto.createSign('RSA-SHA' + bits);\n    var sig = (signer.update(thing), signer.sign(privateKey, 'base64'));\n    return fromBase64(sig);\n  }\n}\n\nfunction createKeyVerifier(bits) {\n  return function verify(thing, signature, publicKey) {\n    checkIsPublicKey(publicKey);\n    thing = normalizeInput(thing);\n    signature = toBase64(signature);\n    var verifier = crypto.createVerify('RSA-SHA' + bits);\n    verifier.update(thing);\n    return verifier.verify(publicKey, signature, 'base64');\n  }\n}\n\nfunction createPSSKeySigner(bits) {\n  return function sign(thing, privateKey) {\n    checkIsPrivateKey(privateKey);\n    thing = normalizeInput(thing);\n    var signer = crypto.createSign('RSA-SHA' + bits);\n    var sig = (signer.update(thing), signer.sign({\n      key: privateKey,\n      padding: crypto.constants.RSA_PKCS1_PSS_PADDING,\n      saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST\n    }, 'base64'));\n    return fromBase64(sig);\n  }\n}\n\nfunction createPSSKeyVerifier(bits) {\n  return function verify(thing, signature, publicKey) {\n    checkIsPublicKey(publicKey);\n    thing = normalizeInput(thing);\n    signature = toBase64(signature);\n    var verifier = crypto.createVerify('RSA-SHA' + bits);\n    verifier.update(thing);\n    return verifier.verify({\n      key: publicKey,\n      padding: crypto.constants.RSA_PKCS1_PSS_PADDING,\n      saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST\n    }, signature, 'base64');\n  }\n}\n\nfunction createECDSASigner(bits) {\n  var inner = createKeySigner(bits);\n  return function sign() {\n    var signature = inner.apply(null, arguments);\n    signature = formatEcdsa.derToJose(signature, 'ES' + bits);\n    return signature;\n  };\n}\n\nfunction createECDSAVerifer(bits) {\n  var inner = createKeyVerifier(bits);\n  return function verify(thing, signature, publicKey) {\n    signature = formatEcdsa.joseToDer(signature, 'ES' + bits).toString('base64');\n    var result = inner(thing, signature, publicKey);\n    return result;\n  };\n}\n\nfunction createNoneSigner() {\n  return function sign() {\n    return '';\n  }\n}\n\nfunction createNoneVerifier() {\n  return function verify(thing, signature) {\n    return signature === '';\n  }\n}\n\nmodule.exports = function jwa(algorithm) {\n  var signerFactories = {\n    hs: createHmacSigner,\n    rs: createKeySigner,\n    ps: createPSSKeySigner,\n    es: createECDSASigner,\n    none: createNoneSigner,\n  }\n  var verifierFactories = {\n    hs: createHmacVerifier,\n    rs: createKeyVerifier,\n    ps: createPSSKeyVerifier,\n    es: createECDSAVerifer,\n    none: createNoneVerifier,\n  }\n  var match = algorithm.match(/^(RS|PS|ES|HS)(256|384|512)$|^(none)$/);\n  if (!match)\n    throw typeError(MSG_INVALID_ALGORITHM, algorithm);\n  var algo = (match[1] || match[3]).toLowerCase();\n  var bits = match[2];\n\n  return {\n    sign: signerFactories[algo](bits),\n    verify: verifierFactories[algo](bits),\n  }\n};"
  },
  {
    "path": "server/libs/jws/LICENSE",
    "content": "Copyright (c) 2013 Brian J. Brennan\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 in \nthe Software without restriction, including without limitation the rights to use, \ncopy, modify, merge, publish, distribute, sublicense, and/or sell copies of the \nSoftware, and to permit persons to whom the Software is furnished to do so, \nsubject 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 IMPLIED, \nINCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR \nPURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE\nFOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/jws/index.js",
    "content": "//\n// used by jsonwebtoken\n// Source: https://github.com/auth0/node-jws\n//\n\n/*global exports*/\nvar SignStream = require('./lib/sign-stream');\nvar VerifyStream = require('./lib/verify-stream');\n\nvar ALGORITHMS = [\n  'HS256', 'HS384', 'HS512',\n  'RS256', 'RS384', 'RS512',\n  'PS256', 'PS384', 'PS512',\n  'ES256', 'ES384', 'ES512'\n];\n\nexports.ALGORITHMS = ALGORITHMS;\nexports.sign = SignStream.sign;\nexports.verify = VerifyStream.verify;\nexports.decode = VerifyStream.decode;\nexports.isValid = VerifyStream.isValid;\nexports.createSign = function createSign(opts) {\n  return new SignStream(opts);\n};\nexports.createVerify = function createVerify(opts) {\n  return new VerifyStream(opts);\n};"
  },
  {
    "path": "server/libs/jws/lib/data-stream.js",
    "content": "/*global module, process*/\nvar Buffer = require('safe-buffer').Buffer;\nvar Stream = require('stream');\nvar util = require('util');\n\nfunction DataStream(data) {\n  this.buffer = null;\n  this.writable = true;\n  this.readable = true;\n\n  // No input\n  if (!data) {\n    this.buffer = Buffer.alloc(0);\n    return this;\n  }\n\n  // Stream\n  if (typeof data.pipe === 'function') {\n    this.buffer = Buffer.alloc(0);\n    data.pipe(this);\n    return this;\n  }\n\n  // Buffer or String\n  // or Object (assumedly a passworded key)\n  if (data.length || typeof data === 'object') {\n    this.buffer = data;\n    this.writable = false;\n    process.nextTick(function () {\n      this.emit('end', data);\n      this.readable = false;\n      this.emit('close');\n    }.bind(this));\n    return this;\n  }\n\n  throw new TypeError('Unexpected data type (' + typeof data + ')');\n}\nutil.inherits(DataStream, Stream);\n\nDataStream.prototype.write = function write(data) {\n  this.buffer = Buffer.concat([this.buffer, Buffer.from(data)]);\n  this.emit('data', data);\n};\n\nDataStream.prototype.end = function end(data) {\n  if (data)\n    this.write(data);\n  this.emit('end', data);\n  this.emit('close');\n  this.writable = false;\n  this.readable = false;\n};\n\nmodule.exports = DataStream;"
  },
  {
    "path": "server/libs/jws/lib/sign-stream.js",
    "content": "/*global module*/\nvar Buffer = require('safe-buffer').Buffer;\nvar DataStream = require('./data-stream');\nvar jwa = require('../../jwa');\nvar Stream = require('stream');\nvar toString = require('./tostring');\nvar util = require('util');\n\nfunction base64url(string, encoding) {\n  return Buffer\n    .from(string, encoding)\n    .toString('base64')\n    .replace(/=/g, '')\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_');\n}\n\nfunction jwsSecuredInput(header, payload, encoding) {\n  encoding = encoding || 'utf8';\n  var encodedHeader = base64url(toString(header), 'binary');\n  var encodedPayload = base64url(toString(payload), encoding);\n  return util.format('%s.%s', encodedHeader, encodedPayload);\n}\n\nfunction jwsSign(opts) {\n  var header = opts.header;\n  var payload = opts.payload;\n  var secretOrKey = opts.secret || opts.privateKey;\n  var encoding = opts.encoding;\n  var algo = jwa(header.alg);\n  var securedInput = jwsSecuredInput(header, payload, encoding);\n  var signature = algo.sign(securedInput, secretOrKey);\n  return util.format('%s.%s', securedInput, signature);\n}\n\nfunction SignStream(opts) {\n  var secret = opts.secret || opts.privateKey || opts.key;\n  var secretStream = new DataStream(secret);\n  this.readable = true;\n  this.header = opts.header;\n  this.encoding = opts.encoding;\n  this.secret = this.privateKey = this.key = secretStream;\n  this.payload = new DataStream(opts.payload);\n  this.secret.once('close', function () {\n    if (!this.payload.writable && this.readable)\n      this.sign();\n  }.bind(this));\n\n  this.payload.once('close', function () {\n    if (!this.secret.writable && this.readable)\n      this.sign();\n  }.bind(this));\n}\nutil.inherits(SignStream, Stream);\n\nSignStream.prototype.sign = function sign() {\n  try {\n    var signature = jwsSign({\n      header: this.header,\n      payload: this.payload.buffer,\n      secret: this.secret.buffer,\n      encoding: this.encoding\n    });\n    this.emit('done', signature);\n    this.emit('data', signature);\n    this.emit('end');\n    this.readable = false;\n    return signature;\n  } catch (e) {\n    this.readable = false;\n    this.emit('error', e);\n    this.emit('close');\n  }\n};\n\nSignStream.sign = jwsSign;\n\nmodule.exports = SignStream;"
  },
  {
    "path": "server/libs/jws/lib/tostring.js",
    "content": "/*global module*/\nvar Buffer = require('buffer').Buffer;\n\nmodule.exports = function toString(obj) {\n  if (typeof obj === 'string')\n    return obj;\n  if (typeof obj === 'number' || Buffer.isBuffer(obj))\n    return obj.toString();\n  return JSON.stringify(obj);\n};"
  },
  {
    "path": "server/libs/jws/lib/verify-stream.js",
    "content": "/*global module*/\nvar Buffer = require('safe-buffer').Buffer;\nvar DataStream = require('./data-stream');\nvar jwa = require('../../jwa');\nvar Stream = require('stream');\nvar toString = require('./tostring');\nvar util = require('util');\nvar JWS_REGEX = /^[a-zA-Z0-9\\-_]+?\\.[a-zA-Z0-9\\-_]+?\\.([a-zA-Z0-9\\-_]+)?$/;\n\nfunction isObject(thing) {\n  return Object.prototype.toString.call(thing) === '[object Object]';\n}\n\nfunction safeJsonParse(thing) {\n  if (isObject(thing))\n    return thing;\n  try { return JSON.parse(thing); }\n  catch (e) { return undefined; }\n}\n\nfunction headerFromJWS(jwsSig) {\n  var encodedHeader = jwsSig.split('.', 1)[0];\n  return safeJsonParse(Buffer.from(encodedHeader, 'base64').toString('binary'));\n}\n\nfunction securedInputFromJWS(jwsSig) {\n  return jwsSig.split('.', 2).join('.');\n}\n\nfunction signatureFromJWS(jwsSig) {\n  return jwsSig.split('.')[2];\n}\n\nfunction payloadFromJWS(jwsSig, encoding) {\n  encoding = encoding || 'utf8';\n  var payload = jwsSig.split('.')[1];\n  return Buffer.from(payload, 'base64').toString(encoding);\n}\n\nfunction isValidJws(string) {\n  return JWS_REGEX.test(string) && !!headerFromJWS(string);\n}\n\nfunction jwsVerify(jwsSig, algorithm, secretOrKey) {\n  if (!algorithm) {\n    var err = new Error(\"Missing algorithm parameter for jws.verify\");\n    err.code = \"MISSING_ALGORITHM\";\n    throw err;\n  }\n  jwsSig = toString(jwsSig);\n  var signature = signatureFromJWS(jwsSig);\n  var securedInput = securedInputFromJWS(jwsSig);\n  var algo = jwa(algorithm);\n  return algo.verify(securedInput, signature, secretOrKey);\n}\n\nfunction jwsDecode(jwsSig, opts) {\n  opts = opts || {};\n  jwsSig = toString(jwsSig);\n\n  if (!isValidJws(jwsSig))\n    return null;\n\n  var header = headerFromJWS(jwsSig);\n\n  if (!header)\n    return null;\n\n  var payload = payloadFromJWS(jwsSig);\n  if (header.typ === 'JWT' || opts.json)\n    payload = JSON.parse(payload, opts.encoding);\n\n  return {\n    header: header,\n    payload: payload,\n    signature: signatureFromJWS(jwsSig)\n  };\n}\n\nfunction VerifyStream(opts) {\n  opts = opts || {};\n  var secretOrKey = opts.secret || opts.publicKey || opts.key;\n  var secretStream = new DataStream(secretOrKey);\n  this.readable = true;\n  this.algorithm = opts.algorithm;\n  this.encoding = opts.encoding;\n  this.secret = this.publicKey = this.key = secretStream;\n  this.signature = new DataStream(opts.signature);\n  this.secret.once('close', function () {\n    if (!this.signature.writable && this.readable)\n      this.verify();\n  }.bind(this));\n\n  this.signature.once('close', function () {\n    if (!this.secret.writable && this.readable)\n      this.verify();\n  }.bind(this));\n}\nutil.inherits(VerifyStream, Stream);\nVerifyStream.prototype.verify = function verify() {\n  try {\n    var valid = jwsVerify(this.signature.buffer, this.algorithm, this.key.buffer);\n    var obj = jwsDecode(this.signature.buffer, this.encoding);\n    this.emit('done', valid, obj);\n    this.emit('data', valid);\n    this.emit('end');\n    this.readable = false;\n    return valid;\n  } catch (e) {\n    this.readable = false;\n    this.emit('error', e);\n    this.emit('close');\n  }\n};\n\nVerifyStream.decode = jwsDecode;\nVerifyStream.isValid = isValidJws;\nVerifyStream.verify = jwsVerify;\n\nmodule.exports = VerifyStream;"
  },
  {
    "path": "server/libs/libarchive/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018 ნიკა\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": "server/libs/libarchive/archive.js",
    "content": "/**\n * Modified from https://github.com/nika-begiashvili/libarchivejs\n */\n\nconst Path = require('path')\nconst { Worker } = require('worker_threads')\n\n/**\n * Represents compressed file before extraction\n */\nclass CompressedFile {\n\n    constructor(name, size, path, archiveRef) {\n        this._name = name\n        this._size = size\n        this._path = path\n        this._archiveRef = archiveRef\n    }\n\n    /**\n     * file name\n     */\n    get name() {\n        return this._name\n    }\n    /**\n     * file size\n     */\n    get size() {\n        return this._size\n    }\n\n    /**\n     * Extract file from archive\n     * @returns {Promise<File>} extracted file\n     */\n    extract() {\n        return this._archiveRef.extractSingleFile(this._path)\n    }\n\n}\n\nclass Archive {\n    /**\n     * Creates new archive instance from browser native File object\n     * @param {Buffer} fileBuffer\n     * @param {object} options\n     * @returns {Archive}\n     */\n    static open(fileBuffer) {\n        const arch = new Archive(fileBuffer, { workerUrl: Path.join(__dirname, 'libarchiveWorker.js') })\n        return arch.open()\n    }\n\n    /**\n     * Create new archive\n     * @param {File} file \n     * @param {Object} options \n     */\n    constructor(file, options) {\n        this._worker = new Worker(options.workerUrl)\n        this._worker.on('message', this._workerMsg.bind(this))\n\n        this._callbacks = []\n        this._content = {}\n        this._processed = 0\n        this._file = file\n    }\n\n    /**\n     * Prepares file for reading\n     * @returns {Promise<Archive>} archive instance\n     */\n    async open() {\n        await this._postMessage({ type: 'HELLO' }, (resolve, reject, msg) => {\n            if (msg.type === 'READY') {\n                resolve()\n            }\n        })\n        return await this._postMessage({ type: 'OPEN', file: this._file }, (resolve, reject, msg) => {\n            if (msg.type === 'OPENED') {\n                resolve(this)\n            }\n        })\n    }\n\n    /**\n     * Terminate worker to free up memory\n     */\n    close() {\n        this._worker.terminate()\n        this._worker = null\n    }\n\n    /**\n     * detect if archive has encrypted data\n     * @returns {boolean|null} null if could not be determined\n     */\n    hasEncryptedData() {\n        return this._postMessage({ type: 'CHECK_ENCRYPTION' },\n            (resolve, reject, msg) => {\n                if (msg.type === 'ENCRYPTION_STATUS') {\n                    resolve(msg.status)\n                }\n            }\n        )\n    }\n\n    /**\n     * set password to be used when reading archive\n     */\n    usePassword(archivePassword) {\n        return this._postMessage({ type: 'SET_PASSPHRASE', passphrase: archivePassword },\n            (resolve, reject, msg) => {\n                if (msg.type === 'PASSPHRASE_STATUS') {\n                    resolve(msg.status)\n                }\n            }\n        )\n    }\n\n    /**\n     * Returns object containing directory structure and file information \n     * @returns {Promise<object>}\n     */\n    getFilesObject() {\n        if (this._processed > 0) {\n            return Promise.resolve().then(() => this._content)\n        }\n        return this._postMessage({ type: 'LIST_FILES' }, (resolve, reject, msg) => {\n            if (msg.type === 'ENTRY') {\n                const entry = msg.entry\n                const [target, prop] = this._getProp(this._content, entry.path)\n                if (entry.type === 'FILE') {\n                    target[prop] = new CompressedFile(entry.fileName, entry.size, entry.path, this)\n                }\n                return true\n            } else if (msg.type === 'END') {\n                this._processed = 1\n                resolve(this._cloneContent(this._content))\n            }\n        })\n    }\n\n    getFilesArray() {\n        return this.getFilesObject().then((obj) => {\n            return this._objectToArray(obj)\n        })\n    }\n\n    extractSingleFile(target) {\n        // Prevent extraction if worker already terminated\n        if (this._worker === null) {\n            throw new Error(\"Archive already closed\")\n        }\n\n        return this._postMessage({ type: 'EXTRACT_SINGLE_FILE', target: target },\n            (resolve, reject, msg) => {\n                if (msg.type === 'FILE') {\n                    resolve(msg.entry)\n                }\n            }\n        )\n    }\n\n    /**\n     * Returns object containing directory structure and extracted File objects \n     * @param {Function} extractCallback\n     * \n     */\n    extractFiles(extractCallback) {\n        if (this._processed > 1) {\n            return Promise.resolve().then(() => this._content)\n        }\n        return this._postMessage({ type: 'EXTRACT_FILES' }, (resolve, reject, msg) => {\n            if (msg.type === 'ENTRY') {\n                const [target, prop] = this._getProp(this._content, msg.entry.path)\n                if (msg.entry.type === 'FILE') {\n                    target[prop] = msg.entry\n                    if (extractCallback !== undefined) {\n                        setTimeout(extractCallback.bind(null, {\n                            file: target[prop],\n                            path: msg.entry.path,\n                        }))\n                    }\n                }\n                return true\n            } else if (msg.type === 'END') {\n                this._processed = 2\n                this._worker.terminate()\n                resolve(this._cloneContent(this._content))\n            }\n        })\n    }\n\n    _cloneContent(obj) {\n        if (obj instanceof CompressedFile || obj === null) return obj\n        const o = {}\n        for (const prop of Object.keys(obj)) {\n            o[prop] = this._cloneContent(obj[prop])\n        }\n        return o\n    }\n\n    _objectToArray(obj, path = '') {\n        const files = []\n        for (const key of Object.keys(obj)) {\n            if (obj[key] instanceof CompressedFile || obj[key] === null) {\n                files.push({\n                    file: obj[key] || key,\n                    path: path\n                })\n            } else {\n                files.push(...this._objectToArray(obj[key], `${path}${key}/`))\n            }\n        }\n        return files\n    }\n\n    _getProp(obj, path) {\n        const parts = path.split('/')\n        if (parts[parts.length - 1] === '') parts.pop()\n        let cur = obj, prev = null\n        for (const part of parts) {\n            cur[part] = cur[part] || {}\n            prev = cur\n            cur = cur[part]\n        }\n        return [prev, parts[parts.length - 1]]\n    }\n\n    _postMessage(msg, callback) {\n        this._worker.postMessage(msg)\n        return new Promise((resolve, reject) => {\n            this._callbacks.push(this._msgHandler.bind(this, callback, resolve, reject))\n        })\n    }\n\n    _msgHandler(callback, resolve, reject, msg) {\n        if (!msg) {\n            reject('invalid msg')\n            return\n        }\n        if (msg.type === 'BUSY') {\n            reject('worker is busy')\n        } else if (msg.type === 'ERROR') {\n            reject(msg.error)\n        } else {\n            return callback(resolve, reject, msg)\n        }\n    }\n\n    _workerMsg(msg) {\n        const callback = this._callbacks[this._callbacks.length - 1]\n        const next = callback(msg)\n        if (!next) {\n            this._callbacks.pop()\n        }\n    }\n\n}\nmodule.exports = Archive"
  },
  {
    "path": "server/libs/libarchive/libarchiveWorker.js",
    "content": "/**\n * Modified from https://github.com/nika-begiashvili/libarchivejs\n */\n\nconst { parentPort } = require('worker_threads')\nconst { getArchiveReader } = require('./wasm-module')\n\nlet reader = null\nlet busy = false\n\ngetArchiveReader((_reader) => {\n  reader = _reader\n  busy = false\n  parentPort.postMessage({ type: 'READY' })\n})\n\nparentPort.on('message', async msg => {\n  if (busy) {\n    parentPort.postMessage({ type: 'BUSY' })\n    return\n  }\n\n  let skipExtraction = false\n  busy = true\n  try {\n    switch (msg.type) {\n      case 'HELLO': // module will respond READY when it's ready\n        break\n      case 'OPEN':\n        await reader.open(msg.file)\n        parentPort.postMessage({ type: 'OPENED' })\n        break\n      case 'LIST_FILES':\n        skipExtraction = true\n      // eslint-disable-next-line no-fallthrough\n      case 'EXTRACT_FILES':\n        for (const entry of reader.entries(skipExtraction)) {\n          parentPort.postMessage({ type: 'ENTRY', entry })\n        }\n        parentPort.postMessage({ type: 'END' })\n        break\n      case 'EXTRACT_SINGLE_FILE':\n        for (const entry of reader.entries(true, msg.target)) {\n          if (entry.fileData) {\n            parentPort.postMessage({ type: 'FILE', entry })\n          }\n        }\n        break\n      case 'CHECK_ENCRYPTION':\n        parentPort.postMessage({ type: 'ENCRYPTION_STATUS', status: reader.hasEncryptedData() })\n        break\n      case 'SET_PASSPHRASE':\n        reader.setPassphrase(msg.passphrase)\n        parentPort.postMessage({ type: 'PASSPHRASE_STATUS', status: true })\n        break\n      default:\n        throw new Error('Invalid Command')\n    }\n  } catch (err) {\n    parentPort.postMessage({\n      type: 'ERROR',\n      error: {\n        message: err.message,\n        name: err.name,\n        stack: err.stack\n      }\n    })\n  } finally {\n    // eslint-disable-next-line require-atomic-updates\n    busy = false\n  }\n})\n"
  },
  {
    "path": "server/libs/libarchive/wasm-libarchive.js",
    "content": "/**\n * Modified from https://github.com/nika-begiashvili/libarchivejs\n */\n\nvar libarchive = (function () {\n  var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined\n  return (\n    function (libarchive) {\n      libarchive = libarchive || {}\n\n      var Module = typeof libarchive !== \"undefined\" ? libarchive : {}; var moduleOverrides = {}; var key; for (key in Module) { if (Module.hasOwnProperty(key)) { moduleOverrides[key] = Module[key] } } Module[\"arguments\"] = []; Module[\"thisProgram\"] = \"./this.program\"; Module[\"quit\"] = function (status, toThrow) { throw toThrow }; Module[\"preRun\"] = []; Module[\"postRun\"] = []; var ENVIRONMENT_IS_WEB = false; var ENVIRONMENT_IS_WORKER = false; var ENVIRONMENT_IS_NODE = false; var ENVIRONMENT_IS_SHELL = false; ENVIRONMENT_IS_WEB = typeof window === \"object\"; ENVIRONMENT_IS_WORKER = typeof importScripts === \"function\"; ENVIRONMENT_IS_NODE = typeof process === \"object\" && typeof require === \"function\" && !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_WORKER; ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER; var scriptDirectory = \"\"; function locateFile(path) { if (Module[\"locateFile\"]) { return Module[\"locateFile\"](path, scriptDirectory) } else { return scriptDirectory + path } } if (ENVIRONMENT_IS_NODE) { scriptDirectory = __dirname + \"/\"; var nodeFS; var nodePath; Module[\"read\"] = function shell_read(filename, binary) { var ret; if (!nodeFS) nodeFS = require(\"fs\"); if (!nodePath) nodePath = require(\"path\"); filename = nodePath[\"normalize\"](filename); ret = nodeFS[\"readFileSync\"](filename); return binary ? ret : ret.toString() }; Module[\"readBinary\"] = function readBinary(filename) { var ret = Module[\"read\"](filename, true); if (!ret.buffer) { ret = new Uint8Array(ret) } assert(ret.buffer); return ret }; if (process[\"argv\"].length > 1) { Module[\"thisProgram\"] = process[\"argv\"][1].replace(/\\\\/g, \"/\") } Module[\"arguments\"] = process[\"argv\"].slice(2); process[\"on\"](\"uncaughtException\", function (ex) { if (!(ex instanceof ExitStatus)) { throw ex } }); process[\"on\"](\"unhandledRejection\", abort); Module[\"quit\"] = function (status) { process[\"exit\"](status) }; Module[\"inspect\"] = function () { return \"[Emscripten Module object]\" } } else if (ENVIRONMENT_IS_SHELL) { if (typeof read != \"undefined\") { Module[\"read\"] = function shell_read(f) { return read(f) } } Module[\"readBinary\"] = function readBinary(f) { var data; if (typeof readbuffer === \"function\") { return new Uint8Array(readbuffer(f)) } data = read(f, \"binary\"); assert(typeof data === \"object\"); return data }; if (typeof scriptArgs != \"undefined\") { Module[\"arguments\"] = scriptArgs } else if (typeof arguments != \"undefined\") { Module[\"arguments\"] = arguments } if (typeof quit === \"function\") { Module[\"quit\"] = function (status) { quit(status) } } } else if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { if (ENVIRONMENT_IS_WORKER) { scriptDirectory = self.location.href } else if (document.currentScript) { scriptDirectory = document.currentScript.src } if (_scriptDir) { scriptDirectory = _scriptDir } if (scriptDirectory.indexOf(\"blob:\") !== 0) { scriptDirectory = scriptDirectory.substr(0, scriptDirectory.lastIndexOf(\"/\") + 1) } else { scriptDirectory = \"\" } Module[\"read\"] = function shell_read(url) { var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, false); xhr.send(null); return xhr.responseText }; if (ENVIRONMENT_IS_WORKER) { Module[\"readBinary\"] = function readBinary(url) { var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, false); xhr.responseType = \"arraybuffer\"; xhr.send(null); return new Uint8Array(xhr.response) } } Module[\"readAsync\"] = function readAsync(url, onload, onerror) { var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, true); xhr.responseType = \"arraybuffer\"; xhr.onload = function xhr_onload() { if (xhr.status == 200 || xhr.status == 0 && xhr.response) { onload(xhr.response); return } onerror() }; xhr.onerror = onerror; xhr.send(null) }; Module[\"setWindowTitle\"] = function (title) { document.title = title } } else { } var out = Module[\"print\"] || (typeof console !== \"undefined\" ? console.log.bind(console) : typeof print !== \"undefined\" ? print : null); var err = Module[\"printErr\"] || (typeof printErr !== \"undefined\" ? printErr : typeof console !== \"undefined\" && console.warn.bind(console) || out); for (key in moduleOverrides) { if (moduleOverrides.hasOwnProperty(key)) { Module[key] = moduleOverrides[key] } } moduleOverrides = undefined; function dynamicAlloc(size) { var ret = HEAP32[DYNAMICTOP_PTR >> 2]; var end = ret + size + 15 & -16; if (end <= _emscripten_get_heap_size()) { HEAP32[DYNAMICTOP_PTR >> 2] = end } else { var success = _emscripten_resize_heap(end); if (!success) return 0 } return ret } function getNativeTypeSize(type) { switch (type) { case \"i1\": case \"i8\": return 1; case \"i16\": return 2; case \"i32\": return 4; case \"i64\": return 8; case \"float\": return 4; case \"double\": return 8; default: { if (type[type.length - 1] === \"*\") { return 4 } else if (type[0] === \"i\") { var bits = parseInt(type.substr(1)); assert(bits % 8 === 0, \"getNativeTypeSize invalid bits \" + bits + \", type \" + type); return bits / 8 } else { return 0 } } } } var asm2wasmImports = { \"f64-rem\": function (x, y) { return x % y }, \"debugger\": function () { debugger } }; var functionPointers = new Array(0); var tempRet0 = 0; var setTempRet0 = function (value) { tempRet0 = value }; if (typeof WebAssembly !== \"object\") { err(\"no native wasm support detected\") } var wasmMemory; var wasmTable; var ABORT = false; var EXITSTATUS = 0; function assert(condition, text) { if (!condition) { abort(\"Assertion failed: \" + text) } } function getCFunc(ident) { var func = Module[\"_\" + ident]; assert(func, \"Cannot call unknown function \" + ident + \", make sure it is exported\"); return func } function ccall(ident, returnType, argTypes, args, opts) { var toC = { \"string\": function (str) { var ret = 0; if (str !== null && str !== undefined && str !== 0) { var len = (str.length << 2) + 1; ret = stackAlloc(len); stringToUTF8(str, ret, len) } return ret }, \"array\": function (arr) { var ret = stackAlloc(arr.length); writeArrayToMemory(arr, ret); return ret } }; function convertReturnValue(ret) { if (returnType === \"string\") return UTF8ToString(ret); if (returnType === \"boolean\") return Boolean(ret); return ret } var func = getCFunc(ident); var cArgs = []; var stack = 0; if (args) { for (var i = 0; i < args.length; i++) { var converter = toC[argTypes[i]]; if (converter) { if (stack === 0) stack = stackSave(); cArgs[i] = converter(args[i]) } else { cArgs[i] = args[i] } } } var ret = func.apply(null, cArgs); ret = convertReturnValue(ret); if (stack !== 0) stackRestore(stack); return ret } function cwrap(ident, returnType, argTypes, opts) { argTypes = argTypes || []; var numericArgs = argTypes.every(function (type) { return type === \"number\" }); var numericRet = returnType !== \"string\"; if (numericRet && numericArgs && !opts) { return getCFunc(ident) } return function () { return ccall(ident, returnType, argTypes, arguments, opts) } } function setValue(ptr, value, type, noSafe) { type = type || \"i8\"; if (type.charAt(type.length - 1) === \"*\") type = \"i32\"; switch (type) { case \"i1\": HEAP8[ptr >> 0] = value; break; case \"i8\": HEAP8[ptr >> 0] = value; break; case \"i16\": HEAP16[ptr >> 1] = value; break; case \"i32\": HEAP32[ptr >> 2] = value; break; case \"i64\": tempI64 = [value >>> 0, (tempDouble = value, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[ptr >> 2] = tempI64[0], HEAP32[ptr + 4 >> 2] = tempI64[1]; break; case \"float\": HEAPF32[ptr >> 2] = value; break; case \"double\": HEAPF64[ptr >> 3] = value; break; default: abort(\"invalid type for setValue: \" + type) } } var ALLOC_NORMAL = 0; var ALLOC_NONE = 3; function allocate(slab, types, allocator, ptr) { var zeroinit, size; if (typeof slab === \"number\") { zeroinit = true; size = slab } else { zeroinit = false; size = slab.length } var singleType = typeof types === \"string\" ? types : null; var ret; if (allocator == ALLOC_NONE) { ret = ptr } else { ret = [_malloc, stackAlloc, dynamicAlloc][allocator](Math.max(size, singleType ? 1 : types.length)) } if (zeroinit) { var stop; ptr = ret; assert((ret & 3) == 0); stop = ret + (size & ~3); for (; ptr < stop; ptr += 4) { HEAP32[ptr >> 2] = 0 } stop = ret + size; while (ptr < stop) { HEAP8[ptr++ >> 0] = 0 } return ret } if (singleType === \"i8\") { if (slab.subarray || slab.slice) { HEAPU8.set(slab, ret) } else { HEAPU8.set(new Uint8Array(slab), ret) } return ret } var i = 0, type, typeSize, previousType; while (i < size) { var curr = slab[i]; type = singleType || types[i]; if (type === 0) { i++; continue } if (type == \"i64\") type = \"i32\"; setValue(ret + i, curr, type); if (previousType !== type) { typeSize = getNativeTypeSize(type); previousType = type } i += typeSize } return ret } function getMemory(size) { if (!runtimeInitialized) return dynamicAlloc(size); return _malloc(size) } var UTF8Decoder = typeof TextDecoder !== \"undefined\" ? new TextDecoder(\"utf8\") : undefined; function UTF8ArrayToString(u8Array, idx, maxBytesToRead) { var endIdx = idx + maxBytesToRead; var endPtr = idx; while (u8Array[endPtr] && !(endPtr >= endIdx)) ++endPtr; if (endPtr - idx > 16 && u8Array.subarray && UTF8Decoder) { return UTF8Decoder.decode(u8Array.subarray(idx, endPtr)) } else { var str = \"\"; while (idx < endPtr) { var u0 = u8Array[idx++]; if (!(u0 & 128)) { str += String.fromCharCode(u0); continue } var u1 = u8Array[idx++] & 63; if ((u0 & 224) == 192) { str += String.fromCharCode((u0 & 31) << 6 | u1); continue } var u2 = u8Array[idx++] & 63; if ((u0 & 240) == 224) { u0 = (u0 & 15) << 12 | u1 << 6 | u2 } else { u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | u8Array[idx++] & 63 } if (u0 < 65536) { str += String.fromCharCode(u0) } else { var ch = u0 - 65536; str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023) } } } return str } function UTF8ToString(ptr, maxBytesToRead) { return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : \"\" } function stringToUTF8Array(str, outU8Array, outIdx, maxBytesToWrite) { if (!(maxBytesToWrite > 0)) return 0; var startIdx = outIdx; var endIdx = outIdx + maxBytesToWrite - 1; for (var i = 0; i < str.length; ++i) { var u = str.charCodeAt(i); if (u >= 55296 && u <= 57343) { var u1 = str.charCodeAt(++i); u = 65536 + ((u & 1023) << 10) | u1 & 1023 } if (u <= 127) { if (outIdx >= endIdx) break; outU8Array[outIdx++] = u } else if (u <= 2047) { if (outIdx + 1 >= endIdx) break; outU8Array[outIdx++] = 192 | u >> 6; outU8Array[outIdx++] = 128 | u & 63 } else if (u <= 65535) { if (outIdx + 2 >= endIdx) break; outU8Array[outIdx++] = 224 | u >> 12; outU8Array[outIdx++] = 128 | u >> 6 & 63; outU8Array[outIdx++] = 128 | u & 63 } else { if (outIdx + 3 >= endIdx) break; outU8Array[outIdx++] = 240 | u >> 18; outU8Array[outIdx++] = 128 | u >> 12 & 63; outU8Array[outIdx++] = 128 | u >> 6 & 63; outU8Array[outIdx++] = 128 | u & 63 } } outU8Array[outIdx] = 0; return outIdx - startIdx } function stringToUTF8(str, outPtr, maxBytesToWrite) { return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite) } function lengthBytesUTF8(str) { var len = 0; for (var i = 0; i < str.length; ++i) { var u = str.charCodeAt(i); if (u >= 55296 && u <= 57343) u = 65536 + ((u & 1023) << 10) | str.charCodeAt(++i) & 1023; if (u <= 127) ++len; else if (u <= 2047) len += 2; else if (u <= 65535) len += 3; else len += 4 } return len } var UTF16Decoder = typeof TextDecoder !== \"undefined\" ? new TextDecoder(\"utf-16le\") : undefined; function writeArrayToMemory(array, buffer) { HEAP8.set(array, buffer) } function writeAsciiToMemory(str, buffer, dontAddNull) { for (var i = 0; i < str.length; ++i) { HEAP8[buffer++ >> 0] = str.charCodeAt(i) } if (!dontAddNull) HEAP8[buffer >> 0] = 0 } function demangle(func) { return func } function demangleAll(text) { var regex = /__Z[\\w\\d_]+/g; return text.replace(regex, function (x) { var y = demangle(x); return x === y ? x : y + \" [\" + x + \"]\" }) } function jsStackTrace() { var err = new Error; if (!err.stack) { try { throw new Error(0) } catch (e) { err = e } if (!err.stack) { return \"(no stack trace available)\" } } return err.stack.toString() } function stackTrace() { var js = jsStackTrace(); if (Module[\"extraStackTrace\"]) js += \"\\n\" + Module[\"extraStackTrace\"](); return demangleAll(js) } var WASM_PAGE_SIZE = 65536; function alignUp(x, multiple) { if (x % multiple > 0) { x += multiple - x % multiple } return x } var buffer, HEAP8, HEAPU8, HEAP16, HEAPU16, HEAP32, HEAPU32, HEAPF32, HEAPF64; function updateGlobalBufferViews() { Module[\"HEAP8\"] = HEAP8 = new Int8Array(buffer); Module[\"HEAP16\"] = HEAP16 = new Int16Array(buffer); Module[\"HEAP32\"] = HEAP32 = new Int32Array(buffer); Module[\"HEAPU8\"] = HEAPU8 = new Uint8Array(buffer); Module[\"HEAPU16\"] = HEAPU16 = new Uint16Array(buffer); Module[\"HEAPU32\"] = HEAPU32 = new Uint32Array(buffer); Module[\"HEAPF32\"] = HEAPF32 = new Float32Array(buffer); Module[\"HEAPF64\"] = HEAPF64 = new Float64Array(buffer) } var DYNAMIC_BASE = 5520464, DYNAMICTOP_PTR = 277552; var TOTAL_STACK = 5242880; var INITIAL_TOTAL_MEMORY = Module[\"TOTAL_MEMORY\"] || 16777216; if (INITIAL_TOTAL_MEMORY < TOTAL_STACK) err(\"TOTAL_MEMORY should be larger than TOTAL_STACK, was \" + INITIAL_TOTAL_MEMORY + \"! (TOTAL_STACK=\" + TOTAL_STACK + \")\"); if (Module[\"buffer\"]) { buffer = Module[\"buffer\"] } else { if (typeof WebAssembly === \"object\" && typeof WebAssembly.Memory === \"function\") { wasmMemory = new WebAssembly.Memory({ \"initial\": INITIAL_TOTAL_MEMORY / WASM_PAGE_SIZE }); buffer = wasmMemory.buffer } else { buffer = new ArrayBuffer(INITIAL_TOTAL_MEMORY) } } updateGlobalBufferViews(); HEAP32[DYNAMICTOP_PTR >> 2] = DYNAMIC_BASE; function callRuntimeCallbacks(callbacks) { while (callbacks.length > 0) { var callback = callbacks.shift(); if (typeof callback == \"function\") { callback(); continue } var func = callback.func; if (typeof func === \"number\") { if (callback.arg === undefined) { Module[\"dynCall_v\"](func) } else { Module[\"dynCall_vi\"](func, callback.arg) } } else { func(callback.arg === undefined ? null : callback.arg) } } } var __ATPRERUN__ = []; var __ATINIT__ = []; var __ATMAIN__ = []; var __ATPOSTRUN__ = []; var runtimeInitialized = false; var runtimeExited = false; function preRun() { if (Module[\"preRun\"]) { if (typeof Module[\"preRun\"] == \"function\") Module[\"preRun\"] = [Module[\"preRun\"]]; while (Module[\"preRun\"].length) { addOnPreRun(Module[\"preRun\"].shift()) } } callRuntimeCallbacks(__ATPRERUN__) } function ensureInitRuntime() { if (runtimeInitialized) return; runtimeInitialized = true; if (!Module[\"noFSInit\"] && !FS.init.initialized) FS.init(); TTY.init(); PIPEFS.root = FS.mount(PIPEFS, {}, null); callRuntimeCallbacks(__ATINIT__) } function preMain() { FS.ignorePermissions = false; callRuntimeCallbacks(__ATMAIN__) } function exitRuntime() { runtimeExited = true } function postRun() { if (Module[\"postRun\"]) { if (typeof Module[\"postRun\"] == \"function\") Module[\"postRun\"] = [Module[\"postRun\"]]; while (Module[\"postRun\"].length) { addOnPostRun(Module[\"postRun\"].shift()) } } callRuntimeCallbacks(__ATPOSTRUN__) } function addOnPreRun(cb) { __ATPRERUN__.unshift(cb) } function addOnPostRun(cb) { __ATPOSTRUN__.unshift(cb) } var Math_abs = Math.abs; var Math_ceil = Math.ceil; var Math_floor = Math.floor; var Math_min = Math.min; var runDependencies = 0; var runDependencyWatcher = null; var dependenciesFulfilled = null; function getUniqueRunDependency(id) { return id } function addRunDependency(id) { runDependencies++; if (Module[\"monitorRunDependencies\"]) { Module[\"monitorRunDependencies\"](runDependencies) } } function removeRunDependency(id) { runDependencies--; if (Module[\"monitorRunDependencies\"]) { Module[\"monitorRunDependencies\"](runDependencies) } if (runDependencies == 0) { if (runDependencyWatcher !== null) { clearInterval(runDependencyWatcher); runDependencyWatcher = null } if (dependenciesFulfilled) { var callback = dependenciesFulfilled; dependenciesFulfilled = null; callback() } } } Module[\"preloadedImages\"] = {}; Module[\"preloadedAudios\"] = {}; var dataURIPrefix = \"data:application/octet-stream;base64,\"; function isDataURI(filename) { return String.prototype.startsWith ? filename.startsWith(dataURIPrefix) : filename.indexOf(dataURIPrefix) === 0 } var wasmBinaryFile = \"libarchive.wasm\"; if (!isDataURI(wasmBinaryFile)) { wasmBinaryFile = locateFile(wasmBinaryFile) } function getBinary() { try { if (Module[\"wasmBinary\"]) { return new Uint8Array(Module[\"wasmBinary\"]) } if (Module[\"readBinary\"]) { return Module[\"readBinary\"](wasmBinaryFile) } else { throw \"both async and sync fetching of the wasm failed\" } } catch (err) { abort(err) } } function getBinaryPromise() { if (!Module[\"wasmBinary\"] && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) && typeof fetch === \"function\") { return fetch(wasmBinaryFile, { credentials: \"same-origin\" }).then(function (response) { if (!response[\"ok\"]) { throw \"failed to load wasm binary file at '\" + wasmBinaryFile + \"'\" } return response[\"arrayBuffer\"]() }).catch(function () { return getBinary() }) } return new Promise(function (resolve, reject) { resolve(getBinary()) }) } function createWasm(env) { var info = { \"env\": env, \"global\": { \"NaN\": NaN, Infinity: Infinity }, \"global.Math\": Math, \"asm2wasm\": asm2wasmImports }; function receiveInstance(instance, module) { var exports = instance.exports; Module[\"asm\"] = exports; removeRunDependency(\"wasm-instantiate\") } addRunDependency(\"wasm-instantiate\"); if (Module[\"instantiateWasm\"]) { try { return Module[\"instantiateWasm\"](info, receiveInstance) } catch (e) { err(\"Module.instantiateWasm callback failed with error: \" + e); return false } } function receiveInstantiatedSource(output) { receiveInstance(output[\"instance\"]) } function instantiateArrayBuffer(receiver) { getBinaryPromise().then(function (binary) { return WebAssembly.instantiate(binary, info) }).then(receiver, function (reason) { err(\"failed to asynchronously prepare wasm: \" + reason); abort(reason) }) } if (!Module[\"wasmBinary\"] && typeof WebAssembly.instantiateStreaming === \"function\" && !isDataURI(wasmBinaryFile) && typeof fetch === \"function\") { WebAssembly.instantiateStreaming(fetch(wasmBinaryFile, { credentials: \"same-origin\" }), info).then(receiveInstantiatedSource, function (reason) { err(\"wasm streaming compile failed: \" + reason); err(\"falling back to ArrayBuffer instantiation\"); instantiateArrayBuffer(receiveInstantiatedSource) }) } else { instantiateArrayBuffer(receiveInstantiatedSource) } return {} } Module[\"asm\"] = function (global, env, providedBuffer) { env[\"memory\"] = wasmMemory; env[\"table\"] = wasmTable = new WebAssembly.Table({ \"initial\": 507, \"maximum\": 507, \"element\": \"anyfunc\" }); env[\"__memory_base\"] = 1024; env[\"__table_base\"] = 0; var exports = createWasm(env); return exports }; __ATINIT__.push({ func: function () { ___emscripten_environ_constructor() } }); var ENV = {}; function ___buildEnvironment(environ) { var MAX_ENV_VALUES = 64; var TOTAL_ENV_SIZE = 1024; var poolPtr; var envPtr; if (!___buildEnvironment.called) { ___buildEnvironment.called = true; ENV[\"USER\"] = ENV[\"LOGNAME\"] = \"web_user\"; ENV[\"PATH\"] = \"/\"; ENV[\"PWD\"] = \"/\"; ENV[\"HOME\"] = \"/home/web_user\"; ENV[\"LANG\"] = \"C.UTF-8\"; ENV[\"_\"] = Module[\"thisProgram\"]; poolPtr = getMemory(TOTAL_ENV_SIZE); envPtr = getMemory(MAX_ENV_VALUES * 4); HEAP32[envPtr >> 2] = poolPtr; HEAP32[environ >> 2] = envPtr } else { envPtr = HEAP32[environ >> 2]; poolPtr = HEAP32[envPtr >> 2] } var strings = []; var totalSize = 0; for (var key in ENV) { if (typeof ENV[key] === \"string\") { var line = key + \"=\" + ENV[key]; strings.push(line); totalSize += line.length } } if (totalSize > TOTAL_ENV_SIZE) { throw new Error(\"Environment size exceeded TOTAL_ENV_SIZE!\") } var ptrSize = 4; for (var i = 0; i < strings.length; i++) { var line = strings[i]; writeAsciiToMemory(line, poolPtr); HEAP32[envPtr + i * ptrSize >> 2] = poolPtr; poolPtr += line.length + 1 } HEAP32[envPtr + strings.length * ptrSize >> 2] = 0 } var PATH = { splitPath: function (filename) { var splitPathRe = /^(\\/?|)([\\s\\S]*?)((?:\\.{1,2}|[^\\/]+?|)(\\.[^.\\/]*|))(?:[\\/]*)$/; return splitPathRe.exec(filename).slice(1) }, normalizeArray: function (parts, allowAboveRoot) { var up = 0; for (var i = parts.length - 1; i >= 0; i--) { var last = parts[i]; if (last === \".\") { parts.splice(i, 1) } else if (last === \"..\") { parts.splice(i, 1); up++ } else if (up) { parts.splice(i, 1); up-- } } if (allowAboveRoot) { for (; up; up--) { parts.unshift(\"..\") } } return parts }, normalize: function (path) { var isAbsolute = path.charAt(0) === \"/\", trailingSlash = path.substr(-1) === \"/\"; path = PATH.normalizeArray(path.split(\"/\").filter(function (p) { return !!p }), !isAbsolute).join(\"/\"); if (!path && !isAbsolute) { path = \".\" } if (path && trailingSlash) { path += \"/\" } return (isAbsolute ? \"/\" : \"\") + path }, dirname: function (path) { var result = PATH.splitPath(path), root = result[0], dir = result[1]; if (!root && !dir) { return \".\" } if (dir) { dir = dir.substr(0, dir.length - 1) } return root + dir }, basename: function (path) { if (path === \"/\") return \"/\"; var lastSlash = path.lastIndexOf(\"/\"); if (lastSlash === -1) return path; return path.substr(lastSlash + 1) }, extname: function (path) { return PATH.splitPath(path)[3] }, join: function () { var paths = Array.prototype.slice.call(arguments, 0); return PATH.normalize(paths.join(\"/\")) }, join2: function (l, r) { return PATH.normalize(l + \"/\" + r) } }; function ___setErrNo(value) { if (Module[\"___errno_location\"]) HEAP32[Module[\"___errno_location\"]() >> 2] = value; return value } var PATH_FS = { resolve: function () { var resolvedPath = \"\", resolvedAbsolute = false; for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { var path = i >= 0 ? arguments[i] : FS.cwd(); if (typeof path !== \"string\") { throw new TypeError(\"Arguments to path.resolve must be strings\") } else if (!path) { return \"\" } resolvedPath = path + \"/\" + resolvedPath; resolvedAbsolute = path.charAt(0) === \"/\" } resolvedPath = PATH.normalizeArray(resolvedPath.split(\"/\").filter(function (p) { return !!p }), !resolvedAbsolute).join(\"/\"); return (resolvedAbsolute ? \"/\" : \"\") + resolvedPath || \".\" }, relative: function (from, to) { from = PATH_FS.resolve(from).substr(1); to = PATH_FS.resolve(to).substr(1); function trim(arr) { var start = 0; for (; start < arr.length; start++) { if (arr[start] !== \"\") break } var end = arr.length - 1; for (; end >= 0; end--) { if (arr[end] !== \"\") break } if (start > end) return []; return arr.slice(start, end - start + 1) } var fromParts = trim(from.split(\"/\")); var toParts = trim(to.split(\"/\")); var length = Math.min(fromParts.length, toParts.length); var samePartsLength = length; for (var i = 0; i < length; i++) { if (fromParts[i] !== toParts[i]) { samePartsLength = i; break } } var outputParts = []; for (var i = samePartsLength; i < fromParts.length; i++) { outputParts.push(\"..\") } outputParts = outputParts.concat(toParts.slice(samePartsLength)); return outputParts.join(\"/\") } }; var TTY = { ttys: [], init: function () { }, shutdown: function () { }, register: function (dev, ops) { TTY.ttys[dev] = { input: [], output: [], ops: ops }; FS.registerDevice(dev, TTY.stream_ops) }, stream_ops: { open: function (stream) { var tty = TTY.ttys[stream.node.rdev]; if (!tty) { throw new FS.ErrnoError(19) } stream.tty = tty; stream.seekable = false }, close: function (stream) { stream.tty.ops.flush(stream.tty) }, flush: function (stream) { stream.tty.ops.flush(stream.tty) }, read: function (stream, buffer, offset, length, pos) { if (!stream.tty || !stream.tty.ops.get_char) { throw new FS.ErrnoError(6) } var bytesRead = 0; for (var i = 0; i < length; i++) { var result; try { result = stream.tty.ops.get_char(stream.tty) } catch (e) { throw new FS.ErrnoError(5) } if (result === undefined && bytesRead === 0) { throw new FS.ErrnoError(11) } if (result === null || result === undefined) break; bytesRead++; buffer[offset + i] = result } if (bytesRead) { stream.node.timestamp = Date.now() } return bytesRead }, write: function (stream, buffer, offset, length, pos) { if (!stream.tty || !stream.tty.ops.put_char) { throw new FS.ErrnoError(6) } try { for (var i = 0; i < length; i++) { stream.tty.ops.put_char(stream.tty, buffer[offset + i]) } } catch (e) { throw new FS.ErrnoError(5) } if (length) { stream.node.timestamp = Date.now() } return i } }, default_tty_ops: { get_char: function (tty) { if (!tty.input.length) { var result = null; if (ENVIRONMENT_IS_NODE) { var BUFSIZE = 256; var buf = new Buffer(BUFSIZE); var bytesRead = 0; var isPosixPlatform = process.platform != \"win32\"; var fd = process.stdin.fd; if (isPosixPlatform) { var usingDevice = false; try { fd = fs.openSync(\"/dev/stdin\", \"r\"); usingDevice = true } catch (e) { } } try { bytesRead = fs.readSync(fd, buf, 0, BUFSIZE, null) } catch (e) { if (e.toString().indexOf(\"EOF\") != -1) bytesRead = 0; else throw e } if (usingDevice) { fs.closeSync(fd) } if (bytesRead > 0) { result = buf.slice(0, bytesRead).toString(\"utf-8\") } else { result = null } } else if (typeof window != \"undefined\" && typeof window.prompt == \"function\") { result = window.prompt(\"Input: \"); if (result !== null) { result += \"\\n\" } } else if (typeof readline == \"function\") { result = readline(); if (result !== null) { result += \"\\n\" } } if (!result) { return null } tty.input = intArrayFromString(result, true) } return tty.input.shift() }, put_char: function (tty, val) { if (val === null || val === 10) { out(UTF8ArrayToString(tty.output, 0)); tty.output = [] } else { if (val != 0) tty.output.push(val) } }, flush: function (tty) { if (tty.output && tty.output.length > 0) { out(UTF8ArrayToString(tty.output, 0)); tty.output = [] } } }, default_tty1_ops: { put_char: function (tty, val) { if (val === null || val === 10) { err(UTF8ArrayToString(tty.output, 0)); tty.output = [] } else { if (val != 0) tty.output.push(val) } }, flush: function (tty) { if (tty.output && tty.output.length > 0) { err(UTF8ArrayToString(tty.output, 0)); tty.output = [] } } } }; var MEMFS = { ops_table: null, mount: function (mount) { return MEMFS.createNode(null, \"/\", 16384 | 511, 0) }, createNode: function (parent, name, mode, dev) { if (FS.isBlkdev(mode) || FS.isFIFO(mode)) { throw new FS.ErrnoError(1) } if (!MEMFS.ops_table) { MEMFS.ops_table = { dir: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr, lookup: MEMFS.node_ops.lookup, mknod: MEMFS.node_ops.mknod, rename: MEMFS.node_ops.rename, unlink: MEMFS.node_ops.unlink, rmdir: MEMFS.node_ops.rmdir, readdir: MEMFS.node_ops.readdir, symlink: MEMFS.node_ops.symlink }, stream: { llseek: MEMFS.stream_ops.llseek } }, file: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr }, stream: { llseek: MEMFS.stream_ops.llseek, read: MEMFS.stream_ops.read, write: MEMFS.stream_ops.write, allocate: MEMFS.stream_ops.allocate, mmap: MEMFS.stream_ops.mmap, msync: MEMFS.stream_ops.msync } }, link: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr, readlink: MEMFS.node_ops.readlink }, stream: {} }, chrdev: { node: { getattr: MEMFS.node_ops.getattr, setattr: MEMFS.node_ops.setattr }, stream: FS.chrdev_stream_ops } } } var node = FS.createNode(parent, name, mode, dev); if (FS.isDir(node.mode)) { node.node_ops = MEMFS.ops_table.dir.node; node.stream_ops = MEMFS.ops_table.dir.stream; node.contents = {} } else if (FS.isFile(node.mode)) { node.node_ops = MEMFS.ops_table.file.node; node.stream_ops = MEMFS.ops_table.file.stream; node.usedBytes = 0; node.contents = null } else if (FS.isLink(node.mode)) { node.node_ops = MEMFS.ops_table.link.node; node.stream_ops = MEMFS.ops_table.link.stream } else if (FS.isChrdev(node.mode)) { node.node_ops = MEMFS.ops_table.chrdev.node; node.stream_ops = MEMFS.ops_table.chrdev.stream } node.timestamp = Date.now(); if (parent) { parent.contents[name] = node } return node }, getFileDataAsRegularArray: function (node) { if (node.contents && node.contents.subarray) { var arr = []; for (var i = 0; i < node.usedBytes; ++i)arr.push(node.contents[i]); return arr } return node.contents }, getFileDataAsTypedArray: function (node) { if (!node.contents) return new Uint8Array; if (node.contents.subarray) return node.contents.subarray(0, node.usedBytes); return new Uint8Array(node.contents) }, expandFileStorage: function (node, newCapacity) { var prevCapacity = node.contents ? node.contents.length : 0; if (prevCapacity >= newCapacity) return; var CAPACITY_DOUBLING_MAX = 1024 * 1024; newCapacity = Math.max(newCapacity, prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2 : 1.125) | 0); if (prevCapacity != 0) newCapacity = Math.max(newCapacity, 256); var oldContents = node.contents; node.contents = new Uint8Array(newCapacity); if (node.usedBytes > 0) node.contents.set(oldContents.subarray(0, node.usedBytes), 0); return }, resizeFileStorage: function (node, newSize) { if (node.usedBytes == newSize) return; if (newSize == 0) { node.contents = null; node.usedBytes = 0; return } if (!node.contents || node.contents.subarray) { var oldContents = node.contents; node.contents = new Uint8Array(new ArrayBuffer(newSize)); if (oldContents) { node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes))) } node.usedBytes = newSize; return } if (!node.contents) node.contents = []; if (node.contents.length > newSize) node.contents.length = newSize; else while (node.contents.length < newSize) node.contents.push(0); node.usedBytes = newSize }, node_ops: { getattr: function (node) { var attr = {}; attr.dev = FS.isChrdev(node.mode) ? node.id : 1; attr.ino = node.id; attr.mode = node.mode; attr.nlink = 1; attr.uid = 0; attr.gid = 0; attr.rdev = node.rdev; if (FS.isDir(node.mode)) { attr.size = 4096 } else if (FS.isFile(node.mode)) { attr.size = node.usedBytes } else if (FS.isLink(node.mode)) { attr.size = node.link.length } else { attr.size = 0 } attr.atime = new Date(node.timestamp); attr.mtime = new Date(node.timestamp); attr.ctime = new Date(node.timestamp); attr.blksize = 4096; attr.blocks = Math.ceil(attr.size / attr.blksize); return attr }, setattr: function (node, attr) { if (attr.mode !== undefined) { node.mode = attr.mode } if (attr.timestamp !== undefined) { node.timestamp = attr.timestamp } if (attr.size !== undefined) { MEMFS.resizeFileStorage(node, attr.size) } }, lookup: function (parent, name) { throw FS.genericErrors[2] }, mknod: function (parent, name, mode, dev) { return MEMFS.createNode(parent, name, mode, dev) }, rename: function (old_node, new_dir, new_name) { if (FS.isDir(old_node.mode)) { var new_node; try { new_node = FS.lookupNode(new_dir, new_name) } catch (e) { } if (new_node) { for (var i in new_node.contents) { throw new FS.ErrnoError(39) } } } delete old_node.parent.contents[old_node.name]; old_node.name = new_name; new_dir.contents[new_name] = old_node; old_node.parent = new_dir }, unlink: function (parent, name) { delete parent.contents[name] }, rmdir: function (parent, name) { var node = FS.lookupNode(parent, name); for (var i in node.contents) { throw new FS.ErrnoError(39) } delete parent.contents[name] }, readdir: function (node) { var entries = [\".\", \"..\"]; for (var key in node.contents) { if (!node.contents.hasOwnProperty(key)) { continue } entries.push(key) } return entries }, symlink: function (parent, newname, oldpath) { var node = MEMFS.createNode(parent, newname, 511 | 40960, 0); node.link = oldpath; return node }, readlink: function (node) { if (!FS.isLink(node.mode)) { throw new FS.ErrnoError(22) } return node.link } }, stream_ops: { read: function (stream, buffer, offset, length, position) { var contents = stream.node.contents; if (position >= stream.node.usedBytes) return 0; var size = Math.min(stream.node.usedBytes - position, length); if (size > 8 && contents.subarray) { buffer.set(contents.subarray(position, position + size), offset) } else { for (var i = 0; i < size; i++)buffer[offset + i] = contents[position + i] } return size }, write: function (stream, buffer, offset, length, position, canOwn) { canOwn = false; if (!length) return 0; var node = stream.node; node.timestamp = Date.now(); if (buffer.subarray && (!node.contents || node.contents.subarray)) { if (canOwn) { node.contents = buffer.subarray(offset, offset + length); node.usedBytes = length; return length } else if (node.usedBytes === 0 && position === 0) { node.contents = new Uint8Array(buffer.subarray(offset, offset + length)); node.usedBytes = length; return length } else if (position + length <= node.usedBytes) { node.contents.set(buffer.subarray(offset, offset + length), position); return length } } MEMFS.expandFileStorage(node, position + length); if (node.contents.subarray && buffer.subarray) node.contents.set(buffer.subarray(offset, offset + length), position); else { for (var i = 0; i < length; i++) { node.contents[position + i] = buffer[offset + i] } } node.usedBytes = Math.max(node.usedBytes, position + length); return length }, llseek: function (stream, offset, whence) { var position = offset; if (whence === 1) { position += stream.position } else if (whence === 2) { if (FS.isFile(stream.node.mode)) { position += stream.node.usedBytes } } if (position < 0) { throw new FS.ErrnoError(22) } return position }, allocate: function (stream, offset, length) { MEMFS.expandFileStorage(stream.node, offset + length); stream.node.usedBytes = Math.max(stream.node.usedBytes, offset + length) }, mmap: function (stream, buffer, offset, length, position, prot, flags) { if (!FS.isFile(stream.node.mode)) { throw new FS.ErrnoError(19) } var ptr; var allocated; var contents = stream.node.contents; if (!(flags & 2) && (contents.buffer === buffer || contents.buffer === buffer.buffer)) { allocated = false; ptr = contents.byteOffset } else { if (position > 0 || position + length < stream.node.usedBytes) { if (contents.subarray) { contents = contents.subarray(position, position + length) } else { contents = Array.prototype.slice.call(contents, position, position + length) } } allocated = true; ptr = _malloc(length); if (!ptr) { throw new FS.ErrnoError(12) } buffer.set(contents, ptr) } return { ptr: ptr, allocated: allocated } }, msync: function (stream, buffer, offset, length, mmapFlags) { if (!FS.isFile(stream.node.mode)) { throw new FS.ErrnoError(19) } if (mmapFlags & 2) { return 0 } var bytesWritten = MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false); return 0 } } }; var IDBFS = { dbs: {}, indexedDB: function () { if (typeof indexedDB !== \"undefined\") return indexedDB; var ret = null; if (typeof window === \"object\") ret = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; assert(ret, \"IDBFS used, but indexedDB not supported\"); return ret }, DB_VERSION: 21, DB_STORE_NAME: \"FILE_DATA\", mount: function (mount) { return MEMFS.mount.apply(null, arguments) }, syncfs: function (mount, populate, callback) { IDBFS.getLocalSet(mount, function (err, local) { if (err) return callback(err); IDBFS.getRemoteSet(mount, function (err, remote) { if (err) return callback(err); var src = populate ? remote : local; var dst = populate ? local : remote; IDBFS.reconcile(src, dst, callback) }) }) }, getDB: function (name, callback) { var db = IDBFS.dbs[name]; if (db) { return callback(null, db) } var req; try { req = IDBFS.indexedDB().open(name, IDBFS.DB_VERSION) } catch (e) { return callback(e) } if (!req) { return callback(\"Unable to connect to IndexedDB\") } req.onupgradeneeded = function (e) { var db = e.target.result; var transaction = e.target.transaction; var fileStore; if (db.objectStoreNames.contains(IDBFS.DB_STORE_NAME)) { fileStore = transaction.objectStore(IDBFS.DB_STORE_NAME) } else { fileStore = db.createObjectStore(IDBFS.DB_STORE_NAME) } if (!fileStore.indexNames.contains(\"timestamp\")) { fileStore.createIndex(\"timestamp\", \"timestamp\", { unique: false }) } }; req.onsuccess = function () { db = req.result; IDBFS.dbs[name] = db; callback(null, db) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, getLocalSet: function (mount, callback) { var entries = {}; function isRealDir(p) { return p !== \".\" && p !== \"..\" } function toAbsolute(root) { return function (p) { return PATH.join2(root, p) } } var check = FS.readdir(mount.mountpoint).filter(isRealDir).map(toAbsolute(mount.mountpoint)); while (check.length) { var path = check.pop(); var stat; try { stat = FS.stat(path) } catch (e) { return callback(e) } if (FS.isDir(stat.mode)) { check.push.apply(check, FS.readdir(path).filter(isRealDir).map(toAbsolute(path))) } entries[path] = { timestamp: stat.mtime } } return callback(null, { type: \"local\", entries: entries }) }, getRemoteSet: function (mount, callback) { var entries = {}; IDBFS.getDB(mount.mountpoint, function (err, db) { if (err) return callback(err); try { var transaction = db.transaction([IDBFS.DB_STORE_NAME], \"readonly\"); transaction.onerror = function (e) { callback(this.error); e.preventDefault() }; var store = transaction.objectStore(IDBFS.DB_STORE_NAME); var index = store.index(\"timestamp\"); index.openKeyCursor().onsuccess = function (event) { var cursor = event.target.result; if (!cursor) { return callback(null, { type: \"remote\", db: db, entries: entries }) } entries[cursor.primaryKey] = { timestamp: cursor.key }; cursor.continue() } } catch (e) { return callback(e) } }) }, loadLocalEntry: function (path, callback) { var stat, node; try { var lookup = FS.lookupPath(path); node = lookup.node; stat = FS.stat(path) } catch (e) { return callback(e) } if (FS.isDir(stat.mode)) { return callback(null, { timestamp: stat.mtime, mode: stat.mode }) } else if (FS.isFile(stat.mode)) { node.contents = MEMFS.getFileDataAsTypedArray(node); return callback(null, { timestamp: stat.mtime, mode: stat.mode, contents: node.contents }) } else { return callback(new Error(\"node type not supported\")) } }, storeLocalEntry: function (path, entry, callback) { try { if (FS.isDir(entry.mode)) { FS.mkdir(path, entry.mode) } else if (FS.isFile(entry.mode)) { FS.writeFile(path, entry.contents, { canOwn: true }) } else { return callback(new Error(\"node type not supported\")) } FS.chmod(path, entry.mode); FS.utime(path, entry.timestamp, entry.timestamp) } catch (e) { return callback(e) } callback(null) }, removeLocalEntry: function (path, callback) { try { var lookup = FS.lookupPath(path); var stat = FS.stat(path); if (FS.isDir(stat.mode)) { FS.rmdir(path) } else if (FS.isFile(stat.mode)) { FS.unlink(path) } } catch (e) { return callback(e) } callback(null) }, loadRemoteEntry: function (store, path, callback) { var req = store.get(path); req.onsuccess = function (event) { callback(null, event.target.result) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, storeRemoteEntry: function (store, path, entry, callback) { var req = store.put(entry, path); req.onsuccess = function () { callback(null) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, removeRemoteEntry: function (store, path, callback) { var req = store.delete(path); req.onsuccess = function () { callback(null) }; req.onerror = function (e) { callback(this.error); e.preventDefault() } }, reconcile: function (src, dst, callback) { var total = 0; var create = []; Object.keys(src.entries).forEach(function (key) { var e = src.entries[key]; var e2 = dst.entries[key]; if (!e2 || e.timestamp > e2.timestamp) { create.push(key); total++ } }); var remove = []; Object.keys(dst.entries).forEach(function (key) { var e = dst.entries[key]; var e2 = src.entries[key]; if (!e2) { remove.push(key); total++ } }); if (!total) { return callback(null) } var errored = false; var completed = 0; var db = src.type === \"remote\" ? src.db : dst.db; var transaction = db.transaction([IDBFS.DB_STORE_NAME], \"readwrite\"); var store = transaction.objectStore(IDBFS.DB_STORE_NAME); function done(err) { if (err) { if (!done.errored) { done.errored = true; return callback(err) } return } if (++completed >= total) { return callback(null) } } transaction.onerror = function (e) { done(this.error); e.preventDefault() }; create.sort().forEach(function (path) { if (dst.type === \"local\") { IDBFS.loadRemoteEntry(store, path, function (err, entry) { if (err) return done(err); IDBFS.storeLocalEntry(path, entry, done) }) } else { IDBFS.loadLocalEntry(path, function (err, entry) { if (err) return done(err); IDBFS.storeRemoteEntry(store, path, entry, done) }) } }); remove.sort().reverse().forEach(function (path) { if (dst.type === \"local\") { IDBFS.removeLocalEntry(path, done) } else { IDBFS.removeRemoteEntry(store, path, done) } }) } }; var NODEFS = { isWindows: false, staticInit: function () { NODEFS.isWindows = !!process.platform.match(/^win/); var flags = process[\"binding\"](\"constants\"); if (flags[\"fs\"]) { flags = flags[\"fs\"] } NODEFS.flagsForNodeMap = { 1024: flags[\"O_APPEND\"], 64: flags[\"O_CREAT\"], 128: flags[\"O_EXCL\"], 0: flags[\"O_RDONLY\"], 2: flags[\"O_RDWR\"], 4096: flags[\"O_SYNC\"], 512: flags[\"O_TRUNC\"], 1: flags[\"O_WRONLY\"] } }, bufferFrom: function (arrayBuffer) { return Buffer.alloc ? Buffer.from(arrayBuffer) : new Buffer(arrayBuffer) }, mount: function (mount) { assert(ENVIRONMENT_IS_NODE); return NODEFS.createNode(null, \"/\", NODEFS.getMode(mount.opts.root), 0) }, createNode: function (parent, name, mode, dev) { if (!FS.isDir(mode) && !FS.isFile(mode) && !FS.isLink(mode)) { throw new FS.ErrnoError(22) } var node = FS.createNode(parent, name, mode); node.node_ops = NODEFS.node_ops; node.stream_ops = NODEFS.stream_ops; return node }, getMode: function (path) { var stat; try { stat = fs.lstatSync(path); if (NODEFS.isWindows) { stat.mode = stat.mode | (stat.mode & 292) >> 2 } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } return stat.mode }, realPath: function (node) { var parts = []; while (node.parent !== node) { parts.push(node.name); node = node.parent } parts.push(node.mount.opts.root); parts.reverse(); return PATH.join.apply(null, parts) }, flagsForNode: function (flags) { flags &= ~2097152; flags &= ~2048; flags &= ~32768; flags &= ~524288; var newFlags = 0; for (var k in NODEFS.flagsForNodeMap) { if (flags & k) { newFlags |= NODEFS.flagsForNodeMap[k]; flags ^= k } } if (!flags) { return newFlags } else { throw new FS.ErrnoError(22) } }, node_ops: { getattr: function (node) { var path = NODEFS.realPath(node); var stat; try { stat = fs.lstatSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } if (NODEFS.isWindows && !stat.blksize) { stat.blksize = 4096 } if (NODEFS.isWindows && !stat.blocks) { stat.blocks = (stat.size + stat.blksize - 1) / stat.blksize | 0 } return { dev: stat.dev, ino: stat.ino, mode: stat.mode, nlink: stat.nlink, uid: stat.uid, gid: stat.gid, rdev: stat.rdev, size: stat.size, atime: stat.atime, mtime: stat.mtime, ctime: stat.ctime, blksize: stat.blksize, blocks: stat.blocks } }, setattr: function (node, attr) { var path = NODEFS.realPath(node); try { if (attr.mode !== undefined) { fs.chmodSync(path, attr.mode); node.mode = attr.mode } if (attr.timestamp !== undefined) { var date = new Date(attr.timestamp); fs.utimesSync(path, date, date) } if (attr.size !== undefined) { fs.truncateSync(path, attr.size) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, lookup: function (parent, name) { var path = PATH.join2(NODEFS.realPath(parent), name); var mode = NODEFS.getMode(path); return NODEFS.createNode(parent, name, mode) }, mknod: function (parent, name, mode, dev) { var node = NODEFS.createNode(parent, name, mode, dev); var path = NODEFS.realPath(node); try { if (FS.isDir(node.mode)) { fs.mkdirSync(path, node.mode) } else { fs.writeFileSync(path, \"\", { mode: node.mode }) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } return node }, rename: function (oldNode, newDir, newName) { var oldPath = NODEFS.realPath(oldNode); var newPath = PATH.join2(NODEFS.realPath(newDir), newName); try { fs.renameSync(oldPath, newPath) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, unlink: function (parent, name) { var path = PATH.join2(NODEFS.realPath(parent), name); try { fs.unlinkSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, rmdir: function (parent, name) { var path = PATH.join2(NODEFS.realPath(parent), name); try { fs.rmdirSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, readdir: function (node) { var path = NODEFS.realPath(node); try { return fs.readdirSync(path) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, symlink: function (parent, newName, oldPath) { var newPath = PATH.join2(NODEFS.realPath(parent), newName); try { fs.symlinkSync(oldPath, newPath) } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, readlink: function (node) { var path = NODEFS.realPath(node); try { path = fs.readlinkSync(path); path = NODEJS_PATH.relative(NODEJS_PATH.resolve(node.mount.opts.root), path); return path } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } } }, stream_ops: { open: function (stream) { var path = NODEFS.realPath(stream.node); try { if (FS.isFile(stream.node.mode)) { stream.nfd = fs.openSync(path, NODEFS.flagsForNode(stream.flags)) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, close: function (stream) { try { if (FS.isFile(stream.node.mode) && stream.nfd) { fs.closeSync(stream.nfd) } } catch (e) { if (!e.code) throw e; throw new FS.ErrnoError(-e.errno) } }, read: function (stream, buffer, offset, length, position) { if (length === 0) return 0; try { return fs.readSync(stream.nfd, NODEFS.bufferFrom(buffer.buffer), offset, length, position) } catch (e) { throw new FS.ErrnoError(-e.errno) } }, write: function (stream, buffer, offset, length, position) { try { return fs.writeSync(stream.nfd, NODEFS.bufferFrom(buffer.buffer), offset, length, position) } catch (e) { throw new FS.ErrnoError(-e.errno) } }, llseek: function (stream, offset, whence) { var position = offset; if (whence === 1) { position += stream.position } else if (whence === 2) { if (FS.isFile(stream.node.mode)) { try { var stat = fs.fstatSync(stream.nfd); position += stat.size } catch (e) { throw new FS.ErrnoError(-e.errno) } } } if (position < 0) { throw new FS.ErrnoError(22) } return position } } }; var WORKERFS = { DIR_MODE: 16895, FILE_MODE: 33279, reader: null, mount: function (mount) { assert(ENVIRONMENT_IS_WORKER); if (!WORKERFS.reader) WORKERFS.reader = new FileReaderSync; var root = WORKERFS.createNode(null, \"/\", WORKERFS.DIR_MODE, 0); var createdParents = {}; function ensureParent(path) { var parts = path.split(\"/\"); var parent = root; for (var i = 0; i < parts.length - 1; i++) { var curr = parts.slice(0, i + 1).join(\"/\"); if (!createdParents[curr]) { createdParents[curr] = WORKERFS.createNode(parent, parts[i], WORKERFS.DIR_MODE, 0) } parent = createdParents[curr] } return parent } function base(path) { var parts = path.split(\"/\"); return parts[parts.length - 1] } Array.prototype.forEach.call(mount.opts[\"files\"] || [], function (file) { WORKERFS.createNode(ensureParent(file.name), base(file.name), WORKERFS.FILE_MODE, 0, file, file.lastModifiedDate) }); (mount.opts[\"blobs\"] || []).forEach(function (obj) { WORKERFS.createNode(ensureParent(obj[\"name\"]), base(obj[\"name\"]), WORKERFS.FILE_MODE, 0, obj[\"data\"]) }); (mount.opts[\"packages\"] || []).forEach(function (pack) { pack[\"metadata\"].files.forEach(function (file) { var name = file.filename.substr(1); WORKERFS.createNode(ensureParent(name), base(name), WORKERFS.FILE_MODE, 0, pack[\"blob\"].slice(file.start, file.end)) }) }); return root }, createNode: function (parent, name, mode, dev, contents, mtime) { var node = FS.createNode(parent, name, mode); node.mode = mode; node.node_ops = WORKERFS.node_ops; node.stream_ops = WORKERFS.stream_ops; node.timestamp = (mtime || new Date).getTime(); assert(WORKERFS.FILE_MODE !== WORKERFS.DIR_MODE); if (mode === WORKERFS.FILE_MODE) { node.size = contents.size; node.contents = contents } else { node.size = 4096; node.contents = {} } if (parent) { parent.contents[name] = node } return node }, node_ops: { getattr: function (node) { return { dev: 1, ino: undefined, mode: node.mode, nlink: 1, uid: 0, gid: 0, rdev: undefined, size: node.size, atime: new Date(node.timestamp), mtime: new Date(node.timestamp), ctime: new Date(node.timestamp), blksize: 4096, blocks: Math.ceil(node.size / 4096) } }, setattr: function (node, attr) { if (attr.mode !== undefined) { node.mode = attr.mode } if (attr.timestamp !== undefined) { node.timestamp = attr.timestamp } }, lookup: function (parent, name) { throw new FS.ErrnoError(2) }, mknod: function (parent, name, mode, dev) { throw new FS.ErrnoError(1) }, rename: function (oldNode, newDir, newName) { throw new FS.ErrnoError(1) }, unlink: function (parent, name) { throw new FS.ErrnoError(1) }, rmdir: function (parent, name) { throw new FS.ErrnoError(1) }, readdir: function (node) { var entries = [\".\", \"..\"]; for (var key in node.contents) { if (!node.contents.hasOwnProperty(key)) { continue } entries.push(key) } return entries }, symlink: function (parent, newName, oldPath) { throw new FS.ErrnoError(1) }, readlink: function (node) { throw new FS.ErrnoError(1) } }, stream_ops: { read: function (stream, buffer, offset, length, position) { if (position >= stream.node.size) return 0; var chunk = stream.node.contents.slice(position, position + length); var ab = WORKERFS.reader.readAsArrayBuffer(chunk); buffer.set(new Uint8Array(ab), offset); return chunk.size }, write: function (stream, buffer, offset, length, position) { throw new FS.ErrnoError(5) }, llseek: function (stream, offset, whence) { var position = offset; if (whence === 1) { position += stream.position } else if (whence === 2) { if (FS.isFile(stream.node.mode)) { position += stream.node.size } } if (position < 0) { throw new FS.ErrnoError(22) } return position } } }; var FS = { root: null, mounts: [], devices: {}, streams: [], nextInode: 1, nameTable: null, currentPath: \"/\", initialized: false, ignorePermissions: true, trackingDelegate: {}, tracking: { openFlags: { READ: 1, WRITE: 2 } }, ErrnoError: null, genericErrors: {}, filesystems: null, syncFSRequests: 0, handleFSError: function (e) { if (!(e instanceof FS.ErrnoError)) throw e + \" : \" + stackTrace(); return ___setErrNo(e.errno) }, lookupPath: function (path, opts) { path = PATH_FS.resolve(FS.cwd(), path); opts = opts || {}; if (!path) return { path: \"\", node: null }; var defaults = { follow_mount: true, recurse_count: 0 }; for (var key in defaults) { if (opts[key] === undefined) { opts[key] = defaults[key] } } if (opts.recurse_count > 8) { throw new FS.ErrnoError(40) } var parts = PATH.normalizeArray(path.split(\"/\").filter(function (p) { return !!p }), false); var current = FS.root; var current_path = \"/\"; for (var i = 0; i < parts.length; i++) { var islast = i === parts.length - 1; if (islast && opts.parent) { break } current = FS.lookupNode(current, parts[i]); current_path = PATH.join2(current_path, parts[i]); if (FS.isMountpoint(current)) { if (!islast || islast && opts.follow_mount) { current = current.mounted.root } } if (!islast || opts.follow) { var count = 0; while (FS.isLink(current.mode)) { var link = FS.readlink(current_path); current_path = PATH_FS.resolve(PATH.dirname(current_path), link); var lookup = FS.lookupPath(current_path, { recurse_count: opts.recurse_count }); current = lookup.node; if (count++ > 40) { throw new FS.ErrnoError(40) } } } } return { path: current_path, node: current } }, getPath: function (node) { var path; while (true) { if (FS.isRoot(node)) { var mount = node.mount.mountpoint; if (!path) return mount; return mount[mount.length - 1] !== \"/\" ? mount + \"/\" + path : mount + path } path = path ? node.name + \"/\" + path : node.name; node = node.parent } }, hashName: function (parentid, name) { var hash = 0; for (var i = 0; i < name.length; i++) { hash = (hash << 5) - hash + name.charCodeAt(i) | 0 } return (parentid + hash >>> 0) % FS.nameTable.length }, hashAddNode: function (node) { var hash = FS.hashName(node.parent.id, node.name); node.name_next = FS.nameTable[hash]; FS.nameTable[hash] = node }, hashRemoveNode: function (node) { var hash = FS.hashName(node.parent.id, node.name); if (FS.nameTable[hash] === node) { FS.nameTable[hash] = node.name_next } else { var current = FS.nameTable[hash]; while (current) { if (current.name_next === node) { current.name_next = node.name_next; break } current = current.name_next } } }, lookupNode: function (parent, name) { var err = FS.mayLookup(parent); if (err) { throw new FS.ErrnoError(err, parent) } var hash = FS.hashName(parent.id, name); for (var node = FS.nameTable[hash]; node; node = node.name_next) { var nodeName = node.name; if (node.parent.id === parent.id && nodeName === name) { return node } } return FS.lookup(parent, name) }, createNode: function (parent, name, mode, rdev) { if (!FS.FSNode) { FS.FSNode = function (parent, name, mode, rdev) { if (!parent) { parent = this } this.parent = parent; this.mount = parent.mount; this.mounted = null; this.id = FS.nextInode++; this.name = name; this.mode = mode; this.node_ops = {}; this.stream_ops = {}; this.rdev = rdev }; FS.FSNode.prototype = {}; var readMode = 292 | 73; var writeMode = 146; Object.defineProperties(FS.FSNode.prototype, { read: { get: function () { return (this.mode & readMode) === readMode }, set: function (val) { val ? this.mode |= readMode : this.mode &= ~readMode } }, write: { get: function () { return (this.mode & writeMode) === writeMode }, set: function (val) { val ? this.mode |= writeMode : this.mode &= ~writeMode } }, isFolder: { get: function () { return FS.isDir(this.mode) } }, isDevice: { get: function () { return FS.isChrdev(this.mode) } } }) } var node = new FS.FSNode(parent, name, mode, rdev); FS.hashAddNode(node); return node }, destroyNode: function (node) { FS.hashRemoveNode(node) }, isRoot: function (node) { return node === node.parent }, isMountpoint: function (node) { return !!node.mounted }, isFile: function (mode) { return (mode & 61440) === 32768 }, isDir: function (mode) { return (mode & 61440) === 16384 }, isLink: function (mode) { return (mode & 61440) === 40960 }, isChrdev: function (mode) { return (mode & 61440) === 8192 }, isBlkdev: function (mode) { return (mode & 61440) === 24576 }, isFIFO: function (mode) { return (mode & 61440) === 4096 }, isSocket: function (mode) { return (mode & 49152) === 49152 }, flagModes: { \"r\": 0, \"rs\": 1052672, \"r+\": 2, \"w\": 577, \"wx\": 705, \"xw\": 705, \"w+\": 578, \"wx+\": 706, \"xw+\": 706, \"a\": 1089, \"ax\": 1217, \"xa\": 1217, \"a+\": 1090, \"ax+\": 1218, \"xa+\": 1218 }, modeStringToFlags: function (str) { var flags = FS.flagModes[str]; if (typeof flags === \"undefined\") { throw new Error(\"Unknown file open mode: \" + str) } return flags }, flagsToPermissionString: function (flag) { var perms = [\"r\", \"w\", \"rw\"][flag & 3]; if (flag & 512) { perms += \"w\" } return perms }, nodePermissions: function (node, perms) { if (FS.ignorePermissions) { return 0 } if (perms.indexOf(\"r\") !== -1 && !(node.mode & 292)) { return 13 } else if (perms.indexOf(\"w\") !== -1 && !(node.mode & 146)) { return 13 } else if (perms.indexOf(\"x\") !== -1 && !(node.mode & 73)) { return 13 } return 0 }, mayLookup: function (dir) { var err = FS.nodePermissions(dir, \"x\"); if (err) return err; if (!dir.node_ops.lookup) return 13; return 0 }, mayCreate: function (dir, name) { try { var node = FS.lookupNode(dir, name); return 17 } catch (e) { } return FS.nodePermissions(dir, \"wx\") }, mayDelete: function (dir, name, isdir) { var node; try { node = FS.lookupNode(dir, name) } catch (e) { return e.errno } var err = FS.nodePermissions(dir, \"wx\"); if (err) { return err } if (isdir) { if (!FS.isDir(node.mode)) { return 20 } if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) { return 16 } } else { if (FS.isDir(node.mode)) { return 21 } } return 0 }, mayOpen: function (node, flags) { if (!node) { return 2 } if (FS.isLink(node.mode)) { return 40 } else if (FS.isDir(node.mode)) { if (FS.flagsToPermissionString(flags) !== \"r\" || flags & 512) { return 21 } } return FS.nodePermissions(node, FS.flagsToPermissionString(flags)) }, MAX_OPEN_FDS: 4096, nextfd: function (fd_start, fd_end) { fd_start = fd_start || 0; fd_end = fd_end || FS.MAX_OPEN_FDS; for (var fd = fd_start; fd <= fd_end; fd++) { if (!FS.streams[fd]) { return fd } } throw new FS.ErrnoError(24) }, getStream: function (fd) { return FS.streams[fd] }, createStream: function (stream, fd_start, fd_end) { if (!FS.FSStream) { FS.FSStream = function () { }; FS.FSStream.prototype = {}; Object.defineProperties(FS.FSStream.prototype, { object: { get: function () { return this.node }, set: function (val) { this.node = val } }, isRead: { get: function () { return (this.flags & 2097155) !== 1 } }, isWrite: { get: function () { return (this.flags & 2097155) !== 0 } }, isAppend: { get: function () { return this.flags & 1024 } } }) } var newStream = new FS.FSStream; for (var p in stream) { newStream[p] = stream[p] } stream = newStream; var fd = FS.nextfd(fd_start, fd_end); stream.fd = fd; FS.streams[fd] = stream; return stream }, closeStream: function (fd) { FS.streams[fd] = null }, chrdev_stream_ops: { open: function (stream) { var device = FS.getDevice(stream.node.rdev); stream.stream_ops = device.stream_ops; if (stream.stream_ops.open) { stream.stream_ops.open(stream) } }, llseek: function () { throw new FS.ErrnoError(29) } }, major: function (dev) { return dev >> 8 }, minor: function (dev) { return dev & 255 }, makedev: function (ma, mi) { return ma << 8 | mi }, registerDevice: function (dev, ops) { FS.devices[dev] = { stream_ops: ops } }, getDevice: function (dev) { return FS.devices[dev] }, getMounts: function (mount) { var mounts = []; var check = [mount]; while (check.length) { var m = check.pop(); mounts.push(m); check.push.apply(check, m.mounts) } return mounts }, syncfs: function (populate, callback) { if (typeof populate === \"function\") { callback = populate; populate = false } FS.syncFSRequests++; if (FS.syncFSRequests > 1) { console.log(\"warning: \" + FS.syncFSRequests + \" FS.syncfs operations in flight at once, probably just doing extra work\") } var mounts = FS.getMounts(FS.root.mount); var completed = 0; function doCallback(err) { FS.syncFSRequests--; return callback(err) } function done(err) { if (err) { if (!done.errored) { done.errored = true; return doCallback(err) } return } if (++completed >= mounts.length) { doCallback(null) } } mounts.forEach(function (mount) { if (!mount.type.syncfs) { return done(null) } mount.type.syncfs(mount, populate, done) }) }, mount: function (type, opts, mountpoint) { var root = mountpoint === \"/\"; var pseudo = !mountpoint; var node; if (root && FS.root) { throw new FS.ErrnoError(16) } else if (!root && !pseudo) { var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); mountpoint = lookup.path; node = lookup.node; if (FS.isMountpoint(node)) { throw new FS.ErrnoError(16) } if (!FS.isDir(node.mode)) { throw new FS.ErrnoError(20) } } var mount = { type: type, opts: opts, mountpoint: mountpoint, mounts: [] }; var mountRoot = type.mount(mount); mountRoot.mount = mount; mount.root = mountRoot; if (root) { FS.root = mountRoot } else if (node) { node.mounted = mount; if (node.mount) { node.mount.mounts.push(mount) } } return mountRoot }, unmount: function (mountpoint) { var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); if (!FS.isMountpoint(lookup.node)) { throw new FS.ErrnoError(22) } var node = lookup.node; var mount = node.mounted; var mounts = FS.getMounts(mount); Object.keys(FS.nameTable).forEach(function (hash) { var current = FS.nameTable[hash]; while (current) { var next = current.name_next; if (mounts.indexOf(current.mount) !== -1) { FS.destroyNode(current) } current = next } }); node.mounted = null; var idx = node.mount.mounts.indexOf(mount); node.mount.mounts.splice(idx, 1) }, lookup: function (parent, name) { return parent.node_ops.lookup(parent, name) }, mknod: function (path, mode, dev) { var lookup = FS.lookupPath(path, { parent: true }); var parent = lookup.node; var name = PATH.basename(path); if (!name || name === \".\" || name === \"..\") { throw new FS.ErrnoError(22) } var err = FS.mayCreate(parent, name); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.mknod) { throw new FS.ErrnoError(1) } return parent.node_ops.mknod(parent, name, mode, dev) }, create: function (path, mode) { mode = mode !== undefined ? mode : 438; mode &= 4095; mode |= 32768; return FS.mknod(path, mode, 0) }, mkdir: function (path, mode) { mode = mode !== undefined ? mode : 511; mode &= 511 | 512; mode |= 16384; return FS.mknod(path, mode, 0) }, mkdirTree: function (path, mode) { var dirs = path.split(\"/\"); var d = \"\"; for (var i = 0; i < dirs.length; ++i) { if (!dirs[i]) continue; d += \"/\" + dirs[i]; try { FS.mkdir(d, mode) } catch (e) { if (e.errno != 17) throw e } } }, mkdev: function (path, mode, dev) { if (typeof dev === \"undefined\") { dev = mode; mode = 438 } mode |= 8192; return FS.mknod(path, mode, dev) }, symlink: function (oldpath, newpath) { if (!PATH_FS.resolve(oldpath)) { throw new FS.ErrnoError(2) } var lookup = FS.lookupPath(newpath, { parent: true }); var parent = lookup.node; if (!parent) { throw new FS.ErrnoError(2) } var newname = PATH.basename(newpath); var err = FS.mayCreate(parent, newname); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.symlink) { throw new FS.ErrnoError(1) } return parent.node_ops.symlink(parent, newname, oldpath) }, rename: function (old_path, new_path) { var old_dirname = PATH.dirname(old_path); var new_dirname = PATH.dirname(new_path); var old_name = PATH.basename(old_path); var new_name = PATH.basename(new_path); var lookup, old_dir, new_dir; try { lookup = FS.lookupPath(old_path, { parent: true }); old_dir = lookup.node; lookup = FS.lookupPath(new_path, { parent: true }); new_dir = lookup.node } catch (e) { throw new FS.ErrnoError(16) } if (!old_dir || !new_dir) throw new FS.ErrnoError(2); if (old_dir.mount !== new_dir.mount) { throw new FS.ErrnoError(18) } var old_node = FS.lookupNode(old_dir, old_name); var relative = PATH_FS.relative(old_path, new_dirname); if (relative.charAt(0) !== \".\") { throw new FS.ErrnoError(22) } relative = PATH_FS.relative(new_path, old_dirname); if (relative.charAt(0) !== \".\") { throw new FS.ErrnoError(39) } var new_node; try { new_node = FS.lookupNode(new_dir, new_name) } catch (e) { } if (old_node === new_node) { return } var isdir = FS.isDir(old_node.mode); var err = FS.mayDelete(old_dir, old_name, isdir); if (err) { throw new FS.ErrnoError(err) } err = new_node ? FS.mayDelete(new_dir, new_name, isdir) : FS.mayCreate(new_dir, new_name); if (err) { throw new FS.ErrnoError(err) } if (!old_dir.node_ops.rename) { throw new FS.ErrnoError(1) } if (FS.isMountpoint(old_node) || new_node && FS.isMountpoint(new_node)) { throw new FS.ErrnoError(16) } if (new_dir !== old_dir) { err = FS.nodePermissions(old_dir, \"w\"); if (err) { throw new FS.ErrnoError(err) } } try { if (FS.trackingDelegate[\"willMovePath\"]) { FS.trackingDelegate[\"willMovePath\"](old_path, new_path) } } catch (e) { console.log(\"FS.trackingDelegate['willMovePath']('\" + old_path + \"', '\" + new_path + \"') threw an exception: \" + e.message) } FS.hashRemoveNode(old_node); try { old_dir.node_ops.rename(old_node, new_dir, new_name) } catch (e) { throw e } finally { FS.hashAddNode(old_node) } try { if (FS.trackingDelegate[\"onMovePath\"]) FS.trackingDelegate[\"onMovePath\"](old_path, new_path) } catch (e) { console.log(\"FS.trackingDelegate['onMovePath']('\" + old_path + \"', '\" + new_path + \"') threw an exception: \" + e.message) } }, rmdir: function (path) { var lookup = FS.lookupPath(path, { parent: true }); var parent = lookup.node; var name = PATH.basename(path); var node = FS.lookupNode(parent, name); var err = FS.mayDelete(parent, name, true); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.rmdir) { throw new FS.ErrnoError(1) } if (FS.isMountpoint(node)) { throw new FS.ErrnoError(16) } try { if (FS.trackingDelegate[\"willDeletePath\"]) { FS.trackingDelegate[\"willDeletePath\"](path) } } catch (e) { console.log(\"FS.trackingDelegate['willDeletePath']('\" + path + \"') threw an exception: \" + e.message) } parent.node_ops.rmdir(parent, name); FS.destroyNode(node); try { if (FS.trackingDelegate[\"onDeletePath\"]) FS.trackingDelegate[\"onDeletePath\"](path) } catch (e) { console.log(\"FS.trackingDelegate['onDeletePath']('\" + path + \"') threw an exception: \" + e.message) } }, readdir: function (path) { var lookup = FS.lookupPath(path, { follow: true }); var node = lookup.node; if (!node.node_ops.readdir) { throw new FS.ErrnoError(20) } return node.node_ops.readdir(node) }, unlink: function (path) { var lookup = FS.lookupPath(path, { parent: true }); var parent = lookup.node; var name = PATH.basename(path); var node = FS.lookupNode(parent, name); var err = FS.mayDelete(parent, name, false); if (err) { throw new FS.ErrnoError(err) } if (!parent.node_ops.unlink) { throw new FS.ErrnoError(1) } if (FS.isMountpoint(node)) { throw new FS.ErrnoError(16) } try { if (FS.trackingDelegate[\"willDeletePath\"]) { FS.trackingDelegate[\"willDeletePath\"](path) } } catch (e) { console.log(\"FS.trackingDelegate['willDeletePath']('\" + path + \"') threw an exception: \" + e.message) } parent.node_ops.unlink(parent, name); FS.destroyNode(node); try { if (FS.trackingDelegate[\"onDeletePath\"]) FS.trackingDelegate[\"onDeletePath\"](path) } catch (e) { console.log(\"FS.trackingDelegate['onDeletePath']('\" + path + \"') threw an exception: \" + e.message) } }, readlink: function (path) { var lookup = FS.lookupPath(path); var link = lookup.node; if (!link) { throw new FS.ErrnoError(2) } if (!link.node_ops.readlink) { throw new FS.ErrnoError(22) } return PATH_FS.resolve(FS.getPath(link.parent), link.node_ops.readlink(link)) }, stat: function (path, dontFollow) { var lookup = FS.lookupPath(path, { follow: !dontFollow }); var node = lookup.node; if (!node) { throw new FS.ErrnoError(2) } if (!node.node_ops.getattr) { throw new FS.ErrnoError(1) } return node.node_ops.getattr(node) }, lstat: function (path) { return FS.stat(path, true) }, chmod: function (path, mode, dontFollow) { var node; if (typeof path === \"string\") { var lookup = FS.lookupPath(path, { follow: !dontFollow }); node = lookup.node } else { node = path } if (!node.node_ops.setattr) { throw new FS.ErrnoError(1) } node.node_ops.setattr(node, { mode: mode & 4095 | node.mode & ~4095, timestamp: Date.now() }) }, lchmod: function (path, mode) { FS.chmod(path, mode, true) }, fchmod: function (fd, mode) { var stream = FS.getStream(fd); if (!stream) { throw new FS.ErrnoError(9) } FS.chmod(stream.node, mode) }, chown: function (path, uid, gid, dontFollow) { var node; if (typeof path === \"string\") { var lookup = FS.lookupPath(path, { follow: !dontFollow }); node = lookup.node } else { node = path } if (!node.node_ops.setattr) { throw new FS.ErrnoError(1) } node.node_ops.setattr(node, { timestamp: Date.now() }) }, lchown: function (path, uid, gid) { FS.chown(path, uid, gid, true) }, fchown: function (fd, uid, gid) { var stream = FS.getStream(fd); if (!stream) { throw new FS.ErrnoError(9) } FS.chown(stream.node, uid, gid) }, truncate: function (path, len) { if (len < 0) { throw new FS.ErrnoError(22) } var node; if (typeof path === \"string\") { var lookup = FS.lookupPath(path, { follow: true }); node = lookup.node } else { node = path } if (!node.node_ops.setattr) { throw new FS.ErrnoError(1) } if (FS.isDir(node.mode)) { throw new FS.ErrnoError(21) } if (!FS.isFile(node.mode)) { throw new FS.ErrnoError(22) } var err = FS.nodePermissions(node, \"w\"); if (err) { throw new FS.ErrnoError(err) } node.node_ops.setattr(node, { size: len, timestamp: Date.now() }) }, ftruncate: function (fd, len) { var stream = FS.getStream(fd); if (!stream) { throw new FS.ErrnoError(9) } if ((stream.flags & 2097155) === 0) { throw new FS.ErrnoError(22) } FS.truncate(stream.node, len) }, utime: function (path, atime, mtime) { var lookup = FS.lookupPath(path, { follow: true }); var node = lookup.node; node.node_ops.setattr(node, { timestamp: Math.max(atime, mtime) }) }, open: function (path, flags, mode, fd_start, fd_end) { if (path === \"\") { throw new FS.ErrnoError(2) } flags = typeof flags === \"string\" ? FS.modeStringToFlags(flags) : flags; mode = typeof mode === \"undefined\" ? 438 : mode; if (flags & 64) { mode = mode & 4095 | 32768 } else { mode = 0 } var node; if (typeof path === \"object\") { node = path } else { path = PATH.normalize(path); try { var lookup = FS.lookupPath(path, { follow: !(flags & 131072) }); node = lookup.node } catch (e) { } } var created = false; if (flags & 64) { if (node) { if (flags & 128) { throw new FS.ErrnoError(17) } } else { node = FS.mknod(path, mode, 0); created = true } } if (!node) { throw new FS.ErrnoError(2) } if (FS.isChrdev(node.mode)) { flags &= ~512 } if (flags & 65536 && !FS.isDir(node.mode)) { throw new FS.ErrnoError(20) } if (!created) { var err = FS.mayOpen(node, flags); if (err) { throw new FS.ErrnoError(err) } } if (flags & 512) { FS.truncate(node, 0) } flags &= ~(128 | 512); var stream = FS.createStream({ node: node, path: FS.getPath(node), flags: flags, seekable: true, position: 0, stream_ops: node.stream_ops, ungotten: [], error: false }, fd_start, fd_end); if (stream.stream_ops.open) { stream.stream_ops.open(stream) } if (Module[\"logReadFiles\"] && !(flags & 1)) { if (!FS.readFiles) FS.readFiles = {}; if (!(path in FS.readFiles)) { FS.readFiles[path] = 1; console.log(\"FS.trackingDelegate error on read file: \" + path) } } try { if (FS.trackingDelegate[\"onOpenFile\"]) { var trackingFlags = 0; if ((flags & 2097155) !== 1) { trackingFlags |= FS.tracking.openFlags.READ } if ((flags & 2097155) !== 0) { trackingFlags |= FS.tracking.openFlags.WRITE } FS.trackingDelegate[\"onOpenFile\"](path, trackingFlags) } } catch (e) { console.log(\"FS.trackingDelegate['onOpenFile']('\" + path + \"', flags) threw an exception: \" + e.message) } return stream }, close: function (stream) { if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if (stream.getdents) stream.getdents = null; try { if (stream.stream_ops.close) { stream.stream_ops.close(stream) } } catch (e) { throw e } finally { FS.closeStream(stream.fd) } stream.fd = null }, isClosed: function (stream) { return stream.fd === null }, llseek: function (stream, offset, whence) { if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if (!stream.seekable || !stream.stream_ops.llseek) { throw new FS.ErrnoError(29) } if (whence != 0 && whence != 1 && whence != 2) { throw new FS.ErrnoError(22) } stream.position = stream.stream_ops.llseek(stream, offset, whence); stream.ungotten = []; return stream.position }, read: function (stream, buffer, offset, length, position) { if (length < 0 || position < 0) { throw new FS.ErrnoError(22) } if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if ((stream.flags & 2097155) === 1) { throw new FS.ErrnoError(9) } if (FS.isDir(stream.node.mode)) { throw new FS.ErrnoError(21) } if (!stream.stream_ops.read) { throw new FS.ErrnoError(22) } var seeking = typeof position !== \"undefined\"; if (!seeking) { position = stream.position } else if (!stream.seekable) { throw new FS.ErrnoError(29) } var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position); if (!seeking) stream.position += bytesRead; return bytesRead }, write: function (stream, buffer, offset, length, position, canOwn) { if (length < 0 || position < 0) { throw new FS.ErrnoError(22) } if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if ((stream.flags & 2097155) === 0) { throw new FS.ErrnoError(9) } if (FS.isDir(stream.node.mode)) { throw new FS.ErrnoError(21) } if (!stream.stream_ops.write) { throw new FS.ErrnoError(22) } if (stream.flags & 1024) { FS.llseek(stream, 0, 2) } var seeking = typeof position !== \"undefined\"; if (!seeking) { position = stream.position } else if (!stream.seekable) { throw new FS.ErrnoError(29) } var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn); if (!seeking) stream.position += bytesWritten; try { if (stream.path && FS.trackingDelegate[\"onWriteToFile\"]) FS.trackingDelegate[\"onWriteToFile\"](stream.path) } catch (e) { console.log(\"FS.trackingDelegate['onWriteToFile']('\" + stream.path + \"') threw an exception: \" + e.message) } return bytesWritten }, allocate: function (stream, offset, length) { if (FS.isClosed(stream)) { throw new FS.ErrnoError(9) } if (offset < 0 || length <= 0) { throw new FS.ErrnoError(22) } if ((stream.flags & 2097155) === 0) { throw new FS.ErrnoError(9) } if (!FS.isFile(stream.node.mode) && !FS.isDir(stream.node.mode)) { throw new FS.ErrnoError(19) } if (!stream.stream_ops.allocate) { throw new FS.ErrnoError(95) } stream.stream_ops.allocate(stream, offset, length) }, mmap: function (stream, buffer, offset, length, position, prot, flags) { if ((stream.flags & 2097155) === 1) { throw new FS.ErrnoError(13) } if (!stream.stream_ops.mmap) { throw new FS.ErrnoError(19) } return stream.stream_ops.mmap(stream, buffer, offset, length, position, prot, flags) }, msync: function (stream, buffer, offset, length, mmapFlags) { if (!stream || !stream.stream_ops.msync) { return 0 } return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags) }, munmap: function (stream) { return 0 }, ioctl: function (stream, cmd, arg) { if (!stream.stream_ops.ioctl) { throw new FS.ErrnoError(25) } return stream.stream_ops.ioctl(stream, cmd, arg) }, readFile: function (path, opts) { opts = opts || {}; opts.flags = opts.flags || \"r\"; opts.encoding = opts.encoding || \"binary\"; if (opts.encoding !== \"utf8\" && opts.encoding !== \"binary\") { throw new Error('Invalid encoding type \"' + opts.encoding + '\"') } var ret; var stream = FS.open(path, opts.flags); var stat = FS.stat(path); var length = stat.size; var buf = new Uint8Array(length); FS.read(stream, buf, 0, length, 0); if (opts.encoding === \"utf8\") { ret = UTF8ArrayToString(buf, 0) } else if (opts.encoding === \"binary\") { ret = buf } FS.close(stream); return ret }, writeFile: function (path, data, opts) { opts = opts || {}; opts.flags = opts.flags || \"w\"; var stream = FS.open(path, opts.flags, opts.mode); if (typeof data === \"string\") { var buf = new Uint8Array(lengthBytesUTF8(data) + 1); var actualNumBytes = stringToUTF8Array(data, buf, 0, buf.length); FS.write(stream, buf, 0, actualNumBytes, undefined, opts.canOwn) } else if (ArrayBuffer.isView(data)) { FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn) } else { throw new Error(\"Unsupported data type\") } FS.close(stream) }, cwd: function () { return FS.currentPath }, chdir: function (path) { var lookup = FS.lookupPath(path, { follow: true }); if (lookup.node === null) { throw new FS.ErrnoError(2) } if (!FS.isDir(lookup.node.mode)) { throw new FS.ErrnoError(20) } var err = FS.nodePermissions(lookup.node, \"x\"); if (err) { throw new FS.ErrnoError(err) } FS.currentPath = lookup.path }, createDefaultDirectories: function () { FS.mkdir(\"/tmp\"); FS.mkdir(\"/home\"); FS.mkdir(\"/home/web_user\") }, createDefaultDevices: function () { FS.mkdir(\"/dev\"); FS.registerDevice(FS.makedev(1, 3), { read: function () { return 0 }, write: function (stream, buffer, offset, length, pos) { return length } }); FS.mkdev(\"/dev/null\", FS.makedev(1, 3)); TTY.register(FS.makedev(5, 0), TTY.default_tty_ops); TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops); FS.mkdev(\"/dev/tty\", FS.makedev(5, 0)); FS.mkdev(\"/dev/tty1\", FS.makedev(6, 0)); var random_device; if (typeof crypto === \"object\" && typeof crypto[\"getRandomValues\"] === \"function\") { var randomBuffer = new Uint8Array(1); random_device = function () { crypto.getRandomValues(randomBuffer); return randomBuffer[0] } } else if (ENVIRONMENT_IS_NODE) { try { var crypto_module = require(\"crypto\"); random_device = function () { return crypto_module[\"randomBytes\"](1)[0] } } catch (e) { } } else { } if (!random_device) { random_device = function () { abort(\"random_device\") } } FS.createDevice(\"/dev\", \"random\", random_device); FS.createDevice(\"/dev\", \"urandom\", random_device); FS.mkdir(\"/dev/shm\"); FS.mkdir(\"/dev/shm/tmp\") }, createSpecialDirectories: function () { FS.mkdir(\"/proc\"); FS.mkdir(\"/proc/self\"); FS.mkdir(\"/proc/self/fd\"); FS.mount({ mount: function () { var node = FS.createNode(\"/proc/self\", \"fd\", 16384 | 511, 73); node.node_ops = { lookup: function (parent, name) { var fd = +name; var stream = FS.getStream(fd); if (!stream) throw new FS.ErrnoError(9); var ret = { parent: null, mount: { mountpoint: \"fake\" }, node_ops: { readlink: function () { return stream.path } } }; ret.parent = ret; return ret } }; return node } }, {}, \"/proc/self/fd\") }, createStandardStreams: function () { if (Module[\"stdin\"]) { FS.createDevice(\"/dev\", \"stdin\", Module[\"stdin\"]) } else { FS.symlink(\"/dev/tty\", \"/dev/stdin\") } if (Module[\"stdout\"]) { FS.createDevice(\"/dev\", \"stdout\", null, Module[\"stdout\"]) } else { FS.symlink(\"/dev/tty\", \"/dev/stdout\") } if (Module[\"stderr\"]) { FS.createDevice(\"/dev\", \"stderr\", null, Module[\"stderr\"]) } else { FS.symlink(\"/dev/tty1\", \"/dev/stderr\") } var stdin = FS.open(\"/dev/stdin\", \"r\"); var stdout = FS.open(\"/dev/stdout\", \"w\"); var stderr = FS.open(\"/dev/stderr\", \"w\") }, ensureErrnoError: function () { if (FS.ErrnoError) return; FS.ErrnoError = function ErrnoError(errno, node) { this.node = node; this.setErrno = function (errno) { this.errno = errno }; this.setErrno(errno); this.message = \"FS error\"; if (this.stack) Object.defineProperty(this, \"stack\", { value: (new Error).stack, writable: true }) }; FS.ErrnoError.prototype = new Error; FS.ErrnoError.prototype.constructor = FS.ErrnoError;[2].forEach(function (code) { FS.genericErrors[code] = new FS.ErrnoError(code); FS.genericErrors[code].stack = \"<generic error, no stack>\" }) }, staticInit: function () { FS.ensureErrnoError(); FS.nameTable = new Array(4096); FS.mount(MEMFS, {}, \"/\"); FS.createDefaultDirectories(); FS.createDefaultDevices(); FS.createSpecialDirectories(); FS.filesystems = { \"MEMFS\": MEMFS, \"IDBFS\": IDBFS, \"NODEFS\": NODEFS, \"WORKERFS\": WORKERFS } }, init: function (input, output, error) { FS.init.initialized = true; FS.ensureErrnoError(); Module[\"stdin\"] = input || Module[\"stdin\"]; Module[\"stdout\"] = output || Module[\"stdout\"]; Module[\"stderr\"] = error || Module[\"stderr\"]; FS.createStandardStreams() }, quit: function () { FS.init.initialized = false; var fflush = Module[\"_fflush\"]; if (fflush) fflush(0); for (var i = 0; i < FS.streams.length; i++) { var stream = FS.streams[i]; if (!stream) { continue } FS.close(stream) } }, getMode: function (canRead, canWrite) { var mode = 0; if (canRead) mode |= 292 | 73; if (canWrite) mode |= 146; return mode }, joinPath: function (parts, forceRelative) { var path = PATH.join.apply(null, parts); if (forceRelative && path[0] == \"/\") path = path.substr(1); return path }, absolutePath: function (relative, base) { return PATH_FS.resolve(base, relative) }, standardizePath: function (path) { return PATH.normalize(path) }, findObject: function (path, dontResolveLastLink) { var ret = FS.analyzePath(path, dontResolveLastLink); if (ret.exists) { return ret.object } else { ___setErrNo(ret.error); return null } }, analyzePath: function (path, dontResolveLastLink) { try { var lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); path = lookup.path } catch (e) { } var ret = { isRoot: false, exists: false, error: 0, name: null, path: null, object: null, parentExists: false, parentPath: null, parentObject: null }; try { var lookup = FS.lookupPath(path, { parent: true }); ret.parentExists = true; ret.parentPath = lookup.path; ret.parentObject = lookup.node; ret.name = PATH.basename(path); lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); ret.exists = true; ret.path = lookup.path; ret.object = lookup.node; ret.name = lookup.node.name; ret.isRoot = lookup.path === \"/\" } catch (e) { ret.error = e.errno } return ret }, createFolder: function (parent, name, canRead, canWrite) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); var mode = FS.getMode(canRead, canWrite); return FS.mkdir(path, mode) }, createPath: function (parent, path, canRead, canWrite) { parent = typeof parent === \"string\" ? parent : FS.getPath(parent); var parts = path.split(\"/\").reverse(); while (parts.length) { var part = parts.pop(); if (!part) continue; var current = PATH.join2(parent, part); try { FS.mkdir(current) } catch (e) { } parent = current } return current }, createFile: function (parent, name, properties, canRead, canWrite) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); var mode = FS.getMode(canRead, canWrite); return FS.create(path, mode) }, createDataFile: function (parent, name, data, canRead, canWrite, canOwn) { var path = name ? PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name) : parent; var mode = FS.getMode(canRead, canWrite); var node = FS.create(path, mode); if (data) { if (typeof data === \"string\") { var arr = new Array(data.length); for (var i = 0, len = data.length; i < len; ++i)arr[i] = data.charCodeAt(i); data = arr } FS.chmod(node, mode | 146); var stream = FS.open(node, \"w\"); FS.write(stream, data, 0, data.length, 0, canOwn); FS.close(stream); FS.chmod(node, mode) } return node }, createDevice: function (parent, name, input, output) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); var mode = FS.getMode(!!input, !!output); if (!FS.createDevice.major) FS.createDevice.major = 64; var dev = FS.makedev(FS.createDevice.major++, 0); FS.registerDevice(dev, { open: function (stream) { stream.seekable = false }, close: function (stream) { if (output && output.buffer && output.buffer.length) { output(10) } }, read: function (stream, buffer, offset, length, pos) { var bytesRead = 0; for (var i = 0; i < length; i++) { var result; try { result = input() } catch (e) { throw new FS.ErrnoError(5) } if (result === undefined && bytesRead === 0) { throw new FS.ErrnoError(11) } if (result === null || result === undefined) break; bytesRead++; buffer[offset + i] = result } if (bytesRead) { stream.node.timestamp = Date.now() } return bytesRead }, write: function (stream, buffer, offset, length, pos) { for (var i = 0; i < length; i++) { try { output(buffer[offset + i]) } catch (e) { throw new FS.ErrnoError(5) } } if (length) { stream.node.timestamp = Date.now() } return i } }); return FS.mkdev(path, mode, dev) }, createLink: function (parent, name, target, canRead, canWrite) { var path = PATH.join2(typeof parent === \"string\" ? parent : FS.getPath(parent), name); return FS.symlink(target, path) }, forceLoadFile: function (obj) { if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; var success = true; if (typeof XMLHttpRequest !== \"undefined\") { throw new Error(\"Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.\") } else if (Module[\"read\"]) { try { obj.contents = intArrayFromString(Module[\"read\"](obj.url), true); obj.usedBytes = obj.contents.length } catch (e) { success = false } } else { throw new Error(\"Cannot load without read() or XMLHttpRequest.\") } if (!success) ___setErrNo(5); return success }, createLazyFile: function (parent, name, url, canRead, canWrite) { function LazyUint8Array() { this.lengthKnown = false; this.chunks = [] } LazyUint8Array.prototype.get = function LazyUint8Array_get(idx) { if (idx > this.length - 1 || idx < 0) { return undefined } var chunkOffset = idx % this.chunkSize; var chunkNum = idx / this.chunkSize | 0; return this.getter(chunkNum)[chunkOffset] }; LazyUint8Array.prototype.setDataGetter = function LazyUint8Array_setDataGetter(getter) { this.getter = getter }; LazyUint8Array.prototype.cacheLength = function LazyUint8Array_cacheLength() { var xhr = new XMLHttpRequest; xhr.open(\"HEAD\", url, false); xhr.send(null); if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error(\"Couldn't load \" + url + \". Status: \" + xhr.status); var datalength = Number(xhr.getResponseHeader(\"Content-length\")); var header; var hasByteServing = (header = xhr.getResponseHeader(\"Accept-Ranges\")) && header === \"bytes\"; var usesGzip = (header = xhr.getResponseHeader(\"Content-Encoding\")) && header === \"gzip\"; var chunkSize = 1024 * 1024; if (!hasByteServing) chunkSize = datalength; var doXHR = function (from, to) { if (from > to) throw new Error(\"invalid range (\" + from + \", \" + to + \") or no bytes requested!\"); if (to > datalength - 1) throw new Error(\"only \" + datalength + \" bytes available! programmer error!\"); var xhr = new XMLHttpRequest; xhr.open(\"GET\", url, false); if (datalength !== chunkSize) xhr.setRequestHeader(\"Range\", \"bytes=\" + from + \"-\" + to); if (typeof Uint8Array != \"undefined\") xhr.responseType = \"arraybuffer\"; if (xhr.overrideMimeType) { xhr.overrideMimeType(\"text/plain; charset=x-user-defined\") } xhr.send(null); if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error(\"Couldn't load \" + url + \". Status: \" + xhr.status); if (xhr.response !== undefined) { return new Uint8Array(xhr.response || []) } else { return intArrayFromString(xhr.responseText || \"\", true) } }; var lazyArray = this; lazyArray.setDataGetter(function (chunkNum) { var start = chunkNum * chunkSize; var end = (chunkNum + 1) * chunkSize - 1; end = Math.min(end, datalength - 1); if (typeof lazyArray.chunks[chunkNum] === \"undefined\") { lazyArray.chunks[chunkNum] = doXHR(start, end) } if (typeof lazyArray.chunks[chunkNum] === \"undefined\") throw new Error(\"doXHR failed!\"); return lazyArray.chunks[chunkNum] }); if (usesGzip || !datalength) { chunkSize = datalength = 1; datalength = this.getter(0).length; chunkSize = datalength; console.log(\"LazyFiles on gzip forces download of the whole file when length is accessed\") } this._length = datalength; this._chunkSize = chunkSize; this.lengthKnown = true }; if (typeof XMLHttpRequest !== \"undefined\") { if (!ENVIRONMENT_IS_WORKER) throw \"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc\"; var lazyArray = new LazyUint8Array; Object.defineProperties(lazyArray, { length: { get: function () { if (!this.lengthKnown) { this.cacheLength() } return this._length } }, chunkSize: { get: function () { if (!this.lengthKnown) { this.cacheLength() } return this._chunkSize } } }); var properties = { isDevice: false, contents: lazyArray } } else { var properties = { isDevice: false, url: url } } var node = FS.createFile(parent, name, properties, canRead, canWrite); if (properties.contents) { node.contents = properties.contents } else if (properties.url) { node.contents = null; node.url = properties.url } Object.defineProperties(node, { usedBytes: { get: function () { return this.contents.length } } }); var stream_ops = {}; var keys = Object.keys(node.stream_ops); keys.forEach(function (key) { var fn = node.stream_ops[key]; stream_ops[key] = function forceLoadLazyFile() { if (!FS.forceLoadFile(node)) { throw new FS.ErrnoError(5) } return fn.apply(null, arguments) } }); stream_ops.read = function stream_ops_read(stream, buffer, offset, length, position) { if (!FS.forceLoadFile(node)) { throw new FS.ErrnoError(5) } var contents = stream.node.contents; if (position >= contents.length) return 0; var size = Math.min(contents.length - position, length); if (contents.slice) { for (var i = 0; i < size; i++) { buffer[offset + i] = contents[position + i] } } else { for (var i = 0; i < size; i++) { buffer[offset + i] = contents.get(position + i) } } return size }; node.stream_ops = stream_ops; return node }, createPreloadedFile: function (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) { Browser.init(); var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent; var dep = getUniqueRunDependency(\"cp \" + fullname); function processData(byteArray) { function finish(byteArray) { if (preFinish) preFinish(); if (!dontCreateFile) { FS.createDataFile(parent, name, byteArray, canRead, canWrite, canOwn) } if (onload) onload(); removeRunDependency(dep) } var handled = false; Module[\"preloadPlugins\"].forEach(function (plugin) { if (handled) return; if (plugin[\"canHandle\"](fullname)) { plugin[\"handle\"](byteArray, fullname, finish, function () { if (onerror) onerror(); removeRunDependency(dep) }); handled = true } }); if (!handled) finish(byteArray) } addRunDependency(dep); if (typeof url == \"string\") { Browser.asyncLoad(url, function (byteArray) { processData(byteArray) }, onerror) } else { processData(url) } }, indexedDB: function () { return window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB }, DB_NAME: function () { return \"EM_FS_\" + window.location.pathname }, DB_VERSION: 20, DB_STORE_NAME: \"FILE_DATA\", saveFilesToDB: function (paths, onload, onerror) { onload = onload || function () { }; onerror = onerror || function () { }; var indexedDB = FS.indexedDB(); try { var openRequest = indexedDB.open(FS.DB_NAME(), FS.DB_VERSION) } catch (e) { return onerror(e) } openRequest.onupgradeneeded = function openRequest_onupgradeneeded() { console.log(\"creating db\"); var db = openRequest.result; db.createObjectStore(FS.DB_STORE_NAME) }; openRequest.onsuccess = function openRequest_onsuccess() { var db = openRequest.result; var transaction = db.transaction([FS.DB_STORE_NAME], \"readwrite\"); var files = transaction.objectStore(FS.DB_STORE_NAME); var ok = 0, fail = 0, total = paths.length; function finish() { if (fail == 0) onload(); else onerror() } paths.forEach(function (path) { var putRequest = files.put(FS.analyzePath(path).object.contents, path); putRequest.onsuccess = function putRequest_onsuccess() { ok++; if (ok + fail == total) finish() }; putRequest.onerror = function putRequest_onerror() { fail++; if (ok + fail == total) finish() } }); transaction.onerror = onerror }; openRequest.onerror = onerror }, loadFilesFromDB: function (paths, onload, onerror) { onload = onload || function () { }; onerror = onerror || function () { }; var indexedDB = FS.indexedDB(); try { var openRequest = indexedDB.open(FS.DB_NAME(), FS.DB_VERSION) } catch (e) { return onerror(e) } openRequest.onupgradeneeded = onerror; openRequest.onsuccess = function openRequest_onsuccess() { var db = openRequest.result; try { var transaction = db.transaction([FS.DB_STORE_NAME], \"readonly\") } catch (e) { onerror(e); return } var files = transaction.objectStore(FS.DB_STORE_NAME); var ok = 0, fail = 0, total = paths.length; function finish() { if (fail == 0) onload(); else onerror() } paths.forEach(function (path) { var getRequest = files.get(path); getRequest.onsuccess = function getRequest_onsuccess() { if (FS.analyzePath(path).exists) { FS.unlink(path) } FS.createDataFile(PATH.dirname(path), PATH.basename(path), getRequest.result, true, true, true); ok++; if (ok + fail == total) finish() }; getRequest.onerror = function getRequest_onerror() { fail++; if (ok + fail == total) finish() } }); transaction.onerror = onerror }; openRequest.onerror = onerror } }; var SYSCALLS = { DEFAULT_POLLMASK: 5, mappings: {}, umask: 511, calculateAt: function (dirfd, path) { if (path[0] !== \"/\") { var dir; if (dirfd === -100) { dir = FS.cwd() } else { var dirstream = FS.getStream(dirfd); if (!dirstream) throw new FS.ErrnoError(9); dir = dirstream.path } path = PATH.join2(dir, path) } return path }, doStat: function (func, path, buf) { try { var stat = func(path) } catch (e) { if (e && e.node && PATH.normalize(path) !== PATH.normalize(FS.getPath(e.node))) { return -20 } throw e } HEAP32[buf >> 2] = stat.dev; HEAP32[buf + 4 >> 2] = 0; HEAP32[buf + 8 >> 2] = stat.ino; HEAP32[buf + 12 >> 2] = stat.mode; HEAP32[buf + 16 >> 2] = stat.nlink; HEAP32[buf + 20 >> 2] = stat.uid; HEAP32[buf + 24 >> 2] = stat.gid; HEAP32[buf + 28 >> 2] = stat.rdev; HEAP32[buf + 32 >> 2] = 0; tempI64 = [stat.size >>> 0, (tempDouble = stat.size, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[buf + 40 >> 2] = tempI64[0], HEAP32[buf + 44 >> 2] = tempI64[1]; HEAP32[buf + 48 >> 2] = 4096; HEAP32[buf + 52 >> 2] = stat.blocks; HEAP32[buf + 56 >> 2] = stat.atime.getTime() / 1e3 | 0; HEAP32[buf + 60 >> 2] = 0; HEAP32[buf + 64 >> 2] = stat.mtime.getTime() / 1e3 | 0; HEAP32[buf + 68 >> 2] = 0; HEAP32[buf + 72 >> 2] = stat.ctime.getTime() / 1e3 | 0; HEAP32[buf + 76 >> 2] = 0; tempI64 = [stat.ino >>> 0, (tempDouble = stat.ino, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[buf + 80 >> 2] = tempI64[0], HEAP32[buf + 84 >> 2] = tempI64[1]; return 0 }, doMsync: function (addr, stream, len, flags) { var buffer = new Uint8Array(HEAPU8.subarray(addr, addr + len)); FS.msync(stream, buffer, 0, len, flags) }, doMkdir: function (path, mode) { path = PATH.normalize(path); if (path[path.length - 1] === \"/\") path = path.substr(0, path.length - 1); FS.mkdir(path, mode, 0); return 0 }, doMknod: function (path, mode, dev) { switch (mode & 61440) { case 32768: case 8192: case 24576: case 4096: case 49152: break; default: return -22 }FS.mknod(path, mode, dev); return 0 }, doReadlink: function (path, buf, bufsize) { if (bufsize <= 0) return -22; var ret = FS.readlink(path); var len = Math.min(bufsize, lengthBytesUTF8(ret)); var endChar = HEAP8[buf + len]; stringToUTF8(ret, buf, bufsize + 1); HEAP8[buf + len] = endChar; return len }, doAccess: function (path, amode) { if (amode & ~7) { return -22 } var node; var lookup = FS.lookupPath(path, { follow: true }); node = lookup.node; var perms = \"\"; if (amode & 4) perms += \"r\"; if (amode & 2) perms += \"w\"; if (amode & 1) perms += \"x\"; if (perms && FS.nodePermissions(node, perms)) { return -13 } return 0 }, doDup: function (path, flags, suggestFD) { var suggest = FS.getStream(suggestFD); if (suggest) FS.close(suggest); return FS.open(path, flags, 0, suggestFD, suggestFD).fd }, doReadv: function (stream, iov, iovcnt, offset) { var ret = 0; for (var i = 0; i < iovcnt; i++) { var ptr = HEAP32[iov + i * 8 >> 2]; var len = HEAP32[iov + (i * 8 + 4) >> 2]; var curr = FS.read(stream, HEAP8, ptr, len, offset); if (curr < 0) return -1; ret += curr; if (curr < len) break } return ret }, doWritev: function (stream, iov, iovcnt, offset) { var ret = 0; for (var i = 0; i < iovcnt; i++) { var ptr = HEAP32[iov + i * 8 >> 2]; var len = HEAP32[iov + (i * 8 + 4) >> 2]; var curr = FS.write(stream, HEAP8, ptr, len, offset); if (curr < 0) return -1; ret += curr } return ret }, varargs: 0, get: function (varargs) { SYSCALLS.varargs += 4; var ret = HEAP32[SYSCALLS.varargs - 4 >> 2]; return ret }, getStr: function () { var ret = UTF8ToString(SYSCALLS.get()); return ret }, getStreamFromFD: function () { var stream = FS.getStream(SYSCALLS.get()); if (!stream) throw new FS.ErrnoError(9); return stream }, get64: function () { var low = SYSCALLS.get(), high = SYSCALLS.get(); return low }, getZero: function () { SYSCALLS.get() } }; function ___syscall140(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), offset_high = SYSCALLS.get(), offset_low = SYSCALLS.get(), result = SYSCALLS.get(), whence = SYSCALLS.get(); if (!(offset_high == -1 && offset_low < 0) && !(offset_high == 0 && offset_low >= 0)) { return -75 } var offset = offset_low; FS.llseek(stream, offset, whence); tempI64 = [stream.position >>> 0, (tempDouble = stream.position, +Math_abs(tempDouble) >= 1 ? tempDouble > 0 ? (Math_min(+Math_floor(tempDouble / 4294967296), 4294967295) | 0) >>> 0 : ~~+Math_ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)], HEAP32[result >> 2] = tempI64[0], HEAP32[result + 4 >> 2] = tempI64[1]; if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; return 0 } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall146(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), iov = SYSCALLS.get(), iovcnt = SYSCALLS.get(); return SYSCALLS.doWritev(stream, iov, iovcnt) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall168(which, varargs) { SYSCALLS.varargs = varargs; try { var fds = SYSCALLS.get(), nfds = SYSCALLS.get(), timeout = SYSCALLS.get(); var nonzero = 0; for (var i = 0; i < nfds; i++) { var pollfd = fds + 8 * i; var fd = HEAP32[pollfd >> 2]; var events = HEAP16[pollfd + 4 >> 1]; var mask = 32; var stream = FS.getStream(fd); if (stream) { mask = SYSCALLS.DEFAULT_POLLMASK; if (stream.stream_ops.poll) { mask = stream.stream_ops.poll(stream) } } mask &= events | 8 | 16; if (mask) nonzero++; HEAP16[pollfd + 6 >> 1] = mask } return nonzero } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall195(which, varargs) { SYSCALLS.varargs = varargs; try { var path = SYSCALLS.getStr(), buf = SYSCALLS.get(); return SYSCALLS.doStat(FS.stat, path, buf) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall196(which, varargs) { SYSCALLS.varargs = varargs; try { var path = SYSCALLS.getStr(), buf = SYSCALLS.get(); return SYSCALLS.doStat(FS.lstat, path, buf) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall197(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), buf = SYSCALLS.get(); return SYSCALLS.doStat(FS.stat, stream.path, buf) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall221(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), cmd = SYSCALLS.get(); switch (cmd) { case 0: { var arg = SYSCALLS.get(); if (arg < 0) { return -22 } var newStream; newStream = FS.open(stream.path, stream.flags, 0, arg); return newStream.fd } case 1: case 2: return 0; case 3: return stream.flags; case 4: { var arg = SYSCALLS.get(); stream.flags |= arg; return 0 } case 12: { var arg = SYSCALLS.get(); var offset = 0; HEAP16[arg + offset >> 1] = 2; return 0 } case 13: case 14: return 0; case 16: case 8: return -22; case 9: ___setErrNo(22); return -1; default: { return -22 } } } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall3(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), buf = SYSCALLS.get(), count = SYSCALLS.get(); return FS.read(stream, HEAP8, buf, count) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall4(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(), buf = SYSCALLS.get(), count = SYSCALLS.get(); return FS.write(stream, HEAP8, buf, count) } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall41(which, varargs) { SYSCALLS.varargs = varargs; try { var old = SYSCALLS.getStreamFromFD(); return FS.open(old.path, old.flags, 0).fd } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } var ERRNO_CODES = { EPERM: 1, ENOENT: 2, ESRCH: 3, EINTR: 4, EIO: 5, ENXIO: 6, E2BIG: 7, ENOEXEC: 8, EBADF: 9, ECHILD: 10, EAGAIN: 11, EWOULDBLOCK: 11, ENOMEM: 12, EACCES: 13, EFAULT: 14, ENOTBLK: 15, EBUSY: 16, EEXIST: 17, EXDEV: 18, ENODEV: 19, ENOTDIR: 20, EISDIR: 21, EINVAL: 22, ENFILE: 23, EMFILE: 24, ENOTTY: 25, ETXTBSY: 26, EFBIG: 27, ENOSPC: 28, ESPIPE: 29, EROFS: 30, EMLINK: 31, EPIPE: 32, EDOM: 33, ERANGE: 34, ENOMSG: 42, EIDRM: 43, ECHRNG: 44, EL2NSYNC: 45, EL3HLT: 46, EL3RST: 47, ELNRNG: 48, EUNATCH: 49, ENOCSI: 50, EL2HLT: 51, EDEADLK: 35, ENOLCK: 37, EBADE: 52, EBADR: 53, EXFULL: 54, ENOANO: 55, EBADRQC: 56, EBADSLT: 57, EDEADLOCK: 35, EBFONT: 59, ENOSTR: 60, ENODATA: 61, ETIME: 62, ENOSR: 63, ENONET: 64, ENOPKG: 65, EREMOTE: 66, ENOLINK: 67, EADV: 68, ESRMNT: 69, ECOMM: 70, EPROTO: 71, EMULTIHOP: 72, EDOTDOT: 73, EBADMSG: 74, ENOTUNIQ: 76, EBADFD: 77, EREMCHG: 78, ELIBACC: 79, ELIBBAD: 80, ELIBSCN: 81, ELIBMAX: 82, ELIBEXEC: 83, ENOSYS: 38, ENOTEMPTY: 39, ENAMETOOLONG: 36, ELOOP: 40, EOPNOTSUPP: 95, EPFNOSUPPORT: 96, ECONNRESET: 104, ENOBUFS: 105, EAFNOSUPPORT: 97, EPROTOTYPE: 91, ENOTSOCK: 88, ENOPROTOOPT: 92, ESHUTDOWN: 108, ECONNREFUSED: 111, EADDRINUSE: 98, ECONNABORTED: 103, ENETUNREACH: 101, ENETDOWN: 100, ETIMEDOUT: 110, EHOSTDOWN: 112, EHOSTUNREACH: 113, EINPROGRESS: 115, EALREADY: 114, EDESTADDRREQ: 89, EMSGSIZE: 90, EPROTONOSUPPORT: 93, ESOCKTNOSUPPORT: 94, EADDRNOTAVAIL: 99, ENETRESET: 102, EISCONN: 106, ENOTCONN: 107, ETOOMANYREFS: 109, EUSERS: 87, EDQUOT: 122, ESTALE: 116, ENOTSUP: 95, ENOMEDIUM: 123, EILSEQ: 84, EOVERFLOW: 75, ECANCELED: 125, ENOTRECOVERABLE: 131, EOWNERDEAD: 130, ESTRPIPE: 86 }; var PIPEFS = { BUCKET_BUFFER_SIZE: 8192, mount: function (mount) { return FS.createNode(null, \"/\", 16384 | 511, 0) }, createPipe: function () { var pipe = { buckets: [] }; pipe.buckets.push({ buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: 0, roffset: 0 }); var rName = PIPEFS.nextname(); var wName = PIPEFS.nextname(); var rNode = FS.createNode(PIPEFS.root, rName, 4096, 0); var wNode = FS.createNode(PIPEFS.root, wName, 4096, 0); rNode.pipe = pipe; wNode.pipe = pipe; var readableStream = FS.createStream({ path: rName, node: rNode, flags: FS.modeStringToFlags(\"r\"), seekable: false, stream_ops: PIPEFS.stream_ops }); rNode.stream = readableStream; var writableStream = FS.createStream({ path: wName, node: wNode, flags: FS.modeStringToFlags(\"w\"), seekable: false, stream_ops: PIPEFS.stream_ops }); wNode.stream = writableStream; return { readable_fd: readableStream.fd, writable_fd: writableStream.fd } }, stream_ops: { poll: function (stream) { var pipe = stream.node.pipe; if ((stream.flags & 2097155) === 1) { return 256 | 4 } else { if (pipe.buckets.length > 0) { for (var i = 0; i < pipe.buckets.length; i++) { var bucket = pipe.buckets[i]; if (bucket.offset - bucket.roffset > 0) { return 64 | 1 } } } } return 0 }, ioctl: function (stream, request, varargs) { return ERRNO_CODES.EINVAL }, read: function (stream, buffer, offset, length, position) { var pipe = stream.node.pipe; var currentLength = 0; for (var i = 0; i < pipe.buckets.length; i++) { var bucket = pipe.buckets[i]; currentLength += bucket.offset - bucket.roffset } assert(buffer instanceof ArrayBuffer || ArrayBuffer.isView(buffer)); var data = buffer.subarray(offset, offset + length); if (length <= 0) { return 0 } if (currentLength == 0) { throw new FS.ErrnoError(ERRNO_CODES.EAGAIN) } var toRead = Math.min(currentLength, length); var totalRead = toRead; var toRemove = 0; for (var i = 0; i < pipe.buckets.length; i++) { var currBucket = pipe.buckets[i]; var bucketSize = currBucket.offset - currBucket.roffset; if (toRead <= bucketSize) { var tmpSlice = currBucket.buffer.subarray(currBucket.roffset, currBucket.offset); if (toRead < bucketSize) { tmpSlice = tmpSlice.subarray(0, toRead); currBucket.roffset += toRead } else { toRemove++ } data.set(tmpSlice); break } else { var tmpSlice = currBucket.buffer.subarray(currBucket.roffset, currBucket.offset); data.set(tmpSlice); data = data.subarray(tmpSlice.byteLength); toRead -= tmpSlice.byteLength; toRemove++ } } if (toRemove && toRemove == pipe.buckets.length) { toRemove--; pipe.buckets[toRemove].offset = 0; pipe.buckets[toRemove].roffset = 0 } pipe.buckets.splice(0, toRemove); return totalRead }, write: function (stream, buffer, offset, length, position) { var pipe = stream.node.pipe; assert(buffer instanceof ArrayBuffer || ArrayBuffer.isView(buffer)); var data = buffer.subarray(offset, offset + length); var dataLen = data.byteLength; if (dataLen <= 0) { return 0 } var currBucket = null; if (pipe.buckets.length == 0) { currBucket = { buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: 0, roffset: 0 }; pipe.buckets.push(currBucket) } else { currBucket = pipe.buckets[pipe.buckets.length - 1] } assert(currBucket.offset <= PIPEFS.BUCKET_BUFFER_SIZE); var freeBytesInCurrBuffer = PIPEFS.BUCKET_BUFFER_SIZE - currBucket.offset; if (freeBytesInCurrBuffer >= dataLen) { currBucket.buffer.set(data, currBucket.offset); currBucket.offset += dataLen; return dataLen } else if (freeBytesInCurrBuffer > 0) { currBucket.buffer.set(data.subarray(0, freeBytesInCurrBuffer), currBucket.offset); currBucket.offset += freeBytesInCurrBuffer; data = data.subarray(freeBytesInCurrBuffer, data.byteLength) } var numBuckets = data.byteLength / PIPEFS.BUCKET_BUFFER_SIZE | 0; var remElements = data.byteLength % PIPEFS.BUCKET_BUFFER_SIZE; for (var i = 0; i < numBuckets; i++) { var newBucket = { buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: PIPEFS.BUCKET_BUFFER_SIZE, roffset: 0 }; pipe.buckets.push(newBucket); newBucket.buffer.set(data.subarray(0, PIPEFS.BUCKET_BUFFER_SIZE)); data = data.subarray(PIPEFS.BUCKET_BUFFER_SIZE, data.byteLength) } if (remElements > 0) { var newBucket = { buffer: new Uint8Array(PIPEFS.BUCKET_BUFFER_SIZE), offset: data.byteLength, roffset: 0 }; pipe.buckets.push(newBucket); newBucket.buffer.set(data) } return dataLen }, close: function (stream) { var pipe = stream.node.pipe; pipe.buckets = null } }, nextname: function () { if (!PIPEFS.nextname.current) { PIPEFS.nextname.current = 0 } return \"pipe[\" + PIPEFS.nextname.current++ + \"]\" } }; function ___syscall42(which, varargs) { SYSCALLS.varargs = varargs; try { var fdPtr = SYSCALLS.get(); if (fdPtr == 0) { throw new FS.ErrnoError(14) } var res = PIPEFS.createPipe(); HEAP32[fdPtr >> 2] = res.readable_fd; HEAP32[fdPtr + 4 >> 2] = res.writable_fd; return 0 } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall5(which, varargs) { SYSCALLS.varargs = varargs; try { var pathname = SYSCALLS.getStr(), flags = SYSCALLS.get(), mode = SYSCALLS.get(); var stream = FS.open(pathname, flags, mode); return stream.fd } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function ___syscall6(which, varargs) { SYSCALLS.varargs = varargs; try { var stream = SYSCALLS.getStreamFromFD(); FS.close(stream); return 0 } catch (e) { if (typeof FS === \"undefined\" || !(e instanceof FS.ErrnoError)) abort(e); return -e.errno } } function _abort() { Module[\"abort\"]() } function _emscripten_get_heap_size() { return HEAP8.length } function abortOnCannotGrowMemory(requestedSize) { abort(\"OOM\") } function emscripten_realloc_buffer(size) { var PAGE_MULTIPLE = 65536; size = alignUp(size, PAGE_MULTIPLE); var oldSize = buffer.byteLength; try { var result = wasmMemory.grow((size - oldSize) / 65536); if (result !== (-1 | 0)) { buffer = wasmMemory.buffer; return true } else { return false } } catch (e) { return false } } function _emscripten_resize_heap(requestedSize) { var oldSize = _emscripten_get_heap_size(); var PAGE_MULTIPLE = 65536; var LIMIT = 2147483648 - PAGE_MULTIPLE; if (requestedSize > LIMIT) { return false } var MIN_TOTAL_MEMORY = 16777216; var newSize = Math.max(oldSize, MIN_TOTAL_MEMORY); while (newSize < requestedSize) { if (newSize <= 536870912) { newSize = alignUp(2 * newSize, PAGE_MULTIPLE) } else { newSize = Math.min(alignUp((3 * newSize + 2147483648) / 4, PAGE_MULTIPLE), LIMIT) } } if (!emscripten_realloc_buffer(newSize)) { return false } updateGlobalBufferViews(); return true } function _exit(status) { exit(status) } var ___tm_current = 277408; var ___tm_timezone = (stringToUTF8(\"GMT\", 277456, 4), 277456); function _tzset() { if (_tzset.called) return; _tzset.called = true; HEAP32[__get_timezone() >> 2] = (new Date).getTimezoneOffset() * 60; var winter = new Date(2e3, 0, 1); var summer = new Date(2e3, 6, 1); HEAP32[__get_daylight() >> 2] = Number(winter.getTimezoneOffset() != summer.getTimezoneOffset()); function extractZone(date) { var match = date.toTimeString().match(/\\(([A-Za-z ]+)\\)$/); return match ? match[1] : \"GMT\" } var winterName = extractZone(winter); var summerName = extractZone(summer); var winterNamePtr = allocate(intArrayFromString(winterName), \"i8\", ALLOC_NORMAL); var summerNamePtr = allocate(intArrayFromString(summerName), \"i8\", ALLOC_NORMAL); if (summer.getTimezoneOffset() < winter.getTimezoneOffset()) { HEAP32[__get_tzname() >> 2] = winterNamePtr; HEAP32[__get_tzname() + 4 >> 2] = summerNamePtr } else { HEAP32[__get_tzname() >> 2] = summerNamePtr; HEAP32[__get_tzname() + 4 >> 2] = winterNamePtr } } function _localtime_r(time, tmPtr) { _tzset(); var date = new Date(HEAP32[time >> 2] * 1e3); HEAP32[tmPtr >> 2] = date.getSeconds(); HEAP32[tmPtr + 4 >> 2] = date.getMinutes(); HEAP32[tmPtr + 8 >> 2] = date.getHours(); HEAP32[tmPtr + 12 >> 2] = date.getDate(); HEAP32[tmPtr + 16 >> 2] = date.getMonth(); HEAP32[tmPtr + 20 >> 2] = date.getFullYear() - 1900; HEAP32[tmPtr + 24 >> 2] = date.getDay(); var start = new Date(date.getFullYear(), 0, 1); var yday = (date.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24) | 0; HEAP32[tmPtr + 28 >> 2] = yday; HEAP32[tmPtr + 36 >> 2] = -(date.getTimezoneOffset() * 60); var summerOffset = new Date(2e3, 6, 1).getTimezoneOffset(); var winterOffset = start.getTimezoneOffset(); var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset)) | 0; HEAP32[tmPtr + 32 >> 2] = dst; var zonePtr = HEAP32[__get_tzname() + (dst ? 4 : 0) >> 2]; HEAP32[tmPtr + 40 >> 2] = zonePtr; return tmPtr } function _localtime(time) { return _localtime_r(time, ___tm_current) } function _emscripten_memcpy_big(dest, src, num) { HEAPU8.set(HEAPU8.subarray(src, src + num), dest) } function _mktime(tmPtr) { _tzset(); var date = new Date(HEAP32[tmPtr + 20 >> 2] + 1900, HEAP32[tmPtr + 16 >> 2], HEAP32[tmPtr + 12 >> 2], HEAP32[tmPtr + 8 >> 2], HEAP32[tmPtr + 4 >> 2], HEAP32[tmPtr >> 2], 0); var dst = HEAP32[tmPtr + 32 >> 2]; var guessedOffset = date.getTimezoneOffset(); var start = new Date(date.getFullYear(), 0, 1); var summerOffset = new Date(2e3, 6, 1).getTimezoneOffset(); var winterOffset = start.getTimezoneOffset(); var dstOffset = Math.min(winterOffset, summerOffset); if (dst < 0) { HEAP32[tmPtr + 32 >> 2] = Number(summerOffset != winterOffset && dstOffset == guessedOffset) } else if (dst > 0 != (dstOffset == guessedOffset)) { var nonDstOffset = Math.max(winterOffset, summerOffset); var trueOffset = dst > 0 ? dstOffset : nonDstOffset; date.setTime(date.getTime() + (trueOffset - guessedOffset) * 6e4) } HEAP32[tmPtr + 24 >> 2] = date.getDay(); var yday = (date.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24) | 0; HEAP32[tmPtr + 28 >> 2] = yday; return date.getTime() / 1e3 | 0 } function _posix_spawn_file_actions_addclose() { err(\"missing function: posix_spawn_file_actions_addclose\"); abort(-1) } function _posix_spawn_file_actions_adddup2() { err(\"missing function: posix_spawn_file_actions_adddup2\"); abort(-1) } function _posix_spawn_file_actions_destroy() { err(\"missing function: posix_spawn_file_actions_destroy\"); abort(-1) } function _posix_spawn_file_actions_init() { err(\"missing function: posix_spawn_file_actions_init\"); abort(-1) } function _fork() { ___setErrNo(11); return -1 } function _posix_spawnp() { return _fork.apply(null, arguments) } function _timegm(tmPtr) { _tzset(); var time = Date.UTC(HEAP32[tmPtr + 20 >> 2] + 1900, HEAP32[tmPtr + 16 >> 2], HEAP32[tmPtr + 12 >> 2], HEAP32[tmPtr + 8 >> 2], HEAP32[tmPtr + 4 >> 2], HEAP32[tmPtr >> 2], 0); var date = new Date(time); HEAP32[tmPtr + 24 >> 2] = date.getUTCDay(); var start = Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0); var yday = (date.getTime() - start) / (1e3 * 60 * 60 * 24) | 0; HEAP32[tmPtr + 28 >> 2] = yday; return date.getTime() / 1e3 | 0 } function _wait(stat_loc) { ___setErrNo(10); return -1 } function _waitpid() { return _wait.apply(null, arguments) } FS.staticInit(); if (ENVIRONMENT_IS_NODE) { var fs = require(\"fs\"); var NODEJS_PATH = require(\"path\"); NODEFS.staticInit() } function intArrayFromString(stringy, dontAddNull, length) { var len = length > 0 ? length : lengthBytesUTF8(stringy) + 1; var u8array = new Array(len); var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); if (dontAddNull) u8array.length = numBytesWritten; return u8array } var asmGlobalArg = {}; var asmLibraryArg = { \"b\": abort, \"q\": setTempRet0, \"G\": ___buildEnvironment, \"l\": ___setErrNo, \"s\": ___syscall140, \"i\": ___syscall146, \"p\": ___syscall168, \"o\": ___syscall195, \"n\": ___syscall196, \"m\": ___syscall197, \"c\": ___syscall221, \"F\": ___syscall3, \"E\": ___syscall4, \"D\": ___syscall41, \"C\": ___syscall42, \"B\": ___syscall5, \"h\": ___syscall6, \"g\": _abort, \"A\": _emscripten_get_heap_size, \"z\": _emscripten_memcpy_big, \"y\": _emscripten_resize_heap, \"f\": _exit, \"x\": _localtime, \"d\": _mktime, \"e\": _posix_spawn_file_actions_addclose, \"k\": _posix_spawn_file_actions_adddup2, \"j\": _posix_spawn_file_actions_destroy, \"w\": _posix_spawn_file_actions_init, \"v\": _posix_spawnp, \"u\": _timegm, \"t\": _waitpid, \"r\": abortOnCannotGrowMemory, \"a\": DYNAMICTOP_PTR }; var asm = Module[\"asm\"](asmGlobalArg, asmLibraryArg, buffer); Module[\"asm\"] = asm; var ___emscripten_environ_constructor = Module[\"___emscripten_environ_constructor\"] = function () { return Module[\"asm\"][\"H\"].apply(null, arguments) }; var ___errno_location = Module[\"___errno_location\"] = function () { return Module[\"asm\"][\"I\"].apply(null, arguments) }; var __get_daylight = Module[\"__get_daylight\"] = function () { return Module[\"asm\"][\"J\"].apply(null, arguments) }; var __get_timezone = Module[\"__get_timezone\"] = function () { return Module[\"asm\"][\"K\"].apply(null, arguments) }; var __get_tzname = Module[\"__get_tzname\"] = function () { return Module[\"asm\"][\"L\"].apply(null, arguments) }; var _archive_close = Module[\"_archive_close\"] = function () { return Module[\"asm\"][\"M\"].apply(null, arguments) }; var _archive_entry_filetype = Module[\"_archive_entry_filetype\"] = function () { return Module[\"asm\"][\"N\"].apply(null, arguments) }; var _archive_entry_is_encrypted = Module[\"_archive_entry_is_encrypted\"] = function () { return Module[\"asm\"][\"O\"].apply(null, arguments) }; var _archive_entry_pathname = Module[\"_archive_entry_pathname\"] = function () { return Module[\"asm\"][\"P\"].apply(null, arguments) }; var _archive_entry_pathname_utf8 = Module[\"_archive_entry_pathname_utf8\"] = function () { return Module[\"asm\"][\"Q\"].apply(null, arguments) }; var _archive_entry_size = Module[\"_archive_entry_size\"] = function () { return Module[\"asm\"][\"R\"].apply(null, arguments) }; var _archive_error_string = Module[\"_archive_error_string\"] = function () { return Module[\"asm\"][\"S\"].apply(null, arguments) }; var _archive_open = Module[\"_archive_open\"] = function () { return Module[\"asm\"][\"T\"].apply(null, arguments) }; var _archive_read_add_passphrase = Module[\"_archive_read_add_passphrase\"] = function () { return Module[\"asm\"][\"U\"].apply(null, arguments) }; var _archive_read_data_skip = Module[\"_archive_read_data_skip\"] = function () { return Module[\"asm\"][\"V\"].apply(null, arguments) }; var _archive_read_has_encrypted_entries = Module[\"_archive_read_has_encrypted_entries\"] = function () { return Module[\"asm\"][\"W\"].apply(null, arguments) }; var _free = Module[\"_free\"] = function () { return Module[\"asm\"][\"X\"].apply(null, arguments) }; var _get_filedata = Module[\"_get_filedata\"] = function () { return Module[\"asm\"][\"Y\"].apply(null, arguments) }; var _get_next_entry = Module[\"_get_next_entry\"] = function () { return Module[\"asm\"][\"Z\"].apply(null, arguments) }; var _get_version = Module[\"_get_version\"] = function () { return Module[\"asm\"][\"_\"].apply(null, arguments) }; var _malloc = Module[\"_malloc\"] = function () { return Module[\"asm\"][\"$\"].apply(null, arguments) }; var stackAlloc = Module[\"stackAlloc\"] = function () { return Module[\"asm\"][\"ca\"].apply(null, arguments) }; var stackRestore = Module[\"stackRestore\"] = function () { return Module[\"asm\"][\"da\"].apply(null, arguments) }; var stackSave = Module[\"stackSave\"] = function () { return Module[\"asm\"][\"ea\"].apply(null, arguments) }; var dynCall_v = Module[\"dynCall_v\"] = function () { return Module[\"asm\"][\"aa\"].apply(null, arguments) }; var dynCall_vi = Module[\"dynCall_vi\"] = function () { return Module[\"asm\"][\"ba\"].apply(null, arguments) }; Module[\"asm\"] = asm; Module[\"intArrayFromString\"] = intArrayFromString; Module[\"cwrap\"] = cwrap; Module[\"allocate\"] = allocate; Module[\"then\"] = function (func) { if (Module[\"calledRun\"]) { func(Module) } else { var old = Module[\"onRuntimeInitialized\"]; Module[\"onRuntimeInitialized\"] = function () { if (old) old(); func(Module) } } return Module }; function ExitStatus(status) { this.name = \"ExitStatus\"; this.message = \"Program terminated with exit(\" + status + \")\"; this.status = status } ExitStatus.prototype = new Error; ExitStatus.prototype.constructor = ExitStatus; dependenciesFulfilled = function runCaller() { if (!Module[\"calledRun\"]) run(); if (!Module[\"calledRun\"]) dependenciesFulfilled = runCaller }; function run(args) { args = args || Module[\"arguments\"]; if (runDependencies > 0) { return } preRun(); if (runDependencies > 0) return; if (Module[\"calledRun\"]) return; function doRun() { if (Module[\"calledRun\"]) return; Module[\"calledRun\"] = true; if (ABORT) return; ensureInitRuntime(); preMain(); if (Module[\"onRuntimeInitialized\"]) Module[\"onRuntimeInitialized\"](); postRun() } if (Module[\"setStatus\"]) { Module[\"setStatus\"](\"Running...\"); setTimeout(function () { setTimeout(function () { Module[\"setStatus\"](\"\") }, 1); doRun() }, 1) } else { doRun() } } Module[\"run\"] = run; function exit(status, implicit) { if (implicit && Module[\"noExitRuntime\"] && status === 0) { return } if (Module[\"noExitRuntime\"]) { } else { ABORT = true; EXITSTATUS = status; exitRuntime(); if (Module[\"onExit\"]) Module[\"onExit\"](status) } Module[\"quit\"](status, new ExitStatus(status)) } function abort(what) { if (Module[\"onAbort\"]) { Module[\"onAbort\"](what) } if (what !== undefined) { out(what); err(what); what = JSON.stringify(what) } else { what = \"\" } ABORT = true; EXITSTATUS = 1; throw \"abort(\" + what + \"). Build with -s ASSERTIONS=1 for more info.\" } Module[\"abort\"] = abort; if (Module[\"preInit\"]) { if (typeof Module[\"preInit\"] == \"function\") Module[\"preInit\"] = [Module[\"preInit\"]]; while (Module[\"preInit\"].length > 0) { Module[\"preInit\"].pop()() } } Module[\"noExitRuntime\"] = true; run()\n\n\n      return libarchive\n    }\n  )\n})()\nmodule.exports = libarchive"
  },
  {
    "path": "server/libs/libarchive/wasm-module.js",
    "content": "/**\n * Modified from https://github.com/nika-begiashvili/libarchivejs\n */\n\nconst Path = require('path')\nconst libarchive = require('./wasm-libarchive')\n\nconst TYPE_MAP = {\n  32768: 'FILE',\n  16384: 'DIR',\n  40960: 'SYMBOLIC_LINK',\n  49152: 'SOCKET',\n  8192: 'CHARACTER_DEVICE',\n  24576: 'BLOCK_DEVICE',\n  4096: 'NAMED_PIPE',\n}\n\nclass ArchiveReader {\n  /**\n   * archive reader\n   * @param {WasmModule} wasmModule emscripten module \n   */\n  constructor(wasmModule) {\n    this._wasmModule = wasmModule\n    this._runCode = wasmModule.runCode\n    this._file = null\n    this._passphrase = null\n  }\n\n  /**\n   * open archive, needs to closed manually\n   * @param {File} file \n   */\n  open(file) {\n    if (this._file !== null) {\n      console.warn('Closing previous file')\n      this.close()\n    }\n    const { promise, resolve, reject } = this._promiseHandles()\n    this._file = file\n    this._loadFile(file, resolve, reject)\n    return promise\n  }\n\n  /**\n   * close archive\n   */\n  close() {\n    this._runCode.closeArchive(this._archive)\n    this._wasmModule._free(this._filePtr)\n    this._file = null\n    this._filePtr = null\n    this._archive = null\n  }\n\n  /**\n   * detect if archive has encrypted data\n   * @returns {boolean|null} null if could not be determined\n   */\n  hasEncryptedData() {\n    this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)\n    this._runCode.getNextEntry(this._archive)\n    const status = this._runCode.hasEncryptedEntries(this._archive)\n    if (status === 0) {\n      return false\n    } else if (status > 0) {\n      return true\n    } else {\n      return null\n    }\n  }\n\n  /**\n   * set passphrase to be used with archive\n   * @param {*} passphrase \n   */\n  setPassphrase(passphrase) {\n    this._passphrase = passphrase\n  }\n\n  /**\n   * get archive entries\n   * @param {boolean} skipExtraction\n   * @param {string} except don't skip this entry\n   */\n  *entries(skipExtraction = false, except = null) {\n    this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)\n    let entry\n    while (true) {\n      entry = this._runCode.getNextEntry(this._archive)\n      if (entry === 0) break\n\n      const entryData = {\n        size: this._runCode.getEntrySize(entry),\n        path: this._runCode.getEntryName(entry),\n        type: TYPE_MAP[this._runCode.getEntryType(entry)],\n        ref: entry,\n      }\n\n      if (entryData.type === 'FILE') {\n        let fileName = entryData.path.split('/')\n        entryData.fileName = fileName[fileName.length - 1]\n      }\n\n      if (skipExtraction && except !== entryData.path) {\n        this._runCode.skipEntry(this._archive)\n      } else {\n        const ptr = this._runCode.getFileData(this._archive, entryData.size)\n        if (ptr < 0) {\n          throw new Error(this._runCode.getError(this._archive))\n        }\n        entryData.fileData = this._wasmModule.HEAP8.slice(ptr, ptr + entryData.size)\n        this._wasmModule._free(ptr)\n      }\n      yield entryData\n    }\n  }\n\n  _loadFile(fileBuffer, resolve, reject) {\n    try {\n      const array = new Uint8Array(fileBuffer)\n      this._fileLength = array.length\n      this._filePtr = this._runCode.malloc(this._fileLength)\n      this._wasmModule.HEAP8.set(array, this._filePtr)\n      resolve()\n    } catch (error) {\n      reject(error)\n    }\n  }\n\n  _promiseHandles() {\n    let resolve = null, reject = null\n    const promise = new Promise((_resolve, _reject) => {\n      resolve = _resolve\n      reject = _reject\n    })\n    return { promise, resolve, reject }\n  }\n\n}\n\nclass WasmModule {\n  constructor() {\n    this.preRun = []\n    this.postRun = []\n    this.totalDependencies = 0\n  }\n\n  print(...text) {\n    console.log(text)\n  }\n\n  printErr(...text) {\n    console.error(text)\n  }\n\n  initFunctions() {\n    this.runCode = {\n      // const char * get_version()\n      getVersion: this.cwrap('get_version', 'string', []),\n      // void * archive_open( const void * buffer, size_t buffer_size)\n      // retuns archive pointer\n      openArchive: this.cwrap('archive_open', 'number', ['number', 'number', 'string']),\n      // void * get_entry(void * archive)\n      // return archive entry pointer\n      getNextEntry: this.cwrap('get_next_entry', 'number', ['number']),\n      // void * get_filedata( void * archive, size_t bufferSize )\n      getFileData: this.cwrap('get_filedata', 'number', ['number', 'number']),\n      // int archive_read_data_skip(struct archive *_a)\n      skipEntry: this.cwrap('archive_read_data_skip', 'number', ['number']),\n      // void archive_close( void * archive )\n      closeArchive: this.cwrap('archive_close', null, ['number']),\n      // la_int64_t archive_entry_size( struct archive_entry * )\n      getEntrySize: this.cwrap('archive_entry_size', 'number', ['number']),\n      // const char * archive_entry_pathname( struct archive_entry * )\n      getEntryName: this.cwrap('archive_entry_pathname', 'string', ['number']),\n      // __LA_MODE_T archive_entry_filetype( struct archive_entry * )\n      /*\n      #define AE_IFMT\t\t((__LA_MODE_T)0170000)\n      #define AE_IFREG\t((__LA_MODE_T)0100000) // Regular file\n      #define AE_IFLNK\t((__LA_MODE_T)0120000) // Sybolic link\n      #define AE_IFSOCK\t((__LA_MODE_T)0140000) // Socket\n      #define AE_IFCHR\t((__LA_MODE_T)0020000) // Character device\n      #define AE_IFBLK\t((__LA_MODE_T)0060000) // Block device\n      #define AE_IFDIR\t((__LA_MODE_T)0040000) // Directory\n      #define AE_IFIFO\t((__LA_MODE_T)0010000) // Named pipe\n      */\n      getEntryType: this.cwrap('archive_entry_filetype', 'number', ['number']),\n      // const char * archive_error_string(struct archive *); \n      getError: this.cwrap('archive_error_string', 'string', ['number']),\n\n      /*\n      * Returns 1 if the archive contains at least one encrypted entry.\n      * If the archive format not support encryption at all\n      * ARCHIVE_READ_FORMAT_ENCRYPTION_UNSUPPORTED is returned.\n      * If for any other reason (e.g. not enough data read so far)\n      * we cannot say whether there are encrypted entries, then\n      * ARCHIVE_READ_FORMAT_ENCRYPTION_DONT_KNOW is returned.\n      * In general, this function will return values below zero when the\n      * reader is uncertain or totally incapable of encryption support.\n      * When this function returns 0 you can be sure that the reader\n      * supports encryption detection but no encrypted entries have\n      * been found yet.\n      *\n      * NOTE: If the metadata/header of an archive is also encrypted, you\n      * cannot rely on the number of encrypted entries. That is why this\n      * function does not return the number of encrypted entries but#\n      * just shows that there are some.\n      */\n      // __LA_DECL int\tarchive_read_has_encrypted_entries(struct archive *);\n      entryIsEncrypted: this.cwrap('archive_entry_is_encrypted', 'number', ['number']),\n      hasEncryptedEntries: this.cwrap('archive_read_has_encrypted_entries', 'number', ['number']),\n      // __LA_DECL int archive_read_add_passphrase(struct archive *, const char *);\n      addPassphrase: this.cwrap('archive_read_add_passphrase', 'number', ['number', 'string']),\n      //this.stringToUTF(str), //\n      string: (str) => this.allocate(this.intArrayFromString(str), 'i8', 0),\n      malloc: this.cwrap('malloc', 'number', ['number']),\n      free: this.cwrap('free', null, ['number']),\n    }\n  }\n\n  monitorRunDependencies() { }\n\n  locateFile(path /* ,prefix */) {\n    const wasmFilepath = Path.join(__dirname, `../../../client/dist/libarchive/wasm-gen/${path}`)\n    return wasmFilepath\n  }\n}\n\nmodule.exports.getArchiveReader = (cb) => {\n  libarchive(new WasmModule()).then((module) => {\n    module.initFunctions()\n    cb(new ArchiveReader(module))\n  })\n}"
  },
  {
    "path": "server/libs/lodash.once/LICENSE",
    "content": "Copyright jQuery Foundation and other contributors <https://jquery.org/>\n\nBased on Underscore.js, copyright Jeremy Ashkenas,\nDocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>\n\nThis software consists of voluntary contributions made by many\nindividuals. For exact contribution history, see the revision history\navailable at https://github.com/lodash/lodash\n\nThe following license applies to all parts of this software except as\ndocumented below:\n\n====\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n====\n\nCopyright and related rights for sample code are waived via CC0. Sample\ncode is defined as all source code displayed within the prose of the\ndocumentation.\n\nCC0: http://creativecommons.org/publicdomain/zero/1.0/\n\n====\n\nFiles located in the node_modules and vendor directories are externally\nmaintained libraries used by this software which have their own\nlicenses; we recommend you read them, as their terms may differ from the\nterms above.\n"
  },
  {
    "path": "server/libs/lodash.once/index.js",
    "content": "//\n// used by jsonwebtoken \n//\n\n/**\n * lodash (Custom Build) <https://lodash.com/>\n * Build: `lodash modularize exports=\"npm\" -o ./`\n * Copyright jQuery Foundation and other contributors <https://jquery.org/>\n * Released under MIT license <https://lodash.com/license>\n * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>\n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n */\n\n/** Used as the `TypeError` message for \"Functions\" methods. */\nvar FUNC_ERROR_TEXT = 'Expected a function';\n\n/** Used as references for various `Number` constants. */\nvar INFINITY = 1 / 0,\n  MAX_INTEGER = 1.7976931348623157e+308,\n  NAN = 0 / 0;\n\n/** `Object#toString` result references. */\nvar symbolTag = '[object Symbol]';\n\n/** Used to match leading and trailing whitespace. */\nvar reTrim = /^\\s+|\\s+$/g;\n\n/** Used to detect bad signed hexadecimal string values. */\nvar reIsBadHex = /^[-+]0x[0-9a-f]+$/i;\n\n/** Used to detect binary string values. */\nvar reIsBinary = /^0b[01]+$/i;\n\n/** Used to detect octal string values. */\nvar reIsOctal = /^0o[0-7]+$/i;\n\n/** Built-in method references without a dependency on `root`. */\nvar freeParseInt = parseInt;\n\n/** Used for built-in method references. */\nvar objectProto = Object.prototype;\n\n/**\n * Used to resolve the\n * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)\n * of values.\n */\nvar objectToString = objectProto.toString;\n\n/**\n * Creates a function that invokes `func`, with the `this` binding and arguments\n * of the created function, while it's called less than `n` times. Subsequent\n * calls to the created function return the result of the last `func` invocation.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Function\n * @param {number} n The number of calls at which `func` is no longer invoked.\n * @param {Function} func The function to restrict.\n * @returns {Function} Returns the new restricted function.\n * @example\n *\n * jQuery(element).on('click', _.before(5, addContactToList));\n * // => Allows adding up to 4 contacts to the list.\n */\nfunction before(n, func) {\n  var result;\n  if (typeof func != 'function') {\n    throw new TypeError(FUNC_ERROR_TEXT);\n  }\n  n = toInteger(n);\n  return function () {\n    if (--n > 0) {\n      result = func.apply(this, arguments);\n    }\n    if (n <= 1) {\n      func = undefined;\n    }\n    return result;\n  };\n}\n\n/**\n * Creates a function that is restricted to invoking `func` once. Repeat calls\n * to the function return the value of the first invocation. The `func` is\n * invoked with the `this` binding and arguments of the created function.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to restrict.\n * @returns {Function} Returns the new restricted function.\n * @example\n *\n * var initialize = _.once(createApplication);\n * initialize();\n * initialize();\n * // => `createApplication` is invoked once\n */\nfunction once(func) {\n  return before(2, func);\n}\n\n/**\n * Checks if `value` is the\n * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)\n * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`)\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an object, else `false`.\n * @example\n *\n * _.isObject({});\n * // => true\n *\n * _.isObject([1, 2, 3]);\n * // => true\n *\n * _.isObject(_.noop);\n * // => true\n *\n * _.isObject(null);\n * // => false\n */\nfunction isObject(value) {\n  var type = typeof value;\n  return !!value && (type == 'object' || type == 'function');\n}\n\n/**\n * Checks if `value` is object-like. A value is object-like if it's not `null`\n * and has a `typeof` result of \"object\".\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is object-like, else `false`.\n * @example\n *\n * _.isObjectLike({});\n * // => true\n *\n * _.isObjectLike([1, 2, 3]);\n * // => true\n *\n * _.isObjectLike(_.noop);\n * // => false\n *\n * _.isObjectLike(null);\n * // => false\n */\nfunction isObjectLike(value) {\n  return !!value && typeof value == 'object';\n}\n\n/**\n * Checks if `value` is classified as a `Symbol` primitive or object.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a symbol, else `false`.\n * @example\n *\n * _.isSymbol(Symbol.iterator);\n * // => true\n *\n * _.isSymbol('abc');\n * // => false\n */\nfunction isSymbol(value) {\n  return typeof value == 'symbol' ||\n    (isObjectLike(value) && objectToString.call(value) == symbolTag);\n}\n\n/**\n * Converts `value` to a finite number.\n *\n * @static\n * @memberOf _\n * @since 4.12.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {number} Returns the converted number.\n * @example\n *\n * _.toFinite(3.2);\n * // => 3.2\n *\n * _.toFinite(Number.MIN_VALUE);\n * // => 5e-324\n *\n * _.toFinite(Infinity);\n * // => 1.7976931348623157e+308\n *\n * _.toFinite('3.2');\n * // => 3.2\n */\nfunction toFinite(value) {\n  if (!value) {\n    return value === 0 ? value : 0;\n  }\n  value = toNumber(value);\n  if (value === INFINITY || value === -INFINITY) {\n    var sign = (value < 0 ? -1 : 1);\n    return sign * MAX_INTEGER;\n  }\n  return value === value ? value : 0;\n}\n\n/**\n * Converts `value` to an integer.\n *\n * **Note:** This method is loosely based on\n * [`ToInteger`](http://www.ecma-international.org/ecma-262/7.0/#sec-tointeger).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {number} Returns the converted integer.\n * @example\n *\n * _.toInteger(3.2);\n * // => 3\n *\n * _.toInteger(Number.MIN_VALUE);\n * // => 0\n *\n * _.toInteger(Infinity);\n * // => 1.7976931348623157e+308\n *\n * _.toInteger('3.2');\n * // => 3\n */\nfunction toInteger(value) {\n  var result = toFinite(value),\n    remainder = result % 1;\n\n  return result === result ? (remainder ? result - remainder : result) : 0;\n}\n\n/**\n * Converts `value` to a number.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to process.\n * @returns {number} Returns the number.\n * @example\n *\n * _.toNumber(3.2);\n * // => 3.2\n *\n * _.toNumber(Number.MIN_VALUE);\n * // => 5e-324\n *\n * _.toNumber(Infinity);\n * // => Infinity\n *\n * _.toNumber('3.2');\n * // => 3.2\n */\nfunction toNumber(value) {\n  if (typeof value == 'number') {\n    return value;\n  }\n  if (isSymbol(value)) {\n    return NAN;\n  }\n  if (isObject(value)) {\n    var other = typeof value.valueOf == 'function' ? value.valueOf() : value;\n    value = isObject(other) ? (other + '') : other;\n  }\n  if (typeof value != 'string') {\n    return value === 0 ? value : +value;\n  }\n  value = value.replace(reTrim, '');\n  var isBinary = reIsBinary.test(value);\n  return (isBinary || reIsOctal.test(value))\n    ? freeParseInt(value.slice(2), isBinary ? 2 : 8)\n    : (reIsBadHex.test(value) ? NAN : +value);\n}\n\nmodule.exports = once;\n"
  },
  {
    "path": "server/libs/memorystore/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Rocco Musolino\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.\n"
  },
  {
    "path": "server/libs/memorystore/index.js",
    "content": "/*!\n * memorystore\n * Copyright(c) 2020 Rocco Musolino <@roccomuso>\n * MIT Licensed\n */\n//\n// modified for audiobookshelf (update to lru-cache 10)\n// SOURCE: https://github.com/roccomuso/memorystore\n//\n\nconst debug = require('debug')('memorystore')\nconst { LRUCache } = require('lru-cache')\nconst { Store } = require('express-session')\n\n/**\n * An alternative memory store implementation for express session that prunes stale entries.\n *\n * @param {number} checkPeriod stale entry pruning frequency in ms\n * @param {number} ttl entry time to live in ms\n * @param {number} max LRU cache max entries\n */\nmodule.exports = class MemoryStore extends Store {\n  constructor(checkPeriod, ttl, max) {\n    if (typeof checkPeriod !== 'number' || typeof ttl !== 'number' || typeof max !== 'number') {\n      throw Error('All arguments must be provided')\n    }\n    super()\n    this.store = new LRUCache({ ttl, max })\n    let prune = () => {\n      let sizeBefore = this.store.size\n      this.store.purgeStale()\n      debug('PRUNE size changed by %i entries', sizeBefore - this.store.size)\n    }\n    setInterval(prune, Math.floor(checkPeriod)).unref()\n    debug('INIT MemoryStore constructed with checkPeriod \"%i\", ttl \"%i\", max \"%i\"', checkPeriod, ttl, max)\n  }\n\n  /**\n   * Attempt to fetch session by the given `sid`.\n   *\n   * @param {String} sid\n   * @param {Function} fn\n   * @api public\n   */\n  get(sid, fn) {\n    let err = null\n    let res = null\n    const data = this.store.get(sid)\n    debug('GET %s: %s', sid, data)\n    if (data) {\n      try {\n        res = JSON.parse(data)\n      } catch (e) {\n        err = e\n      }\n    }\n    fn && setImmediate(fn, err, res)\n  }\n\n  /**\n   * Commit the given `sess` object associated with the given `sid`.\n   *\n   * @param {String} sid\n   * @param {Session} sess\n   * @param {Function} fn\n   * @api public\n   */\n  set(sid, sess, fn) {\n    let err = null\n    try {\n      let jsess = JSON.stringify(sess)\n      debug('SET %s: %s', sid, jsess)\n      this.store.set(sid, jsess)\n    } catch (e) {\n      err = e\n    }\n    fn && setImmediate(fn, err)\n  }\n\n  /**\n   * Destroy the session associated with the given `sid`.\n   *\n   * @param {String} sid\n   * @param {Function} fn\n   * @api public\n   */\n  destroy(sid, fn) {\n    debug('DESTROY %s', sid)\n    let err = null\n    try {\n      this.store.delete(sid)\n    } catch (e) {\n      err = e\n    }\n    fn && setImmediate(fn, err)\n  }\n\n  /**\n   * Refresh the time-to-live for the session with the given `sid` without affecting\n   * LRU recency.\n   *\n   * @param {String} sid\n   * @param {Session} sess\n   * @param {Function} fn\n   * @api public\n   */\n\n  touch(sid, sess, fn) {\n    debug('TOUCH %s', sid)\n    let err = null\n    try {\n      this.store.has(sid, { updateAgeOnHas: true })\n    } catch (e) {\n      err = e\n    }\n    fn && setImmediate(fn, err)\n  }\n}\n"
  },
  {
    "path": "server/libs/nodeCron/LICENSE",
    "content": "## ISC License\n\nCopyright (c) 2016, Lucas Merencia \\<lucas.merencia@gmail.com\\>\n\nPermission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/nodeCron/background-scheduled-task/daemon.js",
    "content": "const ScheduledTask = require('../scheduled-task');\n\nlet scheduledTask;\n\nfunction register(message){\n    const script = require(message.path);\n    scheduledTask = new ScheduledTask(message.cron, script.task, message.options);\n    scheduledTask.on('task-done', (result) => {\n        process.send({ type: 'task-done', result});\n    });\n    process.send({ type: 'registred' });\n}\n\nprocess.on('message', (message) => {\n    switch(message.type){\n    case 'register':\n        return register(message);\n    }\n});\n"
  },
  {
    "path": "server/libs/nodeCron/background-scheduled-task/index.js",
    "content": "const EventEmitter = require('events');\nconst path = require('path');\nconst { fork } = require('child_process');\nconst { getId } = require('../../../utils/index')\n\nconst daemonPath = `${__dirname}/daemon.js`;\n\nclass BackgroundScheduledTask extends EventEmitter {\n    constructor(cronExpression, taskPath, options) {\n        super();\n        if (!options) {\n            options = {\n                scheduled: true,\n                recoverMissedExecutions: false,\n            };\n        }\n        this.cronExpression = cronExpression;\n        this.taskPath = taskPath;\n        this.options = options;\n        this.options.name = this.options.name || getId()\n\n        if (options.scheduled) {\n            this.start();\n        }\n    }\n\n    start() {\n        this.stop();\n        this.forkProcess = fork(daemonPath);\n\n        this.forkProcess.on('message', (message) => {\n            switch (message.type) {\n                case 'task-done':\n                    this.emit('task-done', message.result);\n                    break;\n            }\n        });\n\n        let options = this.options;\n        options.scheduled = true;\n\n        this.forkProcess.send({\n            type: 'register',\n            path: path.resolve(this.taskPath),\n            cron: this.cronExpression,\n            options: options\n        });\n    }\n\n    stop() {\n        if (this.forkProcess) {\n            this.forkProcess.kill();\n        }\n    }\n\n    pid() {\n        if (this.forkProcess) {\n            return this.forkProcess.pid;\n        }\n    }\n\n    isRunning() {\n        return !this.forkProcess.killed;\n    }\n}\n\nmodule.exports = BackgroundScheduledTask;"
  },
  {
    "path": "server/libs/nodeCron/convert-expression/asterisk-to-range-conversion.js",
    "content": "'use strict';\nmodule.exports = (() => {\n    function convertAsterisk(expression, replecement){\n        if(expression.indexOf('*') !== -1){\n            return expression.replace('*', replecement);\n        }\n        return expression;\n    }\n\n    function convertAsterisksToRanges(expressions){\n        expressions[0] = convertAsterisk(expressions[0], '0-59');\n        expressions[1] = convertAsterisk(expressions[1], '0-59');\n        expressions[2] = convertAsterisk(expressions[2], '0-23');\n        expressions[3] = convertAsterisk(expressions[3], '1-31');\n        expressions[4] = convertAsterisk(expressions[4], '1-12');\n        expressions[5] = convertAsterisk(expressions[5], '0-6');\n        return expressions;\n    }\n\n    return convertAsterisksToRanges;\n})();\n"
  },
  {
    "path": "server/libs/nodeCron/convert-expression/index.js",
    "content": "'use strict';\n\n// SOURCE: https://github.com/node-cron/node-cron\n// LICENSE: https://github.com/node-cron/node-cron/blob/master/LICENSE.md\n\nconst monthNamesConversion = require('./month-names-conversion');\nconst weekDayNamesConversion = require('./week-day-names-conversion');\nconst convertAsterisksToRanges = require('./asterisk-to-range-conversion');\nconst convertRanges = require('./range-conversion');\nconst convertSteps = require('./step-values-conversion');\n\nmodule.exports = (() => {\n\n    function appendSeccondExpression(expressions) {\n        if (expressions.length === 5) {\n            return ['0'].concat(expressions);\n        }\n        return expressions;\n    }\n\n    function removeSpaces(str) {\n        return str.replace(/\\s{2,}/g, ' ').trim();\n    }\n\n    // Function that takes care of normalization.\n    function normalizeIntegers(expressions) {\n        for (let i = 0; i < expressions.length; i++) {\n            const numbers = expressions[i].split(',');\n            for (let j = 0; j < numbers.length; j++) {\n                numbers[j] = parseInt(numbers[j]);\n            }\n            expressions[i] = numbers;\n        }\n        return expressions;\n    }\n\n    /*\n   * The node-cron core allows only numbers (including multiple numbers e.g 1,2).\n   * This module is going to translate the month names, week day names and ranges\n   * to integers relatives.\n   *\n   * Month names example:\n   *  - expression 0 1 1 January,Sep *\n   *  - Will be translated to 0 1 1 1,9 *\n   *\n   * Week day names example:\n   *  - expression 0 1 1 2 Monday,Sat\n   *  - Will be translated to 0 1 1 1,5 *\n   *\n   * Ranges example:\n   *  - expression 1-5 * * * *\n   *  - Will be translated to 1,2,3,4,5 * * * *\n   */\n    function interprete(expression) {\n        let expressions = removeSpaces(expression).split(' ');\n        expressions = appendSeccondExpression(expressions);\n        expressions[4] = monthNamesConversion(expressions[4]);\n        expressions[5] = weekDayNamesConversion(expressions[5]);\n        expressions = convertAsterisksToRanges(expressions);\n        expressions = convertRanges(expressions);\n        expressions = convertSteps(expressions);\n\n        expressions = normalizeIntegers(expressions);\n\n        return expressions.join(' ');\n    }\n\n    return interprete;\n})();\n"
  },
  {
    "path": "server/libs/nodeCron/convert-expression/month-names-conversion.js",
    "content": "'use strict';\nmodule.exports = (() => {\n    const months = ['january','february','march','april','may','june','july',\n        'august','september','october','november','december'];\n    const shortMonths = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug',\n        'sep', 'oct', 'nov', 'dec'];\n\n    function convertMonthName(expression, items){\n        for(let i = 0; i < items.length; i++){\n            expression = expression.replace(new RegExp(items[i], 'gi'), parseInt(i, 10) + 1);\n        }\n        return expression;\n    }\n\n    function interprete(monthExpression){\n        monthExpression = convertMonthName(monthExpression, months);\n        monthExpression = convertMonthName(monthExpression, shortMonths);\n        return monthExpression;\n    }\n\n    return interprete;\n})();\n"
  },
  {
    "path": "server/libs/nodeCron/convert-expression/range-conversion.js",
    "content": "'use strict';\nmodule.exports = ( () => {\n    function replaceWithRange(expression, text, init, end) {\n\n        const numbers = [];\n        let last = parseInt(end);\n        let first = parseInt(init);\n\n        if(first > last){\n            last = parseInt(init);\n            first = parseInt(end);\n        }\n\n        for(let i = first; i <= last; i++) {\n            numbers.push(i);\n        }\n\n        return expression.replace(new RegExp(text, 'i'), numbers.join());\n    }\n\n    function convertRange(expression){\n        const rangeRegEx = /(\\d+)-(\\d+)/;\n        let match = rangeRegEx.exec(expression);\n        while(match !== null && match.length > 0){\n            expression = replaceWithRange(expression, match[0], match[1], match[2]);\n            match = rangeRegEx.exec(expression);\n        }\n        return expression;\n    }\n\n    function convertAllRanges(expressions){\n        for(let i = 0; i < expressions.length; i++){\n            expressions[i] = convertRange(expressions[i]);\n        }\n        return expressions;\n    }\n\n    return convertAllRanges;\n})();\n"
  },
  {
    "path": "server/libs/nodeCron/convert-expression/step-values-conversion.js",
    "content": "'use strict';\n\nmodule.exports = (() => {\n    function convertSteps(expressions){\n        var stepValuePattern = /^(.+)\\/(\\w+)$/;\n        for(var i = 0; i < expressions.length; i++){\n            var match = stepValuePattern.exec(expressions[i]);\n            var isStepValue = match !== null && match.length > 0;\n            if(isStepValue){\n                var baseDivider = match[2];\n                if(isNaN(baseDivider)){\n                    throw baseDivider + ' is not a valid step value';\n                }\n                var values = match[1].split(',');\n                var stepValues = [];\n                var divider = parseInt(baseDivider, 10);\n                for(var j = 0; j <= values.length; j++){\n                    var value = parseInt(values[j], 10);\n                    if(value % divider === 0){\n                        stepValues.push(value);\n                    }\n                }\n                expressions[i] = stepValues.join(',');\n            }\n        }\n        return expressions;\n    }\n\n    return convertSteps;\n})();\n"
  },
  {
    "path": "server/libs/nodeCron/convert-expression/week-day-names-conversion.js",
    "content": "'use strict';\nmodule.exports = (() => {\n    const weekDays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday',\n        'friday', 'saturday'];\n    const shortWeekDays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];\n\n    function convertWeekDayName(expression, items){\n        for(let i = 0; i < items.length; i++){\n            expression = expression.replace(new RegExp(items[i], 'gi'), parseInt(i, 10));\n        }\n        return expression;\n    }\n  \n    function convertWeekDays(expression){\n        expression = expression.replace('7', '0');\n        expression = convertWeekDayName(expression, weekDays);\n        return convertWeekDayName(expression, shortWeekDays);\n    }\n\n    return convertWeekDays;\n})();\n"
  },
  {
    "path": "server/libs/nodeCron/index.js",
    "content": "'use strict';\n\nconst ScheduledTask = require('./scheduled-task');\nconst BackgroundScheduledTask = require('./background-scheduled-task');\nconst validation = require('./pattern-validation');\nconst storage = require('./storage');\n\n/**\n * @typedef {Object} CronScheduleOptions\n * @prop {boolean} [scheduled] if a scheduled task is ready and running to be\n *  performed when the time matches the cron expression.\n * @prop {string} [timezone] the timezone to execute the task in.\n */\n\n/**\n * Creates a new task to execute the given function when the cron\n *  expression ticks.\n *\n * @param {string} expression The cron expression.\n * @param {Function} func The task to be executed.\n * @param {CronScheduleOptions} [options] A set of options for the scheduled task.\n * @returns {ScheduledTask} The scheduled task.\n */\nfunction schedule(expression, func, options) {\n    const task = createTask(expression, func, options);\n\n    storage.save(task);\n\n    return task;\n}\n\nfunction createTask(expression, func, options) {\n    if (typeof func === 'string')\n        return new BackgroundScheduledTask(expression, func, options);\n\n    return new ScheduledTask(expression, func, options);\n}\n\n/**\n * Check if a cron expression is valid.\n *\n * @param {string} expression The cron expression.\n * @returns {boolean} Whether the expression is valid or not.\n */\nfunction validate(expression) {\n    try {\n        validation(expression);\n\n        return true;\n    } catch (_) {\n        return false;\n    }\n}\n\n/**\n * Gets the scheduled tasks.\n *\n * @returns {ScheduledTask[]} The scheduled tasks.\n */\nfunction getTasks() {\n    return storage.getTasks();\n}\n\nmodule.exports = { schedule, validate, getTasks };\n"
  },
  {
    "path": "server/libs/nodeCron/pattern-validation.js",
    "content": "'use strict';\n\nconst convertExpression = require('./convert-expression');\n\nconst validationRegex = /^(?:\\d+|\\*|\\*\\/\\d+)$/;\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @param {number} min The minimum value.\n * @param {number} max The maximum value.\n * @returns {boolean}\n */\nfunction isValidExpression(expression, min, max) {\n    const options = expression.split(',');\n\n    for (const option of options) {\n        const optionAsInt = parseInt(option, 10);\n\n        if (\n            (!Number.isNaN(optionAsInt) &&\n                (optionAsInt < min || optionAsInt > max)) ||\n            !validationRegex.test(option)\n        )\n            return false;\n    }\n\n    return true;\n}\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @returns {boolean}\n */\nfunction isInvalidSecond(expression) {\n    return !isValidExpression(expression, 0, 59);\n}\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @returns {boolean}\n */\nfunction isInvalidMinute(expression) {\n    return !isValidExpression(expression, 0, 59);\n}\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @returns {boolean}\n */\nfunction isInvalidHour(expression) {\n    return !isValidExpression(expression, 0, 23);\n}\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @returns {boolean}\n */\nfunction isInvalidDayOfMonth(expression) {\n    return !isValidExpression(expression, 1, 31);\n}\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @returns {boolean}\n */\nfunction isInvalidMonth(expression) {\n    return !isValidExpression(expression, 1, 12);\n}\n\n/**\n * @param {string} expression The Cron-Job expression.\n * @returns {boolean}\n */\nfunction isInvalidWeekDay(expression) {\n    return !isValidExpression(expression, 0, 7);\n}\n\n/**\n * @param {string[]} patterns The Cron-Job expression patterns.\n * @param {string[]} executablePatterns The executable Cron-Job expression\n * patterns.\n * @returns {void}\n */\nfunction validateFields(patterns, executablePatterns) {\n    if (isInvalidSecond(executablePatterns[0]))\n        throw new Error(`${patterns[0]} is a invalid expression for second`);\n\n    if (isInvalidMinute(executablePatterns[1]))\n        throw new Error(`${patterns[1]} is a invalid expression for minute`);\n\n    if (isInvalidHour(executablePatterns[2]))\n        throw new Error(`${patterns[2]} is a invalid expression for hour`);\n\n    if (isInvalidDayOfMonth(executablePatterns[3]))\n        throw new Error(\n            `${patterns[3]} is a invalid expression for day of month`\n        );\n\n    if (isInvalidMonth(executablePatterns[4]))\n        throw new Error(`${patterns[4]} is a invalid expression for month`);\n\n    if (isInvalidWeekDay(executablePatterns[5]))\n        throw new Error(`${patterns[5]} is a invalid expression for week day`);\n}\n\n/**\n * Validates a Cron-Job expression pattern.\n *\n * @param {string} pattern The Cron-Job expression pattern.\n * @returns {void}\n */\nfunction validate(pattern) {\n    if (typeof pattern !== 'string')\n        throw new TypeError('pattern must be a string!');\n\n    const patterns = pattern.split(' ');\n    const executablePatterns = convertExpression(pattern).split(' ');\n\n    if (patterns.length === 5) patterns.unshift('0');\n\n    validateFields(patterns, executablePatterns);\n}\n\nmodule.exports = validate;\n"
  },
  {
    "path": "server/libs/nodeCron/scheduled-task.js",
    "content": "'use strict';\n\nconst EventEmitter = require('events');\nconst Task = require('./task');\nconst Scheduler = require('./scheduler');\nconst { getId } = require('../../utils/index')\n\nclass ScheduledTask extends EventEmitter {\n    constructor(cronExpression, func, options) {\n        super();\n        if (!options) {\n            options = {\n                scheduled: true,\n                recoverMissedExecutions: false\n            };\n        }\n\n        this.options = options;\n        this.options.name = this.options.name || getId()\n\n        this._task = new Task(func);\n        this._scheduler = new Scheduler(cronExpression, options.timezone, options.recoverMissedExecutions);\n\n        this._scheduler.on('scheduled-time-matched', (now) => {\n            this.now(now);\n        });\n\n        if (options.scheduled !== false) {\n            this._scheduler.start();\n        }\n\n        if (options.runOnInit === true) {\n            this.now('init');\n        }\n    }\n\n    now(now = 'manual') {\n        let result = this._task.execute(now);\n        this.emit('task-done', result);\n    }\n\n    start() {\n        this._scheduler.start();\n    }\n\n    stop() {\n        this._scheduler.stop();\n    }\n}\n\nmodule.exports = ScheduledTask;\n"
  },
  {
    "path": "server/libs/nodeCron/scheduler.js",
    "content": "'use strict';\n\nconst EventEmitter = require('events');\nconst TimeMatcher = require('./time-matcher');\n\nclass Scheduler extends EventEmitter {\n    constructor(pattern, timezone, autorecover) {\n        super();\n        this.timeMatcher = new TimeMatcher(pattern, timezone);\n        this.autorecover = autorecover;\n    }\n\n    start() {\n        // clear timeout if exists\n        this.stop();\n\n        let lastCheck = process.hrtime();\n        let lastExecution = this.timeMatcher.apply(new Date());\n\n        const matchTime = () => {\n            const delay = 1000;\n            const elapsedTime = process.hrtime(lastCheck);\n            const elapsedMs = (elapsedTime[0] * 1e9 + elapsedTime[1]) / 1e6;\n            const missedExecutions = Math.floor(elapsedMs / 1000);\n\n            for (let i = missedExecutions; i >= 0; i--) {\n                const date = new Date(new Date().getTime() - i * 1000);\n                let date_tmp = this.timeMatcher.apply(date);\n                if (lastExecution.getTime() < date_tmp.getTime() && (i === 0 || this.autorecover) && this.timeMatcher.match(date)) {\n                    this.emit('scheduled-time-matched', date_tmp);\n                    date_tmp.setMilliseconds(0);\n                    lastExecution = date_tmp;\n                }\n            }\n            lastCheck = process.hrtime();\n            this.timeout = setTimeout(matchTime, delay);\n        };\n        matchTime();\n    }\n\n    stop() {\n        if (this.timeout) {\n            clearTimeout(this.timeout);\n        }\n        this.timeout = null;\n    }\n}\n\nmodule.exports = Scheduler;\n"
  },
  {
    "path": "server/libs/nodeCron/storage.js",
    "content": "module.exports = (() => {\n    if(!global.scheduledTasks){\n        global.scheduledTasks = new Map();\n    }\n    \n    return {\n        save: (task) => {\n            if(!task.options){\n                const uuid = require('uuid');\n                task.options = {};\n                task.options.name = uuid.v4();\n            }\n            global.scheduledTasks.set(task.options.name, task);\n        },\n        getTasks: () => {\n            return global.scheduledTasks;\n        }\n    };\n})();"
  },
  {
    "path": "server/libs/nodeCron/task.js",
    "content": "'use strict';\n\nconst EventEmitter = require('events');\n\nclass Task extends EventEmitter{\n    constructor(execution){\n        super();\n        if(typeof execution !== 'function') {\n            throw 'execution must be a function';\n        }\n        this._execution = execution;\n    }\n\n    execute(now) {\n        let exec;\n        try {\n            exec = this._execution(now);\n        } catch (error) {\n            return this.emit('task-failed', error);\n        }\n        \n        if (exec instanceof Promise) {\n            return exec\n                .then(() => this.emit('task-finished'))\n                .catch((error) => this.emit('task-failed', error));\n        } else {\n            this.emit('task-finished');\n            return exec;\n        }\n    }\n}\n\nmodule.exports = Task;\n\n"
  },
  {
    "path": "server/libs/nodeCron/time-matcher.js",
    "content": "const validatePattern = require('./pattern-validation');\nconst convertExpression = require('./convert-expression');\n\nfunction matchPattern(pattern, value){\n    if( pattern.indexOf(',') !== -1 ){\n        const patterns = pattern.split(',');\n        return patterns.indexOf(value.toString()) !== -1;\n    }\n    return pattern === value.toString();\n}\n\nclass TimeMatcher{\n    constructor(pattern, timezone){\n        validatePattern(pattern);\n        this.pattern = convertExpression(pattern);\n        this.timezone = timezone;\n        this.expressions = this.pattern.split(' ');\n    }\n\n    match(date){\n        date = this.apply(date);\n\n        const runOnSecond = matchPattern(this.expressions[0], date.getSeconds());\n        const runOnMinute = matchPattern(this.expressions[1], date.getMinutes());\n        const runOnHour = matchPattern(this.expressions[2], date.getHours());\n        const runOnDay = matchPattern(this.expressions[3], date.getDate());\n        const runOnMonth = matchPattern(this.expressions[4], date.getMonth() + 1);\n        const runOnWeekDay = matchPattern(this.expressions[5], date.getDay());\n\n        return runOnSecond && runOnMinute && runOnHour && runOnDay && runOnMonth && runOnWeekDay;\n    }\n\n    apply(date){\n        if(this.timezone){\n            const dtf = new Intl.DateTimeFormat('en-US', {\n                year: 'numeric',\n                month: '2-digit',\n                day: '2-digit',\n                hour: '2-digit',\n                minute: '2-digit',\n                second: '2-digit',\n                hourCycle: 'h23',\n                fractionalSecondDigits: 3,\n                timeZone: this.timezone\n            });\n            \n            return new Date(dtf.format(date));\n        }\n        \n        return date;\n    }\n}\n\nmodule.exports = TimeMatcher;"
  },
  {
    "path": "server/libs/nodeFfprobe/LICENSE",
    "content": "(The MIT License)\n\nCopyright (c) 2011 Listener Approved LLC.\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n'Software'), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/nodeFfprobe/index.js",
    "content": "//\n// node-ffprobe modified for audiobookshelf\n// SOURCE: https://github.com/ListenerApproved/node-ffprobe\n//\n\nconst spawn = require('child_process').spawn\n\nmodule.exports = (function () {\n  function doProbe(file) {\n    return new Promise((resolve, reject) => {\n      let proc = spawn(module.exports.FFPROBE_PATH || 'ffprobe', ['-hide_banner', '-loglevel', 'fatal', '-show_error', '-show_format', '-show_streams', '-show_programs', '-show_chapters', '-show_private_data', '-print_format', 'json', file])\n      let probeData = []\n      let errData = []\n\n      proc.stdout.setEncoding('utf8')\n      proc.stderr.setEncoding('utf8')\n\n      proc.stdout.on('data', function (data) { probeData.push(data) })\n      proc.stderr.on('data', function (data) { errData.push(data) })\n\n      proc.on('exit', code => { exitCode = code })\n      proc.on('error', err => reject(err))\n      proc.on('close', () => {\n        try {\n            resolve(JSON.parse(probeData.join('')))\n        } catch (err) {\n            reject(err);\n        }\n      })\n    })\n  }\n\n  return doProbe\n})()"
  },
  {
    "path": "server/libs/nodeStreamZip/LICENSE",
    "content": "Copyright (c) 2021 Antelle https://github.com/antelle\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n== dependency license: adm-zip ==\n\nCopyright (c) 2012 Another-D-Mention Software and other contributors, \nhttp://www.another-d-mention.ro/\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/nodeStreamZip/index.js",
    "content": "/**\n * @license node-stream-zip | (c) 2020 Antelle | https://github.com/antelle/node-stream-zip/blob/master/LICENSE\n * Portions copyright https://github.com/cthackers/adm-zip | https://raw.githubusercontent.com/cthackers/adm-zip/master/LICENSE\n */\n\nlet fs = require('fs');\nconst util = require('util');\nconst path = require('path');\nconst events = require('events');\nconst zlib = require('zlib');\nconst stream = require('stream');\n\nconst consts = {\n  /* The local file header */\n  LOCHDR: 30, // LOC header size\n  LOCSIG: 0x04034b50, // \"PK\\003\\004\"\n  LOCVER: 4, // version needed to extract\n  LOCFLG: 6, // general purpose bit flag\n  LOCHOW: 8, // compression method\n  LOCTIM: 10, // modification time (2 bytes time, 2 bytes date)\n  LOCCRC: 14, // uncompressed file crc-32 value\n  LOCSIZ: 18, // compressed size\n  LOCLEN: 22, // uncompressed size\n  LOCNAM: 26, // filename length\n  LOCEXT: 28, // extra field length\n\n  /* The Data descriptor */\n  EXTSIG: 0x08074b50, // \"PK\\007\\008\"\n  EXTHDR: 16, // EXT header size\n  EXTCRC: 4, // uncompressed file crc-32 value\n  EXTSIZ: 8, // compressed size\n  EXTLEN: 12, // uncompressed size\n\n  /* The central directory file header */\n  CENHDR: 46, // CEN header size\n  CENSIG: 0x02014b50, // \"PK\\001\\002\"\n  CENVEM: 4, // version made by\n  CENVER: 6, // version needed to extract\n  CENFLG: 8, // encrypt, decrypt flags\n  CENHOW: 10, // compression method\n  CENTIM: 12, // modification time (2 bytes time, 2 bytes date)\n  CENCRC: 16, // uncompressed file crc-32 value\n  CENSIZ: 20, // compressed size\n  CENLEN: 24, // uncompressed size\n  CENNAM: 28, // filename length\n  CENEXT: 30, // extra field length\n  CENCOM: 32, // file comment length\n  CENDSK: 34, // volume number start\n  CENATT: 36, // internal file attributes\n  CENATX: 38, // external file attributes (host system dependent)\n  CENOFF: 42, // LOC header offset\n\n  /* The entries in the end of central directory */\n  ENDHDR: 22, // END header size\n  ENDSIG: 0x06054b50, // \"PK\\005\\006\"\n  ENDSIGFIRST: 0x50,\n  ENDSUB: 8, // number of entries on this disk\n  ENDTOT: 10, // total number of entries\n  ENDSIZ: 12, // central directory size in bytes\n  ENDOFF: 16, // offset of first CEN header\n  ENDCOM: 20, // zip file comment length\n  MAXFILECOMMENT: 0xffff,\n\n  /* The entries in the end of ZIP64 central directory locator */\n  ENDL64HDR: 20, // ZIP64 end of central directory locator header size\n  ENDL64SIG: 0x07064b50, // ZIP64 end of central directory locator signature\n  ENDL64SIGFIRST: 0x50,\n  ENDL64OFS: 8, // ZIP64 end of central directory offset\n\n  /* The entries in the end of ZIP64 central directory */\n  END64HDR: 56, // ZIP64 end of central directory header size\n  END64SIG: 0x06064b50, // ZIP64 end of central directory signature\n  END64SIGFIRST: 0x50,\n  END64SUB: 24, // number of entries on this disk\n  END64TOT: 32, // total number of entries\n  END64SIZ: 40,\n  END64OFF: 48,\n\n  /* Compression methods */\n  STORED: 0, // no compression\n  SHRUNK: 1, // shrunk\n  REDUCED1: 2, // reduced with compression factor 1\n  REDUCED2: 3, // reduced with compression factor 2\n  REDUCED3: 4, // reduced with compression factor 3\n  REDUCED4: 5, // reduced with compression factor 4\n  IMPLODED: 6, // imploded\n  // 7 reserved\n  DEFLATED: 8, // deflated\n  ENHANCED_DEFLATED: 9, // deflate64\n  PKWARE: 10, // PKWare DCL imploded\n  // 11 reserved\n  BZIP2: 12, //  compressed using BZIP2\n  // 13 reserved\n  LZMA: 14, // LZMA\n  // 15-17 reserved\n  IBM_TERSE: 18, // compressed using IBM TERSE\n  IBM_LZ77: 19, //IBM LZ77 z\n\n  /* General purpose bit flag */\n  FLG_ENC: 0, // encrypted file\n  FLG_COMP1: 1, // compression option\n  FLG_COMP2: 2, // compression option\n  FLG_DESC: 4, // data descriptor\n  FLG_ENH: 8, // enhanced deflation\n  FLG_STR: 16, // strong encryption\n  FLG_LNG: 1024, // language encoding\n  FLG_MSK: 4096, // mask header values\n  FLG_ENTRY_ENC: 1,\n\n  /* 4.5 Extensible data fields */\n  EF_ID: 0,\n  EF_SIZE: 2,\n\n  /* Header IDs */\n  ID_ZIP64: 0x0001,\n  ID_AVINFO: 0x0007,\n  ID_PFS: 0x0008,\n  ID_OS2: 0x0009,\n  ID_NTFS: 0x000a,\n  ID_OPENVMS: 0x000c,\n  ID_UNIX: 0x000d,\n  ID_FORK: 0x000e,\n  ID_PATCH: 0x000f,\n  ID_X509_PKCS7: 0x0014,\n  ID_X509_CERTID_F: 0x0015,\n  ID_X509_CERTID_C: 0x0016,\n  ID_STRONGENC: 0x0017,\n  ID_RECORD_MGT: 0x0018,\n  ID_X509_PKCS7_RL: 0x0019,\n  ID_IBM1: 0x0065,\n  ID_IBM2: 0x0066,\n  ID_POSZIP: 0x4690,\n\n  EF_ZIP64_OR_32: 0xffffffff,\n  EF_ZIP64_OR_16: 0xffff,\n};\n\nconst StreamZip = function (config) {\n  let fd, fileSize, chunkSize, op, centralDirectory, closed;\n  const ready = false,\n    that = this,\n    entries = config.storeEntries !== false ? {} : null,\n    fileName = config.file,\n    textDecoder = config.nameEncoding ? new TextDecoder(config.nameEncoding) : null;\n\n  open();\n\n  function open() {\n    if (config.fd) {\n      fd = config.fd;\n      readFile();\n    } else {\n      fs.open(fileName, 'r', (err, f) => {\n        if (err) {\n          return that.emit('error', err);\n        }\n        fd = f;\n        readFile();\n      });\n    }\n  }\n\n  function readFile() {\n    fs.fstat(fd, (err, stat) => {\n      if (err) {\n        return that.emit('error', err);\n      }\n      fileSize = stat.size;\n      chunkSize = config.chunkSize || Math.round(fileSize / 1000);\n      chunkSize = Math.max(\n        Math.min(chunkSize, Math.min(128 * 1024, fileSize)),\n        Math.min(1024, fileSize)\n      );\n      readCentralDirectory();\n    });\n  }\n\n  function readUntilFoundCallback(err, bytesRead) {\n    if (err || !bytesRead) {\n      return that.emit('error', err || new Error('Archive read error'));\n    }\n    let pos = op.lastPos;\n    let bufferPosition = pos - op.win.position;\n    const buffer = op.win.buffer;\n    const minPos = op.minPos;\n    while (--pos >= minPos && --bufferPosition >= 0) {\n      if (buffer.length - bufferPosition >= 4 && buffer[bufferPosition] === op.firstByte) {\n        // quick check first signature byte\n        if (buffer.readUInt32LE(bufferPosition) === op.sig) {\n          op.lastBufferPosition = bufferPosition;\n          op.lastBytesRead = bytesRead;\n          op.complete();\n          return;\n        }\n      }\n    }\n    if (pos === minPos) {\n      return that.emit('error', new Error('Bad archive'));\n    }\n    op.lastPos = pos + 1;\n    op.chunkSize *= 2;\n    if (pos <= minPos) {\n      return that.emit('error', new Error('Bad archive'));\n    }\n    const expandLength = Math.min(op.chunkSize, pos - minPos);\n    op.win.expandLeft(expandLength, readUntilFoundCallback);\n  }\n\n  function readCentralDirectory() {\n    const totalReadLength = Math.min(consts.ENDHDR + consts.MAXFILECOMMENT, fileSize);\n    op = {\n      win: new FileWindowBuffer(fd),\n      totalReadLength,\n      minPos: fileSize - totalReadLength,\n      lastPos: fileSize,\n      chunkSize: Math.min(1024, chunkSize),\n      firstByte: consts.ENDSIGFIRST,\n      sig: consts.ENDSIG,\n      complete: readCentralDirectoryComplete,\n    };\n    op.win.read(fileSize - op.chunkSize, op.chunkSize, readUntilFoundCallback);\n  }\n\n  function readCentralDirectoryComplete() {\n    const buffer = op.win.buffer;\n    const pos = op.lastBufferPosition;\n    try {\n      centralDirectory = new CentralDirectoryHeader();\n      centralDirectory.read(buffer.slice(pos, pos + consts.ENDHDR));\n      centralDirectory.headerOffset = op.win.position + pos;\n      if (centralDirectory.commentLength) {\n        that.comment = buffer\n          .slice(\n            pos + consts.ENDHDR,\n            pos + consts.ENDHDR + centralDirectory.commentLength\n          )\n          .toString();\n      } else {\n        that.comment = null;\n      }\n      that.entriesCount = centralDirectory.volumeEntries;\n      that.centralDirectory = centralDirectory;\n      if (\n        (centralDirectory.volumeEntries === consts.EF_ZIP64_OR_16 &&\n          centralDirectory.totalEntries === consts.EF_ZIP64_OR_16) ||\n        centralDirectory.size === consts.EF_ZIP64_OR_32 ||\n        centralDirectory.offset === consts.EF_ZIP64_OR_32\n      ) {\n        readZip64CentralDirectoryLocator();\n      } else {\n        op = {};\n        readEntries();\n      }\n    } catch (err) {\n      that.emit('error', err);\n    }\n  }\n\n  function readZip64CentralDirectoryLocator() {\n    const length = consts.ENDL64HDR;\n    if (op.lastBufferPosition > length) {\n      op.lastBufferPosition -= length;\n      readZip64CentralDirectoryLocatorComplete();\n    } else {\n      op = {\n        win: op.win,\n        totalReadLength: length,\n        minPos: op.win.position - length,\n        lastPos: op.win.position,\n        chunkSize: op.chunkSize,\n        firstByte: consts.ENDL64SIGFIRST,\n        sig: consts.ENDL64SIG,\n        complete: readZip64CentralDirectoryLocatorComplete,\n      };\n      op.win.read(op.lastPos - op.chunkSize, op.chunkSize, readUntilFoundCallback);\n    }\n  }\n\n  function readZip64CentralDirectoryLocatorComplete() {\n    const buffer = op.win.buffer;\n    const locHeader = new CentralDirectoryLoc64Header();\n    locHeader.read(\n      buffer.slice(op.lastBufferPosition, op.lastBufferPosition + consts.ENDL64HDR)\n    );\n    const readLength = fileSize - locHeader.headerOffset;\n    op = {\n      win: op.win,\n      totalReadLength: readLength,\n      minPos: locHeader.headerOffset,\n      lastPos: op.lastPos,\n      chunkSize: op.chunkSize,\n      firstByte: consts.END64SIGFIRST,\n      sig: consts.END64SIG,\n      complete: readZip64CentralDirectoryComplete,\n    };\n    op.win.read(fileSize - op.chunkSize, op.chunkSize, readUntilFoundCallback);\n  }\n\n  function readZip64CentralDirectoryComplete() {\n    const buffer = op.win.buffer;\n    const zip64cd = new CentralDirectoryZip64Header();\n    zip64cd.read(buffer.slice(op.lastBufferPosition, op.lastBufferPosition + consts.END64HDR));\n    that.centralDirectory.volumeEntries = zip64cd.volumeEntries;\n    that.centralDirectory.totalEntries = zip64cd.totalEntries;\n    that.centralDirectory.size = zip64cd.size;\n    that.centralDirectory.offset = zip64cd.offset;\n    that.entriesCount = zip64cd.volumeEntries;\n    op = {};\n    readEntries();\n  }\n\n  function readEntries() {\n    op = {\n      win: new FileWindowBuffer(fd),\n      pos: centralDirectory.offset,\n      chunkSize,\n      entriesLeft: centralDirectory.volumeEntries,\n    };\n    op.win.read(op.pos, Math.min(chunkSize, fileSize - op.pos), readEntriesCallback);\n  }\n\n  function readEntriesCallback(err, bytesRead) {\n    if (err || !bytesRead) {\n      return that.emit('error', err || new Error('Entries read error'));\n    }\n    let bufferPos = op.pos - op.win.position;\n    let entry = op.entry;\n    const buffer = op.win.buffer;\n    const bufferLength = buffer.length;\n    try {\n      while (op.entriesLeft > 0) {\n        if (!entry) {\n          entry = new ZipEntry();\n          entry.readHeader(buffer, bufferPos);\n          entry.headerOffset = op.win.position + bufferPos;\n          op.entry = entry;\n          op.pos += consts.CENHDR;\n          bufferPos += consts.CENHDR;\n        }\n        const entryHeaderSize = entry.fnameLen + entry.extraLen + entry.comLen;\n        const advanceBytes = entryHeaderSize + (op.entriesLeft > 1 ? consts.CENHDR : 0);\n        if (bufferLength - bufferPos < advanceBytes) {\n          op.win.moveRight(chunkSize, readEntriesCallback, bufferPos);\n          op.move = true;\n          return;\n        }\n        entry.read(buffer, bufferPos, textDecoder);\n        if (!config.skipEntryNameValidation) {\n          entry.validateName();\n        }\n        if (entries) {\n          entries[entry.name] = entry;\n        }\n        that.emit('entry', entry);\n        op.entry = entry = null;\n        op.entriesLeft--;\n        op.pos += entryHeaderSize;\n        bufferPos += entryHeaderSize;\n      }\n      that.emit('ready');\n    } catch (err) {\n      that.emit('error', err);\n    }\n  }\n\n  function checkEntriesExist() {\n    if (!entries) {\n      throw new Error('storeEntries disabled');\n    }\n  }\n\n  Object.defineProperty(this, 'ready', {\n    get() {\n      return ready;\n    },\n  });\n\n  this.entry = function (name) {\n    checkEntriesExist();\n    return entries[name];\n  };\n\n  this.entries = function () {\n    checkEntriesExist();\n    return entries;\n  };\n\n  this.stream = function (entry, callback) {\n    return this.openEntry(\n      entry,\n      (err, entry) => {\n        if (err) {\n          return callback(err);\n        }\n        const offset = dataOffset(entry);\n        let entryStream = new EntryDataReaderStream(fd, offset, entry.compressedSize);\n        if (entry.method === consts.STORED) {\n          // nothing to do\n        } else if (entry.method === consts.DEFLATED) {\n          entryStream = entryStream.pipe(zlib.createInflateRaw());\n        } else {\n          return callback(new Error('Unknown compression method: ' + entry.method));\n        }\n        if (canVerifyCrc(entry)) {\n          entryStream = entryStream.pipe(\n            new EntryVerifyStream(entryStream, entry.crc, entry.size)\n          );\n        }\n        callback(null, entryStream);\n      },\n      false\n    );\n  };\n\n  this.entryDataSync = function (entry) {\n    let err = null;\n    this.openEntry(\n      entry,\n      (e, en) => {\n        err = e;\n        entry = en;\n      },\n      true\n    );\n    if (err) {\n      throw err;\n    }\n    let data = Buffer.alloc(entry.compressedSize);\n    new FsRead(fd, data, 0, entry.compressedSize, dataOffset(entry), (e) => {\n      err = e;\n    }).read(true);\n    if (err) {\n      throw err;\n    }\n    if (entry.method === consts.STORED) {\n      // nothing to do\n    } else if (entry.method === consts.DEFLATED || entry.method === consts.ENHANCED_DEFLATED) {\n      data = zlib.inflateRawSync(data);\n    } else {\n      throw new Error('Unknown compression method: ' + entry.method);\n    }\n    if (data.length !== entry.size) {\n      throw new Error('Invalid size');\n    }\n    if (canVerifyCrc(entry)) {\n      const verify = new CrcVerify(entry.crc, entry.size);\n      verify.data(data);\n    }\n    return data;\n  };\n\n  this.openEntry = function (entry, callback, sync) {\n    if (typeof entry === 'string') {\n      checkEntriesExist();\n      entry = entries[entry];\n      if (!entry) {\n        return callback(new Error('Entry not found'));\n      }\n    }\n    if (!entry.isFile) {\n      return callback(new Error('Entry is not file'));\n    }\n    if (!fd) {\n      return callback(new Error('Archive closed'));\n    }\n    const buffer = Buffer.alloc(consts.LOCHDR);\n    new FsRead(fd, buffer, 0, buffer.length, entry.offset, (err) => {\n      if (err) {\n        return callback(err);\n      }\n      let readEx;\n      try {\n        entry.readDataHeader(buffer);\n        if (entry.encrypted) {\n          readEx = new Error('Entry encrypted');\n        }\n      } catch (ex) {\n        readEx = ex;\n      }\n      callback(readEx, entry);\n    }).read(sync);\n  };\n\n  function dataOffset(entry) {\n    return entry.offset + consts.LOCHDR + entry.fnameLen + entry.extraLen;\n  }\n\n  function canVerifyCrc(entry) {\n    // if bit 3 (0x08) of the general-purpose flags field is set, then the CRC-32 and file sizes are not known when the header is written\n    return (entry.flags & 0x8) !== 0x8;\n  }\n\n  function extract(entry, outPath, callback) {\n    that.stream(entry, (err, stm) => {\n      if (err) {\n        callback(err);\n      } else {\n        let fsStm, errThrown;\n        stm.on('error', (err) => {\n          errThrown = err;\n          if (fsStm) {\n            stm.unpipe(fsStm);\n            fsStm.close(() => {\n              callback(err);\n            });\n          }\n        });\n        fs.open(outPath, 'w', (err, fdFile) => {\n          if (err) {\n            return callback(err);\n          }\n          if (errThrown) {\n            fs.close(fd, () => {\n              callback(errThrown);\n            });\n            return;\n          }\n          fsStm = fs.createWriteStream(outPath, { fd: fdFile });\n          fsStm.on('finish', () => {\n            that.emit('extract', entry, outPath);\n            if (!errThrown) {\n              callback();\n            }\n          });\n          stm.pipe(fsStm);\n        });\n      }\n    });\n  }\n\n  function createDirectories(baseDir, dirs, callback) {\n    if (!dirs.length) {\n      return callback();\n    }\n    let dir = dirs.shift();\n    dir = path.join(baseDir, path.join(...dir));\n    fs.mkdir(dir, { recursive: true }, (err) => {\n      if (err && err.code !== 'EEXIST') {\n        return callback(err);\n      }\n      createDirectories(baseDir, dirs, callback);\n    });\n  }\n\n  function extractFiles(baseDir, baseRelPath, files, callback, extractedCount) {\n    if (!files.length) {\n      return callback(null, extractedCount);\n    }\n    const file = files.shift();\n    const targetPath = path.join(baseDir, file.name.replace(baseRelPath, ''));\n    extract(file, targetPath, (err) => {\n      if (err) {\n        return callback(err, extractedCount);\n      }\n      extractFiles(baseDir, baseRelPath, files, callback, extractedCount + 1);\n    });\n  }\n\n  this.extract = function (entry, outPath, callback) {\n    let entryName = entry || '';\n    if (typeof entry === 'string') {\n      entry = this.entry(entry);\n      if (entry) {\n        entryName = entry.name;\n      } else {\n        if (entryName.length && entryName[entryName.length - 1] !== '/') {\n          entryName += '/';\n        }\n      }\n    }\n    if (!entry || entry.isDirectory) {\n      const files = [],\n        dirs = [],\n        allDirs = {};\n      for (const e in entries) {\n        if (\n          Object.prototype.hasOwnProperty.call(entries, e) &&\n          e.lastIndexOf(entryName, 0) === 0\n        ) {\n          let relPath = e.replace(entryName, '');\n          const childEntry = entries[e];\n          if (childEntry.isFile) {\n            files.push(childEntry);\n            relPath = path.dirname(relPath);\n          }\n          if (relPath && !allDirs[relPath] && relPath !== '.') {\n            allDirs[relPath] = true;\n            let parts = relPath.split('/').filter((f) => {\n              return f;\n            });\n            if (parts.length) {\n              dirs.push(parts);\n            }\n            while (parts.length > 1) {\n              parts = parts.slice(0, parts.length - 1);\n              const partsPath = parts.join('/');\n              if (allDirs[partsPath] || partsPath === '.') {\n                break;\n              }\n              allDirs[partsPath] = true;\n              dirs.push(parts);\n            }\n          }\n        }\n      }\n      dirs.sort((x, y) => {\n        return x.length - y.length;\n      });\n      if (dirs.length) {\n        createDirectories(outPath, dirs, (err) => {\n          if (err) {\n            callback(err);\n          } else {\n            extractFiles(outPath, entryName, files, callback, 0);\n          }\n        });\n      } else {\n        extractFiles(outPath, entryName, files, callback, 0);\n      }\n    } else {\n      fs.stat(outPath, (err, stat) => {\n        if (stat && stat.isDirectory()) {\n          extract(entry, path.join(outPath, path.basename(entry.name)), callback);\n        } else {\n          extract(entry, outPath, callback);\n        }\n      });\n    }\n  };\n\n  this.close = function (callback) {\n    if (closed || !fd) {\n      closed = true;\n      if (callback) {\n        callback();\n      }\n    } else {\n      closed = true;\n      fs.close(fd, (err) => {\n        fd = null;\n        if (callback) {\n          callback(err);\n        }\n      });\n    }\n  };\n\n  const originalEmit = events.EventEmitter.prototype.emit;\n  this.emit = function (...args) {\n    if (!closed) {\n      return originalEmit.call(this, ...args);\n    }\n  };\n};\n\nStreamZip.setFs = function (customFs) {\n  fs = customFs;\n};\n\nStreamZip.debugLog = (...args) => {\n  if (StreamZip.debug) {\n    // eslint-disable-next-line no-console\n    console.log(...args);\n  }\n};\n\nutil.inherits(StreamZip, events.EventEmitter);\n\nconst propZip = Symbol('zip');\n\nStreamZip.async = class StreamZipAsync extends events.EventEmitter {\n  constructor(config) {\n    super();\n\n    const zip = new StreamZip(config);\n\n    zip.on('entry', (entry) => this.emit('entry', entry));\n    zip.on('extract', (entry, outPath) => this.emit('extract', entry, outPath));\n\n    this[propZip] = new Promise((resolve, reject) => {\n      zip.on('ready', () => {\n        zip.removeListener('error', reject);\n        resolve(zip);\n      });\n      zip.on('error', reject);\n    });\n  }\n\n  get entriesCount() {\n    return this[propZip].then((zip) => zip.entriesCount);\n  }\n\n  get comment() {\n    return this[propZip].then((zip) => zip.comment);\n  }\n\n  async entry(name) {\n    const zip = await this[propZip];\n    return zip.entry(name);\n  }\n\n  async entries() {\n    const zip = await this[propZip];\n    return zip.entries();\n  }\n\n  async stream(entry) {\n    const zip = await this[propZip];\n    return new Promise((resolve, reject) => {\n      zip.stream(entry, (err, stm) => {\n        if (err) {\n          reject(err);\n        } else {\n          resolve(stm);\n        }\n      });\n    });\n  }\n\n  async entryData(entry) {\n    const stm = await this.stream(entry);\n    return new Promise((resolve, reject) => {\n      const data = [];\n      stm.on('data', (chunk) => data.push(chunk));\n      stm.on('end', () => {\n        resolve(Buffer.concat(data));\n      });\n      stm.on('error', (err) => {\n        stm.removeAllListeners('end');\n        reject(err);\n      });\n    });\n  }\n\n  async extract(entry, outPath) {\n    const zip = await this[propZip];\n    return new Promise((resolve, reject) => {\n      zip.extract(entry, outPath, (err, res) => {\n        if (err) {\n          reject(err);\n        } else {\n          resolve(res);\n        }\n      });\n    });\n  }\n\n  async close() {\n    const zip = await this[propZip];\n    return new Promise((resolve, reject) => {\n      zip.close((err) => {\n        if (err) {\n          reject(err);\n        } else {\n          resolve();\n        }\n      });\n    });\n  }\n};\n\nclass CentralDirectoryHeader {\n  read(data) {\n    if (data.length !== consts.ENDHDR || data.readUInt32LE(0) !== consts.ENDSIG) {\n      throw new Error('Invalid central directory');\n    }\n    // number of entries on this volume\n    this.volumeEntries = data.readUInt16LE(consts.ENDSUB);\n    // total number of entries\n    this.totalEntries = data.readUInt16LE(consts.ENDTOT);\n    // central directory size in bytes\n    this.size = data.readUInt32LE(consts.ENDSIZ);\n    // offset of first CEN header\n    this.offset = data.readUInt32LE(consts.ENDOFF);\n    // zip file comment length\n    this.commentLength = data.readUInt16LE(consts.ENDCOM);\n  }\n}\n\nclass CentralDirectoryLoc64Header {\n  read(data) {\n    if (data.length !== consts.ENDL64HDR || data.readUInt32LE(0) !== consts.ENDL64SIG) {\n      throw new Error('Invalid zip64 central directory locator');\n    }\n    // ZIP64 EOCD header offset\n    this.headerOffset = readUInt64LE(data, consts.ENDSUB);\n  }\n}\n\nclass CentralDirectoryZip64Header {\n  read(data) {\n    if (data.length !== consts.END64HDR || data.readUInt32LE(0) !== consts.END64SIG) {\n      throw new Error('Invalid central directory');\n    }\n    // number of entries on this volume\n    this.volumeEntries = readUInt64LE(data, consts.END64SUB);\n    // total number of entries\n    this.totalEntries = readUInt64LE(data, consts.END64TOT);\n    // central directory size in bytes\n    this.size = readUInt64LE(data, consts.END64SIZ);\n    // offset of first CEN header\n    this.offset = readUInt64LE(data, consts.END64OFF);\n  }\n}\n\nclass ZipEntry {\n  readHeader(data, offset) {\n    // data should be 46 bytes and start with \"PK 01 02\"\n    if (data.length < offset + consts.CENHDR || data.readUInt32LE(offset) !== consts.CENSIG) {\n      throw new Error('Invalid entry header');\n    }\n    // version made by\n    this.verMade = data.readUInt16LE(offset + consts.CENVEM);\n    // version needed to extract\n    this.version = data.readUInt16LE(offset + consts.CENVER);\n    // encrypt, decrypt flags\n    this.flags = data.readUInt16LE(offset + consts.CENFLG);\n    // compression method\n    this.method = data.readUInt16LE(offset + consts.CENHOW);\n    // modification time (2 bytes time, 2 bytes date)\n    const timebytes = data.readUInt16LE(offset + consts.CENTIM);\n    const datebytes = data.readUInt16LE(offset + consts.CENTIM + 2);\n    this.time = parseZipTime(timebytes, datebytes);\n\n    // uncompressed file crc-32 value\n    this.crc = data.readUInt32LE(offset + consts.CENCRC);\n    // compressed size\n    this.compressedSize = data.readUInt32LE(offset + consts.CENSIZ);\n    // uncompressed size\n    this.size = data.readUInt32LE(offset + consts.CENLEN);\n    // filename length\n    this.fnameLen = data.readUInt16LE(offset + consts.CENNAM);\n    // extra field length\n    this.extraLen = data.readUInt16LE(offset + consts.CENEXT);\n    // file comment length\n    this.comLen = data.readUInt16LE(offset + consts.CENCOM);\n    // volume number start\n    this.diskStart = data.readUInt16LE(offset + consts.CENDSK);\n    // internal file attributes\n    this.inattr = data.readUInt16LE(offset + consts.CENATT);\n    // external file attributes\n    this.attr = data.readUInt32LE(offset + consts.CENATX);\n    // LOC header offset\n    this.offset = data.readUInt32LE(offset + consts.CENOFF);\n  }\n\n  readDataHeader(data) {\n    // 30 bytes and should start with \"PK\\003\\004\"\n    if (data.readUInt32LE(0) !== consts.LOCSIG) {\n      throw new Error('Invalid local header');\n    }\n    // version needed to extract\n    this.version = data.readUInt16LE(consts.LOCVER);\n    // general purpose bit flag\n    this.flags = data.readUInt16LE(consts.LOCFLG);\n    // compression method\n    this.method = data.readUInt16LE(consts.LOCHOW);\n    // modification time (2 bytes time ; 2 bytes date)\n    const timebytes = data.readUInt16LE(consts.LOCTIM);\n    const datebytes = data.readUInt16LE(consts.LOCTIM + 2);\n    this.time = parseZipTime(timebytes, datebytes);\n\n    // uncompressed file crc-32 value\n    this.crc = data.readUInt32LE(consts.LOCCRC) || this.crc;\n    // compressed size\n    const compressedSize = data.readUInt32LE(consts.LOCSIZ);\n    if (compressedSize && compressedSize !== consts.EF_ZIP64_OR_32) {\n      this.compressedSize = compressedSize;\n    }\n    // uncompressed size\n    const size = data.readUInt32LE(consts.LOCLEN);\n    if (size && size !== consts.EF_ZIP64_OR_32) {\n      this.size = size;\n    }\n    // filename length\n    this.fnameLen = data.readUInt16LE(consts.LOCNAM);\n    // extra field length\n    this.extraLen = data.readUInt16LE(consts.LOCEXT);\n  }\n\n  read(data, offset, textDecoder) {\n    const nameData = data.slice(offset, (offset += this.fnameLen));\n    this.name = textDecoder\n      ? textDecoder.decode(new Uint8Array(nameData))\n      : nameData.toString('utf8');\n    const lastChar = data[offset - 1];\n    this.isDirectory = lastChar === 47 || lastChar === 92;\n\n    if (this.extraLen) {\n      this.readExtra(data, offset);\n      offset += this.extraLen;\n    }\n    this.comment = this.comLen ? data.slice(offset, offset + this.comLen).toString() : null;\n  }\n\n  validateName() {\n    if (/\\\\|^\\w+:|^\\/|(^|\\/)\\.\\.(\\/|$)/.test(this.name)) {\n      throw new Error('Malicious entry: ' + this.name);\n    }\n  }\n\n  readExtra(data, offset) {\n    let signature, size;\n    const maxPos = offset + this.extraLen;\n    while (offset < maxPos) {\n      signature = data.readUInt16LE(offset);\n      offset += 2;\n      size = data.readUInt16LE(offset);\n      offset += 2;\n      if (consts.ID_ZIP64 === signature) {\n        this.parseZip64Extra(data, offset, size);\n      }\n      offset += size;\n    }\n  }\n\n  parseZip64Extra(data, offset, length) {\n    if (length >= 8 && this.size === consts.EF_ZIP64_OR_32) {\n      this.size = readUInt64LE(data, offset);\n      offset += 8;\n      length -= 8;\n    }\n    if (length >= 8 && this.compressedSize === consts.EF_ZIP64_OR_32) {\n      this.compressedSize = readUInt64LE(data, offset);\n      offset += 8;\n      length -= 8;\n    }\n    if (length >= 8 && this.offset === consts.EF_ZIP64_OR_32) {\n      this.offset = readUInt64LE(data, offset);\n      offset += 8;\n      length -= 8;\n    }\n    if (length >= 4 && this.diskStart === consts.EF_ZIP64_OR_16) {\n      this.diskStart = data.readUInt32LE(offset);\n      // offset += 4; length -= 4;\n    }\n  }\n\n  get encrypted() {\n    return (this.flags & consts.FLG_ENTRY_ENC) === consts.FLG_ENTRY_ENC;\n  }\n\n  get isFile() {\n    return !this.isDirectory;\n  }\n}\n\nclass FsRead {\n  constructor(fd, buffer, offset, length, position, callback) {\n    this.fd = fd;\n    this.buffer = buffer;\n    this.offset = offset;\n    this.length = length;\n    this.position = position;\n    this.callback = callback;\n    this.bytesRead = 0;\n    this.waiting = false;\n  }\n\n  read(sync) {\n    StreamZip.debugLog('read', this.position, this.bytesRead, this.length, this.offset);\n    this.waiting = true;\n    let err;\n    if (sync) {\n      let bytesRead = 0;\n      try {\n        bytesRead = fs.readSync(\n          this.fd,\n          this.buffer,\n          this.offset + this.bytesRead,\n          this.length - this.bytesRead,\n          this.position + this.bytesRead\n        );\n      } catch (e) {\n        err = e;\n      }\n      this.readCallback(sync, err, err ? bytesRead : null);\n    } else {\n      fs.read(\n        this.fd,\n        this.buffer,\n        this.offset + this.bytesRead,\n        this.length - this.bytesRead,\n        this.position + this.bytesRead,\n        this.readCallback.bind(this, sync)\n      );\n    }\n  }\n\n  readCallback(sync, err, bytesRead) {\n    if (typeof bytesRead === 'number') {\n      this.bytesRead += bytesRead;\n    }\n    if (err || !bytesRead || this.bytesRead === this.length) {\n      this.waiting = false;\n      return this.callback(err, this.bytesRead);\n    } else {\n      this.read(sync);\n    }\n  }\n}\n\nclass FileWindowBuffer {\n  constructor(fd) {\n    this.position = 0;\n    this.buffer = Buffer.alloc(0);\n    this.fd = fd;\n    this.fsOp = null;\n  }\n\n  checkOp() {\n    if (this.fsOp && this.fsOp.waiting) {\n      throw new Error('Operation in progress');\n    }\n  }\n\n  read(pos, length, callback) {\n    this.checkOp();\n    if (this.buffer.length < length) {\n      this.buffer = Buffer.alloc(length);\n    }\n    this.position = pos;\n    this.fsOp = new FsRead(this.fd, this.buffer, 0, length, this.position, callback).read();\n  }\n\n  expandLeft(length, callback) {\n    this.checkOp();\n    this.buffer = Buffer.concat([Buffer.alloc(length), this.buffer]);\n    this.position -= length;\n    if (this.position < 0) {\n      this.position = 0;\n    }\n    this.fsOp = new FsRead(this.fd, this.buffer, 0, length, this.position, callback).read();\n  }\n\n  expandRight(length, callback) {\n    this.checkOp();\n    const offset = this.buffer.length;\n    this.buffer = Buffer.concat([this.buffer, Buffer.alloc(length)]);\n    this.fsOp = new FsRead(\n      this.fd,\n      this.buffer,\n      offset,\n      length,\n      this.position + offset,\n      callback\n    ).read();\n  }\n\n  moveRight(length, callback, shift) {\n    this.checkOp();\n    if (shift) {\n      this.buffer.copy(this.buffer, 0, shift);\n    } else {\n      shift = 0;\n    }\n    this.position += shift;\n    this.fsOp = new FsRead(\n      this.fd,\n      this.buffer,\n      this.buffer.length - shift,\n      shift,\n      this.position + this.buffer.length - shift,\n      callback\n    ).read();\n  }\n}\n\nclass EntryDataReaderStream extends stream.Readable {\n  constructor(fd, offset, length) {\n    super();\n    this.fd = fd;\n    this.offset = offset;\n    this.length = length;\n    this.pos = 0;\n    this.readCallback = this.readCallback.bind(this);\n  }\n\n  _read(n) {\n    const buffer = Buffer.alloc(Math.min(n, this.length - this.pos));\n    if (buffer.length) {\n      fs.read(this.fd, buffer, 0, buffer.length, this.offset + this.pos, this.readCallback);\n    } else {\n      this.push(null);\n    }\n  }\n\n  readCallback(err, bytesRead, buffer) {\n    this.pos += bytesRead;\n    if (err) {\n      this.emit('error', err);\n      this.push(null);\n    } else if (!bytesRead) {\n      this.push(null);\n    } else {\n      if (bytesRead !== buffer.length) {\n        buffer = buffer.slice(0, bytesRead);\n      }\n      this.push(buffer);\n    }\n  }\n}\n\nclass EntryVerifyStream extends stream.Transform {\n  constructor(baseStm, crc, size) {\n    super();\n    this.verify = new CrcVerify(crc, size);\n    baseStm.on('error', (e) => {\n      this.emit('error', e);\n    });\n  }\n\n  _transform(data, encoding, callback) {\n    let err;\n    try {\n      this.verify.data(data);\n    } catch (e) {\n      err = e;\n    }\n    callback(err, data);\n  }\n}\n\nclass CrcVerify {\n  constructor(crc, size) {\n    this.crc = crc;\n    this.size = size;\n    this.state = {\n      crc: ~0,\n      size: 0,\n    };\n  }\n\n  data(data) {\n    const crcTable = CrcVerify.getCrcTable();\n    let crc = this.state.crc;\n    let off = 0;\n    let len = data.length;\n    while (--len >= 0) {\n      crc = crcTable[(crc ^ data[off++]) & 0xff] ^ (crc >>> 8);\n    }\n    this.state.crc = crc;\n    this.state.size += data.length;\n    if (this.state.size >= this.size) {\n      const buf = Buffer.alloc(4);\n      buf.writeInt32LE(~this.state.crc & 0xffffffff, 0);\n      crc = buf.readUInt32LE(0);\n      if (crc !== this.crc) {\n        throw new Error('Invalid CRC');\n      }\n      if (this.state.size !== this.size) {\n        throw new Error('Invalid size');\n      }\n    }\n  }\n\n  static getCrcTable() {\n    let crcTable = CrcVerify.crcTable;\n    if (!crcTable) {\n      CrcVerify.crcTable = crcTable = [];\n      const b = Buffer.alloc(4);\n      for (let n = 0; n < 256; n++) {\n        let c = n;\n        for (let k = 8; --k >= 0;) {\n          if ((c & 1) !== 0) {\n            c = 0xedb88320 ^ (c >>> 1);\n          } else {\n            c = c >>> 1;\n          }\n        }\n        if (c < 0) {\n          b.writeInt32LE(c, 0);\n          c = b.readUInt32LE(0);\n        }\n        crcTable[n] = c;\n      }\n    }\n    return crcTable;\n  }\n}\n\nfunction parseZipTime(timebytes, datebytes) {\n  const timebits = toBits(timebytes, 16);\n  const datebits = toBits(datebytes, 16);\n\n  const mt = {\n    h: parseInt(timebits.slice(0, 5).join(''), 2),\n    m: parseInt(timebits.slice(5, 11).join(''), 2),\n    s: parseInt(timebits.slice(11, 16).join(''), 2) * 2,\n    Y: parseInt(datebits.slice(0, 7).join(''), 2) + 1980,\n    M: parseInt(datebits.slice(7, 11).join(''), 2),\n    D: parseInt(datebits.slice(11, 16).join(''), 2),\n  };\n  const dt_str = [mt.Y, mt.M, mt.D].join('-') + ' ' + [mt.h, mt.m, mt.s].join(':') + ' GMT+0';\n  return new Date(dt_str).getTime();\n}\n\nfunction toBits(dec, size) {\n  let b = (dec >>> 0).toString(2);\n  while (b.length < size) {\n    b = '0' + b;\n  }\n  return b.split('');\n}\n\nfunction readUInt64LE(buffer, offset) {\n  return buffer.readUInt32LE(offset + 4) * 0x0000000100000000 + buffer.readUInt32LE(offset);\n}\n\nmodule.exports = StreamZip;"
  },
  {
    "path": "server/libs/passportLocal/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2011-2014 Jared Hanson\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject 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, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "server/libs/passportLocal/index.js",
    "content": "//\n// modified for audiobookshelf\n// Source: https://github.com/jaredhanson/passport-local\n//\n\n/**\n * Module dependencies.\n */\nvar Strategy = require('./strategy');\n\n\n/**\n * Expose `Strategy` directly from package.\n */\nexports = module.exports = Strategy;\n\n/**\n * Export constructors.\n */\nexports.Strategy = Strategy;\n"
  },
  {
    "path": "server/libs/passportLocal/strategy.js",
    "content": "/**\n * Module dependencies.\n */\nconst passport = require('passport-strategy')\nconst util = require('util')\n\n\nfunction lookup(obj, field) {\n  if (!obj) { return null; }\n  var chain = field.split(']').join('').split('[');\n  for (var i = 0, len = chain.length; i < len; i++) {\n    var prop = obj[chain[i]];\n    if (typeof (prop) === 'undefined') { return null; }\n    if (typeof (prop) !== 'object') { return prop; }\n    obj = prop;\n  }\n  return null;\n}\n\n/**\n * `Strategy` constructor.\n *\n * The local authentication strategy authenticates requests based on the\n * credentials submitted through an HTML-based login form.\n *\n * Applications must supply a `verify` callback which accepts `username` and\n * `password` credentials, and then calls the `done` callback supplying a\n * `user`, which should be set to `false` if the credentials are not valid.\n * If an exception occured, `err` should be set.\n *\n * Optionally, `options` can be used to change the fields in which the\n * credentials are found.\n *\n * Options:\n *   - `usernameField`  field name where the username is found, defaults to _username_\n *   - `passwordField`  field name where the password is found, defaults to _password_\n *   - `passReqToCallback`  when `true`, `req` is the first argument to the verify callback (default: `false`)\n *\n * Examples:\n *\n *     passport.use(new LocalStrategy(\n *       function(username, password, done) {\n *         User.findOne({ username: username, password: password }, function (err, user) {\n *           done(err, user);\n *         });\n *       }\n *     ));\n *\n * @param {Object} options\n * @param {Function} verify\n * @api public\n */\nfunction Strategy(options, verify) {\n  if (typeof options == 'function') {\n    verify = options;\n    options = {};\n  }\n  if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); }\n\n  this._usernameField = options.usernameField || 'username';\n  this._passwordField = options.passwordField || 'password';\n\n  passport.Strategy.call(this);\n  this.name = 'local';\n  this._verify = verify;\n  this._passReqToCallback = options.passReqToCallback;\n}\n\n/**\n * Inherit from `passport.Strategy`.\n */\nutil.inherits(Strategy, passport.Strategy);\n\n/**\n * Authenticate request based on the contents of a form submission.\n *\n * @param {Object} req\n * @api protected\n */\nStrategy.prototype.authenticate = function (req, options) {\n  options = options || {};\n  var username = lookup(req.body, this._usernameField)\n  if (username === null) {\n    lookup(req.query, this._usernameField);\n  }\n\n  var password = lookup(req.body, this._passwordField)\n  if (password === null) {\n    password = lookup(req.query, this._passwordField);\n  }\n\n  if (username === null || password === null) {\n    return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);\n  }\n\n  var self = this;\n\n  function verified(err, user, info) {\n    if (err) { return self.error(err); }\n    if (!user) { return self.fail(info); }\n    self.success(user, info);\n  }\n\n  try {\n    if (self._passReqToCallback) {\n      this._verify(req, username, password, verified);\n    } else {\n      this._verify(username, password, verified);\n    }\n  } catch (ex) {\n    return self.error(ex);\n  }\n};\n\n\n/**\n * Expose `Strategy`.\n */\nmodule.exports = Strategy;\n"
  },
  {
    "path": "server/libs/readChunk/LICENSE",
    "content": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/readChunk/index.js",
    "content": "'use strict';\n\n// https://github.com/sindresorhus/read-chunk\n\nconst fs = require('fs');\nconst pify = require('./pify');\nconst withOpenFile = require('./withOpenFile');\n\nconst fsReadP = pify(fs.read, { multiArgs: true });\n\nconst readChunk = (filePath, startPosition, length) => {\n  const buffer = Buffer.alloc(length);\n\n  return withOpenFile(filePath, 'r', fileDescriptor =>\n    fsReadP(fileDescriptor, buffer, 0, length, startPosition)\n  )\n    .then(([bytesRead, buffer]) => {\n      if (bytesRead < length) {\n        buffer = buffer.slice(0, bytesRead);\n      }\n\n      return buffer;\n    });\n};\n\nmodule.exports = readChunk;\n// TODO: Remove this for the next major release\nmodule.exports.default = readChunk;\n\nmodule.exports.sync = (filePath, startPosition, length) => {\n  let buffer = Buffer.alloc(length);\n\n  const bytesRead = withOpenFile.sync(filePath, 'r', fileDescriptor =>\n    fs.readSync(fileDescriptor, buffer, 0, length, startPosition)\n  );\n\n  if (bytesRead < length) {\n    buffer = buffer.slice(0, bytesRead);\n  }\n\n  return buffer;\n};\n"
  },
  {
    "path": "server/libs/readChunk/pify.js",
    "content": "'use strict'\n\n// https://github.com/sindresorhus/pify\n\nconst processFn = (fn, options) => function (...args) {\n  const P = options.promiseModule;\n\n  return new P((resolve, reject) => {\n    if (options.multiArgs) {\n      args.push((...result) => {\n        if (options.errorFirst) {\n          if (result[0]) {\n            reject(result);\n          } else {\n            result.shift();\n            resolve(result);\n          }\n        } else {\n          resolve(result);\n        }\n      });\n    } else if (options.errorFirst) {\n      args.push((error, result) => {\n        if (error) {\n          reject(error);\n        } else {\n          resolve(result);\n        }\n      });\n    } else {\n      args.push(resolve);\n    }\n\n    fn.apply(this, args);\n  });\n};\n\nmodule.exports = (input, options) => {\n  options = Object.assign({\n    exclude: [/.+(Sync|Stream)$/],\n    errorFirst: true,\n    promiseModule: Promise\n  }, options);\n\n  const objType = typeof input;\n  if (!(input !== null && (objType === 'object' || objType === 'function'))) {\n    throw new TypeError(`Expected \\`input\\` to be a \\`Function\\` or \\`Object\\`, got \\`${input === null ? 'null' : objType}\\``);\n  }\n\n  const filter = key => {\n    const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key);\n    return options.include ? options.include.some(match) : !options.exclude.some(match);\n  };\n\n  let ret;\n  if (objType === 'function') {\n    ret = function (...args) {\n      return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args);\n    };\n  } else {\n    ret = Object.create(Object.getPrototypeOf(input));\n  }\n\n  for (const key in input) { // eslint-disable-line guard-for-in\n    const property = input[key];\n    ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property;\n  }\n\n  return ret;\n};\n"
  },
  {
    "path": "server/libs/readChunk/withOpenFile.js",
    "content": "'use strict'\n\nconst fs = require('fs')\nconst pify = require('./pify')\nconst pTry = (fn, ...arguments_) => new Promise(resolve => {\n  resolve(fn(...arguments_));\n})\nconst pFinally = (promise, onFinally) => {\n  onFinally = onFinally || (() => { });\n\n  return promise.then(\n    val => new Promise(resolve => {\n      resolve(onFinally());\n    }).then(() => val),\n    err => new Promise(resolve => {\n      resolve(onFinally());\n    }).then(() => {\n      throw err;\n    })\n  );\n};\n\n\nconst fsP = pify(fs)\n\nmodule.exports = (...args) => {\n  const callback = args.pop()\n  return fsP\n    .open(...args)\n    .then(fd => pFinally(pTry(callback, fd), _ => fsP.close(fd)))\n}\n\nmodule.exports.sync = (...args) => {\n  const callback = args.pop()\n  const fd = fs.openSync(...args)\n  try {\n    return callback(fd)\n  } finally {\n    fs.closeSync(fd)\n  }\n}\n"
  },
  {
    "path": "server/libs/recursiveReaddirAsync/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018 m0rtadelo\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": "server/libs/recursiveReaddirAsync/index.js",
    "content": "\"use strict\";\n/**\n * @packageDocumentation\n * project: recursive-readdir-async\n * @author: m0rtadelo (ricard.figuls)\n * @license MIT\n * 2018\n */\n\n// SOURCE: https://github.com/m0rtadelo/recursive-readdir-async\n\nvar __createBinding = (this && this.__createBinding) || (Object.create ? (function (o, m, k, k2) {\n  if (k2 === undefined) k2 = k;\n  Object.defineProperty(o, k2, { enumerable: true, get: function () { return m[k]; } });\n}) : (function (o, m, k, k2) {\n  if (k2 === undefined) k2 = k;\n  o[k2] = m[k];\n}));\nvar __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) {\n  Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function (o, v) {\n  o[\"default\"] = v;\n});\nvar __importStar = (this && this.__importStar) || function (mod) {\n  if (mod && mod.__esModule) return mod;\n  var result = {};\n  if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n  __setModuleDefault(result, mod);\n  return result;\n};\nvar __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {\n  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n  return new (P || (P = Promise))(function (resolve, reject) {\n    function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n    function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n    function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n    step((generator = generator.apply(thisArg, _arguments || [])).next());\n  });\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.list = exports.readFile = exports.stat = exports.PATH = exports.FS = exports.TREE = exports.LIST = void 0;\n/** @readonly constant for mode LIST to be used in Options */\nexports.LIST = 1;\n/** @readonly constant for mode TREE to be used in Options */\nexports.TREE = 2;\n/**\n * native FS module\n * @see https://nodejs.org/api/fs.html#fs_file_system\n * @external\n */\nconst _fs = __importStar(require(\"fs\"));\n/** native node fs object */\nexports.FS = _fs;\n/**\n * native PATH module\n * @external\n * @see https://nodejs.org/api/path.html#path_path\n */\nconst _path = __importStar(require(\"path\"));\n/** native node path object */\nexports.PATH = _path;\nlet pathSimbol = '/';\n/**\n * Returns a Promise with Stats info of the item (file/folder/...)\n * @param file the name of the object to get stats from\n * @returns {Promise<fs.Stats>} stat object information\n * @async\n */\nfunction stat(file) {\n  return __awaiter(this, void 0, void 0, function* () {\n    return new Promise(function (resolve, reject) {\n      exports.FS.stat(file, function (err, stats) {\n        if (err) {\n          reject(err);\n        }\n        else {\n          resolve(stats);\n        }\n      });\n    });\n  });\n}\nexports.stat = stat;\n/**\n * Returns a Promise with content (data) of the file\n * @param file the name of the file to read content from\n * @param encoding format for returned data (ascii, base64, binary, hex, ucs2/ucs-2/utf16le/utf-16le,\n *  utf8/utf-8, latin1). Default: base64\n * @returns {Promise<string>} data content string (base64 format by default)\n * @async\n */\nfunction readFile(file, encoding = 'base64') {\n  return __awaiter(this, void 0, void 0, function* () {\n    return new Promise(function (resolve, reject) {\n      exports.FS.readFile(file, { encoding }, function (err, data) {\n        if (err) {\n          reject(err);\n        }\n        else {\n          resolve(data);\n        }\n      });\n    });\n  });\n}\nexports.readFile = readFile;\n/**\n * Returns if an item should be added based on include/exclude options.\n * @param path the item fullpath\n * @param settings the options configuration to use\n * @returns {boolean} if item must be added\n * @private\n */\nfunction checkItem(path, settings) {\n  if (settings.exclude) {\n    for (const value of settings.exclude) {\n      if (path.includes(value)) {\n        return false;\n      }\n    }\n  }\n  return true;\n}\n/**\n * Adds optional keys to item\n * @param obj the item object\n * @param file the filename\n * @param settings the options configuration to use\n * @param deep The deep level\n * @returns void\n * @private\n */\nfunction addOptionalKeys(obj, file, settings, deep) {\n  if (settings.extensions) {\n    obj.extension = (exports.PATH.extname(file)).toLowerCase();\n  }\n  if (settings.deep) {\n    obj.deep = deep;\n  }\n}\n/**\n * Reads content and creates a valid IBase collection\n * @param rpath Path relative to\n * @param data Model\n * @param settings the options configuration to use\n * @param deep The deep level\n * @param resolve Promise\n * @param reject Promise\n * @returns void\n */\nfunction read(rpath, data, settings, deep, resolve, reject) {\n  exports.FS.readdir(rpath, function (error, files) {\n    // If error reject them\n    if (error) {\n      reject(error);\n    }\n    else {\n      const removeExt = (file) => {\n        const extSize = exports.PATH.extname(file).length;\n        return file.substring(0, file.length - (extSize > 0 ? extSize : 0));\n      };\n      // Iterate through elements (files and folders)\n      for (const file of files) {\n        const obj = {\n          name: file,\n          title: removeExt(file),\n          path: rpath,\n          fullname: rpath + (rpath.endsWith(pathSimbol) ? '' : pathSimbol) + file,\n        };\n        if (checkItem(obj.fullname, settings)) {\n          addOptionalKeys(obj, file, settings, deep);\n          data.push(obj);\n        }\n      }\n      // Finish, returning content\n      resolve(data);\n    }\n  });\n}\n/**\n * Returns a Promise with an objects info array\n * @param path the item fullpath to be searched for\n * @param settings the options configuration to use\n * @param deep folder depth value\n * @returns {Promise<IBase[]>} the file object info\n * @private\n */\nfunction myReaddir(path, settings, deep) {\n  return __awaiter(this, void 0, void 0, function* () {\n    const data = [];\n    return new Promise(function (resolve, reject) {\n      try {\n        // Asynchronously computes the canonical pathname by resolving ., .. and symbolic links.\n        exports.FS.realpath(path, function (err, rpath) {\n          if (err || settings.realPath === false) {\n            rpath = path;\n          }\n          // Normalizes windows style paths by replacing double backslahes with single forward slahes (unix style).\n          if (settings.normalizePath) {\n            rpath = normalizePath(rpath);\n          }\n          // Reading contents of path\n          read(rpath, data, settings, deep, resolve, reject);\n        });\n      }\n      catch (err) {\n        // If error reject them\n        reject(err);\n      }\n    });\n  });\n}\n/**\n * Normalizes windows style paths by replacing double backslahes with single forward slahes (unix style).\n * @param path windows/unix path\n * @return {string} normalized path (unix style)\n * @private\n */\nfunction normalizePath(path) {\n  return path.toString().replace(/\\\\/g, '/');\n}\n/**\n     * Search if the fullname exist in the include array\n     * @param fullname - The fullname of the item to search for\n     * @param settings the options to be used\n     * @returns true if exists\n     */\nfunction exists(fullname, settings) {\n  if (settings.include) {\n    for (const value of settings.include) {\n      if (fullname.includes(value)) {\n        return true;\n      }\n    }\n  }\n  return false;\n}\n/**\n   * Removes paths that not match the include array\n   * @param settings the options to be used\n   * @param content items list\n   * @returns void\n   */\nfunction onlyInclude(settings, content) {\n  if (settings.include && settings.include.length > 0) {\n    for (let i = content.length - 1; i > -1; i--) {\n      const item = content[i];\n      if (settings.mode === exports.TREE && item.isDirectory && item.content)\n        continue;\n      if (!exists(item.fullname, settings)) {\n        content.splice(i, 1);\n      }\n    }\n  }\n}\n/**\n * Returns an array of items in path\n * @param path path\n * @param settings the options to be used\n * @param progress callback progress\n * @param deep deep index information\n * @returns {object[]} array with file information\n * @private\n */\nfunction listDir(path, settings, progress, deep = 0) {\n  return __awaiter(this, void 0, void 0, function* () {\n    let content;\n    try {\n      content = yield myReaddir(path, settings, deep);\n    }\n    catch (err) {\n      return { 'error': err, 'path': path };\n    }\n    if (settings.stats || settings.recursive || !settings.ignoreFolders ||\n      settings.readContent || settings.mode === exports.TREE) {\n      content = yield statDir(content, settings, progress, deep);\n    }\n    onlyInclude(settings, content);\n    return content;\n  });\n}\n/**\n * Returns an object with all items with selected options\n * @param collection items list\n * @param settings the options to use\n * @param progress callback progress\n * @param deep folder depth\n * @returns {object[]} array with file information\n * @private\n */\nfunction statDir(collection, settings, progress, deep) {\n  return __awaiter(this, void 0, void 0, function* () {\n    let isOk = true;\n    for (let i = collection.length - 1; i > -1; i--) {\n      try {\n        collection = yield statDirItem(collection, i, settings, progress, deep);\n        if (progress !== undefined) {\n          isOk = !progress(collection[i], collection.length - i, collection.length);\n        }\n      }\n      catch (err) {\n        collection[i].error = err;\n      }\n      if ((collection[i].isDirectory && settings.ignoreFolders &&\n        !(collection[i].content) && collection[i].error === undefined) || !isOk) {\n        collection.splice(i, 1);\n      }\n    }\n    return collection;\n  });\n}\n/**\n * Returns an object with updated item information\n * @param collection items list\n * @param i index of item\n * @param settings the options to use\n * @param progress callback progress\n * @param deep folder depth\n * @returns {object[]} array with file information\n * @private\n */\nfunction statDirItem(collection, i, settings, progress, deep) {\n  return __awaiter(this, void 0, void 0, function* () {\n    const stats = yield stat(collection[i].fullname);\n    collection[i].isDirectory = stats.isDirectory();\n    if (settings.stats) {\n      collection[i].stats = stats;\n    }\n    if (settings.readContent && !collection[i].isDirectory) {\n      collection[i].data = yield readFile(collection[i].fullname, settings.encoding);\n    }\n    if (collection[i].isDirectory && settings.recursive) {\n      const item = collection[i];\n      if (settings.mode === exports.LIST) {\n        const result = yield listDir(item.fullname, settings, progress, deep + 1);\n        if (result.length) {\n          collection = collection.concat(result);\n        }\n      }\n      else {\n        item.content = yield listDir(item.fullname, settings, progress, deep + 1);\n        if (item.content && item.content.length === 0) {\n          item.content = undefined;\n        }\n      }\n    }\n    return collection;\n  });\n}\n/**\n * Returns a javascript object with directory items information (non blocking async with Promises)\n * @param path the path to start reading contents\n * @param [options] options (mode, recursive, stats, ignoreFolders)\n * @param [progress] callback with item data and progress info for each item\n * @returns promise array with file/folder information\n * @async\n */\nfunction list(path, options, progress) {\n  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;\n  return __awaiter(this, void 0, void 0, function* () {\n    // options skipped?\n    if (typeof options === 'function') {\n      progress = options;\n    }\n    // Setting default settings\n    const settings = {\n      mode: ((_a = options) === null || _a === void 0 ? void 0 : _a.mode) || exports.LIST,\n      recursive: ((_b = options) === null || _b === void 0 ? void 0 : _b.recursive) === undefined ? true : options.recursive,\n      stats: ((_c = options) === null || _c === void 0 ? void 0 : _c.stats) === undefined ? false : options.stats,\n      ignoreFolders: ((_d = options) === null || _d === void 0 ? void 0 : _d.ignoreFolders) === undefined ? true : options.ignoreFolders,\n      extensions: ((_e = options) === null || _e === void 0 ? void 0 : _e.extensions) === undefined ? false : options.extensions,\n      deep: ((_f = options) === null || _f === void 0 ? void 0 : _f.deep) === undefined ? false : options.deep,\n      realPath: ((_g = options) === null || _g === void 0 ? void 0 : _g.realPath) === undefined ? true : options.realPath,\n      normalizePath: ((_h = options) === null || _h === void 0 ? void 0 : _h.normalizePath) === undefined ? true : options.normalizePath,\n      include: ((_j = options) === null || _j === void 0 ? void 0 : _j.include) || [],\n      exclude: ((_k = options) === null || _k === void 0 ? void 0 : _k.exclude) || [],\n      readContent: ((_l = options) === null || _l === void 0 ? void 0 : _l.readContent) === undefined ? false : options.readContent,\n      encoding: ((_m = options) === null || _m === void 0 ? void 0 : _m.encoding) || undefined,\n    };\n    // Setting pathSimbol if normalizePath is disabled\n    if (settings.normalizePath === false) {\n      pathSimbol = exports.PATH.sep;\n    }\n    else {\n      pathSimbol = '/';\n    }\n    // Reading contents\n    return listDir(path, settings, progress);\n  });\n}\nexports.list = list;\n"
  },
  {
    "path": "server/libs/requestIp/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2022 Petar Bojinov - petarbojinov+github@gmail.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject 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, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/requestIp/index.js",
    "content": "// SOURCE: https://github.com/pbojinov/request-ip\n\n\"use strict\";\n\nfunction _typeof(obj) { if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\nvar is = require('./isJs');\n/**\n * Parse x-forwarded-for headers.\n *\n * @param {string} value - The value to be parsed.\n * @return {string|null} First known IP address, if any.\n */\n\n\nfunction getClientIpFromXForwardedFor(value) {\n  if (!is.existy(value)) {\n    return null;\n  }\n\n  if (is.not.string(value)) {\n    throw new TypeError(\"Expected a string, got \\\"\".concat(_typeof(value), \"\\\"\"));\n  } // x-forwarded-for may return multiple IP addresses in the format:\n  // \"client IP, proxy 1 IP, proxy 2 IP\"\n  // Therefore, the right-most IP address is the IP address of the most recent proxy\n  // and the left-most IP address is the IP address of the originating client.\n  // source: http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html\n  // Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)\n\n\n  var forwardedIps = value.split(',').map(function (e) {\n    var ip = e.trim();\n\n    if (ip.includes(':')) {\n      var splitted = ip.split(':'); // make sure we only use this if it's ipv4 (ip:port)\n\n      if (splitted.length === 2) {\n        return splitted[0];\n      }\n    }\n\n    return ip;\n  }); // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).\n  // Therefore taking the left-most IP address that is not unknown\n  // A Squid configuration directive can also set the value to \"unknown\" (http://www.squid-cache.org/Doc/config/forwarded_for/)\n\n  return forwardedIps.find(is.ip);\n}\n/**\n * Determine client IP address.\n *\n * @param req\n * @returns {string} ip - The IP address if known, defaulting to empty string if unknown.\n */\n\n\nfunction getClientIp(req) {\n  // Server is probably behind a proxy.\n  if (req.headers) {\n    // Standard headers used by Amazon EC2, Heroku, and others.\n    if (is.ip(req.headers['x-client-ip'])) {\n      return req.headers['x-client-ip'];\n    } // Load-balancers (AWS ELB) or proxies.\n\n\n    var xForwardedFor = getClientIpFromXForwardedFor(req.headers['x-forwarded-for']);\n\n    if (is.ip(xForwardedFor)) {\n      return xForwardedFor;\n    } // Cloudflare.\n    // @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-\n    // CF-Connecting-IP - applied to every request to the origin.\n\n\n    if (is.ip(req.headers['cf-connecting-ip'])) {\n      return req.headers['cf-connecting-ip'];\n    } // Fastly and Firebase hosting header (When forwared to cloud function)\n\n\n    if (is.ip(req.headers['fastly-client-ip'])) {\n      return req.headers['fastly-client-ip'];\n    } // Akamai and Cloudflare: True-Client-IP.\n\n\n    if (is.ip(req.headers['true-client-ip'])) {\n      return req.headers['true-client-ip'];\n    } // Default nginx proxy/fcgi; alternative to x-forwarded-for, used by some proxies.\n\n\n    if (is.ip(req.headers['x-real-ip'])) {\n      return req.headers['x-real-ip'];\n    } // (Rackspace LB and Riverbed's Stingray)\n    // http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address\n    // https://splash.riverbed.com/docs/DOC-1926\n\n\n    if (is.ip(req.headers['x-cluster-client-ip'])) {\n      return req.headers['x-cluster-client-ip'];\n    }\n\n    if (is.ip(req.headers['x-forwarded'])) {\n      return req.headers['x-forwarded'];\n    }\n\n    if (is.ip(req.headers['forwarded-for'])) {\n      return req.headers['forwarded-for'];\n    }\n\n    if (is.ip(req.headers.forwarded)) {\n      return req.headers.forwarded;\n    }\n  } // Remote address checks.\n\n\n  if (is.existy(req.connection)) {\n    if (is.ip(req.connection.remoteAddress)) {\n      return req.connection.remoteAddress;\n    }\n\n    if (is.existy(req.connection.socket) && is.ip(req.connection.socket.remoteAddress)) {\n      return req.connection.socket.remoteAddress;\n    }\n  }\n\n  if (is.existy(req.socket) && is.ip(req.socket.remoteAddress)) {\n    return req.socket.remoteAddress;\n  }\n\n  if (is.existy(req.info) && is.ip(req.info.remoteAddress)) {\n    return req.info.remoteAddress;\n  } // AWS Api Gateway + Lambda\n\n\n  if (is.existy(req.requestContext) && is.existy(req.requestContext.identity) && is.ip(req.requestContext.identity.sourceIp)) {\n    return req.requestContext.identity.sourceIp;\n  }\n\n  return null;\n}\n/**\n * Expose request IP as a middleware.\n *\n * @param {object} [options] - Configuration.\n * @param {string} [options.attributeName] - Name of attribute to augment request object with.\n * @return {*}\n */\n\n\nfunction mw(options) {\n  // Defaults.\n  var configuration = is.not.existy(options) ? {} : options; // Validation.\n\n  if (is.not.object(configuration)) {\n    throw new TypeError('Options must be an object!');\n  }\n\n  var attributeName = configuration.attributeName || 'clientIp';\n  return function (req, res, next) {\n    var ip = getClientIp(req);\n    Object.defineProperty(req, attributeName, {\n      get: function get() {\n        return ip;\n      },\n      configurable: true\n    });\n    next();\n  };\n}\n\nmodule.exports = {\n  getClientIpFromXForwardedFor: getClientIpFromXForwardedFor,\n  getClientIp: getClientIp,\n  mw: mw\n};\n"
  },
  {
    "path": "server/libs/requestIp/isJs.js",
    "content": "/*!\n * is.js 0.9.0\n * Author: Aras Atasaygin\n */\n(function (n, t) { if (typeof define === \"function\" && define.amd) { define(function () { return n.is = t() }) } else if (typeof exports === \"object\") { module.exports = t() } else { n.is = t() } })(this, function () { var n = {}; n.VERSION = \"0.8.0\"; n.not = {}; n.all = {}; n.any = {}; var t = Object.prototype.toString; var e = Array.prototype.slice; var r = Object.prototype.hasOwnProperty; function a(n) { return function () { return !n.apply(null, e.call(arguments)) } } function u(n) { return function () { var t = c(arguments); var e = t.length; for (var r = 0; r < e; r++) { if (!n.call(null, t[r])) { return false } } return true } } function o(n) { return function () { var t = c(arguments); var e = t.length; for (var r = 0; r < e; r++) { if (n.call(null, t[r])) { return true } } return false } } var i = { \"<\": function (n, t) { return n < t }, \"<=\": function (n, t) { return n <= t }, \">\": function (n, t) { return n > t }, \">=\": function (n, t) { return n >= t } }; function f(n, t) { var e = t + \"\"; var r = +(e.match(/\\d+/) || NaN); var a = e.match(/^[<>]=?|/)[0]; return i[a] ? i[a](n, r) : n == r || r !== r } function c(t) { var r = e.call(t); var a = r.length; if (a === 1 && n.array(r[0])) { r = r[0] } return r } n.arguments = function (n) { return t.call(n) === \"[object Arguments]\" || n != null && typeof n === \"object\" && \"callee\" in n }; n.array = Array.isArray || function (n) { return t.call(n) === \"[object Array]\" }; n.boolean = function (n) { return n === true || n === false || t.call(n) === \"[object Boolean]\" }; n.char = function (t) { return n.string(t) && t.length === 1 }; n.date = function (n) { return t.call(n) === \"[object Date]\" }; n.domNode = function (t) { return n.object(t) && t.nodeType > 0 }; n.error = function (n) { return t.call(n) === \"[object Error]\" }; n[\"function\"] = function (n) { return t.call(n) === \"[object Function]\" || typeof n === \"function\" }; n.json = function (n) { return t.call(n) === \"[object Object]\" }; n.nan = function (n) { return n !== n }; n[\"null\"] = function (n) { return n === null }; n.number = function (e) { return n.not.nan(e) && t.call(e) === \"[object Number]\" }; n.object = function (n) { return Object(n) === n }; n.regexp = function (n) { return t.call(n) === \"[object RegExp]\" }; n.sameType = function (e, r) { var a = t.call(e); if (a !== t.call(r)) { return false } if (a === \"[object Number]\") { return !n.any.nan(e, r) || n.all.nan(e, r) } return true }; n.sameType.api = [\"not\"]; n.string = function (n) { return t.call(n) === \"[object String]\" }; n.undefined = function (n) { return n === void 0 }; n.windowObject = function (n) { return n != null && typeof n === \"object\" && \"setInterval\" in n }; n.empty = function (t) { if (n.object(t)) { var e = Object.getOwnPropertyNames(t).length; if (e === 0 || e === 1 && n.array(t) || e === 2 && n.arguments(t)) { return true } return false } return t === \"\" }; n.existy = function (n) { return n != null }; n.falsy = function (n) { return !n }; n.truthy = a(n.falsy); n.above = function (t, e) { return n.all.number(t, e) && t > e }; n.above.api = [\"not\"]; n.decimal = function (t) { return n.number(t) && t % 1 !== 0 }; n.equal = function (t, e) { if (n.all.number(t, e)) { return t === e && 1 / t === 1 / e } if (n.all.string(t, e) || n.all.regexp(t, e)) { return \"\" + t === \"\" + e } if (n.all.boolean(t, e)) { return t === e } return false }; n.equal.api = [\"not\"]; n.even = function (t) { return n.number(t) && t % 2 === 0 }; n.finite = isFinite || function (t) { return n.not.infinite(t) && n.not.nan(t) }; n.infinite = function (n) { return n === Infinity || n === -Infinity }; n.integer = function (t) { return n.number(t) && t % 1 === 0 }; n.negative = function (t) { return n.number(t) && t < 0 }; n.odd = function (t) { return n.number(t) && t % 2 === 1 }; n.positive = function (t) { return n.number(t) && t > 0 }; n.under = function (t, e) { return n.all.number(t, e) && t < e }; n.under.api = [\"not\"]; n.within = function (t, e, r) { return n.all.number(t, e, r) && t > e && t < r }; n.within.api = [\"not\"]; var l = { affirmative: /^(?:1|t(?:rue)?|y(?:es)?|ok(?:ay)?)$/, alphaNumeric: /^[A-Za-z0-9]+$/, caPostalCode: /^(?!.*[DFIOQU])[A-VXY][0-9][A-Z]\\s?[0-9][A-Z][0-9]$/, creditCard: /^(?:(4[0-9]{12}(?:[0-9]{3})?)|(5[1-5][0-9]{14})|(6(?:011|5[0-9]{2})[0-9]{12})|(3[47][0-9]{13})|(3(?:0[0-5]|[68][0-9])[0-9]{11})|((?:2131|1800|35[0-9]{3})[0-9]{11}))$/, dateString: /^(1[0-2]|0?[1-9])([\\/-])(3[01]|[12][0-9]|0?[1-9])(?:\\2)(?:[0-9]{2})?[0-9]{2}$/, email: /^((([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+(\\.([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(\\\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.)+(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))$/i, eppPhone: /^\\+[0-9]{1,3}\\.[0-9]{4,14}(?:x.+)?$/, hexadecimal: /^(?:0x)?[0-9a-fA-F]+$/, hexColor: /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, ipv4: /^(?:(?:\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(?:\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])$/, ipv6: /^((?=.*::)(?!.*::.+::)(::)?([\\dA-F]{1,4}:(:|\\b)|){5}|([\\dA-F]{1,4}:){6})((([\\dA-F]{1,4}((?!\\3)::|:\\b|$))|(?!\\2\\3)){2}|(((2[0-4]|1\\d|[1-9])?\\d|25[0-5])\\.?\\b){4})$/i, nanpPhone: /^\\(?([0-9]{3})\\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, socialSecurityNumber: /^(?!000|666)[0-8][0-9]{2}-?(?!00)[0-9]{2}-?(?!0000)[0-9]{4}$/, timeString: /^(2[0-3]|[01]?[0-9]):([0-5]?[0-9]):([0-5]?[0-9])$/, ukPostCode: /^[A-Z]{1,2}[0-9RCHNQ][0-9A-Z]?\\s?[0-9][ABD-HJLNP-UW-Z]{2}$|^[A-Z]{2}-?[0-9]{4}$/, url: /^(?:(?:https?|ftp):\\/\\/)?(?:(?!(?:10|127)(?:\\.\\d{1,3}){3})(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:\\/\\S*)?$/i, usZipCode: /^[0-9]{5}(?:-[0-9]{4})?$/ }; function d(t, e) { n[t] = function (n) { return e[t].test(n) } } for (var s in l) { if (l.hasOwnProperty(s)) { d(s, l) } } n.ip = function (t) { return n.ipv4(t) || n.ipv6(t) }; n.capitalized = function (t) { if (n.not.string(t)) { return false } var e = t.split(\" \"); for (var r = 0; r < e.length; r++) { var a = e[r]; if (a.length) { var u = a.charAt(0); if (u !== u.toUpperCase()) { return false } } } return true }; n.endWith = function (t, e) { if (n.not.string(t)) { return false } e += \"\"; var r = t.length - e.length; return r >= 0 && t.indexOf(e, r) === r }; n.endWith.api = [\"not\"]; n.include = function (n, t) { return n.indexOf(t) > -1 }; n.include.api = [\"not\"]; n.lowerCase = function (t) { return n.string(t) && t === t.toLowerCase() }; n.palindrome = function (t) { if (n.not.string(t)) { return false } t = t.replace(/[^a-zA-Z0-9]+/g, \"\").toLowerCase(); var e = t.length - 1; for (var r = 0, a = Math.floor(e / 2); r <= a; r++) { if (t.charAt(r) !== t.charAt(e - r)) { return false } } return true }; n.space = function (t) { if (n.not.char(t)) { return false } var e = t.charCodeAt(0); return e > 8 && e < 14 || e === 32 }; n.startWith = function (t, e) { return n.string(t) && t.indexOf(e) === 0 }; n.startWith.api = [\"not\"]; n.upperCase = function (t) { return n.string(t) && t === t.toUpperCase() }; var F = [\"sunday\", \"monday\", \"tuesday\", \"wednesday\", \"thursday\", \"friday\", \"saturday\"]; var p = [\"january\", \"february\", \"march\", \"april\", \"may\", \"june\", \"july\", \"august\", \"september\", \"october\", \"november\", \"december\"]; n.day = function (t, e) { return n.date(t) && e.toLowerCase() === F[t.getDay()] }; n.day.api = [\"not\"]; n.dayLightSavingTime = function (n) { var t = new Date(n.getFullYear(), 0, 1); var e = new Date(n.getFullYear(), 6, 1); var r = Math.max(t.getTimezoneOffset(), e.getTimezoneOffset()); return n.getTimezoneOffset() < r }; n.future = function (t) { var e = new Date; return n.date(t) && t.getTime() > e.getTime() }; n.inDateRange = function (t, e, r) { if (n.not.date(t) || n.not.date(e) || n.not.date(r)) { return false } var a = t.getTime(); return a > e.getTime() && a < r.getTime() }; n.inDateRange.api = [\"not\"]; n.inLastMonth = function (t) { return n.inDateRange(t, new Date((new Date).setMonth((new Date).getMonth() - 1)), new Date) }; n.inLastWeek = function (t) { return n.inDateRange(t, new Date((new Date).setDate((new Date).getDate() - 7)), new Date) }; n.inLastYear = function (t) { return n.inDateRange(t, new Date((new Date).setFullYear((new Date).getFullYear() - 1)), new Date) }; n.inNextMonth = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setMonth((new Date).getMonth() + 1))) }; n.inNextWeek = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setDate((new Date).getDate() + 7))) }; n.inNextYear = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setFullYear((new Date).getFullYear() + 1))) }; n.leapYear = function (t) { return n.number(t) && (t % 4 === 0 && t % 100 !== 0 || t % 400 === 0) }; n.month = function (t, e) { return n.date(t) && e.toLowerCase() === p[t.getMonth()] }; n.month.api = [\"not\"]; n.past = function (t) { var e = new Date; return n.date(t) && t.getTime() < e.getTime() }; n.quarterOfYear = function (t, e) { return n.date(t) && n.number(e) && e === Math.floor((t.getMonth() + 3) / 3) }; n.quarterOfYear.api = [\"not\"]; n.today = function (t) { var e = new Date; var r = e.toDateString(); return n.date(t) && t.toDateString() === r }; n.tomorrow = function (t) { var e = new Date; var r = new Date(e.setDate(e.getDate() + 1)).toDateString(); return n.date(t) && t.toDateString() === r }; n.weekend = function (t) { return n.date(t) && (t.getDay() === 6 || t.getDay() === 0) }; n.weekday = a(n.weekend); n.year = function (t, e) { return n.date(t) && n.number(e) && e === t.getFullYear() }; n.year.api = [\"not\"]; n.yesterday = function (t) { var e = new Date; var r = new Date(e.setDate(e.getDate() - 1)).toDateString(); return n.date(t) && t.toDateString() === r }; var D = n.windowObject(typeof global == \"object\" && global) && global; var h = n.windowObject(typeof self == \"object\" && self) && self; var v = n.windowObject(typeof this == \"object\" && this) && this; var b = D || h || v || Function(\"return this\")(); var g = h && h.document; var m = b.is; var w = h && h.navigator; var y = (w && w.appVersion || \"\").toLowerCase(); var x = (w && w.userAgent || \"\").toLowerCase(); var A = (w && w.vendor || \"\").toLowerCase(); n.android = function () { return /android/.test(x) }; n.android.api = [\"not\"]; n.androidPhone = function () { return /android/.test(x) && /mobile/.test(x) }; n.androidPhone.api = [\"not\"]; n.androidTablet = function () { return /android/.test(x) && !/mobile/.test(x) }; n.androidTablet.api = [\"not\"]; n.blackberry = function () { return /blackberry/.test(x) || /bb10/.test(x) }; n.blackberry.api = [\"not\"]; n.chrome = function (n) { var t = /google inc/.test(A) ? x.match(/(?:chrome|crios)\\/(\\d+)/) : null; return t !== null && f(t[1], n) }; n.chrome.api = [\"not\"]; n.desktop = function () { return n.not.mobile() && n.not.tablet() }; n.desktop.api = [\"not\"]; n.edge = function (n) { var t = x.match(/edge\\/(\\d+)/); return t !== null && f(t[1], n) }; n.edge.api = [\"not\"]; n.firefox = function (n) { var t = x.match(/(?:firefox|fxios)\\/(\\d+)/); return t !== null && f(t[1], n) }; n.firefox.api = [\"not\"]; n.ie = function (n) { var t = x.match(/(?:msie |trident.+?; rv:)(\\d+)/); return t !== null && f(t[1], n) }; n.ie.api = [\"not\"]; n.ios = function () { return n.iphone() || n.ipad() || n.ipod() }; n.ios.api = [\"not\"]; n.ipad = function (n) { var t = x.match(/ipad.+?os (\\d+)/); return t !== null && f(t[1], n) }; n.ipad.api = [\"not\"]; n.iphone = function (n) { var t = x.match(/iphone(?:.+?os (\\d+))?/); return t !== null && f(t[1] || 1, n) }; n.iphone.api = [\"not\"]; n.ipod = function (n) { var t = x.match(/ipod.+?os (\\d+)/); return t !== null && f(t[1], n) }; n.ipod.api = [\"not\"]; n.linux = function () { return /linux/.test(y) }; n.linux.api = [\"not\"]; n.mac = function () { return /mac/.test(y) }; n.mac.api = [\"not\"]; n.mobile = function () { return n.iphone() || n.ipod() || n.androidPhone() || n.blackberry() || n.windowsPhone() }; n.mobile.api = [\"not\"]; n.offline = a(n.online); n.offline.api = [\"not\"]; n.online = function () { return !w || w.onLine === true }; n.online.api = [\"not\"]; n.opera = function (n) { var t = x.match(/(?:^opera.+?version|opr)\\/(\\d+)/); return t !== null && f(t[1], n) }; n.opera.api = [\"not\"]; n.phantom = function (n) { var t = x.match(/phantomjs\\/(\\d+)/); return t !== null && f(t[1], n) }; n.phantom.api = [\"not\"]; n.safari = function (n) { var t = x.match(/version\\/(\\d+).+?safari/); return t !== null && f(t[1], n) }; n.safari.api = [\"not\"]; n.tablet = function () { return n.ipad() || n.androidTablet() || n.windowsTablet() }; n.tablet.api = [\"not\"]; n.touchDevice = function () { return !!g && (\"ontouchstart\" in h || \"DocumentTouch\" in h && g instanceof DocumentTouch) }; n.touchDevice.api = [\"not\"]; n.windows = function () { return /win/.test(y) }; n.windows.api = [\"not\"]; n.windowsPhone = function () { return n.windows() && /phone/.test(x) }; n.windowsPhone.api = [\"not\"]; n.windowsTablet = function () { return n.windows() && n.not.windowsPhone() && /touch/.test(x) }; n.windowsTablet.api = [\"not\"]; n.propertyCount = function (t, e) { if (n.not.object(t) || n.not.number(e)) { return false } var a = 0; for (var u in t) { if (r.call(t, u) && ++a > e) { return false } } return a === e }; n.propertyCount.api = [\"not\"]; n.propertyDefined = function (t, e) { return n.object(t) && n.string(e) && e in t }; n.propertyDefined.api = [\"not\"]; n.inArray = function (t, e) { if (n.not.array(e)) { return false } for (var r = 0; r < e.length; r++) { if (e[r] === t) { return true } } return false }; n.inArray.api = [\"not\"]; n.sorted = function (t, e) { if (n.not.array(t)) { return false } var r = i[e] || i[\">=\"]; for (var a = 1; a < t.length; a++) { if (!r(t[a], t[a - 1])) { return false } } return true }; function j() { var t = n; for (var e in t) { if (r.call(t, e) && n[\"function\"](t[e])) { var i = t[e].api || [\"not\", \"all\", \"any\"]; for (var f = 0; f < i.length; f++) { if (i[f] === \"not\") { n.not[e] = a(n[e]) } if (i[f] === \"all\") { n.all[e] = u(n[e]) } if (i[f] === \"any\") { n.any[e] = o(n[e]) } } } } } j(); n.setNamespace = function () { b.is = m; return this }; n.setRegexp = function (n, t) { for (var e in l) { if (r.call(l, e) && t === e) { l[e] = n } } }; return n });"
  },
  {
    "path": "server/libs/rss/LICENSE",
    "content": "(The MIT License)\n\nCopyright (c) 2011-2017 Dylan Greene <dylang@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n'Software'), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/rss/index.js",
    "content": "// node-rss\n// SOURCE: https://github.com/dylang/node-rss\n// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/\n\n'use strict';\n\nvar mime = require('mime-types');\nvar xml = require('../xml');\nvar fs = require('fs');\n\n\nfunction ifTruePush(bool, array, data) {\n    if (bool) {\n        array.push(data);\n    }\n}\n\nfunction ifTruePushArray(bool, array, dataArray) {\n    if (!bool) {\n        return;\n    }\n\n    dataArray.forEach(function (item) {\n        ifTruePush(item, array, item);\n    });\n}\n\nfunction getSize(filename) {\n    if (typeof fs === 'undefined') {\n        return 0;\n    }\n    return fs.statSync(filename).size;\n}\n\nfunction generateXML(data) {\n\n    var channel = [];\n    channel.push({ title: { _cdata: data.title } });\n    channel.push({ description: { _cdata: data.description || data.title } });\n    channel.push({ link: data.site_url || 'https://github.com/advplyr/audiobookshelf' });\n    // image_url set?\n    if (data.image_url) {\n        channel.push({ image: [{ url: data.image_url }, { title: data.title }, { link: data.site_url }] });\n    }\n    channel.push({ generator: data.generator });\n    channel.push({ lastBuildDate: new Date().toUTCString() });\n\n    ifTruePush(data.feed_url, channel, { 'atom:link': { _attr: { href: data.feed_url, rel: 'self', type: 'application/rss+xml' } } });\n    ifTruePush(data.author, channel, { 'author': { _cdata: data.author } });\n    ifTruePush(data.pubDate, channel, { 'pubDate': new Date(data.pubDate).toGMTString() });\n    ifTruePush(data.copyright, channel, { 'copyright': { _cdata: data.copyright } });\n    ifTruePush(data.language, channel, { 'language': { _cdata: data.language } });\n    ifTruePush(data.managingEditor, channel, { 'managingEditor': { _cdata: data.managingEditor } });\n    ifTruePush(data.webMaster, channel, { 'webMaster': { _cdata: data.webMaster } });\n    ifTruePush(data.docs, channel, { 'docs': data.docs });\n    ifTruePush(data.ttl, channel, { 'ttl': data.ttl });\n    ifTruePush(data.hub, channel, { 'atom:link': { _attr: { href: data.hub, rel: 'hub' } } });\n\n    if (data.categories) {\n        data.categories.forEach(function (category) {\n            ifTruePush(category, channel, { category: { _cdata: category } });\n        });\n    }\n\n    ifTruePushArray(data.custom_elements, channel, data.custom_elements);\n\n    data.items.forEach(function (item) {\n        var item_values = [\n            { title: { _cdata: item.title } }\n        ];\n        ifTruePush(item.description, item_values, { description: { _cdata: item.description } });\n        ifTruePush(item.url, item_values, { link: item.url });\n        ifTruePush(item.link || item.guid || item.title, item_values, { guid: [{ _attr: { isPermaLink: !item.guid && !!item.url } }, item.guid || item.url || item.title] });\n\n        item.categories.forEach(function (category) {\n            ifTruePush(category, item_values, { category: { _cdata: category } });\n        });\n\n        ifTruePush(item.author || data.author, item_values, { 'dc:creator': { _cdata: item.author || data.author } });\n        ifTruePush(item.date, item_values, { pubDate: new Date(item.date).toGMTString() });\n\n        //Set GeoRSS to true if lat and long are set\n        data.geoRSS = data.geoRSS || (item.lat && item.long);\n        ifTruePush(item.lat, item_values, { 'geo:lat': item.lat });\n        ifTruePush(item.long, item_values, { 'geo:long': item.long });\n\n        if (item.enclosure && item.enclosure.url) {\n            if (item.enclosure.file) {\n                item_values.push({\n                    enclosure: {\n                        _attr: {\n                            url: item.enclosure.url,\n                            length: item.enclosure.size || getSize(item.enclosure.file),\n                            type: item.enclosure.type || mime.lookup(item.enclosure.file)\n                        }\n                    }\n                });\n            } else {\n                item_values.push({\n                    enclosure: {\n                        _attr: {\n                            url: item.enclosure.url,\n                            length: item.enclosure.size || 0,\n                            type: item.enclosure.type || mime.lookup(item.enclosure.url)\n                        }\n                    }\n                });\n            }\n        }\n\n        ifTruePushArray(item.custom_elements, item_values, item.custom_elements);\n\n        channel.push({ item: item_values });\n\n    });\n\n    //set up the attributes for the RSS feed.\n    var _attr = {\n        'xmlns:dc': 'http://purl.org/dc/elements/1.1/',\n        'xmlns:content': 'http://purl.org/rss/1.0/modules/content/',\n        'xmlns:atom': 'http://www.w3.org/2005/Atom',\n        version: '2.0'\n    };\n\n    Object.keys(data.custom_namespaces).forEach(function (name) {\n        _attr['xmlns:' + name] = data.custom_namespaces[name];\n    });\n\n    //only add namespace if GeoRSS is true\n    if (data.geoRSS) {\n        _attr['xmlns:geo'] = 'http://www.w3.org/2003/01/geo/wgs84_pos#';\n    }\n\n    return {\n        rss: [\n            { _attr: _attr },\n            { channel: channel }\n        ]\n    };\n}\n\nfunction RSS(options, items) {\n    options = options || {};\n\n    this.title = options.title || 'Untitled RSS Feed';\n    this.description = options.description || '';\n    this.generator = options.generator || 'RSS for Node';\n    this.feed_url = options.feed_url;\n    this.site_url = options.site_url;\n    this.image_url = options.image_url;\n    this.author = options.author;\n    this.categories = options.categories;\n    this.pubDate = options.pubDate;\n    this.hub = options.hub;\n    this.docs = options.docs;\n    this.copyright = options.copyright;\n    this.language = options.language;\n    this.managingEditor = options.managingEditor;\n    this.webMaster = options.webMaster;\n    this.ttl = options.ttl;\n    //option to return feed as GeoRSS is set automatically if feed.lat/long is used\n    this.geoRSS = options.geoRSS || false;\n    this.custom_namespaces = options.custom_namespaces || {};\n    this.custom_elements = options.custom_elements || [];\n    this.items = items || [];\n\n    this.item = function (options) {\n        options = options || {};\n        var item = {\n            title: options.title || 'No title',\n            description: options.description || '',\n            url: options.url,\n            guid: options.guid,\n            categories: options.categories || [],\n            author: options.author,\n            date: options.date,\n            lat: options.lat,\n            long: options.long,\n            enclosure: options.enclosure || false,\n            custom_elements: options.custom_elements || []\n        };\n\n        this.items.push(item);\n        return this;\n    };\n\n    this.xml = function (indent) {\n        return '<?xml version=\"1.0\" encoding=\"UTF-8\"?>' +\n            xml(generateXML(this), indent);\n    };\n}\n\nmodule.exports = RSS;"
  },
  {
    "path": "server/libs/sanitizeHtml/LICENSE",
    "content": "Copyright (c) 2013, 2014, 2015 P'unk Avenue LLC\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/sanitizeHtml/index.js",
    "content": "/*\n  sanitize-html (Apostrophe Technologies)\n  SOURCE: https://github.com/apostrophecms/sanitize-html\n  LICENSE: https://github.com/apostrophecms/sanitize-html/blob/main/LICENSE\n\n  Modified for audiobookshelf\n*/\n\nconst htmlparser = require('htmlparser2');\n\n// ABS UPDATE: Packages not necessary\n// SOURCE: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js\nfunction escapeStringRegexp(string) {\n  if (typeof string !== 'string') {\n    throw new TypeError('Expected a string');\n  }\n\n  // Escape characters with special meaning either inside or outside character sets.\n  // Use a simple backslash escape when it’s always valid, and a `\\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.\n  return string\n    .replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&')\n    .replace(/-/g, '\\\\x2d');\n}\n\n// SOURCE: https://github.com/jonschlinkert/is-plain-object/blob/master/is-plain-object.js\nfunction isObject(o) {\n  return Object.prototype.toString.call(o) === '[object Object]';\n}\n\nfunction isPlainObject(o) {\n  var ctor, prot;\n\n  if (isObject(o) === false) return false;\n\n  // If has modified constructor\n  ctor = o.constructor;\n  if (ctor === undefined) return true;\n\n  // If has modified prototype\n  prot = ctor.prototype;\n  if (isObject(prot) === false) return false;\n\n  // If constructor does not have an Object-specific method\n  if (prot.hasOwnProperty('isPrototypeOf') === false) {\n    return false;\n  }\n\n  // Most likely a plain Object\n  return true;\n};\n\n\nconst mediaTags = [\n  'img', 'audio', 'video', 'picture', 'svg',\n  'object', 'map', 'iframe', 'embed'\n];\n// Tags that are inherently vulnerable to being used in XSS attacks.\nconst vulnerableTags = ['script', 'style'];\n\nfunction each(obj, cb) {\n  if (obj) {\n    Object.keys(obj).forEach(function (key) {\n      cb(obj[key], key);\n    });\n  }\n}\n\n// Avoid false positives with .__proto__, .hasOwnProperty, etc.\nfunction has(obj, key) {\n  return ({}).hasOwnProperty.call(obj, key);\n}\n\nfunction isEmptyObject(obj) {\n  for (const key in obj) {\n    if (has(obj, key)) {\n      return false;\n    }\n  }\n  return true;\n}\n\nmodule.exports = sanitizeHtml;\n\n// A valid attribute name.\n// We use a tolerant definition based on the set of strings defined by\n// html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state\n// and html.spec.whatwg.org/multipage/parsing.html#attribute-name-state .\n// The characters accepted are ones which can be appended to the attribute\n// name buffer without triggering a parse error:\n//   * unexpected-equals-sign-before-attribute-name\n//   * unexpected-null-character\n//   * unexpected-character-in-attribute-name\n// We exclude the empty string because it's impossible to get to the after\n// attribute name state with an empty attribute name buffer.\nconst VALID_HTML_ATTRIBUTE_NAME = /^[^\\0\\t\\n\\f\\r /<=>]+$/;\n\n// Ignore the _recursing flag; it's there for recursive\n// invocation as a guard against this exploit:\n// https://github.com/fb55/htmlparser2/issues/105\n\nfunction sanitizeHtml(html, options, _recursing) {\n  if (html == null) {\n    return '';\n  }\n\n  let result = '';\n  // Used for hot swapping the result variable with an empty string in order to \"capture\" the text written to it.\n  let tempResult = '';\n\n  function Frame(tag, attribs) {\n    const that = this;\n    this.tag = tag;\n    this.attribs = attribs || {};\n    this.tagPosition = result.length;\n    this.text = ''; // Node inner text\n    this.mediaChildren = [];\n\n    this.updateParentNodeText = function () {\n      if (stack.length) {\n        const parentFrame = stack[stack.length - 1];\n        parentFrame.text += that.text;\n      }\n    };\n\n    this.updateParentNodeMediaChildren = function () {\n      if (stack.length && mediaTags.includes(this.tag)) {\n        const parentFrame = stack[stack.length - 1];\n        parentFrame.mediaChildren.push(this.tag);\n      }\n    };\n  }\n\n  options = Object.assign({}, sanitizeHtml.defaults, options);\n  options.parser = Object.assign({}, htmlParserDefaults, options.parser);\n\n  // vulnerableTags\n  vulnerableTags.forEach(function (tag) {\n    if (\n      options.allowedTags && options.allowedTags.indexOf(tag) > -1 &&\n      !options.allowVulnerableTags\n    ) {\n      console.warn(`\\n\\n⚠️ Your \\`allowedTags\\` option includes, \\`${tag}\\`, which is inherently\\nvulnerable to XSS attacks. Please remove it from \\`allowedTags\\`.\\nOr, to disable this warning, add the \\`allowVulnerableTags\\` option\\nand ensure you are accounting for this risk.\\n\\n`);\n    }\n  });\n\n  // Tags that contain something other than HTML, or where discarding\n  // the text when the tag is disallowed makes sense for other reasons.\n  // If we are not allowing these tags, we should drop their content too.\n  // For other tags you would drop the tag but keep its content.\n  const nonTextTagsArray = options.nonTextTags || [\n    'script',\n    'style',\n    'textarea',\n    'option'\n  ];\n  let allowedAttributesMap;\n  let allowedAttributesGlobMap;\n  if (options.allowedAttributes) {\n    allowedAttributesMap = {};\n    allowedAttributesGlobMap = {};\n    each(options.allowedAttributes, function (attributes, tag) {\n      allowedAttributesMap[tag] = [];\n      const globRegex = [];\n      attributes.forEach(function (obj) {\n        if (typeof obj === 'string' && obj.indexOf('*') >= 0) {\n          globRegex.push(escapeStringRegexp(obj).replace(/\\\\\\*/g, '.*'));\n        } else {\n          allowedAttributesMap[tag].push(obj);\n        }\n      });\n      if (globRegex.length) {\n        allowedAttributesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');\n      }\n    });\n  }\n  const allowedClassesMap = {};\n  const allowedClassesGlobMap = {};\n  const allowedClassesRegexMap = {};\n  each(options.allowedClasses, function (classes, tag) {\n    // Implicitly allows the class attribute\n    if (allowedAttributesMap) {\n      if (!has(allowedAttributesMap, tag)) {\n        allowedAttributesMap[tag] = [];\n      }\n      allowedAttributesMap[tag].push('class');\n    }\n\n    allowedClassesMap[tag] = [];\n    allowedClassesRegexMap[tag] = [];\n    const globRegex = [];\n    classes.forEach(function (obj) {\n      if (typeof obj === 'string' && obj.indexOf('*') >= 0) {\n        globRegex.push(escapeStringRegexp(obj).replace(/\\\\\\*/g, '.*'));\n      } else if (obj instanceof RegExp) {\n        allowedClassesRegexMap[tag].push(obj);\n      } else {\n        allowedClassesMap[tag].push(obj);\n      }\n    });\n    if (globRegex.length) {\n      allowedClassesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');\n    }\n  });\n\n  const transformTagsMap = {};\n  let transformTagsAll;\n  each(options.transformTags, function (transform, tag) {\n    let transFun;\n    if (typeof transform === 'function') {\n      transFun = transform;\n    } else if (typeof transform === 'string') {\n      transFun = sanitizeHtml.simpleTransform(transform);\n    }\n    if (tag === '*') {\n      transformTagsAll = transFun;\n    } else {\n      transformTagsMap[tag] = transFun;\n    }\n  });\n\n  let depth;\n  let stack;\n  let skipMap;\n  let transformMap;\n  let skipText;\n  let skipTextDepth;\n  let addedText = false;\n\n  initializeState();\n\n  const parser = new htmlparser.Parser({\n    onopentag: function (name, attribs) {\n      // If `enforceHtmlBoundary` is `true` and this has found the opening\n      // `html` tag, reset the state.\n      if (options.enforceHtmlBoundary && name === 'html') {\n        initializeState();\n      }\n\n      if (skipText) {\n        skipTextDepth++;\n        return;\n      }\n      const frame = new Frame(name, attribs);\n      stack.push(frame);\n\n      let skip = false;\n      const hasText = !!frame.text;\n      let transformedTag;\n      if (has(transformTagsMap, name)) {\n        transformedTag = transformTagsMap[name](name, attribs);\n\n        frame.attribs = attribs = transformedTag.attribs;\n\n        if (transformedTag.text !== undefined) {\n          frame.innerText = transformedTag.text;\n        }\n\n        if (name !== transformedTag.tagName) {\n          frame.name = name = transformedTag.tagName;\n          transformMap[depth] = transformedTag.tagName;\n        }\n      }\n      if (transformTagsAll) {\n        transformedTag = transformTagsAll(name, attribs);\n\n        frame.attribs = attribs = transformedTag.attribs;\n        if (name !== transformedTag.tagName) {\n          frame.name = name = transformedTag.tagName;\n          transformMap[depth] = transformedTag.tagName;\n        }\n      }\n\n      if ((options.allowedTags && options.allowedTags.indexOf(name) === -1) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) {\n        skip = true;\n        skipMap[depth] = true;\n        if (options.disallowedTagsMode === 'discard') {\n          if (nonTextTagsArray.indexOf(name) !== -1) {\n            skipText = true;\n            skipTextDepth = 1;\n          }\n        }\n        skipMap[depth] = true;\n      }\n      depth++;\n      if (skip) {\n        if (options.disallowedTagsMode === 'discard') {\n          // We want the contents but not this tag\n          return;\n        }\n        tempResult = result;\n        result = '';\n      }\n      result += '<' + name;\n\n      if (name === 'script') {\n        if (options.allowedScriptHostnames || options.allowedScriptDomains) {\n          frame.innerText = '';\n        }\n      }\n\n      if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) {\n        each(attribs, function (value, a) {\n          if (!VALID_HTML_ATTRIBUTE_NAME.test(a)) {\n            // This prevents part of an attribute name in the output from being\n            // interpreted as the end of an attribute, or end of a tag.\n            delete frame.attribs[a];\n            return;\n          }\n          let parsed;\n          // check allowedAttributesMap for the element and attribute and modify the value\n          // as necessary if there are specific values defined.\n          let passedAllowedAttributesMapCheck = false;\n          if (!allowedAttributesMap ||\n            (has(allowedAttributesMap, name) && allowedAttributesMap[name].indexOf(a) !== -1) ||\n            (allowedAttributesMap['*'] && allowedAttributesMap['*'].indexOf(a) !== -1) ||\n            (has(allowedAttributesGlobMap, name) && allowedAttributesGlobMap[name].test(a)) ||\n            (allowedAttributesGlobMap['*'] && allowedAttributesGlobMap['*'].test(a))) {\n            passedAllowedAttributesMapCheck = true;\n          } else if (allowedAttributesMap && allowedAttributesMap[name]) {\n            for (const o of allowedAttributesMap[name]) {\n              if (isPlainObject(o) && o.name && (o.name === a)) {\n                passedAllowedAttributesMapCheck = true;\n                let newValue = '';\n                if (o.multiple === true) {\n                  // verify the values that are allowed\n                  const splitStrArray = value.split(' ');\n                  for (const s of splitStrArray) {\n                    if (o.values.indexOf(s) !== -1) {\n                      if (newValue === '') {\n                        newValue = s;\n                      } else {\n                        newValue += ' ' + s;\n                      }\n                    }\n                  }\n                } else if (o.values.indexOf(value) >= 0) {\n                  // verified an allowed value matches the entire attribute value\n                  newValue = value;\n                }\n                value = newValue;\n              }\n            }\n          }\n          if (passedAllowedAttributesMapCheck) {\n            if (options.allowedSchemesAppliedToAttributes.indexOf(a) !== -1) {\n              if (naughtyHref(name, value)) {\n                delete frame.attribs[a];\n                return;\n              }\n            }\n\n            if (name === 'script' && a === 'src') {\n\n              let allowed = true;\n\n              try {\n                const parsed = new URL(value);\n\n                if (options.allowedScriptHostnames || options.allowedScriptDomains) {\n                  const allowedHostname = (options.allowedScriptHostnames || []).find(function (hostname) {\n                    return hostname === parsed.hostname;\n                  });\n                  const allowedDomain = (options.allowedScriptDomains || []).find(function (domain) {\n                    return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);\n                  });\n                  allowed = allowedHostname || allowedDomain;\n                }\n              } catch (e) {\n                allowed = false;\n              }\n\n              if (!allowed) {\n                delete frame.attribs[a];\n                return;\n              }\n            }\n\n            if (name === 'iframe' && a === 'src') {\n              let allowed = true;\n              try {\n                // Chrome accepts \\ as a substitute for / in the // at the\n                // start of a URL, so rewrite accordingly to prevent exploit.\n                // Also drop any whitespace at that point in the URL\n                value = value.replace(/^(\\w+:)?\\s*[\\\\/]\\s*[\\\\/]/, '$1//');\n                if (value.startsWith('relative:')) {\n                  // An attempt to exploit our workaround for base URLs being\n                  // mandatory for relative URL validation in the WHATWG\n                  // URL parser, reject it\n                  throw new Error('relative: exploit attempt');\n                }\n                // naughtyHref is in charge of whether protocol relative URLs\n                // are cool. Here we are concerned just with allowed hostnames and\n                // whether to allow relative URLs.\n                //\n                // Build a placeholder \"base URL\" against which any reasonable\n                // relative URL may be parsed successfully\n                let base = 'relative://relative-site';\n                for (let i = 0; (i < 100); i++) {\n                  base += `/${i}`;\n                }\n                const parsed = new URL(value, base);\n                const isRelativeUrl = parsed && parsed.hostname === 'relative-site' && parsed.protocol === 'relative:';\n                if (isRelativeUrl) {\n                  // default value of allowIframeRelativeUrls is true\n                  // unless allowedIframeHostnames or allowedIframeDomains specified\n                  allowed = has(options, 'allowIframeRelativeUrls')\n                    ? options.allowIframeRelativeUrls\n                    : (!options.allowedIframeHostnames && !options.allowedIframeDomains);\n                } else if (options.allowedIframeHostnames || options.allowedIframeDomains) {\n                  const allowedHostname = (options.allowedIframeHostnames || []).find(function (hostname) {\n                    return hostname === parsed.hostname;\n                  });\n                  const allowedDomain = (options.allowedIframeDomains || []).find(function (domain) {\n                    return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);\n                  });\n                  allowed = allowedHostname || allowedDomain;\n                }\n              } catch (e) {\n                // Unparseable iframe src\n                allowed = false;\n              }\n              if (!allowed) {\n                delete frame.attribs[a];\n                return;\n              }\n            }\n            if (a === 'srcset') {\n              delete frame.attribs[a];\n\n              // ABS UPDATE: srcset not necessary\n              // try {\n              //   parsed = parseSrcset(value);\n              //   parsed.forEach(function (value) {\n              //     if (naughtyHref('srcset', value.url)) {\n              //       value.evil = true;\n              //     }\n              //   });\n              //   parsed = filter(parsed, function (v) {\n              //     return !v.evil;\n              //   });\n              //   if (!parsed.length) {\n              //     delete frame.attribs[a];\n              //     return;\n              //   } else {\n              //     value = stringifySrcset(filter(parsed, function (v) {\n              //       return !v.evil;\n              //     }));\n              //     frame.attribs[a] = value;\n              //   }\n              // } catch (e) {\n              //   // Unparseable srcset\n              //   delete frame.attribs[a];\n              //   return;\n              // }\n            }\n            if (a === 'class') {\n              const allowedSpecificClasses = allowedClassesMap[name];\n              const allowedWildcardClasses = allowedClassesMap['*'];\n              const allowedSpecificClassesGlob = allowedClassesGlobMap[name];\n              const allowedSpecificClassesRegex = allowedClassesRegexMap[name];\n              const allowedWildcardClassesGlob = allowedClassesGlobMap['*'];\n              const allowedClassesGlobs = [\n                allowedSpecificClassesGlob,\n                allowedWildcardClassesGlob\n              ]\n                .concat(allowedSpecificClassesRegex)\n                .filter(function (t) {\n                  return t;\n                });\n              if (allowedSpecificClasses && allowedWildcardClasses) {\n                // ABS UPDATE: classes and wildcard classes not necessary now\n                // value = filterClasses(value, deepmerge(allowedSpecificClasses, allowedWildcardClasses), allowedClassesGlobs);\n              } else {\n                value = filterClasses(value, allowedSpecificClasses || allowedWildcardClasses, allowedClassesGlobs);\n              }\n              if (!value.length) {\n                delete frame.attribs[a];\n                return;\n              }\n            }\n            if (a === 'style') {\n              delete frame.attribs[a];\n\n              // ABS UPDATE: Styles not necessary\n              // try {\n              //   const abstractSyntaxTree = postcssParse(name + ' {' + value + '}');\n              //   const filteredAST = filterCss(abstractSyntaxTree, options.allowedStyles);\n\n              //   value = stringifyStyleAttributes(filteredAST);\n\n              //   if (value.length === 0) {\n              //     delete frame.attribs[a];\n              //     return;\n              //   }\n              // } catch (e) {\n              //   delete frame.attribs[a];\n              //   return;\n              // }\n            }\n            result += ' ' + a;\n            if (value && value.length) {\n              result += '=\"' + escapeHtml(value, true) + '\"';\n            }\n          } else {\n            delete frame.attribs[a];\n          }\n        });\n      }\n      if (options.selfClosing.indexOf(name) !== -1) {\n        result += ' />';\n      } else {\n        result += '>';\n        if (frame.innerText && !hasText && !options.textFilter) {\n          result += escapeHtml(frame.innerText);\n          addedText = true;\n        }\n      }\n      if (skip) {\n        result = tempResult + escapeHtml(result);\n        tempResult = '';\n      }\n    },\n    ontext: function (text) {\n      if (skipText) {\n        return;\n      }\n      const lastFrame = stack[stack.length - 1];\n      let tag;\n\n      if (lastFrame) {\n        tag = lastFrame.tag;\n        // If inner text was set by transform function then let's use it\n        text = lastFrame.innerText !== undefined ? lastFrame.innerText : text;\n      }\n\n      if (options.disallowedTagsMode === 'discard' && ((tag === 'script') || (tag === 'style'))) {\n        // htmlparser2 gives us these as-is. Escaping them ruins the content. Allowing\n        // script tags is, by definition, game over for XSS protection, so if that's\n        // your concern, don't allow them. The same is essentially true for style tags\n        // which have their own collection of XSS vectors.\n        result += text;\n      } else {\n        const escaped = escapeHtml(text, false);\n        if (options.textFilter && !addedText) {\n          result += options.textFilter(escaped, tag);\n        } else if (!addedText) {\n          result += escaped;\n        }\n      }\n      if (stack.length) {\n        const frame = stack[stack.length - 1];\n        frame.text += text;\n      }\n    },\n    onclosetag: function (name) {\n\n      if (skipText) {\n        skipTextDepth--;\n        if (!skipTextDepth) {\n          skipText = false;\n        } else {\n          return;\n        }\n      }\n\n      const frame = stack.pop();\n      if (!frame) {\n        // Do not crash on bad markup\n        return;\n      }\n      skipText = options.enforceHtmlBoundary ? name === 'html' : false;\n      depth--;\n      const skip = skipMap[depth];\n      if (skip) {\n        delete skipMap[depth];\n        if (options.disallowedTagsMode === 'discard') {\n          frame.updateParentNodeText();\n          return;\n        }\n        tempResult = result;\n        result = '';\n      }\n\n      if (transformMap[depth]) {\n        name = transformMap[depth];\n        delete transformMap[depth];\n      }\n\n      if (options.exclusiveFilter && options.exclusiveFilter(frame)) {\n        result = result.substr(0, frame.tagPosition);\n        return;\n      }\n\n      frame.updateParentNodeMediaChildren();\n      frame.updateParentNodeText();\n\n      if (options.selfClosing.indexOf(name) !== -1) {\n        // Already output />\n        if (skip) {\n          result = tempResult;\n          tempResult = '';\n        }\n        return;\n      }\n\n      result += '</' + name + '>';\n      if (skip) {\n        result = tempResult + escapeHtml(result);\n        tempResult = '';\n      }\n      addedText = false;\n    }\n  }, options.parser);\n  parser.write(html);\n  parser.end();\n\n  return result;\n\n  function initializeState() {\n    result = '';\n    depth = 0;\n    stack = [];\n    skipMap = {};\n    transformMap = {};\n    skipText = false;\n    skipTextDepth = 0;\n  }\n\n  function escapeHtml(s, quote) {\n    if (typeof (s) !== 'string') {\n      s = s + '';\n    }\n    if (options.parser.decodeEntities) {\n      s = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n      if (quote) {\n        s = s.replace(/\"/g, '&quot;');\n      }\n    }\n    // TODO: this is inadequate because it will pass `&0;`. This approach\n    // will not work, each & must be considered with regard to whether it\n    // is followed by a 100% syntactically valid entity or not, and escaped\n    // if it is not. If this bothers you, don't set parser.decodeEntities\n    // to false. (The default is true.)\n    s = s.replace(/&(?![a-zA-Z0-9#]{1,20};)/g, '&amp;') // Match ampersands not part of existing HTML entity\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;');\n    if (quote) {\n      s = s.replace(/\"/g, '&quot;');\n    }\n    return s;\n  }\n\n  function naughtyHref(name, href) {\n    // Browsers ignore character codes of 32 (space) and below in a surprising\n    // number of situations. Start reading here:\n    // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab\n    // eslint-disable-next-line no-control-regex\n    href = href.replace(/[\\x00-\\x20]+/g, '');\n    // Clobber any comments in URLs, which the browser might\n    // interpret inside an XML data island, allowing\n    // a javascript: URL to be snuck through\n    href = href.replace(/<!--.*?-->/g, '');\n    // Case insensitive so we don't get faked out by JAVASCRIPT #1\n    // Allow more characters after the first so we don't get faked\n    // out by certain schemes browsers accept\n    const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\\-+]*):/);\n    if (!matches) {\n      // Protocol-relative URL starting with any combination of '/' and '\\'\n      if (href.match(/^[/\\\\]{2}/)) {\n        return !options.allowProtocolRelative;\n      }\n\n      // No scheme\n      return false;\n    }\n    const scheme = matches[1].toLowerCase();\n\n    if (has(options.allowedSchemesByTag, name)) {\n      return options.allowedSchemesByTag[name].indexOf(scheme) === -1;\n    }\n\n    return !options.allowedSchemes || options.allowedSchemes.indexOf(scheme) === -1;\n  }\n\n  function filterClasses(classes, allowed, allowedGlobs) {\n    if (!allowed) {\n      // The class attribute is allowed without filtering on this tag\n      return classes;\n    }\n    classes = classes.split(/\\s+/);\n    return classes.filter(function (clss) {\n      return allowed.indexOf(clss) !== -1 || allowedGlobs.some(function (glob) {\n        return glob.test(clss);\n      });\n    }).join(' ');\n  }\n}\n\n// Defaults are accessible to you so that you can use them as a starting point\n// programmatically if you wish\n\nconst htmlParserDefaults = {\n  decodeEntities: true\n};\nsanitizeHtml.defaults = {\n  allowedTags: [\n    // Sections derived from MDN element categories and limited to the more\n    // benign categories.\n    // https://developer.mozilla.org/en-US/docs/Web/HTML/Element\n    // Content sectioning\n    'address', 'article', 'aside', 'footer', 'header',\n    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',\n    'main', 'nav', 'section',\n    // Text content\n    'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure',\n    'hr', 'li', 'main', 'ol', 'p', 'pre', 'ul',\n    // Inline text semantics\n    'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn',\n    'em', 'i', 'kbd', 'mark', 'q',\n    'rb', 'rp', 'rt', 'rtc', 'ruby',\n    's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',\n    // Table content\n    'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th',\n    'thead', 'tr'\n  ],\n  disallowedTagsMode: 'discard',\n  allowedAttributes: {\n    a: ['href', 'name', 'target'],\n    // We don't currently allow img itself by default, but\n    // these attributes would make sense if we did.\n    img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading']\n  },\n  // Lots of these won't come up by default because we don't allow them\n  selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],\n  // URL schemes we permit\n  allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],\n  allowedSchemesByTag: {},\n  allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],\n  allowProtocolRelative: true,\n  enforceHtmlBoundary: false\n};\n\nsanitizeHtml.simpleTransform = function (newTagName, newAttribs, merge) {\n  merge = (merge === undefined) ? true : merge;\n  newAttribs = newAttribs || {};\n\n  return function (tagName, attribs) {\n    let attrib;\n    if (merge) {\n      for (attrib in newAttribs) {\n        attribs[attrib] = newAttribs[attrib];\n      }\n    } else {\n      attribs = newAttribs;\n    }\n\n    return {\n      tagName: newTagName,\n      attribs: attribs\n    };\n  };\n};"
  },
  {
    "path": "server/libs/streamsearch/LICENSE",
    "content": "Copyright Brian White. All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to\ndeal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or\nsell copies 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\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\nIN THE SOFTWARE."
  },
  {
    "path": "server/libs/streamsearch/index.js",
    "content": "'use strict';\n\n//\n// used by busboy\n// Source: https://github.com/mscdex/streamsearch\n//\n\n/*\n  Based heavily on the Streaming Boyer-Moore-Horspool C++ implementation\n  by Hongli Lai at: https://github.com/FooBarWidget/boyer-moore-horspool\n*/\nfunction memcmp(buf1, pos1, buf2, pos2, num) {\n  for (let i = 0; i < num; ++i) {\n    if (buf1[pos1 + i] !== buf2[pos2 + i])\n      return false;\n  }\n  return true;\n}\n\nclass SBMH {\n  constructor(needle, cb) {\n    if (typeof cb !== 'function')\n      throw new Error('Missing match callback');\n\n    if (typeof needle === 'string')\n      needle = Buffer.from(needle);\n    else if (!Buffer.isBuffer(needle))\n      throw new Error(`Expected Buffer for needle, got ${typeof needle}`);\n\n    const needleLen = needle.length;\n\n    this.maxMatches = Infinity;\n    this.matches = 0;\n\n    this._cb = cb;\n    this._lookbehindSize = 0;\n    this._needle = needle;\n    this._bufPos = 0;\n\n    this._lookbehind = Buffer.allocUnsafe(needleLen);\n\n    // Initialize occurrence table.\n    this._occ = [\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen, needleLen, needleLen,\n      needleLen, needleLen, needleLen, needleLen\n    ];\n\n    // Populate occurrence table with analysis of the needle, ignoring the last\n    // letter.\n    if (needleLen > 1) {\n      for (let i = 0; i < needleLen - 1; ++i)\n        this._occ[needle[i]] = needleLen - 1 - i;\n    }\n  }\n\n  reset() {\n    this.matches = 0;\n    this._lookbehindSize = 0;\n    this._bufPos = 0;\n  }\n\n  push(chunk, pos) {\n    let result;\n    if (!Buffer.isBuffer(chunk))\n      chunk = Buffer.from(chunk, 'latin1');\n    const chunkLen = chunk.length;\n    this._bufPos = pos || 0;\n    while (result !== chunkLen && this.matches < this.maxMatches)\n      result = feed(this, chunk);\n    return result;\n  }\n\n  destroy() {\n    const lbSize = this._lookbehindSize;\n    if (lbSize)\n      this._cb(false, this._lookbehind, 0, lbSize, false);\n    this.reset();\n  }\n}\n\nfunction feed(self, data) {\n  const len = data.length;\n  const needle = self._needle;\n  const needleLen = needle.length;\n\n  // Positive: points to a position in `data`\n  //           pos == 3 points to data[3]\n  // Negative: points to a position in the lookbehind buffer\n  //           pos == -2 points to lookbehind[lookbehindSize - 2]\n  let pos = -self._lookbehindSize;\n  const lastNeedleCharPos = needleLen - 1;\n  const lastNeedleChar = needle[lastNeedleCharPos];\n  const end = len - needleLen;\n  const occ = self._occ;\n  const lookbehind = self._lookbehind;\n\n  if (pos < 0) {\n    // Lookbehind buffer is not empty. Perform Boyer-Moore-Horspool\n    // search with character lookup code that considers both the\n    // lookbehind buffer and the current round's haystack data.\n    //\n    // Loop until\n    //   there is a match.\n    // or until\n    //   we've moved past the position that requires the\n    //   lookbehind buffer. In this case we switch to the\n    //   optimized loop.\n    // or until\n    //   the character to look at lies outside the haystack.\n    while (pos < 0 && pos <= end) {\n      const nextPos = pos + lastNeedleCharPos;\n      const ch = (nextPos < 0\n        ? lookbehind[self._lookbehindSize + nextPos]\n        : data[nextPos]);\n\n      if (ch === lastNeedleChar\n        && matchNeedle(self, data, pos, lastNeedleCharPos)) {\n        self._lookbehindSize = 0;\n        ++self.matches;\n        if (pos > -self._lookbehindSize)\n          self._cb(true, lookbehind, 0, self._lookbehindSize + pos, false);\n        else\n          self._cb(true, undefined, 0, 0, true);\n\n        return (self._bufPos = pos + needleLen);\n      }\n\n      pos += occ[ch];\n    }\n\n    // No match.\n\n    // There's too few data for Boyer-Moore-Horspool to run,\n    // so let's use a different algorithm to skip as much as\n    // we can.\n    // Forward pos until\n    //   the trailing part of lookbehind + data\n    //   looks like the beginning of the needle\n    // or until\n    //   pos == 0\n    while (pos < 0 && !matchNeedle(self, data, pos, len - pos))\n      ++pos;\n\n    if (pos < 0) {\n      // Cut off part of the lookbehind buffer that has\n      // been processed and append the entire haystack\n      // into it.\n      const bytesToCutOff = self._lookbehindSize + pos;\n\n      if (bytesToCutOff > 0) {\n        // The cut off data is guaranteed not to contain the needle.\n        self._cb(false, lookbehind, 0, bytesToCutOff, false);\n      }\n\n      self._lookbehindSize -= bytesToCutOff;\n      lookbehind.copy(lookbehind, 0, bytesToCutOff, self._lookbehindSize);\n      lookbehind.set(data, self._lookbehindSize);\n      self._lookbehindSize += len;\n\n      self._bufPos = len;\n      return len;\n    }\n\n    // Discard lookbehind buffer.\n    self._cb(false, lookbehind, 0, self._lookbehindSize, false);\n    self._lookbehindSize = 0;\n  }\n\n  pos += self._bufPos;\n\n  const firstNeedleChar = needle[0];\n\n  // Lookbehind buffer is now empty. Perform Boyer-Moore-Horspool\n  // search with optimized character lookup code that only considers\n  // the current round's haystack data.\n  while (pos <= end) {\n    const ch = data[pos + lastNeedleCharPos];\n\n    if (ch === lastNeedleChar\n      && data[pos] === firstNeedleChar\n      && memcmp(needle, 0, data, pos, lastNeedleCharPos)) {\n      ++self.matches;\n      if (pos > 0)\n        self._cb(true, data, self._bufPos, pos, true);\n      else\n        self._cb(true, undefined, 0, 0, true);\n\n      return (self._bufPos = pos + needleLen);\n    }\n\n    pos += occ[ch];\n  }\n\n  // There was no match. If there's trailing haystack data that we cannot\n  // match yet using the Boyer-Moore-Horspool algorithm (because the trailing\n  // data is less than the needle size) then match using a modified\n  // algorithm that starts matching from the beginning instead of the end.\n  // Whatever trailing data is left after running this algorithm is added to\n  // the lookbehind buffer.\n  while (pos < len) {\n    if (data[pos] !== firstNeedleChar\n      || !memcmp(data, pos, needle, 0, len - pos)) {\n      ++pos;\n      continue;\n    }\n    data.copy(lookbehind, 0, pos, len);\n    self._lookbehindSize = len - pos;\n    break;\n  }\n\n  // Everything until `pos` is guaranteed not to contain needle data.\n  if (pos > 0)\n    self._cb(false, data, self._bufPos, pos < len ? pos : len, true);\n\n  self._bufPos = len;\n  return len;\n}\n\nfunction matchNeedle(self, data, pos, len) {\n  const lb = self._lookbehind;\n  const lbSize = self._lookbehindSize;\n  const needle = self._needle;\n\n  for (let i = 0; i < len; ++i, ++pos) {\n    const ch = (pos < 0 ? lb[lbSize + pos] : data[pos]);\n    if (ch !== needle[i])\n      return false;\n  }\n  return true;\n}\n\nmodule.exports = SBMH;"
  },
  {
    "path": "server/libs/uaParser/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2012-2021 Faisal Salman <<f@faisalman.com>>\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": "server/libs/uaParser/index.js",
    "content": "/* UAParser.js v0.7.31\n   Copyright © 2012-2021 Faisal Salman <f@faisalman.com>\n   MIT License */\n(function (window, undefined) { \"use strict\"; var LIBVERSION = \"0.7.31\", EMPTY = \"\", UNKNOWN = \"?\", FUNC_TYPE = \"function\", UNDEF_TYPE = \"undefined\", OBJ_TYPE = \"object\", STR_TYPE = \"string\", MAJOR = \"major\", MODEL = \"model\", NAME = \"name\", TYPE = \"type\", VENDOR = \"vendor\", VERSION = \"version\", ARCHITECTURE = \"architecture\", CONSOLE = \"console\", MOBILE = \"mobile\", TABLET = \"tablet\", SMARTTV = \"smarttv\", WEARABLE = \"wearable\", EMBEDDED = \"embedded\", UA_MAX_LENGTH = 255; var AMAZON = \"Amazon\", APPLE = \"Apple\", ASUS = \"ASUS\", BLACKBERRY = \"BlackBerry\", BROWSER = \"Browser\", CHROME = \"Chrome\", EDGE = \"Edge\", FIREFOX = \"Firefox\", GOOGLE = \"Google\", HUAWEI = \"Huawei\", LG = \"LG\", MICROSOFT = \"Microsoft\", MOTOROLA = \"Motorola\", OPERA = \"Opera\", SAMSUNG = \"Samsung\", SONY = \"Sony\", XIAOMI = \"Xiaomi\", ZEBRA = \"Zebra\", FACEBOOK = \"Facebook\"; var extend = function (regexes, extensions) { var mergedRegexes = {}; for (var i in regexes) { if (extensions[i] && extensions[i].length % 2 === 0) { mergedRegexes[i] = extensions[i].concat(regexes[i]) } else { mergedRegexes[i] = regexes[i] } } return mergedRegexes }, enumerize = function (arr) { var enums = {}; for (var i = 0; i < arr.length; i++) { enums[arr[i].toUpperCase()] = arr[i] } return enums }, has = function (str1, str2) { return typeof str1 === STR_TYPE ? lowerize(str2).indexOf(lowerize(str1)) !== -1 : false }, lowerize = function (str) { return str.toLowerCase() }, majorize = function (version) { return typeof version === STR_TYPE ? version.replace(/[^\\d\\.]/g, EMPTY).split(\".\")[0] : undefined }, trim = function (str, len) { if (typeof str === STR_TYPE) { str = str.replace(/^\\s\\s*/, EMPTY).replace(/\\s\\s*$/, EMPTY); return typeof len === UNDEF_TYPE ? str : str.substring(0, UA_MAX_LENGTH) } }; var rgxMapper = function (ua, arrays) { var i = 0, j, k, p, q, matches, match; while (i < arrays.length && !matches) { var regex = arrays[i], props = arrays[i + 1]; j = k = 0; while (j < regex.length && !matches) { matches = regex[j++].exec(ua); if (!!matches) { for (p = 0; p < props.length; p++) { match = matches[++k]; q = props[p]; if (typeof q === OBJ_TYPE && q.length > 0) { if (q.length === 2) { if (typeof q[1] == FUNC_TYPE) { this[q[0]] = q[1].call(this, match) } else { this[q[0]] = q[1] } } else if (q.length === 3) { if (typeof q[1] === FUNC_TYPE && !(q[1].exec && q[1].test)) { this[q[0]] = match ? q[1].call(this, match, q[2]) : undefined } else { this[q[0]] = match ? match.replace(q[1], q[2]) : undefined } } else if (q.length === 4) { this[q[0]] = match ? q[3].call(this, match.replace(q[1], q[2])) : undefined } } else { this[q] = match ? match : undefined } } } } i += 2 } }, strMapper = function (str, map) { for (var i in map) { if (typeof map[i] === OBJ_TYPE && map[i].length > 0) { for (var j = 0; j < map[i].length; j++) { if (has(map[i][j], str)) { return i === UNKNOWN ? undefined : i } } } else if (has(map[i], str)) { return i === UNKNOWN ? undefined : i } } return str }; var oldSafariMap = { \"1.0\": \"/8\", 1.2: \"/1\", 1.3: \"/3\", \"2.0\": \"/412\", \"2.0.2\": \"/416\", \"2.0.3\": \"/417\", \"2.0.4\": \"/419\", \"?\": \"/\" }, windowsVersionMap = { ME: \"4.90\", \"NT 3.11\": \"NT3.51\", \"NT 4.0\": \"NT4.0\", 2e3: \"NT 5.0\", XP: [\"NT 5.1\", \"NT 5.2\"], Vista: \"NT 6.0\", 7: \"NT 6.1\", 8: \"NT 6.2\", 8.1: \"NT 6.3\", 10: [\"NT 6.4\", \"NT 10.0\"], RT: \"ARM\" }; var regexes = { browser: [[/\\b(?:crmo|crios)\\/([\\w\\.]+)/i], [VERSION, [NAME, \"Chrome\"]], [/edg(?:e|ios|a)?\\/([\\w\\.]+)/i], [VERSION, [NAME, \"Edge\"]], [/(opera mini)\\/([-\\w\\.]+)/i, /(opera [mobiletab]{3,6})\\b.+version\\/([-\\w\\.]+)/i, /(opera)(?:.+version\\/|[\\/ ]+)([\\w\\.]+)/i], [NAME, VERSION], [/opios[\\/ ]+([\\w\\.]+)/i], [VERSION, [NAME, OPERA + \" Mini\"]], [/\\bopr\\/([\\w\\.]+)/i], [VERSION, [NAME, OPERA]], [/(kindle)\\/([\\w\\.]+)/i, /(lunascape|maxthon|netfront|jasmine|blazer)[\\/ ]?([\\w\\.]*)/i, /(avant |iemobile|slim)(?:browser)?[\\/ ]?([\\w\\.]*)/i, /(ba?idubrowser)[\\/ ]?([\\w\\.]+)/i, /(?:ms|\\()(ie) ([\\w\\.]+)/i, /(flock|rockmelt|midori|epiphany|silk|skyfire|ovibrowser|bolt|iron|vivaldi|iridium|phantomjs|bowser|quark|qupzilla|falkon|rekonq|puffin|brave|whale|qqbrowserlite|qq)\\/([-\\w\\.]+)/i, /(weibo)__([\\d\\.]+)/i], [NAME, VERSION], [/(?:\\buc? ?browser|(?:juc.+)ucweb)[\\/ ]?([\\w\\.]+)/i], [VERSION, [NAME, \"UC\" + BROWSER]], [/\\bqbcore\\/([\\w\\.]+)/i], [VERSION, [NAME, \"WeChat(Win) Desktop\"]], [/micromessenger\\/([\\w\\.]+)/i], [VERSION, [NAME, \"WeChat\"]], [/konqueror\\/([\\w\\.]+)/i], [VERSION, [NAME, \"Konqueror\"]], [/trident.+rv[: ]([\\w\\.]{1,9})\\b.+like gecko/i], [VERSION, [NAME, \"IE\"]], [/yabrowser\\/([\\w\\.]+)/i], [VERSION, [NAME, \"Yandex\"]], [/(avast|avg)\\/([\\w\\.]+)/i], [[NAME, /(.+)/, \"$1 Secure \" + BROWSER], VERSION], [/\\bfocus\\/([\\w\\.]+)/i], [VERSION, [NAME, FIREFOX + \" Focus\"]], [/\\bopt\\/([\\w\\.]+)/i], [VERSION, [NAME, OPERA + \" Touch\"]], [/coc_coc\\w+\\/([\\w\\.]+)/i], [VERSION, [NAME, \"Coc Coc\"]], [/dolfin\\/([\\w\\.]+)/i], [VERSION, [NAME, \"Dolphin\"]], [/coast\\/([\\w\\.]+)/i], [VERSION, [NAME, OPERA + \" Coast\"]], [/miuibrowser\\/([\\w\\.]+)/i], [VERSION, [NAME, \"MIUI \" + BROWSER]], [/fxios\\/([-\\w\\.]+)/i], [VERSION, [NAME, FIREFOX]], [/\\bqihu|(qi?ho?o?|360)browser/i], [[NAME, \"360 \" + BROWSER]], [/(oculus|samsung|sailfish)browser\\/([\\w\\.]+)/i], [[NAME, /(.+)/, \"$1 \" + BROWSER], VERSION], [/(comodo_dragon)\\/([\\w\\.]+)/i], [[NAME, /_/g, \" \"], VERSION], [/(electron)\\/([\\w\\.]+) safari/i, /(tesla)(?: qtcarbrowser|\\/(20\\d\\d\\.[-\\w\\.]+))/i, /m?(qqbrowser|baiduboxapp|2345Explorer)[\\/ ]?([\\w\\.]+)/i], [NAME, VERSION], [/(metasr)[\\/ ]?([\\w\\.]+)/i, /(lbbrowser)/i], [NAME], [/((?:fban\\/fbios|fb_iab\\/fb4a)(?!.+fbav)|;fbav\\/([\\w\\.]+);)/i], [[NAME, FACEBOOK], VERSION], [/safari (line)\\/([\\w\\.]+)/i, /\\b(line)\\/([\\w\\.]+)\\/iab/i, /(chromium|instagram)[\\/ ]([-\\w\\.]+)/i], [NAME, VERSION], [/\\bgsa\\/([\\w\\.]+) .*safari\\//i], [VERSION, [NAME, \"GSA\"]], [/headlesschrome(?:\\/([\\w\\.]+)| )/i], [VERSION, [NAME, CHROME + \" Headless\"]], [/ wv\\).+(chrome)\\/([\\w\\.]+)/i], [[NAME, CHROME + \" WebView\"], VERSION], [/droid.+ version\\/([\\w\\.]+)\\b.+(?:mobile safari|safari)/i], [VERSION, [NAME, \"Android \" + BROWSER]], [/(chrome|omniweb|arora|[tizenoka]{5} ?browser)\\/v?([\\w\\.]+)/i], [NAME, VERSION], [/version\\/([\\w\\.]+) .*mobile\\/\\w+ (safari)/i], [VERSION, [NAME, \"Mobile Safari\"]], [/version\\/([\\w\\.]+) .*(mobile ?safari|safari)/i], [VERSION, NAME], [/webkit.+?(mobile ?safari|safari)(\\/[\\w\\.]+)/i], [NAME, [VERSION, strMapper, oldSafariMap]], [/(webkit|khtml)\\/([\\w\\.]+)/i], [NAME, VERSION], [/(navigator|netscape\\d?)\\/([-\\w\\.]+)/i], [[NAME, \"Netscape\"], VERSION], [/mobile vr; rv:([\\w\\.]+)\\).+firefox/i], [VERSION, [NAME, FIREFOX + \" Reality\"]], [/ekiohf.+(flow)\\/([\\w\\.]+)/i, /(swiftfox)/i, /(icedragon|iceweasel|camino|chimera|fennec|maemo browser|minimo|conkeror|klar)[\\/ ]?([\\w\\.\\+]+)/i, /(seamonkey|k-meleon|icecat|iceape|firebird|phoenix|palemoon|basilisk|waterfox)\\/([-\\w\\.]+)$/i, /(firefox)\\/([\\w\\.]+)/i, /(mozilla)\\/([\\w\\.]+) .+rv\\:.+gecko\\/\\d+/i, /(polaris|lynx|dillo|icab|doris|amaya|w3m|netsurf|sleipnir|obigo|mosaic|(?:go|ice|up)[\\. ]?browser)[-\\/ ]?v?([\\w\\.]+)/i, /(links) \\(([\\w\\.]+)/i], [NAME, VERSION]], cpu: [[/(?:(amd|x(?:(?:86|64)[-_])?|wow|win)64)[;\\)]/i], [[ARCHITECTURE, \"amd64\"]], [/(ia32(?=;))/i], [[ARCHITECTURE, lowerize]], [/((?:i[346]|x)86)[;\\)]/i], [[ARCHITECTURE, \"ia32\"]], [/\\b(aarch64|arm(v?8e?l?|_?64))\\b/i], [[ARCHITECTURE, \"arm64\"]], [/\\b(arm(?:v[67])?ht?n?[fl]p?)\\b/i], [[ARCHITECTURE, \"armhf\"]], [/windows (ce|mobile); ppc;/i], [[ARCHITECTURE, \"arm\"]], [/((?:ppc|powerpc)(?:64)?)(?: mac|;|\\))/i], [[ARCHITECTURE, /ower/, EMPTY, lowerize]], [/(sun4\\w)[;\\)]/i], [[ARCHITECTURE, \"sparc\"]], [/((?:avr32|ia64(?=;))|68k(?=\\))|\\barm(?=v(?:[1-7]|[5-7]1)l?|;|eabi)|(?=atmel )avr|(?:irix|mips|sparc)(?:64)?\\b|pa-risc)/i], [[ARCHITECTURE, lowerize]]], device: [[/\\b(sch-i[89]0\\d|shw-m380s|sm-[pt]\\w{2,4}|gt-[pn]\\d{2,4}|sgh-t8[56]9|nexus 10)/i], [MODEL, [VENDOR, SAMSUNG], [TYPE, TABLET]], [/\\b((?:s[cgp]h|gt|sm)-\\w+|galaxy nexus)/i, /samsung[- ]([-\\w]+)/i, /sec-(sgh\\w+)/i], [MODEL, [VENDOR, SAMSUNG], [TYPE, MOBILE]], [/\\((ip(?:hone|od)[\\w ]*);/i], [MODEL, [VENDOR, APPLE], [TYPE, MOBILE]], [/\\((ipad);[-\\w\\),; ]+apple/i, /applecoremedia\\/[\\w\\.]+ \\((ipad)/i, /\\b(ipad)\\d\\d?,\\d\\d?[;\\]].+ios/i], [MODEL, [VENDOR, APPLE], [TYPE, TABLET]], [/\\b((?:ag[rs][23]?|bah2?|sht?|btv)-a?[lw]\\d{2})\\b(?!.+d\\/s)/i], [MODEL, [VENDOR, HUAWEI], [TYPE, TABLET]], [/(?:huawei|honor)([-\\w ]+)[;\\)]/i, /\\b(nexus 6p|\\w{2,4}-[atu]?[ln][01259x][012359][an]?)\\b(?!.+d\\/s)/i], [MODEL, [VENDOR, HUAWEI], [TYPE, MOBILE]], [/\\b(poco[\\w ]+)(?: bui|\\))/i, /\\b; (\\w+) build\\/hm\\1/i, /\\b(hm[-_ ]?note?[_ ]?(?:\\d\\w)?) bui/i, /\\b(redmi[\\-_ ]?(?:note|k)?[\\w_ ]+)(?: bui|\\))/i, /\\b(mi[-_ ]?(?:a\\d|one|one[_ ]plus|note lte|max)?[_ ]?(?:\\d?\\w?)[_ ]?(?:plus|se|lite)?)(?: bui|\\))/i], [[MODEL, /_/g, \" \"], [VENDOR, XIAOMI], [TYPE, MOBILE]], [/\\b(mi[-_ ]?(?:pad)(?:[\\w_ ]+))(?: bui|\\))/i], [[MODEL, /_/g, \" \"], [VENDOR, XIAOMI], [TYPE, TABLET]], [/; (\\w+) bui.+ oppo/i, /\\b(cph[12]\\d{3}|p(?:af|c[al]|d\\w|e[ar])[mt]\\d0|x9007|a101op)\\b/i], [MODEL, [VENDOR, \"OPPO\"], [TYPE, MOBILE]], [/vivo (\\w+)(?: bui|\\))/i, /\\b(v[12]\\d{3}\\w?[at])(?: bui|;)/i], [MODEL, [VENDOR, \"Vivo\"], [TYPE, MOBILE]], [/\\b(rmx[12]\\d{3})(?: bui|;|\\))/i], [MODEL, [VENDOR, \"Realme\"], [TYPE, MOBILE]], [/\\b(milestone|droid(?:[2-4x]| (?:bionic|x2|pro|razr))?:?( 4g)?)\\b[\\w ]+build\\//i, /\\bmot(?:orola)?[- ](\\w*)/i, /((?:moto[\\w\\(\\) ]+|xt\\d{3,4}|nexus 6)(?= bui|\\)))/i], [MODEL, [VENDOR, MOTOROLA], [TYPE, MOBILE]], [/\\b(mz60\\d|xoom[2 ]{0,2}) build\\//i], [MODEL, [VENDOR, MOTOROLA], [TYPE, TABLET]], [/((?=lg)?[vl]k\\-?\\d{3}) bui| 3\\.[-\\w; ]{10}lg?-([06cv9]{3,4})/i], [MODEL, [VENDOR, LG], [TYPE, TABLET]], [/(lm(?:-?f100[nv]?|-[\\w\\.]+)(?= bui|\\))|nexus [45])/i, /\\blg[-e;\\/ ]+((?!browser|netcast|android tv)\\w+)/i, /\\blg-?([\\d\\w]+) bui/i], [MODEL, [VENDOR, LG], [TYPE, MOBILE]], [/(ideatab[-\\w ]+)/i, /lenovo ?(s[56]000[-\\w]+|tab(?:[\\w ]+)|yt[-\\d\\w]{6}|tb[-\\d\\w]{6})/i], [MODEL, [VENDOR, \"Lenovo\"], [TYPE, TABLET]], [/(?:maemo|nokia).*(n900|lumia \\d+)/i, /nokia[-_ ]?([-\\w\\.]*)/i], [[MODEL, /_/g, \" \"], [VENDOR, \"Nokia\"], [TYPE, MOBILE]], [/(pixel c)\\b/i], [MODEL, [VENDOR, GOOGLE], [TYPE, TABLET]], [/droid.+; (pixel[\\daxl ]{0,6})(?: bui|\\))/i], [MODEL, [VENDOR, GOOGLE], [TYPE, MOBILE]], [/droid.+ ([c-g]\\d{4}|so[-gl]\\w+|xq-a\\w[4-7][12])(?= bui|\\).+chrome\\/(?![1-6]{0,1}\\d\\.))/i], [MODEL, [VENDOR, SONY], [TYPE, MOBILE]], [/sony tablet [ps]/i, /\\b(?:sony)?sgp\\w+(?: bui|\\))/i], [[MODEL, \"Xperia Tablet\"], [VENDOR, SONY], [TYPE, TABLET]], [/ (kb2005|in20[12]5|be20[12][59])\\b/i, /(?:one)?(?:plus)? (a\\d0\\d\\d)(?: b|\\))/i], [MODEL, [VENDOR, \"OnePlus\"], [TYPE, MOBILE]], [/(alexa)webm/i, /(kf[a-z]{2}wi)( bui|\\))/i, /(kf[a-z]+)( bui|\\)).+silk\\//i], [MODEL, [VENDOR, AMAZON], [TYPE, TABLET]], [/((?:sd|kf)[0349hijorstuw]+)( bui|\\)).+silk\\//i], [[MODEL, /(.+)/g, \"Fire Phone $1\"], [VENDOR, AMAZON], [TYPE, MOBILE]], [/(playbook);[-\\w\\),; ]+(rim)/i], [MODEL, VENDOR, [TYPE, TABLET]], [/\\b((?:bb[a-f]|st[hv])100-\\d)/i, /\\(bb10; (\\w+)/i], [MODEL, [VENDOR, BLACKBERRY], [TYPE, MOBILE]], [/(?:\\b|asus_)(transfo[prime ]{4,10} \\w+|eeepc|slider \\w+|nexus 7|padfone|p00[cj])/i], [MODEL, [VENDOR, ASUS], [TYPE, TABLET]], [/ (z[bes]6[027][012][km][ls]|zenfone \\d\\w?)\\b/i], [MODEL, [VENDOR, ASUS], [TYPE, MOBILE]], [/(nexus 9)/i], [MODEL, [VENDOR, \"HTC\"], [TYPE, TABLET]], [/(htc)[-;_ ]{1,2}([\\w ]+(?=\\)| bui)|\\w+)/i, /(zte)[- ]([\\w ]+?)(?: bui|\\/|\\))/i, /(alcatel|geeksphone|nexian|panasonic|sony)[-_ ]?([-\\w]*)/i], [VENDOR, [MODEL, /_/g, \" \"], [TYPE, MOBILE]], [/droid.+; ([ab][1-7]-?[0178a]\\d\\d?)/i], [MODEL, [VENDOR, \"Acer\"], [TYPE, TABLET]], [/droid.+; (m[1-5] note) bui/i, /\\bmz-([-\\w]{2,})/i], [MODEL, [VENDOR, \"Meizu\"], [TYPE, MOBILE]], [/\\b(sh-?[altvz]?\\d\\d[a-ekm]?)/i], [MODEL, [VENDOR, \"Sharp\"], [TYPE, MOBILE]], [/(blackberry|benq|palm(?=\\-)|sonyericsson|acer|asus|dell|meizu|motorola|polytron)[-_ ]?([-\\w]*)/i, /(hp) ([\\w ]+\\w)/i, /(asus)-?(\\w+)/i, /(microsoft); (lumia[\\w ]+)/i, /(lenovo)[-_ ]?([-\\w]+)/i, /(jolla)/i, /(oppo) ?([\\w ]+) bui/i], [VENDOR, MODEL, [TYPE, MOBILE]], [/(archos) (gamepad2?)/i, /(hp).+(touchpad(?!.+tablet)|tablet)/i, /(kindle)\\/([\\w\\.]+)/i, /(nook)[\\w ]+build\\/(\\w+)/i, /(dell) (strea[kpr\\d ]*[\\dko])/i, /(le[- ]+pan)[- ]+(\\w{1,9}) bui/i, /(trinity)[- ]*(t\\d{3}) bui/i, /(gigaset)[- ]+(q\\w{1,9}) bui/i, /(vodafone) ([\\w ]+)(?:\\)| bui)/i], [VENDOR, MODEL, [TYPE, TABLET]], [/(surface duo)/i], [MODEL, [VENDOR, MICROSOFT], [TYPE, TABLET]], [/droid [\\d\\.]+; (fp\\du?)(?: b|\\))/i], [MODEL, [VENDOR, \"Fairphone\"], [TYPE, MOBILE]], [/(u304aa)/i], [MODEL, [VENDOR, \"AT&T\"], [TYPE, MOBILE]], [/\\bsie-(\\w*)/i], [MODEL, [VENDOR, \"Siemens\"], [TYPE, MOBILE]], [/\\b(rct\\w+) b/i], [MODEL, [VENDOR, \"RCA\"], [TYPE, TABLET]], [/\\b(venue[\\d ]{2,7}) b/i], [MODEL, [VENDOR, \"Dell\"], [TYPE, TABLET]], [/\\b(q(?:mv|ta)\\w+) b/i], [MODEL, [VENDOR, \"Verizon\"], [TYPE, TABLET]], [/\\b(?:barnes[& ]+noble |bn[rt])([\\w\\+ ]*) b/i], [MODEL, [VENDOR, \"Barnes & Noble\"], [TYPE, TABLET]], [/\\b(tm\\d{3}\\w+) b/i], [MODEL, [VENDOR, \"NuVision\"], [TYPE, TABLET]], [/\\b(k88) b/i], [MODEL, [VENDOR, \"ZTE\"], [TYPE, TABLET]], [/\\b(nx\\d{3}j) b/i], [MODEL, [VENDOR, \"ZTE\"], [TYPE, MOBILE]], [/\\b(gen\\d{3}) b.+49h/i], [MODEL, [VENDOR, \"Swiss\"], [TYPE, MOBILE]], [/\\b(zur\\d{3}) b/i], [MODEL, [VENDOR, \"Swiss\"], [TYPE, TABLET]], [/\\b((zeki)?tb.*\\b) b/i], [MODEL, [VENDOR, \"Zeki\"], [TYPE, TABLET]], [/\\b([yr]\\d{2}) b/i, /\\b(dragon[- ]+touch |dt)(\\w{5}) b/i], [[VENDOR, \"Dragon Touch\"], MODEL, [TYPE, TABLET]], [/\\b(ns-?\\w{0,9}) b/i], [MODEL, [VENDOR, \"Insignia\"], [TYPE, TABLET]], [/\\b((nxa|next)-?\\w{0,9}) b/i], [MODEL, [VENDOR, \"NextBook\"], [TYPE, TABLET]], [/\\b(xtreme\\_)?(v(1[045]|2[015]|[3469]0|7[05])) b/i], [[VENDOR, \"Voice\"], MODEL, [TYPE, MOBILE]], [/\\b(lvtel\\-)?(v1[12]) b/i], [[VENDOR, \"LvTel\"], MODEL, [TYPE, MOBILE]], [/\\b(ph-1) /i], [MODEL, [VENDOR, \"Essential\"], [TYPE, MOBILE]], [/\\b(v(100md|700na|7011|917g).*\\b) b/i], [MODEL, [VENDOR, \"Envizen\"], [TYPE, TABLET]], [/\\b(trio[-\\w\\. ]+) b/i], [MODEL, [VENDOR, \"MachSpeed\"], [TYPE, TABLET]], [/\\btu_(1491) b/i], [MODEL, [VENDOR, \"Rotor\"], [TYPE, TABLET]], [/(shield[\\w ]+) b/i], [MODEL, [VENDOR, \"Nvidia\"], [TYPE, TABLET]], [/(sprint) (\\w+)/i], [VENDOR, MODEL, [TYPE, MOBILE]], [/(kin\\.[onetw]{3})/i], [[MODEL, /\\./g, \" \"], [VENDOR, MICROSOFT], [TYPE, MOBILE]], [/droid.+; (cc6666?|et5[16]|mc[239][23]x?|vc8[03]x?)\\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, TABLET]], [/droid.+; (ec30|ps20|tc[2-8]\\d[kx])\\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, MOBILE]], [/(ouya)/i, /(nintendo) ([wids3utch]+)/i], [VENDOR, MODEL, [TYPE, CONSOLE]], [/droid.+; (shield) bui/i], [MODEL, [VENDOR, \"Nvidia\"], [TYPE, CONSOLE]], [/(playstation [345portablevi]+)/i], [MODEL, [VENDOR, SONY], [TYPE, CONSOLE]], [/\\b(xbox(?: one)?(?!; xbox))[\\); ]/i], [MODEL, [VENDOR, MICROSOFT], [TYPE, CONSOLE]], [/smart-tv.+(samsung)/i], [VENDOR, [TYPE, SMARTTV]], [/hbbtv.+maple;(\\d+)/i], [[MODEL, /^/, \"SmartTV\"], [VENDOR, SAMSUNG], [TYPE, SMARTTV]], [/(nux; netcast.+smarttv|lg (netcast\\.tv-201\\d|android tv))/i], [[VENDOR, LG], [TYPE, SMARTTV]], [/(apple) ?tv/i], [VENDOR, [MODEL, APPLE + \" TV\"], [TYPE, SMARTTV]], [/crkey/i], [[MODEL, CHROME + \"cast\"], [VENDOR, GOOGLE], [TYPE, SMARTTV]], [/droid.+aft(\\w)( bui|\\))/i], [MODEL, [VENDOR, AMAZON], [TYPE, SMARTTV]], [/\\(dtv[\\);].+(aquos)/i], [MODEL, [VENDOR, \"Sharp\"], [TYPE, SMARTTV]], [/\\b(roku)[\\dx]*[\\)\\/]((?:dvp-)?[\\d\\.]*)/i, /hbbtv\\/\\d+\\.\\d+\\.\\d+ +\\([\\w ]*; *(\\w[^;]*);([^;]*)/i], [[VENDOR, trim], [MODEL, trim], [TYPE, SMARTTV]], [/\\b(android tv|smart[- ]?tv|opera tv|tv; rv:)\\b/i], [[TYPE, SMARTTV]], [/((pebble))app/i], [VENDOR, MODEL, [TYPE, WEARABLE]], [/droid.+; (glass) \\d/i], [MODEL, [VENDOR, GOOGLE], [TYPE, WEARABLE]], [/droid.+; (wt63?0{2,3})\\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, WEARABLE]], [/(quest( 2)?)/i], [MODEL, [VENDOR, FACEBOOK], [TYPE, WEARABLE]], [/(tesla)(?: qtcarbrowser|\\/[-\\w\\.]+)/i], [VENDOR, [TYPE, EMBEDDED]], [/droid .+?; ([^;]+?)(?: bui|\\) applew).+? mobile safari/i], [MODEL, [TYPE, MOBILE]], [/droid .+?; ([^;]+?)(?: bui|\\) applew).+?(?! mobile) safari/i], [MODEL, [TYPE, TABLET]], [/\\b((tablet|tab)[;\\/]|focus\\/\\d(?!.+mobile))/i], [[TYPE, TABLET]], [/(phone|mobile(?:[;\\/]| safari)|pda(?=.+windows ce))/i], [[TYPE, MOBILE]], [/(android[-\\w\\. ]{0,9});.+buil/i], [MODEL, [VENDOR, \"Generic\"]]], engine: [[/windows.+ edge\\/([\\w\\.]+)/i], [VERSION, [NAME, EDGE + \"HTML\"]], [/webkit\\/537\\.36.+chrome\\/(?!27)([\\w\\.]+)/i], [VERSION, [NAME, \"Blink\"]], [/(presto)\\/([\\w\\.]+)/i, /(webkit|trident|netfront|netsurf|amaya|lynx|w3m|goanna)\\/([\\w\\.]+)/i, /ekioh(flow)\\/([\\w\\.]+)/i, /(khtml|tasman|links)[\\/ ]\\(?([\\w\\.]+)/i, /(icab)[\\/ ]([23]\\.[\\d\\.]+)/i], [NAME, VERSION], [/rv\\:([\\w\\.]{1,9})\\b.+(gecko)/i], [VERSION, NAME]], os: [[/microsoft (windows) (vista|xp)/i], [NAME, VERSION], [/(windows) nt 6\\.2; (arm)/i, /(windows (?:phone(?: os)?|mobile))[\\/ ]?([\\d\\.\\w ]*)/i, /(windows)[\\/ ]?([ntce\\d\\. ]+\\w)(?!.+xbox)/i], [NAME, [VERSION, strMapper, windowsVersionMap]], [/(win(?=3|9|n)|win 9x )([nt\\d\\.]+)/i], [[NAME, \"Windows\"], [VERSION, strMapper, windowsVersionMap]], [/ip[honead]{2,4}\\b(?:.*os ([\\w]+) like mac|; opera)/i, /cfnetwork\\/.+darwin/i], [[VERSION, /_/g, \".\"], [NAME, \"iOS\"]], [/(mac os x) ?([\\w\\. ]*)/i, /(macintosh|mac_powerpc\\b)(?!.+haiku)/i], [[NAME, \"Mac OS\"], [VERSION, /_/g, \".\"]], [/droid ([\\w\\.]+)\\b.+(android[- ]x86)/i], [VERSION, NAME], [/(android|webos|qnx|bada|rim tablet os|maemo|meego|sailfish)[-\\/ ]?([\\w\\.]*)/i, /(blackberry)\\w*\\/([\\w\\.]*)/i, /(tizen|kaios)[\\/ ]([\\w\\.]+)/i, /\\((series40);/i], [NAME, VERSION], [/\\(bb(10);/i], [VERSION, [NAME, BLACKBERRY]], [/(?:symbian ?os|symbos|s60(?=;)|series60)[-\\/ ]?([\\w\\.]*)/i], [VERSION, [NAME, \"Symbian\"]], [/mozilla\\/[\\d\\.]+ \\((?:mobile|tablet|tv|mobile; [\\w ]+); rv:.+ gecko\\/([\\w\\.]+)/i], [VERSION, [NAME, FIREFOX + \" OS\"]], [/web0s;.+rt(tv)/i, /\\b(?:hp)?wos(?:browser)?\\/([\\w\\.]+)/i], [VERSION, [NAME, \"webOS\"]], [/crkey\\/([\\d\\.]+)/i], [VERSION, [NAME, CHROME + \"cast\"]], [/(cros) [\\w]+ ([\\w\\.]+\\w)/i], [[NAME, \"Chromium OS\"], VERSION], [/(nintendo|playstation) ([wids345portablevuch]+)/i, /(xbox); +xbox ([^\\);]+)/i, /\\b(joli|palm)\\b ?(?:os)?\\/?([\\w\\.]*)/i, /(mint)[\\/\\(\\) ]?(\\w*)/i, /(mageia|vectorlinux)[; ]/i, /([kxln]?ubuntu|debian|suse|opensuse|gentoo|arch(?= linux)|slackware|fedora|mandriva|centos|pclinuxos|red ?hat|zenwalk|linpus|raspbian|plan 9|minix|risc os|contiki|deepin|manjaro|elementary os|sabayon|linspire)(?: gnu\\/linux)?(?: enterprise)?(?:[- ]linux)?(?:-gnu)?[-\\/ ]?(?!chrom|package)([-\\w\\.]*)/i, /(hurd|linux) ?([\\w\\.]*)/i, /(gnu) ?([\\w\\.]*)/i, /\\b([-frentopcghs]{0,5}bsd|dragonfly)[\\/ ]?(?!amd|[ix346]{1,2}86)([\\w\\.]*)/i, /(haiku) (\\w+)/i], [NAME, VERSION], [/(sunos) ?([\\w\\.\\d]*)/i], [[NAME, \"Solaris\"], VERSION], [/((?:open)?solaris)[-\\/ ]?([\\w\\.]*)/i, /(aix) ((\\d)(?=\\.|\\)| )[\\w\\.])*/i, /\\b(beos|os\\/2|amigaos|morphos|openvms|fuchsia|hp-ux)/i, /(unix) ?([\\w\\.]*)/i], [NAME, VERSION]] }; var UAParser = function (ua, extensions) { if (typeof ua === OBJ_TYPE) { extensions = ua; ua = undefined } if (!(this instanceof UAParser)) { return new UAParser(ua, extensions).getResult() } var _ua = ua || (typeof window !== UNDEF_TYPE && window.navigator && window.navigator.userAgent ? window.navigator.userAgent : EMPTY); var _rgxmap = extensions ? extend(regexes, extensions) : regexes; this.getBrowser = function () { var _browser = {}; _browser[NAME] = undefined; _browser[VERSION] = undefined; rgxMapper.call(_browser, _ua, _rgxmap.browser); _browser.major = majorize(_browser.version); return _browser }; this.getCPU = function () { var _cpu = {}; _cpu[ARCHITECTURE] = undefined; rgxMapper.call(_cpu, _ua, _rgxmap.cpu); return _cpu }; this.getDevice = function () { var _device = {}; _device[VENDOR] = undefined; _device[MODEL] = undefined; _device[TYPE] = undefined; rgxMapper.call(_device, _ua, _rgxmap.device); return _device }; this.getEngine = function () { var _engine = {}; _engine[NAME] = undefined; _engine[VERSION] = undefined; rgxMapper.call(_engine, _ua, _rgxmap.engine); return _engine }; this.getOS = function () { var _os = {}; _os[NAME] = undefined; _os[VERSION] = undefined; rgxMapper.call(_os, _ua, _rgxmap.os); return _os }; this.getResult = function () { return { ua: this.getUA(), browser: this.getBrowser(), engine: this.getEngine(), os: this.getOS(), device: this.getDevice(), cpu: this.getCPU() } }; this.getUA = function () { return _ua }; this.setUA = function (ua) { _ua = typeof ua === STR_TYPE && ua.length > UA_MAX_LENGTH ? trim(ua, UA_MAX_LENGTH) : ua; return this }; this.setUA(_ua); return this }; UAParser.VERSION = LIBVERSION; UAParser.BROWSER = enumerize([NAME, VERSION, MAJOR]); UAParser.CPU = enumerize([ARCHITECTURE]); UAParser.DEVICE = enumerize([MODEL, VENDOR, TYPE, CONSOLE, MOBILE, SMARTTV, TABLET, WEARABLE, EMBEDDED]); UAParser.ENGINE = UAParser.OS = enumerize([NAME, VERSION]); if (typeof exports !== UNDEF_TYPE) { if (typeof module !== UNDEF_TYPE && module.exports) { exports = module.exports = UAParser } exports.UAParser = UAParser } else { if (typeof define === FUNC_TYPE && define.amd) { define(function () { return UAParser }) } else if (typeof window !== UNDEF_TYPE) { window.UAParser = UAParser } } var $ = typeof window !== UNDEF_TYPE && (window.jQuery || window.Zepto); if ($ && !$.ua) { var parser = new UAParser; $.ua = parser.getResult(); $.ua.get = function () { return parser.getUA() }; $.ua.set = function (ua) { parser.setUA(ua); var result = parser.getResult(); for (var prop in result) { $.ua[prop] = result[prop] } } } })(typeof window === \"object\" ? window : this);"
  },
  {
    "path": "server/libs/umzug/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2014-2017 Sequelize contributors\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.\n"
  },
  {
    "path": "server/libs/umzug/index.js",
    "content": "'use strict'\nvar __createBinding =\n  (this && this.__createBinding) ||\n  (Object.create\n    ? function (o, m, k, k2) {\n        if (k2 === undefined) k2 = k\n        var desc = Object.getOwnPropertyDescriptor(m, k)\n        if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n          desc = {\n            enumerable: true,\n            get: function () {\n              return m[k]\n            }\n          }\n        }\n        Object.defineProperty(o, k2, desc)\n      }\n    : function (o, m, k, k2) {\n        if (k2 === undefined) k2 = k\n        o[k2] = m[k]\n      })\nvar __exportStar =\n  (this && this.__exportStar) ||\n  function (m, exports) {\n    for (var p in m) if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p)\n  }\nObject.defineProperty(exports, '__esModule', { value: true })\n__exportStar(require('./umzug'), exports)\n__exportStar(require('./storage'), exports)\n__exportStar(require('./types'), exports)\n//# sourceMappingURL=index.js.map\n"
  },
  {
    "path": "server/libs/umzug/storage/contract.js",
    "content": "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.verifyUmzugStorage = exports.isUmzugStorage = void 0;\nfunction isUmzugStorage(arg) {\n    return (arg &&\n        typeof arg.logMigration === 'function' &&\n        typeof arg.unlogMigration === 'function' &&\n        typeof arg.executed === 'function');\n}\nexports.isUmzugStorage = isUmzugStorage;\nconst verifyUmzugStorage = (arg) => {\n    if (!isUmzugStorage(arg)) {\n        throw new Error(`Invalid umzug storage`);\n    }\n    return arg;\n};\nexports.verifyUmzugStorage = verifyUmzugStorage;\n//# sourceMappingURL=contract.js.map"
  },
  {
    "path": "server/libs/umzug/storage/index.js",
    "content": "\"use strict\";\nvar __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {\n    if (k2 === undefined) k2 = k;\n    var desc = Object.getOwnPropertyDescriptor(m, k);\n    if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n      desc = { enumerable: true, get: function() { return m[k]; } };\n    }\n    Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n    if (k2 === undefined) k2 = k;\n    o[k2] = m[k];\n}));\nvar __exportStar = (this && this.__exportStar) || function(m, exports) {\n    for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\n// codegen:start {preset: barrel}\n__exportStar(require(\"./contract\"), exports);\n__exportStar(require(\"./json\"), exports);\n__exportStar(require(\"./memory\"), exports);\n__exportStar(require(\"./mongodb\"), exports);\n__exportStar(require(\"./sequelize\"), exports);\n// codegen:end\n//# sourceMappingURL=index.js.map"
  },
  {
    "path": "server/libs/umzug/storage/json.js",
    "content": "\"use strict\";\nvar __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {\n    if (k2 === undefined) k2 = k;\n    var desc = Object.getOwnPropertyDescriptor(m, k);\n    if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n      desc = { enumerable: true, get: function() { return m[k]; } };\n    }\n    Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n    if (k2 === undefined) k2 = k;\n    o[k2] = m[k];\n}));\nvar __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {\n    Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n    o[\"default\"] = v;\n});\nvar __importStar = (this && this.__importStar) || function (mod) {\n    if (mod && mod.__esModule) return mod;\n    var result = {};\n    if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n    __setModuleDefault(result, mod);\n    return result;\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.JSONStorage = void 0;\nconst fs_1 = require(\"fs\");\nconst path = __importStar(require(\"path\"));\nconst filesystem = {\n    /** reads a file as a string or returns null if file doesn't exist */\n    async readAsync(filepath) {\n        return fs_1.promises.readFile(filepath).then(c => c.toString(), () => null);\n    },\n    /** writes a string as file contents, creating its parent directory if necessary */\n    async writeAsync(filepath, content) {\n        await fs_1.promises.mkdir(path.dirname(filepath), { recursive: true });\n        await fs_1.promises.writeFile(filepath, content);\n    },\n};\nclass JSONStorage {\n    constructor(options) {\n        var _a;\n        this.path = (_a = options === null || options === void 0 ? void 0 : options.path) !== null && _a !== void 0 ? _a : path.join(process.cwd(), 'umzug.json');\n    }\n    async logMigration({ name: migrationName }) {\n        const loggedMigrations = await this.executed();\n        loggedMigrations.push(migrationName);\n        await filesystem.writeAsync(this.path, JSON.stringify(loggedMigrations, null, 2));\n    }\n    async unlogMigration({ name: migrationName }) {\n        const loggedMigrations = await this.executed();\n        const updatedMigrations = loggedMigrations.filter(name => name !== migrationName);\n        await filesystem.writeAsync(this.path, JSON.stringify(updatedMigrations, null, 2));\n    }\n    async executed() {\n        const content = await filesystem.readAsync(this.path);\n        return content ? JSON.parse(content) : [];\n    }\n}\nexports.JSONStorage = JSONStorage;\n//# sourceMappingURL=json.js.map"
  },
  {
    "path": "server/libs/umzug/storage/memory.js",
    "content": "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.memoryStorage = void 0;\nconst memoryStorage = () => {\n    let executed = [];\n    return {\n        async logMigration({ name }) {\n            executed.push(name);\n        },\n        async unlogMigration({ name }) {\n            executed = executed.filter(n => n !== name);\n        },\n        executed: async () => [...executed],\n    };\n};\nexports.memoryStorage = memoryStorage;\n//# sourceMappingURL=memory.js.map"
  },
  {
    "path": "server/libs/umzug/storage/mongodb.js",
    "content": "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.MongoDBStorage = void 0;\nfunction isMongoDBCollectionOptions(arg) {\n    return Boolean(arg.collection);\n}\nclass MongoDBStorage {\n    constructor(options) {\n        var _a, _b;\n        if (!options || (!options.collection && !options.connection)) {\n            throw new Error('MongoDB Connection or Collection required');\n        }\n        this.collection = isMongoDBCollectionOptions(options)\n            ? options.collection\n            : options.connection.collection((_a = options.collectionName) !== null && _a !== void 0 ? _a : 'migrations');\n        this.connection = options.connection; // TODO remove this\n        this.collectionName = (_b = options.collectionName) !== null && _b !== void 0 ? _b : 'migrations'; // TODO remove this\n    }\n    async logMigration({ name: migrationName }) {\n        await this.collection.insertOne({ migrationName });\n    }\n    async unlogMigration({ name: migrationName }) {\n        await this.collection.deleteOne({ migrationName });\n    }\n    async executed() {\n        const records = await this.collection.find({}).sort({ migrationName: 1 }).toArray();\n        return records.map(r => r.migrationName);\n    }\n}\nexports.MongoDBStorage = MongoDBStorage;\n//# sourceMappingURL=mongodb.js.map"
  },
  {
    "path": "server/libs/umzug/storage/sequelize.js",
    "content": "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.SequelizeStorage = void 0;\nconst DIALECTS_WITH_CHARSET_AND_COLLATE = new Set(['mysql', 'mariadb']);\nclass SequelizeStorage {\n    /**\n      Constructs Sequelize based storage. Migrations will be stored in a SequelizeMeta table using the given instance of Sequelize.\n  \n      If a model is given, it will be used directly as the model for the SequelizeMeta table. Otherwise, it will be created automatically according to the given options.\n  \n      If the table does not exist it will be created automatically upon the logging of the first migration.\n      */\n    constructor(options) {\n        var _a, _b, _c, _d, _e, _f;\n        if (!options || (!options.model && !options.sequelize)) {\n            throw new Error('One of \"sequelize\" or \"model\" storage option is required');\n        }\n        this.sequelize = (_a = options.sequelize) !== null && _a !== void 0 ? _a : options.model.sequelize;\n        this.columnType = (_b = options.columnType) !== null && _b !== void 0 ? _b : this.sequelize.constructor.DataTypes.STRING;\n        this.columnName = (_c = options.columnName) !== null && _c !== void 0 ? _c : 'name';\n        this.timestamps = (_d = options.timestamps) !== null && _d !== void 0 ? _d : false;\n        this.modelName = (_e = options.modelName) !== null && _e !== void 0 ? _e : 'SequelizeMeta';\n        this.tableName = options.tableName;\n        this.schema = options.schema;\n        this.model = (_f = options.model) !== null && _f !== void 0 ? _f : this.getModel();\n    }\n    getModel() {\n        var _a;\n        if (this.sequelize.isDefined(this.modelName)) {\n            // eslint-disable-next-line @typescript-eslint/no-unsafe-return\n            return this.sequelize.model(this.modelName);\n        }\n        const dialectName = (_a = this.sequelize.dialect) === null || _a === void 0 ? void 0 : _a.name;\n        const hasCharsetAndCollate = dialectName && DIALECTS_WITH_CHARSET_AND_COLLATE.has(dialectName);\n        return this.sequelize.define(this.modelName, {\n            [this.columnName]: {\n                type: this.columnType,\n                allowNull: false,\n                unique: true,\n                primaryKey: true,\n                autoIncrement: false,\n            },\n        }, {\n            tableName: this.tableName,\n            schema: this.schema,\n            timestamps: this.timestamps,\n            charset: hasCharsetAndCollate ? 'utf8' : undefined,\n            collate: hasCharsetAndCollate ? 'utf8_unicode_ci' : undefined,\n        });\n    }\n    async syncModel() {\n        await this.model.sync();\n    }\n    async logMigration({ name: migrationName }) {\n        await this.syncModel();\n        await this.model.create({\n            [this.columnName]: migrationName,\n        });\n    }\n    async unlogMigration({ name: migrationName }) {\n        await this.syncModel();\n        await this.model.destroy({\n            where: {\n                [this.columnName]: migrationName,\n            },\n        });\n    }\n    async executed() {\n        await this.syncModel();\n        const migrations = await this.model.findAll({ order: [[this.columnName, 'ASC']] });\n        return migrations.map(migration => {\n            const name = migration[this.columnName];\n            if (typeof name !== 'string') {\n                throw new TypeError(`Unexpected migration name type: expected string, got ${typeof name}`);\n            }\n            return name;\n        });\n    }\n    // TODO remove this\n    _model() {\n        return this.model;\n    }\n}\nexports.SequelizeStorage = SequelizeStorage;\n//# sourceMappingURL=sequelize.js.map"
  },
  {
    "path": "server/libs/umzug/templates.js",
    "content": "'use strict'\n/* eslint-disable unicorn/template-indent */\n// templates for migration file creation\nObject.defineProperty(exports, '__esModule', { value: true })\nexports.sqlDown = exports.sqlUp = exports.mjs = exports.ts = exports.js = void 0\nexports.js = `\n/** @type {import('umzug').MigrationFn<any>} */\nexports.up = async params => {};\n\n/** @type {import('umzug').MigrationFn<any>} */\nexports.down = async params => {};\n`.trimStart()\nexports.ts = `\nimport type { MigrationFn } from 'umzug';\n\nexport const up: MigrationFn = async params => {};\nexport const down: MigrationFn = async params => {};\n`.trimStart()\nexports.mjs = `\n/** @type {import('umzug').MigrationFn<any>} */\nexport const up = async params => {};\n\n/** @type {import('umzug').MigrationFn<any>} */\nexport const down = async params => {};\n`.trimStart()\nexports.sqlUp = `\n-- up migration\n`.trimStart()\nexports.sqlDown = `\n-- down migration\n`.trimStart()\n//# sourceMappingURL=templates.js.map\n"
  },
  {
    "path": "server/libs/umzug/types.js",
    "content": "'use strict'\nObject.defineProperty(exports, '__esModule', { value: true })\nexports.RerunBehavior = void 0\nexports.RerunBehavior = {\n  /** Hard error if an up migration that has already been run, or a down migration that hasn't, is encountered */\n  THROW: 'THROW',\n  /** Silently skip up migrations that have already been run, or down migrations that haven't */\n  SKIP: 'SKIP',\n  /** Re-run up migrations that have already been run, or down migrations that haven't */\n  ALLOW: 'ALLOW'\n}\n//# sourceMappingURL=types.js.map\n"
  },
  {
    "path": "server/libs/umzug/umzug.js",
    "content": "'use strict'\nvar __createBinding =\n  (this && this.__createBinding) ||\n  (Object.create\n    ? function (o, m, k, k2) {\n        if (k2 === undefined) k2 = k\n        var desc = Object.getOwnPropertyDescriptor(m, k)\n        if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n          desc = {\n            enumerable: true,\n            get: function () {\n              return m[k]\n            }\n          }\n        }\n        Object.defineProperty(o, k2, desc)\n      }\n    : function (o, m, k, k2) {\n        if (k2 === undefined) k2 = k\n        o[k2] = m[k]\n      })\nvar __setModuleDefault =\n  (this && this.__setModuleDefault) ||\n  (Object.create\n    ? function (o, v) {\n        Object.defineProperty(o, 'default', { enumerable: true, value: v })\n      }\n    : function (o, v) {\n        o['default'] = v\n      })\nvar __importStar =\n  (this && this.__importStar) ||\n  function (mod) {\n    if (mod && mod.__esModule) return mod\n    var result = {}\n    if (mod != null) for (var k in mod) if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k)\n    __setModuleDefault(result, mod)\n    return result\n  }\nvar __importDefault =\n  (this && this.__importDefault) ||\n  function (mod) {\n    return mod && mod.__esModule ? mod : { default: mod }\n  }\nvar _a\nObject.defineProperty(exports, '__esModule', { value: true })\nexports.Umzug = exports.MigrationError = void 0\nconst fs = __importStar(require('fs'))\nconst path = __importStar(require('path'))\nconst storage_1 = require('./storage')\nconst templates = __importStar(require('./templates'))\nconst types_1 = require('./types')\nclass MigrationError extends Error {\n  // TODO [>=4.0.0] Take a `{ cause: ... }` options bag like the default `Error`, it looks like this because of verror backwards-compatibility.\n  constructor(migration, original) {\n    super(`Migration ${migration.name} (${migration.direction}) failed: ${MigrationError.errorString(original)}`, {\n      cause: original\n    })\n    this.name = 'MigrationError'\n    this.migration = migration\n  }\n  // TODO [>=4.0.0] Remove this backwards-compatibility alias\n  get info() {\n    return this.migration\n  }\n  static errorString(cause) {\n    return cause instanceof Error ? `Original error: ${cause.message}` : `Non-error value thrown. See info for full props: ${cause}`\n  }\n}\nexports.MigrationError = MigrationError\nclass Umzug {\n  /** creates a new Umzug instance */\n  constructor(options) {\n    var _b\n    this.options = options\n    this.storage = (0, storage_1.verifyUmzugStorage)((_b = options.storage) !== null && _b !== void 0 ? _b : new storage_1.JSONStorage())\n    this.migrations = this.getMigrationsResolver(this.options.migrations)\n  }\n  logging(message) {\n    var _b\n    ;(_b = this.options.logger) === null || _b === void 0 ? void 0 : _b.info(message)\n  }\n  /** Get the list of migrations which have already been applied */\n  async executed() {\n    return this.runCommand('executed', async ({ context }) => {\n      const list = await this._executed(context)\n      // We do the following to not expose the `up` and `down` functions to the user\n      return list.map((m) => ({ name: m.name, path: m.path }))\n    })\n  }\n  /** Get the list of migrations which have already been applied */\n  async _executed(context) {\n    const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })])\n    const executedSet = new Set(executedNames)\n    return migrations.filter((m) => executedSet.has(m.name))\n  }\n  /** Get the list of migrations which are yet to be applied */\n  async pending() {\n    return this.runCommand('pending', async ({ context }) => {\n      const list = await this._pending(context)\n      // We do the following to not expose the `up` and `down` functions to the user\n      return list.map((m) => ({ name: m.name, path: m.path }))\n    })\n  }\n  async _pending(context) {\n    const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })])\n    const executedSet = new Set(executedNames)\n    return migrations.filter((m) => !executedSet.has(m.name))\n  }\n  async runCommand(command, cb) {\n    const context = await this.getContext()\n    return await cb({ context })\n  }\n  /**\n   * Apply migrations. By default, runs all pending migrations.\n   * @see MigrateUpOptions for other use cases using `to`, `migrations` and `rerun`.\n   */\n  async up(options = {}) {\n    const eligibleMigrations = async (context) => {\n      var _b\n      if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) {\n        // Allow rerun means the specified migrations should be run even if they've run before - so get all migrations, not just pending\n        const list = await this.migrations(context)\n        return this.findMigrations(list, options.migrations)\n      }\n      if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) {\n        const executedNames = new Set((await this._executed(context)).map((m) => m.name))\n        const filteredMigrations = options.migrations.filter((m) => !executedNames.has(m))\n        return this.findMigrations(await this.migrations(context), filteredMigrations)\n      }\n      if (options.migrations) {\n        return this.findMigrations(await this._pending(context), options.migrations)\n      }\n      const allPending = await this._pending(context)\n      let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : allPending.length\n      if (options.to) {\n        sliceIndex = this.findNameIndex(allPending, options.to) + 1\n      }\n      return allPending.slice(0, sliceIndex)\n    }\n    return this.runCommand('up', async ({ context }) => {\n      const toBeApplied = await eligibleMigrations(context)\n      for (const m of toBeApplied) {\n        const start = Date.now()\n        const params = { name: m.name, path: m.path, context }\n        this.logging({ event: 'migrating', name: m.name })\n        try {\n          await m.up(params)\n        } catch (e) {\n          throw new MigrationError({ direction: 'up', ...params }, e)\n        }\n        await this.storage.logMigration(params)\n        const duration = (Date.now() - start) / 1000\n        this.logging({ event: 'migrated', name: m.name, durationSeconds: duration })\n      }\n      return toBeApplied.map((m) => ({ name: m.name, path: m.path }))\n    })\n  }\n  /**\n   * Revert migrations. By default, the last executed migration is reverted.\n   * @see MigrateDownOptions for other use cases using `to`, `migrations` and `rerun`.\n   */\n  async down(options = {}) {\n    const eligibleMigrations = async (context) => {\n      var _b\n      if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) {\n        const list = await this.migrations(context)\n        return this.findMigrations(list, options.migrations)\n      }\n      if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) {\n        const pendingNames = new Set((await this._pending(context)).map((m) => m.name))\n        const filteredMigrations = options.migrations.filter((m) => !pendingNames.has(m))\n        return this.findMigrations(await this.migrations(context), filteredMigrations)\n      }\n      if (options.migrations) {\n        return this.findMigrations(await this._executed(context), options.migrations)\n      }\n      const executedReversed = (await this._executed(context)).slice().reverse()\n      let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : 1\n      if (options.to === 0 || options.migrations) {\n        sliceIndex = executedReversed.length\n      } else if (options.to) {\n        sliceIndex = this.findNameIndex(executedReversed, options.to) + 1\n      }\n      return executedReversed.slice(0, sliceIndex)\n    }\n    return this.runCommand('down', async ({ context }) => {\n      var _b\n      const toBeReverted = await eligibleMigrations(context)\n      for (const m of toBeReverted) {\n        const start = Date.now()\n        const params = { name: m.name, path: m.path, context }\n        this.logging({ event: 'reverting', name: m.name })\n        try {\n          await ((_b = m.down) === null || _b === void 0 ? void 0 : _b.call(m, params))\n        } catch (e) {\n          throw new MigrationError({ direction: 'down', ...params }, e)\n        }\n        await this.storage.unlogMigration(params)\n        const duration = Number.parseFloat(((Date.now() - start) / 1000).toFixed(3))\n        this.logging({ event: 'reverted', name: m.name, durationSeconds: duration })\n      }\n      return toBeReverted.map((m) => ({ name: m.name, path: m.path }))\n    })\n  }\n  async create(options) {\n    await this.runCommand('create', async ({ context }) => {\n      var _b, _c, _d, _e\n      const isoDate = new Date().toISOString()\n      const prefixes = {\n        TIMESTAMP: isoDate.replace(/\\.\\d{3}Z$/, '').replace(/\\W/g, '.'),\n        DATE: isoDate.split('T')[0].replace(/\\W/g, '.'),\n        NONE: ''\n      }\n      const prefixType = (_b = options.prefix) !== null && _b !== void 0 ? _b : 'TIMESTAMP'\n      const fileBasename = [prefixes[prefixType], options.name].filter(Boolean).join('.')\n      const allowedExtensions = options.allowExtension ? [options.allowExtension] : ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.sql']\n      const existing = await this.migrations(context)\n      const last = existing.slice(-1)[0]\n      const folder = options.folder || ((_c = this.options.create) === null || _c === void 0 ? void 0 : _c.folder) || ((last === null || last === void 0 ? void 0 : last.path) && path.dirname(last.path))\n      if (!folder) {\n        throw new Error(`Couldn't infer a directory to generate migration file in. Pass folder explicitly`)\n      }\n      const filepath = path.join(folder, fileBasename)\n      if (!options.allowConfusingOrdering) {\n        const confusinglyOrdered = existing.find((e) => e.path && e.path >= filepath)\n        if (confusinglyOrdered) {\n          throw new Error(`Can't create ${fileBasename}, since it's unclear if it should run before or after existing migration ${confusinglyOrdered.name}. Use allowConfusingOrdering to bypass this error.`)\n        }\n      }\n      const template =\n        typeof options.content === 'string'\n          ? async () => [[filepath, options.content]]\n          : // eslint-disable-next-line @typescript-eslint/unbound-method\n          (_e = (_d = this.options.create) === null || _d === void 0 ? void 0 : _d.template) !== null && _e !== void 0\n          ? _e\n          : Umzug.defaultCreationTemplate\n      const toWrite = await template(filepath)\n      if (toWrite.length === 0) {\n        toWrite.push([filepath, ''])\n      }\n      toWrite.forEach((pair) => {\n        if (!Array.isArray(pair) || pair.length !== 2) {\n          throw new Error(`Expected [filepath, content] pair. Check that the file template function returns an array of pairs.`)\n        }\n        const ext = path.extname(pair[0])\n        if (!allowedExtensions.includes(ext)) {\n          const allowStr = allowedExtensions.join(', ')\n          const message = `Extension ${ext} not allowed. Allowed extensions are ${allowStr}. See help for allowExtension to avoid this error.`\n          throw new Error(message)\n        }\n        fs.mkdirSync(path.dirname(pair[0]), { recursive: true })\n        fs.writeFileSync(pair[0], pair[1])\n        this.logging({ event: 'created', path: pair[0] })\n      })\n      if (!options.skipVerify) {\n        const [firstFilePath] = toWrite[0]\n        const pending = await this._pending(context)\n        if (!pending.some((p) => p.path && path.resolve(p.path) === path.resolve(firstFilePath))) {\n          const paths = pending.map((p) => p.path).join(', ')\n          throw new Error(`Expected ${firstFilePath} to be a pending migration but it wasn't! Pending migration paths: ${paths}. You should investigate this. Use skipVerify to bypass this error.`)\n        }\n      }\n    })\n  }\n  static defaultCreationTemplate(filepath) {\n    const ext = path.extname(filepath)\n    if ((ext === '.js' && typeof require.main === 'object') || ext === '.cjs') {\n      return [[filepath, templates.js]]\n    }\n    if (ext === '.ts' || ext === '.mts' || ext === '.cts') {\n      return [[filepath, templates.ts]]\n    }\n    if ((ext === '.js' && require.main === undefined) || ext === '.mjs') {\n      return [[filepath, templates.mjs]]\n    }\n    if (ext === '.sql') {\n      const downFilepath = path.join(path.dirname(filepath), 'down', path.basename(filepath))\n      return [\n        [filepath, templates.sqlUp],\n        [downFilepath, templates.sqlDown]\n      ]\n    }\n    return []\n  }\n  findNameIndex(migrations, name) {\n    const index = migrations.findIndex((m) => m.name === name)\n    if (index === -1) {\n      throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`)\n    }\n    return index\n  }\n  findMigrations(migrations, names) {\n    const map = new Map(migrations.map((m) => [m.name, m]))\n    return names.map((name) => {\n      const migration = map.get(name)\n      if (!migration) {\n        throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`)\n      }\n      return migration\n    })\n  }\n  async getContext() {\n    const { context = {} } = this.options\n    // eslint-disable-next-line @typescript-eslint/no-unsafe-return\n    return typeof context === 'function' ? context() : context\n  }\n  /** helper for parsing input migrations into a callback returning a list of ready-to-run migrations */\n  getMigrationsResolver(inputMigrations) {\n    var _b\n    if (Array.isArray(inputMigrations)) {\n      return async () => inputMigrations\n    }\n    if (typeof inputMigrations === 'function') {\n      // Lazy migrations definition, recurse.\n      return async (ctx) => {\n        const resolved = await inputMigrations(ctx)\n        return this.getMigrationsResolver(resolved)(ctx)\n      }\n    }\n    const paths = inputMigrations.files\n    const resolver = (_b = inputMigrations.resolve) !== null && _b !== void 0 ? _b : Umzug.defaultResolver\n    return async (context) => {\n      paths.sort()\n      return paths.map((unresolvedPath) => {\n        const filepath = path.resolve(unresolvedPath)\n        const name = path.basename(filepath)\n        return {\n          path: filepath,\n          ...resolver({ name, path: filepath, context })\n        }\n      })\n    }\n  }\n}\nexports.Umzug = Umzug\n_a = Umzug\nUmzug.defaultResolver = ({ name, path: filepath }) => {\n  if (!filepath) {\n    throw new Error(`Can't use default resolver for non-filesystem migrations`)\n  }\n  const ext = path.extname(filepath)\n  const languageSpecificHelp = {\n    '.ts': \"TypeScript files can be required by adding `ts-node` as a dependency and calling `require('ts-node/register')` at the program entrypoint before running migrations.\",\n    '.sql': 'Try writing a resolver which reads file content and executes it as a sql query.'\n  }\n  languageSpecificHelp['.cts'] = languageSpecificHelp['.ts']\n  languageSpecificHelp['.mts'] = languageSpecificHelp['.ts']\n  let loadModule\n  const jsExt = ext.replace(/\\.([cm]?)ts$/, '.$1js')\n  const getModule = async () => {\n    try {\n      return await loadModule()\n    } catch (e) {\n      if ((e instanceof SyntaxError || e instanceof MissingResolverError) && ext in languageSpecificHelp) {\n        e.message += '\\n\\n' + languageSpecificHelp[ext]\n      }\n      throw e\n    }\n  }\n  if ((jsExt === '.js' && typeof require.main === 'object') || jsExt === '.cjs') {\n    // eslint-disable-next-line @typescript-eslint/no-var-requires\n    loadModule = async () => require(filepath)\n  } else if (jsExt === '.js' || jsExt === '.mjs') {\n    loadModule = async () => import(filepath)\n  } else {\n    loadModule = async () => {\n      throw new MissingResolverError(filepath)\n    }\n  }\n  return {\n    name,\n    path: filepath,\n    up: async ({ context }) => (await getModule()).up({ path: filepath, name, context }),\n    down: async ({ context }) => {\n      var _b, _c\n      return (_c = (_b = await getModule()).down) === null || _c === void 0 ? void 0 : _c.call(_b, { path: filepath, name, context })\n    }\n  }\n}\nclass MissingResolverError extends Error {\n  constructor(filepath) {\n    super(`No resolver specified for file ${filepath}. See docs for guidance on how to write a custom resolver.`)\n  }\n}\n//# sourceMappingURL=umzug.js.map\n"
  },
  {
    "path": "server/libs/universalify/LICENSE",
    "content": "(The MIT License)\n\nCopyright (c) 2017, Ryan Zimmerman <opensrc@ryanzim.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the 'Software'), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject 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, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/universalify/index.js",
    "content": "'use strict'\n\n//\n// used by fsExtra\n// Source: https://github.com/RyanZim/universalify\n//\n\nexports.fromCallback = function (fn) {\n  return Object.defineProperty(function (...args) {\n    if (typeof args[args.length - 1] === 'function') fn.apply(this, args)\n    else {\n      return new Promise((resolve, reject) => {\n        fn.call(\n          this,\n          ...args,\n          (err, res) => (err != null) ? reject(err) : resolve(res)\n        )\n      })\n    }\n  }, 'name', { value: fn.name })\n}\n\nexports.fromPromise = function (fn) {\n  return Object.defineProperty(function (...args) {\n    const cb = args[args.length - 1]\n    if (typeof cb !== 'function') return fn.apply(this, args)\n    else fn.apply(this, args.slice(0, -1)).then(r => cb(null, r), cb)\n  }, 'name', { value: fn.name })\n}"
  },
  {
    "path": "server/libs/watcher/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2020-present Fabio Spampinato\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of this software and associated documentation files (the \"Software\"),\nto deal in the Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies 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\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/watcher/aborter/controller.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar signal_1 = require(\"./signal\");\n/* ABORT CONTROLLER */\nvar AbortController = /** @class */ (function () {\n  function AbortController() {\n    /* VARIABLES */\n    this.signal = new signal_1.default();\n  }\n  /* API */\n  AbortController.prototype.abort = function () {\n    return this.signal.abort();\n  };\n  return AbortController;\n}());\n/* EXPORT */\nmodule.exports = AbortController;\nmodule.exports.default = AbortController;\nObject.defineProperty(module.exports, \"__esModule\", { value: true });\n"
  },
  {
    "path": "server/libs/watcher/aborter/signal.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/* ABORT SIGNAL */\nvar AbortSignal = /** @class */ (function () {\n  function AbortSignal() {\n    /* VARIABLES */\n    this.aborted = false;\n    this.listeners = {};\n  }\n  /* EVENTS API */\n  AbortSignal.prototype.addEventListener = function (event, listener) {\n    var listeners = this.listeners[event] || (this.listeners[event] = []);\n    listeners.push(listener);\n  };\n  AbortSignal.prototype.removeEventListener = function (event, listener) {\n    var listeners = this.listeners[event];\n    if (!listeners)\n      return;\n    listeners.splice(listeners.indexOf(listener), 1);\n  };\n  AbortSignal.prototype.dispatchEvent = function (event) {\n    var listeners = this.listeners[event];\n    if (!listeners)\n      return true;\n    listeners.slice().forEach(function (listener) { return listener(); });\n    return true;\n  };\n  /* API */\n  AbortSignal.prototype.abort = function () {\n    if (this.aborted)\n      return;\n    this.aborted = true;\n    this.dispatchEvent('abort');\n  };\n  return AbortSignal;\n}());\n/* EXPORT */\nexports.default = AbortSignal;\n"
  },
  {
    "path": "server/libs/watcher/are-shallow-equal.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar isPrimitive = require(\"./is-primitive\");\n/* ARE SHALLOW EQUAL */\nvar isNaN = Number.isNaN;\nfunction areShallowEqual(x, y) {\n  if (x === y)\n    return true;\n  if (isNaN(x))\n    return isNaN(y);\n  if (isPrimitive(x) || isPrimitive(y))\n    return x === y;\n  for (var i in x)\n    if (!(i in y))\n      return false;\n  for (var i in y)\n    if (x[i] !== y[i])\n      return false;\n  return true;\n}\n/* EXPORT */\nmodule.exports = areShallowEqual;\nmodule.exports.default = areShallowEqual;\nObject.defineProperty(module.exports, \"__esModule\", { value: true });\n"
  },
  {
    "path": "server/libs/watcher/atomically/consts.js",
    "content": "\"use strict\";\n/* CONSTS */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.NOOP = exports.LIMIT_FILES_DESCRIPTORS = exports.LIMIT_BASENAME_LENGTH = exports.IS_USER_ROOT = exports.IS_POSIX = exports.DEFAULT_TIMEOUT_SYNC = exports.DEFAULT_TIMEOUT_ASYNC = exports.DEFAULT_WRITE_OPTIONS = exports.DEFAULT_READ_OPTIONS = exports.DEFAULT_FOLDER_MODE = exports.DEFAULT_FILE_MODE = exports.DEFAULT_ENCODING = void 0;\nconst DEFAULT_ENCODING = 'utf8';\nexports.DEFAULT_ENCODING = DEFAULT_ENCODING;\nconst DEFAULT_FILE_MODE = 0o666;\nexports.DEFAULT_FILE_MODE = DEFAULT_FILE_MODE;\nconst DEFAULT_FOLDER_MODE = 0o777;\nexports.DEFAULT_FOLDER_MODE = DEFAULT_FOLDER_MODE;\nconst DEFAULT_READ_OPTIONS = {};\nexports.DEFAULT_READ_OPTIONS = DEFAULT_READ_OPTIONS;\nconst DEFAULT_WRITE_OPTIONS = {};\nexports.DEFAULT_WRITE_OPTIONS = DEFAULT_WRITE_OPTIONS;\nconst DEFAULT_TIMEOUT_ASYNC = 5000;\nexports.DEFAULT_TIMEOUT_ASYNC = DEFAULT_TIMEOUT_ASYNC;\nconst DEFAULT_TIMEOUT_SYNC = 100;\nexports.DEFAULT_TIMEOUT_SYNC = DEFAULT_TIMEOUT_SYNC;\nconst IS_POSIX = !!process.getuid;\nexports.IS_POSIX = IS_POSIX;\nconst IS_USER_ROOT = process.getuid ? !process.getuid() : false;\nexports.IS_USER_ROOT = IS_USER_ROOT;\nconst LIMIT_BASENAME_LENGTH = 128; //TODO: fetch the real limit from the filesystem //TODO: fetch the whole-path length limit too\nexports.LIMIT_BASENAME_LENGTH = LIMIT_BASENAME_LENGTH;\nconst LIMIT_FILES_DESCRIPTORS = 10000; //TODO: fetch the real limit from the filesystem\nexports.LIMIT_FILES_DESCRIPTORS = LIMIT_FILES_DESCRIPTORS;\nconst NOOP = () => { };\nexports.NOOP = NOOP;\n"
  },
  {
    "path": "server/libs/watcher/atomically/index.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.writeFileSync = exports.writeFile = exports.readFileSync = exports.readFile = void 0;\nconst path = require(\"path\");\nconst consts_1 = require(\"./consts\");\nconst fs_1 = require(\"./utils/fs\");\nconst lang_1 = require(\"./utils/lang\");\nconst scheduler_1 = require(\"./utils/scheduler\");\nconst temp_1 = require(\"./utils/temp\");\nfunction readFile(filePath, options = consts_1.DEFAULT_READ_OPTIONS) {\n    var _a;\n    if (lang_1.default.isString(options))\n        return readFile(filePath, { encoding: options });\n    const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_ASYNC);\n    return fs_1.default.readFileRetry(timeout)(filePath, options);\n}\nexports.readFile = readFile;\n;\nfunction readFileSync(filePath, options = consts_1.DEFAULT_READ_OPTIONS) {\n    var _a;\n    if (lang_1.default.isString(options))\n        return readFileSync(filePath, { encoding: options });\n    const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_SYNC);\n    return fs_1.default.readFileSyncRetry(timeout)(filePath, options);\n}\nexports.readFileSync = readFileSync;\n;\nconst writeFile = (filePath, data, options, callback) => {\n    if (lang_1.default.isFunction(options))\n        return writeFile(filePath, data, consts_1.DEFAULT_WRITE_OPTIONS, options);\n    const promise = writeFileAsync(filePath, data, options);\n    if (callback)\n        promise.then(callback, callback);\n    return promise;\n};\nexports.writeFile = writeFile;\nconst writeFileAsync = async (filePath, data, options = consts_1.DEFAULT_WRITE_OPTIONS) => {\n    var _a;\n    if (lang_1.default.isString(options))\n        return writeFileAsync(filePath, data, { encoding: options });\n    const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_ASYNC);\n    let schedulerCustomDisposer = null, schedulerDisposer = null, tempDisposer = null, tempPath = null, fd = null;\n    try {\n        if (options.schedule)\n            schedulerCustomDisposer = await options.schedule(filePath);\n        schedulerDisposer = await scheduler_1.default.schedule(filePath);\n        filePath = await fs_1.default.realpathAttempt(filePath) || filePath;\n        [tempPath, tempDisposer] = temp_1.default.get(filePath, options.tmpCreate || temp_1.default.create, !(options.tmpPurge === false));\n        const useStatChown = consts_1.IS_POSIX && lang_1.default.isUndefined(options.chown), useStatMode = lang_1.default.isUndefined(options.mode);\n        if (useStatChown || useStatMode) {\n            const stat = await fs_1.default.statAttempt(filePath);\n            if (stat) {\n                options = { ...options };\n                if (useStatChown)\n                    options.chown = { uid: stat.uid, gid: stat.gid };\n                if (useStatMode)\n                    options.mode = stat.mode;\n            }\n        }\n        const parentPath = path.dirname(filePath);\n        await fs_1.default.mkdirAttempt(parentPath, {\n            mode: consts_1.DEFAULT_FOLDER_MODE,\n            recursive: true\n        });\n        fd = await fs_1.default.openRetry(timeout)(tempPath, 'w', options.mode || consts_1.DEFAULT_FILE_MODE);\n        if (options.tmpCreated)\n            options.tmpCreated(tempPath);\n        if (lang_1.default.isString(data)) {\n            await fs_1.default.writeRetry(timeout)(fd, data, 0, options.encoding || consts_1.DEFAULT_ENCODING);\n        }\n        else if (!lang_1.default.isUndefined(data)) {\n            await fs_1.default.writeRetry(timeout)(fd, data, 0, data.length, 0);\n        }\n        if (options.fsync !== false) {\n            if (options.fsyncWait !== false) {\n                await fs_1.default.fsyncRetry(timeout)(fd);\n            }\n            else {\n                fs_1.default.fsyncAttempt(fd);\n            }\n        }\n        await fs_1.default.closeRetry(timeout)(fd);\n        fd = null;\n        if (options.chown)\n            await fs_1.default.chownAttempt(tempPath, options.chown.uid, options.chown.gid);\n        if (options.mode)\n            await fs_1.default.chmodAttempt(tempPath, options.mode);\n        try {\n            await fs_1.default.renameRetry(timeout)(tempPath, filePath);\n        }\n        catch (error) {\n            if (error.code !== 'ENAMETOOLONG')\n                throw error;\n            await fs_1.default.renameRetry(timeout)(tempPath, temp_1.default.truncate(filePath));\n        }\n        tempDisposer();\n        tempPath = null;\n    }\n    finally {\n        if (fd)\n            await fs_1.default.closeAttempt(fd);\n        if (tempPath)\n            temp_1.default.purge(tempPath);\n        if (schedulerCustomDisposer)\n            schedulerCustomDisposer();\n        if (schedulerDisposer)\n            schedulerDisposer();\n    }\n};\nconst writeFileSync = (filePath, data, options = consts_1.DEFAULT_WRITE_OPTIONS) => {\n    var _a;\n    if (lang_1.default.isString(options))\n        return writeFileSync(filePath, data, { encoding: options });\n    const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_SYNC);\n    let tempDisposer = null, tempPath = null, fd = null;\n    try {\n        filePath = fs_1.default.realpathSyncAttempt(filePath) || filePath;\n        [tempPath, tempDisposer] = temp_1.default.get(filePath, options.tmpCreate || temp_1.default.create, !(options.tmpPurge === false));\n        const useStatChown = consts_1.IS_POSIX && lang_1.default.isUndefined(options.chown), useStatMode = lang_1.default.isUndefined(options.mode);\n        if (useStatChown || useStatMode) {\n            const stat = fs_1.default.statSyncAttempt(filePath);\n            if (stat) {\n                options = { ...options };\n                if (useStatChown)\n                    options.chown = { uid: stat.uid, gid: stat.gid };\n                if (useStatMode)\n                    options.mode = stat.mode;\n            }\n        }\n        const parentPath = path.dirname(filePath);\n        fs_1.default.mkdirSyncAttempt(parentPath, {\n            mode: consts_1.DEFAULT_FOLDER_MODE,\n            recursive: true\n        });\n        fd = fs_1.default.openSyncRetry(timeout)(tempPath, 'w', options.mode || consts_1.DEFAULT_FILE_MODE);\n        if (options.tmpCreated)\n            options.tmpCreated(tempPath);\n        if (lang_1.default.isString(data)) {\n            fs_1.default.writeSyncRetry(timeout)(fd, data, 0, options.encoding || consts_1.DEFAULT_ENCODING);\n        }\n        else if (!lang_1.default.isUndefined(data)) {\n            fs_1.default.writeSyncRetry(timeout)(fd, data, 0, data.length, 0);\n        }\n        if (options.fsync !== false) {\n            if (options.fsyncWait !== false) {\n                fs_1.default.fsyncSyncRetry(timeout)(fd);\n            }\n            else {\n                fs_1.default.fsyncAttempt(fd);\n            }\n        }\n        fs_1.default.closeSyncRetry(timeout)(fd);\n        fd = null;\n        if (options.chown)\n            fs_1.default.chownSyncAttempt(tempPath, options.chown.uid, options.chown.gid);\n        if (options.mode)\n            fs_1.default.chmodSyncAttempt(tempPath, options.mode);\n        try {\n            fs_1.default.renameSyncRetry(timeout)(tempPath, filePath);\n        }\n        catch (error) {\n            if (error.code !== 'ENAMETOOLONG')\n                throw error;\n            fs_1.default.renameSyncRetry(timeout)(tempPath, temp_1.default.truncate(filePath));\n        }\n        tempDisposer();\n        tempPath = null;\n    }\n    finally {\n        if (fd)\n            fs_1.default.closeSyncAttempt(fd);\n        if (tempPath)\n            temp_1.default.purge(tempPath);\n    }\n};\nexports.writeFileSync = writeFileSync;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/attemptify.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.attemptifySync = exports.attemptifyAsync = void 0;\nconst consts_1 = require(\"../consts\");\n/* ATTEMPTIFY */\n//TODO: Maybe publish this as a standalone package\n//FIXME: The type castings here aren't exactly correct\nconst attemptifyAsync = (fn, onError = consts_1.NOOP) => {\n    return function () {\n        return fn.apply(undefined, arguments).catch(onError);\n    };\n};\nexports.attemptifyAsync = attemptifyAsync;\nconst attemptifySync = (fn, onError = consts_1.NOOP) => {\n    return function () {\n        try {\n            return fn.apply(undefined, arguments);\n        }\n        catch (error) {\n            return onError(error);\n        }\n    };\n};\nexports.attemptifySync = attemptifySync;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/fs.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst fs = require(\"fs\");\nconst util_1 = require(\"util\");\nconst attemptify_1 = require(\"./attemptify\");\nconst fs_handlers_1 = require(\"./fs_handlers\");\nconst retryify_1 = require(\"./retryify\");\n/* FS */\nconst FS = {\n    chmodAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.chmod), fs_handlers_1.default.onChangeError),\n    chownAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.chown), fs_handlers_1.default.onChangeError),\n    closeAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.close)),\n    fsyncAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.fsync)),\n    mkdirAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.mkdir)),\n    realpathAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.realpath)),\n    statAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.stat)),\n    unlinkAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.unlink)),\n    closeRetry: retryify_1.retryifyAsync(util_1.promisify(fs.close), fs_handlers_1.default.isRetriableError),\n    fsyncRetry: retryify_1.retryifyAsync(util_1.promisify(fs.fsync), fs_handlers_1.default.isRetriableError),\n    openRetry: retryify_1.retryifyAsync(util_1.promisify(fs.open), fs_handlers_1.default.isRetriableError),\n    readFileRetry: retryify_1.retryifyAsync(util_1.promisify(fs.readFile), fs_handlers_1.default.isRetriableError),\n    renameRetry: retryify_1.retryifyAsync(util_1.promisify(fs.rename), fs_handlers_1.default.isRetriableError),\n    statRetry: retryify_1.retryifyAsync(util_1.promisify(fs.stat), fs_handlers_1.default.isRetriableError),\n    writeRetry: retryify_1.retryifyAsync(util_1.promisify(fs.write), fs_handlers_1.default.isRetriableError),\n    chmodSyncAttempt: attemptify_1.attemptifySync(fs.chmodSync, fs_handlers_1.default.onChangeError),\n    chownSyncAttempt: attemptify_1.attemptifySync(fs.chownSync, fs_handlers_1.default.onChangeError),\n    closeSyncAttempt: attemptify_1.attemptifySync(fs.closeSync),\n    mkdirSyncAttempt: attemptify_1.attemptifySync(fs.mkdirSync),\n    realpathSyncAttempt: attemptify_1.attemptifySync(fs.realpathSync),\n    statSyncAttempt: attemptify_1.attemptifySync(fs.statSync),\n    unlinkSyncAttempt: attemptify_1.attemptifySync(fs.unlinkSync),\n    closeSyncRetry: retryify_1.retryifySync(fs.closeSync, fs_handlers_1.default.isRetriableError),\n    fsyncSyncRetry: retryify_1.retryifySync(fs.fsyncSync, fs_handlers_1.default.isRetriableError),\n    openSyncRetry: retryify_1.retryifySync(fs.openSync, fs_handlers_1.default.isRetriableError),\n    readFileSyncRetry: retryify_1.retryifySync(fs.readFileSync, fs_handlers_1.default.isRetriableError),\n    renameSyncRetry: retryify_1.retryifySync(fs.renameSync, fs_handlers_1.default.isRetriableError),\n    statSyncRetry: retryify_1.retryifySync(fs.statSync, fs_handlers_1.default.isRetriableError),\n    writeSyncRetry: retryify_1.retryifySync(fs.writeSync, fs_handlers_1.default.isRetriableError)\n};\n/* EXPORT */\nexports.default = FS;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/fs_handlers.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst consts_1 = require(\"../consts\");\n/* FS HANDLERS */\nconst Handlers = {\n    isChangeErrorOk: (error) => {\n        const { code } = error;\n        if (code === 'ENOSYS')\n            return true;\n        if (!consts_1.IS_USER_ROOT && (code === 'EINVAL' || code === 'EPERM'))\n            return true;\n        return false;\n    },\n    isRetriableError: (error) => {\n        const { code } = error;\n        if (code === 'EMFILE' || code === 'ENFILE' || code === 'EAGAIN' || code === 'EBUSY' || code === 'EACCESS' || code === 'EACCS' || code === 'EPERM')\n            return true;\n        return false;\n    },\n    onChangeError: (error) => {\n        if (Handlers.isChangeErrorOk(error))\n            return;\n        throw error;\n    }\n};\n/* EXPORT */\nexports.default = Handlers;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/lang.js",
    "content": "\"use strict\";\n/* LANG */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst Lang = {\n    isFunction: (x) => {\n        return typeof x === 'function';\n    },\n    isString: (x) => {\n        return typeof x === 'string';\n    },\n    isUndefined: (x) => {\n        return typeof x === 'undefined';\n    }\n};\n/* EXPORT */\nexports.default = Lang;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/retryify.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.retryifySync = exports.retryifyAsync = void 0;\nconst retryify_queue_1 = require(\"./retryify_queue\");\n/* RETRYIFY */\nconst retryifyAsync = (fn, isRetriableError) => {\n    return function (timestamp) {\n        return function attempt() {\n            return retryify_queue_1.default.schedule().then(cleanup => {\n                return fn.apply(undefined, arguments).then(result => {\n                    cleanup();\n                    return result;\n                }, error => {\n                    cleanup();\n                    if (Date.now() >= timestamp)\n                        throw error;\n                    if (isRetriableError(error)) {\n                        const delay = Math.round(100 + (400 * Math.random())), delayPromise = new Promise(resolve => setTimeout(resolve, delay));\n                        return delayPromise.then(() => attempt.apply(undefined, arguments));\n                    }\n                    throw error;\n                });\n            });\n        };\n    };\n};\nexports.retryifyAsync = retryifyAsync;\nconst retryifySync = (fn, isRetriableError) => {\n    return function (timestamp) {\n        return function attempt() {\n            try {\n                return fn.apply(undefined, arguments);\n            }\n            catch (error) {\n                if (Date.now() > timestamp)\n                    throw error;\n                if (isRetriableError(error))\n                    return attempt.apply(undefined, arguments);\n                throw error;\n            }\n        };\n    };\n};\nexports.retryifySync = retryifySync;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/retryify_queue.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst consts_1 = require(\"../consts\");\n/* RETRYIFY QUEUE */\nconst RetryfyQueue = {\n    interval: 25,\n    intervalId: undefined,\n    limit: consts_1.LIMIT_FILES_DESCRIPTORS,\n    queueActive: new Set(),\n    queueWaiting: new Set(),\n    init: () => {\n        if (RetryfyQueue.intervalId)\n            return;\n        RetryfyQueue.intervalId = setInterval(RetryfyQueue.tick, RetryfyQueue.interval);\n    },\n    reset: () => {\n        if (!RetryfyQueue.intervalId)\n            return;\n        clearInterval(RetryfyQueue.intervalId);\n        delete RetryfyQueue.intervalId;\n    },\n    add: (fn) => {\n        RetryfyQueue.queueWaiting.add(fn);\n        if (RetryfyQueue.queueActive.size < (RetryfyQueue.limit / 2)) { // Active queue not under preassure, executing immediately\n            RetryfyQueue.tick();\n        }\n        else {\n            RetryfyQueue.init();\n        }\n    },\n    remove: (fn) => {\n        RetryfyQueue.queueWaiting.delete(fn);\n        RetryfyQueue.queueActive.delete(fn);\n    },\n    schedule: () => {\n        return new Promise(resolve => {\n            const cleanup = () => RetryfyQueue.remove(resolver);\n            const resolver = () => resolve(cleanup);\n            RetryfyQueue.add(resolver);\n        });\n    },\n    tick: () => {\n        if (RetryfyQueue.queueActive.size >= RetryfyQueue.limit)\n            return;\n        if (!RetryfyQueue.queueWaiting.size)\n            return RetryfyQueue.reset();\n        for (const fn of RetryfyQueue.queueWaiting) {\n            if (RetryfyQueue.queueActive.size >= RetryfyQueue.limit)\n                break;\n            RetryfyQueue.queueWaiting.delete(fn);\n            RetryfyQueue.queueActive.add(fn);\n            fn();\n        }\n    }\n};\n/* EXPORT */\nexports.default = RetryfyQueue;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/scheduler.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/* VARIABLES */\nconst Queues = {};\n/* SCHEDULER */\n//TODO: Maybe publish this as a standalone package\nconst Scheduler = {\n    next: (id) => {\n        const queue = Queues[id];\n        if (!queue)\n            return;\n        queue.shift();\n        const job = queue[0];\n        if (job) {\n            job(() => Scheduler.next(id));\n        }\n        else {\n            delete Queues[id];\n        }\n    },\n    schedule: (id) => {\n        return new Promise(resolve => {\n            let queue = Queues[id];\n            if (!queue)\n                queue = Queues[id] = [];\n            queue.push(resolve);\n            if (queue.length > 1)\n                return;\n            resolve(() => Scheduler.next(id));\n        });\n    }\n};\n/* EXPORT */\nexports.default = Scheduler;\n"
  },
  {
    "path": "server/libs/watcher/atomically/utils/temp.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst path = require(\"path\");\nconst consts_1 = require(\"../consts\");\nconst fs_1 = require(\"./fs\");\n/* TEMP */\n//TODO: Maybe publish this as a standalone package\nconst Temp = {\n    store: {},\n    create: (filePath) => {\n        const randomness = `000000${Math.floor(Math.random() * 16777215).toString(16)}`.slice(-6), // 6 random-enough hex characters\n        timestamp = Date.now().toString().slice(-10), // 10 precise timestamp digits\n        prefix = 'tmp-', suffix = `.${prefix}${timestamp}${randomness}`, tempPath = `${filePath}${suffix}`;\n        return tempPath;\n    },\n    get: (filePath, creator, purge = true) => {\n        const tempPath = Temp.truncate(creator(filePath));\n        if (tempPath in Temp.store)\n            return Temp.get(filePath, creator, purge); // Collision found, try again\n        Temp.store[tempPath] = purge;\n        const disposer = () => delete Temp.store[tempPath];\n        return [tempPath, disposer];\n    },\n    purge: (filePath) => {\n        if (!Temp.store[filePath])\n            return;\n        delete Temp.store[filePath];\n        fs_1.default.unlinkAttempt(filePath);\n    },\n    purgeSync: (filePath) => {\n        if (!Temp.store[filePath])\n            return;\n        delete Temp.store[filePath];\n        fs_1.default.unlinkSyncAttempt(filePath);\n    },\n    purgeSyncAll: () => {\n        for (const filePath in Temp.store) {\n            Temp.purgeSync(filePath);\n        }\n    },\n    truncate: (filePath) => {\n        const basename = path.basename(filePath);\n        if (basename.length <= consts_1.LIMIT_BASENAME_LENGTH)\n            return filePath; //FIXME: Rough and quick attempt at detecting ok lengths\n        const truncable = /^(\\.?)(.*?)((?:\\.[^.]+)?(?:\\.tmp-\\d{10}[a-f0-9]{6})?)$/.exec(basename);\n        if (!truncable)\n            return filePath; //FIXME: No truncable part detected, can't really do much without also changing the parent path, which is unsafe, hoping for the best here\n        const truncationLength = basename.length - consts_1.LIMIT_BASENAME_LENGTH;\n        return `${filePath.slice(0, -basename.length)}${truncable[1]}${truncable[2].slice(0, -truncationLength)}${truncable[3]}`; //FIXME: The truncable part might be shorter than needed here\n    }\n};\n/* INIT */\nprocess.on('exit', Temp.purgeSyncAll); // Ensuring purgeable temp files are purged on exit\n/* EXPORT */\nexports.default = Temp;\n"
  },
  {
    "path": "server/libs/watcher/constants.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.RENAME_TIMEOUT = exports.POLLING_TIMEOUT = exports.POLLING_INTERVAL = exports.PLATFORM = exports.IS_WINDOWS = exports.IS_MAC = exports.IS_LINUX = exports.HAS_NATIVE_RECURSION = exports.DEPTH = exports.DEBOUNCE = void 0;\nconst os_1 = __importDefault(require(\"os\"));\n/* CONSTANTS */\nconst DEBOUNCE = 300;\nexports.DEBOUNCE = DEBOUNCE;\nconst DEPTH = 20;\nexports.DEPTH = DEPTH;\nconst PLATFORM = os_1.default.platform();\nexports.PLATFORM = PLATFORM;\nconst IS_LINUX = (PLATFORM === 'linux');\nexports.IS_LINUX = IS_LINUX;\nconst IS_MAC = (PLATFORM === 'darwin');\nexports.IS_MAC = IS_MAC;\nconst IS_WINDOWS = (PLATFORM === 'win32');\nexports.IS_WINDOWS = IS_WINDOWS;\nconst HAS_NATIVE_RECURSION = IS_MAC || IS_WINDOWS;\nexports.HAS_NATIVE_RECURSION = HAS_NATIVE_RECURSION;\nconst POLLING_INTERVAL = 3000;\nexports.POLLING_INTERVAL = POLLING_INTERVAL;\nconst POLLING_TIMEOUT = 20000;\nexports.POLLING_TIMEOUT = POLLING_TIMEOUT;\nconst RENAME_TIMEOUT = 1250;\nexports.RENAME_TIMEOUT = RENAME_TIMEOUT;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQ0EsWUFBWTs7Ozs7O0FBRVosNENBQW9CO0FBRXBCLGVBQWU7QUFFZixNQUFNLFFBQVEsR0FBRyxHQUFHLENBQUM7QUFzQmIsNEJBQVE7QUFwQmhCLE1BQU0sS0FBSyxHQUFHLEVBQUUsQ0FBQztBQW9CQyxzQkFBSztBQWxCdkIsTUFBTSxRQUFRLEdBQUcsWUFBRSxDQUFDLFFBQVEsRUFBRyxDQUFDO0FBa0I2Qyw0QkFBUTtBQWhCckYsTUFBTSxRQUFRLEdBQUcsQ0FBRSxRQUFRLEtBQUssT0FBTyxDQUFFLENBQUM7QUFnQkssNEJBQVE7QUFkdkQsTUFBTSxNQUFNLEdBQUcsQ0FBRSxRQUFRLEtBQUssUUFBUSxDQUFFLENBQUM7QUFjZ0Isd0JBQU07QUFaL0QsTUFBTSxVQUFVLEdBQUcsQ0FBRSxRQUFRLEtBQUssT0FBTyxDQUFFLENBQUM7QUFZcUIsZ0NBQVU7QUFWM0UsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLElBQUksVUFBVSxDQUFDO0FBVXpCLG9EQUFvQjtBQVI3QyxNQUFNLGdCQUFnQixHQUFHLElBQUksQ0FBQztBQVF5RCw0Q0FBZ0I7QUFOdkcsTUFBTSxlQUFlLEdBQUcsS0FBSyxDQUFDO0FBTTJFLDBDQUFlO0FBSnhILE1BQU0sY0FBYyxHQUFHLElBQUksQ0FBQztBQUk4Rix3Q0FBYyJ9"
  },
  {
    "path": "server/libs/watcher/debounce.js",
    "content": "/**\n * Returns a function, that, as long as it continues to be invoked, will not\n * be triggered. The function will be called after it stops being called for\n * N milliseconds. If `immediate` is passed, trigger the function on the\n * leading edge, instead of the trailing. The function also has a property 'clear' \n * that is a function which will clear the timer to prevent previously scheduled executions. \n *\n * @source underscore.js\n * @see http://unscriptable.com/2009/03/20/debouncing-javascript-methods/\n * @param {Function} function to wrap\n * @param {Number} timeout in ms (`100`)\n * @param {Boolean} whether to execute at the beginning (`false`)\n * @api public\n */\nfunction debounce(func, wait, immediate) {\n  var timeout, args, context, timestamp, result;\n  if (null == wait) wait = 100;\n\n  function later() {\n    var last = Date.now() - timestamp;\n\n    if (last < wait && last >= 0) {\n      timeout = setTimeout(later, wait - last);\n    } else {\n      timeout = null;\n      if (!immediate) {\n        result = func.apply(context, args);\n        context = args = null;\n      }\n    }\n  };\n\n  var debounced = function () {\n    context = this;\n    args = arguments;\n    timestamp = Date.now();\n    var callNow = immediate && !timeout;\n    if (!timeout) timeout = setTimeout(later, wait);\n    if (callNow) {\n      result = func.apply(context, args);\n      context = args = null;\n    }\n\n    return result;\n  };\n\n  debounced.clear = function () {\n    if (timeout) {\n      clearTimeout(timeout);\n      timeout = null;\n    }\n  };\n\n  debounced.flush = function () {\n    if (timeout) {\n      result = func.apply(context, args);\n      context = args = null;\n\n      clearTimeout(timeout);\n      timeout = null;\n    }\n  };\n\n  return debounced;\n};\n\n// Adds compatibility for ES modules\ndebounce.debounce = debounce;\n\nmodule.exports = debounce;\n"
  },
  {
    "path": "server/libs/watcher/enums.js",
    "content": "\"use strict\";\n/* ENUMS */\nObject.defineProperty(exports, \"__esModule\", { value: true });\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZW51bXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvZW51bXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFdBQVcifQ=="
  },
  {
    "path": "server/libs/watcher/is-primitive.js",
    "content": "/*!\n * is-primitive <https://github.com/jonschlinkert/is-primitive>\n *\n * Copyright (c) 2014-present, Jon Schlinkert.\n * Released under the MIT License.\n */\n\n'use strict';\n\nmodule.exports = function isPrimitive(val) {\n  if (typeof val === 'object') {\n    return val === null;\n  }\n  return typeof val !== 'function';\n};\n"
  },
  {
    "path": "server/libs/watcher/promise-concurrency-limiter.js",
    "content": "\"use strict\";\n/* IMPORT */\n/* PROMISE CONCURRENCY LIMITER */\nclass Limiter {\n  /* CONSTRUCTOR */\n  constructor(options) {\n    this.concurrency = options.concurrency;\n    this.count = 0;\n    this.queue = new Set();\n  }\n  /* API */\n  add(fn) {\n    if (this.count < this.concurrency)\n      return this.run(fn);\n    return new Promise(resolve => {\n      const callback = () => resolve(this.run(fn));\n      this.queue.add(callback);\n    });\n  }\n  flush() {\n    for (const callback of this.queue) {\n      if (this.count >= this.concurrency)\n        break;\n      this.queue.delete(callback);\n      callback();\n    }\n  }\n  run(fn) {\n    this.count += 1;\n    const promise = fn();\n    const cleanup = () => {\n      this.count -= 1;\n      this.flush();\n    };\n    promise.then(cleanup, cleanup);\n    return promise;\n  }\n}\nmodule.exports = Limiter;\nmodule.exports.default = Limiter;\nObject.defineProperty(module.exports, \"__esModule\", { value: true });\n"
  },
  {
    "path": "server/libs/watcher/ripstat/consts.js",
    "content": "\"use strict\";\n/* CONSTS */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.S_IFSOCK = exports.S_IFREG = exports.S_IFMT = exports.S_IFLNK = exports.S_IFIFO = exports.S_IFDIR = exports.S_IFCHR = exports.S_IFBLK = exports.RETRY_TIMEOUT = exports.MAX_SAFE_INTEGER = exports.IS_WINDOWS = void 0;\nconst IS_WINDOWS = (process.platform === 'win32');\nexports.IS_WINDOWS = IS_WINDOWS;\nconst { MAX_SAFE_INTEGER } = Number;\nexports.MAX_SAFE_INTEGER = MAX_SAFE_INTEGER;\nconst RETRY_TIMEOUT = 5000;\nexports.RETRY_TIMEOUT = RETRY_TIMEOUT;\nconst { S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG, S_IFSOCK } = process['binding']('constants').fs;\nexports.S_IFBLK = S_IFBLK;\nexports.S_IFCHR = S_IFCHR;\nexports.S_IFDIR = S_IFDIR;\nexports.S_IFIFO = S_IFIFO;\nexports.S_IFLNK = S_IFLNK;\nexports.S_IFMT = S_IFMT;\nexports.S_IFREG = S_IFREG;\nexports.S_IFSOCK = S_IFSOCK;\n"
  },
  {
    "path": "server/libs/watcher/ripstat/index.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.Stats = void 0;\nconst fs_1 = require(\"../atomically/utils/fs\");\nconst path_1 = require(\"path\");\nconst consts_1 = require(\"./consts\");\nconst stats_1 = require(\"./stats\");\nexports.Stats = stats_1.default;\n/* HELPERS */\nconst { stat, FSReqCallback } = process['binding']('fs');\n/* RIPSTAT */\nconst ripstat = (filePath, timeout) => {\n    return new Promise((resolve, reject) => {\n        const req = new FSReqCallback(true);\n        req.oncomplete = (error, statsdata) => {\n            if (error) {\n                const { code } = error;\n                if (code === 'EMFILE' || code === 'ENFILE' || code === 'EAGAIN' || code === 'EBUSY' || code === 'EACCESS' || code === 'EACCS' || code === 'EPERM') { // Retriable error\n                    fs_1.default.statRetry(timeout || consts_1.RETRY_TIMEOUT)(filePath, { bigint: true }).then(nstats => {\n                        const statsdata = [nstats.dev, nstats.mode, nstats.nlink, nstats.uid, nstats.gid, nstats.rdev, nstats.blksize, nstats.ino, nstats.size, nstats.blocks, 0n, nstats.atimeNs, 0n, nstats.mtimeNs, 0n, nstats.ctimeNs, 0n, nstats.birthtimeNs];\n                        const stats = new stats_1.default(statsdata);\n                        resolve(stats);\n                    }, reject);\n                }\n                else {\n                    reject(error);\n                }\n            }\n            else {\n                const stats = new stats_1.default(statsdata);\n                resolve(stats);\n            }\n        };\n        stat(path_1.toNamespacedPath(filePath), true, req);\n    });\n};\n/* EXPORT */\nexports.default = ripstat;\n"
  },
  {
    "path": "server/libs/watcher/ripstat/stats.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst consts_1 = require(\"./consts\");\n/* HELPERS */\nconst { floor } = Math;\nconst toNumber = Number;\n/* STATS */\nclass Stats {\n    /* CONSTRUCTOR */\n    constructor(stats) {\n        this.dev = toNumber(stats[0]);\n        this.mode = toNumber(stats[1]);\n        this.nlink = toNumber(stats[2]);\n        this.uid = toNumber(stats[3]);\n        this.gid = toNumber(stats[4]);\n        this.rdev = toNumber(stats[5]);\n        this.blksize = toNumber(stats[6]);\n        this.ino = (stats[7] <= consts_1.MAX_SAFE_INTEGER) ? toNumber(stats[7]) : stats[7];\n        this.size = toNumber(stats[8]);\n        this.blocks = toNumber(stats[9]);\n        this.atimeMs = (toNumber(stats[10]) * 1000) + floor(toNumber(stats[11]) / 1000000);\n        this.mtimeMs = (toNumber(stats[12]) * 1000) + floor(toNumber(stats[13]) / 1000000);\n        this.ctimeMs = (toNumber(stats[14]) * 1000) + floor(toNumber(stats[15]) / 1000000);\n        this.birthtimeMs = (toNumber(stats[16]) * 1000) + floor(toNumber(stats[17]) / 1000000);\n    }\n    /* HELPERS */\n    _isMode(mode) {\n        return (this.mode & consts_1.S_IFMT) === mode;\n    }\n    /* API */\n    isDirectory() {\n        return this._isMode(consts_1.S_IFDIR);\n    }\n    isFile() {\n        return this._isMode(consts_1.S_IFREG);\n    }\n    isBlockDevice() {\n        return !consts_1.IS_WINDOWS && this._isMode(consts_1.S_IFBLK);\n    }\n    isCharacterDevice() {\n        return this._isMode(consts_1.S_IFCHR);\n    }\n    isSymbolicLink() {\n        return this._isMode(consts_1.S_IFLNK);\n    }\n    isFIFO() {\n        return !consts_1.IS_WINDOWS && this._isMode(consts_1.S_IFIFO);\n    }\n    isSocket() {\n        return !consts_1.IS_WINDOWS && this._isMode(consts_1.S_IFSOCK);\n    }\n}\n/* EXPORT */\nexports.default = Stats;\n"
  },
  {
    "path": "server/libs/watcher/string-indexes.js",
    "content": "\"use strict\";\n/* STRING INDEXES */\nfunction indexes(str, substr) {\n  var indexes = [], rangeLength = substr.length;\n  var indexFrom = 0;\n  while (true) {\n    var index = str.indexOf(substr, indexFrom);\n    if (index === -1)\n      return indexes;\n    indexes.push(index);\n    indexFrom = index + rangeLength;\n  }\n}\n/* EXPORT */\nmodule.exports = indexes;\nmodule.exports.default = indexes;\nObject.defineProperty(module.exports, \"__esModule\", { value: true });\n"
  },
  {
    "path": "server/libs/watcher/tiny-readdir.js",
    "content": "\"use strict\";\n/* IMPORT */\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst promise_concurrency_limiter_1 = require(\"./promise-concurrency-limiter\");\n/* HELPERS */\nconst limiter = new promise_concurrency_limiter_1.default({ concurrency: 500 });\n/* TINY READDIR */\nconst readdir = (rootPath, options) => {\n    var _a, _b, _c, _d;\n    const followSymlinks = (_a = options === null || options === void 0 ? void 0 : options.followSymlinks) !== null && _a !== void 0 ? _a : false, maxDepth = (_b = options === null || options === void 0 ? void 0 : options.depth) !== null && _b !== void 0 ? _b : Infinity, isIgnored = (_c = options === null || options === void 0 ? void 0 : options.ignore) !== null && _c !== void 0 ? _c : (() => false), signal = (_d = options === null || options === void 0 ? void 0 : options.signal) !== null && _d !== void 0 ? _d : { aborted: false }, directories = [], files = [], symlinks = [], map = {}, resultEmpty = { directories: [], files: [], symlinks: [], map: {} }, result = { directories, files, symlinks, map };\n    const handleDirectory = (dirmap, subPath, depth) => {\n        dirmap.directories.push(subPath);\n        directories.push(subPath);\n        if (depth >= maxDepth)\n            return;\n\n        // if depth > 1 and the limiter is full, then we cannot queue this function or the current promise will never return\n        if (depth > 1 && limiter.count >= limiter.concurrency) return populateResultFromPath(subPath, depth + 1)\n\n        return limiter.add(() => populateResultFromPath(subPath, depth + 1));\n    };\n    const handleFile = (dirmap, subPath) => {\n        dirmap.files.push(subPath);\n        files.push(subPath);\n    };\n    const handleSymlink = (dirmap, subPath, depth) => {\n        dirmap.symlinks.push(subPath);\n        symlinks.push(subPath);\n        if (!followSymlinks)\n            return;\n        if (depth >= maxDepth)\n            return;\n        return limiter.add(() => populateResultFromSymlink(subPath, depth + 1));\n    };\n    const handleStat = (dirmap, rootPath, stat, depth) => {\n        if (signal.aborted)\n            return;\n        if (isIgnored(rootPath))\n            return;\n        if (stat.isDirectory()) {\n            return handleDirectory(dirmap, rootPath, depth);\n        }\n        else if (stat.isFile()) {\n            return handleFile(dirmap, rootPath);\n        }\n        else if (stat.isSymbolicLink()) {\n            return handleSymlink(dirmap, rootPath, depth);\n        }\n    };\n    const handleDirent = (dirmap, rootPath, dirent, depth) => {\n        if (signal.aborted)\n            return;\n        const subPath = `${rootPath}${path.sep}${dirent.name}`;\n        if (isIgnored(subPath))\n            return;\n        if (dirent.isDirectory()) {\n            return handleDirectory(dirmap, subPath, depth);\n        }\n        else if (dirent.isFile()) {\n            return handleFile(dirmap, subPath);\n        }\n        else if (dirent.isSymbolicLink()) {\n            return handleSymlink(dirmap, subPath, depth);\n        }\n    };\n    const handleDirents = (dirmap, rootPath, dirents, depth) => {\n        return Promise.all(dirents.map((dirent) => {\n            return handleDirent(dirmap, rootPath, dirent, depth);\n        }));\n    };\n    const populateResultFromPath = async (rootPath, depth) => {\n        if (signal.aborted)\n            return;\n        if (depth > maxDepth)\n            return;\n        const dirents = await fs.promises.readdir(rootPath, { withFileTypes: true }).catch(() => []);\n        if (signal.aborted)\n            return;\n        const dirmap = map[rootPath] = { directories: [], files: [], symlinks: [] };\n        if (!dirents.length)\n            return;\n        await handleDirents(dirmap, rootPath, dirents, depth);\n    };\n    const populateResultFromSymlink = async (rootPath, depth) => {\n        try {\n            const realPath = await fs.promises.realpath(rootPath), stat = await fs.promises.stat(realPath), dirmap = map[rootPath] = { directories: [], files: [], symlinks: [] };\n            await handleStat(dirmap, realPath, stat, depth);\n        }\n        catch (_a) { }\n    };\n    const getResult = async (rootPath, depth = 1) => {\n        rootPath = path.normalize(rootPath);\n        await populateResultFromPath(rootPath, depth);\n        if (signal.aborted)\n            return resultEmpty;\n        return result;\n    };\n    return getResult(rootPath);\n};\n/* EXPORT */\nmodule.exports = readdir;\nmodule.exports.default = readdir;\nObject.defineProperty(module.exports, \"__esModule\", { value: true });\n"
  },
  {
    "path": "server/libs/watcher/types.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.WatcherStats = exports.Stats = void 0;\nconst ripstat_1 = require(\"ripstat\");\nObject.defineProperty(exports, \"Stats\", { enumerable: true, get: function () { return ripstat_1.Stats; } });\nconst watcher_stats_1 = __importDefault(require(\"./watcher_stats\"));\nexports.WatcherStats = watcher_stats_1.default;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9"
  },
  {
    "path": "server/libs/watcher/utils.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst are_shallow_equal_1 = __importDefault(require(\"./are-shallow-equal\"));\nconst debounce_1 = __importDefault(require(\"./debounce\"));\nconst path_1 = __importDefault(require(\"path\"));\nconst ripstat_1 = __importDefault(require(\"./ripstat\"));\nconst tiny_readdir_1 = __importDefault(require(\"./tiny-readdir\"));\nconst constants_1 = require(\"./constants\");\n/* UTILS */\nconst Utils = {\n    lang: {\n        areShallowEqual: //TODO: Import all these utilities from \"nanodash\" instead\n            are_shallow_equal_1.default,\n        debounce: debounce_1.default,\n        attempt: (fn) => {\n            try {\n                return fn();\n            }\n            catch (error) {\n                return Utils.lang.castError(error);\n            }\n        },\n        castArray: (x) => {\n            return Utils.lang.isArray(x) ? x : [x];\n        },\n        castError(exception) {\n            if (Utils.lang.isError(exception))\n                return exception;\n            if (Utils.lang.isString(exception))\n                return new Error(exception);\n            return new Error('Unknown error');\n        },\n        defer: (callback) => {\n            return setTimeout(callback, 0);\n        },\n        isArray: (x) => {\n            return Array.isArray(x);\n        },\n        isError(x) {\n            return x instanceof Error;\n        },\n        isFunction: (x) => {\n            return typeof x === 'function';\n        },\n        isNumber: (x) => {\n            return typeof x === 'number';\n        },\n        isString: (x) => {\n            return typeof x === 'string';\n        },\n        isUndefined: (x) => {\n            return x === undefined;\n        },\n        noop: () => {\n            return;\n        },\n        uniq: (arr) => {\n            if (arr.length < 2)\n                return arr;\n            return Array.from(new Set(arr));\n        }\n    },\n    fs: {\n        isSubPath: (targetPath, subPath) => {\n            return (subPath.startsWith(targetPath) && subPath[targetPath.length] === path_1.default.sep && (subPath.length - targetPath.length) > path_1.default.sep.length);\n        },\n        poll: (targetPath, timeout = constants_1.POLLING_TIMEOUT) => {\n            return ripstat_1.default(targetPath, timeout).catch(Utils.lang.noop);\n        },\n        readdir: async (rootPath, ignore, depth = Infinity, signal, readdirMap) => {\n            if (readdirMap && depth === 1 && rootPath in readdirMap) { // Reusing cached data\n                const result = readdirMap[rootPath];\n                return [result.directories, result.files];\n            }\n            else { // Retrieving fresh data\n                const result = await tiny_readdir_1.default(rootPath, { depth, ignore, signal });\n                return [result.directories, result.files];\n            }\n        }\n    }\n};\n/* EXPORT */\nexports.default = Utils;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidXRpbHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdXRpbHMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7O0FBRVosMEVBQWdEO0FBQ2hELHdEQUFnQztBQUNoQyxnREFBd0I7QUFDeEIsc0RBQThCO0FBQzlCLGdFQUFtQztBQUNuQywyQ0FBNEM7QUFHNUMsV0FBVztBQUVYLE1BQU0sS0FBSyxHQUFHO0lBRVosSUFBSSxFQUFFO1FBRUosZUFBZSxFQUZULDBEQUEwRDtRQUVoRSwyQkFBZTtRQUVmLFFBQVEsRUFBUixrQkFBUTtRQUVSLE9BQU8sRUFBRSxDQUFNLEVBQVcsRUFBYyxFQUFFO1lBRXhDLElBQUk7Z0JBRUYsT0FBTyxFQUFFLEVBQUcsQ0FBQzthQUVkO1lBQUMsT0FBUSxLQUFjLEVBQUc7Z0JBRXpCLE9BQU8sS0FBSyxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUcsS0FBSyxDQUFFLENBQUM7YUFFdkM7UUFFSCxDQUFDO1FBRUQsU0FBUyxFQUFFLENBQU0sQ0FBVSxFQUFRLEVBQUU7WUFFbkMsT0FBTyxLQUFLLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBRyxDQUFDLENBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO1FBRTVDLENBQUM7UUFFRCxTQUFTLENBQUcsU0FBa0I7WUFFNUIsSUFBSyxLQUFLLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBRyxTQUFTLENBQUU7Z0JBQUcsT0FBTyxTQUFTLENBQUM7WUFFekQsSUFBSyxLQUFLLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBRyxTQUFTLENBQUU7Z0JBQUcsT0FBTyxJQUFJLEtBQUssQ0FBRyxTQUFTLENBQUUsQ0FBQztZQUV4RSxPQUFPLElBQUksS0FBSyxDQUFHLGVBQWUsQ0FBRSxDQUFDO1FBRXZDLENBQUM7UUFFRCxLQUFLLEVBQUUsQ0FBRSxRQUFrQixFQUFtQixFQUFFO1lBRTlDLE9BQU8sVUFBVSxDQUFHLFFBQVEsRUFBRSxDQUFDLENBQUUsQ0FBQztRQUVwQyxDQUFDO1FBRUQsT0FBTyxFQUFFLENBQUUsQ0FBTSxFQUFlLEVBQUU7WUFFaEMsT0FBTyxLQUFLLENBQUMsT0FBTyxDQUFHLENBQUMsQ0FBRSxDQUFDO1FBRTdCLENBQUM7UUFFRCxPQUFPLENBQUcsQ0FBTTtZQUVkLE9BQU8sQ0FBQyxZQUFZLEtBQUssQ0FBQztRQUU1QixDQUFDO1FBRUQsVUFBVSxFQUFFLENBQUUsQ0FBTSxFQUFrQixFQUFFO1lBRXRDLE9BQU8sT0FBTyxDQUFDLEtBQUssVUFBVSxDQUFDO1FBRWpDLENBQUM7UUFFRCxRQUFRLEVBQUUsQ0FBRSxDQUFNLEVBQWdCLEVBQUU7WUFFbEMsT0FBTyxPQUFPLENBQUMsS0FBSyxRQUFRLENBQUM7UUFFL0IsQ0FBQztRQUVELFFBQVEsRUFBRSxDQUFFLENBQU0sRUFBZ0IsRUFBRTtZQUVsQyxPQUFPLE9BQU8sQ0FBQyxLQUFLLFFBQVEsQ0FBQztRQUUvQixDQUFDO1FBRUQsV0FBVyxFQUFFLENBQUUsQ0FBTSxFQUFtQixFQUFFO1lBRXhDLE9BQU8sQ0FBQyxLQUFLLFNBQVMsQ0FBQztRQUV6QixDQUFDO1FBRUQsSUFBSSxFQUFFLEdBQWMsRUFBRTtZQUVwQixPQUFPO1FBRVQsQ0FBQztRQUVELElBQUksRUFBRSxDQUFNLEdBQVEsRUFBUSxFQUFFO1lBRTVCLElBQUssR0FBRyxDQUFDLE1BQU0sR0FBRyxDQUFDO2dCQUFHLE9BQU8sR0FBRyxDQUFDO1lBRWpDLE9BQU8sS0FBSyxDQUFDLElBQUksQ0FBRyxJQUFJLEdBQUcsQ0FBRyxHQUFHLENBQUUsQ0FBRSxDQUFDO1FBRXhDLENBQUM7S0FFRjtJQUVELEVBQUUsRUFBRTtRQUVGLFNBQVMsRUFBRSxDQUFFLFVBQWtCLEVBQUUsT0FBZSxFQUFZLEVBQUU7WUFFNUQsT0FBTyxDQUFFLE9BQU8sQ0FBQyxVQUFVLENBQUcsVUFBVSxDQUFFLElBQUksT0FBTyxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsS0FBSyxjQUFJLENBQUMsR0FBRyxJQUFJLENBQUUsT0FBTyxDQUFDLE1BQU0sR0FBRyxVQUFVLENBQUMsTUFBTSxDQUFFLEdBQUcsY0FBSSxDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUUsQ0FBQztRQUV0SixDQUFDO1FBRUQsSUFBSSxFQUFFLENBQUUsVUFBa0IsRUFBRSxVQUFrQiwyQkFBZSxFQUErQixFQUFFO1lBRTVGLE9BQU8saUJBQU8sQ0FBRyxVQUFVLEVBQUUsT0FBTyxDQUFFLENBQUMsS0FBSyxDQUFHLEtBQUssQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFFLENBQUM7UUFFbkUsQ0FBQztRQUVELE9BQU8sRUFBRSxLQUFLLEVBQUcsUUFBZ0IsRUFBRSxNQUFlLEVBQUUsUUFBZ0IsUUFBUSxFQUFFLE1BQTZCLEVBQUUsVUFBdUIsRUFBa0MsRUFBRTtZQUV0SyxJQUFLLFVBQVUsSUFBSSxLQUFLLEtBQUssQ0FBQyxJQUFJLFFBQVEsSUFBSSxVQUFVLEVBQUcsRUFBRSxzQkFBc0I7Z0JBRWpGLE1BQU0sTUFBTSxHQUFHLFVBQVUsQ0FBQyxRQUFRLENBQUMsQ0FBQztnQkFFcEMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxXQUFXLEVBQUUsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDO2FBRTNDO2lCQUFNLEVBQUUsd0JBQXdCO2dCQUUvQixNQUFNLE1BQU0sR0FBRyxNQUFNLHNCQUFPLENBQUcsUUFBUSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxNQUFNLEVBQUUsQ0FBRSxDQUFDO2dCQUVyRSxPQUFPLENBQUMsTUFBTSxDQUFDLFdBQVcsRUFBRSxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7YUFFM0M7UUFFSCxDQUFDO0tBRUY7Q0FFRixDQUFDO0FBRUYsWUFBWTtBQUVaLGtCQUFlLEtBQUssQ0FBQyJ9"
  },
  {
    "path": "server/libs/watcher/watcher.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nconst aborter_1 = __importDefault(require(\"./aborter/controller\"));\nconst events_1 = require(\"events\");\nconst fs_1 = __importDefault(require(\"fs\"));\nconst path_1 = __importDefault(require(\"path\"));\nconst string_indexes_1 = __importDefault(require(\"./string-indexes\"));\nconst constants_1 = require(\"./constants\");\nconst watcher_handler_1 = __importDefault(require(\"./watcher_handler\"));\nconst watcher_locker_1 = __importDefault(require(\"./watcher_locker\"));\nconst watcher_poller_1 = __importDefault(require(\"./watcher_poller\"));\nconst utils_1 = __importDefault(require(\"./utils\"));\n/* WATCHER */\nclass Watcher extends events_1.EventEmitter {\n    /* CONSTRUCTOR */\n    constructor(target, options, handler) {\n        super();\n        this._closed = false;\n        this._ready = false;\n        this._closeAborter = new aborter_1.default();\n        this._closeSignal = this._closeAborter.signal;\n        this.on(\"close\" /* CLOSE */, () => this._closeAborter.abort());\n        this._closeWait = new Promise(resolve => this.on(\"close\" /* CLOSE */, resolve));\n        this._readyWait = new Promise(resolve => this.on(\"ready\" /* READY */, resolve));\n        this._locker = new watcher_locker_1.default(this);\n        this._roots = new Set();\n        this._poller = new watcher_poller_1.default();\n        this._pollers = new Set();\n        this._subwatchers = new Set();\n        this._watchers = {};\n        this._watchersLock = Promise.resolve();\n        this._watchersRestorable = {};\n        this.watch(target, options, handler);\n    }\n    /* API */\n    isClosed() {\n        return this._closed;\n    }\n    isIgnored(targetPath, ignore) {\n        return !!ignore && !!ignore(targetPath);\n    }\n    isReady() {\n        return this._ready;\n    }\n    close() {\n        this._locker.reset();\n        this._poller.reset();\n        this._roots.clear();\n        this.watchersClose();\n        if (this.isClosed())\n            return false;\n        this._closed = true;\n        return this.emit(\"close\" /* CLOSE */);\n    }\n    error(exception) {\n        if (this.isClosed())\n            return false;\n        const error = utils_1.default.lang.castError(exception);\n        return this.emit(\"error\" /* ERROR */, error);\n    }\n    event(event, targetPath, targetPathNext) {\n        if (this.isClosed())\n            return false;\n        this.emit(\"all\" /* ALL */, event, targetPath, targetPathNext);\n        return this.emit(event, targetPath, targetPathNext);\n    }\n    ready() {\n        if (this.isClosed() || this.isReady())\n            return false;\n        this._ready = true;\n        return this.emit(\"ready\" /* READY */);\n    }\n    pollerExists(targetPath, options) {\n        for (const poller of this._pollers) {\n            if (poller.targetPath !== targetPath)\n                continue;\n            if (!utils_1.default.lang.areShallowEqual(poller.options, options))\n                continue;\n            return true;\n        }\n        return false;\n    }\n    subwatcherExists(targetPath, options) {\n        for (const subwatcher of this._subwatchers) {\n            if (subwatcher.targetPath !== targetPath)\n                continue;\n            if (!utils_1.default.lang.areShallowEqual(subwatcher.options, options))\n                continue;\n            return true;\n        }\n        return false;\n    }\n    watchersClose(folderPath, filePath, recursive = true) {\n        if (!folderPath) {\n            for (const folderPath in this._watchers) {\n                this.watchersClose(folderPath, filePath, false);\n            }\n        }\n        else {\n            const configs = this._watchers[folderPath];\n            if (configs) {\n                for (const config of configs) {\n                    if (filePath && config.filePath !== filePath)\n                        continue;\n                    this.watcherClose(config);\n                }\n            }\n            if (recursive) {\n                for (const folderPathOther in this._watchers) {\n                    if (!utils_1.default.fs.isSubPath(folderPath, folderPathOther))\n                        continue;\n                    this.watchersClose(folderPathOther, filePath, false);\n                }\n            }\n        }\n    }\n    watchersLock(callback) {\n        return this._watchersLock.then(() => {\n            return this._watchersLock = new Promise(async (resolve) => {\n                await callback();\n                resolve();\n            });\n        });\n    }\n    watchersRestore() {\n        delete this._watchersRestoreTimeout;\n        const watchers = Object.entries(this._watchersRestorable);\n        this._watchersRestorable = {};\n        for (const [targetPath, config] of watchers) {\n            this.watchPath(targetPath, config.options, config.handler);\n        }\n    }\n    async watcherAdd(config, baseWatcherHandler) {\n        const { folderPath } = config;\n        const configs = this._watchers[folderPath] = (this._watchers[folderPath] || []);\n        configs.push(config);\n        const watcherHandler = new watcher_handler_1.default(this, config, baseWatcherHandler);\n        await watcherHandler.init();\n        return watcherHandler;\n    }\n    watcherClose(config) {\n        config.watcher.close();\n        const configs = this._watchers[config.folderPath];\n        if (configs) {\n            const index = configs.indexOf(config);\n            configs.splice(index, 1);\n            if (!configs.length) {\n                delete this._watchers[config.folderPath];\n            }\n        }\n        const rootPath = config.filePath || config.folderPath, isRoot = this._roots.has(rootPath);\n        if (isRoot) {\n            this._watchersRestorable[rootPath] = config;\n            if (!this._watchersRestoreTimeout) {\n                this._watchersRestoreTimeout = utils_1.default.lang.defer(() => this.watchersRestore());\n            }\n        }\n    }\n    watcherExists(folderPath, options, handler, filePath) {\n        const configsSibling = this._watchers[folderPath];\n        if (!!(configsSibling === null || configsSibling === void 0 ? void 0 : configsSibling.find(config => config.handler === handler && (!config.filePath || config.filePath === filePath) && config.options.ignore === options.ignore && !!config.options.native === !!options.native && (!options.recursive || config.options.recursive))))\n            return true;\n        let folderAncestorPath = path_1.default.dirname(folderPath);\n        for (let depth = 1; depth < Infinity; depth++) {\n            const configsAncestor = this._watchers[folderAncestorPath];\n            if (!!(configsAncestor === null || configsAncestor === void 0 ? void 0 : configsAncestor.find(config => { var _a; return (depth === 1 || (config.options.recursive && depth <= ((_a = config.options.depth) !== null && _a !== void 0 ? _a : constants_1.DEPTH))) && config.handler === handler && (!config.filePath || config.filePath === filePath) && config.options.ignore === options.ignore && !!config.options.native === !!options.native && (!options.recursive || (config.options.recursive && (constants_1.HAS_NATIVE_RECURSION && config.options.native !== false))); })))\n                return true;\n            if (!constants_1.HAS_NATIVE_RECURSION)\n                break; // No other ancestor will possibly be found\n            const folderAncestorPathNext = path_1.default.dirname(folderPath);\n            if (folderAncestorPath === folderAncestorPathNext)\n                break;\n            folderAncestorPath = folderAncestorPathNext;\n        }\n        return false;\n    }\n    async watchDirectories(foldersPaths, options, handler, filePath, baseWatcherHandler) {\n        if (this.isClosed())\n            return;\n        foldersPaths = utils_1.default.lang.uniq(foldersPaths).sort();\n        let watcherHandlerLast;\n        for (const folderPath of foldersPaths) {\n            if (this.isIgnored(folderPath, options.ignore))\n                continue;\n            if (this.watcherExists(folderPath, options, handler, filePath))\n                continue;\n            try {\n                const watcherOptions = (!options.recursive || (constants_1.HAS_NATIVE_RECURSION && options.native !== false)) ? options : { ...options, recursive: false }, // Ensuring recursion is explicitly disabled if not available\n                    watcher = fs_1.default.watch(folderPath, watcherOptions), watcherConfig = { watcher, handler, options, folderPath, filePath }, watcherHandler = watcherHandlerLast = await this.watcherAdd(watcherConfig, baseWatcherHandler);\n                const isRoot = this._roots.has(filePath || folderPath);\n                if (isRoot) {\n                    const parentOptions = { ...options, ignoreInitial: true, recursive: false }, // Ensuring only the parent folder is being watched\n                        parentFolderPath = path_1.default.dirname(folderPath), parentFilePath = folderPath;\n                    await this.watchDirectories([parentFolderPath], parentOptions, handler, parentFilePath, watcherHandler);\n                    //TODO: Watch parents recursively with the following code, which requires other things to be changed too though\n                    // while ( true ) {\n                    //   await this.watchDirectories ( [parentFolderPath], parentOptions, handler, parentFilePath, watcherHandler );\n                    //   const parentFolderPathNext = path.dirname ( parentFolderPath );\n                    //   if ( parentFolderPath === parentFolderPathNext ) break;\n                    //   parentFilePath = parentFolderPath;\n                    //   parentFolderPath = parentFolderPathNext;\n                    // }\n                }\n            }\n            catch (error) {\n                this.error(error);\n            }\n        }\n        return watcherHandlerLast;\n    }\n    async watchDirectory(folderPath, options, handler, filePath, baseWatcherHandler) {\n        var _a;\n        if (this.isClosed())\n            return;\n        if (this.isIgnored(folderPath, options.ignore))\n            return;\n        if (!options.recursive || (constants_1.HAS_NATIVE_RECURSION && options.native !== false)) {\n            return this.watchersLock(() => {\n                return this.watchDirectories([folderPath], options, handler, filePath, baseWatcherHandler);\n            });\n        }\n        else {\n            options = { ...options, recursive: true }; // Ensuring recursion is explicitly enabled\n            const depth = (_a = options.depth) !== null && _a !== void 0 ? _a : constants_1.DEPTH, [folderSubPaths] = await utils_1.default.fs.readdir(folderPath, options.ignore, depth, this._closeSignal, options.readdirMap);\n            return this.watchersLock(async () => {\n                const watcherHandler = await this.watchDirectories([folderPath], options, handler, filePath, baseWatcherHandler);\n                if (folderSubPaths.length) {\n                    const folderPathDepth = string_indexes_1.default(folderPath, path_1.default.sep).length;\n                    for (const folderSubPath of folderSubPaths) {\n                        const folderSubPathDepth = string_indexes_1.default(folderSubPath, path_1.default.sep).length, subDepth = Math.max(0, depth - (folderSubPathDepth - folderPathDepth)), subOptions = { ...options, depth: subDepth }; // Updating the maximum depth to account for depth of the sub path\n                        await this.watchDirectories([folderSubPath], subOptions, handler, filePath, baseWatcherHandler || watcherHandler);\n                    }\n                }\n            });\n        }\n    }\n    async watchFileOnce(filePath, options, callback) {\n        if (this.isClosed())\n            return;\n        options = { ...options, ignoreInitial: false }; // Ensuring initial events are detected too\n        if (this.subwatcherExists(filePath, options))\n            return;\n        const config = { targetPath: filePath, options };\n        const handler = (event, targetPath) => {\n            if (targetPath !== filePath)\n                return;\n            stop();\n            callback();\n        };\n        const watcher = new Watcher(handler);\n        const start = () => {\n            this._subwatchers.add(config);\n            this.on(\"close\" /* CLOSE */, stop); // Ensuring the subwatcher is stopped on close\n            watcher.watchFile(filePath, options, handler);\n        };\n        const stop = () => {\n            this._subwatchers.delete(config);\n            this.removeListener(\"close\" /* CLOSE */, stop); // Ensuring there are no leftover listeners\n            watcher.close();\n        };\n        return start();\n    }\n    async watchFile(filePath, options, handler) {\n        if (this.isClosed())\n            return;\n        if (this.isIgnored(filePath, options.ignore))\n            return;\n        options = { ...options, recursive: false }; // Ensuring recursion is explicitly disabled\n        const folderPath = path_1.default.dirname(filePath);\n        return this.watchDirectory(folderPath, options, handler, filePath);\n    }\n    async watchPollingOnce(targetPath, options, callback) {\n        if (this.isClosed())\n            return;\n        let isDone = false;\n        const poller = new watcher_poller_1.default();\n        const disposer = await this.watchPolling(targetPath, options, async () => {\n            if (isDone)\n                return;\n            const events = await poller.update(targetPath, options.pollingTimeout);\n            if (!events.length)\n                return; // Nothing actually changed, skipping\n            if (isDone)\n                return; // Another async callback has done the work already, skipping\n            isDone = true;\n            disposer();\n            callback();\n        });\n    }\n    async watchPolling(targetPath, options, callback) {\n        var _a;\n        if (this.isClosed())\n            return utils_1.default.lang.noop;\n        if (this.pollerExists(targetPath, options))\n            return utils_1.default.lang.noop;\n        const watcherOptions = { ...options, interval: (_a = options.pollingInterval) !== null && _a !== void 0 ? _a : constants_1.POLLING_INTERVAL }; // Ensuring a default interval is set\n        const config = { targetPath, options };\n        const start = () => {\n            this._pollers.add(config);\n            this.on(\"close\" /* CLOSE */, stop); // Ensuring polling is stopped on close\n            fs_1.default.watchFile(targetPath, watcherOptions, callback);\n        };\n        const stop = () => {\n            this._pollers.delete(config);\n            this.removeListener(\"close\" /* CLOSE */, stop); // Ensuring there are no leftover listeners\n            fs_1.default.unwatchFile(targetPath, callback);\n        };\n        utils_1.default.lang.attempt(start);\n        return () => utils_1.default.lang.attempt(stop);\n    }\n    async watchUnknownChild(targetPath, options, handler) {\n        if (this.isClosed())\n            return;\n        const watch = () => this.watchPath(targetPath, options, handler);\n        return this.watchFileOnce(targetPath, options, watch);\n    }\n    async watchUnknownTarget(targetPath, options, handler) {\n        if (this.isClosed())\n            return;\n        const watch = () => this.watchPath(targetPath, options, handler);\n        return this.watchPollingOnce(targetPath, options, watch);\n    }\n    async watchPaths(targetPaths, options, handler) {\n        if (this.isClosed())\n            return;\n        targetPaths = utils_1.default.lang.uniq(targetPaths).sort();\n        const isParallelizable = targetPaths.every((targetPath, index) => targetPaths.every((t, i) => i === index || !utils_1.default.fs.isSubPath(targetPath, t))); // All paths are about separate subtrees, so we can start watching in parallel safely //TODO: Find parallelizable chunks rather than using an all or nothing approach\n        if (isParallelizable) { // Watching in parallel\n            await Promise.all(targetPaths.map(targetPath => {\n                return this.watchPath(targetPath, options, handler);\n            }));\n        }\n        else { // Watching serially\n            for (const targetPath of targetPaths) {\n                await this.watchPath(targetPath, options, handler);\n            }\n        }\n    }\n    async watchPath(targetPath, options, handler) {\n        if (this.isClosed())\n            return;\n        targetPath = path_1.default.normalize(targetPath);\n        if (this.isIgnored(targetPath, options.ignore))\n            return;\n        const stats = await utils_1.default.fs.poll(targetPath, options.pollingTimeout);\n        if (!stats) {\n            const parentPath = path_1.default.dirname(targetPath), parentStats = await utils_1.default.fs.poll(parentPath, options.pollingTimeout);\n            if (parentStats === null || parentStats === void 0 ? void 0 : parentStats.isDirectory()) {\n                return this.watchUnknownChild(targetPath, options, handler);\n            }\n            else {\n                return this.watchUnknownTarget(targetPath, options, handler);\n            }\n        }\n        else if (stats.isFile()) {\n            return this.watchFile(targetPath, options, handler);\n        }\n        else if (stats.isDirectory()) {\n            return this.watchDirectory(targetPath, options, handler);\n        }\n        else {\n            this.error(`\"${targetPath}\" is not supported`);\n        }\n    }\n    async watch(target, options, handler = utils_1.default.lang.noop) {\n        if (utils_1.default.lang.isFunction(target))\n            return this.watch([], {}, target);\n        if (utils_1.default.lang.isUndefined(target))\n            return this.watch([], options, handler);\n        if (utils_1.default.lang.isFunction(options))\n            return this.watch(target, {}, options);\n        if (utils_1.default.lang.isUndefined(options))\n            return this.watch(target, {}, handler);\n        if (this.isClosed())\n            return;\n        if (this.isReady())\n            options.readdirMap = undefined; // Only usable before initialization\n        const targetPaths = utils_1.default.lang.castArray(target);\n        targetPaths.forEach(targetPath => this._roots.add(targetPath));\n        await this.watchPaths(targetPaths, options, handler);\n        if (this.isClosed())\n            return;\n        if (handler !== utils_1.default.lang.noop) {\n            this.on(\"all\" /* ALL */, handler);\n        }\n        options.readdirMap = undefined; // Only usable before initialization\n        this.ready();\n    }\n}\n/* EXPORT */\nmodule.exports = Watcher;\nmodule.exports.default = Watcher;\nObject.defineProperty(module.exports, \"__esModule\", { value: true });\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2F0Y2hlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NyYy93YXRjaGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7QUFDQSxZQUFZOzs7OztBQUVaLHNEQUE4QjtBQUM5QixtQ0FBb0M7QUFDcEMsNENBQW9CO0FBQ3BCLGdEQUF3QjtBQUN4QixvRUFBMkM7QUFDM0MsMkNBQTBFO0FBRTFFLHdFQUErQztBQUMvQyxzRUFBNkM7QUFDN0Msc0VBQTZDO0FBQzdDLG9EQUE0QjtBQUc1QixhQUFhO0FBRWIsTUFBTSxPQUFRLFNBQVEscUJBQVk7SUFvQmhDLGlCQUFpQjtJQUVqQixZQUFjLE1BQWdDLEVBQUUsT0FBa0MsRUFBRSxPQUFpQjtRQUVuRyxLQUFLLEVBQUcsQ0FBQztRQUVULElBQUksQ0FBQyxPQUFPLEdBQUcsS0FBSyxDQUFDO1FBQ3JCLElBQUksQ0FBQyxNQUFNLEdBQUcsS0FBSyxDQUFDO1FBQ3BCLElBQUksQ0FBQyxhQUFhLEdBQUcsSUFBSSxpQkFBTyxFQUFHLENBQUM7UUFDcEMsSUFBSSxDQUFDLFlBQVksR0FBRyxJQUFJLENBQUMsYUFBYSxDQUFDLE1BQU0sQ0FBQztRQUM5QyxJQUFJLENBQUMsRUFBRSxzQkFBdUIsR0FBRyxFQUFFLENBQUMsSUFBSSxDQUFDLGFBQWEsQ0FBQyxLQUFLLEVBQUcsQ0FBRSxDQUFDO1FBQ2xFLElBQUksQ0FBQyxVQUFVLEdBQUcsSUFBSSxPQUFPLENBQUcsT0FBTyxDQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUMsRUFBRSxzQkFBdUIsT0FBTyxDQUFFLENBQUUsQ0FBQztRQUNyRixJQUFJLENBQUMsVUFBVSxHQUFHLElBQUksT0FBTyxDQUFHLE9BQU8sQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFDLEVBQUUsc0JBQXVCLE9BQU8sQ0FBRSxDQUFFLENBQUM7UUFDckYsSUFBSSxDQUFDLE9BQU8sR0FBRyxJQUFJLHdCQUFhLENBQUcsSUFBSSxDQUFFLENBQUM7UUFDMUMsSUFBSSxDQUFDLE1BQU0sR0FBRyxJQUFJLEdBQUcsRUFBRyxDQUFDO1FBQ3pCLElBQUksQ0FBQyxPQUFPLEdBQUcsSUFBSSx3QkFBYSxFQUFHLENBQUM7UUFDcEMsSUFBSSxDQUFDLFFBQVEsR0FBRyxJQUFJLEdBQUcsRUFBRyxDQUFDO1FBQzNCLElBQUksQ0FBQyxZQUFZLEdBQUcsSUFBSSxHQUFHLEVBQUcsQ0FBQztRQUMvQixJQUFJLENBQUMsU0FBUyxHQUFHLEVBQUUsQ0FBQztRQUNwQixJQUFJLENBQUMsYUFBYSxHQUFHLE9BQU8sQ0FBQyxPQUFPLEVBQUcsQ0FBQztRQUN4QyxJQUFJLENBQUMsbUJBQW1CLEdBQUcsRUFBRSxDQUFDO1FBRTlCLElBQUksQ0FBQyxLQUFLLENBQUcsTUFBTSxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUUsQ0FBQztJQUUxQyxDQUFDO0lBRUQsU0FBUztJQUVULFFBQVE7UUFFTixPQUFPLElBQUksQ0FBQyxPQUFPLENBQUM7SUFFdEIsQ0FBQztJQUVELFNBQVMsQ0FBRyxVQUFnQixFQUFFLE1BQWU7UUFFM0MsT0FBTyxDQUFDLENBQUMsTUFBTSxJQUFJLENBQUMsQ0FBQyxNQUFNLENBQUcsVUFBVSxDQUFFLENBQUM7SUFFN0MsQ0FBQztJQUVELE9BQU87UUFFTCxPQUFPLElBQUksQ0FBQyxNQUFNLENBQUM7SUFFckIsQ0FBQztJQUVELEtBQUs7UUFFSCxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssRUFBRyxDQUFDO1FBQ3RCLElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFHLENBQUM7UUFDdEIsSUFBSSxDQUFDLE1BQU0sQ0FBQyxLQUFLLEVBQUcsQ0FBQztRQUVyQixJQUFJLENBQUMsYUFBYSxFQUFHLENBQUM7UUFFdEIsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHO1lBQUcsT0FBTyxLQUFLLENBQUM7UUFFckMsSUFBSSxDQUFDLE9BQU8sR0FBRyxJQUFJLENBQUM7UUFFcEIsT0FBTyxJQUFJLENBQUMsSUFBSSxxQkFBdUIsQ0FBQztJQUUxQyxDQUFDO0lBRUQsS0FBSyxDQUFHLFNBQWtCO1FBRXhCLElBQUssSUFBSSxDQUFDLFFBQVEsRUFBRztZQUFHLE9BQU8sS0FBSyxDQUFDO1FBRXJDLE1BQU0sS0FBSyxHQUFHLGVBQUssQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFHLFNBQVMsQ0FBRSxDQUFDO1FBRWpELE9BQU8sSUFBSSxDQUFDLElBQUksc0JBQXVCLEtBQUssQ0FBRSxDQUFDO0lBRWpELENBQUM7SUFFRCxLQUFLLENBQUcsS0FBa0IsRUFBRSxVQUFnQixFQUFFLGNBQXFCO1FBRWpFLElBQUssSUFBSSxDQUFDLFFBQVEsRUFBRztZQUFHLE9BQU8sS0FBSyxDQUFDO1FBRXJDLElBQUksQ0FBQyxJQUFJLGtCQUFxQixLQUFLLEVBQUUsVUFBVSxFQUFFLGNBQWMsQ0FBRSxDQUFDO1FBRWxFLE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBRyxLQUFLLEVBQUUsVUFBVSxFQUFFLGNBQWMsQ0FBRSxDQUFDO0lBRXpELENBQUM7SUFFRCxLQUFLO1FBRUgsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHLElBQUksSUFBSSxDQUFDLE9BQU8sRUFBRztZQUFHLE9BQU8sS0FBSyxDQUFDO1FBRXhELElBQUksQ0FBQyxNQUFNLEdBQUcsSUFBSSxDQUFDO1FBRW5CLE9BQU8sSUFBSSxDQUFDLElBQUkscUJBQXVCLENBQUM7SUFFMUMsQ0FBQztJQUVELFlBQVksQ0FBRyxVQUFnQixFQUFFLE9BQXVCO1FBRXRELEtBQU0sTUFBTSxNQUFNLElBQUksSUFBSSxDQUFDLFFBQVEsRUFBRztZQUVwQyxJQUFLLE1BQU0sQ0FBQyxVQUFVLEtBQUssVUFBVTtnQkFBRyxTQUFTO1lBRWpELElBQUssQ0FBQyxlQUFLLENBQUMsSUFBSSxDQUFDLGVBQWUsQ0FBRyxNQUFNLENBQUMsT0FBTyxFQUFFLE9BQU8sQ0FBRTtnQkFBRyxTQUFTO1lBRXhFLE9BQU8sSUFBSSxDQUFDO1NBRWI7UUFFRCxPQUFPLEtBQUssQ0FBQztJQUVmLENBQUM7SUFFRCxnQkFBZ0IsQ0FBRyxVQUFnQixFQUFFLE9BQXVCO1FBRTFELEtBQU0sTUFBTSxVQUFVLElBQUksSUFBSSxDQUFDLFlBQVksRUFBRztZQUU1QyxJQUFLLFVBQVUsQ0FBQyxVQUFVLEtBQUssVUFBVTtnQkFBRyxTQUFTO1lBRXJELElBQUssQ0FBQyxlQUFLLENBQUMsSUFBSSxDQUFDLGVBQWUsQ0FBRyxVQUFVLENBQUMsT0FBTyxFQUFFLE9BQU8sQ0FBRTtnQkFBRyxTQUFTO1lBRTVFLE9BQU8sSUFBSSxDQUFDO1NBRWI7UUFFRCxPQUFPLEtBQUssQ0FBQztJQUVmLENBQUM7SUFFRCxhQUFhLENBQUcsVUFBaUIsRUFBRSxRQUFlLEVBQUUsWUFBcUIsSUFBSTtRQUUzRSxJQUFLLENBQUMsVUFBVSxFQUFHO1lBRWpCLEtBQU0sTUFBTSxVQUFVLElBQUksSUFBSSxDQUFDLFNBQVMsRUFBRztnQkFFekMsSUFBSSxDQUFDLGFBQWEsQ0FBRyxVQUFVLEVBQUUsUUFBUSxFQUFFLEtBQUssQ0FBRSxDQUFDO2FBRXBEO1NBRUY7YUFBTTtZQUVMLE1BQU0sT0FBTyxHQUFHLElBQUksQ0FBQyxTQUFTLENBQUMsVUFBVSxDQUFDLENBQUM7WUFFM0MsSUFBSyxPQUFPLEVBQUc7Z0JBRWIsS0FBTSxNQUFNLE1BQU0sSUFBSSxPQUFPLEVBQUc7b0JBRTlCLElBQUssUUFBUSxJQUFJLE1BQU0sQ0FBQyxRQUFRLEtBQUssUUFBUTt3QkFBRyxTQUFTO29CQUV6RCxJQUFJLENBQUMsWUFBWSxDQUFHLE1BQU0sQ0FBRSxDQUFDO2lCQUU5QjthQUVGO1lBRUQsSUFBSyxTQUFTLEVBQUc7Z0JBRWYsS0FBTSxNQUFNLGVBQWUsSUFBSSxJQUFJLENBQUMsU0FBUyxFQUFHO29CQUU5QyxJQUFLLENBQUMsZUFBSyxDQUFDLEVBQUUsQ0FBQyxTQUFTLENBQUcsVUFBVSxFQUFFLGVBQWUsQ0FBRTt3QkFBRyxTQUFTO29CQUVwRSxJQUFJLENBQUMsYUFBYSxDQUFHLGVBQWUsRUFBRSxRQUFRLEVBQUUsS0FBSyxDQUFFLENBQUM7aUJBRXpEO2FBRUY7U0FFRjtJQUVILENBQUM7SUFFRCxZQUFZLENBQUcsUUFBa0I7UUFFL0IsT0FBTyxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBRyxHQUFHLEVBQUU7WUFFcEMsT0FBTyxJQUFJLENBQUMsYUFBYSxHQUFHLElBQUksT0FBTyxDQUFHLEtBQUssRUFBQyxPQUFPLEVBQUMsRUFBRTtnQkFFeEQsTUFBTSxRQUFRLEVBQUcsQ0FBQztnQkFFbEIsT0FBTyxFQUFHLENBQUM7WUFFYixDQUFDLENBQUMsQ0FBQztRQUVMLENBQUMsQ0FBQyxDQUFDO0lBRUwsQ0FBQztJQUVELGVBQWU7UUFFYixPQUFPLElBQUksQ0FBQyx1QkFBdUIsQ0FBQztRQUVwQyxNQUFNLFFBQVEsR0FBRyxNQUFNLENBQUMsT0FBTyxDQUFHLElBQUksQ0FBQyxtQkFBbUIsQ0FBRSxDQUFDO1FBRTdELElBQUksQ0FBQyxtQkFBbUIsR0FBRyxFQUFFLENBQUM7UUFFOUIsS0FBTSxNQUFNLENBQUMsVUFBVSxFQUFFLE1BQU0sQ0FBQyxJQUFJLFFBQVEsRUFBRztZQUU3QyxJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxNQUFNLENBQUMsT0FBTyxFQUFFLE1BQU0sQ0FBQyxPQUFPLENBQUUsQ0FBQztTQUUvRDtJQUVILENBQUM7SUFFRCxLQUFLLENBQUMsVUFBVSxDQUFHLE1BQXFCLEVBQUUsa0JBQW1DO1FBRTNFLE1BQU0sRUFBQyxVQUFVLEVBQUMsR0FBRyxNQUFNLENBQUM7UUFFNUIsTUFBTSxPQUFPLEdBQUcsSUFBSSxDQUFDLFNBQVMsQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFFLElBQUksQ0FBQyxTQUFTLENBQUMsVUFBVSxDQUFDLElBQUksRUFBRSxDQUFFLENBQUM7UUFFbEYsT0FBTyxDQUFDLElBQUksQ0FBRyxNQUFNLENBQUUsQ0FBQztRQUV4QixNQUFNLGNBQWMsR0FBRyxJQUFJLHlCQUFjLENBQUcsSUFBSSxFQUFFLE1BQU0sRUFBRSxrQkFBa0IsQ0FBRSxDQUFDO1FBRS9FLE1BQU0sY0FBYyxDQUFDLElBQUksRUFBRyxDQUFDO1FBRTdCLE9BQU8sY0FBYyxDQUFDO0lBRXhCLENBQUM7SUFFRCxZQUFZLENBQUcsTUFBcUI7UUFFbEMsTUFBTSxDQUFDLE9BQU8sQ0FBQyxLQUFLLEVBQUcsQ0FBQztRQUV4QixNQUFNLE9BQU8sR0FBRyxJQUFJLENBQUMsU0FBUyxDQUFDLE1BQU0sQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUVsRCxJQUFLLE9BQU8sRUFBRztZQUViLE1BQU0sS0FBSyxHQUFHLE9BQU8sQ0FBQyxPQUFPLENBQUcsTUFBTSxDQUFFLENBQUM7WUFFekMsT0FBTyxDQUFDLE1BQU0sQ0FBRyxLQUFLLEVBQUUsQ0FBQyxDQUFFLENBQUM7WUFFNUIsSUFBSyxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUc7Z0JBRXJCLE9BQU8sSUFBSSxDQUFDLFNBQVMsQ0FBQyxNQUFNLENBQUMsVUFBVSxDQUFDLENBQUM7YUFFMUM7U0FFRjtRQUVELE1BQU0sUUFBUSxHQUFHLE1BQU0sQ0FBQyxRQUFRLElBQUksTUFBTSxDQUFDLFVBQVUsRUFDL0MsTUFBTSxHQUFHLElBQUksQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFHLFFBQVEsQ0FBRSxDQUFDO1FBRTVDLElBQUssTUFBTSxFQUFHO1lBRVosSUFBSSxDQUFDLG1CQUFtQixDQUFDLFFBQVEsQ0FBQyxHQUFHLE1BQU0sQ0FBQztZQUU1QyxJQUFLLENBQUMsSUFBSSxDQUFDLHVCQUF1QixFQUFHO2dCQUVuQyxJQUFJLENBQUMsdUJBQXVCLEdBQUcsZUFBSyxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUcsR0FBRyxFQUFFLENBQUMsSUFBSSxDQUFDLGVBQWUsRUFBRyxDQUFFLENBQUM7YUFFbkY7U0FFRjtJQUVILENBQUM7SUFFRCxhQUFhLENBQUcsVUFBZ0IsRUFBRSxPQUF1QixFQUFFLE9BQWdCLEVBQUUsUUFBZTtRQUUxRixNQUFNLGNBQWMsR0FBRyxJQUFJLENBQUMsU0FBUyxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBRWxELElBQUssQ0FBQyxFQUFDLGNBQWMsYUFBZCxjQUFjLHVCQUFkLGNBQWMsQ0FBRSxJQUFJLENBQUcsTUFBTSxDQUFDLEVBQUUsQ0FBQyxNQUFNLENBQUMsT0FBTyxLQUFLLE9BQU8sSUFBSSxDQUFFLENBQUMsTUFBTSxDQUFDLFFBQVEsSUFBSSxNQUFNLENBQUMsUUFBUSxLQUFLLFFBQVEsQ0FBRSxJQUFJLE1BQU0sQ0FBQyxPQUFPLENBQUMsTUFBTSxLQUFLLE9BQU8sQ0FBQyxNQUFNLElBQUksQ0FBQyxDQUFDLE1BQU0sQ0FBQyxPQUFPLENBQUMsTUFBTSxLQUFLLENBQUMsQ0FBQyxPQUFPLENBQUMsTUFBTSxJQUFJLENBQUUsQ0FBQyxPQUFPLENBQUMsU0FBUyxJQUFJLE1BQU0sQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFFLEVBQUU7WUFBRyxPQUFPLElBQUksQ0FBQztRQUU3UixJQUFJLGtCQUFrQixHQUFHLGNBQUksQ0FBQyxPQUFPLENBQUcsVUFBVSxDQUFFLENBQUM7UUFFckQsS0FBTSxJQUFJLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxHQUFHLFFBQVEsRUFBRSxLQUFLLEVBQUUsRUFBRztZQUUvQyxNQUFNLGVBQWUsR0FBRyxJQUFJLENBQUMsU0FBUyxDQUFDLGtCQUFrQixDQUFDLENBQUM7WUFFM0QsSUFBSyxDQUFDLEVBQUMsZUFBZSxhQUFmLGVBQWUsdUJBQWYsZUFBZSxDQUFFLElBQUksQ0FBRyxNQUFNLENBQUMsRUFBRSxXQUFDLE9BQUEsQ0FBRSxLQUFLLEtBQUssQ0FBQyxJQUFJLENBQUUsTUFBTSxDQUFDLE9BQU8sQ0FBQyxTQUFTLElBQUksS0FBSyxJQUFJLE9BQUUsTUFBTSxDQUFDLE9BQU8sQ0FBQyxLQUFLLG1DQUFJLGlCQUFLLENBQUUsQ0FBRSxDQUFFLElBQUksTUFBTSxDQUFDLE9BQU8sS0FBSyxPQUFPLElBQUksQ0FBRSxDQUFDLE1BQU0sQ0FBQyxRQUFRLElBQUksTUFBTSxDQUFDLFFBQVEsS0FBSyxRQUFRLENBQUUsSUFBSSxNQUFNLENBQUMsT0FBTyxDQUFDLE1BQU0sS0FBSyxPQUFPLENBQUMsTUFBTSxJQUFJLENBQUMsQ0FBQyxNQUFNLENBQUMsT0FBTyxDQUFDLE1BQU0sS0FBSyxDQUFDLENBQUMsT0FBTyxDQUFDLE1BQU0sSUFBSSxDQUFFLENBQUMsT0FBTyxDQUFDLFNBQVMsSUFBSSxDQUFFLE1BQU0sQ0FBQyxPQUFPLENBQUMsU0FBUyxJQUFJLENBQUUsZ0NBQW9CLElBQUksTUFBTSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEtBQUssS0FBSyxDQUFFLENBQUUsQ0FBRSxDQUFBLEVBQUEsRUFBRTtnQkFBRyxPQUFPLElBQUksQ0FBQztZQUVsYyxJQUFLLENBQUMsZ0NBQW9CO2dCQUFHLE1BQU0sQ0FBQywyQ0FBMkM7WUFFL0UsTUFBTSxzQkFBc0IsR0FBRyxjQUFJLENBQUMsT0FBTyxDQUFHLFVBQVUsQ0FBRSxDQUFDO1lBRTNELElBQUssa0JBQWtCLEtBQUssc0JBQXNCO2dCQUFHLE1BQU07WUFFM0Qsa0JBQWtCLEdBQUcsc0JBQXNCLENBQUM7U0FFN0M7UUFFRCxPQUFPLEtBQUssQ0FBQztJQUVmLENBQUM7SUFFRCxLQUFLLENBQUMsZ0JBQWdCLENBQUcsWUFBb0IsRUFBRSxPQUF1QixFQUFFLE9BQWdCLEVBQUUsUUFBZSxFQUFFLGtCQUFtQztRQUU1SSxJQUFLLElBQUksQ0FBQyxRQUFRLEVBQUc7WUFBRyxPQUFPO1FBRS9CLFlBQVksR0FBRyxlQUFLLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBRyxZQUFZLENBQUUsQ0FBQyxJQUFJLEVBQUcsQ0FBQztRQUV4RCxJQUFJLGtCQUE4QyxDQUFDO1FBRW5ELEtBQU0sTUFBTSxVQUFVLElBQUksWUFBWSxFQUFHO1lBRXZDLElBQUssSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLEVBQUUsT0FBTyxDQUFDLE1BQU0sQ0FBRTtnQkFBRyxTQUFTO1lBRTlELElBQUssSUFBSSxDQUFDLGFBQWEsQ0FBRyxVQUFVLEVBQUUsT0FBTyxFQUFFLE9BQU8sRUFBRSxRQUFRLENBQUU7Z0JBQUcsU0FBUztZQUU5RSxJQUFJO2dCQUVGLE1BQU0sY0FBYyxHQUFHLENBQUUsQ0FBQyxPQUFPLENBQUMsU0FBUyxJQUFJLENBQUUsZ0NBQW9CLElBQUksT0FBTyxDQUFDLE1BQU0sS0FBSyxLQUFLLENBQUUsQ0FBRSxDQUFDLENBQUMsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLEVBQUUsR0FBRyxPQUFPLEVBQUUsU0FBUyxFQUFFLEtBQUssRUFBRSxFQUFFLDZEQUE2RDtnQkFDM00sT0FBTyxHQUFHLFlBQUUsQ0FBQyxLQUFLLENBQUcsVUFBVSxFQUFFLGNBQWMsQ0FBRSxFQUNqRCxhQUFhLEdBQWtCLEVBQUUsT0FBTyxFQUFFLE9BQU8sRUFBRSxPQUFPLEVBQUUsVUFBVSxFQUFFLFFBQVEsRUFBRSxFQUNsRixjQUFjLEdBQUcsa0JBQWtCLEdBQUcsTUFBTSxJQUFJLENBQUMsVUFBVSxDQUFHLGFBQWEsRUFBRSxrQkFBa0IsQ0FBRSxDQUFDO2dCQUV4RyxNQUFNLE1BQU0sR0FBRyxJQUFJLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBRyxRQUFRLElBQUksVUFBVSxDQUFFLENBQUM7Z0JBRTFELElBQUssTUFBTSxFQUFHO29CQUVaLE1BQU0sYUFBYSxHQUFtQixFQUFFLEdBQUcsT0FBTyxFQUFFLGFBQWEsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLEtBQUssRUFBRSxFQUFFLG1EQUFtRDtvQkFDMUksZ0JBQWdCLEdBQUcsY0FBSSxDQUFDLE9BQU8sQ0FBRyxVQUFVLENBQUUsRUFDOUMsY0FBYyxHQUFHLFVBQVUsQ0FBQztvQkFFbEMsTUFBTSxJQUFJLENBQUMsZ0JBQWdCLENBQUcsQ0FBQyxnQkFBZ0IsQ0FBQyxFQUFFLGFBQWEsRUFBRSxPQUFPLEVBQUUsY0FBYyxFQUFFLGNBQWMsQ0FBRSxDQUFDO29CQUUzRywrR0FBK0c7b0JBRS9HLG1CQUFtQjtvQkFFbkIsZ0hBQWdIO29CQUVoSCxvRUFBb0U7b0JBRXBFLDREQUE0RDtvQkFFNUQsdUNBQXVDO29CQUN2Qyw2Q0FBNkM7b0JBRTdDLElBQUk7aUJBRUw7YUFFRjtZQUFDLE9BQVEsS0FBYyxFQUFHO2dCQUV6QixJQUFJLENBQUMsS0FBSyxDQUFHLEtBQUssQ0FBRSxDQUFDO2FBRXRCO1NBRUY7UUFFRCxPQUFPLGtCQUFrQixDQUFDO0lBRTVCLENBQUM7SUFFRCxLQUFLLENBQUMsY0FBYyxDQUFHLFVBQWdCLEVBQUUsT0FBdUIsRUFBRSxPQUFnQixFQUFFLFFBQWUsRUFBRSxrQkFBbUM7O1FBRXRJLElBQUssSUFBSSxDQUFDLFFBQVEsRUFBRztZQUFHLE9BQU87UUFFL0IsSUFBSyxJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxPQUFPLENBQUMsTUFBTSxDQUFFO1lBQUcsT0FBTztRQUU1RCxJQUFLLENBQUMsT0FBTyxDQUFDLFNBQVMsSUFBSSxDQUFFLGdDQUFvQixJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssS0FBSyxDQUFFLEVBQUc7WUFFaEYsT0FBTyxJQUFJLENBQUMsWUFBWSxDQUFHLEdBQUcsRUFBRTtnQkFFOUIsT0FBTyxJQUFJLENBQUMsZ0JBQWdCLENBQUcsQ0FBQyxVQUFVLENBQUMsRUFBRSxPQUFPLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxrQkFBa0IsQ0FBRSxDQUFDO1lBRWhHLENBQUMsQ0FBQyxDQUFDO1NBRUo7YUFBTTtZQUVMLE9BQU8sR0FBRyxFQUFFLEdBQUcsT0FBTyxFQUFFLFNBQVMsRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDLDJDQUEyQztZQUV0RixNQUFNLEtBQUssU0FBRyxPQUFPLENBQUMsS0FBSyxtQ0FBSSxpQkFBSyxFQUM5QixDQUFDLGNBQWMsQ0FBQyxHQUFHLE1BQU0sZUFBSyxDQUFDLEVBQUUsQ0FBQyxPQUFPLENBQUcsVUFBVSxFQUFFLE9BQU8sQ0FBQyxNQUFNLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxZQUFZLEVBQUUsT0FBTyxDQUFDLFVBQVUsQ0FBRSxDQUFDO1lBRTdILE9BQU8sSUFBSSxDQUFDLFlBQVksQ0FBRyxLQUFLLElBQUksRUFBRTtnQkFFcEMsTUFBTSxjQUFjLEdBQUcsTUFBTSxJQUFJLENBQUMsZ0JBQWdCLENBQUcsQ0FBQyxVQUFVLENBQUMsRUFBRSxPQUFPLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxrQkFBa0IsQ0FBRSxDQUFDO2dCQUVwSCxJQUFLLGNBQWMsQ0FBQyxNQUFNLEVBQUc7b0JBRTNCLE1BQU0sZUFBZSxHQUFHLHdCQUFhLENBQUcsVUFBVSxFQUFFLGNBQUksQ0FBQyxHQUFHLENBQUUsQ0FBQyxNQUFNLENBQUM7b0JBRXRFLEtBQU0sTUFBTSxhQUFhLElBQUksY0FBYyxFQUFHO3dCQUU1QyxNQUFNLGtCQUFrQixHQUFHLHdCQUFhLENBQUcsYUFBYSxFQUFFLGNBQUksQ0FBQyxHQUFHLENBQUUsQ0FBQyxNQUFNLEVBQ3JFLFFBQVEsR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFHLENBQUMsRUFBRSxLQUFLLEdBQUcsQ0FBRSxrQkFBa0IsR0FBRyxlQUFlLENBQUUsQ0FBRSxFQUMzRSxVQUFVLEdBQUcsRUFBRSxHQUFHLE9BQU8sRUFBRSxLQUFLLEVBQUUsUUFBUSxFQUFFLENBQUMsQ0FBQyxrRUFBa0U7d0JBRXRILE1BQU0sSUFBSSxDQUFDLGdCQUFnQixDQUFHLENBQUMsYUFBYSxDQUFDLEVBQUUsVUFBVSxFQUFFLE9BQU8sRUFBRSxRQUFRLEVBQUUsa0JBQWtCLElBQUksY0FBYyxDQUFFLENBQUM7cUJBRXRIO2lCQUVGO1lBRUgsQ0FBQyxDQUFDLENBQUM7U0FFSjtJQUVILENBQUM7SUFFRCxLQUFLLENBQUMsYUFBYSxDQUFHLFFBQWMsRUFBRSxPQUF1QixFQUFFLFFBQWtCO1FBRS9FLElBQUssSUFBSSxDQUFDLFFBQVEsRUFBRztZQUFHLE9BQU87UUFFL0IsT0FBTyxHQUFHLEVBQUUsR0FBRyxPQUFPLEVBQUUsYUFBYSxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUMsMkNBQTJDO1FBRTNGLElBQUssSUFBSSxDQUFDLGdCQUFnQixDQUFHLFFBQVEsRUFBRSxPQUFPLENBQUU7WUFBRyxPQUFPO1FBRTFELE1BQU0sTUFBTSxHQUFxQixFQUFFLFVBQVUsRUFBRSxRQUFRLEVBQUUsT0FBTyxFQUFFLENBQUM7UUFFbkUsTUFBTSxPQUFPLEdBQUcsQ0FBRSxLQUFrQixFQUFFLFVBQWdCLEVBQUcsRUFBRTtZQUN6RCxJQUFLLFVBQVUsS0FBSyxRQUFRO2dCQUFHLE9BQU87WUFDdEMsSUFBSSxFQUFHLENBQUM7WUFDUixRQUFRLEVBQUcsQ0FBQztRQUNkLENBQUMsQ0FBQztRQUVGLE1BQU0sT0FBTyxHQUFHLElBQUksT0FBTyxDQUFHLE9BQU8sQ0FBRSxDQUFDO1FBRXhDLE1BQU0sS0FBSyxHQUFHLEdBQVMsRUFBRTtZQUN2QixJQUFJLENBQUMsWUFBWSxDQUFDLEdBQUcsQ0FBRyxNQUFNLENBQUUsQ0FBQztZQUNqQyxJQUFJLENBQUMsRUFBRSxzQkFBdUIsSUFBSSxDQUFFLENBQUMsQ0FBQyw4Q0FBOEM7WUFDcEYsT0FBTyxDQUFDLFNBQVMsQ0FBRyxRQUFRLEVBQUUsT0FBTyxFQUFFLE9BQU8sQ0FBRSxDQUFDO1FBQ25ELENBQUMsQ0FBQztRQUVGLE1BQU0sSUFBSSxHQUFHLEdBQVMsRUFBRTtZQUN0QixJQUFJLENBQUMsWUFBWSxDQUFDLE1BQU0sQ0FBRyxNQUFNLENBQUUsQ0FBQztZQUNwQyxJQUFJLENBQUMsY0FBYyxzQkFBdUIsSUFBSSxDQUFFLENBQUMsQ0FBQywyQ0FBMkM7WUFDN0YsT0FBTyxDQUFDLEtBQUssRUFBRyxDQUFDO1FBQ25CLENBQUMsQ0FBQztRQUVGLE9BQU8sS0FBSyxFQUFHLENBQUM7SUFFbEIsQ0FBQztJQUVELEtBQUssQ0FBQyxTQUFTLENBQUcsUUFBYyxFQUFFLE9BQXVCLEVBQUUsT0FBZ0I7UUFFekUsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHO1lBQUcsT0FBTztRQUUvQixJQUFLLElBQUksQ0FBQyxTQUFTLENBQUcsUUFBUSxFQUFFLE9BQU8sQ0FBQyxNQUFNLENBQUU7WUFBRyxPQUFPO1FBRTFELE9BQU8sR0FBRyxFQUFFLEdBQUcsT0FBTyxFQUFFLFNBQVMsRUFBRSxLQUFLLEVBQUUsQ0FBQyxDQUFDLDRDQUE0QztRQUV4RixNQUFNLFVBQVUsR0FBRyxjQUFJLENBQUMsT0FBTyxDQUFHLFFBQVEsQ0FBRSxDQUFDO1FBRTdDLE9BQU8sSUFBSSxDQUFDLGNBQWMsQ0FBRyxVQUFVLEVBQUUsT0FBTyxFQUFFLE9BQU8sRUFBRSxRQUFRLENBQUUsQ0FBQztJQUV4RSxDQUFDO0lBRUQsS0FBSyxDQUFDLGdCQUFnQixDQUFHLFVBQWdCLEVBQUUsT0FBdUIsRUFBRSxRQUFrQjtRQUVwRixJQUFLLElBQUksQ0FBQyxRQUFRLEVBQUc7WUFBRyxPQUFPO1FBRS9CLElBQUksTUFBTSxHQUFHLEtBQUssQ0FBQztRQUVuQixNQUFNLE1BQU0sR0FBRyxJQUFJLHdCQUFhLEVBQUcsQ0FBQztRQUVwQyxNQUFNLFFBQVEsR0FBRyxNQUFNLElBQUksQ0FBQyxZQUFZLENBQUcsVUFBVSxFQUFFLE9BQU8sRUFBRSxLQUFLLElBQUksRUFBRTtZQUV6RSxJQUFLLE1BQU07Z0JBQUcsT0FBTztZQUVyQixNQUFNLE1BQU0sR0FBRyxNQUFNLE1BQU0sQ0FBQyxNQUFNLENBQUcsVUFBVSxFQUFFLE9BQU8sQ0FBQyxjQUFjLENBQUUsQ0FBQztZQUUxRSxJQUFLLENBQUMsTUFBTSxDQUFDLE1BQU07Z0JBQUcsT0FBTyxDQUFDLHFDQUFxQztZQUVuRSxJQUFLLE1BQU07Z0JBQUcsT0FBTyxDQUFDLDZEQUE2RDtZQUVuRixNQUFNLEdBQUcsSUFBSSxDQUFDO1lBRWQsUUFBUSxFQUFHLENBQUM7WUFFWixRQUFRLEVBQUcsQ0FBQztRQUVkLENBQUMsQ0FBQyxDQUFDO0lBRUwsQ0FBQztJQUVELEtBQUssQ0FBQyxZQUFZLENBQUcsVUFBZ0IsRUFBRSxPQUF1QixFQUFFLFFBQWtCOztRQUVoRixJQUFLLElBQUksQ0FBQyxRQUFRLEVBQUc7WUFBRyxPQUFPLGVBQUssQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDO1FBRS9DLElBQUssSUFBSSxDQUFDLFlBQVksQ0FBRyxVQUFVLEVBQUUsT0FBTyxDQUFFO1lBQUcsT0FBTyxlQUFLLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQztRQUV4RSxNQUFNLGNBQWMsR0FBRyxFQUFFLEdBQUcsT0FBTyxFQUFFLFFBQVEsUUFBRSxPQUFPLENBQUMsZUFBZSxtQ0FBSSw0QkFBZ0IsRUFBRSxDQUFDLENBQUMscUNBQXFDO1FBRW5JLE1BQU0sTUFBTSxHQUFpQixFQUFFLFVBQVUsRUFBRSxPQUFPLEVBQUUsQ0FBQztRQUVyRCxNQUFNLEtBQUssR0FBRyxHQUFTLEVBQUU7WUFDdkIsSUFBSSxDQUFDLFFBQVEsQ0FBQyxHQUFHLENBQUcsTUFBTSxDQUFFLENBQUM7WUFDN0IsSUFBSSxDQUFDLEVBQUUsc0JBQXVCLElBQUksQ0FBRSxDQUFDLENBQUMsdUNBQXVDO1lBQzdFLFlBQUUsQ0FBQyxTQUFTLENBQUcsVUFBVSxFQUFFLGNBQWMsRUFBRSxRQUFRLENBQUUsQ0FBQztRQUN4RCxDQUFDLENBQUM7UUFFRixNQUFNLElBQUksR0FBRyxHQUFTLEVBQUU7WUFDdEIsSUFBSSxDQUFDLFFBQVEsQ0FBQyxNQUFNLENBQUcsTUFBTSxDQUFFLENBQUM7WUFDaEMsSUFBSSxDQUFDLGNBQWMsc0JBQXVCLElBQUksQ0FBRSxDQUFDLENBQUMsMkNBQTJDO1lBQzdGLFlBQUUsQ0FBQyxXQUFXLENBQUcsVUFBVSxFQUFFLFFBQVEsQ0FBRSxDQUFDO1FBQzFDLENBQUMsQ0FBQztRQUVGLGVBQUssQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFHLEtBQUssQ0FBRSxDQUFDO1FBRTdCLE9BQU8sR0FBRyxFQUFFLENBQUMsZUFBSyxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUcsSUFBSSxDQUFFLENBQUM7SUFFM0MsQ0FBQztJQUVELEtBQUssQ0FBQyxpQkFBaUIsQ0FBRyxVQUFnQixFQUFFLE9BQXVCLEVBQUUsT0FBZ0I7UUFFbkYsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHO1lBQUcsT0FBTztRQUUvQixNQUFNLEtBQUssR0FBRyxHQUFHLEVBQUUsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxPQUFPLEVBQUUsT0FBTyxDQUFFLENBQUM7UUFFcEUsT0FBTyxJQUFJLENBQUMsYUFBYSxDQUFHLFVBQVUsRUFBRSxPQUFPLEVBQUUsS0FBSyxDQUFFLENBQUM7SUFFM0QsQ0FBQztJQUVELEtBQUssQ0FBQyxrQkFBa0IsQ0FBRyxVQUFnQixFQUFFLE9BQXVCLEVBQUUsT0FBZ0I7UUFFcEYsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHO1lBQUcsT0FBTztRQUUvQixNQUFNLEtBQUssR0FBRyxHQUFHLEVBQUUsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxPQUFPLEVBQUUsT0FBTyxDQUFFLENBQUM7UUFFcEUsT0FBTyxJQUFJLENBQUMsZ0JBQWdCLENBQUcsVUFBVSxFQUFFLE9BQU8sRUFBRSxLQUFLLENBQUUsQ0FBQztJQUU5RCxDQUFDO0lBRUQsS0FBSyxDQUFDLFVBQVUsQ0FBRyxXQUFtQixFQUFFLE9BQXVCLEVBQUUsT0FBZ0I7UUFFL0UsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHO1lBQUcsT0FBTztRQUUvQixXQUFXLEdBQUcsZUFBSyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUcsV0FBVyxDQUFFLENBQUMsSUFBSSxFQUFHLENBQUM7UUFFdEQsTUFBTSxnQkFBZ0IsR0FBRyxXQUFXLENBQUMsS0FBSyxDQUFHLENBQUUsVUFBVSxFQUFFLEtBQUssRUFBRyxFQUFFLENBQUMsV0FBVyxDQUFDLEtBQUssQ0FBRyxDQUFFLENBQUMsRUFBRSxDQUFDLEVBQUcsRUFBRSxDQUFDLENBQUMsS0FBSyxLQUFLLElBQUksQ0FBQyxlQUFLLENBQUMsRUFBRSxDQUFDLFNBQVMsQ0FBRyxVQUFVLEVBQUUsQ0FBQyxDQUFFLENBQUUsQ0FBRSxDQUFDLENBQUMscUtBQXFLO1FBRXJVLElBQUssZ0JBQWdCLEVBQUcsRUFBRSx1QkFBdUI7WUFFL0MsTUFBTSxPQUFPLENBQUMsR0FBRyxDQUFHLFdBQVcsQ0FBQyxHQUFHLENBQUcsVUFBVSxDQUFDLEVBQUU7Z0JBRWpELE9BQU8sSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLEVBQUUsT0FBTyxFQUFFLE9BQU8sQ0FBRSxDQUFDO1lBRXpELENBQUMsQ0FBQyxDQUFDLENBQUM7U0FFTDthQUFNLEVBQUUsb0JBQW9CO1lBRTNCLEtBQU0sTUFBTSxVQUFVLElBQUksV0FBVyxFQUFHO2dCQUV0QyxNQUFNLElBQUksQ0FBQyxTQUFTLENBQUcsVUFBVSxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUUsQ0FBQzthQUV2RDtTQUVGO0lBRUgsQ0FBQztJQUVELEtBQUssQ0FBQyxTQUFTLENBQUcsVUFBZ0IsRUFBRSxPQUF1QixFQUFFLE9BQWdCO1FBRTNFLElBQUssSUFBSSxDQUFDLFFBQVEsRUFBRztZQUFHLE9BQU87UUFFL0IsVUFBVSxHQUFHLGNBQUksQ0FBQyxTQUFTLENBQUcsVUFBVSxDQUFFLENBQUM7UUFFM0MsSUFBSyxJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxPQUFPLENBQUMsTUFBTSxDQUFFO1lBQUcsT0FBTztRQUU1RCxNQUFNLEtBQUssR0FBRyxNQUFNLGVBQUssQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFHLFVBQVUsRUFBRSxPQUFPLENBQUMsY0FBYyxDQUFFLENBQUM7UUFFekUsSUFBSyxDQUFDLEtBQUssRUFBRztZQUVaLE1BQU0sVUFBVSxHQUFHLGNBQUksQ0FBQyxPQUFPLENBQUcsVUFBVSxDQUFFLEVBQ3hDLFdBQVcsR0FBRyxNQUFNLGVBQUssQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFHLFVBQVUsRUFBRSxPQUFPLENBQUMsY0FBYyxDQUFFLENBQUM7WUFFL0UsSUFBSyxXQUFXLGFBQVgsV0FBVyx1QkFBWCxXQUFXLENBQUUsV0FBVyxJQUFNO2dCQUVqQyxPQUFPLElBQUksQ0FBQyxpQkFBaUIsQ0FBRyxVQUFVLEVBQUUsT0FBTyxFQUFFLE9BQU8sQ0FBRSxDQUFDO2FBRWhFO2lCQUFNO2dCQUVMLE9BQU8sSUFBSSxDQUFDLGtCQUFrQixDQUFHLFVBQVUsRUFBRSxPQUFPLEVBQUUsT0FBTyxDQUFFLENBQUM7YUFFakU7U0FFRjthQUFNLElBQUssS0FBSyxDQUFDLE1BQU0sRUFBRyxFQUFHO1lBRTVCLE9BQU8sSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLEVBQUUsT0FBTyxFQUFFLE9BQU8sQ0FBRSxDQUFDO1NBRXhEO2FBQU0sSUFBSyxLQUFLLENBQUMsV0FBVyxFQUFHLEVBQUc7WUFFakMsT0FBTyxJQUFJLENBQUMsY0FBYyxDQUFHLFVBQVUsRUFBRSxPQUFPLEVBQUUsT0FBTyxDQUFFLENBQUM7U0FFN0Q7YUFBTTtZQUVMLElBQUksQ0FBQyxLQUFLLENBQUcsSUFBSSxVQUFVLG9CQUFvQixDQUFFLENBQUM7U0FFbkQ7SUFFSCxDQUFDO0lBRUQsS0FBSyxDQUFDLEtBQUssQ0FBRyxNQUFnQyxFQUFFLE9BQWtDLEVBQUUsVUFBbUIsZUFBSyxDQUFDLElBQUksQ0FBQyxJQUFJO1FBRXBILElBQUssZUFBSyxDQUFDLElBQUksQ0FBQyxVQUFVLENBQUcsTUFBTSxDQUFFO1lBQUcsT0FBTyxJQUFJLENBQUMsS0FBSyxDQUFHLEVBQUUsRUFBRSxFQUFFLEVBQUUsTUFBTSxDQUFFLENBQUM7UUFFN0UsSUFBSyxlQUFLLENBQUMsSUFBSSxDQUFDLFdBQVcsQ0FBRyxNQUFNLENBQUU7WUFBRyxPQUFPLElBQUksQ0FBQyxLQUFLLENBQUcsRUFBRSxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUUsQ0FBQztRQUVwRixJQUFLLGVBQUssQ0FBQyxJQUFJLENBQUMsVUFBVSxDQUFHLE9BQU8sQ0FBRTtZQUFHLE9BQU8sSUFBSSxDQUFDLEtBQUssQ0FBRyxNQUFNLEVBQUUsRUFBRSxFQUFFLE9BQU8sQ0FBRSxDQUFDO1FBRW5GLElBQUssZUFBSyxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUcsT0FBTyxDQUFFO1lBQUcsT0FBTyxJQUFJLENBQUMsS0FBSyxDQUFHLE1BQU0sRUFBRSxFQUFFLEVBQUUsT0FBTyxDQUFFLENBQUM7UUFFcEYsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHO1lBQUcsT0FBTztRQUUvQixJQUFLLElBQUksQ0FBQyxPQUFPLEVBQUc7WUFBRyxPQUFPLENBQUMsVUFBVSxHQUFHLFNBQVMsQ0FBQyxDQUFDLG9DQUFvQztRQUUzRixNQUFNLFdBQVcsR0FBRyxlQUFLLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBRyxNQUFNLENBQUUsQ0FBQztRQUVwRCxXQUFXLENBQUMsT0FBTyxDQUFHLFVBQVUsQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUcsVUFBVSxDQUFFLENBQUUsQ0FBQztRQUVyRSxNQUFNLElBQUksQ0FBQyxVQUFVLENBQUcsV0FBVyxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUUsQ0FBQztRQUV4RCxJQUFLLElBQUksQ0FBQyxRQUFRLEVBQUc7WUFBRyxPQUFPO1FBRS9CLElBQUssT0FBTyxLQUFLLGVBQUssQ0FBQyxJQUFJLENBQUMsSUFBSSxFQUFHO1lBRWpDLElBQUksQ0FBQyxFQUFFLGtCQUFxQixPQUFPLENBQUUsQ0FBQztTQUV2QztRQUVELE9BQU8sQ0FBQyxVQUFVLEdBQUcsU0FBUyxDQUFDLENBQUMsb0NBQW9DO1FBRXBFLElBQUksQ0FBQyxLQUFLLEVBQUcsQ0FBQztJQUVoQixDQUFDO0NBRUY7QUFFRCxZQUFZO0FBRVosa0JBQWUsT0FBTyxDQUFDIn0="
  },
  {
    "path": "server/libs/watcher/watcher_handler.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst path_1 = __importDefault(require(\"path\"));\nconst constants_1 = require(\"./constants\");\nconst utils_1 = __importDefault(require(\"./utils\"));\n/* WATCHER HANDLER */\nclass WatcherHandler {\n    /* CONSTRUCTOR */\n    constructor(watcher, config, base) {\n        this.base = base;\n        this.watcher = watcher;\n        this.handler = config.handler;\n        this.fswatcher = config.watcher;\n        this.options = config.options;\n        this.folderPath = config.folderPath;\n        this.filePath = config.filePath;\n        this['handlerBatched'] = this.base ? this.base.onWatcherEvent.bind(this.base) : this._makeHandlerBatched(this.options.debounce); //UGLY\n    }\n    /* HELPERS */\n    _isSubRoot(targetPath) {\n        if (this.filePath) {\n            return targetPath === this.filePath;\n        }\n        else {\n            return targetPath === this.folderPath || utils_1.default.fs.isSubPath(this.folderPath, targetPath);\n        }\n    }\n    _makeHandlerBatched(delay = constants_1.DEBOUNCE) {\n        return (() => {\n            let lock = this.watcher._readyWait, // ~Ensuring no two flushes are active in parallel, or before the watcher is ready\n            initials = [], regulars = new Set();\n            const flush = async (initials, regulars) => {\n                const initialEvents = this.options.ignoreInitial ? [] : initials, regularEvents = await this.eventsPopulate([...regulars]), events = this.eventsDeduplicate([...initialEvents, ...regularEvents]);\n                this.onTargetEvents(events);\n            };\n            const flushDebounced = utils_1.default.lang.debounce(() => {\n                if (this.watcher.isClosed())\n                    return;\n                lock = flush(initials, regulars);\n                initials = [];\n                regulars = new Set();\n            }, delay);\n            return async (event, targetPath = '', isInitial = false) => {\n                if (isInitial) { // Poll immediately\n                    await this.eventsPopulate([targetPath], initials, true);\n                }\n                else { // Poll later\n                    regulars.add(targetPath);\n                }\n                lock.then(flushDebounced);\n            };\n        })();\n    }\n    /* EVENT HELPERS */\n    eventsDeduplicate(events) {\n        if (events.length < 2)\n            return events;\n        const targetsEventPrev = {};\n        return events.reduce((acc, event) => {\n            const [targetEvent, targetPath] = event, targetEventPrev = targetsEventPrev[targetPath];\n            if (targetEvent === targetEventPrev)\n                return acc; // Same event, ignoring\n            if (targetEvent === \"change\" /* CHANGE */ && targetEventPrev === \"add\" /* ADD */)\n                return acc; // \"change\" after \"add\", ignoring\n            targetsEventPrev[targetPath] = targetEvent;\n            acc.push(event);\n            return acc;\n        }, []);\n    }\n    async eventsPopulate(targetPaths, events = [], isInitial = false) {\n        await Promise.all(targetPaths.map(async (targetPath) => {\n            const targetEvents = await this.watcher._poller.update(targetPath, this.options.pollingTimeout);\n            await Promise.all(targetEvents.map(async (event) => {\n                events.push([event, targetPath]);\n                if (event === \"addDir\" /* ADD_DIR */) {\n                    await this.eventsPopulateAddDir(targetPaths, targetPath, events, isInitial);\n                }\n                else if (event === \"unlinkDir\" /* UNLINK_DIR */) {\n                    await this.eventsPopulateUnlinkDir(targetPaths, targetPath, events, isInitial);\n                }\n            }));\n        }));\n        return events;\n    }\n    ;\n    async eventsPopulateAddDir(targetPaths, targetPath, events = [], isInitial = false) {\n        var _a, _b;\n        if (isInitial)\n            return events;\n        const depth = this.options.recursive ? (_a = this.options.depth) !== null && _a !== void 0 ? _a : constants_1.DEPTH : Math.min(1, (_b = this.options.depth) !== null && _b !== void 0 ? _b : constants_1.DEPTH), [directories, files] = await utils_1.default.fs.readdir(targetPath, this.options.ignore, depth, this.watcher._closeSignal), targetSubPaths = [...directories, ...files];\n        await Promise.all(targetSubPaths.map(targetSubPath => {\n            if (this.watcher.isIgnored(targetSubPath, this.options.ignore))\n                return;\n            if (targetPaths.includes(targetSubPath))\n                return;\n            return this.eventsPopulate([targetSubPath], events, true);\n        }));\n        return events;\n    }\n    async eventsPopulateUnlinkDir(targetPaths, targetPath, events = [], isInitial = false) {\n        if (isInitial)\n            return events;\n        for (const folderPathOther of this.watcher._poller.stats.keys()) {\n            if (!utils_1.default.fs.isSubPath(targetPath, folderPathOther))\n                continue;\n            if (targetPaths.includes(folderPathOther))\n                continue;\n            await this.eventsPopulate([folderPathOther], events, true);\n        }\n        return events;\n    }\n    /* EVENT HANDLERS */\n    onTargetAdd(targetPath) {\n        if (this._isSubRoot(targetPath)) {\n            if (this.options.renameDetection) {\n                this.watcher._locker.getLockTargetAdd(targetPath, this.options.renameTimeout);\n            }\n            else {\n                this.watcher.event(\"add\" /* ADD */, targetPath);\n            }\n        }\n    }\n    onTargetAddDir(targetPath) {\n        if (targetPath !== this.folderPath && this.options.recursive && (!constants_1.HAS_NATIVE_RECURSION && this.options.native !== false)) {\n            this.watcher.watchDirectory(targetPath, this.options, this.handler, undefined, this.base || this);\n        }\n        if (this._isSubRoot(targetPath)) {\n            if (this.options.renameDetection) {\n                this.watcher._locker.getLockTargetAddDir(targetPath, this.options.renameTimeout);\n            }\n            else {\n                this.watcher.event(\"addDir\" /* ADD_DIR */, targetPath);\n            }\n        }\n    }\n    onTargetChange(targetPath) {\n        if (this._isSubRoot(targetPath)) {\n            this.watcher.event(\"change\" /* CHANGE */, targetPath);\n        }\n    }\n    onTargetUnlink(targetPath) {\n        this.watcher.watchersClose(path_1.default.dirname(targetPath), targetPath, false);\n        if (this._isSubRoot(targetPath)) {\n            if (this.options.renameDetection) {\n                this.watcher._locker.getLockTargetUnlink(targetPath, this.options.renameTimeout);\n            }\n            else {\n                this.watcher.event(\"unlink\" /* UNLINK */, targetPath);\n            }\n        }\n    }\n    onTargetUnlinkDir(targetPath) {\n        this.watcher.watchersClose(path_1.default.dirname(targetPath), targetPath, false);\n        this.watcher.watchersClose(targetPath);\n        if (this._isSubRoot(targetPath)) {\n            if (this.options.renameDetection) {\n                this.watcher._locker.getLockTargetUnlinkDir(targetPath, this.options.renameTimeout);\n            }\n            else {\n                this.watcher.event(\"unlinkDir\" /* UNLINK_DIR */, targetPath);\n            }\n        }\n    }\n    onTargetEvent(event) {\n        const [targetEvent, targetPath] = event;\n        if (targetEvent === \"add\" /* ADD */) {\n            this.onTargetAdd(targetPath);\n        }\n        else if (targetEvent === \"addDir\" /* ADD_DIR */) {\n            this.onTargetAddDir(targetPath);\n        }\n        else if (targetEvent === \"change\" /* CHANGE */) {\n            this.onTargetChange(targetPath);\n        }\n        else if (targetEvent === \"unlink\" /* UNLINK */) {\n            this.onTargetUnlink(targetPath);\n        }\n        else if (targetEvent === \"unlinkDir\" /* UNLINK_DIR */) {\n            this.onTargetUnlinkDir(targetPath);\n        }\n    }\n    onTargetEvents(events) {\n        for (const event of events) {\n            this.onTargetEvent(event);\n        }\n    }\n    onWatcherEvent(event, targetPath, isInitial = false) {\n        return this['handlerBatched'](event, targetPath, isInitial);\n    }\n    onWatcherChange(event = \"change\" /* CHANGE */, targetName) {\n        if (this.watcher.isClosed())\n            return;\n        const targetPath = path_1.default.resolve(this.folderPath, targetName || '');\n        if (this.filePath && targetPath !== this.folderPath && targetPath !== this.filePath)\n            return;\n        if (this.watcher.isIgnored(targetPath, this.options.ignore))\n            return;\n        this.onWatcherEvent(event, targetPath);\n    }\n    onWatcherError(error) {\n        if (constants_1.IS_WINDOWS && error.code === 'EPERM') { // This may happen when a folder is deleted\n            this.onWatcherChange(\"change\" /* CHANGE */, '');\n        }\n        else {\n            this.watcher.error(error);\n        }\n    }\n    /* API */\n    async init() {\n        await this.initWatcherEvents();\n        await this.initInitialEvents();\n    }\n    async initWatcherEvents() {\n        const onChange = this.onWatcherChange.bind(this);\n        this.fswatcher.on(\"change\" /* CHANGE */, onChange);\n        const onError = this.onWatcherError.bind(this);\n        this.fswatcher.on(\"error\" /* ERROR */, onError);\n    }\n    async initInitialEvents() {\n        var _a, _b;\n        const isInitial = !this.watcher.isReady(); // \"isInitial\" => is ignorable via the \"ignoreInitial\" option\n        if (this.filePath) { // Single initial path\n            if (this.watcher._poller.stats.has(this.filePath))\n                return; // Already polled\n            await this.onWatcherEvent(\"change\" /* CHANGE */, this.filePath, isInitial);\n        }\n        else { // Multiple initial paths\n            const depth = this.options.recursive && (constants_1.HAS_NATIVE_RECURSION && this.options.native !== false) ? (_a = this.options.depth) !== null && _a !== void 0 ? _a : constants_1.DEPTH : Math.min(1, (_b = this.options.depth) !== null && _b !== void 0 ? _b : constants_1.DEPTH), [directories, files] = await utils_1.default.fs.readdir(this.folderPath, this.options.ignore, depth, this.watcher._closeSignal, this.options.readdirMap), targetPaths = [this.folderPath, ...directories, ...files];\n            await Promise.all(targetPaths.map(targetPath => {\n                if (this.watcher._poller.stats.has(targetPath))\n                    return; // Already polled\n                if (this.watcher.isIgnored(targetPath, this.options.ignore))\n                    return;\n                return this.onWatcherEvent(\"change\" /* CHANGE */, targetPath, isInitial);\n            }));\n        }\n    }\n}\n/* EXPORT */\nexports.default = WatcherHandler;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2F0Y2hlcl9oYW5kbGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL3dhdGNoZXJfaGFuZGxlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQ0EsWUFBWTs7Ozs7QUFFWixnREFBd0I7QUFDeEIsMkNBQThFO0FBRTlFLG9EQUE0QjtBQUk1QixxQkFBcUI7QUFFckIsTUFBTSxjQUFjO0lBWWxCLGlCQUFpQjtJQUVqQixZQUFjLE9BQWdCLEVBQUUsTUFBcUIsRUFBRSxJQUFxQjtRQUUxRSxJQUFJLENBQUMsSUFBSSxHQUFHLElBQUksQ0FBQztRQUNqQixJQUFJLENBQUMsT0FBTyxHQUFHLE9BQU8sQ0FBQztRQUN2QixJQUFJLENBQUMsT0FBTyxHQUFHLE1BQU0sQ0FBQyxPQUFPLENBQUM7UUFDOUIsSUFBSSxDQUFDLFNBQVMsR0FBRyxNQUFNLENBQUMsT0FBTyxDQUFDO1FBQ2hDLElBQUksQ0FBQyxPQUFPLEdBQUcsTUFBTSxDQUFDLE9BQU8sQ0FBQztRQUM5QixJQUFJLENBQUMsVUFBVSxHQUFHLE1BQU0sQ0FBQyxVQUFVLENBQUM7UUFDcEMsSUFBSSxDQUFDLFFBQVEsR0FBRyxNQUFNLENBQUMsUUFBUSxDQUFDO1FBRWhDLElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsY0FBYyxDQUFDLElBQUksQ0FBRyxJQUFJLENBQUMsSUFBSSxDQUFFLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxtQkFBbUIsQ0FBRyxJQUFJLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBRSxDQUFDLENBQUMsTUFBTTtJQUUvSSxDQUFDO0lBRUQsYUFBYTtJQUViLFVBQVUsQ0FBRyxVQUFnQjtRQUUzQixJQUFLLElBQUksQ0FBQyxRQUFRLEVBQUc7WUFFbkIsT0FBTyxVQUFVLEtBQUssSUFBSSxDQUFDLFFBQVEsQ0FBQztTQUVyQzthQUFNO1lBRUwsT0FBTyxVQUFVLEtBQUssSUFBSSxDQUFDLFVBQVUsSUFBSSxlQUFLLENBQUMsRUFBRSxDQUFDLFNBQVMsQ0FBRyxJQUFJLENBQUMsVUFBVSxFQUFFLFVBQVUsQ0FBRSxDQUFDO1NBRTdGO0lBRUgsQ0FBQztJQUVELG1CQUFtQixDQUFHLFFBQWdCLG9CQUFRO1FBRTVDLE9BQU8sQ0FBQyxHQUFHLEVBQUU7WUFFWCxJQUFJLElBQUksR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFDLFVBQVUsRUFBRSxrRkFBa0Y7WUFDbEgsUUFBUSxHQUFZLEVBQUUsRUFDdEIsUUFBUSxHQUFjLElBQUksR0FBRyxFQUFHLENBQUM7WUFFckMsTUFBTSxLQUFLLEdBQUcsS0FBSyxFQUFHLFFBQWlCLEVBQUUsUUFBbUIsRUFBa0IsRUFBRTtnQkFFOUUsTUFBTSxhQUFhLEdBQUcsSUFBSSxDQUFDLE9BQU8sQ0FBQyxhQUFhLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsUUFBUSxFQUMxRCxhQUFhLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFFLENBQUUsR0FBRyxRQUFRLENBQUUsQ0FBQyxFQUMzRCxNQUFNLEdBQUcsSUFBSSxDQUFDLGlCQUFpQixDQUFFLENBQUUsR0FBRyxhQUFhLEVBQUUsR0FBRyxhQUFhLENBQUUsQ0FBQyxDQUFDO2dCQUUvRSxJQUFJLENBQUMsY0FBYyxDQUFHLE1BQU0sQ0FBRSxDQUFDO1lBRWpDLENBQUMsQ0FBQztZQUVGLE1BQU0sY0FBYyxHQUFHLGVBQUssQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFHLEdBQUcsRUFBRTtnQkFFaEQsSUFBSyxJQUFJLENBQUMsT0FBTyxDQUFDLFFBQVEsRUFBRztvQkFBRyxPQUFPO2dCQUV2QyxJQUFJLEdBQUcsS0FBSyxDQUFHLFFBQVEsRUFBRSxRQUFRLENBQUUsQ0FBQztnQkFFcEMsUUFBUSxHQUFHLEVBQUUsQ0FBQztnQkFDZCxRQUFRLEdBQUcsSUFBSSxHQUFHLEVBQUcsQ0FBQztZQUV4QixDQUFDLEVBQUUsS0FBSyxDQUFFLENBQUM7WUFFWCxPQUFPLEtBQUssRUFBRyxLQUFvQixFQUFFLGFBQW1CLEVBQUUsRUFBRSxZQUFxQixLQUFLLEVBQWtCLEVBQUU7Z0JBRXhHLElBQUssU0FBUyxFQUFHLEVBQUUsbUJBQW1CO29CQUVwQyxNQUFNLElBQUksQ0FBQyxjQUFjLENBQUcsQ0FBQyxVQUFVLENBQUMsRUFBRSxRQUFRLEVBQUUsSUFBSSxDQUFFLENBQUM7aUJBRTVEO3FCQUFNLEVBQUUsYUFBYTtvQkFFcEIsUUFBUSxDQUFDLEdBQUcsQ0FBRyxVQUFVLENBQUUsQ0FBQztpQkFFN0I7Z0JBRUQsSUFBSSxDQUFDLElBQUksQ0FBRyxjQUFjLENBQUUsQ0FBQztZQUUvQixDQUFDLENBQUM7UUFFSixDQUFDLENBQUMsRUFBRSxDQUFDO0lBRVAsQ0FBQztJQUVELG1CQUFtQjtJQUVuQixpQkFBaUIsQ0FBRyxNQUFlO1FBRWpDLElBQUssTUFBTSxDQUFDLE1BQU0sR0FBRyxDQUFDO1lBQUcsT0FBTyxNQUFNLENBQUM7UUFFdkMsTUFBTSxnQkFBZ0IsR0FBOEIsRUFBRSxDQUFDO1FBRXZELE9BQU8sTUFBTSxDQUFDLE1BQU0sQ0FBWSxDQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUcsRUFBRTtZQUUvQyxNQUFNLENBQUMsV0FBVyxFQUFFLFVBQVUsQ0FBQyxHQUFHLEtBQUssRUFDakMsZUFBZSxHQUFHLGdCQUFnQixDQUFDLFVBQVUsQ0FBQyxDQUFDO1lBRXJELElBQUssV0FBVyxLQUFLLGVBQWU7Z0JBQUcsT0FBTyxHQUFHLENBQUMsQ0FBQyx1QkFBdUI7WUFFMUUsSUFBSyxXQUFXLDBCQUF1QixJQUFJLGVBQWUsb0JBQW9CO2dCQUFHLE9BQU8sR0FBRyxDQUFDLENBQUMsaUNBQWlDO1lBRTlILGdCQUFnQixDQUFDLFVBQVUsQ0FBQyxHQUFHLFdBQVcsQ0FBQztZQUUzQyxHQUFHLENBQUMsSUFBSSxDQUFHLEtBQUssQ0FBRSxDQUFDO1lBRW5CLE9BQU8sR0FBRyxDQUFDO1FBRWIsQ0FBQyxFQUFFLEVBQUUsQ0FBRSxDQUFDO0lBRVYsQ0FBQztJQUVELEtBQUssQ0FBQyxjQUFjLENBQUcsV0FBbUIsRUFBRSxTQUFrQixFQUFFLEVBQUUsWUFBcUIsS0FBSztRQUUxRixNQUFNLE9BQU8sQ0FBQyxHQUFHLENBQUcsV0FBVyxDQUFDLEdBQUcsQ0FBRyxLQUFLLEVBQUMsVUFBVSxFQUFDLEVBQUU7WUFFdkQsTUFBTSxZQUFZLEdBQUcsTUFBTSxJQUFJLENBQUMsT0FBTyxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUcsVUFBVSxFQUFFLElBQUksQ0FBQyxPQUFPLENBQUMsY0FBYyxDQUFFLENBQUM7WUFFbkcsTUFBTSxPQUFPLENBQUMsR0FBRyxDQUFHLFlBQVksQ0FBQyxHQUFHLENBQUcsS0FBSyxFQUFDLEtBQUssRUFBQyxFQUFFO2dCQUVuRCxNQUFNLENBQUMsSUFBSSxDQUFFLENBQUUsS0FBSyxFQUFFLFVBQVUsQ0FBRSxDQUFDLENBQUM7Z0JBRXBDLElBQUssS0FBSywyQkFBd0IsRUFBRztvQkFFbkMsTUFBTSxJQUFJLENBQUMsb0JBQW9CLENBQUcsV0FBVyxFQUFFLFVBQVUsRUFBRSxNQUFNLEVBQUUsU0FBUyxDQUFFLENBQUM7aUJBRWhGO3FCQUFNLElBQUssS0FBSyxpQ0FBMkIsRUFBRztvQkFFN0MsTUFBTSxJQUFJLENBQUMsdUJBQXVCLENBQUcsV0FBVyxFQUFFLFVBQVUsRUFBRSxNQUFNLEVBQUUsU0FBUyxDQUFFLENBQUM7aUJBRW5GO1lBRUgsQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUVOLENBQUMsQ0FBQyxDQUFDLENBQUM7UUFFSixPQUFPLE1BQU0sQ0FBQztJQUVoQixDQUFDO0lBQUEsQ0FBQztJQUVGLEtBQUssQ0FBQyxvQkFBb0IsQ0FBRyxXQUFtQixFQUFFLFVBQWdCLEVBQUUsU0FBa0IsRUFBRSxFQUFFLFlBQXFCLEtBQUs7O1FBRWxILElBQUssU0FBUztZQUFHLE9BQU8sTUFBTSxDQUFDO1FBRS9CLE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFDLENBQUMsT0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssbUNBQUksaUJBQUssQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBRyxDQUFDLFFBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxLQUFLLG1DQUFJLGlCQUFLLENBQUUsRUFDMUcsQ0FBQyxXQUFXLEVBQUUsS0FBSyxDQUFDLEdBQUcsTUFBTSxlQUFLLENBQUMsRUFBRSxDQUFDLE9BQU8sQ0FBRyxVQUFVLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxPQUFPLENBQUMsWUFBWSxDQUFFLEVBQ25ILGNBQWMsR0FBRyxDQUFDLEdBQUcsV0FBVyxFQUFFLEdBQUcsS0FBSyxDQUFDLENBQUM7UUFFbEQsTUFBTSxPQUFPLENBQUMsR0FBRyxDQUFHLGNBQWMsQ0FBQyxHQUFHLENBQUcsYUFBYSxDQUFDLEVBQUU7WUFFdkQsSUFBSyxJQUFJLENBQUMsT0FBTyxDQUFDLFNBQVMsQ0FBRyxhQUFhLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUU7Z0JBQUcsT0FBTztZQUU1RSxJQUFLLFdBQVcsQ0FBQyxRQUFRLENBQUcsYUFBYSxDQUFFO2dCQUFHLE9BQU87WUFFckQsT0FBTyxJQUFJLENBQUMsY0FBYyxDQUFHLENBQUMsYUFBYSxDQUFDLEVBQUUsTUFBTSxFQUFFLElBQUksQ0FBRSxDQUFDO1FBRS9ELENBQUMsQ0FBQyxDQUFDLENBQUM7UUFFSixPQUFPLE1BQU0sQ0FBQztJQUVoQixDQUFDO0lBRUQsS0FBSyxDQUFDLHVCQUF1QixDQUFHLFdBQW1CLEVBQUUsVUFBZ0IsRUFBRSxTQUFrQixFQUFFLEVBQUUsWUFBcUIsS0FBSztRQUVySCxJQUFLLFNBQVM7WUFBRyxPQUFPLE1BQU0sQ0FBQztRQUUvQixLQUFNLE1BQU0sZUFBZSxJQUFJLElBQUksQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUcsRUFBRztZQUVsRSxJQUFLLENBQUMsZUFBSyxDQUFDLEVBQUUsQ0FBQyxTQUFTLENBQUcsVUFBVSxFQUFFLGVBQWUsQ0FBRTtnQkFBRyxTQUFTO1lBRXBFLElBQUssV0FBVyxDQUFDLFFBQVEsQ0FBRyxlQUFlLENBQUU7Z0JBQUcsU0FBUztZQUV6RCxNQUFNLElBQUksQ0FBQyxjQUFjLENBQUcsQ0FBQyxlQUFlLENBQUMsRUFBRSxNQUFNLEVBQUUsSUFBSSxDQUFFLENBQUM7U0FFL0Q7UUFFRCxPQUFPLE1BQU0sQ0FBQztJQUVoQixDQUFDO0lBRUQsb0JBQW9CO0lBRXBCLFdBQVcsQ0FBRyxVQUFnQjtRQUU1QixJQUFLLElBQUksQ0FBQyxVQUFVLENBQUcsVUFBVSxDQUFFLEVBQUc7WUFFcEMsSUFBSyxJQUFJLENBQUMsT0FBTyxDQUFDLGVBQWUsRUFBRztnQkFFbEMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxPQUFPLENBQUMsZ0JBQWdCLENBQUcsVUFBVSxFQUFFLElBQUksQ0FBQyxPQUFPLENBQUMsYUFBYSxDQUFFLENBQUM7YUFFbEY7aUJBQU07Z0JBRUwsSUFBSSxDQUFDLE9BQU8sQ0FBQyxLQUFLLGtCQUFvQixVQUFVLENBQUUsQ0FBQzthQUVwRDtTQUVGO0lBRUgsQ0FBQztJQUVELGNBQWMsQ0FBRyxVQUFnQjtRQUUvQixJQUFLLFVBQVUsS0FBSyxJQUFJLENBQUMsVUFBVSxJQUFJLElBQUksQ0FBQyxPQUFPLENBQUMsU0FBUyxJQUFJLENBQUUsQ0FBQyxnQ0FBb0IsSUFBSSxJQUFJLENBQUMsT0FBTyxDQUFDLE1BQU0sS0FBSyxLQUFLLENBQUUsRUFBRztZQUU1SCxJQUFJLENBQUMsT0FBTyxDQUFDLGNBQWMsQ0FBRyxVQUFVLEVBQUUsSUFBSSxDQUFDLE9BQU8sRUFBRSxJQUFJLENBQUMsT0FBTyxFQUFFLFNBQVMsRUFBRSxJQUFJLENBQUMsSUFBSSxJQUFJLElBQUksQ0FBRSxDQUFDO1NBRXRHO1FBRUQsSUFBSyxJQUFJLENBQUMsVUFBVSxDQUFHLFVBQVUsQ0FBRSxFQUFHO1lBRXBDLElBQUssSUFBSSxDQUFDLE9BQU8sQ0FBQyxlQUFlLEVBQUc7Z0JBRWxDLElBQUksQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLG1CQUFtQixDQUFHLFVBQVUsRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLGFBQWEsQ0FBRSxDQUFDO2FBRXJGO2lCQUFNO2dCQUVMLElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSyx5QkFBd0IsVUFBVSxDQUFFLENBQUM7YUFFeEQ7U0FFRjtJQUVILENBQUM7SUFFRCxjQUFjLENBQUcsVUFBZ0I7UUFFL0IsSUFBSyxJQUFJLENBQUMsVUFBVSxDQUFHLFVBQVUsQ0FBRSxFQUFHO1lBRXBDLElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSyx3QkFBdUIsVUFBVSxDQUFFLENBQUM7U0FFdkQ7SUFFSCxDQUFDO0lBRUQsY0FBYyxDQUFHLFVBQWdCO1FBRS9CLElBQUksQ0FBQyxPQUFPLENBQUMsYUFBYSxDQUFHLGNBQUksQ0FBQyxPQUFPLENBQUcsVUFBVSxDQUFFLEVBQUUsVUFBVSxFQUFFLEtBQUssQ0FBRSxDQUFDO1FBRTlFLElBQUssSUFBSSxDQUFDLFVBQVUsQ0FBRyxVQUFVLENBQUUsRUFBRztZQUVwQyxJQUFLLElBQUksQ0FBQyxPQUFPLENBQUMsZUFBZSxFQUFHO2dCQUVsQyxJQUFJLENBQUMsT0FBTyxDQUFDLE9BQU8sQ0FBQyxtQkFBbUIsQ0FBRyxVQUFVLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxhQUFhLENBQUUsQ0FBQzthQUVyRjtpQkFBTTtnQkFFTCxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssd0JBQXVCLFVBQVUsQ0FBRSxDQUFDO2FBRXZEO1NBRUY7SUFFSCxDQUFDO0lBRUQsaUJBQWlCLENBQUcsVUFBZ0I7UUFFbEMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxhQUFhLENBQUcsY0FBSSxDQUFDLE9BQU8sQ0FBRyxVQUFVLENBQUUsRUFBRSxVQUFVLEVBQUUsS0FBSyxDQUFFLENBQUM7UUFFOUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxhQUFhLENBQUcsVUFBVSxDQUFFLENBQUM7UUFFMUMsSUFBSyxJQUFJLENBQUMsVUFBVSxDQUFHLFVBQVUsQ0FBRSxFQUFHO1lBRXBDLElBQUssSUFBSSxDQUFDLE9BQU8sQ0FBQyxlQUFlLEVBQUc7Z0JBRWxDLElBQUksQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLHNCQUFzQixDQUFHLFVBQVUsRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLGFBQWEsQ0FBRSxDQUFDO2FBRXhGO2lCQUFNO2dCQUVMLElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSywrQkFBMkIsVUFBVSxDQUFFLENBQUM7YUFFM0Q7U0FFRjtJQUVILENBQUM7SUFFRCxhQUFhLENBQUcsS0FBWTtRQUUxQixNQUFNLENBQUMsV0FBVyxFQUFFLFVBQVUsQ0FBQyxHQUFHLEtBQUssQ0FBQztRQUV4QyxJQUFLLFdBQVcsb0JBQW9CLEVBQUc7WUFFckMsSUFBSSxDQUFDLFdBQVcsQ0FBRyxVQUFVLENBQUUsQ0FBQztTQUVqQzthQUFNLElBQUssV0FBVywyQkFBd0IsRUFBRztZQUVoRCxJQUFJLENBQUMsY0FBYyxDQUFHLFVBQVUsQ0FBRSxDQUFDO1NBRXBDO2FBQU0sSUFBSyxXQUFXLDBCQUF1QixFQUFHO1lBRS9DLElBQUksQ0FBQyxjQUFjLENBQUcsVUFBVSxDQUFFLENBQUM7U0FFcEM7YUFBTSxJQUFLLFdBQVcsMEJBQXVCLEVBQUc7WUFFL0MsSUFBSSxDQUFDLGNBQWMsQ0FBRyxVQUFVLENBQUUsQ0FBQztTQUVwQzthQUFNLElBQUssV0FBVyxpQ0FBMkIsRUFBRztZQUVuRCxJQUFJLENBQUMsaUJBQWlCLENBQUcsVUFBVSxDQUFFLENBQUM7U0FFdkM7SUFFSCxDQUFDO0lBRUQsY0FBYyxDQUFHLE1BQWU7UUFFOUIsS0FBTSxNQUFNLEtBQUssSUFBSSxNQUFNLEVBQUc7WUFFNUIsSUFBSSxDQUFDLGFBQWEsQ0FBRyxLQUFLLENBQUUsQ0FBQztTQUU5QjtJQUVILENBQUM7SUFFRCxjQUFjLENBQUcsS0FBcUIsRUFBRSxVQUFpQixFQUFFLFlBQXFCLEtBQUs7UUFFbkYsT0FBTyxJQUFJLENBQUMsZ0JBQWdCLENBQUMsQ0FBRSxLQUFLLEVBQUUsVUFBVSxFQUFFLFNBQVMsQ0FBRSxDQUFDO0lBRWhFLENBQUM7SUFFRCxlQUFlLENBQUcsNkJBQTJDLEVBQUUsVUFBMEI7UUFFdkYsSUFBSyxJQUFJLENBQUMsT0FBTyxDQUFDLFFBQVEsRUFBRztZQUFHLE9BQU87UUFFdkMsTUFBTSxVQUFVLEdBQUcsY0FBSSxDQUFDLE9BQU8sQ0FBRyxJQUFJLENBQUMsVUFBVSxFQUFFLFVBQVUsSUFBSSxFQUFFLENBQUUsQ0FBQztRQUV0RSxJQUFLLElBQUksQ0FBQyxRQUFRLElBQUksVUFBVSxLQUFLLElBQUksQ0FBQyxVQUFVLElBQUksVUFBVSxLQUFLLElBQUksQ0FBQyxRQUFRO1lBQUcsT0FBTztRQUU5RixJQUFLLElBQUksQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBRTtZQUFHLE9BQU87UUFFekUsSUFBSSxDQUFDLGNBQWMsQ0FBRyxLQUFLLEVBQUUsVUFBVSxDQUFFLENBQUM7SUFFNUMsQ0FBQztJQUVELGNBQWMsQ0FBRyxLQUE0QjtRQUUzQyxJQUFLLHNCQUFVLElBQUksS0FBSyxDQUFDLElBQUksS0FBSyxPQUFPLEVBQUcsRUFBRSwyQ0FBMkM7WUFFdkYsSUFBSSxDQUFDLGVBQWUsd0JBQXlCLEVBQUUsQ0FBRSxDQUFDO1NBRW5EO2FBQU07WUFFTCxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssQ0FBRyxLQUFLLENBQUUsQ0FBQztTQUU5QjtJQUVILENBQUM7SUFFRCxTQUFTO0lBRVQsS0FBSyxDQUFDLElBQUk7UUFFUixNQUFNLElBQUksQ0FBQyxpQkFBaUIsRUFBRyxDQUFDO1FBQ2hDLE1BQU0sSUFBSSxDQUFDLGlCQUFpQixFQUFHLENBQUM7SUFFbEMsQ0FBQztJQUVELEtBQUssQ0FBQyxpQkFBaUI7UUFFckIsTUFBTSxRQUFRLEdBQUcsSUFBSSxDQUFDLGVBQWUsQ0FBQyxJQUFJLENBQUcsSUFBSSxDQUFFLENBQUM7UUFFcEQsSUFBSSxDQUFDLFNBQVMsQ0FBQyxFQUFFLHdCQUEwQixRQUFRLENBQUUsQ0FBQztRQUV0RCxNQUFNLE9BQU8sR0FBRyxJQUFJLENBQUMsY0FBYyxDQUFDLElBQUksQ0FBRyxJQUFJLENBQUUsQ0FBQztRQUVsRCxJQUFJLENBQUMsU0FBUyxDQUFDLEVBQUUsc0JBQXlCLE9BQU8sQ0FBRSxDQUFDO0lBRXRELENBQUM7SUFFRCxLQUFLLENBQUMsaUJBQWlCOztRQUVyQixNQUFNLFNBQVMsR0FBRyxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsT0FBTyxFQUFHLENBQUMsQ0FBQyw2REFBNkQ7UUFFekcsSUFBSyxJQUFJLENBQUMsUUFBUSxFQUFHLEVBQUUsc0JBQXNCO1lBRTNDLElBQUssSUFBSSxDQUFDLE9BQU8sQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBRyxJQUFJLENBQUMsUUFBUSxDQUFFO2dCQUFHLE9BQU8sQ0FBQyxpQkFBaUI7WUFFakYsTUFBTSxJQUFJLENBQUMsY0FBYyx3QkFBeUIsSUFBSSxDQUFDLFFBQVEsRUFBRSxTQUFTLENBQUUsQ0FBQztTQUU5RTthQUFNLEVBQUUseUJBQXlCO1lBRWhDLE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsU0FBUyxJQUFJLENBQUUsZ0NBQW9CLElBQUksSUFBSSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEtBQUssS0FBSyxDQUFFLENBQUMsQ0FBQyxPQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSyxtQ0FBSSxpQkFBSyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFHLENBQUMsUUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssbUNBQUksaUJBQUssQ0FBRSxFQUN2SyxDQUFDLFdBQVcsRUFBRSxLQUFLLENBQUMsR0FBRyxNQUFNLGVBQUssQ0FBQyxFQUFFLENBQUMsT0FBTyxDQUFHLElBQUksQ0FBQyxVQUFVLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxPQUFPLENBQUMsWUFBWSxFQUFFLElBQUksQ0FBQyxPQUFPLENBQUMsVUFBVSxDQUFFLEVBQ2pKLFdBQVcsR0FBRyxDQUFDLElBQUksQ0FBQyxVQUFVLEVBQUUsR0FBRyxXQUFXLEVBQUUsR0FBRyxLQUFLLENBQUMsQ0FBQztZQUVoRSxNQUFNLE9BQU8sQ0FBQyxHQUFHLENBQUcsV0FBVyxDQUFDLEdBQUcsQ0FBRyxVQUFVLENBQUMsRUFBRTtnQkFFakQsSUFBSyxJQUFJLENBQUMsT0FBTyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFHLFVBQVUsQ0FBRTtvQkFBRyxPQUFPLENBQUMsaUJBQWlCO2dCQUU5RSxJQUFLLElBQUksQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFHLFVBQVUsRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBRTtvQkFBRyxPQUFPO2dCQUV6RSxPQUFPLElBQUksQ0FBQyxjQUFjLHdCQUF5QixVQUFVLEVBQUUsU0FBUyxDQUFFLENBQUM7WUFFN0UsQ0FBQyxDQUFDLENBQUMsQ0FBQztTQUVMO0lBRUgsQ0FBQztDQUVGO0FBRUQsWUFBWTtBQUVaLGtCQUFlLGNBQWMsQ0FBQyJ9"
  },
  {
    "path": "server/libs/watcher/watcher_locker.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst constants_1 = require(\"./constants\");\nconst watcher_locks_resolver_1 = __importDefault(require(\"./watcher_locks_resolver\"));\n/* WATCHER LOCKER */\n//TODO: Use a better name for this thing, maybe \"RenameDetector\"\nclass WatcherLocker {\n    /* CONSTRUCTOR */\n    constructor(watcher) {\n        this._watcher = watcher;\n        this.reset();\n    }\n    /* API */\n    getLockAdd(config, timeout = constants_1.RENAME_TIMEOUT) {\n        const { ino, targetPath, events, locks } = config;\n        const emit = () => {\n            this._watcher.event(events.add, targetPath);\n        };\n        if (!ino)\n            return emit();\n        const cleanup = () => {\n            locks.add.delete(ino);\n            watcher_locks_resolver_1.default.remove(free);\n        };\n        const free = () => {\n            cleanup();\n            emit();\n        };\n        watcher_locks_resolver_1.default.add(free, timeout);\n        const resolve = () => {\n            const unlink = locks.unlink.get(ino);\n            if (!unlink)\n                return; // No matching \"unlink\" lock found, skipping\n            cleanup();\n            const targetPathPrev = unlink();\n            if (targetPath === targetPathPrev) {\n                if (events.change) {\n                    if (this._watcher._poller.stats.has(targetPath)) {\n                        this._watcher.event(events.change, targetPath);\n                    }\n                }\n            }\n            else {\n                this._watcher.event(events.rename, targetPathPrev, targetPath);\n            }\n        };\n        locks.add.set(ino, resolve);\n        resolve();\n    }\n    getLockUnlink(config, timeout = constants_1.RENAME_TIMEOUT) {\n        var _a;\n        const { ino, targetPath, events, locks } = config;\n        const emit = () => {\n            this._watcher.event(events.unlink, targetPath);\n        };\n        if (!ino)\n            return emit();\n        const cleanup = () => {\n            locks.unlink.delete(ino);\n            watcher_locks_resolver_1.default.remove(free);\n        };\n        const free = () => {\n            cleanup();\n            emit();\n        };\n        watcher_locks_resolver_1.default.add(free, timeout);\n        const overridden = () => {\n            cleanup();\n            return targetPath;\n        };\n        locks.unlink.set(ino, overridden);\n        (_a = locks.add.get(ino)) === null || _a === void 0 ? void 0 : _a();\n    }\n    getLockTargetAdd(targetPath, timeout) {\n        const ino = this._watcher._poller.getIno(targetPath, \"add\" /* ADD */, 2 /* FILE */);\n        return this.getLockAdd({\n            ino,\n            targetPath,\n            events: WatcherLocker.FILE_EVENTS,\n            locks: this._locksFile\n        }, timeout);\n    }\n    getLockTargetAddDir(targetPath, timeout) {\n        const ino = this._watcher._poller.getIno(targetPath, \"addDir\" /* ADD_DIR */, 1 /* DIR */);\n        return this.getLockAdd({\n            ino,\n            targetPath,\n            events: WatcherLocker.DIR_EVENTS,\n            locks: this._locksDir\n        }, timeout);\n    }\n    getLockTargetUnlink(targetPath, timeout) {\n        const ino = this._watcher._poller.getIno(targetPath, \"unlink\" /* UNLINK */, 2 /* FILE */);\n        return this.getLockUnlink({\n            ino,\n            targetPath,\n            events: WatcherLocker.FILE_EVENTS,\n            locks: this._locksFile\n        }, timeout);\n    }\n    getLockTargetUnlinkDir(targetPath, timeout) {\n        const ino = this._watcher._poller.getIno(targetPath, \"unlinkDir\" /* UNLINK_DIR */, 1 /* DIR */);\n        return this.getLockUnlink({\n            ino,\n            targetPath,\n            events: WatcherLocker.DIR_EVENTS,\n            locks: this._locksDir\n        }, timeout);\n    }\n    reset() {\n        this._locksAdd = new Map();\n        this._locksAddDir = new Map();\n        this._locksUnlink = new Map();\n        this._locksUnlinkDir = new Map();\n        this._locksDir = { add: this._locksAddDir, unlink: this._locksUnlinkDir };\n        this._locksFile = { add: this._locksAdd, unlink: this._locksUnlink };\n    }\n}\nWatcherLocker.DIR_EVENTS = {\n    add: \"addDir\" /* ADD_DIR */,\n    rename: \"renameDir\" /* RENAME_DIR */,\n    unlink: \"unlinkDir\" /* UNLINK_DIR */\n};\nWatcherLocker.FILE_EVENTS = {\n    add: \"add\" /* ADD */,\n    change: \"change\" /* CHANGE */,\n    rename: \"rename\" /* RENAME */,\n    unlink: \"unlink\" /* UNLINK */\n};\n/* EXPORT */\nexports.default = WatcherLocker;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2F0Y2hlcl9sb2NrZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvd2F0Y2hlcl9sb2NrZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7O0FBRVosMkNBQTJDO0FBRzNDLHNGQUE0RDtBQUc1RCxvQkFBb0I7QUFFcEIsZ0VBQWdFO0FBRWhFLE1BQU0sYUFBYTtJQXlCakIsaUJBQWlCO0lBRWpCLFlBQWMsT0FBZ0I7UUFFNUIsSUFBSSxDQUFDLFFBQVEsR0FBRyxPQUFPLENBQUM7UUFFeEIsSUFBSSxDQUFDLEtBQUssRUFBRyxDQUFDO0lBRWhCLENBQUM7SUFFRCxTQUFTO0lBRVQsVUFBVSxDQUFHLE1BQWtCLEVBQUUsVUFBa0IsMEJBQWM7UUFFL0QsTUFBTSxFQUFDLEdBQUcsRUFBRSxVQUFVLEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBQyxHQUFHLE1BQU0sQ0FBQztRQUVoRCxNQUFNLElBQUksR0FBRyxHQUFTLEVBQUU7WUFDdEIsSUFBSSxDQUFDLFFBQVEsQ0FBQyxLQUFLLENBQUcsTUFBTSxDQUFDLEdBQUcsRUFBRSxVQUFVLENBQUUsQ0FBQztRQUNqRCxDQUFDLENBQUM7UUFFRixJQUFLLENBQUMsR0FBRztZQUFHLE9BQU8sSUFBSSxFQUFHLENBQUM7UUFFM0IsTUFBTSxPQUFPLEdBQUcsR0FBUyxFQUFFO1lBQ3pCLEtBQUssQ0FBQyxHQUFHLENBQUMsTUFBTSxDQUFHLEdBQUcsQ0FBRSxDQUFDO1lBQ3pCLGdDQUFvQixDQUFDLE1BQU0sQ0FBRyxJQUFJLENBQUUsQ0FBQztRQUN2QyxDQUFDLENBQUM7UUFFRixNQUFNLElBQUksR0FBRyxHQUFTLEVBQUU7WUFDdEIsT0FBTyxFQUFHLENBQUM7WUFDWCxJQUFJLEVBQUcsQ0FBQztRQUNWLENBQUMsQ0FBQztRQUVGLGdDQUFvQixDQUFDLEdBQUcsQ0FBRyxJQUFJLEVBQUUsT0FBTyxDQUFFLENBQUM7UUFFM0MsTUFBTSxPQUFPLEdBQUcsR0FBUyxFQUFFO1lBQ3pCLE1BQU0sTUFBTSxHQUFHLEtBQUssQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFHLEdBQUcsQ0FBRSxDQUFDO1lBQ3hDLElBQUssQ0FBQyxNQUFNO2dCQUFHLE9BQU8sQ0FBQyw0Q0FBNEM7WUFDbkUsT0FBTyxFQUFHLENBQUM7WUFDWCxNQUFNLGNBQWMsR0FBRyxNQUFNLEVBQUcsQ0FBQztZQUNqQyxJQUFLLFVBQVUsS0FBSyxjQUFjLEVBQUc7Z0JBQ25DLElBQUssTUFBTSxDQUFDLE1BQU0sRUFBRztvQkFDbkIsSUFBSyxJQUFJLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFHLFVBQVUsQ0FBRSxFQUFHO3dCQUNwRCxJQUFJLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBRyxNQUFNLENBQUMsTUFBTSxFQUFFLFVBQVUsQ0FBRSxDQUFDO3FCQUNuRDtpQkFDRjthQUNGO2lCQUFNO2dCQUNMLElBQUksQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFHLE1BQU0sQ0FBQyxNQUFNLEVBQUUsY0FBYyxFQUFFLFVBQVUsQ0FBRSxDQUFDO2FBQ25FO1FBQ0gsQ0FBQyxDQUFDO1FBRUYsS0FBSyxDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUcsR0FBRyxFQUFFLE9BQU8sQ0FBRSxDQUFDO1FBRS9CLE9BQU8sRUFBRyxDQUFDO0lBRWIsQ0FBQztJQUVELGFBQWEsQ0FBRyxNQUFrQixFQUFFLFVBQWtCLDBCQUFjOztRQUVsRSxNQUFNLEVBQUMsR0FBRyxFQUFFLFVBQVUsRUFBRSxNQUFNLEVBQUUsS0FBSyxFQUFDLEdBQUcsTUFBTSxDQUFDO1FBRWhELE1BQU0sSUFBSSxHQUFHLEdBQVMsRUFBRTtZQUN0QixJQUFJLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBRyxNQUFNLENBQUMsTUFBTSxFQUFFLFVBQVUsQ0FBRSxDQUFDO1FBQ3BELENBQUMsQ0FBQztRQUVGLElBQUssQ0FBQyxHQUFHO1lBQUcsT0FBTyxJQUFJLEVBQUcsQ0FBQztRQUUzQixNQUFNLE9BQU8sR0FBRyxHQUFTLEVBQUU7WUFDekIsS0FBSyxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUcsR0FBRyxDQUFFLENBQUM7WUFDNUIsZ0NBQW9CLENBQUMsTUFBTSxDQUFHLElBQUksQ0FBRSxDQUFDO1FBQ3ZDLENBQUMsQ0FBQztRQUVGLE1BQU0sSUFBSSxHQUFHLEdBQVMsRUFBRTtZQUN0QixPQUFPLEVBQUcsQ0FBQztZQUNYLElBQUksRUFBRyxDQUFDO1FBQ1YsQ0FBQyxDQUFDO1FBRUYsZ0NBQW9CLENBQUMsR0FBRyxDQUFHLElBQUksRUFBRSxPQUFPLENBQUUsQ0FBQztRQUUzQyxNQUFNLFVBQVUsR0FBRyxHQUFTLEVBQUU7WUFDNUIsT0FBTyxFQUFHLENBQUM7WUFDWCxPQUFPLFVBQVUsQ0FBQztRQUNwQixDQUFDLENBQUM7UUFFRixLQUFLLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBRyxHQUFHLEVBQUUsVUFBVSxDQUFFLENBQUM7UUFFckMsTUFBQSxLQUFLLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBRyxHQUFHLENBQUUsNENBQUs7SUFFNUIsQ0FBQztJQUVELGdCQUFnQixDQUFHLFVBQWdCLEVBQUUsT0FBZ0I7UUFFbkQsTUFBTSxHQUFHLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFHLFVBQVUsZ0NBQWtDLENBQUM7UUFFeEYsT0FBTyxJQUFJLENBQUMsVUFBVSxDQUFFO1lBQ3RCLEdBQUc7WUFDSCxVQUFVO1lBQ1YsTUFBTSxFQUFFLGFBQWEsQ0FBQyxXQUFXO1lBQ2pDLEtBQUssRUFBRSxJQUFJLENBQUMsVUFBVTtTQUN2QixFQUFFLE9BQU8sQ0FBRSxDQUFDO0lBRWYsQ0FBQztJQUVELG1CQUFtQixDQUFHLFVBQWdCLEVBQUUsT0FBZ0I7UUFFdEQsTUFBTSxHQUFHLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFHLFVBQVUsc0NBQXFDLENBQUM7UUFFM0YsT0FBTyxJQUFJLENBQUMsVUFBVSxDQUFFO1lBQ3RCLEdBQUc7WUFDSCxVQUFVO1lBQ1YsTUFBTSxFQUFFLGFBQWEsQ0FBQyxVQUFVO1lBQ2hDLEtBQUssRUFBRSxJQUFJLENBQUMsU0FBUztTQUN0QixFQUFFLE9BQU8sQ0FBRSxDQUFDO0lBRWYsQ0FBQztJQUVELG1CQUFtQixDQUFHLFVBQWdCLEVBQUUsT0FBZ0I7UUFFdEQsTUFBTSxHQUFHLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFHLFVBQVUsc0NBQXFDLENBQUM7UUFFM0YsT0FBTyxJQUFJLENBQUMsYUFBYSxDQUFFO1lBQ3pCLEdBQUc7WUFDSCxVQUFVO1lBQ1YsTUFBTSxFQUFFLGFBQWEsQ0FBQyxXQUFXO1lBQ2pDLEtBQUssRUFBRSxJQUFJLENBQUMsVUFBVTtTQUN2QixFQUFFLE9BQU8sQ0FBRSxDQUFDO0lBRWYsQ0FBQztJQUVELHNCQUFzQixDQUFHLFVBQWdCLEVBQUUsT0FBZ0I7UUFFekQsTUFBTSxHQUFHLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFHLFVBQVUsNENBQXdDLENBQUM7UUFFOUYsT0FBTyxJQUFJLENBQUMsYUFBYSxDQUFFO1lBQ3pCLEdBQUc7WUFDSCxVQUFVO1lBQ1YsTUFBTSxFQUFFLGFBQWEsQ0FBQyxVQUFVO1lBQ2hDLEtBQUssRUFBRSxJQUFJLENBQUMsU0FBUztTQUN0QixFQUFFLE9BQU8sQ0FBRSxDQUFDO0lBRWYsQ0FBQztJQUVELEtBQUs7UUFFSCxJQUFJLENBQUMsU0FBUyxHQUFHLElBQUksR0FBRyxFQUFHLENBQUM7UUFDNUIsSUFBSSxDQUFDLFlBQVksR0FBRyxJQUFJLEdBQUcsRUFBRyxDQUFDO1FBQy9CLElBQUksQ0FBQyxZQUFZLEdBQUcsSUFBSSxHQUFHLEVBQUcsQ0FBQztRQUMvQixJQUFJLENBQUMsZUFBZSxHQUFHLElBQUksR0FBRyxFQUFHLENBQUM7UUFDbEMsSUFBSSxDQUFDLFNBQVMsR0FBRyxFQUFFLEdBQUcsRUFBRSxJQUFJLENBQUMsWUFBWSxFQUFFLE1BQU0sRUFBRSxJQUFJLENBQUMsZUFBZSxFQUFFLENBQUM7UUFDMUUsSUFBSSxDQUFDLFVBQVUsR0FBRyxFQUFFLEdBQUcsRUFBRSxJQUFJLENBQUMsU0FBUyxFQUFFLE1BQU0sRUFBRSxJQUFJLENBQUMsWUFBWSxFQUFFLENBQUM7SUFFdkUsQ0FBQzs7QUFuS00sd0JBQVUsR0FBVztJQUMxQixHQUFHLHdCQUFxQjtJQUN4QixNQUFNLDhCQUF3QjtJQUM5QixNQUFNLDhCQUF3QjtDQUMvQixDQUFDO0FBRUsseUJBQVcsR0FBVztJQUMzQixHQUFHLGlCQUFpQjtJQUNwQixNQUFNLHVCQUFvQjtJQUMxQixNQUFNLHVCQUFvQjtJQUMxQixNQUFNLHVCQUFvQjtDQUMzQixDQUFDO0FBNEpKLFlBQVk7QUFFWixrQkFBZSxhQUFhLENBQUMifQ=="
  },
  {
    "path": "server/libs/watcher/watcher_locks_resolver.js",
    "content": "\"use strict\";\n/* WATCHER LOCKS RESOLVER */\nObject.defineProperty(exports, \"__esModule\", { value: true });\n// Registering a single interval scales much better than registering N timeouts\n// Timeouts are respected within the interval margin\nconst WatcherLocksResolver = {\n    /* VARIABLES */\n    interval: 100,\n    intervalId: undefined,\n    fns: new Map(),\n    /* LIFECYCLE */\n    init: () => {\n        if (WatcherLocksResolver.intervalId)\n            return;\n        WatcherLocksResolver.intervalId = setInterval(WatcherLocksResolver.resolve, WatcherLocksResolver.interval);\n    },\n    reset: () => {\n        if (!WatcherLocksResolver.intervalId)\n            return;\n        clearInterval(WatcherLocksResolver.intervalId);\n        delete WatcherLocksResolver.intervalId;\n    },\n    /* API */\n    add: (fn, timeout) => {\n        WatcherLocksResolver.fns.set(fn, Date.now() + timeout);\n        WatcherLocksResolver.init();\n    },\n    remove: (fn) => {\n        WatcherLocksResolver.fns.delete(fn);\n    },\n    resolve: () => {\n        if (!WatcherLocksResolver.fns.size)\n            return WatcherLocksResolver.reset();\n        const now = Date.now();\n        for (const [fn, timestamp] of WatcherLocksResolver.fns) {\n            if (timestamp >= now)\n                continue; // We should still wait some more for this\n            WatcherLocksResolver.remove(fn);\n            fn();\n        }\n    }\n};\n/* EXPORT */\nexports.default = WatcherLocksResolver;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2F0Y2hlcl9sb2Nrc19yZXNvbHZlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NyYy93YXRjaGVyX2xvY2tzX3Jlc29sdmVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7QUFDQSw0QkFBNEI7O0FBRTVCLCtFQUErRTtBQUMvRSxvREFBb0Q7QUFFcEQsTUFBTSxvQkFBb0IsR0FBRztJQUUzQixlQUFlO0lBRWYsUUFBUSxFQUFFLEdBQUc7SUFDYixVQUFVLEVBQUUsU0FBdUM7SUFDbkQsR0FBRyxFQUFFLElBQUksR0FBRyxFQUFxQjtJQUVqQyxlQUFlO0lBRWYsSUFBSSxFQUFFLEdBQVMsRUFBRTtRQUVmLElBQUssb0JBQW9CLENBQUMsVUFBVTtZQUFHLE9BQU87UUFFOUMsb0JBQW9CLENBQUMsVUFBVSxHQUFHLFdBQVcsQ0FBRyxvQkFBb0IsQ0FBQyxPQUFPLEVBQUUsb0JBQW9CLENBQUMsUUFBUSxDQUFFLENBQUM7SUFFaEgsQ0FBQztJQUVELEtBQUssRUFBRSxHQUFTLEVBQUU7UUFFaEIsSUFBSyxDQUFDLG9CQUFvQixDQUFDLFVBQVU7WUFBRyxPQUFPO1FBRS9DLGFBQWEsQ0FBRyxvQkFBb0IsQ0FBQyxVQUFVLENBQUUsQ0FBQztRQUVsRCxPQUFPLG9CQUFvQixDQUFDLFVBQVUsQ0FBQztJQUV6QyxDQUFDO0lBRUQsU0FBUztJQUVULEdBQUcsRUFBRSxDQUFFLEVBQVksRUFBRSxPQUFlLEVBQVMsRUFBRTtRQUU3QyxvQkFBb0IsQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFHLEVBQUUsRUFBRSxJQUFJLENBQUMsR0FBRyxFQUFHLEdBQUcsT0FBTyxDQUFFLENBQUM7UUFFM0Qsb0JBQW9CLENBQUMsSUFBSSxFQUFHLENBQUM7SUFFL0IsQ0FBQztJQUVELE1BQU0sRUFBRSxDQUFFLEVBQVksRUFBUyxFQUFFO1FBRS9CLG9CQUFvQixDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUcsRUFBRSxDQUFFLENBQUM7SUFFekMsQ0FBQztJQUVELE9BQU8sRUFBRSxHQUFTLEVBQUU7UUFFbEIsSUFBSyxDQUFDLG9CQUFvQixDQUFDLEdBQUcsQ0FBQyxJQUFJO1lBQUcsT0FBTyxvQkFBb0IsQ0FBQyxLQUFLLEVBQUcsQ0FBQztRQUUzRSxNQUFNLEdBQUcsR0FBRyxJQUFJLENBQUMsR0FBRyxFQUFHLENBQUM7UUFFeEIsS0FBTSxNQUFNLENBQUMsRUFBRSxFQUFFLFNBQVMsQ0FBQyxJQUFJLG9CQUFvQixDQUFDLEdBQUcsRUFBRztZQUV4RCxJQUFLLFNBQVMsSUFBSSxHQUFHO2dCQUFHLFNBQVMsQ0FBQywwQ0FBMEM7WUFFNUUsb0JBQW9CLENBQUMsTUFBTSxDQUFHLEVBQUUsQ0FBRSxDQUFDO1lBRW5DLEVBQUUsRUFBRyxDQUFDO1NBRVA7SUFFSCxDQUFDO0NBRUYsQ0FBQztBQUVGLFlBQVk7QUFFWixrQkFBZSxvQkFBb0IsQ0FBQyJ9"
  },
  {
    "path": "server/libs/watcher/watcher_poller.js",
    "content": "\"use strict\";\n/* IMPORT */\nvar __importDefault = (this && this.__importDefault) || function (mod) {\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\n};\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst utils_1 = __importDefault(require(\"./utils\"));\nconst watcher_stats_1 = __importDefault(require(\"./watcher_stats\"));\n/* WATCHER POLLER */\nclass WatcherPoller {\n    constructor() {\n        /* VARIABLES */\n        this.inos = {};\n        this.stats = new Map();\n    }\n    /* API */\n    getIno(targetPath, event, type) {\n        const inos = this.inos[event];\n        if (!inos)\n            return;\n        const ino = inos[targetPath];\n        if (!ino)\n            return;\n        if (type && ino[1] !== type)\n            return;\n        return ino[0];\n    }\n    getStats(targetPath) {\n        return this.stats.get(targetPath);\n    }\n    async poll(targetPath, timeout) {\n        const stats = await utils_1.default.fs.poll(targetPath, timeout);\n        if (!stats)\n            return;\n        const isSupported = stats.isFile() || stats.isDirectory();\n        if (!isSupported)\n            return;\n        return new watcher_stats_1.default(stats);\n    }\n    reset() {\n        this.inos = {};\n        this.stats = new Map();\n    }\n    async update(targetPath, timeout) {\n        const prev = this.getStats(targetPath), next = await this.poll(targetPath, timeout);\n        this.updateStats(targetPath, next);\n        if (!prev && next) {\n            if (next.isFile()) {\n                this.updateIno(targetPath, \"add\" /* ADD */, next);\n                return [\"add\" /* ADD */];\n            }\n            if (next.isDirectory()) {\n                this.updateIno(targetPath, \"addDir\" /* ADD_DIR */, next);\n                return [\"addDir\" /* ADD_DIR */];\n            }\n        }\n        else if (prev && !next) {\n            if (prev.isFile()) {\n                this.updateIno(targetPath, \"unlink\" /* UNLINK */, prev);\n                return [\"unlink\" /* UNLINK */];\n            }\n            if (prev.isDirectory()) {\n                this.updateIno(targetPath, \"unlinkDir\" /* UNLINK_DIR */, prev);\n                return [\"unlinkDir\" /* UNLINK_DIR */];\n            }\n        }\n        else if (prev && next) {\n            if (prev.isFile()) {\n                if (next.isFile()) {\n                    if (prev.ino === next.ino && !prev.size && !next.size)\n                        return []; // Same path, same content and same file, nothing actually changed\n                    this.updateIno(targetPath, \"change\" /* CHANGE */, next);\n                    return [\"change\" /* CHANGE */];\n                }\n                if (next.isDirectory()) {\n                    this.updateIno(targetPath, \"unlink\" /* UNLINK */, prev);\n                    this.updateIno(targetPath, \"addDir\" /* ADD_DIR */, next);\n                    return [\"unlink\" /* UNLINK */, \"addDir\" /* ADD_DIR */];\n                }\n            }\n            else if (prev.isDirectory()) {\n                if (next.isFile()) {\n                    this.updateIno(targetPath, \"unlinkDir\" /* UNLINK_DIR */, prev);\n                    this.updateIno(targetPath, \"add\" /* ADD */, next);\n                    return [\"unlinkDir\" /* UNLINK_DIR */, \"add\" /* ADD */];\n                }\n                if (next.isDirectory()) {\n                    if (prev.ino === next.ino)\n                        return []; // Same path and same directory, nothing actually changed\n                    this.updateIno(targetPath, \"unlinkDir\" /* UNLINK_DIR */, prev);\n                    this.updateIno(targetPath, \"addDir\" /* ADD_DIR */, next);\n                    return [\"unlinkDir\" /* UNLINK_DIR */, \"addDir\" /* ADD_DIR */];\n                }\n            }\n        }\n        return [];\n    }\n    updateIno(targetPath, event, stats) {\n        const inos = this.inos[event] = this.inos[event] || (this.inos[event] = {}), type = stats.isFile() ? 2 /* FILE */ : 1 /* DIR */;\n        inos[targetPath] = [stats.ino, type];\n    }\n    updateStats(targetPath, stats) {\n        if (stats) {\n            this.stats.set(targetPath, stats);\n        }\n        else {\n            this.stats.delete(targetPath);\n        }\n    }\n}\n/* EXPORT */\nexports.default = WatcherPoller;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2F0Y2hlcl9wb2xsZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvd2F0Y2hlcl9wb2xsZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7O0FBR1osb0RBQTRCO0FBQzVCLG9FQUEyQztBQUczQyxvQkFBb0I7QUFFcEIsTUFBTSxhQUFhO0lBQW5CO1FBRUUsZUFBZTtRQUVmLFNBQUksR0FBZ0UsRUFBRSxDQUFDO1FBQ3ZFLFVBQUssR0FBNEIsSUFBSSxHQUFHLEVBQUcsQ0FBQztJQXNLOUMsQ0FBQztJQXBLQyxTQUFTO0lBRVQsTUFBTSxDQUFHLFVBQWdCLEVBQUUsS0FBa0IsRUFBRSxJQUFlO1FBRTVELE1BQU0sSUFBSSxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUM7UUFFOUIsSUFBSyxDQUFDLElBQUk7WUFBRyxPQUFPO1FBRXBCLE1BQU0sR0FBRyxHQUFHLElBQUksQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUU3QixJQUFLLENBQUMsR0FBRztZQUFHLE9BQU87UUFFbkIsSUFBSyxJQUFJLElBQUksR0FBRyxDQUFDLENBQUMsQ0FBQyxLQUFLLElBQUk7WUFBRyxPQUFPO1FBRXRDLE9BQU8sR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBRWhCLENBQUM7SUFFRCxRQUFRLENBQUcsVUFBZ0I7UUFFekIsT0FBTyxJQUFJLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBRyxVQUFVLENBQUUsQ0FBQztJQUV2QyxDQUFDO0lBRUQsS0FBSyxDQUFDLElBQUksQ0FBRyxVQUFnQixFQUFFLE9BQWdCO1FBRTdDLE1BQU0sS0FBSyxHQUFHLE1BQU0sZUFBSyxDQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUcsVUFBVSxFQUFFLE9BQU8sQ0FBRSxDQUFDO1FBRTFELElBQUssQ0FBQyxLQUFLO1lBQUcsT0FBTztRQUVyQixNQUFNLFdBQVcsR0FBRyxLQUFLLENBQUMsTUFBTSxFQUFHLElBQUksS0FBSyxDQUFDLFdBQVcsRUFBRyxDQUFDO1FBRTVELElBQUssQ0FBQyxXQUFXO1lBQUcsT0FBTztRQUUzQixPQUFPLElBQUksdUJBQVksQ0FBRyxLQUFLLENBQUUsQ0FBQztJQUVwQyxDQUFDO0lBRUQsS0FBSztRQUVILElBQUksQ0FBQyxJQUFJLEdBQUcsRUFBRSxDQUFDO1FBQ2YsSUFBSSxDQUFDLEtBQUssR0FBRyxJQUFJLEdBQUcsRUFBRyxDQUFDO0lBRTFCLENBQUM7SUFFRCxLQUFLLENBQUMsTUFBTSxDQUFHLFVBQWdCLEVBQUUsT0FBZ0I7UUFFL0MsTUFBTSxJQUFJLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBRyxVQUFVLENBQUUsRUFDbkMsSUFBSSxHQUFHLE1BQU0sSUFBSSxDQUFDLElBQUksQ0FBRyxVQUFVLEVBQUUsT0FBTyxDQUFFLENBQUM7UUFFckQsSUFBSSxDQUFDLFdBQVcsQ0FBRyxVQUFVLEVBQUUsSUFBSSxDQUFFLENBQUM7UUFFdEMsSUFBSyxDQUFDLElBQUksSUFBSSxJQUFJLEVBQUc7WUFFbkIsSUFBSyxJQUFJLENBQUMsTUFBTSxFQUFHLEVBQUc7Z0JBRXBCLElBQUksQ0FBQyxTQUFTLENBQUcsVUFBVSxtQkFBbUIsSUFBSSxDQUFFLENBQUM7Z0JBRXJELE9BQU8saUJBQWlCLENBQUM7YUFFMUI7WUFFRCxJQUFLLElBQUksQ0FBQyxXQUFXLEVBQUcsRUFBRztnQkFFekIsSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLDBCQUF1QixJQUFJLENBQUUsQ0FBQztnQkFFekQsT0FBTyx3QkFBcUIsQ0FBQzthQUU5QjtTQUVGO2FBQU0sSUFBSyxJQUFJLElBQUksQ0FBQyxJQUFJLEVBQUc7WUFFMUIsSUFBSyxJQUFJLENBQUMsTUFBTSxFQUFHLEVBQUc7Z0JBRXBCLElBQUksQ0FBQyxTQUFTLENBQUcsVUFBVSx5QkFBc0IsSUFBSSxDQUFFLENBQUM7Z0JBRXhELE9BQU8sdUJBQW9CLENBQUM7YUFFN0I7WUFFRCxJQUFLLElBQUksQ0FBQyxXQUFXLEVBQUcsRUFBRztnQkFFekIsSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLGdDQUEwQixJQUFJLENBQUUsQ0FBQztnQkFFNUQsT0FBTyw4QkFBd0IsQ0FBQzthQUVqQztTQUVGO2FBQU0sSUFBSyxJQUFJLElBQUksSUFBSSxFQUFHO1lBRXpCLElBQUssSUFBSSxDQUFDLE1BQU0sRUFBRyxFQUFHO2dCQUVwQixJQUFLLElBQUksQ0FBQyxNQUFNLEVBQUcsRUFBRztvQkFFcEIsSUFBSyxJQUFJLENBQUMsR0FBRyxLQUFLLElBQUksQ0FBQyxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsSUFBSSxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUk7d0JBQUcsT0FBTyxFQUFFLENBQUMsQ0FBQyxrRUFBa0U7b0JBRXRJLElBQUksQ0FBQyxTQUFTLENBQUcsVUFBVSx5QkFBc0IsSUFBSSxDQUFFLENBQUM7b0JBRXhELE9BQU8sdUJBQW9CLENBQUM7aUJBRTdCO2dCQUVELElBQUssSUFBSSxDQUFDLFdBQVcsRUFBRyxFQUFHO29CQUV6QixJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUseUJBQXNCLElBQUksQ0FBRSxDQUFDO29CQUN4RCxJQUFJLENBQUMsU0FBUyxDQUFHLFVBQVUsMEJBQXVCLElBQUksQ0FBRSxDQUFDO29CQUV6RCxPQUFPLCtDQUF5QyxDQUFDO2lCQUVsRDthQUVGO2lCQUFNLElBQUssSUFBSSxDQUFDLFdBQVcsRUFBRyxFQUFHO2dCQUVoQyxJQUFLLElBQUksQ0FBQyxNQUFNLEVBQUcsRUFBRztvQkFFcEIsSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLGdDQUEwQixJQUFJLENBQUUsQ0FBQztvQkFDNUQsSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLG1CQUFtQixJQUFJLENBQUUsQ0FBQztvQkFFckQsT0FBTywrQ0FBeUMsQ0FBQztpQkFFbEQ7Z0JBRUQsSUFBSyxJQUFJLENBQUMsV0FBVyxFQUFHLEVBQUc7b0JBRXpCLElBQUssSUFBSSxDQUFDLEdBQUcsS0FBSyxJQUFJLENBQUMsR0FBRzt3QkFBRyxPQUFPLEVBQUUsQ0FBQyxDQUFDLHlEQUF5RDtvQkFFakcsSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLGdDQUEwQixJQUFJLENBQUUsQ0FBQztvQkFDNUQsSUFBSSxDQUFDLFNBQVMsQ0FBRyxVQUFVLDBCQUF1QixJQUFJLENBQUUsQ0FBQztvQkFFekQsT0FBTyxzREFBNkMsQ0FBQztpQkFFdEQ7YUFFRjtTQUVGO1FBRUQsT0FBTyxFQUFFLENBQUM7SUFFWixDQUFDO0lBRUQsU0FBUyxDQUFHLFVBQWdCLEVBQUUsS0FBa0IsRUFBRSxLQUFtQjtRQUVuRSxNQUFNLElBQUksR0FBRyxJQUFJLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBRSxJQUFJLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLEVBQUUsQ0FBRSxFQUN2RSxJQUFJLEdBQUcsS0FBSyxDQUFDLE1BQU0sRUFBRyxDQUFDLENBQUMsY0FBZSxDQUFDLFlBQWEsQ0FBQztRQUU1RCxJQUFJLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQyxLQUFLLENBQUMsR0FBRyxFQUFFLElBQUksQ0FBQyxDQUFDO0lBRXZDLENBQUM7SUFFRCxXQUFXLENBQUcsVUFBZ0IsRUFBRSxLQUFvQjtRQUVsRCxJQUFLLEtBQUssRUFBRztZQUVYLElBQUksQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFHLFVBQVUsRUFBRSxLQUFLLENBQUUsQ0FBQztTQUV0QzthQUFNO1lBRUwsSUFBSSxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUcsVUFBVSxDQUFFLENBQUM7U0FFbEM7SUFFSCxDQUFDO0NBRUY7QUFFRCxZQUFZO0FBRVosa0JBQWUsYUFBYSxDQUFDIn0="
  },
  {
    "path": "server/libs/watcher/watcher_stats.js",
    "content": "\"use strict\";\n/* IMPORT */\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/* WATCHER STATS */\n// An even more memory-efficient representation of the useful subset of stats objects\nclass WatcherStats {\n    /* CONSTRUCTOR */\n    constructor(stats) {\n        this.ino = stats.ino;\n        this.size = stats.size;\n        this.atimeMs = stats.atimeMs;\n        this.mtimeMs = stats.mtimeMs;\n        this.ctimeMs = stats.ctimeMs;\n        this.birthtimeMs = stats.birthtimeMs;\n        this._isFile = stats.isFile();\n        this._isDirectory = stats.isDirectory();\n        this._isSymbolicLink = stats.isSymbolicLink();\n    }\n    /* API */\n    isFile() {\n        return this._isFile;\n    }\n    isDirectory() {\n        return this._isDirectory;\n    }\n    isSymbolicLink() {\n        return this._isSymbolicLink;\n    }\n}\n/* EXPORT */\nexports.default = WatcherStats;\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2F0Y2hlcl9zdGF0cy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NyYy93YXRjaGVyX3N0YXRzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7QUFDQSxZQUFZOztBQUlaLG1CQUFtQjtBQUVuQixxRkFBcUY7QUFFckYsTUFBTSxZQUFZO0lBY2hCLGlCQUFpQjtJQUVqQixZQUFjLEtBQVk7UUFFeEIsSUFBSSxDQUFDLEdBQUcsR0FBRyxLQUFLLENBQUMsR0FBRyxDQUFDO1FBQ3JCLElBQUksQ0FBQyxJQUFJLEdBQUcsS0FBSyxDQUFDLElBQUksQ0FBQztRQUN2QixJQUFJLENBQUMsT0FBTyxHQUFHLEtBQUssQ0FBQyxPQUFPLENBQUM7UUFDN0IsSUFBSSxDQUFDLE9BQU8sR0FBRyxLQUFLLENBQUMsT0FBTyxDQUFDO1FBQzdCLElBQUksQ0FBQyxPQUFPLEdBQUcsS0FBSyxDQUFDLE9BQU8sQ0FBQztRQUM3QixJQUFJLENBQUMsV0FBVyxHQUFHLEtBQUssQ0FBQyxXQUFXLENBQUM7UUFDckMsSUFBSSxDQUFDLE9BQU8sR0FBRyxLQUFLLENBQUMsTUFBTSxFQUFHLENBQUM7UUFDL0IsSUFBSSxDQUFDLFlBQVksR0FBRyxLQUFLLENBQUMsV0FBVyxFQUFHLENBQUM7UUFDekMsSUFBSSxDQUFDLGVBQWUsR0FBRyxLQUFLLENBQUMsY0FBYyxFQUFHLENBQUM7SUFFakQsQ0FBQztJQUVELFNBQVM7SUFFVCxNQUFNO1FBRUosT0FBTyxJQUFJLENBQUMsT0FBTyxDQUFDO0lBRXRCLENBQUM7SUFFRCxXQUFXO1FBRVQsT0FBTyxJQUFJLENBQUMsWUFBWSxDQUFDO0lBRTNCLENBQUM7SUFFRCxjQUFjO1FBRVosT0FBTyxJQUFJLENBQUMsZUFBZSxDQUFDO0lBRTlCLENBQUM7Q0FFRjtBQUVELFlBQVk7QUFFWixrQkFBZSxZQUFZLENBQUMifQ=="
  },
  {
    "path": "server/libs/which/LICENSE",
    "content": "The ISC License\n\nCopyright (c) Isaac Z. Schlueter and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE."
  },
  {
    "path": "server/libs/which/index.js",
    "content": "//\n// used by fluentFfmpeg\n// SOURCE: https://github.com/isaacs/isexe\n//\n\nconst isWindows = process.platform === 'win32' ||\n  process.env.OSTYPE === 'cygwin' ||\n  process.env.OSTYPE === 'msys'\n\nconst path = require('path')\nconst COLON = isWindows ? ';' : ':'\nconst isexe = require('../isexe')\n\nconst getNotFoundError = (cmd) =>\n  Object.assign(new Error(`not found: ${cmd}`), { code: 'ENOENT' })\n\nconst getPathInfo = (cmd, opt) => {\n  const colon = opt.colon || COLON\n\n  // If it has a slash, then we don't bother searching the pathenv.\n  // just check the file itself, and that's it.\n  const pathEnv = cmd.match(/\\//) || isWindows && cmd.match(/\\\\/) ? ['']\n    : (\n      [\n        // windows always checks the cwd first\n        ...(isWindows ? [process.cwd()] : []),\n        ...(opt.path || process.env.PATH ||\n          /* istanbul ignore next: very unusual */ '').split(colon),\n      ]\n    )\n  const pathExtExe = isWindows\n    ? opt.pathExt || process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM'\n    : ''\n  const pathExt = isWindows ? pathExtExe.split(colon) : ['']\n\n  if (isWindows) {\n    if (cmd.indexOf('.') !== -1 && pathExt[0] !== '')\n      pathExt.unshift('')\n  }\n\n  return {\n    pathEnv,\n    pathExt,\n    pathExtExe,\n  }\n}\n\nconst which = (cmd, opt, cb) => {\n  if (typeof opt === 'function') {\n    cb = opt\n    opt = {}\n  }\n  if (!opt)\n    opt = {}\n\n  const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt)\n  const found = []\n\n  const step = i => new Promise((resolve, reject) => {\n    if (i === pathEnv.length)\n      return opt.all && found.length ? resolve(found)\n        : reject(getNotFoundError(cmd))\n\n    const ppRaw = pathEnv[i]\n    const pathPart = /^\".*\"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw\n\n    const pCmd = path.join(pathPart, cmd)\n    const p = !pathPart && /^\\.[\\\\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd\n      : pCmd\n\n    resolve(subStep(p, i, 0))\n  })\n\n  const subStep = (p, i, ii) => new Promise((resolve, reject) => {\n    if (ii === pathExt.length)\n      return resolve(step(i + 1))\n    const ext = pathExt[ii]\n    isexe(p + ext, { pathExt: pathExtExe }, (er, is) => {\n      if (!er && is) {\n        if (opt.all)\n          found.push(p + ext)\n        else\n          return resolve(p + ext)\n      }\n      return resolve(subStep(p, i, ii + 1))\n    })\n  })\n\n  return cb ? step(0).then(res => cb(null, res), cb) : step(0)\n}\n\nconst whichSync = (cmd, opt) => {\n  opt = opt || {}\n\n  const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt)\n  const found = []\n\n  for (let i = 0; i < pathEnv.length; i++) {\n    const ppRaw = pathEnv[i]\n    const pathPart = /^\".*\"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw\n\n    const pCmd = path.join(pathPart, cmd)\n    const p = !pathPart && /^\\.[\\\\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd\n      : pCmd\n\n    for (let j = 0; j < pathExt.length; j++) {\n      const cur = p + pathExt[j]\n      try {\n        const is = isexe.sync(cur, { pathExt: pathExtExe })\n        if (is) {\n          if (opt.all)\n            found.push(cur)\n          else\n            return cur\n        }\n      } catch (ex) { }\n    }\n  }\n\n  if (opt.all && found.length)\n    return found\n\n  if (opt.nothrow)\n    return null\n\n  throw getNotFoundError(cmd)\n}\n\nmodule.exports = which\nwhich.sync = whichSync"
  },
  {
    "path": "server/libs/xml/LICENSE",
    "content": "(The MIT License)\n\nCopyright (c) 2011-2017 Dylan Greene <dylang@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n'Software'), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "server/libs/xml/escapeForXML.js",
    "content": "var XML_CHARACTER_MAP = {\n  '&': '&amp;',\n  '\"': '&quot;',\n  \"'\": '&apos;',\n  '<': '&lt;',\n  '>': '&gt;'\n};\n\nfunction escapeForXML(string) {\n  return string && string.replace\n    ? string.replace(/([&\"<>'])/g, function (str, item) {\n      return XML_CHARACTER_MAP[item];\n    })\n    : string;\n}\n\nmodule.exports = escapeForXML;"
  },
  {
    "path": "server/libs/xml/index.js",
    "content": "// node-xml\n// SOURCE: https://github.com/dylang/node-xml\n// LICENSE: https://github.com/dylang/node-xml/blob/master/LICENSE\n\nvar escapeForXML = require('./escapeForXML');\nvar Stream = require('stream').Stream;\n\nvar DEFAULT_INDENT = '    ';\n\nfunction xml(input, options) {\n\n  if (typeof options !== 'object') {\n    options = {\n      indent: options\n    };\n  }\n\n  var stream = options.stream ? new Stream() : null,\n    output = \"\",\n    interrupted = false,\n    indent = !options.indent ? ''\n      : options.indent === true ? DEFAULT_INDENT\n        : options.indent,\n    instant = true;\n\n\n  function delay(func) {\n    if (!instant) {\n      func();\n    } else {\n      process.nextTick(func);\n    }\n  }\n\n  function append(interrupt, out) {\n    if (out !== undefined) {\n      output += out;\n    }\n    if (interrupt && !interrupted) {\n      stream = stream || new Stream();\n      interrupted = true;\n    }\n    if (interrupt && interrupted) {\n      var data = output;\n      delay(function () { stream.emit('data', data) });\n      output = \"\";\n    }\n  }\n\n  function add(value, last) {\n    format(append, resolve(value, indent, indent ? 1 : 0), last);\n  }\n\n  function end() {\n    if (stream) {\n      var data = output;\n      delay(function () {\n        stream.emit('data', data);\n        stream.emit('end');\n        stream.readable = false;\n        stream.emit('close');\n      });\n    }\n  }\n\n  function addXmlDeclaration(declaration) {\n    var encoding = declaration.encoding || 'UTF-8',\n      attr = { version: '1.0', encoding: encoding };\n\n    if (declaration.standalone) {\n      attr.standalone = declaration.standalone\n    }\n\n    add({ '?xml': { _attr: attr } });\n    output = output.replace('/>', '?>');\n  }\n\n  // disable delay delayed\n  delay(function () { instant = false });\n\n  if (options.declaration) {\n    addXmlDeclaration(options.declaration);\n  }\n\n  if (input && input.forEach) {\n    input.forEach(function (value, i) {\n      var last;\n      if (i + 1 === input.length)\n        last = end;\n      add(value, last);\n    });\n  } else {\n    add(input, end);\n  }\n\n  if (stream) {\n    stream.readable = true;\n    return stream;\n  }\n  return output;\n}\n\nfunction element(/*input, …*/) {\n  var input = Array.prototype.slice.call(arguments),\n    self = {\n      _elem: resolve(input)\n    };\n\n  self.push = function (input) {\n    if (!this.append) {\n      throw new Error(\"not assigned to a parent!\");\n    }\n    var that = this;\n    var indent = this._elem.indent;\n    format(this.append, resolve(\n      input, indent, this._elem.icount + (indent ? 1 : 0)),\n      function () { that.append(true) });\n  };\n\n  self.close = function (input) {\n    if (input !== undefined) {\n      this.push(input);\n    }\n    if (this.end) {\n      this.end();\n    }\n  };\n\n  return self;\n}\n\nfunction create_indent(character, count) {\n  return (new Array(count || 0).join(character || ''))\n}\n\nfunction resolve(data, indent, indent_count) {\n  indent_count = indent_count || 0;\n  var indent_spaces = create_indent(indent, indent_count);\n  var name;\n  var values = data;\n  var interrupt = false;\n\n  if (typeof data === 'object') {\n    var keys = Object.keys(data);\n    name = keys[0];\n    values = data[name];\n\n    if (values && values._elem) {\n      values._elem.name = name;\n      values._elem.icount = indent_count;\n      values._elem.indent = indent;\n      values._elem.indents = indent_spaces;\n      values._elem.interrupt = values;\n      return values._elem;\n    }\n  }\n\n  var attributes = [],\n    content = [];\n\n  var isStringContent;\n\n  function get_attributes(obj) {\n    var keys = Object.keys(obj);\n    keys.forEach(function (key) {\n      attributes.push(attribute(key, obj[key]));\n    });\n  }\n\n  switch (typeof values) {\n    case 'object':\n      if (values === null) break;\n\n      if (values._attr) {\n        get_attributes(values._attr);\n      }\n\n      if (values._cdata) {\n        content.push(\n          ('<![CDATA[' + values._cdata).replace(/\\]\\]>/g, ']]]]><![CDATA[>') + ']]>'\n        );\n      }\n\n      if (values.forEach) {\n        isStringContent = false;\n        content.push('');\n        values.forEach(function (value) {\n          if (typeof value == 'object') {\n            var _name = Object.keys(value)[0];\n\n            if (_name == '_attr') {\n              get_attributes(value._attr);\n            } else {\n              content.push(resolve(\n                value, indent, indent_count + 1));\n            }\n          } else {\n            //string\n            content.pop();\n            isStringContent = true;\n            content.push(escapeForXML(value));\n          }\n\n        });\n        if (!isStringContent) {\n          content.push('');\n        }\n      }\n      break;\n\n    default:\n      //string\n      content.push(escapeForXML(values));\n\n  }\n\n  return {\n    name: name,\n    interrupt: interrupt,\n    attributes: attributes,\n    content: content,\n    icount: indent_count,\n    indents: indent_spaces,\n    indent: indent\n  };\n}\n\nfunction format(append, elem, end) {\n\n  if (typeof elem != 'object') {\n    return append(false, elem);\n  }\n\n  var len = elem.interrupt ? 1 : elem.content.length;\n\n  function proceed() {\n    while (elem.content.length) {\n      var value = elem.content.shift();\n\n      if (value === undefined) continue;\n      if (interrupt(value)) return;\n\n      format(append, value);\n    }\n\n    append(false, (len > 1 ? elem.indents : '')\n      + (elem.name ? '</' + elem.name + '>' : '')\n      + (elem.indent && !end ? '\\n' : ''));\n\n    if (end) {\n      end();\n    }\n  }\n\n  function interrupt(value) {\n    if (value.interrupt) {\n      value.interrupt.append = append;\n      value.interrupt.end = proceed;\n      value.interrupt = false;\n      append(true);\n      return true;\n    }\n    return false;\n  }\n\n  append(false, elem.indents\n    + (elem.name ? '<' + elem.name : '')\n    + (elem.attributes.length ? ' ' + elem.attributes.join(' ') : '')\n    + (len ? (elem.name ? '>' : '') : (elem.name ? '/>' : ''))\n    + (elem.indent && len > 1 ? '\\n' : ''));\n\n  if (!len) {\n    return append(false, elem.indent ? '\\n' : '');\n  }\n\n  if (!interrupt(elem)) {\n    proceed();\n  }\n}\n\nfunction attribute(key, value) {\n  return key + '=' + '\"' + escapeForXML(value) + '\"';\n}\n\nmodule.exports = xml;\nmodule.exports.element = module.exports.Element = element;"
  },
  {
    "path": "server/managers/AbMergeManager.js",
    "content": "const Path = require('path')\nconst fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst TaskManager = require('./TaskManager')\nconst Task = require('../objects/Task')\nconst ffmpegHelpers = require('../utils/ffmpegHelpers')\nconst Ffmpeg = require('../libs/fluentFfmpeg')\nconst SocketAuthority = require('../SocketAuthority')\nconst { isWritable, copyToExisting } = require('../utils/fileUtils')\nconst TrackProgressMonitor = require('../objects/TrackProgressMonitor')\n\n/**\n * @typedef AbMergeEncodeOptions\n * @property {string} codec\n * @property {string} channels\n * @property {string} bitrate\n */\n\nclass AbMergeManager {\n  constructor() {\n    this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')\n\n    /** @type {Task[]} */\n    this.pendingTasks = []\n  }\n\n  /**\n   *\n   * @param {string} libraryItemId\n   * @returns {Task|null}\n   */\n  getPendingTaskByLibraryItemId(libraryItemId) {\n    return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)\n  }\n\n  /**\n   * Cancel and fail running task\n   *\n   * @param {Task} task\n   * @returns {Promise<void>}\n   */\n  cancelEncode(task) {\n    const taskFailedString = {\n      text: 'Task canceled by user',\n      key: 'MessageTaskCanceledByUser'\n    }\n    task.setFailed(taskFailedString)\n    return this.removeTask(task, true)\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {AbMergeEncodeOptions} [options={}]\n   */\n  async startAudiobookMerge(userId, libraryItem, options = {}) {\n    const task = new Task()\n\n    const audiobookBaseName = libraryItem.isFile ? Path.basename(libraryItem.path, Path.extname(libraryItem.path)) : Path.basename(libraryItem.path)\n    const targetFilename = audiobookBaseName + '.m4b'\n    const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)\n    const tempFilepath = Path.join(itemCachePath, targetFilename)\n    const ffmetadataPath = Path.join(itemCachePath, 'ffmetadata.txt')\n    const libraryItemDir = libraryItem.isFile ? Path.dirname(libraryItem.path) : libraryItem.path\n    const taskData = {\n      libraryItemId: libraryItem.id,\n      libraryItemDir,\n      userId,\n      originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path),\n      inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),\n      tempFilepath,\n      targetFilename,\n      targetFilepath: Path.join(libraryItemDir, targetFilename),\n      itemCachePath,\n      ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),\n      chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),\n      coverPath: libraryItem.media.coverPath,\n      ffmetadataPath,\n      duration: libraryItem.media.duration,\n      encodeOptions: options\n    }\n\n    const taskTitleString = {\n      text: 'Encoding M4b',\n      key: 'MessageTaskEncodingM4b'\n    }\n    const taskDescriptionString = {\n      text: `Encoding audiobook \"${libraryItem.media.title}\" into a single m4b file.`,\n      key: 'MessageTaskEncodingM4bDescription',\n      subs: [libraryItem.media.title]\n    }\n    task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)\n    TaskManager.addTask(task)\n    Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)\n\n    if (!(await fs.pathExists(taskData.itemCachePath))) {\n      await fs.mkdir(taskData.itemCachePath)\n    }\n\n    this.runAudiobookMerge(libraryItem, task, options || {})\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {Task} task\n   * @param {AbMergeEncodeOptions} encodingOptions\n   */\n  async runAudiobookMerge(libraryItem, task, encodingOptions) {\n    // Make sure the target directory is writable\n    if (!(await isWritable(task.data.libraryItemDir))) {\n      Logger.error(`[AbMergeManager] Target directory is not writable: ${task.data.libraryItemDir}`)\n      const taskFailedString = {\n        text: 'Target directory is not writable',\n        key: 'MessageTaskTargetDirectoryNotWritable'\n      }\n      task.setFailed(taskFailedString)\n      this.removeTask(task, true)\n      return\n    }\n\n    // Create ffmetadata file\n    if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) {\n      Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook \"${task.data.libraryItemId}\"`)\n      const taskFailedString = {\n        text: 'Failed to write metadata file',\n        key: 'MessageTaskFailedToWriteMetadataFile'\n      }\n      task.setFailed(taskFailedString)\n      this.removeTask(task, true)\n      return\n    }\n\n    this.pendingTasks.push({\n      id: task.id,\n      task\n    })\n\n    const encodeFraction = 0.95\n    const embedFraction = 1 - encodeFraction\n    try {\n      const trackProgressMonitor = new TrackProgressMonitor(\n        libraryItem.media.includedAudioFiles.map((t) => t.duration),\n        (trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),\n        (trackIndex, progressInTrack, taskProgress) => {\n          SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })\n          SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: taskProgress * encodeFraction })\n        },\n        (trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })\n      )\n      task.data.ffmpeg = new Ffmpeg()\n      await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)\n      delete task.data.ffmpeg\n      trackProgressMonitor.finish()\n    } catch (error) {\n      if (error.message === 'FFMPEG_CANCELED') {\n        Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)\n      } else {\n        Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error)\n        const taskFailedString = {\n          text: 'Failed to merge audio files',\n          key: 'MessageTaskFailedToMergeAudioFiles'\n        }\n        task.setFailed(taskFailedString)\n        this.removeTask(task, true)\n      }\n      return\n    }\n\n    // Write metadata to merged file\n    try {\n      task.data.ffmpeg = new Ffmpeg()\n      await ffmpegHelpers.addCoverAndMetadataToFile(\n        task.data.tempFilepath,\n        task.data.coverPath,\n        task.data.ffmetadataPath,\n        1,\n        'audio/mp4',\n        (progress) => {\n          Logger.debug(`[AbMergeManager] Embedding metadata progress: ${100 * encodeFraction + progress * embedFraction}`)\n          SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: 100 * encodeFraction + progress * embedFraction })\n        },\n        task.data.ffmpeg\n      )\n      delete task.data.ffmpeg\n    } catch (error) {\n      if (error.message === 'FFMPEG_CANCELED') {\n        Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)\n      } else {\n        Logger.error(`[AbMergeManager] Failed to embed metadata in file \"${task.data.tempFilepath}\"`)\n        const taskFailedString = {\n          text: `Failed to embed metadata in file ${Path.basename(task.data.tempFilepath)}`,\n          key: 'MessageTaskFailedToEmbedMetadataInFile',\n          subs: [Path.basename(task.data.tempFilepath)]\n        }\n        task.setFailed(taskFailedString)\n        this.removeTask(task, true)\n      }\n      return\n    }\n\n    // Move library item tracks to cache\n    for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {\n      const trackFilename = Path.basename(trackPath)\n      let moveToPath = Path.join(task.data.itemCachePath, trackFilename)\n\n      // If the track is the same as the temp file, we need to rename it to avoid overwriting it\n      if (task.data.tempFilepath === moveToPath) {\n        const trackExtname = Path.extname(task.data.tempFilepath)\n        const newTrackFilename = Path.basename(task.data.tempFilepath, trackExtname) + '.backup' + trackExtname\n        moveToPath = Path.join(task.data.itemCachePath, newTrackFilename)\n      }\n\n      Logger.debug(`[AbMergeManager] Backing up original track \"${trackPath}\" to ${moveToPath}`)\n      if (index === 0) {\n        // copy the first track to the cache directory\n        await fs.copy(trackPath, moveToPath).catch((err) => {\n          Logger.error(`[AbMergeManager] Failed to copy track \"${trackPath}\" to \"${moveToPath}\"`, err)\n        })\n      } else {\n        // move the rest of the tracks to the cache directory\n        await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {\n          Logger.error(`[AbMergeManager] Failed to move track \"${trackPath}\" to \"${moveToPath}\"`, err)\n        })\n      }\n    }\n\n    // Move m4b to target, preserving the original track's permissions\n    Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)\n    try {\n      await copyToExisting(task.data.tempFilepath, task.data.originalTrackPaths[0])\n      await fs.rename(task.data.originalTrackPaths[0], task.data.targetFilepath)\n      await fs.remove(task.data.tempFilepath)\n    } catch (err) {\n      Logger.error(`[AbMergeManager] Failed to move m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`, err)\n      const taskFailedString = {\n        text: 'Failed to move m4b file',\n        key: 'MessageTaskFailedToMoveM4bFile'\n      }\n      task.setFailed(taskFailedString)\n      this.removeTask(task, true)\n      return\n    }\n\n    // Remove ffmetadata file\n    await fs.remove(task.data.ffmetadataPath)\n\n    task.setFinished()\n    await this.removeTask(task, false)\n    Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)\n  }\n\n  /**\n   * Remove ab merge task\n   *\n   * @param {Task} task\n   * @param {boolean} [removeTempFilepath=false]\n   */\n  async removeTask(task, removeTempFilepath = false) {\n    Logger.info('[AbMergeManager] Removing task ' + task.id)\n\n    const pendingTask = this.pendingTasks.find((d) => d.id === task.id)\n    if (pendingTask) {\n      this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)\n      if (task.data.ffmpeg) {\n        Logger.warn(`[AbMergeManager] Killing ffmpeg process for task ${task.id}`)\n        task.data.ffmpeg.kill()\n        // wait for ffmpeg to exit, so that the output file is unlocked\n        await new Promise((resolve) => setTimeout(resolve, 500))\n      }\n    }\n\n    if (removeTempFilepath) {\n      // On failed tasks remove the bad file if it exists\n      if (await fs.pathExists(task.data.tempFilepath)) {\n        await fs\n          .remove(task.data.tempFilepath)\n          .then(() => {\n            Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)\n          })\n          .catch((err) => {\n            Logger.error('[AbMergeManager] Failed to delete target file', err)\n          })\n      }\n      if (await fs.pathExists(task.data.ffmetadataPath)) {\n        await fs\n          .remove(task.data.ffmetadataPath)\n          .then(() => {\n            Logger.info('[AbMergeManager] Deleted ffmetadata file', task.data.ffmetadataPath)\n          })\n          .catch((err) => {\n            Logger.error('[AbMergeManager] Failed to delete ffmetadata file', err)\n          })\n      }\n    }\n\n    TaskManager.taskFinished(task)\n  }\n}\nmodule.exports = AbMergeManager\n"
  },
  {
    "path": "server/managers/ApiCacheManager.js",
    "content": "const { LRUCache } = require('lru-cache')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\n\nclass ApiCacheManager {\n  defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: (item) => item.body.length + JSON.stringify(item.headers).length }\n  defaultTtlOptions = { ttl: 30 * 60 * 1000 }\n  highChurnModels = new Set(['session', 'mediaProgress', 'playbackSession', 'device'])\n  modelsInvalidatingPersonalized = new Set(['mediaProgress'])\n  modelsInvalidatingMe = new Set(['session', 'mediaProgress', 'playbackSession', 'device'])\n\n  constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {\n    this.cache = cache\n    this.ttlOptions = ttlOptions\n  }\n\n  init(database = Database) {\n    let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert']\n    hooks.forEach((hook) => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))\n  }\n\n  getModelName(model) {\n    if (typeof model?.name === 'string') return model.name\n    if (typeof model?.model?.name === 'string') return model.model.name\n    if (typeof model?.constructor?.name === 'string' && model.constructor.name !== 'Object') return model.constructor.name\n    return 'unknown'\n  }\n\n  clearByUrlPattern(urlPattern) {\n    let removed = 0\n    for (const key of this.cache.keys()) {\n      try {\n        const parsed = JSON.parse(key)\n        if (typeof parsed?.url === 'string' && urlPattern.test(parsed.url)) {\n          if (this.cache.delete(key)) removed++\n        }\n      } catch {\n        if (this.cache.delete(key)) removed++\n      }\n    }\n    return removed\n  }\n\n  clearUserProgressSlices(modelName, hook) {\n    const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\\/libraries\\/[^/]+\\/personalized/) : 0\n    const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\\/me(\\/|\\?|$)/) : 0\n    Logger.debug(\n      `[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, me=${removedMe})`\n    )\n  }\n\n  clear(model, hook) {\n    const modelName = this.getModelName(model)\n    if (this.highChurnModels.has(modelName)) {\n      this.clearUserProgressSlices(modelName, hook)\n      return\n    }\n\n    Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: Clearing cache`)\n    this.cache.clear()\n  }\n\n  /**\n   * Reset hooks and clear cache. Used when applying backups\n   */\n  reset() {\n    Logger.info(`[ApiCacheManager] Resetting cache`)\n\n    this.init()\n    this.cache.clear()\n  }\n\n  get middleware() {\n    /**\n     * @param {import('express').Request} req\n     * @param {import('express').Response} res\n     * @param {import('express').NextFunction} next\n     */\n    return (req, res, next) => {\n      if (req.query.sort === 'random') {\n        Logger.debug(`[ApiCacheManager] Skipping cache for random sort`)\n        return next()\n      }\n\n      const key = { user: req.user.username, url: req.url }\n      const stringifiedKey = JSON.stringify(key)\n      Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)\n      const cached = this.cache.get(stringifiedKey)\n      if (cached) {\n        Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`)\n        res.set(cached.headers)\n        res.status(cached.statusCode)\n        res.send(cached.body)\n        return\n      }\n      res.originalSend = res.send\n      res.send = (body) => {\n        Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`)\n        const cached = { body, headers: res.getHeaders(), statusCode: res.statusCode }\n        if (key.url.search(/^\\/libraries\\/.*?\\/personalized/) !== -1) {\n          Logger.debug(`[ApiCacheManager] Caching with ${this.ttlOptions.ttl} ms TTL`)\n          this.cache.set(stringifiedKey, cached, this.ttlOptions)\n        } else {\n          this.cache.set(stringifiedKey, cached)\n        }\n        res.originalSend(body)\n      }\n      next()\n    }\n  }\n}\nmodule.exports = ApiCacheManager\n"
  },
  {
    "path": "server/managers/AudioMetadataManager.js",
    "content": "const Path = require('path')\nconst SocketAuthority = require('../SocketAuthority')\nconst Logger = require('../Logger')\nconst fs = require('../libs/fsExtra')\nconst ffmpegHelpers = require('../utils/ffmpegHelpers')\nconst TaskManager = require('./TaskManager')\nconst Task = require('../objects/Task')\nconst fileUtils = require('../utils/fileUtils')\n\n/**\n * @typedef UpdateMetadataOptions\n * @property {boolean} [forceEmbedChapters=false] - Whether to force embed chapters.\n * @property {boolean} [backup=false] - Whether to backup the files.\n */\n\nclass AudioMetadataMangaer {\n  constructor() {\n    this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')\n\n    this.MAX_CONCURRENT_TASKS = 1\n    this.tasksRunning = []\n    this.tasksQueued = []\n  }\n\n  /**\n   * Get queued task data\n   * @return {Array}\n   */\n  getQueuedTaskData() {\n    return this.tasksQueued.map((t) => t.data)\n  }\n\n  getIsLibraryItemQueuedOrProcessing(libraryItemId) {\n    return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @returns\n   */\n  getMetadataObjectForApi(libraryItem) {\n    return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('../models/LibraryItem')[]} libraryItems\n   * @param {UpdateMetadataOptions} options\n   */\n  handleBatchEmbed(userId, libraryItems, options = {}) {\n    libraryItems.forEach((li) => {\n      this.updateMetadataForItem(userId, li, options)\n    })\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {UpdateMetadataOptions} [options={}]\n   */\n  async updateMetadataForItem(userId, libraryItem, options = {}) {\n    const forceEmbedChapters = !!options.forceEmbedChapters\n    const backupFiles = !!options.backup\n\n    const audioFiles = libraryItem.media.includedAudioFiles\n\n    const task = new Task()\n\n    const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)\n\n    // Only writing chapters for single file audiobooks\n    const chapters = audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null\n\n    let mimeType = audioFiles[0].mimeType\n    if (audioFiles.some((a) => a.mimeType !== mimeType)) mimeType = null\n\n    // Create task\n    const libraryItemDir = libraryItem.isFile ? Path.dirname(libraryItem.path) : libraryItem.path\n    const taskData = {\n      libraryItemId: libraryItem.id,\n      libraryItemDir,\n      userId,\n      audioFiles: audioFiles.map((af) => ({\n        index: af.index,\n        ino: af.ino,\n        filename: af.metadata.filename,\n        path: af.metadata.path,\n        cachePath: Path.join(itemCachePath, af.metadata.filename),\n        duration: af.duration\n      })),\n      coverPath: libraryItem.media.coverPath,\n      metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),\n      itemCachePath,\n      chapters,\n      mimeType,\n      options: {\n        forceEmbedChapters,\n        backupFiles\n      },\n      duration: libraryItem.media.duration\n    }\n\n    const taskTitleString = {\n      text: 'Embedding Metadata',\n      key: 'MessageTaskEmbeddingMetadata'\n    }\n    const taskDescriptionString = {\n      text: `Embedding metadata in audiobook \"${libraryItem.media.title}\".`,\n      key: 'MessageTaskEmbeddingMetadataDescription',\n      subs: [libraryItem.media.title]\n    }\n    task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)\n\n    if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {\n      Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook \"${libraryItem.media.title}\"`)\n      SocketAuthority.adminEmitter('metadata_embed_queue_update', {\n        libraryItemId: libraryItem.id,\n        queued: true\n      })\n      this.tasksQueued.push(task)\n    } else {\n      this.runMetadataEmbed(task)\n    }\n  }\n\n  /**\n   *\n   * @param {import('../objects/Task')} task\n   */\n  async runMetadataEmbed(task) {\n    this.tasksRunning.push(task)\n    TaskManager.addTask(task)\n\n    Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)\n\n    // Ensure target directory is writable\n    const targetDirWritable = await fileUtils.isWritable(task.data.libraryItemDir)\n    Logger.debug(`[AudioMetadataManager] Target directory ${task.data.libraryItemDir} writable: ${targetDirWritable}`)\n    if (!targetDirWritable) {\n      Logger.error(`[AudioMetadataManager] Target directory is not writable: ${task.data.libraryItemDir}`)\n      const taskFailedString = {\n        text: 'Target directory is not writable',\n        key: 'MessageTaskTargetDirectoryNotWritable'\n      }\n      task.setFailed(taskFailedString)\n      this.handleTaskFinished(task)\n      return\n    }\n\n    // Ensure target audio files are writable\n    for (const af of task.data.audioFiles) {\n      try {\n        await fs.access(af.path, fs.constants.W_OK)\n      } catch (err) {\n        Logger.error(`[AudioMetadataManager] Audio file is not writable: ${af.path}`)\n        const taskFailedString = {\n          text: `Audio file \"${Path.basename(af.path)}\" is not writable`,\n          key: 'MessageTaskAudioFileNotWritable',\n          subs: [Path.basename(af.path)]\n        }\n        task.setFailed(taskFailedString)\n        this.handleTaskFinished(task)\n        return\n      }\n    }\n\n    // Ensure item cache dir exists\n    let cacheDirCreated = false\n    if (!(await fs.pathExists(task.data.itemCachePath))) {\n      try {\n        await fs.mkdir(task.data.itemCachePath)\n        cacheDirCreated = true\n      } catch (err) {\n        Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err)\n        const taskFailedString = {\n          text: 'Failed to create cache directory',\n          key: 'MessageTaskFailedToCreateCacheDirectory'\n        }\n        task.setFailed(taskFailedString)\n        this.handleTaskFinished(task)\n        return\n      }\n    }\n\n    // Create ffmetadata file\n    const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')\n    const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)\n    if (!success) {\n      Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook \"${task.data.libraryItemId}\"`)\n      const taskFailedString = {\n        text: 'Failed to write metadata file',\n        key: 'MessageTaskFailedToWriteMetadataFile'\n      }\n      task.setFailed(taskFailedString)\n      this.handleTaskFinished(task)\n      return\n    }\n\n    // Tag audio files\n    let cummulativeProgress = 0\n    for (const af of task.data.audioFiles) {\n      const audioFileRelativeDuration = af.duration / task.data.duration\n      SocketAuthority.adminEmitter('track_started', {\n        libraryItemId: task.data.libraryItemId,\n        ino: af.ino\n      })\n\n      // Backup audio file\n      if (task.data.options.backupFiles) {\n        try {\n          const backupFilePath = Path.join(task.data.itemCachePath, af.filename)\n          await fs.copy(af.path, backupFilePath)\n          Logger.debug(`[AudioMetadataManager] Backed up audio file at \"${backupFilePath}\"`)\n        } catch (err) {\n          Logger.error(`[AudioMetadataManager] Failed to backup audio file \"${af.path}\"`, err)\n          const taskFailedString = {\n            text: `Failed to backup audio file \"${Path.basename(af.path)}\"`,\n            key: 'MessageTaskFailedToBackupAudioFile',\n            subs: [Path.basename(af.path)]\n          }\n          task.setFailed(taskFailedString)\n          this.handleTaskFinished(task)\n          return\n        }\n      }\n\n      try {\n        await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType, (progress) => {\n          SocketAuthority.adminEmitter('task_progress', { libraryItemId: task.data.libraryItemId, progress: cummulativeProgress + progress * audioFileRelativeDuration })\n          SocketAuthority.adminEmitter('track_progress', { libraryItemId: task.data.libraryItemId, ino: af.ino, progress })\n        })\n        Logger.info(`[AudioMetadataManager] Successfully tagged audio file \"${af.path}\"`)\n      } catch (err) {\n        Logger.error(`[AudioMetadataManager] Failed to tag audio file \"${af.path}\"`, err)\n        const taskFailedString = {\n          text: `Failed to embed metadata in file \"${Path.basename(af.path)}\"`,\n          key: 'MessageTaskFailedToEmbedMetadataInFile',\n          subs: [Path.basename(af.path)]\n        }\n        task.setFailed(taskFailedString)\n        this.handleTaskFinished(task)\n        return\n      }\n\n      SocketAuthority.adminEmitter('track_finished', {\n        libraryItemId: task.data.libraryItemId,\n        ino: af.ino\n      })\n\n      cummulativeProgress += audioFileRelativeDuration * 100\n    }\n\n    // Remove temp cache file/folder if not backing up\n    if (!task.data.options.backupFiles) {\n      // If cache dir was created from this then remove it\n      if (cacheDirCreated) {\n        await fs.remove(task.data.itemCachePath)\n      } else {\n        await fs.remove(ffmetadataPath)\n      }\n    }\n\n    task.setFinished()\n    this.handleTaskFinished(task)\n  }\n\n  handleTaskFinished(task) {\n    TaskManager.taskFinished(task)\n    this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)\n\n    if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {\n      Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)\n      const nextTask = this.tasksQueued.shift()\n      SocketAuthority.emitter('metadata_embed_queue_update', {\n        libraryItemId: nextTask.data.libraryItemId,\n        queued: false\n      })\n      this.runMetadataEmbed(nextTask)\n    } else if (this.tasksRunning.length > 0) {\n      Logger.debug(`[AudioMetadataManager] Task finished but not dequeueing. Currently running ${this.tasksRunning.length} tasks. ${this.tasksQueued.length} tasks queued.`)\n    } else {\n      Logger.debug(`[AudioMetadataManager] Task finished and no tasks remain in queue`)\n    }\n  }\n}\nmodule.exports = AudioMetadataMangaer\n"
  },
  {
    "path": "server/managers/BackupManager.js",
    "content": "const sqlite3 = require('sqlite3')\nconst Path = require('path')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst cron = require('../libs/nodeCron')\nconst fs = require('../libs/fsExtra')\nconst archiver = require('../libs/archiver')\nconst StreamZip = require('../libs/nodeStreamZip')\nconst fileUtils = require('../utils/fileUtils')\n\n// Utils\nconst { getFileSize } = require('../utils/fileUtils')\n\nconst Backup = require('../objects/Backup')\nconst CacheManager = require('./CacheManager')\nconst NotificationManager = require('./NotificationManager')\n\nclass BackupManager {\n  constructor() {\n    this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')\n    this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')\n\n    this.scheduleTask = null\n\n    this.backups = []\n  }\n\n  get backupPath() {\n    return global.ServerSettings.backupPath\n  }\n\n  get backupPathEnvSet() {\n    return !!process.env.BACKUP_PATH\n  }\n\n  get backupSchedule() {\n    return global.ServerSettings.backupSchedule\n  }\n\n  get backupsToKeep() {\n    return global.ServerSettings.backupsToKeep || 2\n  }\n\n  get maxBackupSize() {\n    return global.ServerSettings.maxBackupSize || Infinity\n  }\n\n  async init() {\n    try {\n      const backupsDirExists = await fs.pathExists(this.backupPath)\n      if (!backupsDirExists) {\n        await fs.ensureDir(this.backupPath)\n      }\n    } catch (error) {\n      Logger.error(`[BackupManager] Failed to ensure backup directory at \"${this.backupPath}\": ${error.message}`)\n      throw new Error(`[BackupManager] Failed to ensure backup directory at \"${this.backupPath}\"`, { cause: error })\n    }\n\n    await this.loadBackups()\n    this.scheduleCron()\n  }\n\n  /**\n   * Reload backups after updating backup path\n   */\n  async reload() {\n    Logger.info(`[BackupManager] Reloading backups with backup path \"${this.backupPath}\"`)\n    this.backups = []\n    await this.loadBackups()\n    this.updateCronSchedule()\n  }\n\n  scheduleCron() {\n    if (!this.backupSchedule) {\n      Logger.info(`[BackupManager] Auto Backups are disabled`)\n      return\n    }\n    try {\n      var cronSchedule = this.backupSchedule\n      this.scheduleTask = cron.schedule(cronSchedule, this.runBackup.bind(this))\n    } catch (error) {\n      Logger.error(`[BackupManager] Failed to schedule backup cron ${this.backupSchedule}`, error)\n    }\n  }\n\n  updateCronSchedule() {\n    if (this.scheduleTask && !this.backupSchedule) {\n      Logger.info(`[BackupManager] Disabling backup schedule`)\n      if (this.scheduleTask.stop) this.scheduleTask.stop()\n      this.scheduleTask = null\n    } else if (!this.scheduleTask && this.backupSchedule) {\n      Logger.info(`[BackupManager] Starting backup schedule ${this.backupSchedule}`)\n      this.scheduleCron()\n    } else if (this.backupSchedule) {\n      Logger.info(`[BackupManager] Restarting backup schedule ${this.backupSchedule}`)\n      if (this.scheduleTask.stop) this.scheduleTask.stop()\n      this.scheduleCron()\n    }\n  }\n\n  async uploadBackup(req, res) {\n    const backupFile = req.files.file\n    if (Path.extname(backupFile.name) !== '.audiobookshelf') {\n      Logger.error(`[BackupManager] Invalid backup file uploaded \"${backupFile.name}\"`)\n      return res.status(500).send('Invalid backup file')\n    }\n\n    const tempPath = Path.join(this.backupPath, fileUtils.sanitizeFilename(backupFile.name))\n    const success = await backupFile\n      .mv(tempPath)\n      .then(() => true)\n      .catch((error) => {\n        Logger.error('[BackupManager] Failed to move backup file', path, error)\n        return false\n      })\n    if (!success) {\n      return res.status(500).send('Failed to move backup file into backups directory')\n    }\n\n    const zip = new StreamZip.async({ file: tempPath })\n    let entries\n    try {\n      entries = await zip.entries()\n    } catch (error) {\n      // Not a valid zip file\n      Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)\n      return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')\n    }\n    if (!Object.keys(entries).includes('absdatabase.sqlite')) {\n      Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)\n      return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')\n    }\n\n    const data = await zip.entryData('details')\n    const details = data.toString('utf8').split('\\n')\n\n    const backup = new Backup({ details, fullPath: tempPath })\n\n    if (!backup.serverVersion) {\n      Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)\n      return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')\n    }\n\n    backup.fileSize = await getFileSize(backup.fullPath)\n\n    const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)\n    if (existingBackupIndex >= 0) {\n      Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)\n      this.backups.splice(existingBackupIndex, 1, backup)\n    } else {\n      this.backups.push(backup)\n    }\n\n    res.json({\n      backups: this.backups.map((b) => b.toJSON())\n    })\n  }\n\n  async requestCreateBackup(res) {\n    var backupSuccess = await this.runBackup()\n    if (backupSuccess) {\n      res.json({\n        backups: this.backups.map((b) => b.toJSON())\n      })\n    } else {\n      res.sendStatus(500)\n    }\n  }\n\n  /**\n   *\n   * @param {import('./ApiCacheManager')} apiCacheManager\n   * @param {Backup} backup\n   * @param {import('express').Response} res\n   */\n  async requestApplyBackup(apiCacheManager, backup, res) {\n    Logger.info(`[BackupManager] Applying backup at \"${backup.fullPath}\"`)\n\n    const zip = new StreamZip.async({ file: backup.fullPath })\n\n    const entries = await zip.entries()\n\n    // Ensure backup has an absdatabase.sqlite file\n    if (!Object.keys(entries).includes('absdatabase.sqlite')) {\n      Logger.error(`[BackupManager] Cannot apply old backup ${backup.fullPath}`)\n      await zip.close()\n      return res.status(500).send('Invalid backup file. Does not include absdatabase.sqlite. This might be from an older Audiobookshelf server.')\n    }\n\n    await Database.disconnect()\n\n    const dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')\n    const tempDbPath = Path.join(global.ConfigPath, 'absdatabase-temp.sqlite')\n\n    // Extract backup sqlite file to temporary path\n    await zip.extract('absdatabase.sqlite', tempDbPath)\n    Logger.info(`[BackupManager] Extracted backup sqlite db to temp path ${tempDbPath}`)\n\n    // Verify extract - Abandon backup if sqlite file did not extract\n    if (!(await fs.pathExists(tempDbPath))) {\n      Logger.error(`[BackupManager] Sqlite file not found after extract - abandon backup apply and reconnect db`)\n      await zip.close()\n      await Database.reconnect()\n      return res.status(500).send('Failed to extract sqlite db from backup')\n    }\n\n    // Attempt to remove existing db file\n    try {\n      await fs.remove(dbPath)\n    } catch (error) {\n      // Abandon backup and remove extracted sqlite file if unable to remove existing db file\n      Logger.error(`[BackupManager] Unable to overwrite existing db file - abandon backup apply and reconnect db`, error)\n      await fs.remove(tempDbPath)\n      await zip.close()\n      await Database.reconnect()\n      return res.status(500).send(`Failed to overwrite sqlite db: ${error?.message || 'Unknown Error'}`)\n    }\n\n    // Rename temp db\n    await fs.move(tempDbPath, dbPath)\n    Logger.info(`[BackupManager] Saved backup sqlite file at \"${dbPath}\"`)\n\n    // Extract /metadata/items and /metadata/authors folders\n    await fs.ensureDir(this.ItemsMetadataPath)\n    await zip.extract('metadata-items/', this.ItemsMetadataPath)\n    await fs.ensureDir(this.AuthorsMetadataPath)\n    await zip.extract('metadata-authors/', this.AuthorsMetadataPath)\n    await zip.close()\n\n    // Reconnect db\n    await Database.reconnect()\n\n    // Reset api cache, set hooks again\n    await apiCacheManager.reset()\n\n    // Clear metadata cache\n    await CacheManager.purgeAll()\n\n    res.sendStatus(200)\n\n    // Triggers browser refresh for all clients\n    SocketAuthority.emitter('backup_applied')\n  }\n\n  async loadBackups() {\n    try {\n      const filesInDir = await fs.readdir(this.backupPath)\n\n      for (let i = 0; i < filesInDir.length; i++) {\n        const filename = filesInDir[i]\n        if (filename.endsWith('.audiobookshelf')) {\n          const fullFilePath = Path.join(this.backupPath, filename)\n\n          let zip = null\n          let data = null\n          try {\n            zip = new StreamZip.async({ file: fullFilePath })\n            data = await zip.entryData('details')\n          } catch (error) {\n            Logger.error(`[BackupManager] Failed to unzip backup \"${fullFilePath}\"`, error)\n            continue\n          }\n\n          const details = data.toString('utf8').split('\\n')\n\n          const backup = new Backup({ details, fullPath: fullFilePath })\n\n          if (!backup.serverVersion) {\n            // Backups before v2\n            Logger.error(`[BackupManager] Old unsupported backup was found \"${backup.filename}\"`)\n          } else if (!backup.key) {\n            // Backups before sqlite migration\n            Logger.warn(`[BackupManager] Old unsupported backup was found \"${backup.filename}\" (pre sqlite migration)`)\n          }\n\n          backup.fileSize = await getFileSize(backup.fullPath)\n          const existingBackupWithId = this.backups.find((b) => b.id === backup.id)\n          if (existingBackupWithId) {\n            Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)\n          } else {\n            this.backups.push(backup)\n          }\n\n          Logger.debug(`[BackupManager] Backup found \"${backup.id}\"`)\n          await zip.close()\n        }\n      }\n      Logger.info(`[BackupManager] ${this.backups.length} Backups Found`)\n    } catch (error) {\n      Logger.error('[BackupManager] Failed to load backups', error)\n    }\n  }\n\n  async runBackup() {\n    // Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)\n    Logger.info(`[BackupManager] Running Backup`)\n    const newBackup = new Backup()\n    newBackup.setData(this.backupPath)\n\n    await fs.ensureDir(this.AuthorsMetadataPath)\n\n    // Create backup sqlite file\n    const sqliteBackupPath = await this.backupSqliteDb(newBackup).catch((error) => {\n      Logger.error(`[BackupManager] Failed to backup sqlite db`, error)\n      const errorMsg = error?.message || error || 'Unknown Error'\n      NotificationManager.onBackupFailed(errorMsg)\n      return false\n    })\n\n    if (!sqliteBackupPath) {\n      return false\n    }\n\n    // Zip sqlite file, /metadata/items, and /metadata/authors folders\n    const zipResult = await this.zipBackup(sqliteBackupPath, newBackup).catch((error) => {\n      Logger.error(`[BackupManager] Backup Failed ${error}`)\n      const errorMsg = error?.message || error || 'Unknown Error'\n      NotificationManager.onBackupFailed(errorMsg)\n      return false\n    })\n\n    // Remove sqlite backup\n    await fs.remove(sqliteBackupPath)\n\n    if (!zipResult) return false\n\n    Logger.info(`[BackupManager] Backup successful ${newBackup.id}`)\n\n    newBackup.fileSize = await getFileSize(newBackup.fullPath)\n\n    const existingIndex = this.backups.findIndex((b) => b.id === newBackup.id)\n    if (existingIndex >= 0) {\n      this.backups.splice(existingIndex, 1, newBackup)\n    } else {\n      this.backups.push(newBackup)\n    }\n\n    // Check remove oldest backup\n    const removeOldest = this.backups.length > this.backupsToKeep\n    if (removeOldest) {\n      this.backups.sort((a, b) => a.createdAt - b.createdAt)\n\n      const oldBackup = this.backups.shift()\n      Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)\n      this.removeBackup(oldBackup)\n    }\n\n    // Notification for backup successfully completed\n    NotificationManager.onBackupCompleted(newBackup, this.backups.length, removeOldest)\n\n    return true\n  }\n\n  async removeBackup(backup) {\n    try {\n      Logger.debug(`[BackupManager] Removing Backup \"${backup.fullPath}\"`)\n      await fs.remove(backup.fullPath)\n      this.backups = this.backups.filter((b) => b.id !== backup.id)\n      Logger.info(`[BackupManager] Backup \"${backup.id}\" Removed`)\n    } catch (error) {\n      Logger.error(`[BackupManager] Failed to remove backup`, error)\n    }\n  }\n\n  /**\n   * @see https://github.com/TryGhost/node-sqlite3/pull/1116\n   * @param {Backup} backup\n   */\n  backupSqliteDb(backup) {\n    const db = new sqlite3.Database(Database.dbPath)\n    const dbFilePath = Path.join(global.ConfigPath, `absdatabase.${backup.id}.sqlite`)\n    return new Promise(async (resolve, reject) => {\n      const backup = db.backup(dbFilePath)\n      backup.step(-1)\n      backup.finish()\n\n      // Max time ~2 mins\n      for (let i = 0; i < 240; i++) {\n        if (backup.completed) {\n          return resolve(dbFilePath)\n        } else if (backup.failed) {\n          return reject(backup.message || 'Unknown failure reason')\n        }\n        await new Promise((r) => setTimeout(r, 500))\n      }\n\n      Logger.error(`[BackupManager] Backup sqlite timed out`)\n      reject('Backup timed out')\n    })\n  }\n\n  zipBackup(sqliteBackupPath, backup) {\n    return new Promise((resolve, reject) => {\n      // create a file to stream archive data to\n      const output = fs.createWriteStream(backup.fullPath)\n      const archive = archiver('zip', {\n        zlib: { level: 9 } // Sets the compression level.\n      })\n\n      // listen for all archive data to be written\n      // 'close' event is fired only when a file descriptor is involved\n      output.on('close', () => {\n        Logger.info('[BackupManager]', archive.pointer() + ' total bytes')\n        resolve(true)\n      })\n\n      // This event is fired when the data source is drained no matter what was the data source.\n      // It is not part of this library but rather from the NodeJS Stream API.\n      // @see: https://nodejs.org/api/stream.html#stream_event_end\n      output.on('end', () => {\n        Logger.debug('Data has been drained')\n      })\n\n      output.on('finish', () => {\n        Logger.debug('Write Stream Finished')\n      })\n\n      output.on('error', (err) => {\n        Logger.debug('Write Stream Error', err)\n        reject(err)\n      })\n\n      // good practice to catch warnings (ie stat failures and other non-blocking errors)\n      archive.on('warning', function (err) {\n        if (err.code === 'ENOENT') {\n          // log warning\n          Logger.warn(`[BackupManager] Archiver warning: ${err.message}`)\n        } else {\n          // throw error\n          Logger.error(`[BackupManager] Archiver error: ${err.message}`)\n          // throw err\n          reject(err)\n        }\n      })\n      archive.on('error', function (err) {\n        Logger.error(`[BackupManager] Archiver error: ${err.message}`)\n        reject(err)\n      })\n      archive.on('progress', ({ fs: fsobj }) => {\n        if (this.maxBackupSize !== Infinity) {\n          const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000\n          if (fsobj.processedBytes > maxBackupSizeInBytes) {\n            Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)\n            archive.abort()\n            setTimeout(() => {\n              this.removeBackup(backup)\n              output.destroy('Backup too large') // Promise is reject in write stream error evt\n            }, 500)\n          }\n        }\n      })\n\n      // pipe archive data to the file\n      archive.pipe(output)\n\n      archive.file(sqliteBackupPath, { name: 'absdatabase.sqlite' })\n      archive.directory(this.ItemsMetadataPath, 'metadata-items')\n      archive.directory(this.AuthorsMetadataPath, 'metadata-authors')\n\n      archive.append(backup.detailsString, { name: 'details' })\n\n      archive.finalize()\n    })\n  }\n}\nmodule.exports = BackupManager\n"
  },
  {
    "path": "server/managers/BinaryManager.js",
    "content": "const child_process = require('child_process')\nconst { promisify } = require('util')\nconst exec = promisify(child_process.exec)\nconst os = require('os')\nconst axios = require('axios')\nconst path = require('path')\nconst which = require('../libs/which')\nconst fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst fileUtils = require('../utils/fileUtils')\nconst StreamZip = require('../libs/nodeStreamZip')\n\nclass ZippedAssetDownloader {\n  constructor() {\n    this.assetCache = {}\n  }\n\n  getReleaseUrl(releaseTag) {\n    throw new Error('Not implemented')\n  }\n\n  extractAssetUrl(assets, assetName) {\n    throw new Error('Not implemented')\n  }\n\n  getAssetName(binaryName, releaseTag) {\n    throw new Error('Not implemented')\n  }\n\n  getAssetFileName(binaryName) {\n    throw new Error('Not implemented')\n  }\n\n  async getAssetUrl(releaseTag, assetName) {\n    // Check if the assets information is already cached for the release tag\n    if (this.assetCache[releaseTag]) {\n      Logger.debug(`[ZippedAssetDownloader] release ${releaseTag}: assets found in cache.`)\n    } else {\n      // Get the release information\n      const releaseUrl = this.getReleaseUrl(releaseTag)\n      const releaseResponse = await axios.get(releaseUrl, { headers: { 'User-Agent': 'axios' } })\n\n      // Cache the assets information for the release tag\n      this.assetCache[releaseTag] = releaseResponse.data\n      Logger.debug(`[ZippedAssetDownloader] release ${releaseTag}: assets fetched from API.`)\n    }\n\n    const assets = this.assetCache[releaseTag]\n    const assetUrl = this.extractAssetUrl(assets, assetName)\n\n    return assetUrl\n  }\n\n  async downloadAsset(assetUrl, destDir) {\n    const zipPath = path.join(destDir, 'temp.zip')\n    const writer = fs.createWriteStream(zipPath)\n\n    const assetResponse = await axios({ url: assetUrl, responseType: 'stream' })\n\n    assetResponse.data.pipe(writer)\n\n    await new Promise((resolve, reject) => {\n      writer.on('finish', () => {\n        Logger.debug(`[ZippedAssetDownloader] Downloaded asset ${assetUrl} to ${zipPath}`)\n        resolve()\n      })\n      writer.on('error', (err) => {\n        Logger.error(`[ZippedAssetDownloader] Error downloading asset ${assetUrl}: ${err.message}`)\n        reject(err)\n      })\n    })\n\n    return zipPath\n  }\n\n  async extractFiles(zipPath, filesToExtract, destDir) {\n    const zip = new StreamZip.async({ file: zipPath })\n\n    try {\n      for (const file of filesToExtract) {\n        const outputPath = path.join(destDir, file.outputFileName)\n        if (!(await zip.entry(file.pathInsideZip))) {\n          Logger.error(`[ZippedAssetDownloader] File ${file.pathInsideZip} not found in zip file ${zipPath}`)\n          continue\n        }\n        await zip.extract(file.pathInsideZip, outputPath)\n        Logger.debug(`[ZippedAssetDownloader] Extracted file ${file.pathInsideZip} to ${outputPath}`)\n\n        // Set executable permission for Linux\n        if (process.platform !== 'win32') {\n          await fs.chmod(outputPath, 0o755)\n        }\n      }\n    } catch (error) {\n      Logger.error('[ZippedAssetDownloader] Error extracting files:', error)\n      throw error\n    } finally {\n      await zip.close()\n    }\n  }\n\n  async downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir) {\n    let zipPath\n    try {\n      await fs.ensureDir(destDir)\n      const assetUrl = await this.getAssetUrl(releaseTag, assetName)\n      zipPath = await this.downloadAsset(assetUrl, destDir)\n      await this.extractFiles(zipPath, filesToExtract, destDir)\n    } catch (error) {\n      Logger.error(`[ZippedAssetDownloader] Error downloading or extracting files: ${error.message}`)\n    } finally {\n      if (zipPath) await fs.remove(zipPath)\n    }\n  }\n\n  async downloadBinary(binaryName, releaseTag, destDir) {\n    const assetName = this.getAssetName(binaryName, releaseTag)\n    const fileName = this.getAssetFileName(binaryName)\n    const filesToExtract = [{ pathInsideZip: fileName, outputFileName: fileName }]\n\n    await this.downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir)\n  }\n}\n\nclass FFBinariesDownloader extends ZippedAssetDownloader {\n  constructor() {\n    super()\n    this.platformSuffix = this.getPlatformSuffix()\n  }\n\n  getPlatformSuffix() {\n    var type = os.type().toLowerCase()\n    var arch = os.arch().toLowerCase()\n\n    if (type === 'darwin') {\n      return 'osx-64'\n    }\n\n    if (type === 'windows_nt') {\n      return arch === 'x64' ? 'windows-64' : 'windows-32'\n    }\n\n    if (type === 'linux') {\n      if (arch === 'arm') return 'linux-armel'\n      if (arch === 'arm64') return 'linux-arm64'\n      return arch === 'x64' ? 'linux-64' : 'linux-32'\n    }\n\n    return null\n  }\n\n  getReleaseUrl(releaseTag) {\n    return `https://ffbinaries.com/api/v1/version/${releaseTag}`\n  }\n\n  extractAssetUrl(assets, assetName) {\n    const assetUrl = assets?.bin?.[this.platformSuffix]?.[assetName]\n\n    if (!assetUrl) {\n      throw new Error(`[FFBinariesDownloader] Asset ${assetName} not found for platform ${this.platformSuffix}`)\n    }\n\n    return assetUrl\n  }\n\n  getAssetName(binaryName, releaseTag) {\n    return binaryName\n  }\n\n  getAssetFileName(binaryName) {\n    return process.platform === 'win32' ? `${binaryName}.exe` : binaryName\n  }\n}\n\nclass NunicodeDownloader extends ZippedAssetDownloader {\n  constructor() {\n    super()\n    this.platformSuffix = this.getPlatformSuffix()\n  }\n\n  getPlatformSuffix() {\n    const platform = process.platform\n    const arch = process.arch\n\n    if (platform === 'win32' && arch === 'x64') {\n      return 'win-x64'\n    } else if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {\n      return 'osx-arm64'\n    } else if (platform === 'linux' && arch === 'x64') {\n      return 'linux-x64'\n    } else if (platform === 'linux' && arch === 'arm64') {\n      return 'linux-arm64'\n    }\n\n    return null\n  }\n\n  async getAssetUrl(releaseTag, assetName) {\n    return `https://github.com/mikiher/nunicode-sqlite/releases/download/v${releaseTag}/${assetName}`\n  }\n\n  getAssetName(binaryName, releaseTag) {\n    if (!this.platformSuffix) {\n      throw new Error(`[NunicodeDownloader] Platform ${process.platform}-${process.arch} not supported`)\n    }\n    return `${binaryName}-${this.platformSuffix}.zip`\n  }\n\n  getAssetFileName(binaryName) {\n    if (process.platform === 'win32') {\n      return `${binaryName}.dll`\n    } else if (process.platform === 'darwin') {\n      return `${binaryName}.dylib`\n    } else if (process.platform === 'linux') {\n      return `${binaryName}.so`\n    }\n\n    throw new Error(`[NunicodeDownloader] Platform ${process.platform} not supported`)\n  }\n}\n\nclass Binary {\n  constructor(name, type, envVariable, validVersions, source, required = true) {\n    if (!name) throw new Error('Binary name is required')\n    this.name = name\n    if (!type) throw new Error('Binary type is required')\n    this.type = type\n    if (!envVariable) throw new Error('Binary environment variable name is required')\n    this.envVariable = envVariable\n    if (!validVersions || !validVersions.length) throw new Error(`No valid versions specified for ${type} ${name}. At least one version is required.`)\n    this.validVersions = validVersions\n    if (!source || !(source instanceof ZippedAssetDownloader)) throw new Error('Binary source is required, and must be an instance of ZippedAssetDownloader')\n    this.source = source\n    this.fileName = this.getFileName()\n    this.required = required\n    this.exec = exec\n  }\n\n  async find(mainInstallDir, altInstallDir) {\n    // 1. check path specified in environment variable\n    const defaultPath = process.env[this.envVariable]\n    if (await this.isGood(defaultPath)) return defaultPath\n    // 2. find the first instance of the binary in the PATH environment variable\n    if (this.type === 'executable') {\n      const whichPath = which.sync(this.fileName, { nothrow: true })\n      if (await this.isGood(whichPath)) return whichPath\n    }\n    // 3. check main install path (binary root dir)\n    const mainInstallPath = path.join(mainInstallDir, this.fileName)\n    if (await this.isGood(mainInstallPath)) return mainInstallPath\n    // 4. check alt install path (/config)\n    const altInstallPath = path.join(altInstallDir, this.fileName)\n    if (await this.isGood(altInstallPath)) return altInstallPath\n    return null\n  }\n\n  getFileName() {\n    const platform = process.platform\n\n    if (this.type === 'executable') {\n      return this.name + (platform == 'win32' ? '.exe' : '')\n    } else if (this.type === 'library') {\n      return this.name + (platform == 'win32' ? '.dll' : platform == 'darwin' ? '.dylib' : '.so')\n    } else {\n      return this.name\n    }\n  }\n\n  async isLibraryVersionValid(libraryPath) {\n    try {\n      const versionFilePath = libraryPath + '.ver'\n      if (!(await fs.pathExists(versionFilePath))) return false\n      const version = (await fs.readFile(versionFilePath, 'utf8')).trim()\n      return this.validVersions.some((validVersion) => version.startsWith(validVersion))\n    } catch (err) {\n      Logger.error(`[Binary] Failed to check version of ${libraryPath}`, err)\n      return false\n    }\n  }\n\n  async isExecutableVersionValid(executablePath) {\n    try {\n      const { stdout } = await this.exec('\"' + executablePath + '\"' + ' -version')\n      const version = stdout.match(/version\\s([\\d\\.]+)/)?.[1]\n      if (!version) return false\n      return this.validVersions.some((validVersion) => version.startsWith(validVersion))\n    } catch (err) {\n      Logger.error(`[Binary] Failed to check version of ${executablePath}`, err)\n      return false\n    }\n  }\n\n  async isGood(binaryPath) {\n    try {\n      if (!binaryPath || !(await fs.pathExists(binaryPath))) return false\n      if (this.type === 'library') return await this.isLibraryVersionValid(binaryPath)\n      else if (this.type === 'executable') return await this.isExecutableVersionValid(binaryPath)\n      else return true\n    } catch (err) {\n      Logger.error(`[Binary] Failed to check ${this.type} ${this.name} at ${binaryPath}`, err)\n      return false\n    }\n  }\n\n  async download(destination) {\n    const version = this.validVersions[0]\n    try {\n      await this.source.downloadBinary(this.name, version, destination)\n      // if it's a library, write the version string to a file\n      if (this.type === 'library') {\n        const libraryPath = path.join(destination, this.fileName)\n        await fs.writeFile(libraryPath + '.ver', version)\n      }\n    } catch (err) {\n      Logger.error(`[Binary] Failed to download ${this.type} ${this.name} version ${version} to ${destination}`, err)\n    }\n  }\n}\n\nconst ffbinaries = new FFBinariesDownloader()\nconst nunicode = new NunicodeDownloader()\n\nclass BinaryManager {\n  defaultRequiredBinaries = [\n    new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries), // ffmpeg executable\n    new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries), // ffprobe executable\n    new Binary('libnusqlite3', 'library', 'NUSQLITE3_PATH', ['1.2'], nunicode, false) // nunicode sqlite3 extension\n  ]\n\n  constructor(requiredBinaries = this.defaultRequiredBinaries) {\n    this.requiredBinaries = requiredBinaries\n    this.mainInstallDir = process.pkg ? path.dirname(process.execPath) : global.appRoot\n    this.altInstallDir = global.ConfigPath\n    this.initialized = false\n  }\n\n  async init() {\n    // Optional skip binaries check\n    if (process.env.SKIP_BINARIES_CHECK === '1') {\n      for (const binary of this.requiredBinaries) {\n        if (!process.env[binary.envVariable] && binary.required) {\n          await Logger.fatal(`[BinaryManager] Environment variable ${binary.envVariable} must be set`)\n          process.exit(1)\n        }\n      }\n      Logger.info('[BinaryManager] Skipping check for binaries')\n      return\n    }\n\n    if (this.initialized) return\n\n    const missingBinaries = await this.findRequiredBinaries()\n    if (missingBinaries.length == 0) return\n    await this.removeOldBinaries(missingBinaries)\n    await this.install(missingBinaries)\n    const missingBinariesAfterInstall = await this.findRequiredBinaries()\n    const missingRequiredBinryNames = missingBinariesAfterInstall.filter((binary) => binary.required).map((binary) => binary.name)\n    if (missingRequiredBinryNames.length) {\n      Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingRequiredBinryNames.join(', ')}`)\n      process.exit(1)\n    }\n    this.initialized = true\n  }\n\n  /**\n   * Remove binary\n   *\n   * @param {string} destination\n   * @param {Binary} binary\n   */\n  async removeBinary(destination, binary) {\n    try {\n      const binaryPath = path.join(destination, binary.fileName)\n      if (await fs.pathExists(binaryPath)) {\n        Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)\n        await fs.remove(binaryPath)\n      }\n    } catch (err) {\n      Logger.error(`[BinaryManager] Error removing binary: ${binaryPath}`)\n    }\n  }\n\n  /**\n   * Remove old binaries\n   *\n   * @param {Binary[]} binaries\n   */\n  async removeOldBinaries(binaries) {\n    for (const binary of binaries) {\n      await this.removeBinary(this.mainInstallDir, binary)\n      await this.removeBinary(this.altInstallDir, binary)\n    }\n  }\n\n  /**\n   * Find required binaries and return array of binary names that are missing\n   *\n   * @returns {Promise<Binary[]>} Array of missing binaries\n   */\n  async findRequiredBinaries() {\n    const missingBinaries = []\n    for (const binary of this.requiredBinaries) {\n      const binaryPath = await binary.find(this.mainInstallDir, this.altInstallDir)\n      if (binaryPath) {\n        Logger.info(`[BinaryManager] Found valid ${binary.type} ${binary.name} at ${binaryPath}`)\n        if (process.env[binary.envVariable] !== binaryPath) {\n          Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`)\n          process.env[binary.envVariable] = binaryPath\n        }\n      } else {\n        Logger.info(`[BinaryManager] ${binary.name} not found or not a valid version`)\n        missingBinaries.push(binary)\n      }\n    }\n    return missingBinaries\n  }\n\n  /**\n   * Install missing binaries\n   *\n   * @param {Binary[]} binaries\n   */\n  async install(binaries) {\n    if (!binaries.length) return\n    Logger.info(`[BinaryManager] Installing binaries: ${binaries.map((binary) => binary.name).join(', ')}`)\n    let destination = (await fileUtils.isWritable(this.mainInstallDir)) ? this.mainInstallDir : this.altInstallDir\n    for (const binary of binaries) {\n      await binary.download(destination)\n    }\n    Logger.info(`[BinaryManager] Binaries installed to ${destination}`)\n  }\n}\n\nmodule.exports = BinaryManager\nmodule.exports.Binary = Binary // for testing\nmodule.exports.ffbinaries = ffbinaries // for testing\nmodule.exports.nunicode = nunicode // for testing\n"
  },
  {
    "path": "server/managers/CacheManager.js",
    "content": "const Path = require('path')\nconst fs = require('../libs/fsExtra')\nconst stream = require('stream')\nconst Logger = require('../Logger')\nconst { resizeImage } = require('../utils/ffmpegHelpers')\nconst { encodeUriPath } = require('../utils/fileUtils')\nconst Database = require('../Database')\n\nclass CacheManager {\n  constructor() {\n    this.CachePath = null\n    this.CoverCachePath = null\n    this.ImageCachePath = null\n    this.ItemCachePath = null\n  }\n\n  /**\n   * Create cache directory paths if they dont exist\n   */\n  async ensureCachePaths() {\n    // Creates cache paths if necessary and sets owner and permissions\n    this.CachePath = Path.join(global.MetadataPath, 'cache')\n    this.CoverCachePath = Path.join(this.CachePath, 'covers')\n    this.ImageCachePath = Path.join(this.CachePath, 'images')\n    this.ItemCachePath = Path.join(this.CachePath, 'items')\n\n    try {\n      await fs.ensureDir(this.CachePath)\n      await fs.ensureDir(this.CoverCachePath)\n      await fs.ensureDir(this.ImageCachePath)\n      await fs.ensureDir(this.ItemCachePath)\n    } catch (error) {\n      Logger.error(`[CacheManager] Failed to create cache directories at \"${this.CachePath}\": ${error.message}`)\n      throw new Error(`[CacheManager] Failed to create cache directories at \"${this.CachePath}\"`, { cause: error })\n    }\n  }\n\n  async handleCoverCache(res, libraryItemId, options = {}) {\n    const format = options.format || 'webp'\n    const width = options.width || 400\n    const height = options.height || null\n\n    res.type(`image/${format}`)\n\n    const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format\n\n    // Cache exists\n    if (await fs.pathExists(cachePath)) {\n      if (global.XAccel) {\n        const encodedURI = encodeUriPath(global.XAccel + cachePath)\n        Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n        return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n      }\n\n      const r = fs.createReadStream(cachePath)\n      const ps = new stream.PassThrough()\n      stream.pipeline(r, ps, (err) => {\n        if (err) {\n          console.log(err)\n          return res.sendStatus(500)\n        }\n      })\n      return ps.pipe(res)\n    }\n\n    // Cached cover does not exist, generate it\n    const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)\n    if (!coverPath || !(await fs.pathExists(coverPath))) {\n      return res.sendStatus(404)\n    }\n\n    const writtenFile = await resizeImage(coverPath, cachePath, width, height)\n    if (!writtenFile) return res.sendStatus(500)\n\n    if (global.XAccel) {\n      const encodedURI = encodeUriPath(global.XAccel + writtenFile)\n      Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)\n      return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()\n    }\n\n    var readStream = fs.createReadStream(writtenFile)\n    readStream.pipe(res)\n  }\n\n  purgeCoverCache(libraryItemId) {\n    return this.purgeEntityCache(libraryItemId, this.CoverCachePath)\n  }\n\n  purgeImageCache(entityId) {\n    return this.purgeEntityCache(entityId, this.ImageCachePath)\n  }\n\n  async purgeEntityCache(entityId, cachePath) {\n    if (!entityId || !cachePath) return []\n    return Promise.all(\n      (await fs.readdir(cachePath)).reduce((promises, file) => {\n        if (file.startsWith(entityId)) {\n          Logger.debug(`[CacheManager] Going to purge ${file}`)\n          promises.push(this.removeCache(Path.join(cachePath, file)))\n        }\n        return promises\n      }, [])\n    )\n  }\n\n  removeCache(path) {\n    if (!path) return false\n    return fs.pathExists(path).then((exists) => {\n      if (!exists) return false\n      return fs\n        .unlink(path)\n        .then(() => true)\n        .catch((err) => {\n          Logger.error(`[CacheManager] Failed to remove cache \"${path}\"`, err)\n          return false\n        })\n    })\n  }\n\n  async purgeAll() {\n    Logger.info(`[CacheManager] Purging all cache at \"${this.CachePath}\"`)\n    if (await fs.pathExists(this.CachePath)) {\n      await fs.remove(this.CachePath).catch((error) => {\n        Logger.error(`[CacheManager] Failed to remove cache dir \"${this.CachePath}\"`, error)\n      })\n    }\n    await this.ensureCachePaths()\n  }\n\n  async purgeItems() {\n    Logger.info(`[CacheManager] Purging items cache at \"${this.ItemCachePath}\"`)\n    if (await fs.pathExists(this.ItemCachePath)) {\n      await fs.remove(this.ItemCachePath).catch((error) => {\n        Logger.error(`[CacheManager] Failed to remove items cache dir \"${this.ItemCachePath}\"`, error)\n      })\n    }\n    await this.ensureCachePaths()\n  }\n\n  /**\n   *\n   * @param {import('express').Response} res\n   * @param {String} authorId\n   * @param {{ format?: string, width?: number, height?: number }} options\n   * @returns\n   */\n  async handleAuthorCache(res, authorId, options = {}) {\n    const format = options.format || 'webp'\n    const width = options.width || 400\n    const height = options.height || null\n\n    res.type(`image/${format}`)\n\n    var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format\n\n    // Cache exists\n    if (await fs.pathExists(cachePath)) {\n      const r = fs.createReadStream(cachePath)\n      const ps = new stream.PassThrough()\n      stream.pipeline(r, ps, (err) => {\n        if (err) {\n          console.log(err)\n          return res.sendStatus(500)\n        }\n      })\n      return ps.pipe(res)\n    }\n\n    const author = await Database.authorModel.findByPk(authorId)\n    if (!author || !author.imagePath || !(await fs.pathExists(author.imagePath))) {\n      return res.sendStatus(404)\n    }\n\n    let writtenFile = await resizeImage(author.imagePath, cachePath, width, height)\n    if (!writtenFile) return res.sendStatus(500)\n\n    var readStream = fs.createReadStream(writtenFile)\n    readStream.pipe(res)\n  }\n}\nmodule.exports = new CacheManager()\n"
  },
  {
    "path": "server/managers/CoverManager.js",
    "content": "const fs = require('../libs/fsExtra')\nconst Path = require('path')\nconst Logger = require('../Logger')\nconst readChunk = require('../libs/readChunk')\nconst imageType = require('../libs/imageType')\n\nconst globals = require('../utils/globals')\nconst { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')\nconst { extractCoverArt } = require('../utils/ffmpegHelpers')\nconst parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')\n\nconst CacheManager = require('../managers/CacheManager')\n\nclass CoverManager {\n  constructor() {}\n\n  getCoverDirectory(libraryItem) {\n    if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {\n      return libraryItem.path\n    } else {\n      return Path.posix.join(Path.posix.join(global.MetadataPath, 'items'), libraryItem.id)\n    }\n  }\n\n  getFilesInDirectory(dir) {\n    try {\n      return fs.readdir(dir)\n    } catch (error) {\n      Logger.error(`[CoverManager] Failed to get files in dir ${dir}`, error)\n      return []\n    }\n  }\n\n  removeFile(filepath) {\n    try {\n      return fs.pathExists(filepath).then((exists) => {\n        if (!exists) Logger.warn(`[CoverManager] Attempting to remove file that does not exist ${filepath}`)\n        return exists ? fs.unlink(filepath) : false\n      })\n    } catch (error) {\n      Logger.error(`[CoverManager] Failed to remove file \"${filepath}\"`, error)\n      return false\n    }\n  }\n\n  // Remove covers that dont have the same filename as the new cover\n  async removeOldCovers(dirpath, newCoverExt) {\n    var filesInDir = await this.getFilesInDirectory(dirpath)\n\n    const imageExtensions = ['.jpeg', '.jpg', '.png', '.webp', '.jiff']\n    for (let i = 0; i < filesInDir.length; i++) {\n      var file = filesInDir[i]\n      var _extname = Path.extname(file).toLowerCase()\n      var _filename = Path.basename(file, _extname).toLowerCase()\n      if (_filename === 'cover' && _extname !== newCoverExt && imageExtensions.includes(_extname)) {\n        var filepath = Path.join(dirpath, file)\n        Logger.debug(`[CoverManager] Removing old cover from metadata \"${filepath}\"`)\n        await this.removeFile(filepath)\n      }\n    }\n  }\n\n  async checkFileIsValidImage(imagepath, removeOnInvalid = false) {\n    const buffer = await readChunk(imagepath, 0, 12)\n    const imgType = imageType(buffer)\n    if (!imgType) {\n      if (removeOnInvalid) await this.removeFile(imagepath)\n      return {\n        error: 'Invalid image'\n      }\n    }\n\n    if (!globals.SupportedImageTypes.includes(imgType.ext)) {\n      if (removeOnInvalid) await this.removeFile(imagepath)\n      return {\n        error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})`\n      }\n    }\n    return imgType\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} coverFile - file object from req.files\n   * @returns {Promise<{error:string}|{cover:string}>}\n   */\n  async uploadCover(libraryItem, coverFile) {\n    const extname = Path.extname(coverFile.name.toLowerCase())\n    if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {\n      return {\n        error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})`\n      }\n    }\n\n    const coverDirPath = this.getCoverDirectory(libraryItem)\n    await fs.ensureDir(coverDirPath)\n\n    const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)\n\n    // Move cover from temp upload dir to destination\n    const success = await coverFile\n      .mv(coverFullPath)\n      .then(() => true)\n      .catch((error) => {\n        Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)\n        return false\n      })\n\n    if (!success) {\n      return {\n        error: 'Failed to move cover into destination'\n      }\n    }\n\n    await this.removeOldCovers(coverDirPath, extname)\n    await CacheManager.purgeCoverCache(libraryItem.id)\n\n    Logger.info(`[CoverManager] Uploaded libraryItem cover \"${coverFullPath}\" for \"${libraryItem.media.title}\"`)\n\n    return {\n      cover: coverFullPath\n    }\n  }\n\n  /**\n   *\n   * @param {string} coverPath\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @returns {Promise<{error:string}|{cover:string,updated:boolean}>}\n   */\n  async validateCoverPath(coverPath, libraryItem) {\n    // Invalid cover path\n    if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) {\n      Logger.error(`[CoverManager] validate cover path invalid http url \"${coverPath}\"`)\n      return {\n        error: 'Invalid cover path'\n      }\n    }\n    coverPath = filePathToPOSIX(coverPath)\n    // Cover path already set on media\n    if (libraryItem.media.coverPath == coverPath) {\n      Logger.debug(`[CoverManager] validate cover path already set \"${coverPath}\"`)\n      return {\n        cover: coverPath,\n        updated: false\n      }\n    }\n\n    // Cover path does not exist\n    if (!(await fs.pathExists(coverPath))) {\n      Logger.error(`[CoverManager] validate cover path does not exist \"${coverPath}\"`)\n      return {\n        error: 'Cover path does not exist'\n      }\n    }\n\n    // Cover path is not a file\n    if (!(await checkPathIsFile(coverPath))) {\n      Logger.error(`[CoverManager] validate cover path is not a file \"${coverPath}\"`)\n      return {\n        error: 'Cover path is not a file'\n      }\n    }\n\n    // Check valid image at path\n    var imgtype = await this.checkFileIsValidImage(coverPath, false)\n    if (imgtype.error) {\n      return imgtype\n    }\n\n    var coverDirPath = this.getCoverDirectory(libraryItem)\n\n    // Cover path is not in correct directory - make a copy\n    if (!coverPath.startsWith(coverDirPath)) {\n      await fs.ensureDir(coverDirPath)\n\n      var coverFilename = `cover.${imgtype.ext}`\n      var newCoverPath = Path.posix.join(coverDirPath, coverFilename)\n      Logger.debug(`[CoverManager] validate cover path copy cover from \"${coverPath}\" to \"${newCoverPath}\"`)\n\n      var copySuccess = await fs\n        .copy(coverPath, newCoverPath, { overwrite: true })\n        .then(() => true)\n        .catch((error) => {\n          Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)\n          return false\n        })\n      if (!copySuccess) {\n        return {\n          error: 'Failed to copy cover to dir'\n        }\n      }\n      await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)\n      Logger.debug(`[CoverManager] cover copy success`)\n      coverPath = newCoverPath\n    }\n\n    await CacheManager.purgeCoverCache(libraryItem.id)\n\n    return {\n      cover: coverPath,\n      updated: true\n    }\n  }\n\n  /**\n   * Extract cover art from audio file and save for library item\n   *\n   * @param {import('../models/Book').AudioFileObject[]} audioFiles\n   * @param {string} libraryItemId\n   * @param {string} [libraryItemPath] null for isFile library items\n   * @returns {Promise<string>} returns cover path\n   */\n  async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {\n    let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)\n    if (!audioFileWithCover) return null\n\n    let coverDirPath = null\n    if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {\n      coverDirPath = libraryItemPath\n    } else {\n      coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)\n    }\n    await fs.ensureDir(coverDirPath)\n\n    const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'\n    const coverFilePath = Path.join(coverDirPath, coverFilename)\n\n    const coverAlreadyExists = await fs.pathExists(coverFilePath)\n    if (coverAlreadyExists) {\n      Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for \"${coverFilePath}\" - bail`)\n      return null\n    }\n\n    const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)\n    if (success) {\n      await CacheManager.purgeCoverCache(libraryItemId)\n      return coverFilePath\n    }\n    return null\n  }\n\n  /**\n   * Extract cover art from ebook and save for library item\n   *\n   * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData\n   * @param {string} libraryItemId\n   * @param {string} [libraryItemPath] null for isFile library items\n   * @returns {Promise<string>} returns cover path\n   */\n  async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) {\n    if (!ebookFileScanData?.ebookCoverPath) return null\n\n    let coverDirPath = null\n    if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {\n      coverDirPath = libraryItemPath\n    } else {\n      coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)\n    }\n    await fs.ensureDir(coverDirPath)\n\n    let extname = Path.extname(ebookFileScanData.ebookCoverPath) || '.jpg'\n    if (extname === '.jpeg') extname = '.jpg'\n    const coverFilename = `cover${extname}`\n    const coverFilePath = Path.join(coverDirPath, coverFilename)\n\n    // TODO: Overwrite if exists?\n    const coverAlreadyExists = await fs.pathExists(coverFilePath)\n    if (coverAlreadyExists) {\n      Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for \"${coverFilePath}\" - overwriting`)\n    }\n\n    const success = await parseEbookMetadata.extractCoverImage(ebookFileScanData, coverFilePath)\n    if (success) {\n      await CacheManager.purgeCoverCache(libraryItemId)\n      return coverFilePath\n    }\n    return null\n  }\n\n  /**\n   *\n   * @param {string} url\n   * @param {string} libraryItemId\n   * @param {string} [libraryItemPath] - null if library item isFile\n   * @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts)\n   * @returns {Promise<{error:string}|{cover:string}>}\n   */\n  async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {\n    try {\n      let coverDirPath = null\n      if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {\n        coverDirPath = libraryItemPath\n      } else {\n        coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)\n      }\n\n      await fs.ensureDir(coverDirPath)\n\n      const temppath = Path.posix.join(coverDirPath, 'cover')\n      const success = await downloadImageFile(url, temppath)\n        .then(() => true)\n        .catch((err) => {\n          Logger.error(`[CoverManager] Download image file failed for \"${url}\"`, err)\n          return false\n        })\n      if (!success) {\n        return {\n          error: 'Failed to download image from url'\n        }\n      }\n\n      const imgtype = await this.checkFileIsValidImage(temppath, true)\n      if (imgtype.error) {\n        return imgtype\n      }\n\n      const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`)\n      await fs.rename(temppath, coverFullPath)\n\n      await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)\n      await CacheManager.purgeCoverCache(libraryItemId)\n\n      Logger.info(`[CoverManager] Downloaded libraryItem cover \"${coverFullPath}\" from url \"${url}\"`)\n      return {\n        cover: coverFullPath\n      }\n    } catch (error) {\n      Logger.error(`[CoverManager] Fetch cover image from url \"${url}\" failed`, error)\n      return {\n        error: 'Failed to fetch image from url'\n      }\n    }\n  }\n}\nmodule.exports = new CoverManager()\n"
  },
  {
    "path": "server/managers/CoverSearchManager.js",
    "content": "const { setMaxListeners } = require('events')\nconst Logger = require('../Logger')\nconst BookFinder = require('../finders/BookFinder')\nconst PodcastFinder = require('../finders/PodcastFinder')\n\n/**\n * Manager for handling streaming cover search across multiple providers\n */\nclass CoverSearchManager {\n  constructor() {\n    /** @type {Map<string, AbortController>} Map of requestId to AbortController */\n    this.activeSearches = new Map()\n\n    // Default timeout for each provider search\n    this.providerTimeout = 10000 // 10 seconds\n\n    // Set to 0 to disable the max listeners limit\n    // We need one listener per provider (15+) and may have multiple concurrent searches\n    this.maxListeners = 0\n  }\n\n  /**\n   * Start a streaming cover search\n   * @param {string} requestId - Unique identifier for this search request\n   * @param {Object} searchParams - Search parameters\n   * @param {string} searchParams.title - Title to search for\n   * @param {string} searchParams.author - Author to search for (optional)\n   * @param {string} searchParams.provider - Provider to search (or 'all')\n   * @param {boolean} searchParams.podcast - Whether this is a podcast search\n   * @param {Function} onResult - Callback for each result chunk\n   * @param {Function} onComplete - Callback when search completes\n   * @param {Function} onError - Callback for errors\n   */\n  async startSearch(requestId, searchParams, onResult, onComplete, onError) {\n    if (this.activeSearches.has(requestId)) {\n      Logger.warn(`[CoverSearchManager] Search with requestId ${requestId} already exists`)\n      return\n    }\n\n    const abortController = new AbortController()\n\n    // Increase max listeners on this signal to accommodate parallel provider searches\n    // AbortSignal is an EventTarget, so we use the events module's setMaxListeners\n    setMaxListeners(this.maxListeners, abortController.signal)\n\n    this.activeSearches.set(requestId, abortController)\n\n    Logger.info(`[CoverSearchManager] Starting search ${requestId} with params:`, searchParams)\n\n    try {\n      const { title, author, provider, podcast } = searchParams\n\n      if (podcast) {\n        await this.searchPodcastCovers(requestId, title, abortController.signal, onResult, onError)\n      } else {\n        await this.searchBookCovers(requestId, provider, title, author, abortController.signal, onResult, onError)\n      }\n\n      if (!abortController.signal.aborted) {\n        onComplete()\n      }\n    } catch (error) {\n      if (error.name === 'AbortError') {\n        Logger.info(`[CoverSearchManager] Search ${requestId} was cancelled`)\n      } else {\n        Logger.error(`[CoverSearchManager] Search ${requestId} failed:`, error)\n        onError(error.message)\n      }\n    } finally {\n      this.activeSearches.delete(requestId)\n    }\n  }\n\n  /**\n   * Cancel an active search\n   * @param {string} requestId - Request ID to cancel\n   */\n  cancelSearch(requestId) {\n    const abortController = this.activeSearches.get(requestId)\n    if (abortController) {\n      Logger.info(`[CoverSearchManager] Cancelling search ${requestId}`)\n      abortController.abort()\n      this.activeSearches.delete(requestId)\n      return true\n    }\n    return false\n  }\n\n  /**\n   * Search for podcast covers\n   */\n  async searchPodcastCovers(requestId, title, signal, onResult, onError) {\n    try {\n      const results = await this.executeWithTimeout(() => PodcastFinder.findCovers(title), this.providerTimeout, signal)\n\n      if (signal.aborted) return\n\n      const covers = this.extractCoversFromResults(results)\n      if (covers.length > 0) {\n        onResult({\n          provider: 'itunes',\n          covers,\n          total: covers.length\n        })\n      }\n    } catch (error) {\n      if (error.name !== 'AbortError') {\n        Logger.error(`[CoverSearchManager] Podcast search failed:`, error)\n        onError('itunes', error.message)\n      }\n    }\n  }\n\n  /**\n   * Search for book covers across providers\n   */\n  async searchBookCovers(requestId, provider, title, author, signal, onResult, onError) {\n    let providers = []\n\n    if (provider === 'all') {\n      providers = [...BookFinder.providers]\n    } else if (provider === 'best') {\n      // Best providers: google, fantlab, and audible.com\n      providers = ['google', 'fantlab', 'audible']\n    } else {\n      providers = [provider]\n    }\n\n    Logger.debug(`[CoverSearchManager] Searching ${providers.length} providers in parallel`)\n\n    // Search all providers in parallel\n    const searchPromises = providers.map(async (providerName) => {\n      if (signal.aborted) return\n\n      try {\n        const searchResults = await this.executeWithTimeout(() => BookFinder.search(null, providerName, title, author || ''), this.providerTimeout, signal)\n\n        if (signal.aborted) return\n\n        const covers = this.extractCoversFromResults(searchResults)\n\n        Logger.debug(`[CoverSearchManager] Found ${covers.length} covers from ${providerName}`)\n\n        if (covers.length > 0) {\n          onResult({\n            provider: providerName,\n            covers,\n            total: covers.length\n          })\n        }\n      } catch (error) {\n        if (error.name !== 'AbortError') {\n          Logger.warn(`[CoverSearchManager] Provider ${providerName} failed:`, error.message)\n          onError(providerName, error.message)\n        }\n      }\n    })\n\n    await Promise.allSettled(searchPromises)\n  }\n\n  /**\n   * Execute a promise with timeout and abort signal\n   */\n  async executeWithTimeout(fn, timeout, signal) {\n    return new Promise(async (resolve, reject) => {\n      let abortHandler = null\n      let timeoutId = null\n\n      // Cleanup function to ensure we always remove listeners\n      const cleanup = () => {\n        if (timeoutId) {\n          clearTimeout(timeoutId)\n          timeoutId = null\n        }\n        if (abortHandler) {\n          signal.removeEventListener('abort', abortHandler)\n          abortHandler = null\n        }\n      }\n\n      // Set up timeout\n      timeoutId = setTimeout(() => {\n        cleanup()\n        const error = new Error('Provider timeout')\n        error.name = 'TimeoutError'\n        reject(error)\n      }, timeout)\n\n      // Check if already aborted\n      if (signal.aborted) {\n        cleanup()\n        const error = new Error('Search cancelled')\n        error.name = 'AbortError'\n        reject(error)\n        return\n      }\n\n      // Set up abort handler\n      abortHandler = () => {\n        cleanup()\n        const error = new Error('Search cancelled')\n        error.name = 'AbortError'\n        reject(error)\n      }\n      signal.addEventListener('abort', abortHandler)\n\n      try {\n        const result = await fn()\n        cleanup()\n        resolve(result)\n      } catch (error) {\n        cleanup()\n        reject(error)\n      }\n    })\n  }\n\n  /**\n   * Extract cover URLs from search results\n   */\n  extractCoversFromResults(results) {\n    const covers = []\n    if (!Array.isArray(results)) return covers\n\n    results.forEach((result) => {\n      if (typeof result === 'string') {\n        covers.push(result)\n      }\n      if (result.covers && Array.isArray(result.covers)) {\n        covers.push(...result.covers)\n      }\n      if (result.cover) {\n        covers.push(result.cover)\n      }\n    })\n\n    // Remove duplicates\n    return [...new Set(covers)]\n  }\n\n  /**\n   * Cancel all active searches (cleanup on server shutdown)\n   */\n  cancelAllSearches() {\n    Logger.info(`[CoverSearchManager] Cancelling ${this.activeSearches.size} active searches`)\n    for (const [requestId, abortController] of this.activeSearches.entries()) {\n      abortController.abort()\n    }\n    this.activeSearches.clear()\n  }\n}\n\nmodule.exports = new CoverSearchManager()\n"
  },
  {
    "path": "server/managers/CronManager.js",
    "content": "const Sequelize = require('sequelize')\nconst cron = require('../libs/nodeCron')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\nconst LibraryScanner = require('../scanner/LibraryScanner')\n\nconst ShareManager = require('./ShareManager')\n\nclass CronManager {\n  constructor(podcastManager, playbackSessionManager) {\n    /** @type {import('./PodcastManager')} */\n    this.podcastManager = podcastManager\n    /** @type {import('./PlaybackSessionManager')} */\n    this.playbackSessionManager = playbackSessionManager\n\n    this.libraryScanCrons = []\n    this.podcastCrons = []\n\n    this.podcastCronExpressionsExecuting = []\n  }\n\n  /**\n   * Initialize library scan crons & podcast download crons\n   *\n   * @param {import('../models/Library')[]} libraries\n   */\n  async init(libraries) {\n    this.initOpenSessionCleanupCron()\n    this.initLibraryScanCrons(libraries)\n    await this.initPodcastCrons()\n  }\n\n  /**\n   * Initialize open session & auth session cleanup cron\n   * Runs every day at 00:30\n   * Closes open share sessions that have not been updated in 24 hours\n   * Closes open playback sessions that have not been updated in 36 hours\n   * Cleans up expired auth sessions\n   * Deactivates expired api keys\n   * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner\n   */\n  initOpenSessionCleanupCron() {\n    cron.schedule('30 0 * * *', async () => {\n      Logger.debug('[CronManager] Open session cleanup cron executing')\n      ShareManager.closeStaleOpenShareSessions()\n      await this.playbackSessionManager.closeStaleOpenSessions()\n      await Database.cleanupExpiredSessions()\n      await Database.deactivateExpiredApiKeys()\n    })\n  }\n\n  /**\n   * Initialize library scan crons\n   * @param {import('../models/Library')[]} libraries\n   */\n  initLibraryScanCrons(libraries) {\n    for (const library of libraries) {\n      if (library.settings.autoScanCronExpression) {\n        this.startCronForLibrary(library)\n      }\n    }\n  }\n\n  /**\n   * Start cron schedule for library\n   *\n   * @param {import('../models/Library')} _library\n   */\n  startCronForLibrary(_library) {\n    Logger.debug(`[CronManager] Init library scan cron for ${_library.name} on schedule ${_library.settings.autoScanCronExpression}`)\n    const libScanCron = cron.schedule(_library.settings.autoScanCronExpression, async () => {\n      const library = await Database.libraryModel.findByIdWithFolders(_library.id)\n      if (!library) {\n        Logger.error(`[CronManager] Library not found for scan cron ${_library.id}`)\n      } else {\n        Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)\n        LibraryScanner.scan(library)\n      }\n    })\n    this.libraryScanCrons.push({\n      libraryId: _library.id,\n      expression: _library.settings.autoScanCronExpression,\n      task: libScanCron\n    })\n  }\n\n  /**\n   *\n   * @param {import('../models/Library')} library\n   */\n  removeCronForLibrary(library) {\n    Logger.debug(`[CronManager] Removing library scan cron for ${library.name}`)\n    this.libraryScanCrons = this.libraryScanCrons.filter((lsc) => lsc.libraryId !== library.id)\n  }\n\n  /**\n   *\n   * @param {import('../models/Library')} library\n   */\n  updateLibraryScanCron(library) {\n    const expression = library.settings.autoScanCronExpression\n    const existingCron = this.libraryScanCrons.find((lsc) => lsc.libraryId === library.id)\n\n    if (!expression && existingCron) {\n      if (existingCron.task.stop) existingCron.task.stop()\n\n      this.removeCronForLibrary(library)\n    } else if (!existingCron && expression) {\n      this.startCronForLibrary(library)\n    } else if (existingCron && existingCron.expression !== expression) {\n      if (existingCron.task.stop) existingCron.task.stop()\n\n      this.removeCronForLibrary(library)\n      this.startCronForLibrary(library)\n    }\n  }\n\n  /**\n   * Init cron jobs for auto-download podcasts\n   */\n  async initPodcastCrons() {\n    const cronExpressionMap = {}\n\n    const podcastsWithAutoDownload = await Database.podcastModel.findAll({\n      where: {\n        autoDownloadEpisodes: true,\n        autoDownloadSchedule: {\n          [Sequelize.Op.not]: null\n        }\n      },\n      include: {\n        model: Database.libraryItemModel\n      }\n    })\n\n    for (const podcast of podcastsWithAutoDownload) {\n      if (!cronExpressionMap[podcast.autoDownloadSchedule]) {\n        cronExpressionMap[podcast.autoDownloadSchedule] = {\n          expression: podcast.autoDownloadSchedule,\n          libraryItemIds: []\n        }\n      }\n      cronExpressionMap[podcast.autoDownloadSchedule].libraryItemIds.push(podcast.libraryItem.id)\n    }\n\n    if (!Object.keys(cronExpressionMap).length) return\n\n    Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)\n    for (const expression in cronExpressionMap) {\n      this.startPodcastCron(expression, cronExpressionMap[expression].libraryItemIds)\n    }\n  }\n\n  startPodcastCron(expression, libraryItemIds) {\n    try {\n      Logger.debug(`[CronManager] Scheduling podcast episode check cron \"${expression}\" for ${libraryItemIds.length} item(s)`)\n      const task = cron.schedule(expression, () => {\n        if (this.podcastCronExpressionsExecuting.includes(expression)) {\n          Logger.warn(`[CronManager] Podcast cron \"${expression}\" is already executing`)\n        } else {\n          this.executePodcastCron(expression, libraryItemIds)\n        }\n      })\n      this.podcastCrons.push({\n        libraryItemIds,\n        expression,\n        task\n      })\n    } catch (error) {\n      Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)\n    }\n  }\n\n  async executePodcastCron(expression) {\n    const podcastCron = this.podcastCrons.find((cron) => cron.expression === expression)\n    if (!podcastCron) {\n      Logger.error(`[CronManager] Podcast cron not found for expression ${expression}`)\n      return\n    }\n    this.podcastCronExpressionsExecuting.push(expression)\n\n    const libraryItemIds = podcastCron.libraryItemIds\n    Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`)\n\n    // Get podcast library items to check\n    const libraryItems = []\n    for (const libraryItemId of libraryItemIds) {\n      const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)\n      if (!libraryItem) {\n        Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)\n        podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out\n      } else {\n        libraryItems.push(libraryItem)\n      }\n    }\n\n    // Run episode checks\n    for (const libraryItem of libraryItems) {\n      const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)\n      if (!keepAutoDownloading) {\n        // auto download was disabled\n        podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out\n      }\n    }\n\n    // Stop and remove cron if no more library items\n    if (!podcastCron.libraryItemIds.length) {\n      this.removePodcastEpisodeCron(podcastCron)\n      return\n    }\n\n    Logger.debug(`[CronManager] Finished executing podcast cron ${expression} for ${libraryItems.length} item(s)`)\n    this.podcastCronExpressionsExecuting = this.podcastCronExpressionsExecuting.filter((exp) => exp !== expression)\n  }\n\n  removePodcastEpisodeCron(podcastCron) {\n    Logger.info(`[CronManager] Stopping & removing podcast episode cron for ${podcastCron.expression}`)\n    if (podcastCron.task) podcastCron.task.stop()\n    this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression)\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   */\n  checkUpdatePodcastCron(libraryItem) {\n    // Remove from old cron by library item id\n    const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id))\n    if (existingCron) {\n      existingCron.libraryItemIds = existingCron.libraryItemIds.filter((lid) => lid !== libraryItem.id)\n      if (!existingCron.libraryItemIds.length) {\n        this.removePodcastEpisodeCron(existingCron)\n      }\n    }\n\n    // Add to cron or start new cron\n    if (libraryItem.media.autoDownloadEpisodes && libraryItem.media.autoDownloadSchedule) {\n      const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule)\n      if (cronMatchingExpression) {\n        cronMatchingExpression.libraryItemIds.push(libraryItem.id)\n\n        // TODO: Update after old model removed\n        const podcastTitle = libraryItem.media.title || libraryItem.media.metadata?.title\n        Logger.info(`[CronManager] Added podcast \"${podcastTitle}\" to auto dl episode cron \"${cronMatchingExpression.expression}\"`)\n      } else {\n        this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])\n      }\n    }\n  }\n}\nmodule.exports = CronManager\n"
  },
  {
    "path": "server/managers/EmailManager.js",
    "content": "const nodemailer = require('nodemailer')\nconst Database = require('../Database')\nconst Logger = require(\"../Logger\")\n\nclass EmailManager {\n  constructor() { }\n\n  getTransporter() {\n    return nodemailer.createTransport(Database.emailSettings.getTransportObject())\n  }\n\n  async sendTest(res) {\n    Logger.info(`[EmailManager] Sending test email`)\n    const transporter = this.getTransporter()\n\n    const success = await transporter.verify().catch((error) => {\n      Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)\n      return false\n    })\n\n    if (!success) {\n      return res.status(400).send('Failed to verify SMTP connection configuration')\n    }\n\n    transporter.sendMail({\n      from: Database.emailSettings.fromAddress,\n      to: Database.emailSettings.testAddress || Database.emailSettings.fromAddress,\n      subject: 'Test email from Audiobookshelf',\n      text: 'Success!'\n    }).then((result) => {\n      Logger.info(`[EmailManager] Test email sent successfully`, result)\n      res.sendStatus(200)\n    }).catch((error) => {\n      Logger.error(`[EmailManager] Failed to send test email`, error)\n      res.status(400).send(error.message || 'Failed to send test email')\n    })\n  }\n\n  async sendEBookToDevice(ebookFile, device, res) {\n    Logger.info(`[EmailManager] Sending ebook \"${ebookFile.metadata.filename}\" to device \"${device.name}\"/\"${device.email}\"`)\n    const transporter = this.getTransporter()\n\n    const success = await transporter.verify().catch((error) => {\n      Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)\n      return false\n    })\n\n    if (!success) {\n      return res.status(400).send('Failed to verify SMTP connection configuration')\n    }\n\n    transporter.sendMail({\n      from: Database.emailSettings.fromAddress,\n      to: device.email,\n      subject: \"Here is your Ebook!\",\n      html: '<div dir=\"auto\"></div>',\n      attachments: [\n        {\n          filename: ebookFile.metadata.filename,\n          path: ebookFile.metadata.path,\n        }\n      ]\n    }).then((result) => {\n      Logger.info(`[EmailManager] Ebook sent to device successfully`, result)\n      res.sendStatus(200)\n    }).catch((error) => {\n      Logger.error(`[EmailManager] Failed to send ebook to device`, error)\n      res.status(400).send(error.message || 'Failed to send ebook to device')\n    })\n  }\n}\nmodule.exports = EmailManager\n"
  },
  {
    "path": "server/managers/LogManager.js",
    "content": "const Path = require('path')\nconst fs = require('../libs/fsExtra')\n\nconst Logger = require('../Logger')\nconst DailyLog = require('../objects/DailyLog')\n\nconst { LogLevel } = require('../utils/constants')\n\nconst TAG = '[LogManager]'\n\n/**\n * @typedef LogObject\n * @property {string} timestamp\n * @property {string} source\n * @property {string} message\n * @property {string} levelName\n * @property {number} level\n */\n\nclass LogManager {\n  constructor() {\n    this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')\n    this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')\n\n    /** @type {DailyLog} */\n    this.currentDailyLog = null\n\n    /** @type {LogObject[]} */\n    this.dailyLogBuffer = []\n\n    /** @type {string[]} */\n    this.dailyLogFiles = []\n  }\n\n  get loggerDailyLogsToKeep() {\n    return global.ServerSettings.loggerDailyLogsToKeep || 7\n  }\n\n  async ensureLogDirs() {\n    try {\n      await fs.ensureDir(this.DailyLogPath)\n      await fs.ensureDir(this.ScanLogPath)\n    } catch (error) {\n      console.error(`[LogManager] Failed to create log directories at \"${this.DailyLogPath}\": ${error.message}`)\n      throw new Error(`[LogManager] Failed to create log directories at \"${this.DailyLogPath}\"`, { cause: error })\n    }\n  }\n\n  /**\n   * 1. Ensure log directories exist\n   * 2. Load daily log files\n   * 3. Remove old daily log files\n   * 4. Create/set current daily log file\n   */\n  async init() {\n    await this.ensureLogDirs()\n\n    // Load daily logs\n    await this.scanLogFiles()\n\n    // Check remove extra daily logs\n    if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {\n      const dailyLogFilesCopy = [...this.dailyLogFiles]\n      for (let i = 0; i < dailyLogFilesCopy.length - this.loggerDailyLogsToKeep; i++) {\n        await this.removeLogFile(dailyLogFilesCopy[i])\n      }\n    }\n\n    // set current daily log file or create if does not exist\n    const currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()\n    Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`)\n\n    this.currentDailyLog = new DailyLog(this.DailyLogPath)\n\n    if (this.dailyLogFiles.includes(currentDailyLogFilename)) {\n      Logger.debug(TAG, `Daily log file already exists - set in Logger`)\n      await this.currentDailyLog.loadLogs()\n    } else {\n      this.dailyLogFiles.push(this.currentDailyLog.filename)\n    }\n\n    // Log buffered daily logs\n    if (this.dailyLogBuffer.length) {\n      this.dailyLogBuffer.forEach((logObj) => {\n        this.currentDailyLog.appendLog(logObj)\n      })\n      this.dailyLogBuffer = []\n    }\n  }\n\n  /**\n   * Load all daily log filenames in /metadata/logs/daily\n   */\n  async scanLogFiles() {\n    const dailyFiles = await fs.readdir(this.DailyLogPath)\n    if (dailyFiles?.length) {\n      dailyFiles.forEach((logFile) => {\n        if (Path.extname(logFile) === '.txt') {\n          Logger.debug('Daily Log file found', logFile)\n          this.dailyLogFiles.push(logFile)\n        } else {\n          Logger.debug(TAG, 'Unknown File in Daily log files dir', logFile)\n        }\n      })\n    }\n    this.dailyLogFiles.sort()\n  }\n\n  /**\n   *\n   * @param {string} filename\n   */\n  async removeLogFile(filename) {\n    const fullPath = Path.join(this.DailyLogPath, filename)\n    const exists = await fs.pathExists(fullPath)\n    if (!exists) {\n      Logger.error(TAG, 'Invalid log dne ' + fullPath)\n      this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename)\n    } else {\n      try {\n        await fs.unlink(fullPath)\n        Logger.info(TAG, 'Removed daily log: ' + filename)\n        this.dailyLogFiles = this.dailyLogFiles.filter((dlf) => dlf !== filename)\n      } catch (error) {\n        Logger.error(TAG, 'Failed to unlink log file ' + fullPath)\n      }\n    }\n  }\n\n  /**\n   *\n   * @param {LogObject} logObj\n   */\n  async logToFile(logObj) {\n    // Fatal crashes get logged to a separate file\n    if (logObj.level === LogLevel.FATAL) {\n      await this.logCrashToFile(logObj)\n    }\n\n    // Buffer when logging before daily logs have been initialized\n    if (!this.currentDailyLog) {\n      this.dailyLogBuffer.push(logObj)\n      return\n    }\n\n    // Check log rolls to next day\n    if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) {\n      this.currentDailyLog = new DailyLog(this.DailyLogPath)\n      if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {\n        // Remove oldest log\n        this.removeLogFile(this.dailyLogFiles[0])\n      }\n    }\n\n    // Append log line to log file\n    return this.currentDailyLog.appendLog(logObj)\n  }\n\n  /**\n   *\n   * @param {LogObject} logObj\n   */\n  async logCrashToFile(logObj) {\n    const line = JSON.stringify(logObj) + '\\n'\n\n    const logsDir = Path.join(global.MetadataPath, 'logs')\n    await fs.ensureDir(logsDir)\n    const crashLogPath = Path.join(logsDir, 'crash_logs.txt')\n    return fs.writeFile(crashLogPath, line, { flag: 'a+' }).catch((error) => {\n      console.log('[LogManager] Appended crash log', error)\n    })\n  }\n\n  /**\n   * Most recent 5000 daily logs\n   *\n   * @returns {string}\n   */\n  getMostRecentCurrentDailyLogs() {\n    return this.currentDailyLog?.logs.slice(-5000) || ''\n  }\n}\nmodule.exports = LogManager\n"
  },
  {
    "path": "server/managers/MigrationManager.js",
    "content": "const { Umzug, SequelizeStorage } = require('../libs/umzug')\nconst { Sequelize, DataTypes } = require('sequelize')\nconst semver = require('semver')\nconst path = require('path')\nconst Module = require('module')\nconst fs = require('../libs/fsExtra')\nconst Logger = require('../Logger')\n\nclass MigrationManager {\n  static MIGRATIONS_META_TABLE = 'migrationsMeta'\n\n  /**\n   * @param {import('../Database').sequelize} sequelize\n   * @param {boolean} isDatabaseNew\n   * @param {string} [configPath]\n   */\n  constructor(sequelize, isDatabaseNew, configPath = global.configPath) {\n    if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')\n    this.sequelize = sequelize\n    this.isDatabaseNew = isDatabaseNew\n    if (!configPath) throw new Error('Config path is required for MigrationManager.')\n    this.configPath = configPath\n    this.migrationsSourceDir = path.join(__dirname, '..', 'migrations')\n    this.initialized = false\n    this.migrationsDir = null\n    this.maxVersion = null\n    this.databaseVersion = null\n    this.serverVersion = null\n    this.umzug = null\n  }\n\n  /**\n   * Init version vars and copy migration files to config dir if necessary\n   *\n   * @param {string} serverVersion\n   */\n  async init(serverVersion) {\n    if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)\n\n    this.migrationsDir = path.join(this.configPath, 'migrations')\n    try {\n      await fs.ensureDir(this.migrationsDir)\n    } catch (error) {\n      Logger.error(`[MigrationManager] Failed to create migrations directory at \"${this.migrationsDir}\": ${error.message}`)\n      throw new Error(`[MigrationManager] Failed to create migrations directory at \"${this.migrationsDir}\"`, { cause: error })\n    }\n\n    this.serverVersion = this.extractVersionFromTag(serverVersion)\n    if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)\n\n    await this.fetchVersionsFromDatabase()\n    if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.')\n    Logger.debug(`[MigrationManager] Database version: ${this.databaseVersion}, Max version: ${this.maxVersion}, Server version: ${this.serverVersion}`)\n\n    if (semver.gt(this.serverVersion, this.maxVersion)) {\n      try {\n        await this.copyMigrationsToConfigDir()\n      } catch (error) {\n        throw new Error('Failed to copy migrations to the config directory.', { cause: error })\n      }\n\n      try {\n        await this.updateMaxVersion()\n      } catch (error) {\n        throw new Error('Failed to update max version in the database.', { cause: error })\n      }\n    }\n\n    this.initialized = true\n  }\n\n  async runMigrations() {\n    if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.')\n\n    if (this.isDatabaseNew) {\n      Logger.info('[MigrationManager] Database is new. Skipping migrations.')\n      return\n    }\n\n    const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)\n    if (versionCompare == 0) {\n      Logger.info('[MigrationManager] Database is already up to date.')\n      return\n    }\n\n    await this.initUmzug()\n    const migrations = await this.umzug.migrations()\n    const executedMigrations = (await this.umzug.executed()).map((m) => m.name)\n\n    const migrationDirection = versionCompare == 1 ? 'up' : 'down'\n\n    let migrationsToRun = []\n    migrationsToRun = this.findMigrationsToRun(migrations, executedMigrations, migrationDirection)\n\n    // Only proceed with migration if there are migrations to run\n    if (migrationsToRun.length > 0) {\n      const originalDbPath = path.join(this.configPath, 'absdatabase.sqlite')\n      const backupDbPath = path.join(this.configPath, 'absdatabase.backup.sqlite')\n      try {\n        Logger.info(`[MigrationManager] Migrating database ${migrationDirection} to version ${this.serverVersion}`)\n        Logger.info(`[MigrationManager] Migrations to run: ${migrationsToRun.join(', ')}`)\n        // Create a backup copy of the SQLite database before starting migrations\n        await fs.copy(originalDbPath, backupDbPath)\n        Logger.info('Created a backup of the original database.')\n\n        // Run migrations\n        await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' })\n\n        // Clean up the backup\n        await fs.remove(backupDbPath)\n\n        Logger.info('[MigrationManager] Migrations successfully applied to the original database.')\n      } catch (error) {\n        Logger.error('[MigrationManager] Migration failed:', error)\n\n        await this.sequelize.close()\n\n        // Step 3: If migration fails, save the failed original and restore the backup\n        const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite')\n        await fs.move(originalDbPath, failedDbPath, { overwrite: true })\n        Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.')\n\n        await fs.move(backupDbPath, originalDbPath, { overwrite: true })\n        Logger.info('[MigrationManager] Restored the original database from the backup.')\n\n        Logger.info('[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.')\n        process.exit(1)\n      }\n    } else {\n      Logger.info('[MigrationManager] No migrations to run.')\n    }\n\n    await this.updateDatabaseVersion()\n  }\n\n  async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {\n    // This check is for dependency injection in tests\n    const files = (await fs.readdir(this.migrationsDir))\n      .filter((file) => {\n        // Only include .js files and exclude dot files\n        return !file.startsWith('.') && path.extname(file).toLowerCase() === '.js'\n      })\n      .map((file) => path.join(this.migrationsDir, file))\n\n    // Validate migration names\n    for (const file of files) {\n      const migrationName = path.basename(file, path.extname(file))\n      const migrationVersion = this.extractVersionFromTag(migrationName)\n      if (!migrationVersion) {\n        throw new Error(`Invalid migration file: \"${migrationName}\". Unable to extract version from filename.`)\n      }\n    }\n\n    const parent = new Umzug({\n      migrations: {\n        files,\n        resolve: (params) => {\n          // make script think it's in migrationsSourceDir\n          const migrationPath = params.path\n          const migrationName = params.name\n          const contents = fs.readFileSync(migrationPath, 'utf8')\n          const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath))\n          const module = new Module(fakePath)\n          module.filename = fakePath\n          module.paths = Module._nodeModulePaths(this.migrationsSourceDir)\n          module._compile(contents, fakePath)\n          const script = module.exports\n          return {\n            name: migrationName,\n            path: migrationPath,\n            up: script.up,\n            down: script.down\n          }\n        }\n      },\n      context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger },\n      storage: umzugStorage,\n      logger: Logger\n    })\n\n    // Sort migrations by version\n    this.umzug = new Umzug({\n      ...parent.options,\n      migrations: async () =>\n        (await parent.migrations()).sort((a, b) => {\n          const versionA = this.extractVersionFromTag(a.name)\n          const versionB = this.extractVersionFromTag(b.name)\n          return semver.compare(versionA, versionB)\n        })\n    })\n  }\n\n  async fetchVersionsFromDatabase() {\n    await this.checkOrCreateMigrationsMetaTable()\n\n    const [{ version }] = await this.sequelize.query(\"SELECT value as version FROM :migrationsMeta WHERE key = 'version'\", {\n      replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },\n      type: Sequelize.QueryTypes.SELECT\n    })\n    this.databaseVersion = version\n\n    const [{ maxVersion }] = await this.sequelize.query(\"SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'\", {\n      replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },\n      type: Sequelize.QueryTypes.SELECT\n    })\n    this.maxVersion = maxVersion\n  }\n\n  async checkOrCreateMigrationsMetaTable() {\n    const queryInterface = this.sequelize.getQueryInterface()\n    let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE)\n\n    // If the table exists, check that the `version` and `maxVersion` rows exist\n    if (migrationsMetaTableExists) {\n      const [{ count }] = await this.sequelize.query(\"SELECT COUNT(*) as count FROM :migrationsMeta WHERE key IN ('version', 'maxVersion')\", {\n        replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },\n        type: Sequelize.QueryTypes.SELECT\n      })\n      if (count < 2) {\n        Logger.warn(`[MigrationManager] migrationsMeta table exists but is missing 'version' or 'maxVersion' row. Dropping it...`)\n        await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)\n        migrationsMetaTableExists = false\n      }\n    }\n\n    if (this.isDatabaseNew && migrationsMetaTableExists) {\n      Logger.warn(`[MigrationManager] migrationsMeta table already exists. Dropping it...`)\n      // This can happen if database was initialized with force: true\n      await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)\n      migrationsMetaTableExists = false\n    }\n\n    if (!migrationsMetaTableExists) {\n      await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, {\n        key: {\n          type: DataTypes.STRING,\n          allowNull: false\n        },\n        value: {\n          type: DataTypes.STRING,\n          allowNull: false\n        }\n      })\n      await this.sequelize.query(\"INSERT INTO :migrationsMeta (key, value) VALUES ('version', :version), ('maxVersion', '0.0.0')\", {\n        replacements: { version: this.isDatabaseNew ? this.serverVersion : '0.0.0', migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },\n        type: Sequelize.QueryTypes.INSERT\n      })\n      Logger.debug(`[MigrationManager] Created migrationsMeta table: \"${MigrationManager.MIGRATIONS_META_TABLE}\"`)\n    }\n  }\n\n  extractVersionFromTag(tag) {\n    if (!tag) return null\n    const versionMatch = tag.match(/^v?(\\d+\\.\\d+\\.\\d+)/)\n    return versionMatch ? versionMatch[1] : null\n  }\n\n  async copyMigrationsToConfigDir() {\n    if (!(await fs.pathExists(this.migrationsSourceDir))) return\n\n    const files = await fs.readdir(this.migrationsSourceDir)\n    await Promise.all(\n      files\n        .filter((file) => path.extname(file) === '.js')\n        .map(async (file) => {\n          const sourceFile = path.join(this.migrationsSourceDir, file)\n          const targetFile = path.join(this.migrationsDir, file)\n          await fs.copy(sourceFile, targetFile) // Asynchronously copy the files\n        })\n    )\n    Logger.debug(`[MigrationManager] Copied migrations to the config directory: \"${this.migrationsDir}\"`)\n  }\n\n  /**\n   *\n   * @param {{ name: string }[]} migrations\n   * @param {string[]} executedMigrations - names of executed migrations\n   * @param {string} direction - 'up' or 'down'\n   * @returns {string[]} - names of migrations to run\n   */\n  findMigrationsToRun(migrations, executedMigrations, direction) {\n    const migrationsToRun = migrations\n      .filter((migration) => {\n        const migrationVersion = this.extractVersionFromTag(migration.name)\n        if (direction === 'up') {\n          return semver.gt(migrationVersion, this.databaseVersion) && semver.lte(migrationVersion, this.serverVersion) && !executedMigrations.includes(migration.name)\n        } else {\n          // A down migration should be run even if the associated up migration wasn't executed before\n          return semver.lte(migrationVersion, this.databaseVersion) && semver.gt(migrationVersion, this.serverVersion)\n        }\n      })\n      .map((migration) => migration.name)\n    if (direction === 'down') {\n      return migrationsToRun.reverse()\n    } else {\n      return migrationsToRun\n    }\n  }\n\n  async updateMaxVersion() {\n    try {\n      await this.sequelize.query(\"UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'\", {\n        replacements: { maxVersion: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },\n        type: Sequelize.QueryTypes.UPDATE\n      })\n    } catch (error) {\n      throw new Error('Failed to update maxVersion in the migrationsMeta table.', { cause: error })\n    }\n    this.maxVersion = this.serverVersion\n  }\n\n  async updateDatabaseVersion() {\n    try {\n      await this.sequelize.query(\"UPDATE :migrationsMeta SET value = :version WHERE key = 'version'\", {\n        replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },\n        type: Sequelize.QueryTypes.UPDATE\n      })\n    } catch (error) {\n      throw new Error('Failed to update version in the migrationsMeta table.', { cause: error })\n    }\n    this.databaseVersion = this.serverVersion\n  }\n}\n\nmodule.exports = MigrationManager\n"
  },
  {
    "path": "server/managers/NotificationManager.js",
    "content": "const axios = require('axios')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst { notificationData } = require('../utils/notifications')\n\nclass NotificationManager {\n  constructor() {\n    this.sendingNotification = false\n    this.notificationQueue = []\n  }\n\n  getData() {\n    return notificationData\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {import('../models/PodcastEpisode')} episode\n   */\n  async onPodcastEpisodeDownloaded(libraryItem, episode) {\n    if (!Database.notificationSettings.isUseable) return\n\n    if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onPodcastEpisodeDownloaded')) {\n      Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: No active notifications`)\n      return\n    }\n\n    Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode \"${episode.title}\" for podcast ${libraryItem.media.title}`)\n    const library = await Database.libraryModel.findByPk(libraryItem.libraryId)\n    const eventData = {\n      libraryItemId: libraryItem.id,\n      libraryId: libraryItem.libraryId,\n      libraryName: library?.name || 'Unknown',\n      mediaTags: (libraryItem.media.tags || []).join(', '),\n      podcastTitle: libraryItem.media.title,\n      podcastAuthor: libraryItem.media.author || '',\n      podcastDescription: libraryItem.media.description || '',\n      podcastGenres: (libraryItem.media.genres || []).join(', '),\n      episodeId: episode.id,\n      episodeTitle: episode.title,\n      episodeSubtitle: episode.subtitle || '',\n      episodeDescription: episode.description || ''\n    }\n    this.triggerNotification('onPodcastEpisodeDownloaded', eventData)\n  }\n\n  /**\n   *\n   * @param {import('../objects/Backup')} backup\n   * @param {number} totalBackupCount\n   * @param {boolean} removedOldest - If oldest backup was removed\n   */\n  async onBackupCompleted(backup, totalBackupCount, removedOldest) {\n    if (!Database.notificationSettings.isUseable) return\n\n    if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onBackupCompleted')) {\n      Logger.debug(`[NotificationManager] onBackupCompleted: No active notifications`)\n      return\n    }\n\n    Logger.debug(`[NotificationManager] onBackupCompleted: Backup completed`)\n    const eventData = {\n      completionTime: backup.createdAt,\n      backupPath: backup.fullPath,\n      backupSize: backup.fileSize,\n      backupCount: totalBackupCount || 'Invalid',\n      removedOldest: removedOldest || 'false'\n    }\n    this.triggerNotification('onBackupCompleted', eventData)\n  }\n\n  /**\n   * Handles scheduled episode download RSS feed request failed\n   *\n   * @param {string} feedUrl\n   * @param {number} numFailed\n   * @param {string} title\n   */\n  async onRSSFeedFailed(feedUrl, numFailed, title) {\n    if (!Database.notificationSettings.isUseable) return\n\n    if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedFailed')) {\n      Logger.debug(`[NotificationManager] onRSSFeedFailed: No active notifications`)\n      return\n    }\n\n    Logger.debug(`[NotificationManager] onRSSFeedFailed: RSS feed request failed for ${feedUrl}`)\n    const eventData = {\n      feedUrl: feedUrl,\n      numFailed: numFailed || 0,\n      title: title || 'Unknown Title'\n    }\n    this.triggerNotification('onRSSFeedFailed', eventData)\n  }\n\n  /**\n   * Handles scheduled episode downloads disabled due to too many failed attempts\n   *\n   * @param {string} feedUrl\n   * @param {number} numFailed\n   * @param {string} title\n   */\n  async onRSSFeedDisabled(feedUrl, numFailed, title) {\n    if (!Database.notificationSettings.isUseable) return\n\n    if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedDisabled')) {\n      Logger.debug(`[NotificationManager] onRSSFeedDisabled: No active notifications`)\n      return\n    }\n\n    Logger.debug(`[NotificationManager] onRSSFeedDisabled: Podcast scheduled episode download disabled due to ${numFailed} failed requests for ${feedUrl}`)\n    const eventData = {\n      feedUrl: feedUrl,\n      numFailed: numFailed || 0,\n      title: title || 'Unknown Title'\n    }\n    this.triggerNotification('onRSSFeedDisabled', eventData)\n  }\n\n  /**\n   *\n   * @param {string} errorMsg\n   */\n  async onBackupFailed(errorMsg) {\n    if (!Database.notificationSettings.isUseable) return\n\n    if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onBackupFailed')) {\n      Logger.debug(`[NotificationManager] onBackupFailed: No active notifications`)\n      return\n    }\n\n    Logger.debug(`[NotificationManager] onBackupFailed: Backup failed (${errorMsg})`)\n    const eventData = {\n      errorMsg: errorMsg || 'Backup failed'\n    }\n    this.triggerNotification('onBackupFailed', eventData)\n  }\n\n  onTest() {\n    this.triggerNotification('onTest')\n  }\n\n  /**\n   *\n   * @param {string} eventName\n   * @param {any} eventData\n   * @param {boolean} [intentionallyFail=false] - If true, will intentionally fail the notification\n   */\n  async triggerNotification(eventName, eventData, intentionallyFail = false) {\n    if (!Database.notificationSettings.isUseable) return\n\n    // Will queue the notification if sendingNotification and queue is not full\n    if (!this.checkTriggerNotification(eventName, eventData)) return\n\n    const notifications = Database.notificationSettings.getActiveNotificationsForEvent(eventName)\n    for (const notification of notifications) {\n      Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`)\n      const success = intentionallyFail ? false : await this.sendNotification(notification, eventData)\n\n      notification.updateNotificationFired(success)\n      if (!success) {\n        // Failed notification\n        if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) {\n          Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`)\n          notification.enabled = false\n        } else {\n          Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} ${notification.numConsecutiveFailedAttempts} failed attempts`)\n        }\n      }\n    }\n\n    await Database.updateSetting(Database.notificationSettings)\n    SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON())\n\n    this.notificationFinished()\n  }\n\n  /**\n   *\n   * @param {string} eventName\n   * @param {any} eventData\n   * @returns {boolean} - TRUE if notification should be triggered now\n   */\n  checkTriggerNotification(eventName, eventData) {\n    if (this.sendingNotification) {\n      if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) {\n        Logger.warn(`[NotificationManager] Notification queue is full - ignoring event ${eventName}`)\n      } else {\n        Logger.debug(`[NotificationManager] Queueing notification ${eventName} (Queue size: ${this.notificationQueue.length})`)\n        this.notificationQueue.push({ eventName, eventData })\n      }\n      return false\n    }\n    this.sendingNotification = true\n    return true\n  }\n\n  notificationFinished() {\n    // Delay between events then run next notification in queue\n    setTimeout(() => {\n      this.sendingNotification = false\n      if (this.notificationQueue.length) {\n        // Send next notification in queue\n        const nextNotificationEvent = this.notificationQueue.shift()\n        this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)\n      }\n    }, Database.notificationSettings.notificationDelay)\n  }\n\n  sendTestNotification(notification) {\n    const eventData = notificationData.events.find((e) => e.name === notification.eventName)\n    if (!eventData) {\n      Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`)\n      return false\n    }\n\n    return this.sendNotification(notification, eventData.testData)\n  }\n\n  sendNotification(notification, eventData) {\n    const payload = notification.getApprisePayload(eventData)\n    return axios\n      .post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 })\n      .then((response) => {\n        Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)\n        return true\n      })\n      .catch((error) => {\n        Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error)\n        return false\n      })\n  }\n}\nmodule.exports = new NotificationManager()\n"
  },
  {
    "path": "server/managers/PlaybackSessionManager.js",
    "content": "const uuidv4 = require('uuid').v4\nconst Path = require('path')\nconst serverVersion = require('../../package.json').version\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst date = require('../libs/dateAndTime')\nconst fs = require('../libs/fsExtra')\nconst uaParserJs = require('../libs/uaParser')\nconst requestIp = require('../libs/requestIp')\n\nconst { PlayMethod } = require('../utils/constants')\n\nconst PlaybackSession = require('../objects/PlaybackSession')\nconst DeviceInfo = require('../objects/DeviceInfo')\nconst Stream = require('../objects/Stream')\n\nclass PlaybackSessionManager {\n  constructor() {\n    this.StreamsPath = Path.join(global.MetadataPath, 'streams')\n\n    this.oldPlaybackSessionMap = {} // TODO: Remove after updated mobile versions\n\n    /** @type {PlaybackSession[]} */\n    this.sessions = []\n  }\n\n  /**\n   * Get open session by id\n   *\n   * @param {string} sessionId\n   * @returns {PlaybackSession}\n   */\n  getSession(sessionId) {\n    return this.sessions.find((s) => s.id === sessionId)\n  }\n  getUserSession(userId) {\n    return this.sessions.find((s) => s.userId === userId)\n  }\n  getStream(sessionId) {\n    const session = this.getSession(sessionId)\n    return session?.stream || null\n  }\n\n  /**\n   *\n   * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req\n   * @param {Object} [clientDeviceInfo]\n   * @returns {Promise<DeviceInfo>}\n   */\n  async getDeviceInfo(req, clientDeviceInfo = null) {\n    const ua = uaParserJs(req.headers['user-agent'])\n    const ip = requestIp.getClientIp(req)\n\n    const deviceInfo = new DeviceInfo()\n    deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id)\n\n    if (clientDeviceInfo?.deviceId) {\n      const existingDevice = await Database.deviceModel.getOldDeviceByDeviceId(clientDeviceInfo.deviceId)\n      if (existingDevice) {\n        if (existingDevice.update(deviceInfo)) {\n          await Database.deviceModel.updateFromOld(existingDevice)\n        }\n        return existingDevice\n      }\n    }\n\n    await Database.deviceModel.createFromOld(deviceInfo)\n\n    return deviceInfo\n  }\n\n  /**\n   *\n   * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req\n   * @param {import('express').Response} res\n   * @param {string} [episodeId]\n   */\n  async startSessionRequest(req, res, episodeId) {\n    const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)\n    Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)\n    const { libraryItem, body: options } = req\n    const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options)\n    res.json(session.toJSONForClient(libraryItem))\n  }\n\n  /**\n   *\n   * @param {import('../models/User')} user\n   * @param {*} session\n   * @param {*} payload\n   * @param {import('express').Response} res\n   */\n  async syncSessionRequest(user, session, payload, res) {\n    if (await this.syncSession(user, session, payload)) {\n      res.sendStatus(200)\n    } else {\n      res.sendStatus(500)\n    }\n  }\n\n  async syncLocalSessionsRequest(req, res) {\n    const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)\n    const user = req.user\n    const sessions = req.body.sessions || []\n\n    const syncResults = []\n    for (const sessionJson of sessions) {\n      Logger.info(`[PlaybackSessionManager] Syncing local session \"${sessionJson.displayTitle}\" (${sessionJson.id}) (updatedAt: ${sessionJson.updatedAt})`)\n      const result = await this.syncLocalSession(user, sessionJson, deviceInfo)\n      syncResults.push(result)\n    }\n\n    res.json({\n      results: syncResults\n    })\n  }\n\n  /**\n   *\n   * @param {import('../models/User')} user\n   * @param {*} sessionJson\n   * @param {*} deviceInfo\n   * @returns\n   */\n  async syncLocalSession(user, sessionJson, deviceInfo) {\n    // TODO: Combine libraryItem query with library query\n    const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId)\n    const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null\n    if (!libraryItem || (libraryItem.isPodcast && !episode)) {\n      Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session \"${sessionJson.displayTitle}\" (${sessionJson.id})`)\n      return {\n        id: sessionJson.id,\n        success: false,\n        error: 'Media item not found'\n      }\n    }\n\n    const library = await Database.libraryModel.findByPk(libraryItem.libraryId)\n    if (!library) {\n      Logger.error(`[PlaybackSessionManager] syncLocalSession: Library not found for session \"${sessionJson.displayTitle}\" (${sessionJson.id})`)\n      return {\n        id: sessionJson.id,\n        success: false,\n        error: 'Library not found'\n      }\n    }\n\n    sessionJson.userId = user.id\n    sessionJson.serverVersion = serverVersion\n\n    // TODO: Temp update local playback session id to uuidv4 & library item/book/episode ids\n    if (sessionJson.id?.startsWith('play_local_')) {\n      if (!this.oldPlaybackSessionMap[sessionJson.id]) {\n        const newSessionId = uuidv4()\n        this.oldPlaybackSessionMap[sessionJson.id] = newSessionId\n        sessionJson.id = newSessionId\n      } else {\n        sessionJson.id = this.oldPlaybackSessionMap[sessionJson.id]\n      }\n    }\n    if (sessionJson.libraryItemId !== libraryItem.id) {\n      Logger.info(`[PlaybackSessionManager] Mapped old libraryItemId \"${sessionJson.libraryItemId}\" to ${libraryItem.id}`)\n      sessionJson.libraryItemId = libraryItem.id\n      sessionJson.bookId = episode ? null : libraryItem.media.id\n    }\n    if (!sessionJson.bookId && !episode) {\n      sessionJson.bookId = libraryItem.media.id\n    }\n    if (episode && sessionJson.episodeId !== episode.id) {\n      Logger.info(`[PlaybackSessionManager] Mapped old episodeId \"${sessionJson.episodeId}\" to ${episode.id}`)\n      sessionJson.episodeId = episode.id\n    }\n    if (sessionJson.libraryId !== libraryItem.libraryId) {\n      sessionJson.libraryId = libraryItem.libraryId\n    }\n\n    let session = await Database.getPlaybackSession(sessionJson.id)\n    if (!session) {\n      // New session from local\n      session = new PlaybackSession(sessionJson)\n      session.deviceInfo = deviceInfo\n\n      if (session.mediaMetadata == null) {\n        session.mediaMetadata = {}\n      }\n\n      // Populate mediaMetadata with the current library items metadata for any keys not set by client\n      const libraryItemMediaMetadata = libraryItem.media.oldMetadataToJSON()\n      for (const key in libraryItemMediaMetadata) {\n        if (session.mediaMetadata[key] === undefined) {\n          session.mediaMetadata[key] = libraryItemMediaMetadata[key]\n        }\n      }\n\n      if (session.displayTitle == null || session.displayTitle === '') {\n        session.displayTitle = libraryItem.title\n      }\n      if (session.displayAuthor == null || session.displayAuthor === '') {\n        session.displayAuthor = libraryItem.authorNamesFirstLast\n      }\n      session.duration = libraryItem.media.getPlaybackDuration(sessionJson.episodeId)\n\n      Logger.debug(`[PlaybackSessionManager] Inserting new session for \"${session.displayTitle}\" (${session.id})`)\n      await Database.createPlaybackSession(session)\n    } else {\n      session.currentTime = sessionJson.currentTime\n      session.timeListening = sessionJson.timeListening\n      session.updatedAt = sessionJson.updatedAt\n\n      let jsDate = new Date(sessionJson.updatedAt)\n      if (isNaN(jsDate)) {\n        jsDate = new Date()\n      }\n      session.date = date.format(jsDate, 'YYYY-MM-DD')\n      session.dayOfWeek = date.format(jsDate, 'dddd')\n\n      Logger.debug(`[PlaybackSessionManager] Updated session for \"${session.displayTitle}\" (${session.id})`)\n      await Database.updatePlaybackSession(session)\n    }\n\n    const result = {\n      id: session.id,\n      success: true,\n      progressSynced: false\n    }\n\n    const mediaItemId = session.episodeId || libraryItem.media.id\n    let userProgressForItem = user.getMediaProgress(mediaItemId)\n    if (userProgressForItem) {\n      if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {\n        Logger.info(`[PlaybackSessionManager] Not updating progress for \"${session.displayTitle}\" because it has been updated more recently (${userProgressForItem.updatedAt.valueOf()} > ${session.updatedAt}) (incoming currentTime: ${session.currentTime}) (current currentTime: ${userProgressForItem.currentTime})`)\n      } else {\n        Logger.info(`[PlaybackSessionManager] Updating progress for \"${session.displayTitle}\" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)\n        const updateResponse = await user.createUpdateMediaProgressFromPayload({\n          libraryItemId: libraryItem.id,\n          episodeId: session.episodeId,\n          ...session.mediaProgressObject,\n          markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete,\n          markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining\n        })\n        result.progressSynced = !!updateResponse.mediaProgress\n        if (result.progressSynced) {\n          userProgressForItem = updateResponse.mediaProgress\n        }\n      }\n    } else {\n      Logger.info(`[PlaybackSessionManager] Creating new media progress for media item \"${session.displayTitle}\"`)\n      const updateResponse = await user.createUpdateMediaProgressFromPayload({\n        libraryItemId: libraryItem.id,\n        episodeId: session.episodeId,\n        ...session.mediaProgressObject,\n        markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete,\n        markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining\n      })\n      result.progressSynced = !!updateResponse.mediaProgress\n      if (result.progressSynced) {\n        userProgressForItem = updateResponse.mediaProgress\n      }\n    }\n\n    // Update user and emit socket event\n    if (result.progressSynced) {\n      SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {\n        id: userProgressForItem.id,\n        sessionId: session.id,\n        deviceDescription: session.deviceDescription,\n        data: userProgressForItem.getOldMediaProgress()\n      })\n    }\n\n    return result\n  }\n\n  /**\n   *\n   * @param {import('../controllers/SessionController').RequestWithUser} req\n   * @param {*} res\n   */\n  async syncLocalSessionRequest(req, res) {\n    const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)\n    const sessionJson = req.body\n    const result = await this.syncLocalSession(req.user, sessionJson, deviceInfo)\n    if (result.error) {\n      res.status(500).send(result.error)\n    } else {\n      res.sendStatus(200)\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/User')} user\n   * @param {*} session\n   * @param {*} syncData\n   * @param {import('express').Response} res\n   */\n  async closeSessionRequest(user, session, syncData, res) {\n    await this.closeSession(user, session, syncData)\n    res.sendStatus(200)\n  }\n\n  /**\n   *\n   * @param {import('../models/User')} user\n   * @param {DeviceInfo} deviceInfo\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {string|null} episodeId\n   * @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options\n   * @returns {Promise<PlaybackSession>}\n   */\n  async startSession(user, deviceInfo, libraryItem, episodeId, options) {\n    // Close any sessions already open for user and device\n    const userSessions = this.sessions.filter((playbackSession) => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.id)\n    for (const session of userSessions) {\n      Logger.info(`[PlaybackSessionManager] startSession: Closing open session \"${session.displayTitle}\" for user \"${user.username}\" (Device: ${session.deviceDescription})`)\n      await this.closeSession(user, session, null)\n    }\n\n    const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options.supportedMimeTypes, episodeId))\n    const mediaPlayer = options.mediaPlayer || 'unknown'\n\n    const mediaItemId = episodeId || libraryItem.media.id\n    const userProgress = user.getMediaProgress(mediaItemId)\n    let userStartTime = 0\n    if (userProgress) {\n      if (userProgress.isFinished) {\n        Logger.info(`[PlaybackSessionManager] Starting session for user \"${user.username}\" and resetting progress for finished item \"${libraryItem.media.title}\"`)\n        // Keep userStartTime as 0 so the client restarts the media\n      } else {\n        userStartTime = Number.parseFloat(userProgress.currentTime) || 0\n      }\n    }\n    const newPlaybackSession = new PlaybackSession()\n    newPlaybackSession.setData(libraryItem, user.id, mediaPlayer, deviceInfo, userStartTime, episodeId)\n\n    let audioTracks = []\n    if (shouldDirectPlay) {\n      Logger.debug(`[PlaybackSessionManager] \"${user.username}\" starting direct play session for item \"${libraryItem.id}\" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)\n      audioTracks = libraryItem.getTrackList(episodeId)\n      newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY\n    } else {\n      Logger.debug(`[PlaybackSessionManager] \"${user.username}\" starting stream session for item \"${libraryItem.id}\" (Device: ${newPlaybackSession.deviceDescription})`)\n      const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)\n      await stream.generatePlaylist()\n      stream.start() // Start transcode\n\n      audioTracks = [stream.getAudioTrack()]\n      newPlaybackSession.stream = stream\n      newPlaybackSession.playMethod = PlayMethod.TRANSCODE\n\n      stream.on('closed', () => {\n        Logger.debug(`[PlaybackSessionManager] Stream closed for session \"${newPlaybackSession.id}\" (Device: ${newPlaybackSession.deviceDescription})`)\n        newPlaybackSession.stream = null\n      })\n    }\n    newPlaybackSession.audioTracks = audioTracks\n\n    this.sessions.push(newPlaybackSession)\n    SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))\n\n    return newPlaybackSession\n  }\n\n  /**\n   *\n   * @param {import('../models/User')} user\n   * @param {*} session\n   * @param {*} syncData\n   * @returns {Promise<boolean>}\n   */\n  async syncSession(user, session, syncData) {\n    // TODO: Combine libraryItem query with library query\n    const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId)\n    if (!libraryItem) {\n      Logger.error(`[PlaybackSessionManager] syncSession Library Item not found \"${session.libraryItemId}\"`)\n      return false\n    }\n\n    const library = await Database.libraryModel.findByPk(libraryItem.libraryId)\n    if (!library) {\n      Logger.error(`[PlaybackSessionManager] syncSession Library not found \"${libraryItem.libraryId}\"`)\n      return false\n    }\n\n    session.currentTime = syncData.currentTime\n    session.addListeningTime(syncData.timeListened)\n    Logger.debug(`[PlaybackSessionManager] syncSession \"${session.id}\" (Device: ${session.deviceDescription}) | Total Time Listened: ${session.timeListening}`)\n\n    const updateResponse = await user.createUpdateMediaProgressFromPayload({\n      libraryItemId: libraryItem.id,\n      episodeId: session.episodeId,\n      // duration no longer required (v2.15.1) but used if available\n      duration: syncData.duration || session.duration || 0,\n      currentTime: syncData.currentTime,\n      progress: session.progress,\n      markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining,\n      markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete\n    })\n    if (updateResponse.mediaProgress) {\n      SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {\n        id: updateResponse.mediaProgress.id,\n        sessionId: session.id,\n        deviceDescription: session.deviceDescription,\n        data: updateResponse.mediaProgress.getOldMediaProgress()\n      })\n    }\n    this.saveSession(session)\n\n    return true\n  }\n\n  /**\n   *\n   * @param {import('../models/User')} user\n   * @param {*} session\n   * @param {*} syncData\n   * @returns\n   */\n  async closeSession(user, session, syncData = null) {\n    if (syncData) {\n      await this.syncSession(user, session, syncData)\n    } else {\n      await this.saveSession(session)\n    }\n    Logger.debug(`[PlaybackSessionManager] closeSession \"${session.id}\"`)\n    SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))\n    SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)\n    return this.removeSession(session.id)\n  }\n\n  saveSession(session) {\n    if (!session.timeListening) return // Do not save a session with no listening time\n\n    if (session.lastSave) {\n      return Database.updatePlaybackSession(session)\n    } else {\n      session.lastSave = Date.now()\n      return Database.createPlaybackSession(session)\n    }\n  }\n\n  /**\n   *\n   * @param {string} sessionId\n   */\n  async removeSession(sessionId) {\n    const session = this.sessions.find((s) => s.id === sessionId)\n    if (!session) return\n    if (session.stream) {\n      await session.stream.close()\n    }\n    this.sessions = this.sessions.filter((s) => s.id !== sessionId)\n    Logger.debug(`[PlaybackSessionManager] Removed session \"${sessionId}\"`)\n  }\n\n  /**\n   * Remove all stream folders in `/metadata/streams`\n   */\n  async removeOrphanStreams() {\n    try {\n      await fs.ensureDir(this.StreamsPath)\n    } catch (error) {\n      Logger.error(`[PlaybackSessionManager] Failed to create streams directory at \"${this.StreamsPath}\": ${error.message}`)\n      throw new Error(`[PlaybackSessionManager] Failed to create streams directory at \"${this.StreamsPath}\"`, { cause: error })\n    }\n    try {\n      const streamsInPath = await fs.readdir(this.StreamsPath)\n      for (const streamId of streamsInPath) {\n        if (/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/.test(streamId)) {\n          // Ensure is uuidv4\n          const session = this.sessions.find((se) => se.id === streamId)\n          if (!session) {\n            const streamPath = Path.join(this.StreamsPath, streamId)\n            Logger.debug(`[PlaybackSessionManager] Removing orphan stream \"${streamPath}\"`)\n            await fs.remove(streamPath)\n          }\n        }\n      }\n    } catch (error) {\n      Logger.error(`[PlaybackSessionManager] cleanOrphanStreams failed`, error)\n    }\n  }\n\n  /**\n   * Close all open sessions that have not been updated in the last 36 hours\n   */\n  async closeStaleOpenSessions() {\n    const updatedAtTimeCutoff = Date.now() - 1000 * 60 * 60 * 36\n    const staleSessions = this.sessions.filter((session) => session.updatedAt < updatedAtTimeCutoff)\n    for (const session of staleSessions) {\n      const sessionLastUpdate = new Date(session.updatedAt)\n      Logger.info(`[PlaybackSessionManager] Closing stale session \"${session.displayTitle}\" (${session.id}) last updated at ${sessionLastUpdate}`)\n      await this.removeSession(session.id)\n    }\n  }\n}\nmodule.exports = PlaybackSessionManager\n"
  },
  {
    "path": "server/managers/PodcastManager.js",
    "content": "const Path = require('path')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst Watcher = require('../Watcher')\n\nconst fs = require('../libs/fsExtra')\n\nconst { getPodcastFeed } = require('../utils/podcastUtils')\nconst { removeFile, downloadFile, sanitizeFilename, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')\nconst { levenshteinDistance } = require('../utils/index')\nconst opmlParser = require('../utils/parsers/parseOPML')\nconst opmlGenerator = require('../utils/generators/opmlGenerator')\nconst prober = require('../utils/prober')\nconst ffmpegHelpers = require('../utils/ffmpegHelpers')\n\nconst TaskManager = require('./TaskManager')\nconst CoverManager = require('../managers/CoverManager')\nconst NotificationManager = require('../managers/NotificationManager')\n\nconst LibraryFile = require('../objects/files/LibraryFile')\nconst PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')\nconst AudioFile = require('../objects/files/AudioFile')\n\nclass PodcastManager {\n  constructor() {\n    /** @type {PodcastEpisodeDownload[]} */\n    this.downloadQueue = []\n    /** @type {PodcastEpisodeDownload} */\n    this.currentDownload = null\n\n    this.failedCheckMap = {}\n    this.MaxFailedEpisodeChecks = global.MaxFailedEpisodeChecks\n  }\n\n  getEpisodeDownloadsInQueue(libraryItemId) {\n    return this.downloadQueue.filter((d) => d.libraryItemId === libraryItemId)\n  }\n\n  clearDownloadQueue(libraryItemId = null) {\n    if (!this.downloadQueue.length) return\n\n    if (!libraryItemId) {\n      Logger.info(`[PodcastManager] Clearing all downloads in queue (${this.downloadQueue.length})`)\n      this.downloadQueue = []\n    } else {\n      var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId)\n      Logger.info(`[PodcastManager] Clearing downloads in queue for item \"${libraryItemId}\" (${itemDownloads.length})`)\n      this.downloadQueue = this.downloadQueue.filter((d) => d.libraryItemId !== libraryItemId)\n      SocketAuthority.emitter('episode_download_queue_cleared', libraryItemId)\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload\n   * @param {boolean} isAutoDownload - If this download was triggered by auto download\n   */\n  async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {\n    for (const ep of episodesToDownload) {\n      const newPeDl = new PodcastEpisodeDownload()\n      newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)\n      this.startPodcastEpisodeDownload(newPeDl)\n    }\n  }\n\n  /**\n   *\n   * @param {PodcastEpisodeDownload} podcastEpisodeDownload\n   * @returns\n   */\n  async startPodcastEpisodeDownload(podcastEpisodeDownload) {\n    if (this.currentDownload) {\n      // Prevent downloading episodes from the same URL for the same library item.\n      // Allow downloading for different library items in case of the same podcast existing in multiple libraries (e.g. different folders)\n      if (this.downloadQueue.some((d) => d.url === podcastEpisodeDownload.url && d.libraryItem.id === podcastEpisodeDownload.libraryItem.id)) {\n        Logger.warn(`[PodcastManager] Episode already in queue: \"${this.currentDownload.episodeTitle}\"`)\n        return\n      } else if (this.currentDownload.url === podcastEpisodeDownload.url && this.currentDownload.libraryItem.id === podcastEpisodeDownload.libraryItem.id) {\n        Logger.warn(`[PodcastManager] Episode download already in progress for \"${podcastEpisodeDownload.episodeTitle}\"`)\n        return\n      }\n      this.downloadQueue.push(podcastEpisodeDownload)\n      SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())\n      return\n    }\n\n    const taskData = {\n      libraryId: podcastEpisodeDownload.libraryId,\n      libraryItemId: podcastEpisodeDownload.libraryItemId\n    }\n    const taskTitleString = {\n      text: 'Downloading episode',\n      key: 'MessageDownloadingEpisode'\n    }\n    const taskDescriptionString = {\n      text: `Downloading episode \"${podcastEpisodeDownload.episodeTitle}\".`,\n      key: 'MessageTaskDownloadingEpisodeDescription',\n      subs: [podcastEpisodeDownload.episodeTitle]\n    }\n    const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)\n\n    SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())\n    this.currentDownload = podcastEpisodeDownload\n\n    // If this file already exists then append a uuid to the filename\n    //  e.g. \"/tagesschau 20 Uhr.mp3\" becomes \"/tagesschau 20 Uhr (ep_asdfasdf).mp3\"\n    //  this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)\n    if (await fs.pathExists(this.currentDownload.targetPath)) {\n      this.currentDownload.setAppendRandomId(true)\n    }\n\n    // Ignores all added files to this dir\n    Watcher.addIgnoreDir(this.currentDownload.libraryItem.path)\n    Watcher.ignoreFilePathsDownloading.add(this.currentDownload.targetPath)\n\n    // Make sure podcast library item folder exists\n    if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {\n      Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at \"${this.currentDownload.libraryItem.path}\" - Creating it`)\n      await fs.mkdir(this.currentDownload.libraryItem.path)\n    }\n\n    // Download episode and tag it\n    const ffmpegDownloadResponse = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {\n      Logger.error(`[PodcastManager] Podcast Episode download failed`, error)\n    })\n    let success = !!ffmpegDownloadResponse?.success\n\n    if (success) {\n      // Attempt to ffprobe and add podcast episode audio file\n      success = await this.scanAddPodcastEpisodeAudioFile()\n      if (!success) {\n        Logger.error(`[PodcastManager] Failed to scan and add podcast episode audio file - removing file`)\n        await fs.remove(this.currentDownload.targetPath)\n      }\n    }\n\n    // If failed due to ffmpeg or ffprobe error, retry without tagging\n    // e.g. RSS feed may have incorrect file extension and file type\n    // See https://github.com/advplyr/audiobookshelf/issues/3837\n    // e.g. Ffmpeg may be download the file without streams causing the ffprobe to fail\n    if (!success && !ffmpegDownloadResponse?.isRequestError) {\n      Logger.info(`[PodcastManager] Retrying episode download without tagging`)\n      // Download episode only\n      success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath)\n        .then(() => true)\n        .catch((error) => {\n          Logger.error(`[PodcastManager] Podcast Episode download failed`, error)\n          return false\n        })\n\n      if (success) {\n        success = await this.scanAddPodcastEpisodeAudioFile()\n        if (!success) {\n          Logger.error(`[PodcastManager] Failed to scan and add podcast episode audio file - removing file`)\n          await fs.remove(this.currentDownload.targetPath)\n        }\n      }\n    }\n\n    if (success) {\n      Logger.info(`[PodcastManager] Successfully downloaded podcast episode \"${this.currentDownload.episodeTitle}\"`)\n      this.currentDownload.setFinished(true)\n      task.setFinished()\n    } else {\n      const taskFailedString = {\n        text: 'Failed',\n        key: 'MessageTaskFailed'\n      }\n      task.setFailed(taskFailedString)\n      this.currentDownload.setFinished(false)\n    }\n\n    TaskManager.taskFinished(task)\n\n    SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient())\n\n    Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)\n\n    Watcher.ignoreFilePathsDownloading.delete(this.currentDownload.targetPath)\n    this.currentDownload = null\n    if (this.downloadQueue.length) {\n      this.startPodcastEpisodeDownload(this.downloadQueue.shift())\n    }\n  }\n\n  /**\n   * Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary\n   * @returns {Promise<boolean>} - Returns true if added\n   */\n  async scanAddPodcastEpisodeAudioFile() {\n    const libraryFile = new LibraryFile()\n    await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath)\n\n    const audioFile = await this.probeAudioFile(libraryFile)\n    if (!audioFile) {\n      return false\n    }\n\n    const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)\n    if (!libraryItem) {\n      Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)\n      return false\n    }\n\n    const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)\n\n    libraryItem.libraryFiles.push(libraryFile.toJSON())\n    // Re-calculating library item size because this wasnt being updated properly for podcasts in v2.20.0 and below\n    let libraryItemSize = 0\n    libraryItem.libraryFiles.forEach((lf) => {\n      if (lf.metadata.size && !isNaN(lf.metadata.size)) {\n        libraryItemSize += Number(lf.metadata.size)\n      }\n    })\n    libraryItem.size = libraryItemSize\n    libraryItem.changed('libraryFiles', true)\n\n    libraryItem.media.podcastEpisodes.push(podcastEpisode)\n\n    if (this.currentDownload.isAutoDownload) {\n      // Check setting maxEpisodesToKeep and remove episode if necessary\n      const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length\n      if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {\n        Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)\n        const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id)\n        if (episodeToRemove) {\n          // Remove episode from playlists\n          await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])\n          // Remove media progress for this episode\n          await Database.mediaProgressModel.destroy({\n            where: {\n              mediaItemId: episodeToRemove.id\n            }\n          })\n          await episodeToRemove.destroy()\n          libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id)\n\n          // Remove library file\n          libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino)\n        }\n      }\n    }\n\n    await libraryItem.save()\n\n    if (libraryItem.media.numEpisodes !== libraryItem.media.podcastEpisodes.length) {\n      libraryItem.media.numEpisodes = libraryItem.media.podcastEpisodes.length\n      await libraryItem.media.save()\n    }\n\n    SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n    const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)\n    podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()\n    SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)\n\n    if (this.currentDownload.isAutoDownload) {\n      // Notifications only for auto downloaded episodes\n      NotificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)\n    }\n\n    return true\n  }\n\n  /**\n   * Find oldest episode publishedAt and delete the audio file\n   *\n   * @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem\n   * @param {string} episodeIdJustDownloaded\n   * @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove\n   */\n  async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {\n    let smallestPublishedAt = 0\n    /** @type {import('../models/PodcastEpisode')} */\n    let oldestEpisode = null\n\n    /** @type {import('../models/PodcastEpisode')[]} */\n    const podcastEpisodes = libraryItem.media.podcastEpisodes\n\n    for (const ep of podcastEpisodes) {\n      if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue\n\n      if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {\n        smallestPublishedAt = ep.publishedAt\n        oldestEpisode = ep\n      }\n    }\n\n    if (oldestEpisode?.audioFile) {\n      Logger.info(`[PodcastManager] Deleting oldest episode \"${oldestEpisode.title}\"`)\n      const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)\n      if (successfullyDeleted) {\n        return oldestEpisode\n      } else {\n        Logger.warn(`[PodcastManager] Failed to remove oldest episode \"${oldestEpisode.title}\"`)\n      }\n    }\n    return null\n  }\n\n  /**\n   *\n   * @param {LibraryFile} libraryFile\n   * @returns {Promise<AudioFile|null>}\n   */\n  async probeAudioFile(libraryFile) {\n    const path = libraryFile.metadata.path\n    const mediaProbeData = await prober.probe(path)\n    if (mediaProbeData.error) {\n      Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe \"${path}\"`, mediaProbeData.error)\n      return null\n    }\n    const newAudioFile = new AudioFile()\n    newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)\n    newAudioFile.index = 1\n    return newAudioFile\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @returns {Promise<boolean>} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks)\n   */\n  async runEpisodeCheck(libraryItem) {\n    const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0\n    const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()\n\n    Logger.info(`[PodcastManager] runEpisodeCheck: \"${libraryItem.media.title}\" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)\n\n    // Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck\n    //    lastEpisodeCheck will be the current time when adding a new podcast\n    const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck\n    Logger.debug(`[PodcastManager] runEpisodeCheck: \"${libraryItem.media.title}\" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)\n\n    const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)\n    Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)\n\n    if (!newEpisodes) {\n      // Failed\n      // Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download\n      if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0\n      this.failedCheckMap[libraryItem.id]++\n      if (this.MaxFailedEpisodeChecks !== 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {\n        Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for \"${libraryItem.media.title}\" - disabling auto download`)\n        void NotificationManager.onRSSFeedDisabled(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)\n        libraryItem.media.autoDownloadEpisodes = false\n        delete this.failedCheckMap[libraryItem.id]\n      } else {\n        Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for \"${libraryItem.media.title}\"`)\n        void NotificationManager.onRSSFeedFailed(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)\n      }\n    } else if (newEpisodes.length) {\n      delete this.failedCheckMap[libraryItem.id]\n      Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast \"${libraryItem.media.title}\" - starting download`)\n      this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)\n    } else {\n      delete this.failedCheckMap[libraryItem.id]\n      Logger.debug(`[PodcastManager] No new episodes for \"${libraryItem.media.title}\"`)\n    }\n\n    libraryItem.media.lastEpisodeCheck = new Date()\n    await libraryItem.media.save()\n\n    libraryItem.changed('updatedAt', true)\n    await libraryItem.save()\n\n    SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n\n    return libraryItem.media.autoDownloadEpisodes\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} podcastLibraryItem\n   * @param {number} dateToCheckForEpisodesAfter - Unix timestamp\n   * @param {number} maxNewEpisodes\n   * @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}\n   */\n  async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {\n    if (!podcastLibraryItem.media.feedURL) {\n      Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)\n      return null\n    }\n    const feed = await Promise.race([\n      getPodcastFeed(podcastLibraryItem.media.feedURL),\n      new Promise((_, reject) =>\n        // The added second is to make sure that axios can fail first and only falls back later\n        setTimeout(() => reject(new Error('Timeout. getPodcastFeed seemed to timeout but not triggering the timeout.')), global.PodcastDownloadTimeout + 1000)\n      )\n    ]).catch((error) => {\n      Logger.error(`[PodcastManager] checkPodcastForNewEpisodes failed to fetch feed for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id}):`, error)\n      return null\n    })\n\n    if (!feed?.episodes) {\n      Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)\n      return null\n    }\n\n    // Filter new and not already has\n    let newEpisodes = feed.episodes.filter((ep) => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedEpisode(ep))\n\n    if (maxNewEpisodes > 0) {\n      newEpisodes = newEpisodes.slice(0, maxNewEpisodes)\n    }\n\n    return newEpisodes\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} maxEpisodesToDownload\n   * @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}\n   */\n  async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {\n    const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0\n    const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'\n    Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for \"${libraryItem.media.title}\" - Last episode check: ${lastEpisodeCheckDate}`)\n\n    const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload)\n    if (newEpisodes?.length) {\n      Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast \"${libraryItem.media.title}\" - starting download`)\n      this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)\n    } else {\n      Logger.info(`[PodcastManager] No new episodes found for podcast \"${libraryItem.media.title}\"`)\n    }\n\n    libraryItem.media.lastEpisodeCheck = new Date()\n    await libraryItem.media.save()\n\n    libraryItem.changed('updatedAt', true)\n    await libraryItem.save()\n\n    SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n\n    return newEpisodes || []\n  }\n\n  async findEpisode(rssFeedUrl, searchTitle) {\n    const feed = await getPodcastFeed(rssFeedUrl).catch(() => {\n      return null\n    })\n    if (!feed || !feed.episodes) {\n      return null\n    }\n\n    const matches = []\n    feed.episodes.forEach((ep) => {\n      if (!ep.title) return\n\n      const epTitle = ep.title.toLowerCase().trim()\n      if (epTitle === searchTitle) {\n        matches.push({\n          episode: ep,\n          levenshtein: 0\n        })\n      } else {\n        const levenshtein = levenshteinDistance(searchTitle, epTitle, true)\n        if (levenshtein <= 6 && epTitle.length > levenshtein) {\n          matches.push({\n            episode: ep,\n            levenshtein\n          })\n        }\n      }\n    })\n    return matches.sort((a, b) => a.levenshtein - b.levenshtein)\n  }\n\n  getParsedOPMLFileFeeds(opmlText) {\n    return opmlParser.parse(opmlText)\n  }\n\n  async getOPMLFeeds(opmlText) {\n    const extractedFeeds = opmlParser.parse(opmlText)\n    if (!extractedFeeds?.length) {\n      Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')\n      return {\n        error: 'No RSS feeds found in OPML'\n      }\n    }\n\n    const rssFeedData = []\n\n    for (let feed of extractedFeeds) {\n      const feedData = await getPodcastFeed(feed.feedUrl, true)\n      if (feedData) {\n        feedData.metadata.feedUrl = feed.feedUrl\n        rssFeedData.push(feedData)\n      }\n    }\n\n    return {\n      feeds: rssFeedData\n    }\n  }\n\n  /**\n   * OPML file string for podcasts in a library\n   * @param {import('../models/Podcast')[]} podcasts\n   * @returns {string} XML string\n   */\n  generateOPMLFileText(podcasts) {\n    return opmlGenerator.generate(podcasts)\n  }\n\n  getDownloadQueueDetails(libraryId = null) {\n    let _currentDownload = this.currentDownload\n    if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null\n\n    return {\n      currentDownload: _currentDownload?.toJSONForClient(),\n      queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient())\n    }\n  }\n\n  /**\n   *\n   * @param {string[]} rssFeedUrls\n   * @param {import('../models/LibraryFolder')} folder\n   * @param {boolean} autoDownloadEpisodes\n   * @param {import('../managers/CronManager')} cronManager\n   */\n  async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) {\n    const taskTitleString = {\n      text: 'OPML import',\n      key: 'MessageTaskOpmlImport'\n    }\n    const taskDescriptionString = {\n      text: `Creating podcasts from ${rssFeedUrls.length} RSS feeds`,\n      key: 'MessageTaskOpmlImportDescription',\n      subs: [rssFeedUrls.length]\n    }\n    const task = TaskManager.createAndAddTask('opml-import', taskTitleString, taskDescriptionString, true, null)\n    let numPodcastsAdded = 0\n    Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder \"${folder.path}\"`)\n    for (const feedUrl of rssFeedUrls) {\n      const feed = await getPodcastFeed(feedUrl).catch(() => null)\n      if (!feed?.episodes) {\n        const taskTitleStringFeed = {\n          text: 'OPML import feed',\n          key: 'MessageTaskOpmlImportFeed'\n        }\n        const taskDescriptionStringFeed = {\n          text: `Importing RSS feed \"${feedUrl}\"`,\n          key: 'MessageTaskOpmlImportFeedDescription',\n          subs: [feedUrl]\n        }\n        const taskErrorString = {\n          text: 'Failed to get podcast feed',\n          key: 'MessageTaskOpmlImportFeedFailed'\n        }\n        TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringFeed, taskErrorString)\n        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for \"${feedUrl}\"`)\n        continue\n      }\n\n      const podcastFilename = sanitizeFilename(feed.metadata.title)\n      const podcastPath = filePathToPOSIX(`${folder.path}/${podcastFilename}`)\n      // Check if a library item with this podcast folder exists already\n      const existingLibraryItem =\n        (await Database.libraryItemModel.count({\n          where: {\n            path: podcastPath\n          }\n        })) > 0\n      if (existingLibraryItem) {\n        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path \"${podcastPath}\"`)\n        const taskTitleStringFeed = {\n          text: 'OPML import feed',\n          key: 'MessageTaskOpmlImportFeed'\n        }\n        const taskDescriptionStringPodcast = {\n          text: `Creating podcast \"${feed.metadata.title}\"`,\n          key: 'MessageTaskOpmlImportFeedPodcastDescription',\n          subs: [feed.metadata.title]\n        }\n        const taskErrorString = {\n          text: 'Podcast already exists at path',\n          key: 'MessageTaskOpmlImportFeedPodcastExists'\n        }\n        TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)\n        continue\n      }\n\n      const successCreatingPath = await fs\n        .ensureDir(podcastPath)\n        .then(() => true)\n        .catch((error) => {\n          Logger.error(`[PodcastManager] Failed to ensure podcast dir \"${podcastPath}\"`, error)\n          return false\n        })\n      if (!successCreatingPath) {\n        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at \"${podcastPath}\"`)\n        const taskTitleStringFeed = {\n          text: 'OPML import feed',\n          key: 'MessageTaskOpmlImportFeed'\n        }\n        const taskDescriptionStringPodcast = {\n          text: `Creating podcast \"${feed.metadata.title}\"`,\n          key: 'MessageTaskOpmlImportFeedPodcastDescription',\n          subs: [feed.metadata.title]\n        }\n        const taskErrorString = {\n          text: 'Failed to create podcast folder',\n          key: 'MessageTaskOpmlImportFeedPodcastFailed'\n        }\n        TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)\n        continue\n      }\n\n      let newLibraryItem = null\n      const transaction = await Database.sequelize.transaction()\n      try {\n        const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)\n\n        const podcastPayload = {\n          autoDownloadEpisodes,\n          metadata: {\n            title: feed.metadata.title,\n            author: feed.metadata.author,\n            description: feed.metadata.description,\n            releaseDate: '',\n            genres: [...feed.metadata.categories],\n            feedUrl: feed.metadata.feedUrl,\n            imageUrl: feed.metadata.image,\n            itunesPageUrl: '',\n            itunesId: '',\n            itunesArtistId: '',\n            language: '',\n            numEpisodes: feed.numEpisodes\n          }\n        }\n        const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction)\n\n        newLibraryItem = await Database.libraryItemModel.create(\n          {\n            ino: libraryItemFolderStats.ino,\n            path: podcastPath,\n            relPath: podcastFilename,\n            mediaId: podcast.id,\n            mediaType: 'podcast',\n            isFile: false,\n            isMissing: false,\n            isInvalid: false,\n            mtime: libraryItemFolderStats.mtimeMs || 0,\n            ctime: libraryItemFolderStats.ctimeMs || 0,\n            birthtime: libraryItemFolderStats.birthtimeMs || 0,\n            size: 0,\n            libraryFiles: [],\n            extraData: {},\n            libraryId: folder.libraryId,\n            libraryFolderId: folder.id,\n            title: podcast.title,\n            titleIgnorePrefix: podcast.titleIgnorePrefix\n          },\n          { transaction }\n        )\n\n        await transaction.commit()\n      } catch (error) {\n        await transaction.rollback()\n        Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for \"${feed.metadata.title}\"`, error)\n        const taskTitleStringFeed = {\n          text: 'OPML import feed',\n          key: 'MessageTaskOpmlImportFeed'\n        }\n        const taskDescriptionStringPodcast = {\n          text: `Creating podcast \"${feed.metadata.title}\"`,\n          key: 'MessageTaskOpmlImportFeedPodcastDescription',\n          subs: [feed.metadata.title]\n        }\n        const taskErrorString = {\n          text: 'Failed to create podcast library item',\n          key: 'MessageTaskOpmlImportFeedPodcastFailed'\n        }\n        TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)\n        continue\n      }\n\n      newLibraryItem.media = await newLibraryItem.getMediaExpanded()\n\n      // Download and save cover image\n      if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {\n        // Podcast cover will always go into library item folder\n        const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)\n        if (coverResponse.error) {\n          Logger.error(`[PodcastManager] Download cover error from \"${feed.metadata.image}\": ${coverResponse.error}`)\n        } else if (coverResponse.cover) {\n          const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)\n          if (!coverImageFileStats) {\n            Logger.error(`[PodcastManager] Failed to get cover image stats for \"${coverResponse.cover}\"`)\n          } else {\n            // Add libraryFile to libraryItem and coverPath to podcast\n            const newLibraryFile = {\n              ino: coverImageFileStats.ino,\n              fileType: 'image',\n              addedAt: Date.now(),\n              updatedAt: Date.now(),\n              metadata: {\n                filename: Path.basename(coverResponse.cover),\n                ext: Path.extname(coverResponse.cover).slice(1),\n                path: coverResponse.cover,\n                relPath: Path.basename(coverResponse.cover),\n                size: coverImageFileStats.size,\n                mtimeMs: coverImageFileStats.mtimeMs || 0,\n                ctimeMs: coverImageFileStats.ctimeMs || 0,\n                birthtimeMs: coverImageFileStats.birthtimeMs || 0\n              }\n            }\n            newLibraryItem.libraryFiles.push(newLibraryFile)\n            newLibraryItem.changed('libraryFiles', true)\n            await newLibraryItem.save()\n\n            newLibraryItem.media.coverPath = coverResponse.cover\n            await newLibraryItem.media.save()\n          }\n        }\n      }\n\n      SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)\n\n      // Turn on podcast auto download cron if not already on\n      if (newLibraryItem.media.autoDownloadEpisodes) {\n        cronManager.checkUpdatePodcastCron(newLibraryItem)\n      }\n\n      numPodcastsAdded++\n    }\n\n    const taskFinishedString = {\n      text: `Added ${numPodcastsAdded} podcasts`,\n      key: 'MessageTaskOpmlImportFinished',\n      subs: [numPodcastsAdded]\n    }\n    task.setFinished(taskFinishedString)\n    TaskManager.taskFinished(task)\n    Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)\n  }\n}\nmodule.exports = PodcastManager\n"
  },
  {
    "path": "server/managers/RssFeedManager.js",
    "content": "const { Request, Response } = require('express')\nconst Path = require('path')\n\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\n\nconst fs = require('../libs/fsExtra')\n\nclass RssFeedManager {\n  constructor() {}\n\n  /**\n   * Remove invalid feeds (invalid if the entity does not exist)\n   */\n  async init() {\n    const feeds = await Database.feedModel.findAll({\n      attributes: ['id', 'entityId', 'entityType', 'title'],\n      include: [\n        {\n          model: Database.libraryItemModel,\n          attributes: ['id']\n        },\n        {\n          model: Database.collectionModel,\n          attributes: ['id']\n        },\n        {\n          model: Database.seriesModel,\n          attributes: ['id']\n        }\n      ]\n    })\n\n    const feedIdsToRemove = []\n    for (const feed of feeds) {\n      if (!feed.entity) {\n        Logger.error(`[RssFeedManager] Removing feed \"${feed.title}\". Entity not found`)\n        feedIdsToRemove.push(feed.id)\n      }\n    }\n\n    if (feedIdsToRemove.length) {\n      Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)\n      await Database.feedModel.destroy({\n        where: {\n          id: feedIdsToRemove\n        }\n      })\n    }\n  }\n\n  /**\n   * Find open feed for an entity (e.g. collection id, playlist id, library item id)\n   * @param {string} entityId\n   * @returns {Promise<import('../models/Feed')>}\n   */\n  findFeedForEntityId(entityId) {\n    return Database.feedModel.findOne({\n      where: {\n        entityId\n      }\n    })\n  }\n\n  /**\n   *\n   * @param {string} slug\n   * @returns {Promise<boolean>}\n   */\n  checkExistsBySlug(slug) {\n    return Database.feedModel\n      .count({\n        where: {\n          slug\n        }\n      })\n      .then((count) => count > 0)\n  }\n\n  /**\n   * Feed requires update if the entity (or child entities) has been updated since the feed was last updated\n   *\n   * @param {import('../models/Feed')} feed\n   * @returns {Promise<boolean>}\n   */\n  async checkFeedRequiresUpdate(feed) {\n    if (feed.entityType === 'libraryItem') {\n      feed.entity = await feed.getEntity({\n        attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']\n      })\n\n      let newEntityUpdatedAt = feed.entity.updatedAt\n\n      if (feed.entity.mediaType === 'podcast') {\n        const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({\n          where: {\n            podcastId: feed.entity.mediaId\n          },\n          attributes: ['id', 'updatedAt'],\n          order: [['updatedAt', 'DESC']]\n        })\n\n        if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {\n          newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt\n        }\n      } else {\n        const book = await Database.bookModel.findOne({\n          where: {\n            id: feed.entity.mediaId\n          },\n          attributes: ['id', 'updatedAt']\n        })\n        if (book && book.updatedAt > newEntityUpdatedAt) {\n          newEntityUpdatedAt = book.updatedAt\n        }\n      }\n\n      return newEntityUpdatedAt > feed.entityUpdatedAt\n    } else if (feed.entityType === 'collection' || feed.entityType === 'series') {\n      feed.entity = await feed.getEntity({\n        attributes: ['id', 'updatedAt'],\n        include: {\n          model: Database.bookModel,\n          attributes: ['id', 'audioFiles', 'updatedAt'],\n          through: {\n            attributes: []\n          },\n          include: {\n            model: Database.libraryItemModel,\n            attributes: ['id', 'updatedAt']\n          }\n        }\n      })\n\n      const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0)\n      if (feed.feedEpisodes.length !== totalBookTracks) {\n        return true\n      }\n\n      let newEntityUpdatedAt = feed.entity.updatedAt\n\n      const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {\n        let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt\n        return updatedAt > mostRecent ? updatedAt : mostRecent\n      }, 0)\n\n      if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {\n        newEntityUpdatedAt = mostRecentItemUpdatedAt\n      }\n\n      return newEntityUpdatedAt > feed.entityUpdatedAt\n    } else {\n      throw new Error('Invalid feed entity type')\n    }\n  }\n\n  /**\n   * GET: /feed/:slug\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getFeed(req, res) {\n    let feed = await Database.feedModel.findOne({\n      where: {\n        slug: req.params.slug\n      },\n      include: {\n        model: Database.feedEpisodeModel\n      }\n    })\n    if (!feed) {\n      Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)\n      res.sendStatus(404)\n      return\n    }\n\n    const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)\n    if (feedRequiresUpdate) {\n      Logger.info(`[RssFeedManager] Feed \"${feed.title}\" requires update - updating feed`)\n      feed = await feed.updateFeedForEntity()\n    }\n\n    const xml = feed.buildXml(req.originalHostPrefix)\n    res.set('Content-Type', 'text/xml')\n    res.send(xml)\n  }\n\n  /**\n   * GET: /feed/:slug/item/:episodeId/*\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getFeedItem(req, res) {\n    const feed = await Database.feedModel.findOne({\n      where: {\n        slug: req.params.slug\n      },\n      attributes: ['id', 'slug'],\n      include: {\n        model: Database.feedEpisodeModel,\n        attributes: ['id', 'filePath']\n      }\n    })\n\n    if (!feed) {\n      Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)\n      res.sendStatus(404)\n      return\n    }\n    const episodePath = feed.getEpisodePath(req.params.episodeId)\n    if (!episodePath) {\n      Logger.error(`[RssFeedManager] Feed episode not found ${req.params.episodeId}`)\n      res.sendStatus(404)\n      return\n    }\n    res.sendFile(episodePath)\n  }\n\n  /**\n   * GET: /feed/:slug/cover*\n   *\n   * @param {Request} req\n   * @param {Response} res\n   */\n  async getFeedCover(req, res) {\n    const feed = await Database.feedModel.findOne({\n      where: {\n        slug: req.params.slug\n      },\n      attributes: ['coverPath']\n    })\n    if (!feed) {\n      Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)\n      res.sendStatus(404)\n      return\n    }\n\n    if (!feed.coverPath) {\n      res.sendStatus(404)\n      return\n    }\n\n    const extname = Path.extname(feed.coverPath).toLowerCase().slice(1)\n    res.type(`image/${extname}`)\n    const readStream = fs.createReadStream(feed.coverPath)\n\n    readStream.on('error', (error) => {\n      Logger.error(`[RssFeedManager] Error streaming cover image: ${error.message}`)\n      // Only send error if headers haven't been sent yet\n      if (!res.headersSent) {\n        res.sendStatus(404)\n      }\n    })\n\n    readStream.pipe(res)\n  }\n\n  /**\n   *\n   * @param {*} options\n   * @returns {import('../models/Feed').FeedOptions}\n   */\n  getFeedOptionsFromReqOptions(options) {\n    const metadataDetails = options.metadataDetails || {}\n\n    if (metadataDetails.preventIndexing !== false) {\n      metadataDetails.preventIndexing = true\n    }\n\n    return {\n      preventIndexing: metadataDetails.preventIndexing,\n      ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,\n      ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null\n    }\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} options\n   * @returns {Promise<import('../models/Feed').FeedExpanded>}\n   */\n  async openFeedForItem(userId, libraryItem, options) {\n    const serverAddress = options.serverAddress\n    const slug = options.slug\n    const feedOptions = this.getFeedOptionsFromReqOptions(options)\n\n    Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} \"${libraryItem.media.title}\"`)\n    const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)\n    if (feedExpanded) {\n      Logger.info(`[RssFeedManager] Opened RSS feed \"${feedExpanded.feedURL}\"`)\n      SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())\n    }\n    return feedExpanded\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('../models/Collection')} collectionExpanded\n   * @param {*} options\n   * @returns {Promise<import('../models/Feed').FeedExpanded>}\n   */\n  async openFeedForCollection(userId, collectionExpanded, options) {\n    const serverAddress = options.serverAddress\n    const slug = options.slug\n    const feedOptions = this.getFeedOptionsFromReqOptions(options)\n\n    Logger.info(`[RssFeedManager] Creating RSS feed for collection \"${collectionExpanded.name}\"`)\n    const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)\n    if (feedExpanded) {\n      Logger.info(`[RssFeedManager] Opened RSS feed \"${feedExpanded.feedURL}\"`)\n      SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())\n    }\n    return feedExpanded\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('../models/Series')} seriesExpanded\n   * @param {*} options\n   * @returns {Promise<import('../models/Feed').FeedExpanded>}\n   */\n  async openFeedForSeries(userId, seriesExpanded, options) {\n    const serverAddress = options.serverAddress\n    const slug = options.slug\n    const feedOptions = this.getFeedOptionsFromReqOptions(options)\n\n    Logger.info(`[RssFeedManager] Creating RSS feed for series \"${seriesExpanded.name}\"`)\n    const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)\n    if (feedExpanded) {\n      Logger.info(`[RssFeedManager] Opened RSS feed \"${feedExpanded.feedURL}\"`)\n      SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())\n    }\n    return feedExpanded\n  }\n\n  /**\n   * Close Feed and emit Socket event\n   *\n   * @param {import('../models/Feed')} feed\n   * @returns {Promise<boolean>} - true if feed was closed\n   */\n  async handleCloseFeed(feed) {\n    if (!feed) return false\n    const wasRemoved = await Database.feedModel.removeById(feed.id)\n    SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())\n    Logger.info(`[RssFeedManager] Closed RSS feed \"${feed.feedURL}\"`)\n    return wasRemoved\n  }\n\n  /**\n   *\n   * @param {string} entityId\n   * @returns {Promise<boolean>} - true if feed was closed\n   */\n  async closeFeedForEntityId(entityId) {\n    const feed = await Database.feedModel.findOne({\n      where: {\n        entityId\n      }\n    })\n    if (!feed) {\n      return false\n    }\n    return this.handleCloseFeed(feed)\n  }\n\n  /**\n   *\n   * @param {string[]} entityIds\n   */\n  async closeFeedsForEntityIds(entityIds) {\n    const feeds = await Database.feedModel.findAll({\n      where: {\n        entityId: entityIds\n      }\n    })\n    for (const feed of feeds) {\n      await this.handleCloseFeed(feed)\n    }\n  }\n\n  /**\n   *\n   * @returns {Promise<import('../models/Feed').FeedExpanded[]>}\n   */\n  getFeeds() {\n    return Database.feedModel.findAll({\n      include: {\n        model: Database.feedEpisodeModel\n      }\n    })\n  }\n}\nmodule.exports = new RssFeedManager()\n"
  },
  {
    "path": "server/managers/ShareManager.js",
    "content": "const Database = require('../Database')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst LongTimeout = require('../utils/longTimeout')\nconst { elapsedPretty } = require('../utils/index')\n\n/**\n * @typedef OpenMediaItemShareObject\n * @property {string} id\n * @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare\n * @property {LongTimeout} timeout\n */\n\nclass ShareManager {\n  constructor() {\n    /** @type {OpenMediaItemShareObject[]} */\n    this.openMediaItemShares = []\n\n    /** @type {import('../objects/PlaybackSession')[]} */\n    this.openSharePlaybackSessions = []\n  }\n\n  init() {\n    this.loadMediaItemShares()\n  }\n\n  /**\n   * @param {import('../objects/PlaybackSession')} playbackSession\n   */\n  addOpenSharePlaybackSession(playbackSession) {\n    Logger.info(`[ShareManager] Adding new open share playback session \"${playbackSession.displayTitle}\"`)\n    this.openSharePlaybackSessions.push(playbackSession)\n  }\n\n  /**\n   *\n   * @param {import('../objects/PlaybackSession')} playbackSession\n   */\n  closeSharePlaybackSession(playbackSession) {\n    Logger.info(`[ShareManager] Closing share playback session \"${playbackSession.displayTitle}\"`)\n    this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.id !== playbackSession.id)\n  }\n\n  /**\n   * Find an open media item share by media item ID\n   * @param {string} mediaItemId\n   * @returns {import('../models/MediaItemShare').MediaItemShareForClient}\n   */\n  findByMediaItemId(mediaItemId) {\n    const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.mediaItemId === mediaItemId)?.mediaItemShare\n    if (mediaItemShareObject) {\n      const mediaItemShareObjectForClient = { ...mediaItemShareObject }\n      delete mediaItemShareObjectForClient.pash\n      delete mediaItemShareObjectForClient.userId\n      delete mediaItemShareObjectForClient.extraData\n      return mediaItemShareObjectForClient\n    }\n    return null\n  }\n\n  /**\n   * Find an open media item share by slug\n   * @param {string} slug\n   * @returns {import('../models/MediaItemShare').MediaItemShareForClient}\n   */\n  findBySlug(slug) {\n    const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.slug === slug)?.mediaItemShare\n    if (mediaItemShareObject) {\n      const mediaItemShareObjectForClient = { ...mediaItemShareObject }\n      delete mediaItemShareObjectForClient.pash\n      delete mediaItemShareObjectForClient.userId\n      delete mediaItemShareObjectForClient.extraData\n      return mediaItemShareObjectForClient\n    }\n    return null\n  }\n\n  /**\n   * @param {string} shareSessionId\n   * @returns {import('../objects/PlaybackSession')}\n   */\n  findPlaybackSessionBySessionId(shareSessionId) {\n    return this.openSharePlaybackSessions.find((s) => s.shareSessionId === shareSessionId)\n  }\n\n  /**\n   * Load all media item shares from the database\n   * Remove expired & schedule active\n   */\n  async loadMediaItemShares() {\n    /** @type {import('../models/MediaItemShare').MediaItemShareModel[]} */\n    const mediaItemShares = await Database.models.mediaItemShare.findAll()\n\n    for (const mediaItemShare of mediaItemShares) {\n      if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {\n        Logger.info(`[ShareManager] Removing expired media item share \"${mediaItemShare.id}\"`)\n        await this.destroyMediaItemShare(mediaItemShare.id)\n      } else if (mediaItemShare.expiresAt) {\n        this.scheduleMediaItemShare(mediaItemShare)\n      } else {\n        Logger.info(`[ShareManager] Loaded permanent media item share \"${mediaItemShare.id}\"`)\n        this.openMediaItemShares.push({\n          id: mediaItemShare.id,\n          mediaItemShare: mediaItemShare.toJSON()\n        })\n      }\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare\n   */\n  scheduleMediaItemShare(mediaItemShare) {\n    if (!mediaItemShare?.expiresAt) return\n\n    const expiresAtDuration = mediaItemShare.expiresAt.valueOf() - Date.now()\n    if (expiresAtDuration <= 0) {\n      Logger.warn(`[ShareManager] Attempted to schedule expired media item share \"${mediaItemShare.id}\"`)\n      this.destroyMediaItemShare(mediaItemShare.id)\n      return\n    }\n    const timeout = new LongTimeout()\n    timeout.set(() => {\n      Logger.info(`[ShareManager] Removing expired media item share \"${mediaItemShare.id}\"`)\n      this.removeMediaItemShare(mediaItemShare.id)\n    }, expiresAtDuration)\n    this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout })\n    Logger.info(`[ShareManager] Scheduled media item share \"${mediaItemShare.id}\" to expire in ${elapsedPretty(expiresAtDuration / 1000)}`)\n  }\n\n  /**\n   *\n   * @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare\n   */\n  openMediaItemShare(mediaItemShare) {\n    if (mediaItemShare.expiresAt) {\n      this.scheduleMediaItemShare(mediaItemShare)\n    } else {\n      this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON() })\n    }\n    SocketAuthority.adminEmitter('share_open', mediaItemShare.toJSONForClient())\n  }\n\n  /**\n   *\n   * @param {string} mediaItemShareId\n   */\n  async removeMediaItemShare(mediaItemShareId) {\n    const mediaItemShare = this.openMediaItemShares.find((s) => s.id === mediaItemShareId)\n    if (!mediaItemShare) return\n\n    if (mediaItemShare.timeout) {\n      mediaItemShare.timeout.clear()\n    }\n\n    this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)\n    this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId)\n    await this.destroyMediaItemShare(mediaItemShareId)\n\n    const mediaItemShareObjectForClient = { ...mediaItemShare.mediaItemShare }\n    delete mediaItemShareObjectForClient.pash\n    delete mediaItemShareObjectForClient.userId\n    delete mediaItemShareObjectForClient.extraData\n    SocketAuthority.adminEmitter('share_closed', mediaItemShareObjectForClient)\n  }\n\n  /**\n   *\n   * @param {string} mediaItemShareId\n   */\n  destroyMediaItemShare(mediaItemShareId) {\n    return Database.models.mediaItemShare.destroy({ where: { id: mediaItemShareId } })\n  }\n\n  /**\n   * Close open share sessions that have not been updated in the last 24 hours\n   */\n  closeStaleOpenShareSessions() {\n    const updatedAtTimeCutoff = Date.now() - 1000 * 60 * 60 * 24\n    const staleSessions = this.openSharePlaybackSessions.filter((session) => session.updatedAt < updatedAtTimeCutoff)\n    for (const session of staleSessions) {\n      const sessionLastUpdate = new Date(session.updatedAt)\n      Logger.info(`[PlaybackSessionManager] Closing stale session \"${session.displayTitle}\" (${session.id}) last updated at ${sessionLastUpdate}`)\n      this.closeSharePlaybackSession(session)\n    }\n  }\n}\nmodule.exports = new ShareManager()\n"
  },
  {
    "path": "server/managers/TaskManager.js",
    "content": "const SocketAuthority = require('../SocketAuthority')\nconst Task = require('../objects/Task')\n\n/**\n * @typedef TaskString\n * @property {string} text\n * @property {string} key\n * @property {string[]} [subs]\n */\n\nclass TaskManager {\n  constructor() {\n    /** @type {Task[]} */\n    this.tasks = []\n  }\n\n  /**\n   * Add task and emit socket task_started event\n   *\n   * @param {Task} task\n   */\n  addTask(task) {\n    this.tasks.push(task)\n    SocketAuthority.emitter('task_started', task.toJSON())\n  }\n\n  /**\n   * Remove task and emit task_finished event\n   *\n   * @param {Task} task\n   */\n  taskFinished(task) {\n    if (this.tasks.some((t) => t.id === task.id)) {\n      this.tasks = this.tasks.filter((t) => t.id !== task.id)\n      SocketAuthority.emitter('task_finished', task.toJSON())\n    }\n  }\n\n  /**\n   * Create new task and add\n   *\n   * @param {string} action\n   * @param {TaskString} titleString\n   * @param {TaskString|null} descriptionString\n   * @param {boolean} showSuccess\n   * @param {Object} [data]\n   */\n  createAndAddTask(action, titleString, descriptionString, showSuccess, data = {}) {\n    const task = new Task()\n    task.setData(action, titleString, descriptionString, showSuccess, data)\n    this.addTask(task)\n    return task\n  }\n\n  /**\n   * Create new failed task and add\n   *\n   * @param {string} action\n   * @param {TaskString} titleString\n   * @param {TaskString|null} descriptionString\n   * @param {TaskString} errorMessageString\n   */\n  createAndEmitFailedTask(action, titleString, descriptionString, errorMessageString) {\n    const task = new Task()\n    task.setData(action, titleString, descriptionString, false)\n    task.setFailed(errorMessageString)\n    SocketAuthority.emitter('task_started', task.toJSON())\n    return task\n  }\n}\nmodule.exports = new TaskManager()\n"
  },
  {
    "path": "server/migrations/changelog.md",
    "content": "# Migrations Changelog\n\nPlease add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.\n\n| Server Version | Migration Script Name                        | Description                                                                                                   |\n| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |\n| v2.15.0        | v2.15.0-series-column-unique                 | Series must have unique names in the same library                                                             |\n| v2.15.1        | v2.15.1-reindex-nocase                       | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0                          |\n| v2.15.2        | v2.15.2-index-creation                       | Creates author, series, and podcast episode indexes                                                           |\n| v2.17.0        | v2.17.0-uuid-replacement                     | Changes the data type of columns with UUIDv4 to UUID matching the associated model                            |\n| v2.17.3        | v2.17.3-fk-constraints                       | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |\n| v2.17.4        | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations                                        |\n| v2.17.5        | v2.17.5-remove-host-from-feed-urls           | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables                        |\n| v2.17.6        | v2.17.6-share-add-isdownloadable             | Adds the isDownloadable column to the mediaItemShares table                                                   |\n| v2.17.7        | v2.17.7-add-indices                          | Adds indices to the libraryItems and books tables to reduce query times                                       |\n| v2.19.1        | v2.19.1-copy-title-to-library-items          | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices             |\n| v2.19.4        | v2.19.4-improve-podcast-queries              | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems         |\n| v2.20.0        | v2.20.0-improve-author-sort-queries          | Adds AuthorNames(FirstLast\\|LastFirst) to libraryItems to improve author sort queries                         |\n"
  },
  {
    "path": "server/migrations/readme.md",
    "content": "# Database Migrations\n\nThis directory contains all the database migration scripts for the server.\n\n## What is a migration?\n\nA migration is a script that changes the structure of the database. This can include creating tables, adding columns, or modifying existing columns. A migration script consists of two parts: an \"up\" script that applies the changes to the database, and a \"down\" script that undoes the changes.\n\n## Guidelines for writing migrations\n\nWhen writing a migration, keep the following guidelines in mind:\n\n- You **_must_** name your migration script according to the following convention: `<server_version>-<migration_name>.js`. For example, `v2.14.0-create-users-table.js`.\n\n  - `server_version` should be the version of the server that the migration was created for (this should usually be the next server release).\n  - `migration_name` should be a short description of the changes that the migration makes.\n\n- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object, and a [Logger](https://github.com/advplyr/audiobookshelf/blob/423a2129d10c6d8aaac9e8c75941fa6283889602/server/Logger.js#L4) object for logging. A typical migration script might look like this:\n\n  ```javascript\n  async function up({ context: { queryInterface, logger } }) {\n    // Upwards migration script\n    logger.info('migrating ...');\n    ...\n  }\n\n  async function down({ context: { queryInterface, logger } }) {\n    // Downward migration script\n    logger.info('reverting ...');\n    ...\n  }\n\n  module.exports = {up, down}\n  ```\n\n- Always implement both the `up` and `down` functions.\n- The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times).\n- Prefer using only `queryInterface` and `logger` parameters, the `sequelize` module, and node.js built-in modules in your migration scripts. You can require other modules, but be aware that they might not be available or change from they ones you tested with.\n- It's your responsibility to make sure that the down migration reverts the changes made by the up migration.\n- Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`.\n- Test tour migrations thoroughly before committing them.\n  - write unit tests for your migrations (see `test/server/migrations` for an example)\n  - you can force a server version change by modifying the `version` field in `package.json` on your dev environment (but don't forget to revert it back before committing)\n\n## How migrations are run\n\nMigrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run in server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions.\n\nThis means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts.\n"
  },
  {
    "path": "server/migrations/v2.15.0-series-column-unique.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This upward migration script cleans any duplicate series in the `Series` table and\n * adds a unique index on the `name` and `libraryId` columns.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique ')\n\n  // Run reindex nocase to fix potential corruption issues due to the bad sqlite extension introduced in v2.12.0\n  logger.info('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues')\n  await queryInterface.sequelize.query('REINDEX NOCASE;')\n\n  // Check if the unique index already exists\n  const seriesIndexes = await queryInterface.showIndex('Series')\n  if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) {\n    logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists')\n    logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ')\n    return\n  }\n\n  // The steps taken to deduplicate the series are as follows:\n  // 1. Find all duplicate series in the `Series` table.\n  // 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table.\n  //    2.a For each book ID, check if the ID occurs multiple times for the duplicate series.\n  //    2.b If so, keep only one of the rows that has this bookId and seriesId.\n  // 3. Update `bookSeries` table to point to the most recent series.\n  // 4. Delete the older series.\n\n  // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column\n  const [duplicates] = await queryInterface.sequelize.query(`\n    SELECT name, libraryId\n    FROM Series\n    GROUP BY name, libraryId\n    HAVING COUNT(name) > 1\n  `)\n\n  // Print out how many duplicates were found\n  logger.info(`[2.15.0 migration] Found ${duplicates.length} duplicate series`)\n\n  // Iterate over each duplicate series\n  for (const duplicate of duplicates) {\n    // Report the series name that is being deleted\n    logger.info(`[2.15.0 migration] Deduplicating series \"${duplicate.name}\" in library ${duplicate.libraryId}`)\n\n    // Determine any duplicate book IDs in the `bookSeries` table for the same series\n    const [duplicateBookIds] = await queryInterface.sequelize.query(\n      `\n        SELECT bookId\n        FROM BookSeries\n        WHERE seriesId IN (\n          SELECT id\n          FROM Series\n          WHERE name = :name AND libraryId = :libraryId\n        )\n        GROUP BY bookId\n        HAVING COUNT(bookId) > 1\n        `,\n      {\n        replacements: {\n          name: duplicate.name,\n          libraryId: duplicate.libraryId\n        }\n      }\n    )\n\n    // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId\n    for (const { bookId } of duplicateBookIds) {\n      logger.info(`[2.15.0 migration] Deduplicating bookId ${bookId} in series \"${duplicate.name}\" of library ${duplicate.libraryId}`)\n      // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last\n      const [duplicateBookSeries] = await queryInterface.sequelize.query(\n        `\n            SELECT id\n            FROM BookSeries\n            WHERE bookId = :bookId\n            AND seriesId IN (\n              SELECT id\n              FROM Series\n              WHERE name = :name AND libraryId = :libraryId\n            )\n            ORDER BY sequence NULLS LAST\n            `,\n        {\n          replacements: {\n            bookId,\n            name: duplicate.name,\n            libraryId: duplicate.libraryId\n          }\n        }\n      )\n\n      // remove the first element from the array\n      duplicateBookSeries.shift()\n\n      // Delete the remaining duplicate rows\n      if (duplicateBookSeries.length > 0) {\n        const [deletedBookSeries] = await queryInterface.sequelize.query(\n          `\n              DELETE FROM BookSeries\n              WHERE id IN (:ids)\n              `,\n          {\n            replacements: {\n              ids: duplicateBookSeries.map((row) => row.id)\n            }\n          }\n        )\n      }\n      logger.info(`[2.15.0 migration] Finished cleanup of bookId ${bookId} in series \"${duplicate.name}\" of library ${duplicate.libraryId}`)\n    }\n\n    // Get all the most recent series which matches the `name` and `libraryId`\n    const [mostRecentSeries] = await queryInterface.sequelize.query(\n      `\n        SELECT id\n        FROM Series\n        WHERE name = :name AND libraryId = :libraryId\n        ORDER BY updatedAt DESC\n        LIMIT 1\n        `,\n      {\n        replacements: {\n          name: duplicate.name,\n          libraryId: duplicate.libraryId\n        },\n        type: queryInterface.sequelize.QueryTypes.SELECT\n      }\n    )\n\n    if (mostRecentSeries) {\n      // Update all BookSeries records for this series to point to the most recent series\n      const [seriesUpdated] = await queryInterface.sequelize.query(\n        `\n          UPDATE BookSeries\n          SET seriesId = :mostRecentSeriesId\n          WHERE seriesId IN (\n            SELECT id\n            FROM Series\n            WHERE name = :name AND libraryId = :libraryId\n            AND id != :mostRecentSeriesId\n          )\n          `,\n        {\n          replacements: {\n            name: duplicate.name,\n            libraryId: duplicate.libraryId,\n            mostRecentSeriesId: mostRecentSeries.id\n          }\n        }\n      )\n\n      // Delete the older series\n      const seriesDeleted = await queryInterface.sequelize.query(\n        `\n          DELETE FROM Series\n          WHERE name = :name AND libraryId = :libraryId\n          AND id != :mostRecentSeriesId\n          `,\n        {\n          replacements: {\n            name: duplicate.name,\n            libraryId: duplicate.libraryId,\n            mostRecentSeriesId: mostRecentSeries.id\n          }\n        }\n      )\n    }\n  }\n\n  logger.info(`[2.15.0 migration] Deduplication complete`)\n\n  // Create a unique index based on the name and library ID for the `Series` table\n  await queryInterface.addIndex('Series', ['name', 'libraryId'], {\n    unique: true,\n    name: 'unique_series_name_per_library'\n  })\n  logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId')\n\n  logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ')\n}\n\n/**\n * This removes the unique index on the `Series` table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique ')\n\n  // Remove the unique index\n  await queryInterface.removeIndex('Series', 'unique_series_name_per_library')\n  logger.info('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId')\n\n  logger.info('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique ')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.15.1-reindex-nocase.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This upward migration script fixes old database corruptions due to the a bad sqlite extension introduced in v2.12.0.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info('[2.15.1 migration] UPGRADE BEGIN: 2.15.1-reindex-nocase ')\n\n  // Run reindex nocase to fix potential corruption issues due to the bad sqlite extension introduced in v2.12.0\n  logger.info('[2.15.1 migration] Reindexing NOCASE indices to fix potential hidden corruption issues')\n  await queryInterface.sequelize.query('REINDEX NOCASE;')\n\n  logger.info('[2.15.1 migration] UPGRADE END: 2.15.1-reindex-nocase ')\n}\n\n/**\n * This downward migration script is a no-op.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info('[2.15.1 migration] DOWNGRADE BEGIN: 2.15.1-reindex-nocase ')\n\n  // This migration is a no-op\n  logger.info('[2.15.1 migration] No action required for downgrade')\n\n  logger.info('[2.15.1 migration] DOWNGRADE END: 2.15.1-reindex-nocase ')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.15.2-index-creation.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This upward migration script adds indexes to speed up queries on the `BookAuthor`, `BookSeries`, and `podcastEpisodes` tables.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info('[2.15.2 migration] UPGRADE BEGIN: 2.15.2-index-creation')\n\n  // Create index for bookAuthors\n  logger.info('[2.15.2 migration] Creating index for bookAuthors')\n  const bookAuthorsIndexes = await queryInterface.showIndex('bookAuthors')\n  if (!bookAuthorsIndexes.some((index) => index.name === 'bookAuthor_authorId')) {\n    await queryInterface.addIndex('bookAuthors', ['authorId'], {\n      name: 'bookAuthor_authorId'\n    })\n  } else {\n    logger.info('[2.15.2 migration] Index bookAuthor_authorId already exists')\n  }\n\n  // Create index for bookSeries\n  logger.info('[2.15.2 migration] Creating index for bookSeries')\n  const bookSeriesIndexes = await queryInterface.showIndex('bookSeries')\n  if (!bookSeriesIndexes.some((index) => index.name === 'bookSeries_seriesId')) {\n    await queryInterface.addIndex('bookSeries', ['seriesId'], {\n      name: 'bookSeries_seriesId'\n    })\n  } else {\n    logger.info('[2.15.2 migration] Index bookSeries_seriesId already exists')\n  }\n\n  // Delete existing podcastEpisode index\n  logger.info('[2.15.2 migration] Deleting existing podcastEpisode index')\n  await queryInterface.removeIndex('podcastEpisodes', 'podcast_episodes_created_at')\n\n  // Create index for podcastEpisode and createdAt\n  logger.info('[2.15.2 migration] Creating index for podcastEpisode and createdAt')\n  const podcastEpisodesIndexes = await queryInterface.showIndex('podcastEpisodes')\n  if (!podcastEpisodesIndexes.some((index) => index.name === 'podcastEpisode_createdAt_podcastId')) {\n    await queryInterface.addIndex('podcastEpisodes', ['createdAt', 'podcastId'], {\n      name: 'podcastEpisode_createdAt_podcastId'\n    })\n  } else {\n    logger.info('[2.15.2 migration] Index podcastEpisode_createdAt_podcastId already exists')\n  }\n\n  // Completed migration\n  logger.info('[2.15.2 migration] UPGRADE END: 2.15.2-index-creation')\n}\n\n/**\n * This downward migration script removes the newly created indexes and re-adds the old index on the `podcastEpisodes` table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info('[2.15.2 migration] DOWNGRADE BEGIN: 2.15.2-index-creation')\n\n  // Remove index for bookAuthors\n  logger.info('[2.15.2 migration] Removing index for bookAuthors')\n  await queryInterface.removeIndex('bookAuthors', 'bookAuthor_authorId')\n\n  // Remove index for bookSeries\n  logger.info('[2.15.2 migration] Removing index for bookSeries')\n  await queryInterface.removeIndex('bookSeries', 'bookSeries_seriesId')\n\n  // Delete existing podcastEpisode index\n  logger.info('[2.15.2 migration] Deleting existing podcastEpisode index')\n  await queryInterface.removeIndex('podcastEpisodes', 'podcastEpisode_createdAt_podcastId')\n\n  // Create index for podcastEpisode and createdAt\n  logger.info('[2.15.2 migration] Creating original index for podcastEpisode createdAt')\n  await queryInterface.addIndex('podcastEpisodes', ['createdAt'], {\n    name: 'podcast_episodes_created_at'\n  })\n\n  // Finished migration\n  logger.info('[2.15.2 migration] DOWNGRADE END: 2.15.2-index-creation')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.17.0-uuid-replacement.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement')\n\n  logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID')\n  await queryInterface.changeColumn('libraryItems', 'mediaId', {\n    type: 'UUID'\n  })\n\n  logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID')\n  await queryInterface.changeColumn('feeds', 'entityId', {\n    type: 'UUID'\n  })\n\n  if (await queryInterface.tableExists('mediaItemShares')) {\n    logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID')\n    await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {\n      type: 'UUID'\n    })\n  } else {\n    logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change')\n  }\n\n  logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID')\n  await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {\n    type: 'UUID'\n  })\n\n  logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID')\n  await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', {\n    type: 'UUID'\n  })\n\n  logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID')\n  await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', {\n    type: 'UUID'\n  })\n\n  // Completed migration\n  logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement')\n}\n\n/**\n * This downward migration script changes table columns data type back to UUIDv4.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement')\n\n  logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4')\n  await queryInterface.changeColumn('libraryItems', 'mediaId', {\n    type: 'UUIDV4'\n  })\n\n  logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4')\n  await queryInterface.changeColumn('feeds', 'entityId', {\n    type: 'UUIDV4'\n  })\n\n  logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4')\n  await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', {\n    type: 'UUIDV4'\n  })\n\n  logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4')\n  await queryInterface.changeColumn('playbackSessions', 'mediaItemId', {\n    type: 'UUIDV4'\n  })\n\n  logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4')\n  await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', {\n    type: 'UUIDV4'\n  })\n\n  logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4')\n  await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', {\n    type: 'UUIDV4'\n  })\n\n  // Completed migration\n  logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.17.3-fk-constraints.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This upward migration script changes foreign key constraints for the\n * libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')\n\n  const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)\n\n  // Disable foreign key constraints for the next sequence of operations\n  await execQuery(`PRAGMA foreign_keys = OFF;`)\n\n  try {\n    await execQuery(`BEGIN TRANSACTION;`)\n\n    logger.info('[2.17.3 migration] Updating libraryItems constraints')\n    const libraryItemsConstraints = [\n      { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },\n      { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }\n    ]\n    if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {\n      logger.info('[2.17.3 migration] Finished updating libraryItems constraints')\n    } else {\n      logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')\n    }\n\n    logger.info('[2.17.3 migration] Updating feeds constraints')\n    const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]\n    if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {\n      logger.info('[2.17.3 migration] Finished updating feeds constraints')\n    } else {\n      logger.info('[2.17.3 migration] No changes needed for feeds constraints')\n    }\n\n    if (await queryInterface.tableExists('mediaItemShares')) {\n      logger.info('[2.17.3 migration] Updating mediaItemShares constraints')\n      const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]\n      if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {\n        logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')\n      } else {\n        logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')\n      }\n    } else {\n      logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')\n    }\n\n    logger.info('[2.17.3 migration] Updating playbackSessions constraints')\n    const playbackSessionsConstraints = [\n      { field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },\n      { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },\n      { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }\n    ]\n    if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {\n      logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')\n    } else {\n      logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')\n    }\n\n    logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')\n    const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]\n    if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {\n      logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')\n    } else {\n      logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')\n    }\n\n    logger.info('[2.17.3 migration] Updating mediaProgresses constraints')\n    const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]\n    if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {\n      logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')\n    } else {\n      logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')\n    }\n\n    await execQuery(`COMMIT;`)\n  } catch (error) {\n    logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)\n    await execQuery(`ROLLBACK;`)\n  }\n\n  await execQuery(`PRAGMA foreign_keys = ON;`)\n\n  // Completed migration\n  logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')\n}\n\n/**\n * This downward migration script is a no-op.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')\n\n  // This migration is a no-op\n  logger.info('[2.17.3 migration] No action required for downgrade')\n\n  // Completed migration\n  logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')\n}\n\n/**\n * @typedef ConstraintUpdateObj\n * @property {string} field - The field to update\n * @property {string} onDelete - The onDelete constraint\n * @property {string} onUpdate - The onUpdate constraint\n */\n\n/**\n * @typedef SequelizeFKObj\n * @property {{ model: string, key: string }} references\n * @property {string} onDelete\n * @property {string} onUpdate\n */\n\n/**\n * @param {Object} fk - The foreign key object from PRAGMA foreign_key_list\n * @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize\n */\nconst formatFKsPragmaToSequelizeFK = (fk) => {\n  return {\n    references: {\n      model: fk.table,\n      key: fk.to\n    },\n    onDelete: fk['on_delete'],\n    onUpdate: fk['on_update']\n  }\n}\n\n/**\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {string} tableName\n * @param {ConstraintUpdateObj[]} constraints\n * @returns {Promise<Record<string, SequelizeFKObj>|null>}\n */\nasync function getUpdatedForeignKeys(queryInterface, tableName, constraints) {\n  const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)\n  const quotedTableName = queryInterface.quoteIdentifier(tableName)\n\n  const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)\n\n  let hasUpdates = false\n  const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {\n    const fk = formatFKsPragmaToSequelizeFK(curr)\n\n    const constraint = constraints.find((c) => c.field === curr.from)\n    if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {\n      fk.onDelete = constraint.onDelete\n      fk.onUpdate = constraint.onUpdate\n      hasUpdates = true\n    }\n\n    return { ...prev, [curr.from]: fk }\n  }, {})\n\n  return hasUpdates ? foreignKeysByColName : null\n}\n\n/**\n * Extends the Sequelize describeTable function to include the updated foreign key constraints\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {String} tableName\n * @param {Record<string, SequelizeFKObj>} updatedForeignKeys\n */\nasync function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {\n  const tableDescription = await queryInterface.describeTable(tableName)\n\n  const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {\n    let extendedAttributes = attributes\n\n    if (updatedForeignKeys[col]) {\n      extendedAttributes = {\n        ...extendedAttributes,\n        ...updatedForeignKeys[col]\n      }\n    }\n    return { ...prev, [col]: extendedAttributes }\n  }, {})\n\n  return tableDescriptionWithFks\n}\n\n/**\n * @see https://www.sqlite.org/lang_altertable.html#otheralter\n * @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {string} tableName\n * @param {ConstraintUpdateObj[]} constraints\n * @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise\n */\nasync function changeConstraints(queryInterface, tableName, constraints) {\n  const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)\n  if (!updatedForeignKeys) {\n    return false\n  }\n\n  const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)\n  const quotedTableName = queryInterface.quoteIdentifier(tableName)\n\n  const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`\n  const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)\n\n  try {\n    const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)\n\n    const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)\n\n    // Create the backup table\n    await queryInterface.createTable(backupTableName, attributes)\n\n    const attributeNames = Object.keys(attributes)\n      .map((attr) => queryInterface.quoteIdentifier(attr))\n      .join(', ')\n\n    // Copy all data from the target table to the backup table\n    await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)\n\n    // Drop the old (original) table\n    await queryInterface.dropTable(tableName)\n\n    // Rename the backup table to the original table's name\n    await queryInterface.renameTable(backupTableName, tableName)\n\n    // Validate that all foreign key constraints are correct\n    const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {\n      type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT\n    })\n\n    // There are foreign key violations, exit\n    if (result.length) {\n      return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)\n    }\n\n    return true\n  } catch (error) {\n    return Promise.reject(error)\n  }\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This upward migration adds an subfolder setting for OIDC redirect URIs.\n * It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.\n * IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),\n * so that future OIDC setups will use the default subfolder.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')\n\n  const serverSettings = await getServerSettings(queryInterface, logger)\n  if (serverSettings.authActiveAuthMethods?.includes('openid')) {\n    logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')\n    serverSettings.authOpenIDSubfolderForRedirectURLs = ''\n    await updateServerSettings(queryInterface, logger, serverSettings)\n  } else {\n    logger.info('[2.17.4 migration] OIDC is not enabled, no action required')\n  }\n\n  logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')\n}\n\n/**\n * This downward migration script removes the subfolder setting for OIDC redirect URIs.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')\n\n  // Remove the OIDC subfolder option from the server settings\n  const serverSettings = await getServerSettings(queryInterface, logger)\n  if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {\n    logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')\n    delete serverSettings.authOpenIDSubfolderForRedirectURLs\n    await updateServerSettings(queryInterface, logger, serverSettings)\n  } else {\n    logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')\n  }\n\n  logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')\n}\n\nasync function getServerSettings(queryInterface, logger) {\n  const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = \"server-settings\";')\n  if (!result[0].length) {\n    logger.error('[2.17.4 migration] Server settings not found')\n    throw new Error('Server settings not found')\n  }\n\n  let serverSettings = null\n  try {\n    serverSettings = JSON.parse(result[0][0].value)\n  } catch (error) {\n    logger.error('[2.17.4 migration] Error parsing server settings:', error)\n    throw error\n  }\n\n  return serverSettings\n}\n\nasync function updateServerSettings(queryInterface, logger, serverSettings) {\n  await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = \"server-settings\";', {\n    replacements: {\n      value: JSON.stringify(serverSettings)\n    }\n  })\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.17.5-remove-host-from-feed-urls.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.17.5'\nconst migrationName = `${migrationVersion}-remove-host-from-feed-urls`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n/**\n * This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)\n  await queryInterface.sequelize.query(`\n    UPDATE Feeds\n    SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),\n        imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),\n        siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');\n  `)\n  logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)\n\n  logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)\n  await queryInterface.sequelize.query(`\n    UPDATE FeedEpisodes\n      SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),\n          enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');\n  `)\n  logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)\n  await queryInterface.sequelize.query(`\n    UPDATE Feeds\n    SET feedUrl = COALESCE(serverAddress, '') || feedUrl,\n        imageUrl = COALESCE(serverAddress, '') || imageUrl,\n        siteUrl = COALESCE(serverAddress, '') || siteUrl;\n  `)\n  logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)\n\n  logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)\n  await queryInterface.sequelize.query(`\n    UPDATE FeedEpisodes\n      SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),\n          enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);\n  `)\n  logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.17.6-share-add-isdownloadable.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.17.6'\nconst migrationName = `${migrationVersion}-share-add-isdownloadable`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n/**\n * This migration script adds the isDownloadable column to the mediaItemShares table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  if (await queryInterface.tableExists('mediaItemShares')) {\n    const tableDescription = await queryInterface.describeTable('mediaItemShares')\n    if (!tableDescription.isDownloadable) {\n      logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)\n      await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {\n        type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,\n        defaultValue: false,\n        allowNull: false\n      })\n      logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)\n    } else {\n      logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)\n    }\n  } else {\n    logger.info(`${loggerPrefix} mediaItemShares table does not exist`)\n  }\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This migration script removes the isDownloadable column from the mediaItemShares table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  if (await queryInterface.tableExists('mediaItemShares')) {\n    const tableDescription = await queryInterface.describeTable('mediaItemShares')\n    if (tableDescription.isDownloadable) {\n      logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)\n      await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')\n      logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)\n    } else {\n      logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)\n    }\n  } else {\n    logger.info(`${loggerPrefix} mediaItemShares table does not exist`)\n  }\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.17.7-add-indices.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.17.7'\nconst migrationName = `${migrationVersion}-add-indices`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n/**\n * This upward migration adds some indices to the libraryItems and books tables to improve query performance\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])\n  await addIndex(queryInterface, logger, 'books', ['duration'])\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This downward migration script removes the indices added in the upward migration script\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size'])\n  await removeIndex(queryInterface, logger, 'books', ['duration'])\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\n/**\n * Utility function to add an index to a table. If the index already exists, it logs a message and continues.\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {import ('../Logger')} logger\n * @param {string} tableName\n * @param {string[]} columns\n */\nasync function addIndex(queryInterface, logger, tableName, columns) {\n  try {\n    logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table \"${tableName}\"`)\n    await queryInterface.addIndex(tableName, columns)\n    logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table \"${tableName}\"`)\n  } catch (error) {\n    if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {\n      logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table \"${tableName}\" already exists`)\n    } else {\n      throw error\n    }\n  }\n}\n\n/**\n * Utility function to remove an index from a table.\n * Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {import ('../Logger')} logger\n * @param {string} tableName\n * @param {string[]} columns\n */\nasync function removeIndex(queryInterface, logger, tableName, columns) {\n  logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table \"${tableName}\"`)\n  await queryInterface.removeIndex(tableName, columns)\n  logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table \"${tableName}\"`)\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.19.1-copy-title-to-library-items.js",
    "content": "const util = require('util')\n\n/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.19.1'\nconst migrationName = `${migrationVersion}-copy-title-to-library-items`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n/**\n * This upward migration adds a title column to the libraryItems table, copies the title from the book to the libraryItem,\n * and creates a new index on the title column. In addition it sets a trigger on the books table to update the title column\n * in the libraryItems table when a book is updated.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  await addColumn(queryInterface, logger, 'libraryItems', 'title', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true })\n  await copyColumn(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId')\n  await addTrigger(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId')\n  await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }])\n\n  await addColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true })\n  await copyColumn(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')\n  await addTrigger(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')\n  await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }])\n\n  await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt'])\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This downward migration script removes the title column from the libraryItems table, removes the trigger on the books table,\n * and removes the index on the title column.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'title'])\n  await removeTrigger(queryInterface, logger, 'libraryItems', 'title')\n  await removeColumn(queryInterface, logger, 'libraryItems', 'title')\n\n  await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'titleIgnorePrefix'])\n  await removeTrigger(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix')\n  await removeColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix')\n\n  await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt'])\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\n/**\n * Utility function to add an index to a table. If the index already z`exists, it logs a message and continues.\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {import ('../Logger')} logger\n * @param {string} tableName\n * @param {string[]} columns\n */\nasync function addIndex(queryInterface, logger, tableName, columns) {\n  const columnString = columns.map((column) => util.inspect(column)).join(', ')\n  const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`)\n  try {\n    logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}\"`)\n    await queryInterface.addIndex(tableName, columns)\n    logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}\"`)\n  } catch (error) {\n    if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {\n      logger.info(`${loggerPrefix} index [${columnString}] for table \"${tableName}\" already exists`)\n    } else {\n      throw error\n    }\n  }\n}\n\n/**\n * Utility function to remove an index from a table.\n * Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist.\n *\n * @param {import('sequelize').QueryInterface} queryInterface\n * @param {import ('../Logger')} logger\n * @param {string} tableName\n * @param {string[]} columns\n */\nasync function removeIndex(queryInterface, logger, tableName, columns) {\n  logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table \"${tableName}\"`)\n  await queryInterface.removeIndex(tableName, columns)\n  logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table \"${tableName}\"`)\n}\n\nasync function addColumn(queryInterface, logger, table, column, options) {\n  logger.info(`${loggerPrefix} adding column \"${column}\" to table \"${table}\"`)\n  const tableDescription = await queryInterface.describeTable(table)\n  if (!tableDescription[column]) {\n    await queryInterface.addColumn(table, column, options)\n    logger.info(`${loggerPrefix} added column \"${column}\" to table \"${table}\"`)\n  } else {\n    logger.info(`${loggerPrefix} column \"${column}\" already exists in table \"${table}\"`)\n  }\n}\n\nasync function removeColumn(queryInterface, logger, table, column) {\n  logger.info(`${loggerPrefix} removing column \"${column}\" from table \"${table}\"`)\n  await queryInterface.removeColumn(table, column)\n  logger.info(`${loggerPrefix} removed column \"${column}\" from table \"${table}\"`)\n}\n\nasync function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {\n  logger.info(`${loggerPrefix} copying column \"${sourceColumn}\" from table \"${sourceTable}\" to table \"${targetTable}\"`)\n  await queryInterface.sequelize.query(`\n    UPDATE ${targetTable}\n    SET ${targetColumn} = ${sourceTable}.${sourceColumn}\n    FROM ${sourceTable}\n    WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn}\n  `)\n  logger.info(`${loggerPrefix} copied column \"${sourceColumn}\" from table \"${sourceTable}\" to table \"${targetTable}\"`)\n}\n\nasync function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {\n  logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)\n  const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`)\n\n  await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n\n  await queryInterface.sequelize.query(`\n    CREATE TRIGGER ${triggerName}\n      AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}\n      FOR EACH ROW\n      BEGIN\n        UPDATE ${targetTable}\n          SET ${targetColumn} = NEW.${sourceColumn}\n        WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};\n      END;\n  `)\n  logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)\n}\n\nasync function removeTrigger(queryInterface, logger, targetTable, targetColumn) {\n  logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`)\n  const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`)\n  await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n  logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`)\n}\n\nfunction convertToSnakeCase(str) {\n  return str.replace(/([A-Z])/g, '_$1').toLowerCase()\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.19.4-improve-podcast-queries.js",
    "content": "const util = require('util')\n\n/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.19.4'\nconst migrationName = `${migrationVersion}-improve-podcast-queries`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n/**\n * This upward migration adds a numEpisodes column to the podcasts table and populates it.\n * It also adds a podcastId column to the mediaProgresses table and populates it.\n * It also copies the title and titleIgnorePrefix columns from the podcasts table to the libraryItems table,\n * and adds triggers to update them when the corresponding columns in the podcasts table are updated.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  // Add numEpisodes column to podcasts table\n  await addColumn(queryInterface, logger, 'podcasts', 'numEpisodes', { type: queryInterface.sequelize.Sequelize.INTEGER, allowNull: false, defaultValue: 0 })\n\n  // Populate numEpisodes column with the number of episodes for each podcast\n  await populateNumEpisodes(queryInterface, logger)\n\n  // Add podcastId column to mediaProgresses table\n  await addColumn(queryInterface, logger, 'mediaProgresses', 'podcastId', { type: queryInterface.sequelize.Sequelize.UUID, allowNull: true })\n\n  // Populate podcastId column with the podcastId for each mediaProgress\n  await populatePodcastId(queryInterface, logger)\n\n  // Copy title and titleIgnorePrefix columns from podcasts to libraryItems\n  await copyColumn(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')\n  await copyColumn(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')\n\n  // Add triggers to update title and titleIgnorePrefix in libraryItems\n  await addTrigger(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')\n  await addTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This downward migration removes the triggers on the podcasts table,\n * the numEpisodes column from the podcasts table, and the podcastId column from the mediaProgresses table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  // Remove triggers from libraryItems\n  await removeTrigger(queryInterface, logger, 'podcasts', 'title', 'libraryItems', 'title')\n  await removeTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'libraryItems', 'titleIgnorePrefix')\n\n  // Remove numEpisodes column from podcasts table\n  await removeColumn(queryInterface, logger, 'podcasts', 'numEpisodes')\n\n  // Remove podcastId column from mediaProgresses table\n  await removeColumn(queryInterface, logger, 'mediaProgresses', 'podcastId')\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\nasync function populateNumEpisodes(queryInterface, logger) {\n  logger.info(`${loggerPrefix} populating numEpisodes column in podcasts table`)\n  await queryInterface.sequelize.query(`\n    UPDATE podcasts\n    SET numEpisodes = (SELECT COUNT(*) FROM podcastEpisodes WHERE podcastEpisodes.podcastId = podcasts.id)\n  `)\n  logger.info(`${loggerPrefix} populated numEpisodes column in podcasts table`)\n}\n\nasync function populatePodcastId(queryInterface, logger) {\n  logger.info(`${loggerPrefix} populating podcastId column in mediaProgresses table`)\n  // bulk update podcastId to the podcastId of the podcastEpisode if the mediaItemType is podcastEpisode\n  await queryInterface.sequelize.query(`\n    UPDATE mediaProgresses\n    SET podcastId = (SELECT podcastId FROM podcastEpisodes WHERE podcastEpisodes.id = mediaProgresses.mediaItemId)\n    WHERE mediaItemType = 'podcastEpisode'\n  `)\n  logger.info(`${loggerPrefix} populated podcastId column in mediaProgresses table`)\n}\n\n/**\n * Utility function to add a column to a table. If the column already exists, it logs a message and continues.\n *\n * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @param {import('../Logger')} logger - a Logger object.\n * @param {string} table - the name of the table to add the column to.\n * @param {string} column - the name of the column to add.\n * @param {Object} options - the options for the column.\n */\nasync function addColumn(queryInterface, logger, table, column, options) {\n  logger.info(`${loggerPrefix} adding column \"${column}\" to table \"${table}\"`)\n  const tableDescription = await queryInterface.describeTable(table)\n  if (!tableDescription[column]) {\n    await queryInterface.addColumn(table, column, options)\n    logger.info(`${loggerPrefix} added column \"${column}\" to table \"${table}\"`)\n  } else {\n    logger.info(`${loggerPrefix} column \"${column}\" already exists in table \"${table}\"`)\n  }\n}\n\n/**\n * Utility function to remove a column from a table. If the column does not exist, it logs a message and continues.\n *\n * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @param {import('../Logger')} logger - a Logger object.\n * @param {string} table - the name of the table to remove the column from.\n * @param {string} column - the name of the column to remove.\n */\nasync function removeColumn(queryInterface, logger, table, column) {\n  logger.info(`${loggerPrefix} removing column \"${column}\" from table \"${table}\"`)\n  const tableDescription = await queryInterface.describeTable(table)\n  if (tableDescription[column]) {\n    await queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`)\n    logger.info(`${loggerPrefix} removed column \"${column}\" from table \"${table}\"`)\n  } else {\n    logger.info(`${loggerPrefix} column \"${column}\" does not exist in table \"${table}\"`)\n  }\n}\n\n/**\n * Utility function to add a trigger to update a column in a target table when a column in a source table is updated.\n * If the trigger already exists, it drops it and creates a new one.\n * sourceIdColumn and targetIdColumn are used to match the source and target rows.\n *\n * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @param {import('../Logger')} logger - a Logger object.\n * @param {string} sourceTable - the name of the source table.\n * @param {string} sourceColumn - the name of the column to update.\n * @param {string} sourceIdColumn - the name of the id column of the source table.\n * @param {string} targetTable - the name of the target table.\n * @param {string} targetColumn - the name of the column to update.\n * @param {string} targetIdColumn - the name of the id column of the target table.\n */\nasync function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {\n  logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)\n  const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`)\n\n  await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n\n  await queryInterface.sequelize.query(`\n    CREATE TRIGGER ${triggerName}\n      AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}\n      FOR EACH ROW\n      BEGIN\n        UPDATE ${targetTable}\n          SET ${targetColumn} = NEW.${sourceColumn}\n        WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};\n      END;\n  `)\n  logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)\n}\n\n/**\n * Utility function to remove an update trigger from a table.\n *\n * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @param {import('../Logger')} logger - a Logger object.\n * @param {string} sourceTable - the name of the source table.\n * @param {string} sourceColumn - the name of the column to update.\n * @param {string} targetTable - the name of the target table.\n * @param {string} targetColumn - the name of the column to update.\n */\nasync function removeTrigger(queryInterface, logger, sourceTable, sourceColumn, targetTable, targetColumn) {\n  logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`)\n  const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`)\n  await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n  logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`)\n}\n\n/**\n * Utility function to copy a column from a source table to a target table.\n * sourceIdColumn and targetIdColumn are used to match the source and target rows.\n *\n * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @param {import('../Logger')} logger - a Logger object.\n * @param {string} sourceTable - the name of the source table.\n * @param {string} sourceColumn - the name of the column to copy.\n * @param {string} sourceIdColumn - the name of the id column of the source table.\n * @param {string} targetTable - the name of the target table.\n * @param {string} targetColumn - the name of the column to copy to.\n * @param {string} targetIdColumn - the name of the id column of the target table.\n */\nasync function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {\n  logger.info(`${loggerPrefix} copying column \"${sourceColumn}\" from table \"${sourceTable}\" to table \"${targetTable}\"`)\n  await queryInterface.sequelize.query(`\n    UPDATE ${targetTable}\n    SET ${targetColumn} = ${sourceTable}.${sourceColumn}\n    FROM ${sourceTable}\n    WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn}\n  `)\n  logger.info(`${loggerPrefix} copied column \"${sourceColumn}\" from table \"${sourceTable}\" to table \"${targetTable}\"`)\n}\n\n/**\n * Utility function to convert a string to snake case, e.g. \"titleIgnorePrefix\" -> \"title_ignore_prefix\"\n *\n * @param {string} str - the string to convert to snake case.\n * @returns {string} - the string in snake case.\n */\nfunction convertToSnakeCase(str) {\n  return str.replace(/([A-Z])/g, '_$1').toLowerCase()\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.20.0-improve-author-sort-queries.js",
    "content": "const util = require('util')\nconst { Sequelize } = require('sequelize')\n\n/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.20.0'\nconst migrationName = `${migrationVersion}-improve-author-sort-queries`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n// Migration constants\nconst libraryItems = 'libraryItems'\nconst bookAuthors = 'bookAuthors'\nconst authors = 'authors'\nconst podcastEpisodes = 'podcastEpisodes'\nconst columns = [\n  { name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } },\n  { name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } }\n]\nconst authorsSort = `${bookAuthors}.createdAt ASC`\nconst columnNames = columns.map((column) => column.name).join(', ')\nconst columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ')\nconst authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId`\n\n/**\n * This upward migration adds an authorNames column to the libraryItems table and populates it.\n * It also creates triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated.\n * It also creates an index on the authorNames column.\n *\n * It also adds an index on publishedAt to the podcastEpisodes table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  const helper = new MigrationHelper(queryInterface, logger)\n\n  // Upwards migration script\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  // Add authorNames columns to libraryItems table\n  await helper.addColumns()\n\n  // Populate authorNames columns with the author names for each libraryItem\n  await helper.populateColumnsFromSource()\n\n  // Create triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated\n  await helper.addTriggers()\n\n  // Create indexes on the authorNames columns\n  await helper.addIndexes()\n\n  // Add index on publishedAt to the podcastEpisodes table\n  await helper.addIndex(podcastEpisodes, ['publishedAt'])\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This downward migration removes the authorNames column from the libraryItems table,\n * the triggers on the bookAuthors and authors tables, and the index on the authorNames column.\n *\n * It also removes the index on publishedAt from the podcastEpisodes table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  const helper = new MigrationHelper(queryInterface, logger)\n\n  // Remove triggers to update authorNames columns\n  await helper.removeTriggers()\n\n  // Remove index on publishedAt from the podcastEpisodes table\n  await helper.removeIndex(podcastEpisodes, ['publishedAt'])\n\n  // Remove indexes on the authorNames columns\n  await helper.removeIndexes()\n\n  // Remove authorNames columns from libraryItems table\n  await helper.removeColumns()\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\nclass MigrationHelper {\n  constructor(queryInterface, logger) {\n    this.queryInterface = queryInterface\n    this.logger = logger\n  }\n\n  async addColumn(table, column, options) {\n    this.logger.info(`${loggerPrefix} adding column \"${column}\" to table \"${table}\"`)\n    const tableDescription = await this.queryInterface.describeTable(table)\n    if (!tableDescription[column]) {\n      await this.queryInterface.addColumn(table, column, options)\n      this.logger.info(`${loggerPrefix} added column \"${column}\" to table \"${table}\"`)\n    } else {\n      this.logger.info(`${loggerPrefix} column \"${column}\" already exists in table \"${table}\"`)\n    }\n  }\n\n  async addColumns() {\n    this.logger.info(`${loggerPrefix} adding ${columnNames} columns to ${libraryItems} table`)\n    for (const column of columns) {\n      await this.addColumn(libraryItems, column.name, column.spec)\n    }\n    this.logger.info(`${loggerPrefix} added ${columnNames} columns to ${libraryItems} table`)\n  }\n\n  async removeColumn(table, column) {\n    this.logger.info(`${loggerPrefix} removing column \"${column}\" from table \"${table}\"`)\n    const tableDescription = await this.queryInterface.describeTable(table)\n    if (tableDescription[column]) {\n      await this.queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`)\n      this.logger.info(`${loggerPrefix} removed column \"${column}\" from table \"${table}\"`)\n    } else {\n      this.logger.info(`${loggerPrefix} column \"${column}\" does not exist in table \"${table}\"`)\n    }\n  }\n\n  async removeColumns() {\n    this.logger.info(`${loggerPrefix} removing ${columnNames} columns from ${libraryItems} table`)\n    for (const column of columns) {\n      await this.removeColumn(libraryItems, column.name)\n    }\n    this.logger.info(`${loggerPrefix} removed ${columnNames} columns from ${libraryItems} table`)\n  }\n\n  async populateColumnsFromSource() {\n    this.logger.info(`${loggerPrefix} populating ${columnNames} columns in ${libraryItems} table`)\n    const authorNamesSubQuery = `\n      SELECT ${columnSourcesExpression}\n      FROM ${authorsJoin}\n      WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId\n    `\n    await this.queryInterface.sequelize.query(`\n      UPDATE ${libraryItems}\n        SET (${columnNames}) = (${authorNamesSubQuery})\n      WHERE mediaType = 'book';\n    `)\n    this.logger.info(`${loggerPrefix} populated ${columnNames} columns in ${libraryItems} table`)\n  }\n\n  async addBookAuthorsTrigger(action) {\n    this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)\n    const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW'\n    const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`)\n    const authorNamesSubQuery = `\n      SELECT ${columnSourcesExpression}\n      FROM ${authorsJoin}\n      WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId\n    `\n    await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n\n    await this.queryInterface.sequelize.query(`\n      CREATE TRIGGER ${triggerName}\n        AFTER ${action} ON ${bookAuthors}\n        FOR EACH ROW\n        BEGIN\n          UPDATE ${libraryItems}\n            SET (${columnNames}) = (${authorNamesSubQuery})\n          WHERE mediaId = ${modifiedRecord}.bookId;\n        END;\n    `)\n    this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)\n  }\n\n  async addAuthorsUpdateTrigger() {\n    this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${authors} update`)\n    const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`)\n    const authorNamesSubQuery = `\n      SELECT ${columnSourcesExpression}\n      FROM ${authorsJoin}\n      WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId\n    `\n\n    await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n\n    await this.queryInterface.sequelize.query(`\n      CREATE TRIGGER ${triggerName}\n        AFTER UPDATE OF name ON ${authors}\n        FOR EACH ROW\n        BEGIN\n          UPDATE ${libraryItems}\n            SET (${columnNames}) = (${authorNamesSubQuery})\n          WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id);\n      END;\n  `)\n    this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${authors} update`)\n  }\n\n  async addTriggers() {\n    await this.addBookAuthorsTrigger('insert')\n    await this.addBookAuthorsTrigger('delete')\n    await this.addAuthorsUpdateTrigger()\n  }\n\n  async removeBookAuthorsTrigger(action) {\n    this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)\n    const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`)\n    await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n    this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`)\n  }\n\n  async removeAuthorsUpdateTrigger() {\n    this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${authors} update`)\n    const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`)\n    await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)\n    this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${authors} update`)\n  }\n\n  async removeTriggers() {\n    await this.removeBookAuthorsTrigger('insert')\n    await this.removeBookAuthorsTrigger('delete')\n    await this.removeAuthorsUpdateTrigger()\n  }\n\n  async addIndex(tableName, columns) {\n    const columnString = columns.map((column) => util.inspect(column)).join(', ')\n    const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`)\n    try {\n      this.logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}\"`)\n      await this.queryInterface.addIndex(tableName, columns)\n      this.logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}\"`)\n    } catch (error) {\n      if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) {\n        this.logger.info(`${loggerPrefix} index [${columnString}] for table \"${tableName}\" already exists`)\n      } else {\n        throw error\n      }\n    }\n  }\n\n  async addIndexes() {\n    for (const column of columns) {\n      await this.addIndex(libraryItems, ['libraryId', 'mediaType', { name: column.name, collate: 'NOCASE' }])\n    }\n  }\n\n  async removeIndex(tableName, columns) {\n    this.logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table \"${tableName}\"`)\n    await this.queryInterface.removeIndex(tableName, columns)\n    this.logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table \"${tableName}\"`)\n  }\n\n  async removeIndexes() {\n    for (const column of columns) {\n      await this.removeIndex(libraryItems, ['libraryId', 'mediaType', column.name])\n    }\n  }\n}\n/**\n * Utility function to convert a string to snake case, e.g. \"titleIgnorePrefix\" -> \"title_ignore_prefix\"\n *\n * @param {string} str - the string to convert to snake case.\n * @returns {string} - the string in snake case.\n */\nfunction convertToSnakeCase(str) {\n  return str.replace(/([A-Z])/g, '_$1').toLowerCase()\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.26.0-create-auth-tables.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\nconst migrationVersion = '2.26.0'\nconst migrationName = `${migrationVersion}-create-auth-tables`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\n/**\n * This upward migration creates a sessions table and apiKeys table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  // Upwards migration script\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  // Check if table exists\n  if (await queryInterface.tableExists('sessions')) {\n    logger.info(`${loggerPrefix} table \"sessions\" already exists`)\n  } else {\n    // Create table\n    logger.info(`${loggerPrefix} creating table \"sessions\"`)\n    const DataTypes = queryInterface.sequelize.Sequelize.DataTypes\n    await queryInterface.createTable('sessions', {\n      id: {\n        type: DataTypes.UUID,\n        defaultValue: DataTypes.UUIDV4,\n        primaryKey: true\n      },\n      ipAddress: DataTypes.STRING,\n      userAgent: DataTypes.STRING,\n      refreshToken: {\n        type: DataTypes.STRING,\n        allowNull: false\n      },\n      expiresAt: {\n        type: DataTypes.DATE,\n        allowNull: false\n      },\n      createdAt: {\n        type: DataTypes.DATE,\n        allowNull: false\n      },\n      updatedAt: {\n        type: DataTypes.DATE,\n        allowNull: false\n      },\n      userId: {\n        type: DataTypes.UUID,\n        references: {\n          model: {\n            tableName: 'users'\n          },\n          key: 'id'\n        },\n        allowNull: false,\n        onDelete: 'CASCADE'\n      }\n    })\n    logger.info(`${loggerPrefix} created table \"sessions\"`)\n  }\n\n  // Check if table exists\n  if (await queryInterface.tableExists('apiKeys')) {\n    logger.info(`${loggerPrefix} table \"apiKeys\" already exists`)\n  } else {\n    // Create table\n    logger.info(`${loggerPrefix} creating table \"apiKeys\"`)\n    const DataTypes = queryInterface.sequelize.Sequelize.DataTypes\n    await queryInterface.createTable('apiKeys', {\n      id: {\n        type: DataTypes.UUID,\n        defaultValue: DataTypes.UUIDV4,\n        primaryKey: true\n      },\n      name: {\n        type: DataTypes.STRING,\n        allowNull: false\n      },\n      description: DataTypes.TEXT,\n      expiresAt: DataTypes.DATE,\n      lastUsedAt: DataTypes.DATE,\n      isActive: {\n        type: DataTypes.BOOLEAN,\n        allowNull: false,\n        defaultValue: false\n      },\n      permissions: DataTypes.JSON,\n      createdAt: {\n        type: DataTypes.DATE,\n        allowNull: false\n      },\n      updatedAt: {\n        type: DataTypes.DATE,\n        allowNull: false\n      },\n      userId: {\n        type: DataTypes.UUID,\n        references: {\n          model: {\n            tableName: 'users'\n          },\n          key: 'id'\n        },\n        onDelete: 'CASCADE'\n      },\n      createdByUserId: {\n        type: DataTypes.UUID,\n        references: {\n          model: {\n            tableName: 'users',\n            as: 'createdByUser'\n          },\n          key: 'id'\n        },\n        onDelete: 'SET NULL'\n      }\n    })\n    logger.info(`${loggerPrefix} created table \"apiKeys\"`)\n  }\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\n/**\n * This downward migration script removes the sessions table and apiKeys table.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  // Downward migration script\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  // Check if table exists\n  if (await queryInterface.tableExists('sessions')) {\n    logger.info(`${loggerPrefix} dropping table \"sessions\"`)\n    // Drop table\n    await queryInterface.dropTable('sessions')\n    logger.info(`${loggerPrefix} dropped table \"sessions\"`)\n  } else {\n    logger.info(`${loggerPrefix} table \"sessions\" does not exist`)\n  }\n\n  if (await queryInterface.tableExists('apiKeys')) {\n    logger.info(`${loggerPrefix} dropping table \"apiKeys\"`)\n    await queryInterface.dropTable('apiKeys')\n    logger.info(`${loggerPrefix} dropped table \"apiKeys\"`)\n  } else {\n    logger.info(`${loggerPrefix} table \"apiKeys\" does not exist`)\n  }\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/migrations/v2.33.0-add-discover-query-indexes.js",
    "content": "/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface\n * @property {import('../Logger')} logger\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context\n */\n\nconst migrationVersion = '2.33.0'\nconst migrationName = `${migrationVersion}-add-discover-query-indexes`\nconst loggerPrefix = `[${migrationVersion} migration]`\n\nconst indexes = [\n  {\n    table: 'mediaProgresses',\n    name: 'media_progresses_user_item_finished_time',\n    fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime']\n  },\n  {\n    table: 'bookSeries',\n    name: 'book_series_series_book',\n    fields: ['seriesId', 'bookId']\n  }\n]\n\nasync function up({ context: { queryInterface, logger } }) {\n  logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)\n\n  for (const index of indexes) {\n    await addIndexIfMissing(queryInterface, logger, index)\n  }\n\n  logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)\n}\n\nasync function down({ context: { queryInterface, logger } }) {\n  logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)\n\n  for (const index of indexes) {\n    await removeIndexIfExists(queryInterface, logger, index)\n  }\n\n  logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)\n}\n\nasync function addIndexIfMissing(queryInterface, logger, index) {\n  const existing = await queryInterface.showIndex(index.table)\n  if (existing.some((i) => i.name === index.name)) {\n    logger.info(`${loggerPrefix} index ${index.name} already exists on ${index.table}`)\n    return\n  }\n\n  logger.info(`${loggerPrefix} adding index ${index.name} on ${index.table}(${index.fields.join(', ')})`)\n  await queryInterface.addIndex(index.table, {\n    name: index.name,\n    fields: index.fields\n  })\n  logger.info(`${loggerPrefix} added index ${index.name}`)\n}\n\nasync function removeIndexIfExists(queryInterface, logger, index) {\n  const existing = await queryInterface.showIndex(index.table)\n  if (!existing.some((i) => i.name === index.name)) {\n    logger.info(`${loggerPrefix} index ${index.name} does not exist on ${index.table}`)\n    return\n  }\n\n  logger.info(`${loggerPrefix} removing index ${index.name}`)\n  await queryInterface.removeIndex(index.table, index.name)\n  logger.info(`${loggerPrefix} removed index ${index.name}`)\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "server/models/ApiKey.js",
    "content": "const { DataTypes, Model, Op } = require('sequelize')\nconst jwt = require('jsonwebtoken')\nconst { LRUCache } = require('lru-cache')\nconst Logger = require('../Logger')\n\n/**\n * @typedef {Object} ApiKeyPermissions\n * @property {boolean} download\n * @property {boolean} update\n * @property {boolean} delete\n * @property {boolean} upload\n * @property {boolean} createEreader\n * @property {boolean} accessAllLibraries\n * @property {boolean} accessAllTags\n * @property {boolean} accessExplicitContent\n * @property {boolean} selectedTagsNotAccessible\n * @property {string[]} librariesAccessible\n * @property {string[]} itemTagsSelected\n */\n\nclass ApiKeyCache {\n  constructor() {\n    this.cache = new LRUCache({ max: 100 })\n  }\n\n  getById(id) {\n    const apiKey = this.cache.get(id)\n    return apiKey\n  }\n\n  set(apiKey) {\n    apiKey.fromCache = true\n    this.cache.set(apiKey.id, apiKey)\n  }\n\n  delete(apiKeyId) {\n    this.cache.delete(apiKeyId)\n  }\n\n  maybeInvalidate(apiKey) {\n    if (!apiKey.fromCache) this.delete(apiKey.id)\n  }\n}\n\nconst apiKeyCache = new ApiKeyCache()\n\nclass ApiKey extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.name\n    /** @type {string} */\n    this.description\n    /** @type {Date} */\n    this.expiresAt\n    /** @type {Date} */\n    this.lastUsedAt\n    /** @type {boolean} */\n    this.isActive\n    /** @type {ApiKeyPermissions} */\n    this.permissions\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {UUIDV4} */\n    this.createdByUserId\n\n    // Expanded properties\n\n    /** @type {import('./User').User} */\n    this.user\n  }\n\n  /**\n   * Same properties as User.getDefaultPermissions\n   * @returns {ApiKeyPermissions}\n   */\n  static getDefaultPermissions() {\n    return {\n      download: true,\n      update: true,\n      delete: true,\n      upload: true,\n      createEreader: true,\n      accessAllLibraries: true,\n      accessAllTags: true,\n      accessExplicitContent: true,\n      selectedTagsNotAccessible: false, // Inverts itemTagsSelected\n      librariesAccessible: [],\n      itemTagsSelected: []\n    }\n  }\n\n  /**\n   * Merge permissions from request with default permissions\n   * @param {ApiKeyPermissions} reqPermissions\n   * @returns {ApiKeyPermissions}\n   */\n  static mergePermissionsWithDefault(reqPermissions) {\n    const permissions = this.getDefaultPermissions()\n\n    if (!reqPermissions || typeof reqPermissions !== 'object') {\n      Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`)\n      return permissions\n    }\n\n    for (const key in reqPermissions) {\n      if (reqPermissions[key] === undefined) {\n        Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`)\n        continue\n      }\n\n      if (key === 'librariesAccessible' || key === 'itemTagsSelected') {\n        if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) {\n          Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`)\n          continue\n        }\n\n        permissions[key] = reqPermissions[key]\n      } else if (typeof reqPermissions[key] !== 'boolean') {\n        Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`)\n        continue\n      }\n\n      permissions[key] = reqPermissions[key]\n    }\n\n    return permissions\n  }\n\n  /**\n   * Deactivate expired api keys\n   * @returns {Promise<number>} Number of api keys affected\n   */\n  static async deactivateExpiredApiKeys() {\n    const [affectedCount] = await ApiKey.update(\n      {\n        isActive: false\n      },\n      {\n        where: {\n          isActive: true,\n          expiresAt: {\n            [Op.lt]: new Date()\n          }\n        }\n      }\n    )\n    return affectedCount\n  }\n\n  /**\n   * Generate a new api key\n   * @param {string} tokenSecret\n   * @param {string} keyId\n   * @param {string} name\n   * @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration\n   * @returns {Promise<string>}\n   */\n  static async generateApiKey(tokenSecret, keyId, name, expiresIn) {\n    const options = {}\n    if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {\n      options.expiresIn = expiresIn\n    }\n\n    return new Promise((resolve) => {\n      jwt.sign(\n        {\n          keyId,\n          name,\n          type: 'api'\n        },\n        tokenSecret,\n        options,\n        (err, token) => {\n          if (err) {\n            Logger.error(`[ApiKey] Error generating API key: ${err}`)\n            resolve(null)\n          } else {\n            resolve(token)\n          }\n        }\n      )\n    })\n  }\n\n  /**\n   * Get an api key by id, from cache or database\n   * @param {string} apiKeyId\n   * @returns {Promise<ApiKey | null>}\n   */\n  static async getById(apiKeyId) {\n    if (!apiKeyId) return null\n\n    const cachedApiKey = apiKeyCache.getById(apiKeyId)\n    if (cachedApiKey) return cachedApiKey\n\n    const apiKey = await ApiKey.findByPk(apiKeyId)\n    if (!apiKey) return null\n\n    apiKeyCache.set(apiKey)\n    return apiKey\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: {\n          type: DataTypes.STRING,\n          allowNull: false\n        },\n        description: DataTypes.TEXT,\n        expiresAt: DataTypes.DATE,\n        lastUsedAt: DataTypes.DATE,\n        isActive: {\n          type: DataTypes.BOOLEAN,\n          allowNull: false,\n          defaultValue: false\n        },\n        permissions: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'apiKey'\n      }\n    )\n\n    const { user } = sequelize.models\n    user.hasMany(ApiKey, {\n      onDelete: 'CASCADE'\n    })\n    ApiKey.belongsTo(user)\n\n    user.hasMany(ApiKey, {\n      foreignKey: 'createdByUserId',\n      onDelete: 'SET NULL'\n    })\n    ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })\n  }\n\n  async update(values, options) {\n    apiKeyCache.maybeInvalidate(this)\n    return await super.update(values, options)\n  }\n\n  async save(options) {\n    apiKeyCache.maybeInvalidate(this)\n    return await super.save(options)\n  }\n\n  async destroy(options) {\n    apiKeyCache.delete(this.id)\n    await super.destroy(options)\n  }\n}\n\nmodule.exports = ApiKey\n"
  },
  {
    "path": "server/models/Author.js",
    "content": "const { DataTypes, Model, where, fn, col } = require('sequelize')\nconst parseNameString = require('../utils/parsers/parseNameString')\n\nclass Author extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.name\n    /** @type {string} */\n    this.lastFirst\n    /** @type {string} */\n    this.asin\n    /** @type {string} */\n    this.description\n    /** @type {string} */\n    this.imagePath\n    /** @type {UUIDV4} */\n    this.libraryId\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {Date} */\n    this.createdAt\n  }\n\n  /**\n   *\n   * @param {string} name\n   * @returns {string}\n   */\n  static getLastFirst(name) {\n    if (!name) return null\n    return parseNameString.nameToLastFirst(name)\n  }\n\n  /**\n   * Check if author exists\n   * @param {string} authorId\n   * @returns {Promise<boolean>}\n   */\n  static async checkExistsById(authorId) {\n    return (await this.count({ where: { id: authorId } })) > 0\n  }\n\n  /**\n   * Get author by name and libraryId. name case insensitive\n   * TODO: Look for authors ignoring punctuation\n   *\n   * @param {string} authorName\n   * @param {string} libraryId\n   * @returns {Promise<Author>}\n   */\n  static async getByNameAndLibrary(authorName, libraryId) {\n    return this.findOne({\n      where: [\n        where(fn('lower', col('name')), authorName.toLowerCase()),\n        {\n          libraryId\n        }\n      ]\n    })\n  }\n\n  /**\n   *\n   * @param {string} authorId\n   * @returns {Promise<import('./LibraryItem')[]>}\n   */\n  static async getAllLibraryItemsForAuthor(authorId) {\n    const author = await this.findByPk(authorId, {\n      include: [\n        {\n          model: this.sequelize.models.book,\n          include: [\n            {\n              model: this.sequelize.models.libraryItem\n            },\n            {\n              model: this.sequelize.models.author,\n              through: {\n                attributes: []\n              }\n            },\n            {\n              model: this.sequelize.models.series,\n              through: {\n                attributes: ['sequence']\n              }\n            }\n          ]\n        }\n      ]\n    })\n\n    const libraryItems = []\n    if (author.books) {\n      for (const book of author.books) {\n        const libraryItem = book.libraryItem\n        libraryItem.media = book\n        delete book.libraryItem\n        libraryItems.push(libraryItem)\n      }\n    }\n\n    return libraryItems\n  }\n\n  /**\n   *\n   * @param {string} name\n   * @param {string} libraryId\n   * @returns {Promise<Author>}\n   */\n  static async findOrCreateByNameAndLibrary(name, libraryId) {\n    const author = await this.getByNameAndLibrary(name, libraryId)\n    if (author) return author\n    return this.create({\n      name,\n      lastFirst: this.getLastFirst(name),\n      libraryId\n    })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: DataTypes.STRING,\n        lastFirst: DataTypes.STRING,\n        asin: DataTypes.STRING,\n        description: DataTypes.TEXT,\n        imagePath: DataTypes.STRING\n      },\n      {\n        sequelize,\n        modelName: 'author',\n        indexes: [\n          {\n            fields: [\n              {\n                name: 'name',\n                collate: 'NOCASE'\n              }\n            ]\n          },\n          // {\n          //   fields: [{\n          //     name: 'lastFirst',\n          //     collate: 'NOCASE'\n          //   }]\n          // },\n          {\n            fields: ['libraryId']\n          }\n        ]\n      }\n    )\n\n    const { library } = sequelize.models\n    library.hasMany(Author, {\n      onDelete: 'CASCADE'\n    })\n    Author.belongsTo(library)\n  }\n\n  toOldJSON() {\n    return {\n      id: this.id,\n      asin: this.asin,\n      name: this.name,\n      description: this.description,\n      imagePath: this.imagePath,\n      libraryId: this.libraryId,\n      addedAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf()\n    }\n  }\n\n  /**\n   *\n   * @param {number} numBooks\n   * @returns\n   */\n  toOldJSONExpanded(numBooks = 0) {\n    const oldJson = this.toOldJSON()\n    oldJson.numBooks = numBooks\n    return oldJson\n  }\n\n  toJSONMinimal() {\n    return {\n      id: this.id,\n      name: this.name\n    }\n  }\n}\nmodule.exports = Author\n"
  },
  {
    "path": "server/models/Book.js",
    "content": "const { DataTypes, Model } = require('sequelize')\nconst Logger = require('../Logger')\nconst { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')\nconst parseNameString = require('../utils/parsers/parseNameString')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\nconst libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')\n\n/**\n * @typedef EBookFileObject\n * @property {string} ino\n * @property {string} ebookFormat\n * @property {number} addedAt\n * @property {number} updatedAt\n * @property {{filename:string, ext:string, path:string, relPath:strFing, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata\n */\n\n/**\n * @typedef ChapterObject\n * @property {number} id\n * @property {number} start\n * @property {number} end\n * @property {string} title\n */\n\n/**\n * @typedef SeriesExpandedProperties\n * @property {{sequence:string}} bookSeries\n *\n * @typedef {import('./Series') & SeriesExpandedProperties} SeriesExpanded\n *\n * @typedef BookExpandedProperties\n * @property {import('./Author')[]} authors\n * @property {SeriesExpanded[]} series\n *\n * @typedef {Book & BookExpandedProperties} BookExpanded\n *\n * Collections use BookExpandedWithLibraryItem\n * @typedef BookExpandedWithLibraryItemProperties\n * @property {import('./LibraryItem')} libraryItem\n *\n * @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem\n */\n\n/**\n * @typedef AudioFileObject\n * @property {number} index\n * @property {string} ino\n * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata\n * @property {number} addedAt\n * @property {number} updatedAt\n * @property {number} trackNumFromMeta\n * @property {number} discNumFromMeta\n * @property {number} trackNumFromFilename\n * @property {number} discNumFromFilename\n * @property {boolean} manuallyVerified\n * @property {string} format\n * @property {number} duration\n * @property {number} bitRate\n * @property {string} language\n * @property {string} codec\n * @property {string} timeBase\n * @property {number} channels\n * @property {string} channelLayout\n * @property {ChapterObject[]} chapters\n * @property {Object} metaTags\n * @property {string} mimeType\n *\n * @typedef AudioTrackProperties\n * @property {string} title\n * @property {string} contentUrl\n * @property {number} startOffset\n *\n * @typedef {AudioFileObject & AudioTrackProperties} AudioTrack\n */\n\nclass Book extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {string} */\n    this.id\n    /** @type {string} */\n    this.title\n    /** @type {string} */\n    this.titleIgnorePrefix\n    /** @type {string} */\n    this.subtitle\n    /** @type {string} */\n    this.publishedYear\n    /** @type {string} */\n    this.publishedDate\n    /** @type {string} */\n    this.publisher\n    /** @type {string} */\n    this.description\n    /** @type {string} */\n    this.isbn\n    /** @type {string} */\n    this.asin\n    /** @type {string} */\n    this.language\n    /** @type {boolean} */\n    this.explicit\n    /** @type {boolean} */\n    this.abridged\n    /** @type {string} */\n    this.coverPath\n    /** @type {number} */\n    this.duration\n    /** @type {string[]} */\n    this.narrators\n    /** @type {AudioFileObject[]} */\n    this.audioFiles\n    /** @type {EBookFileObject} */\n    this.ebookFile\n    /** @type {ChapterObject[]} */\n    this.chapters\n    /** @type {string[]} */\n    this.tags\n    /** @type {string[]} */\n    this.genres\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {Date} */\n    this.createdAt\n\n    // Expanded properties\n\n    /** @type {import('./Author')[]} - optional if expanded */\n    this.authors\n    /** @type {import('./Series')[]} - optional if expanded */\n    this.series\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        title: DataTypes.STRING,\n        titleIgnorePrefix: DataTypes.STRING,\n        subtitle: DataTypes.STRING,\n        publishedYear: DataTypes.STRING,\n        publishedDate: DataTypes.STRING,\n        publisher: DataTypes.STRING,\n        description: DataTypes.TEXT,\n        isbn: DataTypes.STRING,\n        asin: DataTypes.STRING,\n        language: DataTypes.STRING,\n        explicit: DataTypes.BOOLEAN,\n        abridged: DataTypes.BOOLEAN,\n        coverPath: DataTypes.STRING,\n        duration: DataTypes.FLOAT,\n\n        narrators: DataTypes.JSON,\n        audioFiles: DataTypes.JSON,\n        ebookFile: DataTypes.JSON,\n        chapters: DataTypes.JSON,\n        tags: DataTypes.JSON,\n        genres: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'book',\n        indexes: [\n          {\n            fields: [\n              {\n                name: 'title',\n                collate: 'NOCASE'\n              }\n            ]\n          },\n          // {\n          //   fields: [{\n          //     name: 'titleIgnorePrefix',\n          //     collate: 'NOCASE'\n          //   }]\n          // },\n          {\n            fields: ['publishedYear']\n          },\n          {\n            fields: ['duration']\n          }\n        ]\n      }\n    )\n\n    Book.addHook('afterDestroy', async (instance) => {\n      libraryItemsBookFilters.clearCountCache('afterDestroy')\n    })\n\n    Book.addHook('afterCreate', async (instance) => {\n      libraryItemsBookFilters.clearCountCache('afterCreate')\n    })\n  }\n\n  /**\n   * Comma separated array of author names\n   * Requires authors to be loaded\n   *\n   * @returns {string}\n   */\n  get authorName() {\n    if (this.authors === undefined) {\n      Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)\n      return ''\n    }\n    return this.authors.map((au) => au.name).join(', ')\n  }\n\n  /**\n   * Comma separated array of author names in Last, First format\n   * Requires authors to be loaded\n   *\n   * @returns {string}\n   */\n  get authorNameLF() {\n    if (this.authors === undefined) {\n      Logger.error(`[Book] authorNameLF: Cannot get authorNameLF because authors are not loaded`)\n      return ''\n    }\n\n    // Last, First\n    if (!this.authors.length) return ''\n    return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')\n  }\n\n  /**\n   * Comma separated array of series with sequence\n   * Requires series to be loaded\n   *\n   * @returns {string}\n   */\n  get seriesName() {\n    if (this.series === undefined) {\n      Logger.error(`[Book] seriesName: Cannot get seriesName because series are not loaded`)\n      return ''\n    }\n\n    if (!this.series.length) return ''\n    return this.series\n      .map((se) => {\n        const sequence = se.bookSeries?.sequence || ''\n        if (!sequence) return se.name\n        return `${se.name} #${sequence}`\n      })\n      .join(', ')\n  }\n\n  get includedAudioFiles() {\n    return this.audioFiles.filter((af) => !af.exclude)\n  }\n\n  get hasMediaFiles() {\n    return !!this.hasAudioTracks || !!this.ebookFile\n  }\n\n  get hasAudioTracks() {\n    return !!this.includedAudioFiles.length\n  }\n\n  /**\n   * Supported mime types are sent from the web client and are retrieved using the browser Audio player \"canPlayType\" function.\n   *\n   * @param {string[]} supportedMimeTypes\n   * @returns {boolean}\n   */\n  checkCanDirectPlay(supportedMimeTypes) {\n    if (!Array.isArray(supportedMimeTypes)) {\n      Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)\n      return false\n    }\n    return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType))\n  }\n\n  /**\n   * Get the track list to be used in client audio players\n   * AudioTrack is the AudioFile with startOffset, contentUrl and title\n   *\n   * @param {string} libraryItemId\n   * @returns {AudioTrack[]}\n   */\n  getTracklist(libraryItemId) {\n    let startOffset = 0\n    return this.includedAudioFiles.map((af) => {\n      const track = structuredClone(af)\n      track.title = af.metadata.filename\n      track.startOffset = startOffset\n      track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}`\n      startOffset += track.duration\n      return track\n    })\n  }\n\n  /**\n   *\n   * @returns {ChapterObject[]}\n   */\n  getChapters() {\n    return structuredClone(this.chapters) || []\n  }\n\n  getPlaybackTitle() {\n    return this.title\n  }\n\n  getPlaybackAuthor() {\n    return this.authorName\n  }\n\n  getPlaybackDuration() {\n    return this.duration\n  }\n\n  /**\n   * Total file size of all audio files and ebook file\n   *\n   * @returns {number}\n   */\n  get size() {\n    let total = 0\n    this.audioFiles.forEach((af) => (total += af.metadata.size))\n    if (this.ebookFile) {\n      total += this.ebookFile.metadata.size\n    }\n    return total\n  }\n\n  getAbsMetadataJson() {\n    return {\n      tags: this.tags || [],\n      chapters: this.chapters?.map((c) => ({ ...c })) || [],\n      title: this.title,\n      subtitle: this.subtitle,\n      authors: this.authors.map((a) => a.name),\n      narrators: this.narrators,\n      series: this.series.map((se) => {\n        const sequence = se.bookSeries?.sequence || ''\n        if (!sequence) return se.name\n        return `${se.name} #${sequence}`\n      }),\n      genres: this.genres || [],\n      publishedYear: this.publishedYear,\n      publishedDate: this.publishedDate,\n      publisher: this.publisher,\n      description: this.description,\n      isbn: this.isbn,\n      asin: this.asin,\n      language: this.language,\n      explicit: !!this.explicit,\n      abridged: !!this.abridged\n    }\n  }\n\n  /**\n   *\n   * @param {Object} payload - old book object\n   * @returns {Promise<boolean>}\n   */\n  async updateFromRequest(payload) {\n    if (!payload) return false\n\n    let hasUpdates = false\n\n    if (payload.metadata) {\n      const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']\n      metadataStringKeys.forEach((key) => {\n        if (typeof payload.metadata[key] == 'number') {\n          payload.metadata[key] = String(payload.metadata[key])\n        }\n\n        if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) {\n          // Sanitize description HTML\n          if (key === 'description' && payload.metadata[key]) {\n            const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])\n            if (sanitizedDescription !== payload.metadata[key]) {\n              Logger.debug(`[Book] \"${this.title}\" Sanitized description from \"${payload.metadata[key]}\" to \"${sanitizedDescription}\"`)\n              payload.metadata[key] = sanitizedDescription\n            }\n          }\n\n          this[key] = payload.metadata[key] || null\n\n          if (key === 'title') {\n            this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)\n          }\n\n          hasUpdates = true\n        }\n      })\n      if (payload.metadata.explicit !== undefined && this.explicit !== !!payload.metadata.explicit) {\n        this.explicit = !!payload.metadata.explicit\n        hasUpdates = true\n      }\n      if (payload.metadata.abridged !== undefined && this.abridged !== !!payload.metadata.abridged) {\n        this.abridged = !!payload.metadata.abridged\n        hasUpdates = true\n      }\n      const arrayOfStringsKeys = ['narrators', 'genres']\n      arrayOfStringsKeys.forEach((key) => {\n        if (Array.isArray(payload.metadata[key]) && !payload.metadata[key].some((item) => typeof item !== 'string') && JSON.stringify(this[key]) !== JSON.stringify(payload.metadata[key])) {\n          this[key] = payload.metadata[key]\n          this.changed(key, true)\n          hasUpdates = true\n        }\n      })\n    }\n\n    if (Array.isArray(payload.tags) && !payload.tags.some((tag) => typeof tag !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {\n      this.tags = payload.tags\n      this.changed('tags', true)\n      hasUpdates = true\n    }\n\n    // TODO: Remove support for updating audioFiles, chapters and ebookFile here\n    const arrayOfObjectsKeys = ['audioFiles', 'chapters']\n    arrayOfObjectsKeys.forEach((key) => {\n      if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {\n        this[key] = payload[key]\n        this.changed(key, true)\n        hasUpdates = true\n      }\n    })\n    if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {\n      this.ebookFile = payload.ebookFile\n      this.changed('ebookFile', true)\n      hasUpdates = true\n    }\n\n    if (hasUpdates) {\n      Logger.debug(`[Book] \"${this.title}\" changed keys:`, this.changed())\n      await this.save()\n    }\n\n    return hasUpdates\n  }\n\n  /**\n   * Creates or removes authors from the book using the author names from the request\n   *\n   * @param {string[]} authors\n   * @param {string} libraryId\n   * @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>}\n   */\n  async updateAuthorsFromRequest(authors, libraryId) {\n    if (!Array.isArray(authors)) return null\n\n    if (!this.authors) {\n      throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)\n    }\n\n    /** @type {typeof import('./Author')} */\n    const authorModel = this.sequelize.models.author\n\n    /** @type {typeof import('./BookAuthor')} */\n    const bookAuthorModel = this.sequelize.models.bookAuthor\n\n    const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a)\n    const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase()))\n    const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase()))\n\n    for (const author of authorsRemoved) {\n      await bookAuthorModel.removeByIds(author.id, this.id)\n      Logger.debug(`[Book] \"${this.title}\" Removed author \"${author.name}\"`)\n      this.authors = this.authors.filter((au) => au.id !== author.id)\n    }\n    const authorsAdded = []\n    for (const authorName of newAuthorNames) {\n      const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)\n      await bookAuthorModel.create({ bookId: this.id, authorId: author.id })\n      Logger.debug(`[Book] \"${this.title}\" Added author \"${author.name}\"`)\n      this.authors.push(author)\n      authorsAdded.push(author)\n    }\n\n    return {\n      authorsRemoved,\n      authorsAdded\n    }\n  }\n\n  /**\n   * Creates or removes series from the book using the series names from the request.\n   * Updates series sequence if it has changed.\n   *\n   * @param {{ name: string, sequence: string }[]} seriesObjects\n   * @param {string} libraryId\n   * @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>}\n   */\n  async updateSeriesFromRequest(seriesObjects, libraryId) {\n    if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null\n\n    if (!this.series) {\n      throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`)\n    }\n\n    /** @type {typeof import('./Series')} */\n    const seriesModel = this.sequelize.models.series\n\n    /** @type {typeof import('./BookSeries')} */\n    const bookSeriesModel = this.sequelize.models.bookSeries\n\n    const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase())\n    const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase()))\n    const seriesAdded = []\n    let hasUpdates = false\n    for (const seriesObj of seriesObjects) {\n      const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null\n\n      const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase())\n      if (existingSeries) {\n        if (existingSeries.bookSeries.sequence !== seriesObjSequence) {\n          existingSeries.bookSeries.sequence = seriesObjSequence\n          await existingSeries.bookSeries.save()\n          hasUpdates = true\n          Logger.debug(`[Book] \"${this.title}\" Updated series \"${existingSeries.name}\" sequence ${seriesObjSequence}`)\n        }\n      } else {\n        const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)\n        series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })\n        this.series.push(series)\n        seriesAdded.push(series)\n        hasUpdates = true\n        Logger.debug(`[Book] \"${this.title}\" Added series \"${series.name}\"`)\n      }\n    }\n\n    for (const series of seriesRemoved) {\n      await bookSeriesModel.removeByIds(series.id, this.id)\n      this.series = this.series.filter((se) => se.id !== series.id)\n      Logger.debug(`[Book] \"${this.title}\" Removed series ${series.id}`)\n      hasUpdates = true\n    }\n\n    return {\n      seriesRemoved,\n      seriesAdded,\n      hasUpdates\n    }\n  }\n\n  /**\n   * Old model kept metadata in a separate object\n   */\n  oldMetadataToJSON() {\n    const authors = this.authors.map((au) => ({ id: au.id, name: au.name }))\n    const series = this.series.map((se) => ({ id: se.id, name: se.name, sequence: se.bookSeries.sequence }))\n    return {\n      title: this.title,\n      subtitle: this.subtitle,\n      authors,\n      narrators: [...(this.narrators || [])],\n      series,\n      genres: [...(this.genres || [])],\n      publishedYear: this.publishedYear,\n      publishedDate: this.publishedDate,\n      publisher: this.publisher,\n      description: this.description,\n      isbn: this.isbn,\n      asin: this.asin,\n      language: this.language,\n      explicit: this.explicit,\n      abridged: this.abridged\n    }\n  }\n\n  oldMetadataToJSONMinified() {\n    return {\n      title: this.title,\n      titleIgnorePrefix: getTitlePrefixAtEnd(this.title),\n      subtitle: this.subtitle,\n      authorName: this.authorName,\n      authorNameLF: this.authorNameLF,\n      narratorName: (this.narrators || []).join(', '),\n      seriesName: this.seriesName,\n      genres: [...(this.genres || [])],\n      publishedYear: this.publishedYear,\n      publishedDate: this.publishedDate,\n      publisher: this.publisher,\n      description: this.description,\n      isbn: this.isbn,\n      asin: this.asin,\n      language: this.language,\n      explicit: this.explicit,\n      abridged: this.abridged\n    }\n  }\n\n  oldMetadataToJSONExpanded() {\n    const oldMetadataJSON = this.oldMetadataToJSON()\n    oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)\n    oldMetadataJSON.authorName = this.authorName\n    oldMetadataJSON.authorNameLF = this.authorNameLF\n    oldMetadataJSON.narratorName = (this.narrators || []).join(', ')\n    oldMetadataJSON.seriesName = this.seriesName\n    oldMetadataJSON.descriptionPlain = this.description ? htmlSanitizer.stripAllTags(this.description) : null\n    return oldMetadataJSON\n  }\n\n  /**\n   * The old model stored a minified series and authors array with the book object.\n   * Minified series is { id, name, sequence }\n   * Minified author is { id, name }\n   *\n   * @param {string} libraryItemId\n   */\n  toOldJSON(libraryItemId) {\n    if (!libraryItemId) {\n      throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)\n    }\n    if (!this.authors) {\n      throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)\n    }\n    if (!this.series) {\n      throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)\n    }\n\n    return {\n      id: this.id,\n      libraryItemId: libraryItemId,\n      metadata: this.oldMetadataToJSON(),\n      coverPath: this.coverPath,\n      tags: [...(this.tags || [])],\n      audioFiles: structuredClone(this.audioFiles),\n      chapters: structuredClone(this.chapters),\n      ebookFile: structuredClone(this.ebookFile)\n    }\n  }\n\n  toOldJSONMinified() {\n    if (!this.authors) {\n      throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)\n    }\n    if (!this.series) {\n      throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)\n    }\n\n    return {\n      id: this.id,\n      metadata: this.oldMetadataToJSONMinified(),\n      coverPath: this.coverPath,\n      tags: [...(this.tags || [])],\n      numTracks: this.includedAudioFiles.length,\n      numAudioFiles: this.audioFiles?.length || 0,\n      numChapters: this.chapters?.length || 0,\n      duration: this.duration,\n      size: this.size,\n      ebookFormat: this.ebookFile?.ebookFormat\n    }\n  }\n\n  toOldJSONExpanded(libraryItemId) {\n    if (!libraryItemId) {\n      throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)\n    }\n    if (!this.authors) {\n      throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)\n    }\n    if (!this.series) {\n      throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)\n    }\n\n    return {\n      id: this.id,\n      libraryItemId: libraryItemId,\n      metadata: this.oldMetadataToJSONExpanded(),\n      coverPath: this.coverPath,\n      tags: [...(this.tags || [])],\n      audioFiles: structuredClone(this.audioFiles),\n      chapters: structuredClone(this.chapters),\n      ebookFile: structuredClone(this.ebookFile),\n      duration: this.duration,\n      size: this.size,\n      tracks: this.getTracklist(libraryItemId)\n    }\n  }\n}\n\nmodule.exports = Book\n"
  },
  {
    "path": "server/models/BookAuthor.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nclass BookAuthor extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {UUIDV4} */\n    this.bookId\n    /** @type {UUIDV4} */\n    this.authorId\n    /** @type {Date} */\n    this.createdAt\n  }\n\n  static removeByIds(authorId = null, bookId = null) {\n    const where = {}\n    if (authorId) where.authorId = authorId\n    if (bookId) where.bookId = bookId\n    return this.destroy({\n      where\n    })\n  }\n\n  /**\n   * Get number of books for author\n   *\n   * @param {string} authorId\n   * @returns {Promise<number>}\n   */\n  static getCountForAuthor(authorId) {\n    return this.count({\n      where: {\n        authorId\n      }\n    })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        }\n      },\n      {\n        sequelize,\n        modelName: 'bookAuthor',\n        timestamps: true,\n        updatedAt: false,\n        indexes: [\n          {\n            name: 'bookAuthor_authorId',\n            fields: ['authorId']\n          }\n        ]\n      }\n    )\n\n    // Super Many-to-Many\n    // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship\n    const { book, author } = sequelize.models\n    book.belongsToMany(author, { through: BookAuthor })\n    author.belongsToMany(book, { through: BookAuthor })\n\n    book.hasMany(BookAuthor, {\n      onDelete: 'CASCADE'\n    })\n    BookAuthor.belongsTo(book)\n\n    author.hasMany(BookAuthor, {\n      onDelete: 'CASCADE'\n    })\n    BookAuthor.belongsTo(author)\n  }\n}\nmodule.exports = BookAuthor\n"
  },
  {
    "path": "server/models/BookSeries.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nclass BookSeries extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.sequence\n    /** @type {UUIDV4} */\n    this.bookId\n    /** @type {UUIDV4} */\n    this.seriesId\n    /** @type {Date} */\n    this.createdAt\n  }\n\n  static removeByIds(seriesId = null, bookId = null) {\n    const where = {}\n    if (seriesId) where.seriesId = seriesId\n    if (bookId) where.bookId = bookId\n    return this.destroy({\n      where\n    })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        sequence: DataTypes.STRING\n      },\n      {\n        sequelize,\n        modelName: 'bookSeries',\n        timestamps: true,\n        updatedAt: false,\n        indexes: [\n          {\n            name: 'bookSeries_seriesId',\n            fields: ['seriesId']\n          },\n          {\n            name: 'book_series_series_book',\n            fields: ['seriesId', 'bookId']\n          }\n        ]\n      }\n    )\n\n    // Super Many-to-Many\n    // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship\n    const { book, series } = sequelize.models\n    book.belongsToMany(series, { through: BookSeries })\n    series.belongsToMany(book, { through: BookSeries })\n\n    book.hasMany(BookSeries, {\n      onDelete: 'CASCADE'\n    })\n    BookSeries.belongsTo(book)\n\n    series.hasMany(BookSeries, {\n      onDelete: 'CASCADE'\n    })\n    BookSeries.belongsTo(series)\n  }\n}\n\nmodule.exports = BookSeries\n"
  },
  {
    "path": "server/models/Collection.js",
    "content": "const { DataTypes, Model, Sequelize } = require('sequelize')\n\nclass Collection extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.name\n    /** @type {string} */\n    this.description\n    /** @type {UUIDV4} */\n    this.libraryId\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {Date} */\n    this.createdAt\n\n    // Expanded properties\n\n    /** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */\n    this.books\n  }\n\n  /**\n   * Get all toOldJSONExpanded, items filtered for user permissions\n   *\n   * @param {import('./User')} user\n   * @param {string} [libraryId]\n   * @param {string[]} [include]\n   * @async\n   */\n  static async getOldCollectionsJsonExpanded(user, libraryId, include) {\n    let collectionWhere = null\n    if (libraryId) {\n      collectionWhere = {\n        libraryId\n      }\n    }\n\n    // Optionally include rssfeed for collection\n    const collectionIncludes = []\n    if (include?.includes('rssfeed')) {\n      collectionIncludes.push({\n        model: this.sequelize.models.feed\n      })\n    }\n\n    const collections = await this.findAll({\n      where: collectionWhere,\n      include: [\n        {\n          model: this.sequelize.models.book,\n          include: [\n            {\n              model: this.sequelize.models.libraryItem\n            },\n            {\n              model: this.sequelize.models.author,\n              through: {\n                attributes: []\n              }\n            },\n            {\n              model: this.sequelize.models.series,\n              through: {\n                attributes: ['sequence']\n              }\n            }\n          ]\n        },\n        ...collectionIncludes\n      ],\n      order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]\n    })\n    // TODO: Handle user permission restrictions on initial query\n    return collections\n      .map((c) => {\n        // Filter books using user permissions\n        const books =\n          c.books?.filter((b) => {\n            if (user) {\n              if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {\n                return false\n              }\n              if (b.explicit === true && !user.canAccessExplicitContent) {\n                return false\n              }\n            }\n            return true\n          }) || []\n\n        // Users with restricted permissions will not see this collection\n        if (!books.length && c.books.length) {\n          return null\n        }\n\n        this.books = books\n\n        const collectionExpanded = c.toOldJSONExpanded()\n\n        // Map feed if found\n        if (c.feeds?.length) {\n          collectionExpanded.rssFeed = c.feeds[0].toOldJSON()\n        }\n\n        return collectionExpanded\n      })\n      .filter((c) => c)\n  }\n\n  /**\n   *\n   * @param {string} collectionId\n   * @returns {Promise<Collection>}\n   */\n  static async getExpandedById(collectionId) {\n    return this.findByPk(collectionId, {\n      include: [\n        {\n          model: this.sequelize.models.book,\n          include: [\n            {\n              model: this.sequelize.models.libraryItem\n            },\n            {\n              model: this.sequelize.models.author,\n              through: {\n                attributes: []\n              }\n            },\n            {\n              model: this.sequelize.models.series,\n              through: {\n                attributes: ['sequence']\n              }\n            }\n          ]\n        }\n      ],\n      order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]\n    })\n  }\n\n  /**\n   * Remove all collections belonging to library\n   * @param {string} libraryId\n   * @returns {Promise<number>} number of collections destroyed\n   */\n  static async removeAllForLibrary(libraryId) {\n    if (!libraryId) return 0\n    return this.destroy({\n      where: {\n        libraryId\n      }\n    })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: DataTypes.STRING,\n        description: DataTypes.TEXT\n      },\n      {\n        sequelize,\n        modelName: 'collection'\n      }\n    )\n\n    const { library } = sequelize.models\n\n    library.hasMany(Collection)\n    Collection.belongsTo(library)\n  }\n\n  /**\n   * Get all books in collection expanded with library item\n   *\n   * @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}\n   */\n  getBooksExpandedWithLibraryItem() {\n    return this.getBooks({\n      include: [\n        {\n          model: this.sequelize.models.libraryItem\n        },\n        {\n          model: this.sequelize.models.author,\n          through: {\n            attributes: []\n          }\n        },\n        {\n          model: this.sequelize.models.series,\n          through: {\n            attributes: ['sequence']\n          }\n        }\n      ],\n      order: [Sequelize.literal('`collectionBook.order` ASC')]\n    })\n  }\n\n  /**\n   * Get toOldJSONExpanded, items filtered for user permissions\n   *\n   * @param {import('./User')|null} user\n   * @param {string[]} [include]\n   * @async\n   */\n  async getOldJsonExpanded(user, include) {\n    this.books = await this.getBooksExpandedWithLibraryItem()\n\n    // Filter books using user permissions\n    // TODO: Handle user permission restrictions on initial query\n    if (user) {\n      const books = this.books.filter((b) => {\n        if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {\n          return false\n        }\n        if (b.explicit === true && !user.canAccessExplicitContent) {\n          return false\n        }\n        return true\n      })\n\n      // Users with restricted permissions will not see this collection\n      if (!books.length && this.books.length) {\n        return null\n      }\n\n      this.books = books\n    }\n\n    const collectionExpanded = this.toOldJSONExpanded()\n\n    if (include?.includes('rssfeed')) {\n      const feeds = await this.getFeeds()\n      if (feeds?.length) {\n        collectionExpanded.rssFeed = feeds[0].toOldJSON()\n      }\n    }\n\n    return collectionExpanded\n  }\n\n  /**\n   *\n   * @param {string[]} [libraryItemIds=[]]\n   * @returns\n   */\n  toOldJSON(libraryItemIds = []) {\n    return {\n      id: this.id,\n      libraryId: this.libraryId,\n      name: this.name,\n      description: this.description,\n      books: [...libraryItemIds],\n      lastUpdate: this.updatedAt.valueOf(),\n      createdAt: this.createdAt.valueOf()\n    }\n  }\n\n  toOldJSONExpanded() {\n    if (!this.books) {\n      throw new Error('Books are required to expand Collection')\n    }\n\n    const json = this.toOldJSON()\n    json.books = this.books.map((book) => {\n      const libraryItem = book.libraryItem\n      delete book.libraryItem\n      libraryItem.media = book\n      return libraryItem.toOldJSONExpanded()\n    })\n\n    return json\n  }\n}\n\nmodule.exports = Collection\n"
  },
  {
    "path": "server/models/CollectionBook.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nclass CollectionBook extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {number} */\n    this.order\n    /** @type {UUIDV4} */\n    this.bookId\n    /** @type {UUIDV4} */\n    this.collectionId\n    /** @type {Date} */\n    this.createdAt\n  }\n\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        order: DataTypes.INTEGER\n      },\n      {\n        sequelize,\n        timestamps: true,\n        updatedAt: false,\n        modelName: 'collectionBook'\n      }\n    )\n\n    // Super Many-to-Many\n    // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship\n    const { book, collection } = sequelize.models\n    book.belongsToMany(collection, { through: CollectionBook })\n    collection.belongsToMany(book, { through: CollectionBook })\n\n    book.hasMany(CollectionBook, {\n      onDelete: 'CASCADE'\n    })\n    CollectionBook.belongsTo(book)\n\n    collection.hasMany(CollectionBook, {\n      onDelete: 'CASCADE'\n    })\n    CollectionBook.belongsTo(collection)\n  }\n}\n\nmodule.exports = CollectionBook\n"
  },
  {
    "path": "server/models/CustomMetadataProvider.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\n/**\n * @typedef ClientCustomMetadataProvider\n * @property {UUIDV4} id\n * @property {string} name\n * @property {string} url\n * @property {string} slug\n */\n\nclass CustomMetadataProvider extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.mediaType\n    /** @type {string} */\n    this.name\n    /** @type {string} */\n    this.url\n    /** @type {string} */\n    this.authHeaderValue\n    /** @type {Object} */\n    this.extraData\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n  }\n\n  /**\n   * Get providers for client by media type\n   * Currently only available for \"book\" media type\n   *\n   * @param {string} mediaType\n   * @returns {Promise<ClientCustomMetadataProvider[]>}\n   */\n  static async getForClientByMediaType(mediaType) {\n    if (mediaType !== 'book') return []\n    const customMetadataProviders = await this.findAll({\n      where: {\n        mediaType\n      }\n    })\n    return customMetadataProviders.map((cmp) => cmp.toClientJson())\n  }\n\n  /**\n   * Check if provider exists by slug\n   *\n   * @param {string} providerSlug\n   * @returns {Promise<boolean>}\n   */\n  static async checkExistsBySlug(providerSlug) {\n    const providerId = providerSlug?.split?.('custom-')[1]\n    if (!providerId) return false\n\n    return (await this.count({ where: { id: providerId } })) > 0\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: DataTypes.STRING,\n        mediaType: DataTypes.STRING,\n        url: DataTypes.STRING,\n        authHeaderValue: DataTypes.STRING,\n        extraData: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'customMetadataProvider'\n      }\n    )\n  }\n\n  getSlug() {\n    return `custom-${this.id}`\n  }\n\n  /**\n   * Safe for clients\n   * @returns {ClientCustomMetadataProvider}\n   */\n  toClientJson() {\n    return {\n      id: this.id,\n      name: this.name,\n      mediaType: this.mediaType,\n      slug: this.getSlug()\n    }\n  }\n}\n\nmodule.exports = CustomMetadataProvider\n"
  },
  {
    "path": "server/models/Device.js",
    "content": "const { DataTypes, Model } = require('sequelize')\nconst oldDevice = require('../objects/DeviceInfo')\n\nclass Device extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.deviceId\n    /** @type {string} */\n    this.clientName\n    /** @type {string} */\n    this.clientVersion\n    /** @type {string} */\n    this.ipAddress\n    /** @type {string} */\n    this.deviceName\n    /** @type {string} */\n    this.deviceVersion\n    /** @type {object} */\n    this.extraData\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n  }\n\n  static async getOldDeviceByDeviceId(deviceId) {\n    const device = await this.findOne({\n      where: {\n        deviceId\n      }\n    })\n    if (!device) return null\n    return device.getOldDevice()\n  }\n\n  static createFromOld(oldDevice) {\n    const device = this.getFromOld(oldDevice)\n    return this.create(device)\n  }\n\n  static updateFromOld(oldDevice) {\n    const device = this.getFromOld(oldDevice)\n    return this.update(device, {\n      where: {\n        id: device.id\n      }\n    })\n  }\n\n  static getFromOld(oldDeviceInfo) {\n    let extraData = {}\n\n    if (oldDeviceInfo.manufacturer) {\n      extraData.manufacturer = oldDeviceInfo.manufacturer\n    }\n    if (oldDeviceInfo.model) {\n      extraData.model = oldDeviceInfo.model\n    }\n    if (oldDeviceInfo.osName) {\n      extraData.osName = oldDeviceInfo.osName\n    }\n    if (oldDeviceInfo.osVersion) {\n      extraData.osVersion = oldDeviceInfo.osVersion\n    }\n    if (oldDeviceInfo.browserName) {\n      extraData.browserName = oldDeviceInfo.browserName\n    }\n\n    return {\n      id: oldDeviceInfo.id,\n      deviceId: oldDeviceInfo.deviceId,\n      clientName: oldDeviceInfo.clientName || null,\n      clientVersion: oldDeviceInfo.clientVersion || null,\n      ipAddress: oldDeviceInfo.ipAddress,\n      deviceName: oldDeviceInfo.deviceName || null,\n      deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,\n      userId: oldDeviceInfo.userId,\n      extraData\n    }\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        deviceId: DataTypes.STRING,\n        clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android\n        clientVersion: DataTypes.STRING, // e.g. Server version or mobile version\n        ipAddress: DataTypes.STRING,\n        deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3\n        deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK\n        extraData: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'device'\n      }\n    )\n\n    const { user } = sequelize.models\n\n    user.hasMany(Device, {\n      onDelete: 'CASCADE'\n    })\n    Device.belongsTo(user)\n  }\n\n  toOldJSON() {\n    let browserVersion = null\n    let sdkVersion = null\n    if (this.clientName === 'Abs Android') {\n      sdkVersion = this.deviceVersion || null\n    } else {\n      browserVersion = this.deviceVersion || null\n    }\n\n    return {\n      id: this.id,\n      deviceId: this.deviceId,\n      userId: this.userId,\n      ipAddress: this.ipAddress,\n      browserName: this.extraData.browserName || null,\n      browserVersion,\n      osName: this.extraData.osName || null,\n      osVersion: this.extraData.osVersion || null,\n      clientVersion: this.clientVersion || null,\n      manufacturer: this.extraData.manufacturer || null,\n      model: this.extraData.model || null,\n      sdkVersion,\n      deviceName: this.deviceName,\n      clientName: this.clientName\n    }\n  }\n\n  getOldDevice() {\n    let browserVersion = null\n    let sdkVersion = null\n    if (this.clientName === 'Abs Android') {\n      sdkVersion = this.deviceVersion || null\n    } else {\n      browserVersion = this.deviceVersion || null\n    }\n\n    return new oldDevice({\n      id: this.id,\n      deviceId: this.deviceId,\n      userId: this.userId,\n      ipAddress: this.ipAddress,\n      browserName: this.extraData.browserName || null,\n      browserVersion,\n      osName: this.extraData.osName || null,\n      osVersion: this.extraData.osVersion || null,\n      clientVersion: this.clientVersion || null,\n      manufacturer: this.extraData.manufacturer || null,\n      model: this.extraData.model || null,\n      sdkVersion,\n      deviceName: this.deviceName,\n      clientName: this.clientName\n    })\n  }\n}\n\nmodule.exports = Device\n"
  },
  {
    "path": "server/models/Feed.js",
    "content": "const Path = require('path')\nconst { DataTypes, Model } = require('sequelize')\nconst Logger = require('../Logger')\n\nconst RSS = require('../libs/rss')\n\n/**\n * @typedef FeedOptions\n * @property {boolean} preventIndexing\n * @property {string} ownerName\n * @property {string} ownerEmail\n */\n\n/**\n * @typedef FeedExpandedProperties\n * @property {import('./FeedEpisode')} feedEpisodes\n *\n * @typedef {Feed & FeedExpandedProperties} FeedExpanded\n */\n\nclass Feed extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.slug\n    /** @type {string} */\n    this.entityType\n    /** @type {UUIDV4} */\n    this.entityId\n    /** @type {Date} */\n    this.entityUpdatedAt\n    /** @type {string} */\n    this.serverAddress\n    /** @type {string} */\n    this.feedURL\n    /** @type {string} */\n    this.imageURL\n    /** @type {string} */\n    this.siteURL\n    /** @type {string} */\n    this.title\n    /** @type {string} */\n    this.description\n    /** @type {string} */\n    this.author\n    /** @type {string} */\n    this.podcastType\n    /** @type {string} */\n    this.language\n    /** @type {string} */\n    this.ownerName\n    /** @type {string} */\n    this.ownerEmail\n    /** @type {boolean} */\n    this.explicit\n    /** @type {boolean} */\n    this.preventIndexing\n    /** @type {string} */\n    this.coverPath\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n\n    // Expanded properties\n\n    /** @type {import('./FeedEpisode')[]} - only set if expanded */\n    this.feedEpisodes\n  }\n\n  /**\n   * @param {string} feedId\n   * @returns {Promise<boolean>} - true if feed was removed\n   */\n  static async removeById(feedId) {\n    return (\n      (await this.destroy({\n        where: {\n          id: feedId\n        }\n      })) > 0\n    )\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem\n   * @param {string} slug\n   * @param {string} serverAddress\n   * @param {FeedOptions} [feedOptions=null]\n   *\n   * @returns {Feed}\n   */\n  static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {\n    const media = libraryItem.media\n\n    let entityUpdatedAt = libraryItem.updatedAt\n\n    // Podcast feeds should use the most recent episode updatedAt if more recent\n    if (libraryItem.mediaType === 'podcast') {\n      entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {\n        return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent\n      }, entityUpdatedAt)\n    } else if (libraryItem.media.updatedAt > entityUpdatedAt) {\n      // Book feeds will use Book.updatedAt if more recent\n      entityUpdatedAt = libraryItem.media.updatedAt\n    }\n\n    const feedObj = {\n      slug,\n      entityType: 'libraryItem',\n      entityId: libraryItem.id,\n      entityUpdatedAt,\n      serverAddress,\n      feedURL: `/feed/${slug}`,\n      imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,\n      siteURL: `/item/${libraryItem.id}`,\n      title: media.title,\n      description: media.description,\n      author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,\n      podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',\n      language: media.language,\n      explicit: media.explicit,\n      coverPath: media.coverPath,\n      userId\n    }\n\n    if (feedOptions) {\n      feedObj.preventIndexing = feedOptions.preventIndexing\n      feedObj.ownerName = feedOptions.ownerName\n      feedObj.ownerEmail = feedOptions.ownerEmail\n    }\n\n    return feedObj\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem\n   * @param {string} slug\n   * @param {string} serverAddress\n   * @param {FeedOptions} feedOptions\n   *\n   * @returns {Promise<FeedExpanded>}\n   */\n  static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {\n    const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)\n\n    /** @type {typeof import('./FeedEpisode')} */\n    const feedEpisodeModel = this.sequelize.models.feedEpisode\n\n    const transaction = await this.sequelize.transaction()\n    try {\n      const feed = await this.create(feedObj, { transaction })\n\n      if (libraryItem.mediaType === 'podcast') {\n        feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)\n      } else {\n        feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)\n      }\n\n      await transaction.commit()\n\n      return feed\n    } catch (error) {\n      Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)\n      await transaction.rollback()\n      return null\n    }\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('./Collection')} collectionExpanded\n   * @param {string} slug\n   * @param {string} serverAddress\n   * @param {FeedOptions} [feedOptions=null]\n   *\n   * @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}\n   */\n  static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) {\n    const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)\n\n    const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {\n      const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt\n      return updatedAt > mostRecent ? updatedAt : mostRecent\n    }, collectionExpanded.updatedAt)\n\n    const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)\n\n    const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {\n      const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)\n      return authorNames.concat(bookAuthorsToAdd)\n    }, [])\n    let author = allBookAuthorNames.slice(0, 3).join(', ')\n    if (allBookAuthorNames.length > 3) {\n      author += ' & more'\n    }\n\n    const feedObj = {\n      slug,\n      entityType: 'collection',\n      entityId: collectionExpanded.id,\n      entityUpdatedAt,\n      serverAddress,\n      feedURL: `/feed/${slug}`,\n      imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,\n      siteURL: `/collection/${collectionExpanded.id}`,\n      title: collectionExpanded.name,\n      description: collectionExpanded.description || '',\n      author,\n      podcastType: 'serial',\n      explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit\n      coverPath: firstBookWithCover?.coverPath || null,\n      userId\n    }\n\n    if (feedOptions) {\n      feedObj.preventIndexing = feedOptions.preventIndexing\n      feedObj.ownerName = feedOptions.ownerName\n      feedObj.ownerEmail = feedOptions.ownerEmail\n    }\n\n    return {\n      feedObj,\n      booksWithTracks\n    }\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('./Collection')} collectionExpanded\n   * @param {string} slug\n   * @param {string} serverAddress\n   * @param {FeedOptions} feedOptions\n   *\n   * @returns {Promise<FeedExpanded>}\n   */\n  static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {\n    const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)\n\n    /** @type {typeof import('./FeedEpisode')} */\n    const feedEpisodeModel = this.sequelize.models.feedEpisode\n\n    const transaction = await this.sequelize.transaction()\n    try {\n      const feed = await this.create(feedObj, { transaction })\n      feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)\n\n      await transaction.commit()\n\n      return feed\n    } catch (error) {\n      Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)\n      await transaction.rollback()\n      return null\n    }\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('./Series')} seriesExpanded\n   * @param {string} slug\n   * @param {string} serverAddress\n   * @param {FeedOptions} [feedOptions=null]\n   *\n   * @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}\n   */\n  static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {\n    const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)\n    const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {\n      const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt\n      return updatedAt > mostRecent ? updatedAt : mostRecent\n    }, seriesExpanded.updatedAt)\n\n    const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)\n\n    const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {\n      const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)\n      return authorNames.concat(bookAuthorsToAdd)\n    }, [])\n    let author = allBookAuthorNames.slice(0, 3).join(', ')\n    if (allBookAuthorNames.length > 3) {\n      author += ' & more'\n    }\n\n    const feedObj = {\n      slug,\n      entityType: 'series',\n      entityId: seriesExpanded.id,\n      entityUpdatedAt,\n      serverAddress,\n      feedURL: `/feed/${slug}`,\n      imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,\n      siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,\n      title: seriesExpanded.name,\n      description: seriesExpanded.description || '',\n      author,\n      podcastType: 'serial',\n      explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit\n      coverPath: firstBookWithCover?.coverPath || null,\n      userId\n    }\n\n    if (feedOptions) {\n      feedObj.preventIndexing = feedOptions.preventIndexing\n      feedObj.ownerName = feedOptions.ownerName\n      feedObj.ownerEmail = feedOptions.ownerEmail\n    }\n\n    return {\n      feedObj,\n      booksWithTracks\n    }\n  }\n\n  /**\n   *\n   * @param {string} userId\n   * @param {import('./Series')} seriesExpanded\n   * @param {string} slug\n   * @param {string} serverAddress\n   * @param {FeedOptions} feedOptions\n   *\n   * @returns {Promise<FeedExpanded>}\n   */\n  static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {\n    const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)\n\n    /** @type {typeof import('./FeedEpisode')} */\n    const feedEpisodeModel = this.sequelize.models.feedEpisode\n\n    const transaction = await this.sequelize.transaction()\n    try {\n      const feed = await this.create(feedObj, { transaction })\n      feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)\n\n      await transaction.commit()\n\n      return feed\n    } catch (error) {\n      Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)\n      await transaction.rollback()\n      return null\n    }\n  }\n\n  /**\n   * Initialize model\n   *\n   * Polymorphic association: Feeds can be created from LibraryItem, Collection, Playlist or Series\n   * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/\n   *\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        slug: DataTypes.STRING,\n        entityType: DataTypes.STRING,\n        entityId: DataTypes.UUID,\n        entityUpdatedAt: DataTypes.DATE,\n        serverAddress: DataTypes.STRING,\n        feedURL: DataTypes.STRING,\n        imageURL: DataTypes.STRING,\n        siteURL: DataTypes.STRING,\n        title: DataTypes.STRING,\n        description: DataTypes.TEXT,\n        author: DataTypes.STRING,\n        podcastType: DataTypes.STRING,\n        language: DataTypes.STRING,\n        ownerName: DataTypes.STRING,\n        ownerEmail: DataTypes.STRING,\n        explicit: DataTypes.BOOLEAN,\n        preventIndexing: DataTypes.BOOLEAN,\n        coverPath: DataTypes.STRING\n      },\n      {\n        sequelize,\n        modelName: 'feed'\n      }\n    )\n\n    const { user, libraryItem, collection, series, playlist } = sequelize.models\n\n    user.hasMany(Feed)\n    Feed.belongsTo(user)\n\n    libraryItem.hasMany(Feed, {\n      foreignKey: 'entityId',\n      constraints: false,\n      scope: {\n        entityType: 'libraryItem'\n      }\n    })\n    Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })\n\n    collection.hasMany(Feed, {\n      foreignKey: 'entityId',\n      constraints: false,\n      scope: {\n        entityType: 'collection'\n      }\n    })\n    Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })\n\n    series.hasMany(Feed, {\n      foreignKey: 'entityId',\n      constraints: false,\n      scope: {\n        entityType: 'series'\n      }\n    })\n    Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })\n\n    playlist.hasMany(Feed, {\n      foreignKey: 'entityId',\n      constraints: false,\n      scope: {\n        entityType: 'playlist'\n      }\n    })\n    Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })\n\n    Feed.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n      for (const instance of findResult) {\n        if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {\n          instance.entity = instance.libraryItem\n          instance.dataValues.entity = instance.dataValues.libraryItem\n        } else if (instance.entityType === 'collection' && instance.collection !== undefined) {\n          instance.entity = instance.collection\n          instance.dataValues.entity = instance.dataValues.collection\n        } else if (instance.entityType === 'series' && instance.series !== undefined) {\n          instance.entity = instance.series\n          instance.dataValues.entity = instance.dataValues.series\n        } else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {\n          instance.entity = instance.playlist\n          instance.dataValues.entity = instance.dataValues.playlist\n        }\n\n        // To prevent mistakes:\n        delete instance.libraryItem\n        delete instance.dataValues.libraryItem\n        delete instance.collection\n        delete instance.dataValues.collection\n        delete instance.series\n        delete instance.dataValues.series\n        delete instance.playlist\n        delete instance.dataValues.playlist\n      }\n    })\n  }\n\n  /**\n   *\n   * @returns {Promise<FeedExpanded>}\n   */\n  async updateFeedForEntity() {\n    /** @type {typeof import('./FeedEpisode')} */\n    const feedEpisodeModel = this.sequelize.models.feedEpisode\n\n    let feedObj = null\n    let feedEpisodeCreateFunc = null\n    let feedEpisodeCreateFuncEntity = null\n\n    if (this.entityType === 'libraryItem') {\n      /** @type {typeof import('./LibraryItem')} */\n      const libraryItemModel = this.sequelize.models.libraryItem\n\n      const itemExpanded = await libraryItemModel.getExpandedById(this.entityId)\n      feedObj = Feed.getFeedObjForLibraryItem(this.userId, itemExpanded, this.slug, this.serverAddress)\n\n      feedEpisodeCreateFuncEntity = itemExpanded\n      if (itemExpanded.mediaType === 'podcast') {\n        feedEpisodeCreateFunc = feedEpisodeModel.createFromPodcastEpisodes.bind(feedEpisodeModel)\n      } else {\n        feedEpisodeCreateFunc = feedEpisodeModel.createFromAudiobookTracks.bind(feedEpisodeModel)\n      }\n    } else if (this.entityType === 'collection') {\n      /** @type {typeof import('./Collection')} */\n      const collectionModel = this.sequelize.models.collection\n\n      const collectionExpanded = await collectionModel.getExpandedById(this.entityId)\n      const feedObjData = Feed.getFeedObjForCollection(this.userId, collectionExpanded, this.slug, this.serverAddress)\n      feedObj = feedObjData.feedObj\n      feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks\n      feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)\n    } else if (this.entityType === 'series') {\n      /** @type {typeof import('./Series')} */\n      const seriesModel = this.sequelize.models.series\n\n      const seriesExpanded = await seriesModel.getExpandedById(this.entityId)\n      const feedObjData = Feed.getFeedObjForSeries(this.userId, seriesExpanded, this.slug, this.serverAddress)\n      feedObj = feedObjData.feedObj\n      feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks\n      feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)\n    } else {\n      Logger.error(`[Feed] Invalid entity type ${this.entityType} for feed ${this.id}`)\n      return null\n    }\n\n    const transaction = await this.sequelize.transaction()\n    try {\n      const updatedFeed = await this.update(feedObj, { transaction })\n\n      const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id)\n\n      // Create new feed episodes\n      updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)\n\n      const newFeedEpisodeIds = updatedFeed.feedEpisodes.map((ep) => ep.id)\n      const feedEpisodeIdsToRemove = existingFeedEpisodeIds.filter((epid) => !newFeedEpisodeIds.includes(epid))\n\n      if (feedEpisodeIdsToRemove.length) {\n        Logger.info(`[Feed] Removing ${feedEpisodeIdsToRemove.length} episodes from feed ${this.id}`)\n        await feedEpisodeModel.destroy({\n          where: {\n            id: feedEpisodeIdsToRemove\n          },\n          transaction\n        })\n      }\n\n      await transaction.commit()\n\n      return updatedFeed\n    } catch (error) {\n      Logger.error(`[Feed] Error updating feed ${this.entityId}`, error)\n      await transaction.rollback()\n\n      return null\n    }\n  }\n\n  getEntity(options) {\n    if (!this.entityType) return Promise.resolve(null)\n    const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`\n    return this[mixinMethodName](options)\n  }\n\n  /**\n   *\n   * @param {string} hostPrefix\n   */\n  buildXml(hostPrefix) {\n    const customElements = [\n      { language: this.language || 'en' },\n      { author: this.author || 'advplyr' },\n      { 'itunes:author': this.author || 'advplyr' },\n      { 'itunes:type': this.podcastType || 'serial' },\n      {\n        'itunes:image': {\n          _attr: {\n            href: `${hostPrefix}${this.imageURL}`\n          }\n        }\n      },\n      { 'itunes:explicit': !!this.explicit }\n    ]\n\n    if (this.description) {\n      customElements.push({ 'itunes:summary': { _cdata: this.description } })\n    }\n\n    const itunesOwnersData = []\n    if (this.ownerName || this.author) {\n      itunesOwnersData.push({ 'itunes:name': this.ownerName || this.author })\n    }\n    if (this.ownerEmail) {\n      itunesOwnersData.push({ 'itunes:email': this.ownerEmail })\n    }\n    if (itunesOwnersData.length) {\n      customElements.push({\n        'itunes:owner': itunesOwnersData\n      })\n    }\n\n    if (this.preventIndexing) {\n      customElements.push({ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' })\n    }\n\n    const rssData = {\n      title: this.title,\n      description: this.description || '',\n      generator: 'Audiobookshelf',\n      feed_url: `${hostPrefix}${this.feedURL}`,\n      site_url: `${hostPrefix}${this.siteURL}`,\n      image_url: `${hostPrefix}${this.imageURL}`,\n      custom_namespaces: {\n        itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',\n        podcast: 'https://podcastindex.org/namespace/1.0',\n        googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'\n      },\n      custom_elements: customElements\n    }\n\n    const rssfeed = new RSS(rssData)\n    this.feedEpisodes.forEach((ep) => {\n      rssfeed.item(ep.getRSSData(hostPrefix))\n    })\n    return rssfeed.xml()\n  }\n\n  /**\n   *\n   * @param {string} id\n   * @returns {string}\n   */\n  getEpisodePath(id) {\n    const episode = this.feedEpisodes.find((ep) => ep.id === id)\n    if (!episode) return null\n    return episode.filePath\n  }\n\n  toOldJSON() {\n    const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())\n    return {\n      id: this.id,\n      slug: this.slug,\n      userId: this.userId,\n      entityType: this.entityType,\n      entityId: this.entityId,\n      entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,\n      coverPath: this.coverPath || null,\n      meta: {\n        title: this.title,\n        description: this.description,\n        author: this.author,\n        imageUrl: this.imageURL,\n        feedUrl: this.feedURL,\n        link: this.siteURL,\n        explicit: this.explicit,\n        type: this.podcastType,\n        language: this.language,\n        preventIndexing: this.preventIndexing,\n        ownerName: this.ownerName,\n        ownerEmail: this.ownerEmail\n      },\n      serverAddress: this.serverAddress,\n      feedUrl: this.feedURL,\n      episodes: episodes || [],\n      createdAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf()\n    }\n  }\n\n  toOldJSONMinified() {\n    return {\n      id: this.id,\n      entityType: this.entityType,\n      entityId: this.entityId,\n      feedUrl: this.feedURL,\n      meta: {\n        title: this.title,\n        description: this.description,\n        preventIndexing: this.preventIndexing,\n        ownerName: this.ownerName,\n        ownerEmail: this.ownerEmail\n      }\n    }\n  }\n}\n\nmodule.exports = Feed\n"
  },
  {
    "path": "server/models/FeedEpisode.js",
    "content": "const Path = require('path')\nconst { DataTypes, Model } = require('sequelize')\nconst uuidv4 = require('uuid').v4\nconst Logger = require('../Logger')\nconst date = require('../libs/dateAndTime')\nconst { secondsToTimestamp } = require('../utils')\n\nclass FeedEpisode extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.title\n    /** @type {string} */\n    this.author\n    /** @type {string} */\n    this.description\n    /** @type {string} */\n    this.siteURL\n    /** @type {string} */\n    this.enclosureURL\n    /** @type {string} */\n    this.enclosureType\n    /** @type {BigInt} */\n    this.enclosureSize\n    /** @type {string} */\n    this.pubDate\n    /** @type {string} */\n    this.season\n    /** @type {string} */\n    this.episode\n    /** @type {string} */\n    this.episodeType\n    /** @type {number} */\n    this.duration\n    /** @type {string} */\n    this.filePath\n    /** @type {boolean} */\n    this.explicit\n    /** @type {UUIDV4} */\n    this.feedId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n  }\n\n  /**\n   *\n   * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded\n   * @param {import('./Feed')} feed\n   * @param {string} slug\n   * @param {import('./PodcastEpisode')} episode\n   * @param {string} [existingEpisodeId]\n   */\n  static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) {\n    const episodeId = existingEpisodeId || uuidv4()\n    return {\n      id: episodeId,\n      title: episode.title,\n      author: feed.author,\n      description: episode.description,\n      siteURL: feed.siteURL,\n      enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,\n      enclosureType: episode.audioFile.mimeType,\n      enclosureSize: episode.audioFile.metadata.size,\n      pubDate: episode.pubDate,\n      season: episode.season,\n      episode: episode.episode,\n      episodeType: episode.episodeType,\n      duration: episode.audioFile.duration,\n      filePath: episode.audioFile.metadata.path,\n      explicit: libraryItemExpanded.media.explicit,\n      feedId: feed.id\n    }\n  }\n\n  /**\n   *\n   * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded\n   * @param {import('./Feed')} feed\n   * @param {string} slug\n   * @param {import('sequelize').Transaction} transaction\n   * @returns {Promise<FeedEpisode[]>}\n   */\n  static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) {\n    const feedEpisodeObjs = []\n\n    // Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest.\n    if (feed.podcastType === 'episodic') {\n      libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))\n    } else {\n      libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))\n    }\n\n    let numExisting = 0\n    for (const episode of libraryItemExpanded.media.podcastEpisodes) {\n      // Check for existing episode by filepath\n      const existingEpisode = feed.feedEpisodes?.find((feedEpisode) => {\n        return feedEpisode.filePath === episode.audioFile.metadata.path\n      })\n      numExisting = existingEpisode ? numExisting + 1 : numExisting\n\n      feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id))\n    }\n    Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)\n    return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })\n  }\n\n  /**\n   * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names\n   *\n   * @param {import('./Book').AudioTrack[]} trackList\n   * @param {import('./Book')} book\n   * @returns {boolean}\n   */\n  static checkUseChapterTitlesForEpisodes(trackList, book) {\n    const chapters = book.chapters || []\n    if (trackList.length !== chapters.length) return false\n    for (let i = 0; i < trackList.length; i++) {\n      if (Math.abs(chapters[i].start - trackList[i].startOffset) >= 1) {\n        return false\n      }\n    }\n    return true\n  }\n\n  /**\n   *\n   * @param {import('./Book')} book\n   * @param {Date} pubDateStart\n   * @param {import('./Feed')} feed\n   * @param {string} slug\n   * @param {import('./Book').AudioFileObject} audioTrack\n   * @param {boolean} useChapterTitles\n   * @param {number} offsetIndex\n   * @param {string} [existingEpisodeId]\n   */\n  static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, offsetIndex, existingEpisodeId = null) {\n    // Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>\n    // Offset pubdate in 1 minute intervals to ensure correct order\n    const timeOffset = offsetIndex * 60000\n    const episodeId = existingEpisodeId || uuidv4()\n\n    // e.g. Track 1 will have a pub date before Track 2\n    const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')\n\n    const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`\n\n    let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename))\n    if (book.includedAudioFiles.length == 1) {\n      // If audiobook is a single file, use book title instead of chapter/file title\n      title = book.title\n    } else {\n      if (useChapterTitles) {\n        // If audio track start and chapter start are within 1 seconds of eachother then use the chapter title\n        const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)\n        if (matchingChapter?.title) title = matchingChapter.title\n      }\n    }\n\n    return {\n      id: episodeId,\n      title,\n      author: feed.author,\n      description: book.description || '',\n      siteURL: feed.siteURL,\n      enclosureURL: contentUrl,\n      enclosureType: audioTrack.mimeType,\n      enclosureSize: audioTrack.metadata.size,\n      pubDate: audiobookPubDate,\n      duration: audioTrack.duration,\n      filePath: audioTrack.metadata.path,\n      explicit: book.explicit,\n      feedId: feed.id\n    }\n  }\n\n  /**\n   *\n   * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded\n   * @param {import('./Feed')} feed\n   * @param {string} slug\n   * @param {import('sequelize').Transaction} transaction\n   * @returns {Promise<FeedEpisode[]>}\n   */\n  static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {\n    const trackList = libraryItemExpanded.getTrackList()\n    const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, libraryItemExpanded.media)\n\n    const feedEpisodeObjs = []\n    let numExisting = 0\n    for (let i = 0; i < trackList.length; i++) {\n      const track = trackList[i]\n      // Check for existing episode by filepath\n      const existingEpisode = feed.feedEpisodes?.find((episode) => {\n        return episode.filePath === track.metadata.path\n      })\n      numExisting = existingEpisode ? numExisting + 1 : numExisting\n\n      feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, i, existingEpisode?.id))\n    }\n    Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)\n    return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })\n  }\n\n  /**\n   *\n   * @param {import('./Book').BookExpandedWithLibraryItem[]} books\n   * @param {import('./Feed')} feed\n   * @param {string} slug\n   * @param {import('sequelize').Transaction} transaction\n   * @returns {Promise<FeedEpisode[]>}\n   */\n  static async createFromBooks(books, feed, slug, transaction) {\n    // This is never null unless the books array is empty, as this method is not invoked when no books. Reduce needs an initial item\n    const earliestLibraryItemCreatedAt =\n      books.length > 0\n        ? books.reduce((earliest, book) => {\n            return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest\n          }).libraryItem.createdAt\n        : null\n\n    const feedEpisodeObjs = []\n    let numExisting = 0\n    let offsetIndex = 0\n    for (const book of books) {\n      const trackList = book.getTracklist(book.libraryItem.id)\n      const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book)\n      for (const track of trackList) {\n        // Check for existing episode by filepath\n        const existingEpisode = feed.feedEpisodes?.find((episode) => {\n          return episode.filePath === track.metadata.path\n        })\n        numExisting = existingEpisode ? numExisting + 1 : numExisting\n\n        feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles, offsetIndex++, existingEpisode?.id))\n      }\n    }\n    Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`)\n    return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        title: DataTypes.STRING,\n        author: DataTypes.STRING,\n        description: DataTypes.TEXT,\n        siteURL: DataTypes.STRING,\n        enclosureURL: DataTypes.STRING,\n        enclosureType: DataTypes.STRING,\n        enclosureSize: DataTypes.BIGINT,\n        pubDate: DataTypes.STRING,\n        season: DataTypes.STRING,\n        episode: DataTypes.STRING,\n        episodeType: DataTypes.STRING,\n        duration: DataTypes.FLOAT,\n        filePath: DataTypes.STRING,\n        explicit: DataTypes.BOOLEAN\n      },\n      {\n        sequelize,\n        modelName: 'feedEpisode'\n      }\n    )\n\n    const { feed } = sequelize.models\n\n    feed.hasMany(FeedEpisode, {\n      onDelete: 'CASCADE'\n    })\n    FeedEpisode.belongsTo(feed)\n  }\n\n  getOldEpisode() {\n    const enclosure = {\n      url: this.enclosureURL,\n      size: this.enclosureSize,\n      type: this.enclosureType\n    }\n    return {\n      id: this.id,\n      title: this.title,\n      description: this.description,\n      enclosure,\n      pubDate: this.pubDate,\n      link: this.siteURL,\n      author: this.author,\n      explicit: this.explicit,\n      duration: this.duration,\n      season: this.season,\n      episode: this.episode,\n      episodeType: this.episodeType,\n      fullPath: this.filePath\n    }\n  }\n\n  /**\n   *\n   * @param {string} hostPrefix\n   */\n  getRSSData(hostPrefix) {\n    const customElements = [\n      { 'itunes:author': this.author || null },\n      { 'itunes:duration': Math.round(Number(this.duration)) },\n      {\n        'itunes:explicit': !!this.explicit\n      },\n      { 'itunes:episodeType': this.episodeType || null },\n      { 'itunes:season': this.season || null },\n      { 'itunes:episode': this.episode || null }\n    ].filter((element) => {\n      // Remove empty custom elements\n      return Object.values(element)[0] !== null\n    })\n    if (this.description) {\n      customElements.push({ 'itunes:summary': { _cdata: this.description } })\n    }\n\n    return {\n      title: this.title,\n      description: this.description || '',\n      url: `${hostPrefix}${this.siteURL}`,\n      guid: `${hostPrefix}${this.enclosureURL}`,\n      author: this.author,\n      date: this.pubDate,\n      enclosure: {\n        url: `${hostPrefix}${this.enclosureURL}`,\n        type: this.enclosureType,\n        size: this.enclosureSize\n      },\n      custom_elements: customElements\n    }\n  }\n}\n\nmodule.exports = FeedEpisode\n"
  },
  {
    "path": "server/models/Library.js",
    "content": "const { DataTypes, Model } = require('sequelize')\nconst Logger = require('../Logger')\n\n/**\n * @typedef LibrarySettingsObject\n * @property {number} coverAspectRatio BookCoverAspectRatio\n * @property {boolean} disableWatcher\n * @property {boolean} skipMatchingMediaWithAsin\n * @property {boolean} skipMatchingMediaWithIsbn\n * @property {string} autoScanCronExpression\n * @property {boolean} audiobooksOnly\n * @property {boolean} hideSingleBookSeries Do not show series that only have 1 book\n * @property {boolean} onlyShowLaterBooksInContinueSeries Skip showing books that are earlier than the max sequence read\n * @property {string[]} metadataPrecedence\n * @property {number} markAsFinishedTimeRemaining Time remaining in seconds to mark as finished. (defaults to 10s)\n * @property {number} markAsFinishedPercentComplete Percent complete to mark as finished (0-100). If this is set it will be used over markAsFinishedTimeRemaining.\n */\n\nclass Library extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.name\n    /** @type {number} */\n    this.displayOrder\n    /** @type {string} */\n    this.icon\n    /** @type {string} */\n    this.mediaType\n    /** @type {string} */\n    this.provider\n    /** @type {Date} */\n    this.lastScan\n    /** @type {string} */\n    this.lastScanVersion\n    /** @type {LibrarySettingsObject} */\n    this.settings\n    /** @type {Object} */\n    this.extraData\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {import('./LibraryFolder')[]|undefined} */\n    this.libraryFolders\n  }\n\n  /**\n   *\n   * @param {string} mediaType\n   * @returns\n   */\n  static getDefaultLibrarySettingsForMediaType(mediaType) {\n    if (mediaType === 'podcast') {\n      return {\n        coverAspectRatio: 1, // Square\n        disableWatcher: false,\n        autoScanCronExpression: null,\n        podcastSearchRegion: 'us',\n        markAsFinishedPercentComplete: null,\n        markAsFinishedTimeRemaining: 10\n      }\n    } else {\n      return {\n        coverAspectRatio: 1, // Square\n        disableWatcher: false,\n        autoScanCronExpression: null,\n        skipMatchingMediaWithAsin: false,\n        skipMatchingMediaWithIsbn: false,\n        audiobooksOnly: false,\n        epubsAllowScriptedContent: false,\n        hideSingleBookSeries: false,\n        onlyShowLaterBooksInContinueSeries: false,\n        metadataPrecedence: this.defaultMetadataPrecedence,\n        markAsFinishedPercentComplete: null,\n        markAsFinishedTimeRemaining: 10\n      }\n    }\n  }\n\n  static get defaultMetadataPrecedence() {\n    return ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']\n  }\n\n  /**\n   *\n   * @returns {Promise<Library[]>}\n   */\n  static getAllWithFolders() {\n    return this.findAll({\n      include: this.sequelize.models.libraryFolder,\n      order: [['displayOrder', 'ASC']]\n    })\n  }\n\n  /**\n   *\n   * @param {string} libraryId\n   * @returns {Promise<Library>}\n   */\n  static findByIdWithFolders(libraryId) {\n    return this.findByPk(libraryId, {\n      include: this.sequelize.models.libraryFolder\n    })\n  }\n\n  /**\n   * Get all library ids\n   * @returns {Promise<string[]>} array of library ids\n   */\n  static async getAllLibraryIds() {\n    const libraries = await this.findAll({\n      attributes: ['id', 'displayOrder'],\n      order: [['displayOrder', 'ASC']]\n    })\n    return libraries.map((l) => l.id)\n  }\n\n  /**\n   * Get the largest value in the displayOrder column\n   * Used for setting a new libraries display order\n   * @returns {Promise<number>}\n   */\n  static getMaxDisplayOrder() {\n    return this.max('displayOrder') || 0\n  }\n\n  /**\n   * Updates displayOrder to be sequential\n   * Used after removing a library\n   */\n  static async resetDisplayOrder() {\n    const libraries = await this.findAll({\n      order: [['displayOrder', 'ASC']]\n    })\n    for (let i = 0; i < libraries.length; i++) {\n      const library = libraries[i]\n      if (library.displayOrder !== i + 1) {\n        Logger.debug(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)\n        await library.update({ displayOrder: i + 1 }).catch((error) => {\n          Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)\n        })\n      }\n    }\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: DataTypes.STRING,\n        displayOrder: DataTypes.INTEGER,\n        icon: DataTypes.STRING,\n        mediaType: DataTypes.STRING,\n        provider: DataTypes.STRING,\n        lastScan: DataTypes.DATE,\n        lastScanVersion: DataTypes.STRING,\n        settings: DataTypes.JSON,\n        extraData: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'library'\n      }\n    )\n  }\n\n  get isPodcast() {\n    return this.mediaType === 'podcast'\n  }\n  get isBook() {\n    return this.mediaType === 'book'\n  }\n  /**\n   * @returns {string[]}\n   */\n  get lastScanMetadataPrecedence() {\n    return this.extraData?.lastScanMetadataPrecedence || []\n  }\n\n  /**\n   * @returns {LibrarySettingsObject}\n   */\n  get librarySettings() {\n    return this.settings || Library.getDefaultLibrarySettingsForMediaType(this.mediaType)\n  }\n\n  /**\n   * TODO: Update to use new model\n   */\n  toOldJSON() {\n    return {\n      id: this.id,\n      name: this.name,\n      folders: (this.libraryFolders || []).map((f) => f.toOldJSON()),\n      displayOrder: this.displayOrder,\n      icon: this.icon,\n      mediaType: this.mediaType,\n      provider: this.provider,\n      settings: {\n        ...this.settings\n      },\n      lastScan: this.lastScan?.valueOf() || null,\n      lastScanVersion: this.lastScanVersion,\n      createdAt: this.createdAt.valueOf(),\n      lastUpdate: this.updatedAt.valueOf()\n    }\n  }\n}\n\nmodule.exports = Library\n"
  },
  {
    "path": "server/models/LibraryFolder.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nclass LibraryFolder extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.path\n    /** @type {UUIDV4} */\n    this.libraryId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        path: DataTypes.STRING\n      },\n      {\n        sequelize,\n        modelName: 'libraryFolder'\n      }\n    )\n\n    const { library } = sequelize.models\n    library.hasMany(LibraryFolder, {\n      onDelete: 'CASCADE'\n    })\n    LibraryFolder.belongsTo(library)\n  }\n\n  /**\n   * TODO: Update to use new model\n   */\n  toOldJSON() {\n    return {\n      id: this.id,\n      fullPath: this.path,\n      libraryId: this.libraryId,\n      addedAt: this.createdAt.valueOf()\n    }\n  }\n}\n\nmodule.exports = LibraryFolder\n"
  },
  {
    "path": "server/models/LibraryItem.js",
    "content": "const Path = require('path')\nconst { DataTypes, Model } = require('sequelize')\nconst fsExtra = require('../libs/fsExtra')\nconst Logger = require('../Logger')\nconst libraryFilters = require('../utils/queries/libraryFilters')\nconst { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')\nconst LibraryFile = require('../objects/files/LibraryFile')\nconst Book = require('./Book')\nconst Podcast = require('./Podcast')\n\n/**\n * @typedef LibraryFileObject\n * @property {string} ino\n * @property {boolean} isSupplementary\n * @property {number} addedAt\n * @property {number} updatedAt\n * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata\n */\n\n/**\n * @typedef LibraryItemExpandedProperties\n * @property {Book.BookExpanded|Podcast.PodcastExpanded} media\n *\n * @typedef {LibraryItem & LibraryItemExpandedProperties} LibraryItemExpanded\n */\n\nclass LibraryItem extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {string} */\n    this.id\n    /** @type {string} */\n    this.ino\n    /** @type {string} */\n    this.path\n    /** @type {string} */\n    this.relPath\n    /** @type {string} */\n    this.mediaId\n    /** @type {string} */\n    this.mediaType\n    /** @type {boolean} */\n    this.isFile\n    /** @type {boolean} */\n    this.isMissing\n    /** @type {boolean} */\n    this.isInvalid\n    /** @type {Date} */\n    this.mtime\n    /** @type {Date} */\n    this.ctime\n    /** @type {Date} */\n    this.birthtime\n    /** @type {BigInt} */\n    this.size\n    /** @type {Date} */\n    this.lastScan\n    /** @type {string} */\n    this.lastScanVersion\n    /** @type {LibraryFileObject[]} */\n    this.libraryFiles\n    /** @type {Object} */\n    this.extraData\n    /** @type {string} */\n    this.libraryId\n    /** @type {string} */\n    this.libraryFolderId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n\n    /** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */\n    this.media\n    /** @type {string} */\n    this.title // Only used for sorting\n    /** @type {string} */\n    this.titleIgnorePrefix // Only used for sorting\n    /** @type {string} */\n    this.authorNamesFirstLast // Only used for sorting\n    /** @type {string} */\n    this.authorNamesLastFirst // Only used for sorting\n  }\n\n  /**\n   * Gets library items partially expanded, not including podcast episodes\n   * @todo temporary solution\n   *\n   * @param {number} offset\n   * @param {number} limit\n   * @returns {Promise<LibraryItem[]>} LibraryItem\n   */\n  static getLibraryItemsIncrement(offset, limit, where = null) {\n    return this.findAll({\n      where,\n      include: [\n        {\n          model: this.sequelize.models.book,\n          include: [\n            {\n              model: this.sequelize.models.author,\n              through: {\n                attributes: ['createdAt']\n              }\n            },\n            {\n              model: this.sequelize.models.series,\n              through: {\n                attributes: ['id', 'sequence', 'createdAt']\n              }\n            }\n          ]\n        },\n        {\n          model: this.sequelize.models.podcast\n        }\n      ],\n      order: [\n        ['createdAt', 'ASC'],\n        // Ensure author & series stay in the same order\n        [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],\n        [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']\n      ],\n      offset,\n      limit\n    })\n  }\n\n  /**\n   * Remove library item by id\n   *\n   * @param {string} libraryItemId\n   * @returns {Promise<number>} The number of destroyed rows\n   */\n  static removeById(libraryItemId) {\n    return this.destroy({\n      where: {\n        id: libraryItemId\n      },\n      individualHooks: true\n    })\n  }\n\n  /**\n   *\n   * @param {import('sequelize').WhereOptions} where\n   * @returns {Promise<LibraryItemExpanded[]>}\n   */\n  static async findAllExpandedWhere(where = null) {\n    return this.findAll({\n      where,\n      include: [\n        {\n          model: this.sequelize.models.book,\n          include: [\n            {\n              model: this.sequelize.models.author,\n              through: {\n                attributes: []\n              }\n            },\n            {\n              model: this.sequelize.models.series,\n              through: {\n                attributes: ['id', 'sequence']\n              }\n            }\n          ]\n        },\n        {\n          model: this.sequelize.models.podcast,\n          include: {\n            model: this.sequelize.models.podcastEpisode\n          }\n        }\n      ],\n      order: [\n        // Ensure author & series stay in the same order\n        [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],\n        [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']\n      ]\n    })\n  }\n\n  /**\n   *\n   * @param {string} libraryItemId\n   * @returns {Promise<LibraryItemExpanded>}\n   */\n  static async getExpandedById(libraryItemId) {\n    if (!libraryItemId) return null\n\n    const libraryItem = await this.findByPk(libraryItemId)\n    if (!libraryItem) {\n      Logger.error(`[LibraryItem] Library item not found with id \"${libraryItemId}\"`)\n      return null\n    }\n\n    if (libraryItem.mediaType === 'podcast') {\n      libraryItem.media = await libraryItem.getMedia({\n        include: [\n          {\n            model: this.sequelize.models.podcastEpisode\n          }\n        ]\n      })\n    } else {\n      libraryItem.media = await libraryItem.getMedia({\n        include: [\n          {\n            model: this.sequelize.models.author,\n            through: {\n              attributes: []\n            }\n          },\n          {\n            model: this.sequelize.models.series,\n            through: {\n              attributes: ['id', 'sequence']\n            }\n          }\n        ],\n        order: [\n          [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],\n          [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']\n        ]\n      })\n    }\n\n    if (!libraryItem.media) return null\n    return libraryItem\n  }\n\n  /**\n   *\n   * @param {import('sequelize').WhereOptions} where\n   * @param {import('sequelize').BindOrReplacements} [replacements]\n   * @param {import('sequelize').IncludeOptions} [include]\n   * @returns {Promise<LibraryItemExpanded>}\n   */\n  static async findOneExpanded(where, replacements = null, include = null) {\n    const libraryItem = await this.findOne({\n      where,\n      replacements,\n      include\n    })\n    if (!libraryItem) {\n      return null\n    }\n\n    if (libraryItem.mediaType === 'podcast') {\n      libraryItem.media = await libraryItem.getMedia({\n        include: [\n          {\n            model: this.sequelize.models.podcastEpisode\n          }\n        ]\n      })\n    } else {\n      libraryItem.media = await libraryItem.getMedia({\n        include: [\n          {\n            model: this.sequelize.models.author,\n            through: {\n              attributes: []\n            }\n          },\n          {\n            model: this.sequelize.models.series,\n            through: {\n              attributes: ['id', 'sequence']\n            }\n          }\n        ],\n        order: [\n          [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],\n          [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']\n        ]\n      })\n    }\n\n    if (!libraryItem.media) return null\n    return libraryItem\n  }\n\n  /**\n   * Get library items using filter and sort\n   * @param {import('./Library')} library\n   * @param {import('./User')} user\n   * @param {object} options\n   * @returns {{ libraryItems:Object[], count:number }}\n   */\n  static async getByFilterAndSort(library, user, options) {\n    let start = Date.now()\n    const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library.id, user, options)\n    Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`)\n\n    return {\n      libraryItems: libraryItems.map((li) => {\n        const oldLibraryItem = li.toOldJSONMinified()\n        if (li.collapsedSeries) {\n          oldLibraryItem.collapsedSeries = li.collapsedSeries\n        }\n        if (li.series) {\n          oldLibraryItem.media.metadata.series = li.series\n        }\n        if (li.rssFeed) {\n          oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n        }\n        if (li.media.numEpisodes) {\n          oldLibraryItem.media.numEpisodes = li.media.numEpisodes\n        }\n        if (li.size && !oldLibraryItem.media.size) {\n          oldLibraryItem.media.size = li.size\n        }\n        if (li.numEpisodesIncomplete) {\n          oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete\n        }\n        if (li.mediaItemShare) {\n          oldLibraryItem.mediaItemShare = li.mediaItemShare\n        }\n\n        return oldLibraryItem\n      }),\n      count\n    }\n  }\n\n  /**\n   * Get home page data personalized shelves\n   * @param {import('./Library')} library\n   * @param {import('./User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {object[]} array of shelf objects\n   */\n  static async getPersonalizedShelves(library, user, include, limit) {\n    const fullStart = Date.now() // Used for testing load times\n\n    const shelves = []\n\n    const timed = async (loader) => {\n      const start = Date.now()\n      const payload = await loader()\n      return {\n        payload,\n        elapsedSeconds: ((Date.now() - start) / 1000).toFixed(2)\n      }\n    }\n\n    // \"Continue Listening\" shelf\n    const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)\n    if (itemsInProgressPayload.items.length) {\n      const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)\n      const audioItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')\n\n      if (audioItemsInProgress.length) {\n        shelves.push({\n          id: 'continue-listening',\n          label: 'Continue Listening',\n          labelStringKey: 'LabelContinueListening',\n          type: library.isPodcast ? 'episode' : 'book',\n          entities: audioItemsInProgress,\n          total: itemsInProgressPayload.count\n        })\n      }\n\n      if (ebookOnlyItemsInProgress.length) {\n        // \"Continue Reading\" shelf\n        shelves.push({\n          id: 'continue-reading',\n          label: 'Continue Reading',\n          labelStringKey: 'LabelContinueReading',\n          type: 'book',\n          entities: ebookOnlyItemsInProgress,\n          total: itemsInProgressPayload.count\n        })\n      }\n    }\n    Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for \"Continue Listening/Reading\" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)\n\n    if (library.isBook) {\n      const [continueSeriesResult, mostRecentResult, seriesMostRecentResult, discoverResult, mediaFinishedResult, newestAuthorsResult] = await Promise.all([\n        timed(() => libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)),\n        timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),\n        timed(() => libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)),\n        timed(() => libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)),\n        timed(() => libraryFilters.getMediaFinished(library, user, include, limit)),\n        timed(() => libraryFilters.getNewestAuthors(library, user, limit))\n      ])\n\n      const continueSeriesPayload = continueSeriesResult.payload\n      // \"Continue Series\" shelf\n      if (continueSeriesPayload.libraryItems.length) {\n        shelves.push({\n          id: 'continue-series',\n          label: 'Continue Series',\n          labelStringKey: 'LabelContinueSeries',\n          type: 'book',\n          entities: continueSeriesPayload.libraryItems,\n          total: continueSeriesPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for \"Continue Series\" in ${continueSeriesResult.elapsedSeconds}s`)\n\n      const mostRecentPayload = mostRecentResult.payload\n      // \"Recently Added\" shelf\n      if (mostRecentPayload.libraryItems.length) {\n        shelves.push({\n          id: 'recently-added',\n          label: 'Recently Added',\n          labelStringKey: 'LabelRecentlyAdded',\n          type: library.mediaType,\n          entities: mostRecentPayload.libraryItems,\n          total: mostRecentPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for \"Recently Added\" in ${mostRecentResult.elapsedSeconds}s`)\n\n      const seriesMostRecentPayload = seriesMostRecentResult.payload\n      // \"Recent Series\" shelf\n      if (seriesMostRecentPayload.series.length) {\n        shelves.push({\n          id: 'recent-series',\n          label: 'Recent Series',\n          labelStringKey: 'LabelRecentSeries',\n          type: 'series',\n          entities: seriesMostRecentPayload.series,\n          total: seriesMostRecentPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for \"Recent Series\" in ${seriesMostRecentResult.elapsedSeconds}s`)\n\n      const discoverLibraryItemsPayload = discoverResult.payload\n      // \"Discover\" shelf\n      if (discoverLibraryItemsPayload.libraryItems.length) {\n        shelves.push({\n          id: 'discover',\n          label: 'Discover',\n          labelStringKey: 'LabelDiscover',\n          type: library.mediaType,\n          entities: discoverLibraryItemsPayload.libraryItems,\n          total: discoverLibraryItemsPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for \"Discover\" in ${discoverResult.elapsedSeconds}s`)\n\n      const mediaFinishedPayload = mediaFinishedResult.payload\n      // \"Listen Again\" shelf\n      if (mediaFinishedPayload.items.length) {\n        const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)\n        const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')\n\n        if (audioItemsInProgress.length) {\n          shelves.push({\n            id: 'listen-again',\n            label: 'Listen Again',\n            labelStringKey: 'LabelListenAgain',\n            type: library.isPodcast ? 'episode' : 'book',\n            entities: audioItemsInProgress,\n            total: mediaFinishedPayload.count\n          })\n        }\n\n        if (ebookOnlyItemsInProgress.length) {\n          // \"Read Again\" shelf\n          shelves.push({\n            id: 'read-again',\n            label: 'Read Again',\n            labelStringKey: 'LabelReadAgain',\n            type: 'book',\n            entities: ebookOnlyItemsInProgress,\n            total: mediaFinishedPayload.count\n          })\n        }\n      }\n      Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for \"Listen/Read Again\" in ${mediaFinishedResult.elapsedSeconds}s`)\n\n      const newestAuthorsPayload = newestAuthorsResult.payload\n      // \"Newest Authors\" shelf\n      if (newestAuthorsPayload.authors.length) {\n        shelves.push({\n          id: 'newest-authors',\n          label: 'Newest Authors',\n          labelStringKey: 'LabelNewestAuthors',\n          type: 'authors',\n          entities: newestAuthorsPayload.authors,\n          total: newestAuthorsPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for \"Newest Authors\" in ${newestAuthorsResult.elapsedSeconds}s`)\n    } else if (library.isPodcast) {\n      const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([\n        timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)),\n        timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),\n        timed(() => libraryFilters.getMediaFinished(library, user, include, limit))\n      ])\n\n      const newestEpisodesPayload = newestEpisodesResult.payload\n      // \"Newest Episodes\" shelf\n      if (newestEpisodesPayload.libraryItems.length) {\n        shelves.push({\n          id: 'newest-episodes',\n          label: 'Newest Episodes',\n          labelStringKey: 'LabelNewestEpisodes',\n          type: 'episode',\n          entities: newestEpisodesPayload.libraryItems,\n          total: newestEpisodesPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for \"Newest Episodes\" in ${newestEpisodesResult.elapsedSeconds}s`)\n\n      const mostRecentPayload = mostRecentResult.payload\n      // \"Recently Added\" shelf\n      if (mostRecentPayload.libraryItems.length) {\n        shelves.push({\n          id: 'recently-added',\n          label: 'Recently Added',\n          labelStringKey: 'LabelRecentlyAdded',\n          type: library.mediaType,\n          entities: mostRecentPayload.libraryItems,\n          total: mostRecentPayload.count\n        })\n      }\n      Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for \"Recently Added\" in ${mostRecentResult.elapsedSeconds}s`)\n\n      const mediaFinishedPayload = mediaFinishedResult.payload\n      // \"Listen Again\" shelf\n      if (mediaFinishedPayload.items.length) {\n        const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)\n        const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')\n\n        if (audioItemsInProgress.length) {\n          shelves.push({\n            id: 'listen-again',\n            label: 'Listen Again',\n            labelStringKey: 'LabelListenAgain',\n            type: 'episode',\n            entities: audioItemsInProgress,\n            total: mediaFinishedPayload.count\n          })\n        }\n\n        if (ebookOnlyItemsInProgress.length) {\n          // \"Read Again\" shelf\n          shelves.push({\n            id: 'read-again',\n            label: 'Read Again',\n            labelStringKey: 'LabelReadAgain',\n            type: 'book',\n            entities: ebookOnlyItemsInProgress,\n            total: mediaFinishedPayload.count\n          })\n        }\n      }\n      Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for \"Listen/Read Again\" in ${mediaFinishedResult.elapsedSeconds}s`)\n    }\n\n    Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)\n\n    return shelves\n  }\n\n  /**\n   * Get book library items for author, optional use user permissions\n   * @param {import('./Author')} author\n   * @param {import('./User')} user\n   * @returns {Promise<LibraryItemExpanded[]>}\n   */\n  static async getForAuthor(author, user = null) {\n    const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined)\n    return libraryItems\n  }\n\n  /**\n   * Check if library item exists\n   * @param {string} libraryItemId\n   * @returns {Promise<boolean>}\n   */\n  static async checkExistsById(libraryItemId) {\n    return (await this.count({ where: { id: libraryItemId } })) > 0\n  }\n\n  /**\n   *\n   * @param {string} libraryItemId\n   * @returns {Promise<string>}\n   */\n  static async getCoverPath(libraryItemId) {\n    const libraryItem = await this.findByPk(libraryItemId, {\n      attributes: ['id', 'mediaType', 'mediaId', 'libraryId'],\n      include: [\n        {\n          model: this.sequelize.models.book,\n          attributes: ['id', 'coverPath']\n        },\n        {\n          model: this.sequelize.models.podcast,\n          attributes: ['id', 'coverPath']\n        }\n      ]\n    })\n    if (!libraryItem) {\n      Logger.warn(`[LibraryItem] getCoverPath: Library item \"${libraryItemId}\" does not exist`)\n      return null\n    }\n\n    return libraryItem.media.coverPath\n  }\n\n  /**\n   *\n   * @returns {Promise}\n   */\n  async saveMetadataFile() {\n    let metadataPath = Path.join(global.MetadataPath, 'items', this.id)\n    let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem\n    if (storeMetadataWithItem && !this.isFile) {\n      metadataPath = this.path\n    } else {\n      // Make sure metadata book dir exists\n      storeMetadataWithItem = false\n      await fsExtra.ensureDir(metadataPath)\n    }\n\n    const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)\n\n    // Expanded with series, authors, podcastEpisodes\n    const mediaExpanded = this.media || (await this.getMediaExpanded())\n\n    let jsonObject = {}\n    if (this.mediaType === 'book') {\n      jsonObject = {\n        tags: mediaExpanded.tags || [],\n        chapters: mediaExpanded.chapters?.map((c) => ({ ...c })) || [],\n        title: mediaExpanded.title,\n        subtitle: mediaExpanded.subtitle,\n        authors: mediaExpanded.authors.map((a) => a.name),\n        narrators: mediaExpanded.narrators,\n        series: mediaExpanded.series.map((se) => {\n          const sequence = se.bookSeries?.sequence || ''\n          if (!sequence) return se.name\n          return `${se.name} #${sequence}`\n        }),\n        genres: mediaExpanded.genres || [],\n        publishedYear: mediaExpanded.publishedYear,\n        publishedDate: mediaExpanded.publishedDate,\n        publisher: mediaExpanded.publisher,\n        description: mediaExpanded.description,\n        isbn: mediaExpanded.isbn,\n        asin: mediaExpanded.asin,\n        language: mediaExpanded.language,\n        explicit: !!mediaExpanded.explicit,\n        abridged: !!mediaExpanded.abridged\n      }\n    } else {\n      jsonObject = {\n        tags: mediaExpanded.tags || [],\n        title: mediaExpanded.title,\n        author: mediaExpanded.author,\n        description: mediaExpanded.description,\n        releaseDate: mediaExpanded.releaseDate,\n        genres: mediaExpanded.genres || [],\n        feedURL: mediaExpanded.feedURL,\n        imageURL: mediaExpanded.imageURL,\n        itunesPageURL: mediaExpanded.itunesPageURL,\n        itunesId: mediaExpanded.itunesId,\n        itunesArtistId: mediaExpanded.itunesArtistId,\n        asin: mediaExpanded.asin,\n        language: mediaExpanded.language,\n        explicit: !!mediaExpanded.explicit,\n        podcastType: mediaExpanded.podcastType\n      }\n    }\n\n    return fsExtra\n      .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))\n      .then(async () => {\n        // Add metadata.json to libraryFiles array if it is new\n        let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))\n        if (storeMetadataWithItem) {\n          if (!metadataLibraryFile) {\n            const newLibraryFile = new LibraryFile()\n            await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)\n            metadataLibraryFile = newLibraryFile.toJSON()\n            this.libraryFiles.push(metadataLibraryFile)\n          } else {\n            const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)\n            if (fileTimestamps) {\n              metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs\n              metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs\n              metadataLibraryFile.metadata.size = fileTimestamps.size\n              metadataLibraryFile.ino = fileTimestamps.ino\n            }\n          }\n          const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)\n          if (libraryItemDirTimestamps) {\n            this.mtime = libraryItemDirTimestamps.mtimeMs\n            this.ctime = libraryItemDirTimestamps.ctimeMs\n            let size = 0\n            this.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))\n            this.size = size\n            await this.save()\n          }\n        }\n\n        Logger.debug(`[LibraryItem] Saved metadata for \"${this.media.title}\" file to \"${metadataFilePath}\"`)\n\n        return metadataLibraryFile\n      })\n      .catch((error) => {\n        Logger.error(`Failed to save json file at \"${metadataFilePath}\"`, error)\n        return null\n      })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        ino: DataTypes.STRING,\n        path: DataTypes.STRING,\n        relPath: DataTypes.STRING,\n        mediaId: DataTypes.UUID,\n        mediaType: DataTypes.STRING,\n        isFile: DataTypes.BOOLEAN,\n        isMissing: DataTypes.BOOLEAN,\n        isInvalid: DataTypes.BOOLEAN,\n        mtime: DataTypes.DATE(6),\n        ctime: DataTypes.DATE(6),\n        birthtime: DataTypes.DATE(6),\n        size: DataTypes.BIGINT,\n        lastScan: DataTypes.DATE,\n        lastScanVersion: DataTypes.STRING,\n        libraryFiles: DataTypes.JSON,\n        extraData: DataTypes.JSON,\n        title: DataTypes.STRING,\n        titleIgnorePrefix: DataTypes.STRING,\n        authorNamesFirstLast: DataTypes.STRING,\n        authorNamesLastFirst: DataTypes.STRING\n      },\n      {\n        sequelize,\n        modelName: 'libraryItem',\n        indexes: [\n          {\n            fields: ['createdAt']\n          },\n          {\n            fields: ['mediaId']\n          },\n          {\n            fields: ['libraryId', 'mediaType']\n          },\n          {\n            fields: ['libraryId', 'mediaType', 'size']\n          },\n          {\n            fields: ['libraryId', 'mediaType', 'createdAt']\n          },\n          {\n            fields: ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }]\n          },\n          {\n            fields: ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }]\n          },\n          {\n            fields: ['libraryId', 'mediaType', { name: 'authorNamesFirstLast', collate: 'NOCASE' }]\n          },\n          {\n            fields: ['libraryId', 'mediaType', { name: 'authorNamesLastFirst', collate: 'NOCASE' }]\n          },\n          {\n            fields: ['libraryId', 'mediaId', 'mediaType']\n          },\n          {\n            fields: ['birthtime']\n          },\n          {\n            fields: ['mtime']\n          }\n        ]\n      }\n    )\n\n    const { library, libraryFolder, book, podcast } = sequelize.models\n    library.hasMany(LibraryItem)\n    LibraryItem.belongsTo(library)\n\n    libraryFolder.hasMany(LibraryItem)\n    LibraryItem.belongsTo(libraryFolder)\n\n    book.hasOne(LibraryItem, {\n      foreignKey: 'mediaId',\n      constraints: false,\n      scope: {\n        mediaType: 'book'\n      }\n    })\n    LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })\n\n    podcast.hasOne(LibraryItem, {\n      foreignKey: 'mediaId',\n      constraints: false,\n      scope: {\n        mediaType: 'podcast'\n      }\n    })\n    LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })\n\n    LibraryItem.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n      for (const instance of findResult) {\n        if (instance.mediaType === 'book' && instance.book !== undefined) {\n          instance.media = instance.book\n          instance.dataValues.media = instance.dataValues.book\n        } else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) {\n          instance.media = instance.podcast\n          instance.dataValues.media = instance.dataValues.podcast\n        }\n        // To prevent mistakes:\n        delete instance.book\n        delete instance.dataValues.book\n        delete instance.podcast\n        delete instance.dataValues.podcast\n      }\n    })\n\n    LibraryItem.addHook('afterDestroy', async (instance) => {\n      if (!instance) return\n      const media = await instance.getMedia()\n      if (media) {\n        media.destroy()\n      }\n    })\n  }\n\n  get isBook() {\n    return this.mediaType === 'book'\n  }\n  get isPodcast() {\n    return this.mediaType === 'podcast'\n  }\n  get hasAudioTracks() {\n    return this.media.hasAudioTracks()\n  }\n\n  /**\n   *\n   * @param {import('sequelize').FindOptions} options\n   * @returns {Promise<Book|Podcast>}\n   */\n  getMedia(options) {\n    if (!this.mediaType) return Promise.resolve(null)\n    const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`\n    return this[mixinMethodName](options)\n  }\n\n  /**\n   *\n   * @returns {Promise<Book|Podcast>}\n   */\n  getMediaExpanded() {\n    if (this.mediaType === 'podcast') {\n      return this.getMedia({\n        include: [\n          {\n            model: this.sequelize.models.podcastEpisode\n          }\n        ]\n      })\n    } else {\n      return this.getMedia({\n        include: [\n          {\n            model: this.sequelize.models.author,\n            through: {\n              attributes: []\n            }\n          },\n          {\n            model: this.sequelize.models.series,\n            through: {\n              attributes: ['sequence']\n            }\n          }\n        ],\n        order: [\n          [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],\n          [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']\n        ]\n      })\n    }\n  }\n\n  /**\n   * Check if book or podcast library item has audio tracks\n   * Requires expanded library item\n   *\n   * @returns {boolean}\n   */\n  hasAudioTracks() {\n    if (!this.media) {\n      Logger.error(`[LibraryItem] hasAudioTracks: Library item \"${this.id}\" does not have media`)\n      return false\n    }\n    if (this.isBook) {\n      return this.media.audioFiles?.length > 0\n    } else {\n      return this.media.podcastEpisodes?.length > 0\n    }\n  }\n\n  /**\n   *\n   * @param {string} ino\n   * @returns {import('./Book').AudioFileObject}\n   */\n  getAudioFileWithIno(ino) {\n    if (!this.media) {\n      Logger.error(`[LibraryItem] getAudioFileWithIno: Library item \"${this.id}\" does not have media`)\n      return null\n    }\n    if (this.isBook) {\n      return this.media.audioFiles.find((af) => af.ino === ino)\n    } else {\n      return this.media.podcastEpisodes.find((pe) => pe.audioFile?.ino === ino)?.audioFile\n    }\n  }\n\n  /**\n   * Get the track list to be used in client audio players\n   * AudioTrack is the AudioFile with startOffset and contentUrl\n   * Podcasts must have an episodeId to get the track list\n   *\n   * @param {string} [episodeId]\n   * @returns {import('./Book').AudioTrack[]}\n   */\n  getTrackList(episodeId) {\n    if (!this.media) {\n      Logger.error(`[LibraryItem] getTrackList: Library item \"${this.id}\" does not have media`)\n      return []\n    }\n    return this.media.getTracklist(this.id, episodeId)\n  }\n\n  /**\n   *\n   * @param {string} ino\n   * @returns {LibraryFile}\n   */\n  getLibraryFileWithIno(ino) {\n    const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)\n    if (!libraryFile) return null\n    return new LibraryFile(libraryFile)\n  }\n\n  getLibraryFiles() {\n    return this.libraryFiles.map((lf) => new LibraryFile(lf))\n  }\n\n  getLibraryFilesJson() {\n    return this.libraryFiles.map((lf) => new LibraryFile(lf).toJSON())\n  }\n\n  toOldJSON() {\n    if (!this.media) {\n      throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item \"${this.id}\"`)\n    }\n\n    return {\n      id: this.id,\n      ino: this.ino,\n      oldLibraryItemId: this.extraData?.oldLibraryItemId || null,\n      libraryId: this.libraryId,\n      folderId: this.libraryFolderId,\n      path: this.path,\n      relPath: this.relPath,\n      isFile: this.isFile,\n      mtimeMs: this.mtime?.valueOf(),\n      ctimeMs: this.ctime?.valueOf(),\n      birthtimeMs: this.birthtime?.valueOf(),\n      addedAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf(),\n      lastScan: this.lastScan?.valueOf(),\n      scanVersion: this.lastScanVersion,\n      isMissing: !!this.isMissing,\n      isInvalid: !!this.isInvalid,\n      mediaType: this.mediaType,\n      media: this.media.toOldJSON(this.id),\n      // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database\n      libraryFiles: this.getLibraryFilesJson()\n    }\n  }\n\n  toOldJSONMinified() {\n    if (!this.media) {\n      throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item \"${this.id}\"`)\n    }\n\n    return {\n      id: this.id,\n      ino: this.ino,\n      oldLibraryItemId: this.extraData?.oldLibraryItemId || null,\n      libraryId: this.libraryId,\n      folderId: this.libraryFolderId,\n      path: this.path,\n      relPath: this.relPath,\n      isFile: this.isFile,\n      mtimeMs: this.mtime?.valueOf(),\n      ctimeMs: this.ctime?.valueOf(),\n      birthtimeMs: this.birthtime?.valueOf(),\n      addedAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf(),\n      isMissing: !!this.isMissing,\n      isInvalid: !!this.isInvalid,\n      mediaType: this.mediaType,\n      media: this.media.toOldJSONMinified(),\n      numFiles: this.libraryFiles.length,\n      size: this.size\n    }\n  }\n\n  toOldJSONExpanded() {\n    return {\n      id: this.id,\n      ino: this.ino,\n      oldLibraryItemId: this.extraData?.oldLibraryItemId || null,\n      libraryId: this.libraryId,\n      folderId: this.libraryFolderId,\n      path: this.path,\n      relPath: this.relPath,\n      isFile: this.isFile,\n      mtimeMs: this.mtime?.valueOf(),\n      ctimeMs: this.ctime?.valueOf(),\n      birthtimeMs: this.birthtime?.valueOf(),\n      addedAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf(),\n      lastScan: this.lastScan?.valueOf(),\n      scanVersion: this.lastScanVersion,\n      isMissing: !!this.isMissing,\n      isInvalid: !!this.isInvalid,\n      mediaType: this.mediaType,\n      media: this.media.toOldJSONExpanded(this.id),\n      // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database\n      libraryFiles: this.getLibraryFilesJson(),\n      size: this.size\n    }\n  }\n}\n\nmodule.exports = LibraryItem\n"
  },
  {
    "path": "server/models/MediaItemShare.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\n/**\n * @typedef MediaItemShareObject\n * @property {UUIDV4} id\n * @property {UUIDV4} mediaItemId\n * @property {string} mediaItemType\n * @property {string} slug\n * @property {string} pash\n * @property {UUIDV4} userId\n * @property {Date} expiresAt\n * @property {Object} extraData\n * @property {Date} createdAt\n * @property {Date} updatedAt\n * @property {boolean} isDownloadable\n *\n * @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel\n */\n\n/**\n * @typedef MediaItemShareForClient\n * @property {UUIDV4} id\n * @property {UUIDV4} mediaItemId\n * @property {string} mediaItemType\n * @property {string} slug\n * @property {Date} expiresAt\n * @property {Date} createdAt\n * @property {Date} updatedAt\n * @property {boolean} isDownloadable\n */\n\nclass MediaItemShare extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {UUIDV4} */\n    this.mediaItemId\n    /** @type {string} */\n    this.mediaItemType\n    /** @type {string} */\n    this.slug\n    /** @type {string} */\n    this.pash\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {Date} */\n    this.expiresAt\n    /** @type {Object} */\n    this.extraData\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {boolean} */\n    this.isDownloadable\n\n    // Expanded properties\n\n    /** @type {import('./Book')|import('./PodcastEpisode')} */\n    this.mediaItem\n  }\n\n  toJSONForClient() {\n    return {\n      id: this.id,\n      mediaItemId: this.mediaItemId,\n      mediaItemType: this.mediaItemType,\n      slug: this.slug,\n      expiresAt: this.expiresAt,\n      createdAt: this.createdAt,\n      updatedAt: this.updatedAt,\n      isDownloadable: this.isDownloadable\n    }\n  }\n\n  /**\n   * Expanded book that includes library settings\n   *\n   * @param {string} mediaItemId\n   * @param {string} mediaItemType\n   * @returns {Promise<import('./LibraryItem').LibraryItemExpanded>}\n   */\n  static async getMediaItemsLibraryItem(mediaItemId, mediaItemType) {\n    /** @type {typeof import('./LibraryItem')} */\n    const libraryItemModel = this.sequelize.models.libraryItem\n\n    if (mediaItemType === 'book') {\n      const libraryItem = await libraryItemModel.findOneExpanded({ mediaId: mediaItemId }, null, {\n        model: this.sequelize.models.library,\n        attributes: ['settings']\n      })\n\n      return libraryItem\n    }\n    return null\n  }\n\n  /**\n   *\n   * @param {import('sequelize').FindOptions} options\n   * @returns {Promise<import('./Book')|import('./PodcastEpisode')>}\n   */\n  getMediaItem(options) {\n    if (!this.mediaItemType) return Promise.resolve(null)\n    const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`\n    return this[mixinMethodName](options)\n  }\n\n  /**\n   * Initialize model\n   *\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        mediaItemId: DataTypes.UUID,\n        mediaItemType: DataTypes.STRING,\n        slug: DataTypes.STRING,\n        pash: DataTypes.STRING,\n        expiresAt: DataTypes.DATE,\n        extraData: DataTypes.JSON,\n        isDownloadable: DataTypes.BOOLEAN\n      },\n      {\n        sequelize,\n        modelName: 'mediaItemShare'\n      }\n    )\n\n    const { user, book, podcastEpisode } = sequelize.models\n\n    user.hasMany(MediaItemShare)\n    MediaItemShare.belongsTo(user)\n\n    book.hasMany(MediaItemShare, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'book'\n      }\n    })\n    MediaItemShare.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })\n\n    podcastEpisode.hasOne(MediaItemShare, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'podcastEpisode'\n      }\n    })\n    MediaItemShare.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })\n\n    MediaItemShare.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n\n      for (const instance of findResult) {\n        if (instance.mediaItemType === 'book' && instance.book !== undefined) {\n          instance.mediaItem = instance.book\n          instance.dataValues.mediaItem = instance.dataValues.book\n        } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {\n          instance.mediaItem = instance.podcastEpisode\n          instance.dataValues.mediaItem = instance.dataValues.podcastEpisode\n        }\n        // To prevent mistakes:\n        delete instance.book\n        delete instance.dataValues.book\n        delete instance.podcastEpisode\n        delete instance.dataValues.podcastEpisode\n      }\n    })\n  }\n}\n\nmodule.exports = MediaItemShare\n"
  },
  {
    "path": "server/models/MediaProgress.js",
    "content": "const { DataTypes, Model } = require('sequelize')\nconst Logger = require('../Logger')\nconst { isNullOrNaN } = require('../utils')\n\nclass MediaProgress extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {UUIDV4} */\n    this.mediaItemId\n    /** @type {string} */\n    this.mediaItemType\n    /** @type {number} */\n    this.duration\n    /** @type {number} */\n    this.currentTime\n    /** @type {boolean} */\n    this.isFinished\n    /** @type {boolean} */\n    this.hideFromContinueListening\n    /** @type {string} */\n    this.ebookLocation\n    /** @type {number} */\n    this.ebookProgress\n    /** @type {Date} */\n    this.finishedAt\n    /** @type {Object} */\n    this.extraData\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {Date} */\n    this.createdAt\n    /** @type {UUIDV4} */\n    this.podcastId\n  }\n\n  static removeById(mediaProgressId) {\n    return this.destroy({\n      where: {\n        id: mediaProgressId\n      }\n    })\n  }\n\n  /**\n   * Initialize model\n   *\n   * Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress.\n   * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/\n   *\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        mediaItemId: DataTypes.UUID,\n        mediaItemType: DataTypes.STRING,\n        duration: DataTypes.FLOAT,\n        currentTime: DataTypes.FLOAT,\n        isFinished: DataTypes.BOOLEAN,\n        hideFromContinueListening: DataTypes.BOOLEAN,\n        ebookLocation: DataTypes.STRING,\n        ebookProgress: DataTypes.FLOAT,\n        finishedAt: DataTypes.DATE,\n        extraData: DataTypes.JSON,\n        podcastId: DataTypes.UUID\n      },\n      {\n        sequelize,\n        modelName: 'mediaProgress',\n        indexes: [\n          {\n            fields: ['updatedAt']\n          },\n          {\n            name: 'media_progresses_user_item_finished_time',\n            fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime']\n          }\n        ]\n      }\n    )\n\n    const { book, podcastEpisode, user } = sequelize.models\n\n    book.hasMany(MediaProgress, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'book'\n      }\n    })\n    MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })\n\n    podcastEpisode.hasMany(MediaProgress, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'podcastEpisode'\n      }\n    })\n    MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })\n\n    MediaProgress.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n\n      for (const instance of findResult) {\n        if (instance.mediaItemType === 'book' && instance.book !== undefined) {\n          instance.mediaItem = instance.book\n          instance.dataValues.mediaItem = instance.dataValues.book\n        } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {\n          instance.mediaItem = instance.podcastEpisode\n          instance.dataValues.mediaItem = instance.dataValues.podcastEpisode\n        }\n        // To prevent mistakes:\n        delete instance.book\n        delete instance.dataValues.book\n        delete instance.podcastEpisode\n        delete instance.dataValues.podcastEpisode\n      }\n    })\n\n    // make sure to call the afterDestroy hook for each instance\n    MediaProgress.addHook('beforeBulkDestroy', (options) => {\n      options.individualHooks = true\n    })\n\n    // update the potentially cached user after destroying the media progress\n    MediaProgress.addHook('afterDestroy', (instance) => {\n      user.mediaProgressRemoved(instance)\n    })\n\n    user.hasMany(MediaProgress, {\n      onDelete: 'CASCADE'\n    })\n    MediaProgress.belongsTo(user)\n  }\n\n  getMediaItem(options) {\n    if (!this.mediaItemType) return Promise.resolve(null)\n    const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`\n    return this[mixinMethodName](options)\n  }\n\n  getOldMediaProgress() {\n    const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'\n\n    return {\n      id: this.id,\n      userId: this.userId,\n      libraryItemId: this.extraData?.libraryItemId || null,\n      episodeId: isPodcastEpisode ? this.mediaItemId : null,\n      mediaItemId: this.mediaItemId,\n      mediaItemType: this.mediaItemType,\n      duration: this.duration,\n      progress: this.extraData?.progress || 0,\n      currentTime: this.currentTime,\n      isFinished: !!this.isFinished,\n      hideFromContinueListening: !!this.hideFromContinueListening,\n      ebookLocation: this.ebookLocation,\n      ebookProgress: this.ebookProgress,\n      lastUpdate: this.updatedAt.valueOf(),\n      startedAt: this.createdAt.valueOf(),\n      finishedAt: this.finishedAt?.valueOf() || null\n    }\n  }\n\n  get progress() {\n    // Value between 0 and 1\n    if (!this.duration) return 0\n    return Math.max(0, Math.min(this.currentTime / this.duration, 1))\n  }\n\n  /**\n   * Apply update to media progress\n   *\n   * @param {import('./User').ProgressUpdatePayload} progressPayload\n   * @returns {Promise<MediaProgress>}\n   */\n  async applyProgressUpdate(progressPayload) {\n    if (!this.extraData) this.extraData = {}\n    if (progressPayload.isFinished !== undefined) {\n      if (progressPayload.isFinished && !this.isFinished) {\n        this.finishedAt = progressPayload.finishedAt || Date.now()\n        this.extraData.progress = 1\n        this.changed('extraData', true)\n        delete progressPayload.finishedAt\n      } else if (!progressPayload.isFinished && this.isFinished) {\n        this.finishedAt = null\n        this.extraData.progress = 0\n        this.currentTime = 0\n        this.changed('extraData', true)\n        delete progressPayload.finishedAt\n        delete progressPayload.currentTime\n      }\n    } else if (!isNaN(progressPayload.progress) && progressPayload.progress !== this.progress) {\n      // Old model stored progress on object\n      this.extraData.progress = Math.min(1, Math.max(0, progressPayload.progress))\n      this.changed('extraData', true)\n    }\n\n    this.set(progressPayload)\n\n    // Reset hideFromContinueListening if the progress has changed\n    if (this.changed('currentTime') && !progressPayload.hideFromContinueListening) {\n      this.hideFromContinueListening = false\n    }\n\n    const timeRemaining = this.duration - this.currentTime\n\n    // Check if progress is far enough to mark as finished\n    //   - If markAsFinishedPercentComplete is provided, use that otherwise use markAsFinishedTimeRemaining (default 10 seconds)\n    let shouldMarkAsFinished = false\n    if (this.duration) {\n      if (!isNullOrNaN(progressPayload.markAsFinishedPercentComplete) && progressPayload.markAsFinishedPercentComplete > 0) {\n        const markAsFinishedPercentComplete = Number(progressPayload.markAsFinishedPercentComplete) / 100\n        shouldMarkAsFinished = markAsFinishedPercentComplete < this.progress\n        if (shouldMarkAsFinished) {\n          Logger.info(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete} (media item ${this.mediaItemId})`)\n        }\n      } else {\n        const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 10 : Number(progressPayload.markAsFinishedTimeRemaining)\n        shouldMarkAsFinished = timeRemaining < markAsFinishedTimeRemaining\n        if (shouldMarkAsFinished) {\n          Logger.info(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds (media item ${this.mediaItemId})`)\n        }\n      }\n    }\n\n    if (!this.isFinished && shouldMarkAsFinished) {\n      this.isFinished = true\n      this.finishedAt = this.finishedAt || Date.now()\n      this.extraData.progress = 1\n      this.changed('extraData', true)\n    } else if (this.isFinished && this.changed('currentTime') && !shouldMarkAsFinished) {\n      this.isFinished = false\n      this.finishedAt = null\n    }\n\n    await this.save()\n\n    // For local sync\n    if (progressPayload.lastUpdate) {\n      if (isNaN(new Date(progressPayload.lastUpdate))) {\n        Logger.warn(`[MediaProgress] Invalid date provided for lastUpdate: ${progressPayload.lastUpdate} (media item ${this.mediaItemId})`)\n      } else {\n        const escapedDate = this.sequelize.escape(new Date(progressPayload.lastUpdate))\n        Logger.info(`[MediaProgress] Manually setting updatedAt to ${escapedDate} (media item ${this.mediaItemId})`)\n\n        await this.sequelize.query(`UPDATE \"mediaProgresses\" SET \"updatedAt\" = ${escapedDate} WHERE \"id\" = '${this.id}'`)\n\n        await this.reload()\n      }\n    }\n\n    return this\n  }\n}\n\nmodule.exports = MediaProgress\n"
  },
  {
    "path": "server/models/PlaybackSession.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nconst oldPlaybackSession = require('../objects/PlaybackSession')\n\nclass PlaybackSession extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {UUIDV4} */\n    this.mediaItemId\n    /** @type {string} */\n    this.mediaItemType\n    /** @type {string} */\n    this.displayTitle\n    /** @type {string} */\n    this.displayAuthor\n    /** @type {number} */\n    this.duration\n    /** @type {number} */\n    this.playMethod\n    /** @type {string} */\n    this.mediaPlayer\n    /** @type {number} */\n    this.startTime\n    /** @type {number} */\n    this.currentTime\n    /** @type {string} */\n    this.serverVersion\n    /** @type {string} */\n    this.coverPath\n    /** @type {number} */\n    this.timeListening\n    /** @type {Object} */\n    this.mediaMetadata\n    /** @type {string} */\n    this.date\n    /** @type {string} */\n    this.dayOfWeek\n    /** @type {Object} */\n    this.extraData\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {UUIDV4} */\n    this.deviceId\n    /** @type {UUIDV4} */\n    this.libraryId\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {Date} */\n    this.createdAt\n  }\n\n  static async getOldPlaybackSessions(where = null) {\n    const playbackSessions = await this.findAll({\n      where,\n      include: [\n        {\n          model: this.sequelize.models.device\n        }\n      ]\n    })\n    return playbackSessions.map((session) => this.getOldPlaybackSession(session))\n  }\n\n  static async getById(sessionId) {\n    const playbackSession = await this.findByPk(sessionId, {\n      include: [\n        {\n          model: this.sequelize.models.device\n        }\n      ]\n    })\n    if (!playbackSession) return null\n    return this.getOldPlaybackSession(playbackSession)\n  }\n\n  static getOldPlaybackSession(playbackSessionExpanded) {\n    const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'\n\n    return new oldPlaybackSession({\n      id: playbackSessionExpanded.id,\n      userId: playbackSessionExpanded.userId,\n      libraryId: playbackSessionExpanded.libraryId,\n      libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,\n      bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,\n      episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null,\n      mediaType: isPodcastEpisode ? 'podcast' : 'book',\n      mediaMetadata: playbackSessionExpanded.mediaMetadata,\n      chapters: null,\n      displayTitle: playbackSessionExpanded.displayTitle,\n      displayAuthor: playbackSessionExpanded.displayAuthor,\n      coverPath: playbackSessionExpanded.coverPath,\n      duration: playbackSessionExpanded.duration,\n      playMethod: playbackSessionExpanded.playMethod,\n      mediaPlayer: playbackSessionExpanded.mediaPlayer,\n      deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null,\n      serverVersion: playbackSessionExpanded.serverVersion,\n      date: playbackSessionExpanded.date,\n      dayOfWeek: playbackSessionExpanded.dayOfWeek,\n      timeListening: playbackSessionExpanded.timeListening,\n      startTime: playbackSessionExpanded.startTime,\n      currentTime: playbackSessionExpanded.currentTime,\n      startedAt: playbackSessionExpanded.createdAt.valueOf(),\n      updatedAt: playbackSessionExpanded.updatedAt.valueOf()\n    })\n  }\n\n  static removeById(sessionId) {\n    return this.destroy({\n      where: {\n        id: sessionId\n      }\n    })\n  }\n\n  static createFromOld(oldPlaybackSession) {\n    const playbackSession = this.getFromOld(oldPlaybackSession)\n    return this.upsert(playbackSession, {\n      silent: true\n    })\n  }\n\n  static updateFromOld(oldPlaybackSession) {\n    const playbackSession = this.getFromOld(oldPlaybackSession)\n    return this.update(playbackSession, {\n      where: {\n        id: playbackSession.id\n      },\n      silent: true\n    })\n  }\n\n  static getFromOld(oldPlaybackSession) {\n    return {\n      id: oldPlaybackSession.id,\n      mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId,\n      mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book',\n      libraryId: oldPlaybackSession.libraryId,\n      displayTitle: oldPlaybackSession.displayTitle,\n      displayAuthor: oldPlaybackSession.displayAuthor,\n      duration: oldPlaybackSession.duration,\n      playMethod: oldPlaybackSession.playMethod,\n      mediaPlayer: oldPlaybackSession.mediaPlayer,\n      startTime: oldPlaybackSession.startTime,\n      currentTime: oldPlaybackSession.currentTime,\n      serverVersion: oldPlaybackSession.serverVersion || null,\n      createdAt: oldPlaybackSession.startedAt,\n      updatedAt: oldPlaybackSession.updatedAt,\n      userId: oldPlaybackSession.userId,\n      deviceId: oldPlaybackSession.deviceInfo?.id || null,\n      timeListening: oldPlaybackSession.timeListening,\n      coverPath: oldPlaybackSession.coverPath,\n      mediaMetadata: oldPlaybackSession.mediaMetadata,\n      date: oldPlaybackSession.date,\n      dayOfWeek: oldPlaybackSession.dayOfWeek,\n      extraData: {\n        libraryItemId: oldPlaybackSession.libraryItemId\n      }\n    }\n  }\n\n  getMediaItem(options) {\n    if (!this.mediaItemType) return Promise.resolve(null)\n    const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`\n    return this[mixinMethodName](options)\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        mediaItemId: DataTypes.UUID,\n        mediaItemType: DataTypes.STRING,\n        displayTitle: DataTypes.STRING,\n        displayAuthor: DataTypes.STRING,\n        duration: DataTypes.FLOAT,\n        playMethod: DataTypes.INTEGER,\n        mediaPlayer: DataTypes.STRING,\n        startTime: DataTypes.FLOAT,\n        currentTime: DataTypes.FLOAT,\n        serverVersion: DataTypes.STRING,\n        coverPath: DataTypes.STRING,\n        timeListening: DataTypes.INTEGER,\n        mediaMetadata: DataTypes.JSON,\n        date: DataTypes.STRING,\n        dayOfWeek: DataTypes.STRING,\n        extraData: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'playbackSession'\n      }\n    )\n\n    const { book, podcastEpisode, user, device, library } = sequelize.models\n\n    user.hasMany(PlaybackSession)\n    PlaybackSession.belongsTo(user)\n\n    device.hasMany(PlaybackSession)\n    PlaybackSession.belongsTo(device)\n\n    library.hasMany(PlaybackSession)\n    PlaybackSession.belongsTo(library)\n\n    book.hasMany(PlaybackSession, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'book'\n      }\n    })\n    PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })\n\n    podcastEpisode.hasOne(PlaybackSession, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'podcastEpisode'\n      }\n    })\n    PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })\n\n    PlaybackSession.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n\n      for (const instance of findResult) {\n        if (instance.mediaItemType === 'book' && instance.book !== undefined) {\n          instance.mediaItem = instance.book\n          instance.dataValues.mediaItem = instance.dataValues.book\n        } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {\n          instance.mediaItem = instance.podcastEpisode\n          instance.dataValues.mediaItem = instance.dataValues.podcastEpisode\n        }\n        // To prevent mistakes:\n        delete instance.book\n        delete instance.dataValues.book\n        delete instance.podcastEpisode\n        delete instance.dataValues.podcastEpisode\n      }\n    })\n  }\n}\n\nmodule.exports = PlaybackSession\n"
  },
  {
    "path": "server/models/Playlist.js",
    "content": "const { DataTypes, Model, Op } = require('sequelize')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\n\nclass Playlist extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.name\n    /** @type {string} */\n    this.description\n    /** @type {UUIDV4} */\n    this.libraryId\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n\n    // Expanded properties\n\n    /** @type {import('./PlaylistMediaItem')[]} - only set when expanded */\n    this.playlistMediaItems\n  }\n\n  /**\n   * Get old playlists for user and library\n   *\n   * @param {string} userId\n   * @param {string} libraryId\n   * @async\n   */\n  static async getOldPlaylistsForUserAndLibrary(userId, libraryId) {\n    if (!userId && !libraryId) return []\n\n    const whereQuery = {}\n    if (userId) {\n      whereQuery.userId = userId\n    }\n    if (libraryId) {\n      whereQuery.libraryId = libraryId\n    }\n    const playlistsExpanded = await this.findAll({\n      where: whereQuery,\n      include: {\n        model: this.sequelize.models.playlistMediaItem,\n        include: [\n          {\n            model: this.sequelize.models.book,\n            include: [\n              {\n                model: this.sequelize.models.libraryItem\n              },\n              {\n                model: this.sequelize.models.author,\n                through: {\n                  attributes: []\n                }\n              },\n              {\n                model: this.sequelize.models.series,\n                through: {\n                  attributes: ['sequence']\n                }\n              }\n            ]\n          },\n          {\n            model: this.sequelize.models.podcastEpisode,\n            include: {\n              model: this.sequelize.models.podcast,\n              include: this.sequelize.models.libraryItem\n            }\n          }\n        ]\n      },\n      order: [['playlistMediaItems', 'order', 'ASC']]\n    })\n\n    // Sort by name asc\n    playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name))\n\n    return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded())\n  }\n\n  /**\n   * Get number of playlists for a user and library\n   * @param {string} userId\n   * @param {string} libraryId\n   * @returns\n   */\n  static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {\n    return this.count({\n      where: {\n        userId,\n        libraryId\n      }\n    })\n  }\n\n  /**\n   * Get all playlists for mediaItemIds\n   * @param {string[]} mediaItemIds\n   * @returns {Promise<Playlist[]>}\n   */\n  static async getPlaylistsForMediaItemIds(mediaItemIds) {\n    if (!mediaItemIds?.length) return []\n\n    const playlistMediaItemsExpanded = await this.sequelize.models.playlistMediaItem.findAll({\n      where: {\n        mediaItemId: {\n          [Op.in]: mediaItemIds\n        }\n      },\n      include: [\n        {\n          model: this.sequelize.models.playlist,\n          include: {\n            model: this.sequelize.models.playlistMediaItem,\n            include: [\n              {\n                model: this.sequelize.models.book,\n                include: this.sequelize.models.libraryItem\n              },\n              {\n                model: this.sequelize.models.podcastEpisode,\n                include: {\n                  model: this.sequelize.models.podcast,\n                  include: this.sequelize.models.libraryItem\n                }\n              }\n            ]\n          }\n        }\n      ],\n      order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]\n    })\n\n    const playlists = []\n    for (const playlistMediaItem of playlistMediaItemsExpanded) {\n      const playlist = playlistMediaItem.playlist\n      if (playlists.some((p) => p.id === playlist.id)) continue\n\n      playlist.playlistMediaItems = playlist.playlistMediaItems.map((pmi) => {\n        if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {\n          pmi.mediaItem = pmi.book\n          pmi.dataValues.mediaItem = pmi.dataValues.book\n        } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {\n          pmi.mediaItem = pmi.podcastEpisode\n          pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode\n        }\n        delete pmi.book\n        delete pmi.dataValues.book\n        delete pmi.podcastEpisode\n        delete pmi.dataValues.podcastEpisode\n        return pmi\n      })\n      playlists.push(playlist)\n    }\n    return playlists\n  }\n\n  /**\n   * Removes media items and re-orders playlists\n   *\n   * @param {string[]} mediaItemIds\n   */\n  static async removeMediaItemsFromPlaylists(mediaItemIds) {\n    if (!mediaItemIds?.length) return\n\n    const playlistsWithItem = await this.getPlaylistsForMediaItemIds(mediaItemIds)\n\n    if (!playlistsWithItem.length) return\n\n    for (const playlist of playlistsWithItem) {\n      let numMediaItems = playlist.playlistMediaItems.length\n\n      let order = 1\n      // Remove items in playlist and re-order\n      for (const playlistMediaItem of playlist.playlistMediaItems) {\n        if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) {\n          await playlistMediaItem.destroy()\n          numMediaItems--\n        } else {\n          if (playlistMediaItem.order !== order) {\n            playlistMediaItem.update({\n              order\n            })\n          }\n          order++\n        }\n      }\n\n      // If playlist is now empty then remove it\n      const jsonExpanded = await playlist.getOldJsonExpanded()\n      if (!numMediaItems) {\n        Logger.info(`[ApiRouter] Playlist \"${playlist.name}\" has no more items - removing it`)\n        await playlist.destroy()\n        SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)\n      } else {\n        SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)\n      }\n    }\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: DataTypes.STRING,\n        description: DataTypes.TEXT\n      },\n      {\n        sequelize,\n        modelName: 'playlist'\n      }\n    )\n\n    const { library, user } = sequelize.models\n    library.hasMany(Playlist)\n    Playlist.belongsTo(library)\n\n    user.hasMany(Playlist, {\n      onDelete: 'CASCADE'\n    })\n    Playlist.belongsTo(user)\n\n    Playlist.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n\n      for (const instance of findResult) {\n        if (instance.playlistMediaItems?.length) {\n          instance.playlistMediaItems = instance.playlistMediaItems.map((pmi) => {\n            if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {\n              pmi.mediaItem = pmi.book\n              pmi.dataValues.mediaItem = pmi.dataValues.book\n            } else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {\n              pmi.mediaItem = pmi.podcastEpisode\n              pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode\n            }\n            // To prevent mistakes:\n            delete pmi.book\n            delete pmi.dataValues.book\n            delete pmi.podcastEpisode\n            delete pmi.dataValues.podcastEpisode\n            return pmi\n          })\n        }\n      }\n    })\n  }\n\n  /**\n   * Get all media items in playlist expanded with library item\n   *\n   * @returns {Promise<import('./PlaylistMediaItem')[]>}\n   */\n  getMediaItemsExpandedWithLibraryItem() {\n    return this.getPlaylistMediaItems({\n      include: [\n        {\n          model: this.sequelize.models.book,\n          include: [\n            {\n              model: this.sequelize.models.libraryItem\n            },\n            {\n              model: this.sequelize.models.author,\n              through: {\n                attributes: []\n              }\n            },\n            {\n              model: this.sequelize.models.series,\n              through: {\n                attributes: ['sequence']\n              }\n            }\n          ]\n        },\n        {\n          model: this.sequelize.models.podcastEpisode,\n          include: [\n            {\n              model: this.sequelize.models.podcast,\n              include: this.sequelize.models.libraryItem\n            }\n          ]\n        }\n      ],\n      order: [['order', 'ASC']]\n    })\n  }\n\n  /**\n   * Get playlists toOldJSONExpanded\n   *\n   * @async\n   */\n  async getOldJsonExpanded() {\n    this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem()\n    return this.toOldJSONExpanded()\n  }\n\n  /**\n   * Old model used libraryItemId instead of bookId\n   *\n   * @param {string} libraryItemId\n   * @param {string} [episodeId]\n   */\n  checkHasMediaItem(libraryItemId, episodeId) {\n    if (!this.playlistMediaItems) {\n      throw new Error('playlistMediaItems are required to check Playlist')\n    }\n    if (episodeId) {\n      return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId)\n    }\n    return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId)\n  }\n\n  toOldJSON() {\n    return {\n      id: this.id,\n      name: this.name,\n      libraryId: this.libraryId,\n      userId: this.userId,\n      description: this.description,\n      lastUpdate: this.updatedAt.valueOf(),\n      createdAt: this.createdAt.valueOf()\n    }\n  }\n\n  toOldJSONExpanded() {\n    if (!this.playlistMediaItems) {\n      throw new Error('playlistMediaItems are required to expand Playlist')\n    }\n\n    const json = this.toOldJSON()\n    json.items = this.playlistMediaItems.map((pmi) => {\n      if (pmi.mediaItemType === 'book') {\n        const libraryItem = pmi.mediaItem.libraryItem\n        delete pmi.mediaItem.libraryItem\n        libraryItem.media = pmi.mediaItem\n        return {\n          libraryItemId: libraryItem.id,\n          libraryItem: libraryItem.toOldJSONExpanded()\n        }\n      }\n\n      const libraryItem = pmi.mediaItem.podcast.libraryItem\n      delete pmi.mediaItem.podcast.libraryItem\n      libraryItem.media = pmi.mediaItem.podcast\n      return {\n        episodeId: pmi.mediaItemId,\n        episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id),\n        libraryItemId: libraryItem.id,\n        libraryItem: libraryItem.toOldJSONMinified()\n      }\n    })\n\n    return json\n  }\n}\n\nmodule.exports = Playlist\n"
  },
  {
    "path": "server/models/PlaylistMediaItem.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nclass PlaylistMediaItem extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {UUIDV4} */\n    this.mediaItemId\n    /** @type {string} */\n    this.mediaItemType\n    /** @type {number} */\n    this.order\n    /** @type {UUIDV4} */\n    this.playlistId\n    /** @type {Date} */\n    this.createdAt\n\n    // Expanded properties\n\n    /** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */\n    this.mediaItem\n  }\n\n  getMediaItem(options) {\n    if (!this.mediaItemType) return Promise.resolve(null)\n    const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`\n    return this[mixinMethodName](options)\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        mediaItemId: DataTypes.UUID,\n        mediaItemType: DataTypes.STRING,\n        order: DataTypes.INTEGER\n      },\n      {\n        sequelize,\n        timestamps: true,\n        updatedAt: false,\n        modelName: 'playlistMediaItem'\n      }\n    )\n\n    const { book, podcastEpisode, playlist } = sequelize.models\n\n    book.hasMany(PlaylistMediaItem, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'book'\n      }\n    })\n    PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })\n\n    podcastEpisode.hasOne(PlaylistMediaItem, {\n      foreignKey: 'mediaItemId',\n      constraints: false,\n      scope: {\n        mediaItemType: 'podcastEpisode'\n      }\n    })\n    PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })\n\n    PlaylistMediaItem.addHook('afterFind', (findResult) => {\n      if (!findResult) return\n\n      if (!Array.isArray(findResult)) findResult = [findResult]\n\n      for (const instance of findResult) {\n        if (instance.mediaItemType === 'book' && instance.book !== undefined) {\n          instance.mediaItem = instance.book\n          instance.dataValues.mediaItem = instance.dataValues.book\n        } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {\n          instance.mediaItem = instance.podcastEpisode\n          instance.dataValues.mediaItem = instance.dataValues.podcastEpisode\n        }\n        // To prevent mistakes:\n        delete instance.book\n        delete instance.dataValues.book\n        delete instance.podcastEpisode\n        delete instance.dataValues.podcastEpisode\n      }\n    })\n\n    playlist.hasMany(PlaylistMediaItem, {\n      onDelete: 'CASCADE'\n    })\n    PlaylistMediaItem.belongsTo(playlist)\n  }\n}\n\nmodule.exports = PlaylistMediaItem\n"
  },
  {
    "path": "server/models/Podcast.js",
    "content": "const { DataTypes, Model } = require('sequelize')\nconst { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')\nconst Logger = require('../Logger')\nconst libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\n/**\n * @typedef PodcastExpandedProperties\n * @property {import('./PodcastEpisode')[]} podcastEpisodes\n *\n * @typedef {Podcast & PodcastExpandedProperties} PodcastExpanded\n */\n\nclass Podcast extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {string} */\n    this.id\n    /** @type {string} */\n    this.title\n    /** @type {string} */\n    this.titleIgnorePrefix\n    /** @type {string} */\n    this.author\n    /** @type {string} */\n    this.releaseDate\n    /** @type {string} */\n    this.feedURL\n    /** @type {string} */\n    this.imageURL\n    /** @type {string} */\n    this.description\n    /** @type {string} */\n    this.itunesPageURL\n    /** @type {string} */\n    this.itunesId\n    /** @type {string} */\n    this.itunesArtistId\n    /** @type {string} */\n    this.language\n    /** @type {string} */\n    this.podcastType\n    /** @type {boolean} */\n    this.explicit\n    /** @type {boolean} */\n    this.autoDownloadEpisodes\n    /** @type {string} */\n    this.autoDownloadSchedule\n    /** @type {Date} */\n    this.lastEpisodeCheck\n    /** @type {number} */\n    this.maxEpisodesToKeep\n    /** @type {number} */\n    this.maxNewEpisodesToDownload\n    /** @type {string} */\n    this.coverPath\n    /** @type {string[]} */\n    this.tags\n    /** @type {string[]} */\n    this.genres\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {number} */\n    this.numEpisodes\n\n    /** @type {import('./PodcastEpisode')[]} */\n    this.podcastEpisodes\n  }\n\n  /**\n   * Payload from the /api/podcasts POST endpoint\n   *\n   * @param {Object} payload\n   * @param {import('sequelize').Transaction} transaction\n   */\n  static async createFromRequest(payload, transaction) {\n    const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null\n    const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null\n    const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []\n    const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []\n\n    const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type']\n    stringKeys.forEach((key) => {\n      if (typeof payload.metadata[key] === 'number') {\n        payload.metadata[key] = String(payload.metadata[key])\n      }\n    })\n\n    return this.create(\n      {\n        title,\n        titleIgnorePrefix: getTitleIgnorePrefix(title),\n        author: typeof payload.metadata.author === 'string' ? payload.metadata.author : null,\n        releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,\n        feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,\n        imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,\n        description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,\n        itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,\n        itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,\n        itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,\n        language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,\n        podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,\n        explicit: !!payload.metadata.explicit,\n        autoDownloadEpisodes: !!payload.autoDownloadEpisodes,\n        autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,\n        lastEpisodeCheck: new Date(),\n        maxEpisodesToKeep: 0,\n        maxNewEpisodesToDownload: 3,\n        tags,\n        genres\n      },\n      { transaction }\n    )\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        title: DataTypes.STRING,\n        titleIgnorePrefix: DataTypes.STRING,\n        author: DataTypes.STRING,\n        releaseDate: DataTypes.STRING,\n        feedURL: DataTypes.STRING,\n        imageURL: DataTypes.STRING,\n        description: DataTypes.TEXT,\n        itunesPageURL: DataTypes.STRING,\n        itunesId: DataTypes.STRING,\n        itunesArtistId: DataTypes.STRING,\n        language: DataTypes.STRING,\n        podcastType: DataTypes.STRING,\n        explicit: DataTypes.BOOLEAN,\n\n        autoDownloadEpisodes: DataTypes.BOOLEAN,\n        autoDownloadSchedule: DataTypes.STRING,\n        lastEpisodeCheck: DataTypes.DATE,\n        maxEpisodesToKeep: DataTypes.INTEGER,\n        maxNewEpisodesToDownload: DataTypes.INTEGER,\n        coverPath: DataTypes.STRING,\n        tags: DataTypes.JSON,\n        genres: DataTypes.JSON,\n        numEpisodes: DataTypes.INTEGER\n      },\n      {\n        sequelize,\n        modelName: 'podcast'\n      }\n    )\n\n    Podcast.addHook('afterDestroy', async (instance) => {\n      libraryItemsPodcastFilters.clearCountCache('podcast', 'afterDestroy')\n    })\n\n    Podcast.addHook('afterCreate', async (instance) => {\n      libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate')\n    })\n  }\n\n  get hasMediaFiles() {\n    return !!this.podcastEpisodes?.length\n  }\n\n  get hasAudioTracks() {\n    return this.hasMediaFiles\n  }\n\n  get size() {\n    if (!this.podcastEpisodes?.length) return 0\n    return this.podcastEpisodes.reduce((total, episode) => total + episode.size, 0)\n  }\n\n  getAbsMetadataJson() {\n    return {\n      tags: this.tags || [],\n      title: this.title,\n      author: this.author,\n      description: this.description,\n      releaseDate: this.releaseDate,\n      genres: this.genres || [],\n      feedURL: this.feedURL,\n      imageURL: this.imageURL,\n      itunesPageURL: this.itunesPageURL,\n      itunesId: this.itunesId,\n      itunesArtistId: this.itunesArtistId,\n      language: this.language,\n      explicit: !!this.explicit,\n      podcastType: this.podcastType\n    }\n  }\n\n  /**\n   *\n   * @param {Object} payload - Old podcast object\n   * @returns {Promise<boolean>}\n   */\n  async updateFromRequest(payload) {\n    if (!payload) return false\n\n    let hasUpdates = false\n\n    if (payload.metadata) {\n      const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type']\n      stringKeys.forEach((key) => {\n        // Convert numbers to strings\n        if (typeof payload.metadata[key] === 'number') {\n          payload.metadata[key] = String(payload.metadata[key])\n        }\n\n        let newKey = key\n        if (key === 'type') {\n          newKey = 'podcastType'\n        } else if (key === 'feedUrl') {\n          newKey = 'feedURL'\n        } else if (key === 'imageUrl') {\n          newKey = 'imageURL'\n        } else if (key === 'itunesPageUrl') {\n          newKey = 'itunesPageURL'\n        }\n        if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && payload.metadata[key] !== this[newKey]) {\n          // Sanitize description HTML\n          if (key === 'description' && payload.metadata[key]) {\n            const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])\n            if (sanitizedDescription !== payload.metadata[key]) {\n              Logger.debug(`[Podcast] \"${this.title}\" Sanitized description from \"${payload.metadata[key]}\" to \"${sanitizedDescription}\"`)\n              payload.metadata[key] = sanitizedDescription\n            }\n          }\n\n          this[newKey] = payload.metadata[key] || null\n\n          if (key === 'title') {\n            this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)\n          }\n\n          hasUpdates = true\n        }\n      })\n\n      if (payload.metadata.explicit !== undefined && payload.metadata.explicit !== this.explicit) {\n        this.explicit = !!payload.metadata.explicit\n        hasUpdates = true\n      }\n\n      if (Array.isArray(payload.metadata.genres) && !payload.metadata.genres.some((item) => typeof item !== 'string') && JSON.stringify(this.genres) !== JSON.stringify(payload.metadata.genres)) {\n        this.genres = payload.metadata.genres\n        this.changed('genres', true)\n        hasUpdates = true\n      }\n    }\n\n    if (Array.isArray(payload.tags) && !payload.tags.some((item) => typeof item !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {\n      this.tags = payload.tags\n      this.changed('tags', true)\n      hasUpdates = true\n    }\n\n    if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) {\n      this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes\n      hasUpdates = true\n    }\n    if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {\n      this.autoDownloadSchedule = payload.autoDownloadSchedule\n      hasUpdates = true\n    }\n    if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {\n      this.lastEpisodeCheck = payload.lastEpisodeCheck\n      hasUpdates = true\n    }\n\n    const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']\n    numberKeys.forEach((key) => {\n      if (typeof payload[key] === 'number' && payload[key] !== this[key]) {\n        this[key] = payload[key]\n        hasUpdates = true\n      }\n    })\n\n    if (hasUpdates) {\n      Logger.debug(`[Podcast] changed keys:`, this.changed())\n      await this.save()\n    }\n\n    return hasUpdates\n  }\n\n  checkCanDirectPlay(supportedMimeTypes, episodeId) {\n    if (!Array.isArray(supportedMimeTypes)) {\n      Logger.error(`[Podcast] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)\n      return false\n    }\n    const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[Podcast] checkCanDirectPlay: episode not found`, episodeId)\n      return false\n    }\n    return supportedMimeTypes.includes(episode.audioFile.mimeType)\n  }\n\n  /**\n   * Get the track list to be used in client audio players\n   * AudioTrack is the AudioFile with startOffset and contentUrl\n   * Podcast episodes only have one track\n   *\n   * @param {string} libraryItemId\n   * @param {string} episodeId\n   * @returns {import('./Book').AudioTrack[]}\n   */\n  getTracklist(libraryItemId, episodeId) {\n    const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[Podcast] getTracklist: episode not found`, episodeId)\n      return []\n    }\n\n    const audioTrack = episode.getAudioTrack(libraryItemId)\n    return [audioTrack]\n  }\n\n  /**\n   *\n   * @param {string} episodeId\n   * @returns {import('./PodcastEpisode').ChapterObject[]}\n   */\n  getChapters(episodeId) {\n    const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[Podcast] getChapters: episode not found`, episodeId)\n      return []\n    }\n\n    return structuredClone(episode.chapters) || []\n  }\n\n  getPlaybackTitle(episodeId) {\n    const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[Podcast] getPlaybackTitle: episode not found`, episodeId)\n      return ''\n    }\n\n    return episode.title\n  }\n\n  getPlaybackAuthor() {\n    return this.author\n  }\n\n  getPlaybackDuration(episodeId) {\n    const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)\n    if (!episode) {\n      Logger.error(`[Podcast] getPlaybackDuration: episode not found`, episodeId)\n      return 0\n    }\n\n    return episode.duration\n  }\n\n  /**\n   *\n   * @returns {number} - Unix timestamp\n   */\n  getLatestEpisodePublishedAt() {\n    return this.podcastEpisodes.reduce((latest, episode) => {\n      if (episode.publishedAt?.valueOf() > latest) {\n        return episode.publishedAt.valueOf()\n      }\n      return latest\n    }, 0)\n  }\n\n  /**\n   * Used for checking if an rss feed episode is already in the podcast\n   *\n   * @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed\n   * @returns {boolean}\n   */\n  checkHasEpisodeByFeedEpisode(feedEpisode) {\n    const guid = feedEpisode.guid\n    const url = feedEpisode.enclosure.url\n    return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url))\n  }\n\n  /**\n   * Old model kept metadata in a separate object\n   */\n  oldMetadataToJSON() {\n    return {\n      title: this.title,\n      author: this.author,\n      description: this.description,\n      releaseDate: this.releaseDate,\n      genres: [...(this.genres || [])],\n      feedUrl: this.feedURL,\n      imageUrl: this.imageURL,\n      itunesPageUrl: this.itunesPageURL,\n      itunesId: this.itunesId,\n      itunesArtistId: this.itunesArtistId,\n      explicit: this.explicit,\n      language: this.language,\n      type: this.podcastType\n    }\n  }\n\n  oldMetadataToJSONExpanded() {\n    const oldMetadataJSON = this.oldMetadataToJSON()\n    oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)\n    return oldMetadataJSON\n  }\n\n  /**\n   * The old model stored episodes with the podcast object\n   *\n   * @param {string} libraryItemId\n   */\n  toOldJSON(libraryItemId) {\n    if (!libraryItemId) {\n      throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)\n    }\n    if (!this.podcastEpisodes) {\n      throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)\n    }\n\n    return {\n      id: this.id,\n      libraryItemId: libraryItemId,\n      metadata: this.oldMetadataToJSON(),\n      coverPath: this.coverPath,\n      tags: [...(this.tags || [])],\n      episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),\n      autoDownloadEpisodes: this.autoDownloadEpisodes,\n      autoDownloadSchedule: this.autoDownloadSchedule,\n      lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,\n      maxEpisodesToKeep: this.maxEpisodesToKeep,\n      maxNewEpisodesToDownload: this.maxNewEpisodesToDownload\n    }\n  }\n\n  toOldJSONMinified() {\n    return {\n      id: this.id,\n      // Minified metadata and expanded metadata are the same\n      metadata: this.oldMetadataToJSONExpanded(),\n      coverPath: this.coverPath,\n      tags: [...(this.tags || [])],\n      numEpisodes: this.podcastEpisodes?.length || 0,\n      autoDownloadEpisodes: this.autoDownloadEpisodes,\n      autoDownloadSchedule: this.autoDownloadSchedule,\n      lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,\n      maxEpisodesToKeep: this.maxEpisodesToKeep,\n      maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,\n      size: this.size\n    }\n  }\n\n  toOldJSONExpanded(libraryItemId) {\n    if (!libraryItemId) {\n      throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)\n    }\n    if (!this.podcastEpisodes) {\n      throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)\n    }\n\n    return {\n      id: this.id,\n      libraryItemId: libraryItemId,\n      metadata: this.oldMetadataToJSONExpanded(),\n      coverPath: this.coverPath,\n      tags: [...(this.tags || [])],\n      episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),\n      autoDownloadEpisodes: this.autoDownloadEpisodes,\n      autoDownloadSchedule: this.autoDownloadSchedule,\n      lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,\n      maxEpisodesToKeep: this.maxEpisodesToKeep,\n      maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,\n      size: this.size\n    }\n  }\n}\n\nmodule.exports = Podcast\n"
  },
  {
    "path": "server/models/PodcastEpisode.js",
    "content": "const { DataTypes, Model } = require('sequelize')\nconst libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')\n/**\n * @typedef ChapterObject\n * @property {number} id\n * @property {number} start\n * @property {number} end\n * @property {string} title\n */\n\nclass PodcastEpisode extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {string} */\n    this.id\n    /** @type {number} */\n    this.index\n    /** @type {string} */\n    this.season\n    /** @type {string} */\n    this.episode\n    /** @type {string} */\n    this.episodeType\n    /** @type {string} */\n    this.title\n    /** @type {string} */\n    this.subtitle\n    /** @type {string} */\n    this.description\n    /** @type {string} */\n    this.pubDate\n    /** @type {string} */\n    this.enclosureURL\n    /** @type {BigInt} */\n    this.enclosureSize\n    /** @type {string} */\n    this.enclosureType\n    /** @type {Date} */\n    this.publishedAt\n    /** @type {import('./Book').AudioFileObject} */\n    this.audioFile\n    /** @type {ChapterObject[]} */\n    this.chapters\n    /** @type {Object} */\n    this.extraData\n    /** @type {string} */\n    this.podcastId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n  }\n\n  /**\n   *\n   * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode\n   * @param {string} podcastId\n   * @param {import('../objects/files/AudioFile')} audioFile\n   */\n  static async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, audioFile) {\n    const podcastEpisode = {\n      index: null,\n      season: rssPodcastEpisode.season,\n      episode: rssPodcastEpisode.episode,\n      episodeType: rssPodcastEpisode.episodeType,\n      title: rssPodcastEpisode.title,\n      subtitle: rssPodcastEpisode.subtitle,\n      description: rssPodcastEpisode.description,\n      pubDate: rssPodcastEpisode.pubDate,\n      enclosureURL: rssPodcastEpisode.enclosure?.url || null,\n      enclosureSize: rssPodcastEpisode.enclosure?.length || null,\n      enclosureType: rssPodcastEpisode.enclosure?.type || null,\n      publishedAt: rssPodcastEpisode.publishedAt,\n      podcastId,\n      audioFile: audioFile.toJSON(),\n      chapters: [],\n      extraData: {}\n    }\n    if (rssPodcastEpisode.guid) {\n      podcastEpisode.extraData.guid = rssPodcastEpisode.guid\n    }\n\n    if (audioFile.chapters?.length) {\n      podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))\n    } else if (rssPodcastEpisode.chapters?.length) {\n      podcastEpisode.chapters = rssPodcastEpisode.chapters.map((ch) => ({ ...ch }))\n    }\n\n    return this.create(podcastEpisode)\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        index: DataTypes.INTEGER,\n        season: DataTypes.STRING,\n        episode: DataTypes.STRING,\n        episodeType: DataTypes.STRING,\n        title: DataTypes.STRING,\n        subtitle: DataTypes.STRING(1000),\n        description: DataTypes.TEXT,\n        pubDate: DataTypes.STRING,\n        enclosureURL: DataTypes.STRING,\n        enclosureSize: DataTypes.BIGINT,\n        enclosureType: DataTypes.STRING,\n        publishedAt: DataTypes.DATE,\n\n        audioFile: DataTypes.JSON,\n        chapters: DataTypes.JSON,\n        extraData: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'podcastEpisode',\n        indexes: [\n          {\n            name: 'podcastEpisode_createdAt_podcastId',\n            fields: ['createdAt', 'podcastId']\n          },\n          {\n            name: 'podcast_episodes_published_at',\n            fields: ['publishedAt']\n          }\n        ]\n      }\n    )\n\n    const { podcast } = sequelize.models\n    podcast.hasMany(PodcastEpisode, {\n      onDelete: 'CASCADE'\n    })\n    PodcastEpisode.belongsTo(podcast)\n\n    PodcastEpisode.addHook('afterDestroy', async (instance) => {\n      libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterDestroy')\n    })\n\n    PodcastEpisode.addHook('afterCreate', async (instance) => {\n      libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterCreate')\n    })\n  }\n\n  get size() {\n    return this.audioFile?.metadata.size || 0\n  }\n\n  get duration() {\n    return this.audioFile?.duration || 0\n  }\n\n  /**\n   * Used for matching the episode with an episode in the RSS feed\n   *\n   * @param {string} guid\n   * @param {string} enclosureURL\n   * @returns {boolean}\n   */\n  checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) {\n    if (this.extraData?.guid && this.extraData.guid === guid) {\n      return true\n    }\n    if (this.enclosureURL && this.enclosureURL === enclosureURL) {\n      return true\n    }\n    return false\n  }\n\n  /**\n   * Used in client players\n   *\n   * @param {string} libraryItemId\n   * @returns {import('./Book').AudioTrack}\n   */\n  getAudioTrack(libraryItemId) {\n    const track = structuredClone(this.audioFile)\n    track.startOffset = 0\n    track.title = this.audioFile.metadata.filename\n    track.index = 1 // Podcast episodes only have one track\n    track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}`\n    return track\n  }\n\n  toOldJSON(libraryItemId) {\n    if (!libraryItemId) {\n      throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)\n    }\n\n    let enclosure = null\n    if (this.enclosureURL) {\n      enclosure = {\n        url: this.enclosureURL,\n        type: this.enclosureType,\n        length: this.enclosureSize !== null ? String(this.enclosureSize) : null\n      }\n    }\n\n    return {\n      libraryItemId: libraryItemId,\n      podcastId: this.podcastId,\n      id: this.id,\n      oldEpisodeId: this.extraData?.oldEpisodeId || null,\n      index: this.index,\n      season: this.season,\n      episode: this.episode,\n      episodeType: this.episodeType,\n      title: this.title,\n      subtitle: this.subtitle,\n      description: this.description,\n      enclosure,\n      guid: this.extraData?.guid || null,\n      pubDate: this.pubDate,\n      chapters: structuredClone(this.chapters),\n      audioFile: structuredClone(this.audioFile),\n      publishedAt: this.publishedAt?.valueOf() || null,\n      addedAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf()\n    }\n  }\n\n  toOldJSONExpanded(libraryItemId) {\n    const json = this.toOldJSON(libraryItemId)\n\n    json.audioTrack = this.getAudioTrack(libraryItemId)\n    json.size = this.size\n    json.duration = this.duration\n\n    return json\n  }\n}\n\nmodule.exports = PodcastEpisode\n"
  },
  {
    "path": "server/models/Series.js",
    "content": "const { DataTypes, Model, where, fn, col, literal } = require('sequelize')\n\nconst { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')\n\nclass Series extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.name\n    /** @type {string} */\n    this.nameIgnorePrefix\n    /** @type {string} */\n    this.description\n    /** @type {UUIDV4} */\n    this.libraryId\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n\n    // Expanded properties\n\n    /** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */\n    this.books\n  }\n\n  /**\n   * Check if series exists\n   * @param {string} seriesId\n   * @returns {Promise<boolean>}\n   */\n  static async checkExistsById(seriesId) {\n    return (await this.count({ where: { id: seriesId } })) > 0\n  }\n\n  /**\n   * Get series by name and libraryId. name case insensitive\n   *\n   * @param {string} seriesName\n   * @param {string} libraryId\n   * @returns {Promise<Series>}\n   */\n  static async getByNameAndLibrary(seriesName, libraryId) {\n    return this.findOne({\n      where: [\n        where(fn('lower', col('name')), seriesName.toLowerCase()),\n        {\n          libraryId\n        }\n      ]\n    })\n  }\n\n  /**\n   *\n   * @param {string} seriesId\n   * @returns {Promise<Series>}\n   */\n  static async getExpandedById(seriesId) {\n    const series = await this.findByPk(seriesId)\n    if (!series) return null\n    series.books = await series.getBooksExpandedWithLibraryItem()\n    return series\n  }\n\n  /**\n   *\n   * @param {string} seriesName\n   * @param {string} libraryId\n   * @returns {Promise<Series>}\n   */\n  static async findOrCreateByNameAndLibrary(seriesName, libraryId) {\n    const series = await this.getByNameAndLibrary(seriesName, libraryId)\n    if (series) return series\n    return this.create({\n      name: seriesName,\n      nameIgnorePrefix: getTitleIgnorePrefix(seriesName),\n      libraryId\n    })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        name: DataTypes.STRING,\n        nameIgnorePrefix: DataTypes.STRING,\n        description: DataTypes.TEXT\n      },\n      {\n        sequelize,\n        modelName: 'series',\n        indexes: [\n          {\n            fields: [\n              {\n                name: 'name',\n                collate: 'NOCASE'\n              }\n            ]\n          },\n          // {\n          //   fields: [{\n          //     name: 'nameIgnorePrefix',\n          //     collate: 'NOCASE'\n          //   }]\n          // },\n          {\n            // unique constraint on name and libraryId\n            fields: ['name', 'libraryId'],\n            unique: true,\n            name: 'unique_series_name_per_library'\n          },\n          {\n            fields: ['libraryId']\n          }\n        ]\n      }\n    )\n\n    const { library } = sequelize.models\n    library.hasMany(Series, {\n      onDelete: 'CASCADE'\n    })\n    Series.belongsTo(library)\n  }\n\n  /**\n   * Get all books in collection expanded with library item\n   *\n   * @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}\n   */\n  getBooksExpandedWithLibraryItem() {\n    return this.getBooks({\n      joinTableAttributes: ['sequence'],\n      include: [\n        {\n          model: this.sequelize.models.libraryItem\n        },\n        {\n          model: this.sequelize.models.author,\n          through: {\n            attributes: []\n          }\n        },\n        {\n          model: this.sequelize.models.series,\n          through: {\n            attributes: ['sequence']\n          }\n        }\n      ],\n      order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]]\n    })\n  }\n\n  toOldJSON() {\n    return {\n      id: this.id,\n      name: this.name,\n      nameIgnorePrefix: getTitlePrefixAtEnd(this.name),\n      description: this.description,\n      addedAt: this.createdAt.valueOf(),\n      updatedAt: this.updatedAt.valueOf(),\n      libraryId: this.libraryId\n    }\n  }\n\n  toJSONMinimal(sequence) {\n    return {\n      id: this.id,\n      name: this.name,\n      sequence\n    }\n  }\n}\n\nmodule.exports = Series\n"
  },
  {
    "path": "server/models/Session.js",
    "content": "const { DataTypes, Model, Op } = require('sequelize')\n\nclass Session extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.ipAddress\n    /** @type {string} */\n    this.userAgent\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {UUIDV4} */\n    this.userId\n    /** @type {Date} */\n    this.expiresAt\n\n    // Expanded properties\n\n    /** @type {import('./User').User} */\n    this.user\n  }\n\n  static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {\n    const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })\n    return session\n  }\n\n  /**\n   * Clean up expired sessions from the database\n   * @returns {Promise<number>} Number of sessions deleted\n   */\n  static async cleanupExpiredSessions() {\n    const deletedCount = await Session.destroy({\n      where: {\n        expiresAt: {\n          [Op.lt]: new Date()\n        }\n      }\n    })\n    return deletedCount\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        ipAddress: DataTypes.STRING,\n        userAgent: DataTypes.STRING,\n        refreshToken: {\n          type: DataTypes.STRING,\n          allowNull: false\n        },\n        expiresAt: {\n          type: DataTypes.DATE,\n          allowNull: false\n        }\n      },\n      {\n        sequelize,\n        modelName: 'session'\n      }\n    )\n\n    const { user } = sequelize.models\n    user.hasMany(Session, {\n      onDelete: 'CASCADE',\n      foreignKey: {\n        allowNull: false\n      }\n    })\n    Session.belongsTo(user)\n  }\n}\n\nmodule.exports = Session\n"
  },
  {
    "path": "server/models/Setting.js",
    "content": "const { DataTypes, Model } = require('sequelize')\n\nconst oldEmailSettings = require('../objects/settings/EmailSettings')\nconst oldServerSettings = require('../objects/settings/ServerSettings')\nconst oldNotificationSettings = require('../objects/settings/NotificationSettings')\n\nclass Setting extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {string} */\n    this.key\n    /** @type {Object} */\n    this.value\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n  }\n\n  static async getOldSettings() {\n    const settings = (await this.findAll()).map((se) => se.value)\n\n    const emailSettingsJson = settings.find((se) => se.id === 'email-settings')\n    const serverSettingsJson = settings.find((se) => se.id === 'server-settings')\n    const notificationSettingsJson = settings.find((se) => se.id === 'notification-settings')\n\n    return {\n      settings,\n      emailSettings: new oldEmailSettings(emailSettingsJson),\n      serverSettings: new oldServerSettings(serverSettingsJson),\n      notificationSettings: new oldNotificationSettings(notificationSettingsJson)\n    }\n  }\n\n  static updateSettingObj(setting) {\n    return this.upsert({\n      key: setting.id,\n      value: setting\n    })\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        key: {\n          type: DataTypes.STRING,\n          primaryKey: true\n        },\n        value: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'setting'\n      }\n    )\n  }\n}\n\nmodule.exports = Setting\n"
  },
  {
    "path": "server/models/User.js",
    "content": "const uuidv4 = require('uuid').v4\nconst sequelize = require('sequelize')\nconst { LRUCache } = require('lru-cache')\n\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst { isNullOrNaN } = require('../utils')\nconst TokenManager = require('../auth/TokenManager')\n\nclass UserCache {\n  constructor() {\n    this.cache = new LRUCache({ max: 100 })\n  }\n\n  getById(id) {\n    const user = this.cache.get(id)\n    return user\n  }\n\n  getByEmail(email) {\n    const user = this.cache.find((u) => u.email === email)\n    return user\n  }\n\n  getByUsername(username) {\n    const user = this.cache.find((u) => u.username === username)\n    return user\n  }\n\n  getByOldId(oldUserId) {\n    const user = this.cache.find((u) => u.extraData?.oldUserId === oldUserId)\n    return user\n  }\n\n  getByOpenIDSub(sub) {\n    const user = this.cache.find((u) => u.extraData?.authOpenIDSub === sub)\n    return user\n  }\n\n  set(user) {\n    user.fromCache = true\n    this.cache.set(user.id, user)\n  }\n\n  delete(userId) {\n    this.cache.delete(userId)\n  }\n\n  maybeInvalidate(user) {\n    if (!user.fromCache) this.delete(user.id)\n  }\n}\n\nconst userCache = new UserCache()\n\nconst { DataTypes, Model } = sequelize\n\n/**\n * @typedef AudioBookmarkObject\n * @property {string} libraryItemId\n * @property {string} title\n * @property {number} time\n * @property {number} createdAt\n */\n\n/**\n * @typedef ProgressUpdatePayload\n * @property {string} libraryItemId\n * @property {string} [episodeId]\n * @property {number} [duration]\n * @property {number} [progress]\n * @property {number} [currentTime]\n * @property {boolean} [isFinished]\n * @property {boolean} [hideFromContinueListening]\n * @property {string} [ebookLocation]\n * @property {number} [ebookProgress]\n * @property {string} [finishedAt]\n * @property {number} [lastUpdate]\n * @property {number} [markAsFinishedTimeRemaining]\n * @property {number} [markAsFinishedPercentComplete]\n */\n\nclass User extends Model {\n  constructor(values, options) {\n    super(values, options)\n\n    /** @type {UUIDV4} */\n    this.id\n    /** @type {string} */\n    this.username\n    /** @type {string} */\n    this.email\n    /** @type {string} */\n    this.pash\n    /** @type {string} */\n    this.type\n    /** @type {string} */\n    this.token\n    /** @type {boolean} */\n    this.isActive\n    /** @type {boolean} */\n    this.isLocked\n    /** @type {Date} */\n    this.lastSeen\n    /** @type {Object} */\n    this.permissions\n    /** @type {AudioBookmarkObject[]} */\n    this.bookmarks\n    /** @type {Object} */\n    this.extraData\n    /** @type {Date} */\n    this.createdAt\n    /** @type {Date} */\n    this.updatedAt\n    /** @type {import('./MediaProgress')[]?} - Only included when extended */\n    this.mediaProgresses\n  }\n\n  // Excludes \"root\" since their can only be 1 root user\n  static accountTypes = ['admin', 'user', 'guest']\n\n  /**\n   * List of expected permission properties from the client\n   * Only used for OpenID\n   */\n  static permissionMapping = {\n    canDownload: 'download',\n    canUpload: 'upload',\n    canDelete: 'delete',\n    canUpdate: 'update',\n    canAccessExplicitContent: 'accessExplicitContent',\n    canAccessAllLibraries: 'accessAllLibraries',\n    canAccessAllTags: 'accessAllTags',\n    canCreateEReader: 'createEreader',\n    tagsAreDenylist: 'selectedTagsNotAccessible',\n    // Direct mapping for array-based permissions\n    allowedLibraries: 'librariesAccessible',\n    allowedTags: 'itemTagsSelected'\n  }\n\n  /**\n   * Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like\n   * Only used for OpenID\n   *\n   * @returns {string} JSON string\n   */\n  static getSampleAbsPermissions() {\n    // Start with a template object where all permissions are false for simplicity\n    const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => {\n      // For array-based permissions, provide a sample array\n      if (key === 'allowedLibraries') {\n        acc[key] = [`5406ba8a-16e1-451d-96d7-4931b0a0d966`, `918fd848-7c1d-4a02-818a-847435a879ca`]\n      } else if (key === 'allowedTags') {\n        acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]\n      } else {\n        acc[key] = false\n      }\n      return acc\n    }, {})\n\n    return JSON.stringify(samplePermissions, null, 2) // Pretty print the JSON\n  }\n\n  /**\n   *\n   * @param {string} type\n   * @returns\n   */\n  static getDefaultPermissionsForUserType(type) {\n    return {\n      download: true,\n      update: type === 'root' || type === 'admin',\n      delete: type === 'root',\n      upload: type === 'root' || type === 'admin',\n      createEreader: type === 'root' || type === 'admin',\n      accessAllLibraries: true,\n      accessAllTags: true,\n      accessExplicitContent: type === 'root' || type === 'admin',\n      selectedTagsNotAccessible: false,\n      librariesAccessible: [],\n      itemTagsSelected: []\n    }\n  }\n\n  /**\n   * Create root user\n   * @param {string} username\n   * @param {string} pash\n   * @param {import('../Auth')} auth\n   * @returns {Promise<User>}\n   */\n  static async createRootUser(username, pash, auth) {\n    const userId = uuidv4()\n\n    const token = auth.generateAccessToken({ id: userId, username })\n\n    const newUser = {\n      id: userId,\n      type: 'root',\n      username,\n      pash,\n      token,\n      isActive: true,\n      permissions: this.getDefaultPermissionsForUserType('root'),\n      bookmarks: [],\n      extraData: {\n        seriesHideFromContinueListening: []\n      }\n    }\n    return this.create(newUser)\n  }\n\n  /**\n   * Finds an existing user by OpenID subject identifier, or by email/username based on server settings\n   * Returns null if no user is found\n   *\n   * @param {Object} userinfo\n   * @returns {Promise<User|{error: string}>}\n   */\n  static async findUserFromOpenIdUserInfo(userinfo) {\n    let user = await this.getUserByOpenIDSub(userinfo.sub)\n\n    // Matched by sub\n    if (user) {\n      Logger.debug(`[User] openid: User found by sub \"${userinfo.sub}\"`)\n      return user\n    }\n\n    // Match existing user by email\n    if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') {\n      if (userinfo.email) {\n        // Only disallow when email_verified explicitly set to false (allow both if not set or true)\n        if (userinfo.email_verified === false) {\n          Logger.warn(`[User] openid: User not found and email \"${userinfo.email}\" is not verified`)\n          return {\n            error: 'Email not verified'\n          }\n        } else {\n          Logger.info(`[User] openid: User not found, checking existing with email \"${userinfo.email}\"`)\n          user = await this.getUserByEmail(userinfo.email)\n\n          if (user?.authOpenIDSub) {\n            Logger.warn(`[User] openid: User found with email \"${userinfo.email}\" but is already matched with sub \"${user.authOpenIDSub}\"`)\n            // User is linked to a different OpenID subject; do not proceed.\n            return {\n              error: 'User already linked to a different OpenID subject'\n            }\n          }\n        }\n      } else {\n        Logger.warn(`[User] openid: User not found and no email in userinfo`)\n        // We deny login, because if the admin whishes to match email, it makes sense to require it\n        return {\n          error: 'No email in userinfo'\n        }\n      }\n    }\n    // Match existing user by username\n    else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {\n      let username\n\n      if (userinfo.preferred_username) {\n        Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username \"${userinfo.preferred_username}\"`)\n        username = userinfo.preferred_username\n      } else if (userinfo.username) {\n        Logger.info(`[User] openid: User not found, checking existing with userinfo.username \"${userinfo.username}\"`)\n        username = userinfo.username\n      } else {\n        Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)\n        return {\n          error: 'No username in userinfo'\n        }\n      }\n\n      user = await this.getUserByUsername(username)\n\n      if (user?.authOpenIDSub) {\n        Logger.warn(`[User] openid: User found with username \"${username}\" but is already matched with sub \"${user.authOpenIDSub}\"`)\n        // User is linked to a different OpenID subject; do not proceed.\n        return {\n          error: 'User already linked to a different OpenID subject'\n        }\n      }\n    }\n\n    if (!user) {\n      return null\n    }\n\n    // Found existing user via email or username\n    if (!user.isActive) {\n      Logger.warn(`[User] openid: User found but is not active`)\n      return user\n    }\n\n    // Update user with OpenID sub\n    if (!user.extraData) user.extraData = {}\n    user.extraData.authOpenIDSub = userinfo.sub\n    user.changed('extraData', true)\n    await user.save()\n\n    Logger.debug(`[User] openid: User found by email/username`)\n    return user\n  }\n\n  /**\n   * Create user from openid userinfo\n   * @param {Object} userinfo\n   * @returns {Promise<User>}\n   */\n  static async createUserFromOpenIdUserInfo(userinfo) {\n    const userId = uuidv4()\n    // TODO: Ensure username is unique?\n    const username = userinfo.preferred_username || userinfo.name || userinfo.sub\n    const email = userinfo.email && userinfo.email_verified ? userinfo.email : null\n\n    const token = TokenManager.generateAccessToken({ id: userId, username })\n\n    const newUser = {\n      id: userId,\n      type: 'user',\n      username,\n      email,\n      pash: null,\n      token,\n      isActive: true,\n      permissions: this.getDefaultPermissionsForUserType('user'),\n      bookmarks: [],\n      extraData: {\n        authOpenIDSub: userinfo.sub,\n        seriesHideFromContinueListening: []\n      }\n    }\n    const user = await this.create(newUser)\n\n    if (user) {\n      SocketAuthority.adminEmitter('user_added', user.toOldJSONForBrowser())\n      return user\n    }\n    return null\n  }\n\n  /**\n   * Get user by username case insensitive\n   * @param {string} username\n   * @returns {Promise<User>}\n   */\n  static async getUserByUsername(username) {\n    if (!username) return null\n\n    const cachedUser = userCache.getByUsername(username)\n    if (cachedUser) return cachedUser\n\n    const user = await this.findOne({\n      where: {\n        username: {\n          [sequelize.Op.like]: username\n        }\n      },\n      include: this.sequelize.models.mediaProgress\n    })\n\n    if (user) userCache.set(user)\n\n    return user\n  }\n\n  /**\n   * Get user by email case insensitive\n   * @param {string} email\n   * @returns {Promise<User>}\n   */\n  static async getUserByEmail(email) {\n    if (!email) return null\n\n    const cachedUser = userCache.getByEmail(email)\n    if (cachedUser) return cachedUser\n\n    const user = await this.findOne({\n      where: {\n        email: {\n          [sequelize.Op.like]: email\n        }\n      },\n      include: this.sequelize.models.mediaProgress\n    })\n\n    if (user) userCache.set(user)\n\n    return user\n  }\n\n  /**\n   * Get user by id\n   * @param {string} userId\n   * @returns {Promise<User>}\n   */\n  static async getUserById(userId) {\n    if (!userId) return null\n\n    const cachedUser = userCache.getById(userId)\n    if (cachedUser) return cachedUser\n\n    const user = await this.findByPk(userId, {\n      include: this.sequelize.models.mediaProgress\n    })\n\n    if (user) userCache.set(user)\n\n    return user\n  }\n\n  /**\n   * Get user by id or old id\n   * JWT tokens generated before 2.3.0 used old user ids\n   *\n   * @param {string} userId\n   * @returns {Promise<User>}\n   */\n  static async getUserByIdOrOldId(userId) {\n    if (!userId) return null\n    const cachedUser = userCache.getById(userId) || userCache.getByOldId(userId)\n    if (cachedUser) return cachedUser\n\n    const user = await this.findOne({\n      where: {\n        [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }]\n      },\n      include: this.sequelize.models.mediaProgress\n    })\n\n    if (user) userCache.set(user)\n\n    return user\n  }\n\n  /**\n   * Get user by openid sub\n   * @param {string} sub\n   * @returns {Promise<User>}\n   */\n  static async getUserByOpenIDSub(sub) {\n    if (!sub) return null\n\n    const cachedUser = userCache.getByOpenIDSub(sub)\n    if (cachedUser) return cachedUser\n\n    const user = await this.findOne({\n      where: sequelize.where(sequelize.literal(`extraData->>\"authOpenIDSub\"`), sub),\n      include: this.sequelize.models.mediaProgress\n    })\n\n    if (user) userCache.set(user)\n\n    return user\n  }\n\n  /**\n   * Get array of user id and username\n   * @returns {object[]} { id, username }\n   */\n  static async getMinifiedUserObjects() {\n    const users = await this.findAll({\n      attributes: ['id', 'username']\n    })\n    return users.map((u) => {\n      return {\n        id: u.id,\n        username: u.username\n      }\n    })\n  }\n\n  /**\n   * Return true if root user exists\n   * @returns {boolean}\n   */\n  static async getHasRootUser() {\n    const count = await this.count({\n      where: {\n        type: 'root'\n      }\n    })\n    return count > 0\n  }\n\n  /**\n   * Check if user exists with username\n   * @param {string} username\n   * @returns {boolean}\n   */\n  static async checkUserExistsWithUsername(username) {\n    const count = await this.count({\n      where: {\n        username\n      }\n    })\n    return count > 0\n  }\n\n  static mediaProgressRemoved(mediaProgress) {\n    const cachedUser = userCache.getById(mediaProgress.userId)\n    if (cachedUser) {\n      Logger.debug(`[User] mediaProgressRemoved: ${mediaProgress.id} from user ${cachedUser.id}`)\n      cachedUser.mediaProgresses = cachedUser.mediaProgresses.filter((mp) => mp.id !== mediaProgress.id)\n    }\n  }\n\n  /**\n   * Initialize model\n   * @param {import('../Database').sequelize} sequelize\n   */\n  static init(sequelize) {\n    super.init(\n      {\n        id: {\n          type: DataTypes.UUID,\n          defaultValue: DataTypes.UUIDV4,\n          primaryKey: true\n        },\n        username: DataTypes.STRING,\n        email: DataTypes.STRING,\n        pash: DataTypes.STRING,\n        type: DataTypes.STRING,\n        token: DataTypes.STRING,\n        isActive: {\n          type: DataTypes.BOOLEAN,\n          defaultValue: false\n        },\n        isLocked: {\n          type: DataTypes.BOOLEAN,\n          defaultValue: false\n        },\n        lastSeen: DataTypes.DATE,\n        permissions: DataTypes.JSON,\n        bookmarks: DataTypes.JSON,\n        extraData: DataTypes.JSON\n      },\n      {\n        sequelize,\n        modelName: 'user'\n      }\n    )\n  }\n\n  get isRoot() {\n    return this.type === 'root'\n  }\n  get isAdminOrUp() {\n    return this.isRoot || this.type === 'admin'\n  }\n  get isUser() {\n    return this.type === 'user'\n  }\n  get isGuest() {\n    return this.type === 'guest'\n  }\n  get canAccessExplicitContent() {\n    return !!this.permissions?.accessExplicitContent && this.isActive\n  }\n  get canDelete() {\n    return !!this.permissions?.delete && this.isActive\n  }\n  get canUpdate() {\n    return !!this.permissions?.update && this.isActive\n  }\n  get canDownload() {\n    return !!this.permissions?.download && this.isActive\n  }\n  get canUpload() {\n    return !!this.permissions?.upload && this.isActive\n  }\n  /** @type {string|null} */\n  get authOpenIDSub() {\n    return this.extraData?.authOpenIDSub || null\n  }\n\n  /**\n   * User data for clients\n   * Emitted on socket events user_online, user_offline and user_stream_update\n   *\n   * @param {import('../objects/PlaybackSession')[]} sessions\n   * @returns\n   */\n  toJSONForPublic(sessions) {\n    const session = sessions?.find((s) => s.userId === this.id)?.toJSONForClient() || null\n    return {\n      id: this.id,\n      username: this.username,\n      type: this.type,\n      session,\n      lastSeen: this.lastSeen?.valueOf() || null,\n      createdAt: this.createdAt.valueOf()\n    }\n  }\n\n  /**\n   * User data for browser using old model\n   *\n   * @param {boolean} [hideRootToken=false]\n   * @param {boolean} [minimal=false]\n   * @returns\n   */\n  toOldJSONForBrowser(hideRootToken = false, minimal = false) {\n    const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || []\n    const librariesAccessible = this.permissions?.librariesAccessible || []\n    const itemTagsSelected = this.permissions?.itemTagsSelected || []\n    const permissions = { ...this.permissions }\n    delete permissions.librariesAccessible\n    delete permissions.itemTagsSelected\n\n    const json = {\n      id: this.id,\n      username: this.username,\n      email: this.email,\n      type: this.type,\n      // TODO: Old non-expiring token\n      token: this.type === 'root' && hideRootToken ? '' : this.token,\n      // TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck\n      // Necessary to detect apps using old tokens that no longer match the old token stored on the user\n      isOldToken: this.isOldToken,\n      mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],\n      seriesHideFromContinueListening: [...seriesHideFromContinueListening],\n      bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],\n      isActive: this.isActive,\n      isLocked: this.isLocked,\n      lastSeen: this.lastSeen?.valueOf() || null,\n      createdAt: this.createdAt.valueOf(),\n      permissions: permissions,\n      librariesAccessible: [...librariesAccessible],\n      itemTagsSelected: [...itemTagsSelected],\n      hasOpenIDLink: !!this.authOpenIDSub\n    }\n    if (minimal) {\n      delete json.mediaProgress\n      delete json.bookmarks\n    }\n    return json\n  }\n\n  /**\n   * Check user has access to library\n   *\n   * @param {string} libraryId\n   * @returns {boolean}\n   */\n  checkCanAccessLibrary(libraryId) {\n    if (this.permissions?.accessAllLibraries) return true\n    if (!this.permissions?.librariesAccessible) return false\n    return this.permissions.librariesAccessible.includes(libraryId)\n  }\n\n  /**\n   * Check user has access to library item with tags\n   *\n   * @param {string[]} tags\n   * @returns {boolean}\n   */\n  checkCanAccessLibraryItemWithTags(tags) {\n    if (this.permissions.accessAllTags) return true\n    const itemTagsSelected = this.permissions?.itemTagsSelected || []\n    if (this.permissions.selectedTagsNotAccessible) {\n      if (!tags?.length) return true\n      return tags.every((tag) => !itemTagsSelected?.includes(tag))\n    }\n    if (!tags?.length) return false\n    return itemTagsSelected.some((tag) => tags.includes(tag))\n  }\n\n  /**\n   * Check user can access library item\n   *\n   * @param {import('./LibraryItem')} libraryItem\n   * @returns {boolean}\n   */\n  checkCanAccessLibraryItem(libraryItem) {\n    if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false\n\n    const libraryItemExplicit = !!libraryItem.media.explicit || !!libraryItem.media.metadata?.explicit\n\n    if (libraryItemExplicit && !this.canAccessExplicitContent) return false\n\n    return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)\n  }\n\n  /**\n   * Get first available library id for user\n   *\n   * @param {string[]} libraryIds\n   * @returns {string|null}\n   */\n  getDefaultLibraryId(libraryIds) {\n    // Libraries should already be in ascending display order, find first accessible\n    return libraryIds.find((lid) => this.checkCanAccessLibrary(lid)) || null\n  }\n\n  /**\n   * Get media progress by media item id\n   *\n   * @param {string} libraryItemId\n   * @param {string|null} [episodeId]\n   * @returns {import('./MediaProgress')|null}\n   */\n  getMediaProgress(mediaItemId) {\n    if (!this.mediaProgresses?.length) return null\n    return this.mediaProgresses.find((mp) => mp.mediaItemId === mediaItemId)\n  }\n\n  /**\n   * Get old media progress\n   * TODO: Update to new model\n   *\n   * @param {string} libraryItemId\n   * @param {string} [episodeId]\n   * @returns\n   */\n  getOldMediaProgress(libraryItemId, episodeId = null) {\n    const mediaProgress = this.mediaProgresses?.find((mp) => {\n      if (episodeId && mp.mediaItemId !== episodeId) return false\n      return mp.extraData?.libraryItemId === libraryItemId\n    })\n    return mediaProgress?.getOldMediaProgress() || null\n  }\n\n  /**\n   * TODO: Uses old model and should account for the different between ebook/audiobook progress\n   *\n   * @param {ProgressUpdatePayload} progressPayload\n   * @returns {Promise<{ mediaProgress: import('./MediaProgress'), error: [string], statusCode: [number] }>}\n   */\n  async createUpdateMediaProgressFromPayload(progressPayload) {\n    /** @type {import('./MediaProgress')|null} */\n    let mediaProgress = null\n    let mediaItemId = null\n    let podcastId = null\n    if (progressPayload.episodeId) {\n      const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {\n        attributes: ['id', 'podcastId'],\n        include: [\n          {\n            model: this.sequelize.models.mediaProgress,\n            where: { userId: this.id },\n            required: false\n          },\n          {\n            model: this.sequelize.models.podcast,\n            attributes: ['id', 'title'],\n            include: {\n              model: this.sequelize.models.libraryItem,\n              attributes: ['id']\n            }\n          }\n        ]\n      })\n      if (!podcastEpisode) {\n        Logger.error(`[User] createUpdateMediaProgress: episode ${progressPayload.episodeId} not found`)\n        return {\n          error: 'Episode not found',\n          statusCode: 404\n        }\n      }\n      mediaItemId = podcastEpisode.id\n      mediaProgress = podcastEpisode.mediaProgresses?.[0]\n      podcastId = podcastEpisode.podcastId\n    } else {\n      const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {\n        attributes: ['id', 'mediaId', 'mediaType'],\n        include: {\n          model: this.sequelize.models.book,\n          attributes: ['id', 'title'],\n          required: false,\n          include: {\n            model: this.sequelize.models.mediaProgress,\n            where: { userId: this.id },\n            required: false\n          }\n        }\n      })\n      if (!libraryItem) {\n        Logger.error(`[User] createUpdateMediaProgress: library item ${progressPayload.libraryItemId} not found`)\n        return {\n          error: 'Library item not found',\n          statusCode: 404\n        }\n      } else if (libraryItem.mediaType !== 'book') {\n        Logger.error(`[User] createUpdateMediaProgress: library item ${progressPayload.libraryItemId} is not a book`)\n        return {\n          error: 'Library item is not a book',\n          statusCode: 400\n        }\n      }\n\n      mediaItemId = libraryItem.media.id\n      mediaProgress = libraryItem.media.mediaProgresses?.[0]\n    }\n\n    if (mediaProgress) {\n      mediaProgress = await mediaProgress.applyProgressUpdate(progressPayload)\n      this.mediaProgresses = this.mediaProgresses.map((mp) => (mp.id === mediaProgress.id ? mediaProgress : mp))\n    } else {\n      const newMediaProgressPayload = {\n        userId: this.id,\n        mediaItemId,\n        podcastId,\n        mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',\n        duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),\n        currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),\n        isFinished: !!progressPayload.isFinished,\n        hideFromContinueListening: !!progressPayload.hideFromContinueListening,\n        ebookLocation: progressPayload.ebookLocation || null,\n        ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress),\n        finishedAt: progressPayload.finishedAt || null,\n        createdAt: progressPayload.createdAt || new Date(),\n        extraData: {\n          libraryItemId: progressPayload.libraryItemId,\n          progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress)\n        }\n      }\n      if (newMediaProgressPayload.isFinished) {\n        newMediaProgressPayload.finishedAt = newMediaProgressPayload.finishedAt || new Date()\n        newMediaProgressPayload.extraData.progress = 1\n      } else {\n        newMediaProgressPayload.finishedAt = null\n      }\n      mediaProgress = await this.sequelize.models.mediaProgress.create(newMediaProgressPayload)\n      this.mediaProgresses.push(mediaProgress)\n    }\n    userCache.maybeInvalidate(this)\n    return {\n      mediaProgress\n    }\n  }\n\n  /**\n   * Find bookmark\n   * TODO: Bookmarks should use mediaItemId instead of libraryItemId to support podcast episodes\n   *\n   * @param {string} libraryItemId\n   * @param {number} time\n   * @returns {AudioBookmarkObject|null}\n   */\n  findBookmark(libraryItemId, time) {\n    return this.bookmarks.find((bm) => bm.libraryItemId === libraryItemId && bm.time == time)\n  }\n\n  /**\n   * Create bookmark\n   *\n   * @param {string} libraryItemId\n   * @param {number} time\n   * @param {string} title\n   * @returns {Promise<AudioBookmarkObject>}\n   */\n  async createBookmark(libraryItemId, time, title) {\n    const existingBookmark = this.findBookmark(libraryItemId, time)\n    if (existingBookmark) {\n      Logger.warn('[User] Create Bookmark already exists for this time')\n      if (existingBookmark.title !== title) {\n        existingBookmark.title = title\n        this.changed('bookmarks', true)\n        await this.save()\n      }\n      return existingBookmark\n    }\n\n    const newBookmark = {\n      libraryItemId,\n      time,\n      title,\n      createdAt: Date.now()\n    }\n    this.bookmarks.push(newBookmark)\n    this.changed('bookmarks', true)\n    await this.save()\n    return newBookmark\n  }\n\n  /**\n   * Update bookmark\n   *\n   * @param {string} libraryItemId\n   * @param {number} time\n   * @param {string} title\n   * @returns {Promise<AudioBookmarkObject>}\n   */\n  async updateBookmark(libraryItemId, time, title) {\n    const bookmark = this.findBookmark(libraryItemId, time)\n    if (!bookmark) {\n      Logger.error(`[User] updateBookmark not found`)\n      return null\n    }\n    bookmark.title = title\n    this.changed('bookmarks', true)\n    await this.save()\n    return bookmark\n  }\n\n  /**\n   * Remove bookmark\n   *\n   * @param {string} libraryItemId\n   * @param {number} time\n   * @returns {Promise<boolean>} - true if bookmark was removed\n   */\n  async removeBookmark(libraryItemId, time) {\n    if (!this.findBookmark(libraryItemId, time)) {\n      Logger.error(`[User] removeBookmark not found`)\n      return false\n    }\n    this.bookmarks = this.bookmarks.filter((bm) => bm.libraryItemId !== libraryItemId || bm.time !== time)\n    this.changed('bookmarks', true)\n    await this.save()\n    return true\n  }\n\n  /**\n   *\n   * @param {string} seriesId\n   * @returns {Promise<boolean>}\n   */\n  async addSeriesToHideFromContinueListening(seriesId) {\n    if (!this.extraData) this.extraData = {}\n    const seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || []\n    if (seriesHideFromContinueListening.includes(seriesId)) return false\n    seriesHideFromContinueListening.push(seriesId)\n    this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening\n    this.changed('extraData', true)\n    await this.save()\n    return true\n  }\n\n  /**\n   *\n   * @param {string} seriesId\n   * @returns {Promise<boolean>}\n   */\n  async removeSeriesFromHideFromContinueListening(seriesId) {\n    if (!this.extraData) this.extraData = {}\n    let seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || []\n    if (!seriesHideFromContinueListening.includes(seriesId)) return false\n    seriesHideFromContinueListening = seriesHideFromContinueListening.filter((sid) => sid !== seriesId)\n    this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening\n    this.changed('extraData', true)\n    await this.save()\n    return true\n  }\n\n  /**\n   * Update user permissions from external JSON\n   *\n   * @param {Object} absPermissions JSON containing user permissions\n   * @returns {Promise<boolean>} true if updates were made\n   */\n  async updatePermissionsFromExternalJSON(absPermissions) {\n    if (!this.permissions) this.permissions = {}\n    let hasUpdates = false\n\n    // Map the boolean permissions from absPermissions\n    Object.keys(absPermissions).forEach((absKey) => {\n      const userPermKey = User.permissionMapping[absKey]\n      if (!userPermKey) {\n        throw new Error(`Unexpected permission property: ${absKey}`)\n      }\n\n      if (!['librariesAccessible', 'itemTagsSelected'].includes(userPermKey)) {\n        if (this.permissions[userPermKey] !== !!absPermissions[absKey]) {\n          this.permissions[userPermKey] = !!absPermissions[absKey]\n          hasUpdates = true\n        }\n      }\n    })\n\n    // Handle allowedLibraries\n    const librariesAccessible = this.permissions.librariesAccessible || []\n    if (this.permissions.accessAllLibraries) {\n      if (librariesAccessible.length) {\n        this.permissions.librariesAccessible = []\n        hasUpdates = true\n      }\n    } else if (absPermissions.allowedLibraries?.length && absPermissions.allowedLibraries.join(',') !== librariesAccessible.join(',')) {\n      if (absPermissions.allowedLibraries.some((lid) => typeof lid !== 'string')) {\n        throw new Error('Invalid permission property \"allowedLibraries\", expecting array of strings')\n      }\n      this.permissions.librariesAccessible = absPermissions.allowedLibraries\n      hasUpdates = true\n    }\n\n    // Handle allowedTags\n    const itemTagsSelected = this.permissions.itemTagsSelected || []\n    if (this.permissions.accessAllTags) {\n      if (itemTagsSelected.length) {\n        this.permissions.itemTagsSelected = []\n        hasUpdates = true\n      }\n    } else if (absPermissions.allowedTags?.length && absPermissions.allowedTags.join(',') !== itemTagsSelected.join(',')) {\n      if (absPermissions.allowedTags.some((tag) => typeof tag !== 'string')) {\n        throw new Error('Invalid permission property \"allowedTags\", expecting array of strings')\n      }\n      this.permissions.itemTagsSelected = absPermissions.allowedTags\n      hasUpdates = true\n    }\n\n    if (hasUpdates) {\n      this.changed('permissions', true)\n      await this.save()\n    }\n\n    return hasUpdates\n  }\n\n  async update(values, options) {\n    userCache.maybeInvalidate(this)\n    return await super.update(values, options)\n  }\n\n  async save(options) {\n    userCache.maybeInvalidate(this)\n    return await super.save(options)\n  }\n\n  async destroy(options) {\n    userCache.delete(this.id)\n    await super.destroy(options)\n  }\n}\n\nmodule.exports = User\n"
  },
  {
    "path": "server/objects/Backup.js",
    "content": "const Path = require('path')\nconst date = require('../libs/dateAndTime')\nconst version = require('../../package.json').version\n\nclass Backup {\n  constructor(data = null) {\n    this.id = null\n    this.key = null // Special key for pre-version checks\n    this.datePretty = null\n\n    this.backupDirPath = null\n    this.filename = null\n    this.path = null\n    this.fullPath = null\n    this.serverVersion = null\n\n    this.fileSize = null\n    this.createdAt = null\n\n    if (data) {\n      this.construct(data)\n    }\n  }\n\n  get detailsString() {\n    const details = []\n    details.push(this.id)\n    details.push(this.key)\n    details.push(this.createdAt)\n    details.push(this.serverVersion)\n    return details.join('\\n')\n  }\n\n  construct(data) {\n    this.id = data.details[0]\n    this.key = data.details[1]\n    if (this.key == 1) this.key = null // v2.2.23 and below backups stored '1' here\n\n    this.createdAt = Number(data.details[2])\n    this.serverVersion = data.details[3] || null\n\n    this.datePretty = date.format(new Date(this.createdAt), 'ddd, MMM D YYYY HH:mm')\n\n    this.backupDirPath = Path.dirname(data.fullPath)\n    this.filename = Path.basename(data.fullPath)\n    this.path = Path.join('backups', this.filename)\n    this.fullPath = data.fullPath\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      key: this.key,\n      backupDirPath: this.backupDirPath,\n      datePretty: this.datePretty,\n      fullPath: this.fullPath,\n      path: this.path,\n      filename: this.filename,\n      fileSize: this.fileSize,\n      createdAt: this.createdAt,\n      serverVersion: this.serverVersion\n    }\n  }\n\n  setData(backupDirPath) {\n    this.id = date.format(new Date(), 'YYYY-MM-DD[T]HHmm')\n    this.key = 'sqlite'\n    this.datePretty = date.format(new Date(), 'ddd, MMM D YYYY HH:mm')\n\n    this.backupDirPath = backupDirPath\n\n    this.filename = this.id + '.audiobookshelf'\n    this.path = Path.join('backups', this.filename)\n    this.fullPath = Path.join(this.backupDirPath, this.filename)\n\n    this.serverVersion = version\n\n    this.createdAt = Date.now()\n  }\n}\nmodule.exports = Backup"
  },
  {
    "path": "server/objects/DailyLog.js",
    "content": "const Path = require('path')\nconst date = require('../libs/dateAndTime')\nconst fs = require('../libs/fsExtra')\nconst fileUtils = require('../utils/fileUtils')\nconst Logger = require('../Logger')\n\nclass DailyLog {\n  /**\n   * \n   * @param {string} dailyLogDirPath Path to daily logs /metadata/logs/daily\n   */\n  constructor(dailyLogDirPath) {\n    this.id = date.format(new Date(), 'YYYY-MM-DD')\n\n    this.dailyLogDirPath = dailyLogDirPath\n    this.filename = this.id + '.txt'\n    this.fullPath = Path.join(this.dailyLogDirPath, this.filename)\n\n    this.createdAt = Date.now()\n\n    /** @type {import('../managers/LogManager').LogObject[]} */\n    this.logs = []\n    /** @type {string[]} */\n    this.bufferedLogLines = []\n\n    this.locked = false\n  }\n\n  static getCurrentDailyLogFilename() {\n    return date.format(new Date(), 'YYYY-MM-DD') + '.txt'\n  }\n\n  static getCurrentDateString() {\n    return date.format(new Date(), 'YYYY-MM-DD')\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      dailyLogDirPath: this.dailyLogDirPath,\n      fullPath: this.fullPath,\n      filename: this.filename,\n      createdAt: this.createdAt\n    }\n  }\n\n  /**\n   * Append all buffered lines to daily log file\n   */\n  appendBufferedLogs() {\n    let buffered = [...this.bufferedLogLines]\n    this.bufferedLogLines = []\n\n    let oneBigLog = ''\n    buffered.forEach((logLine) => {\n      oneBigLog += logLine\n    })\n    return this.appendLogLine(oneBigLog)\n  }\n\n  /**\n   * \n   * @param {import('../managers/LogManager').LogObject} logObj \n   */\n  appendLog(logObj) {\n    this.logs.push(logObj)\n    return this.appendLogLine(JSON.stringify(logObj) + '\\n')\n  }\n\n  /**\n   * Append log to daily log file\n   * \n   * @param {string} line \n   */\n  async appendLogLine(line) {\n    if (this.locked) {\n      this.bufferedLogLines.push(line)\n      return\n    }\n    this.locked = true\n\n    await fs.writeFile(this.fullPath, line, { flag: \"a+\" }).catch((error) => {\n      console.log('[DailyLog] Append log failed', error)\n    })\n\n    this.locked = false\n    if (this.bufferedLogLines.length) {\n      await this.appendBufferedLogs()\n    }\n  }\n\n  /**\n   * Load all logs from file\n   * Parses lines and re-saves the file if bad lines are removed\n   */\n  async loadLogs() {\n    if (!await fs.pathExists(this.fullPath)) {\n      console.error('Daily log does not exist')\n      return\n    }\n\n    const text = await fileUtils.readTextFile(this.fullPath)\n\n    let hasFailures = false\n\n    let logLines = text.split(/\\r?\\n/)\n    // remove last log if empty\n    if (logLines.length && !logLines[logLines.length - 1]) logLines = logLines.slice(0, -1)\n\n    // JSON parse log lines\n    this.logs = logLines.map(t => {\n      if (!t) {\n        hasFailures = true\n        return null\n      }\n      try {\n        return JSON.parse(t)\n      } catch (err) {\n        console.error('Failed to parse log line', t, err)\n        hasFailures = true\n        return null\n      }\n    }).filter(l => !!l)\n\n    // Rewrite log file to remove errors\n    if (hasFailures) {\n      const newLogLines = this.logs.map(l => JSON.stringify(l)).join('\\n') + '\\n'\n      await fs.writeFile(this.fullPath, newLogLines)\n      console.log('Re-Saved log file to remove bad lines')\n    }\n\n    Logger.debug(`[DailyLog] ${this.id}: Loaded ${this.logs.length} Logs`)\n  }\n}\nmodule.exports = DailyLog"
  },
  {
    "path": "server/objects/DeviceInfo.js",
    "content": "const uuidv4 = require('uuid').v4\nconst { stripAllTags } = require('../utils/htmlSanitizer')\n\nclass DeviceInfo {\n  /** @type {string[]} Fields to sanitize when loading from stored data */\n  static stringFields = ['deviceId', 'clientVersion', 'manufacturer', 'model', 'sdkVersion', 'clientName', 'deviceName']\n\n  constructor(deviceInfo = null) {\n    this.id = null\n    this.userId = null\n    this.deviceId = null\n    this.ipAddress = null\n\n    // From User Agent (see: https://www.npmjs.com/package/ua-parser-js)\n    this.browserName = null\n    this.browserVersion = null\n    this.osName = null\n    this.osVersion = null\n    this.deviceType = null\n\n    // From client\n    this.clientVersion = null\n    this.manufacturer = null\n    this.model = null\n    this.sdkVersion = null // Android Only\n\n    this.clientName = null\n    this.deviceName = null\n\n    if (deviceInfo) {\n      this.construct(deviceInfo)\n    }\n  }\n\n  construct(deviceInfo) {\n    for (const key in deviceInfo) {\n      if (deviceInfo[key] !== undefined && this[key] !== undefined) {\n        this[key] = DeviceInfo.stringFields.includes(key) ? stripAllTags(deviceInfo[key]) : deviceInfo[key]\n      }\n    }\n  }\n\n  toJSON() {\n    const obj = {\n      id: this.id,\n      userId: this.userId,\n      deviceId: this.deviceId,\n      ipAddress: this.ipAddress,\n      browserName: this.browserName,\n      browserVersion: this.browserVersion,\n      osName: this.osName,\n      osVersion: this.osVersion,\n      deviceType: this.deviceType,\n      clientVersion: this.clientVersion,\n      manufacturer: this.manufacturer,\n      model: this.model,\n      sdkVersion: this.sdkVersion,\n      clientName: this.clientName,\n      deviceName: this.deviceName\n    }\n    for (const key in obj) {\n      if (obj[key] === null || obj[key] === undefined) {\n        delete obj[key]\n      }\n    }\n    return obj\n  }\n\n  get deviceDescription() {\n    if (this.model) {\n      // Set from mobile apps\n      if (this.sdkVersion) return `${this.model} SDK ${this.sdkVersion} / v${this.clientVersion}`\n      return `${this.model} / v${this.clientVersion}`\n    }\n    return `${this.osName} ${this.osVersion} / ${this.browserName}`\n  }\n\n  // When client doesn't send a device id\n  getTempDeviceId() {\n    const keys = [this.userId, this.browserName, this.browserVersion, this.osName, this.osVersion, this.clientVersion, this.manufacturer, this.model, this.sdkVersion, this.ipAddress].map((k) => k || '')\n    return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')\n  }\n\n  setData(ip, ua, clientDeviceInfo, serverVersion, userId) {\n    this.id = uuidv4()\n    this.userId = userId\n    this.deviceId = clientDeviceInfo?.deviceId || this.id\n    this.ipAddress = ip || null\n\n    this.browserName = ua?.browser.name || null\n    this.browserVersion = ua?.browser.version || null\n    this.osName = ua?.os.name || null\n    this.osVersion = ua?.os.version || null\n    this.deviceType = ua?.device.type || null\n\n    this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion\n    this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null\n    this.model = stripAllTags(clientDeviceInfo?.model) || null\n    this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null\n\n    this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null\n    if (this.sdkVersion) {\n      if (!this.clientName) this.clientName = 'Abs Android'\n      this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}`\n    } else if (this.model) {\n      if (!this.clientName) this.clientName = 'Abs iOS'\n      this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}`\n    } else if (this.osName && this.browserName) {\n      if (!this.clientName) this.clientName = 'Abs Web'\n      this.deviceName = `${this.osName} ${this.osVersion || 'N/A'} ${this.browserName}`\n    } else if (!this.clientName) {\n      this.clientName = 'Unknown'\n    }\n\n    if (!this.deviceId) {\n      this.deviceId = this.getTempDeviceId()\n    }\n  }\n\n  update(deviceInfo) {\n    const deviceInfoJson = deviceInfo.toJSON ? deviceInfo.toJSON() : deviceInfo\n    const existingDeviceInfoJson = this.toJSON()\n\n    let hasUpdates = false\n    for (const key in deviceInfoJson) {\n      if (['id', 'deviceId'].includes(key)) continue\n\n      if (deviceInfoJson[key] !== existingDeviceInfoJson[key]) {\n        this[key] = deviceInfoJson[key]\n        hasUpdates = true\n      }\n    }\n\n    for (const key in existingDeviceInfoJson) {\n      if (['id', 'deviceId'].includes(key)) continue\n\n      if (existingDeviceInfoJson[key] && !deviceInfoJson[key]) {\n        this[key] = null\n        hasUpdates = true\n      }\n    }\n\n    return hasUpdates\n  }\n}\nmodule.exports = DeviceInfo\n"
  },
  {
    "path": "server/objects/Notification.js",
    "content": "const uuidv4 = require(\"uuid\").v4\n\nclass Notification {\n  constructor(notification = null) {\n    this.id = null\n    this.libraryId = null\n    this.eventName = ''\n    this.urls = []\n    this.titleTemplate = ''\n    this.bodyTemplate = ''\n    this.type = 'info'\n    this.enabled = false\n\n    this.lastFiredAt = null\n    this.lastAttemptFailed = false\n    this.numConsecutiveFailedAttempts = 0\n    this.numTimesFired = 0\n    this.createdAt = null\n\n    if (notification) {\n      this.construct(notification)\n    }\n  }\n\n  construct(notification) {\n    this.id = notification.id\n    this.libraryId = notification.libraryId || null\n    this.eventName = notification.eventName\n    this.urls = notification.urls || []\n    this.titleTemplate = notification.titleTemplate || ''\n    this.bodyTemplate = notification.bodyTemplate || ''\n    this.type = notification.type || 'info'\n    this.enabled = !!notification.enabled\n    this.lastFiredAt = notification.lastFiredAt || null\n    this.lastAttemptFailed = !!notification.lastAttemptFailed\n    this.numConsecutiveFailedAttempts = notification.numConsecutiveFailedAttempts || 0\n    this.numTimesFired = notification.numTimesFired || 0\n    this.createdAt = notification.createdAt\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      libraryId: this.libraryId,\n      eventName: this.eventName,\n      urls: this.urls,\n      titleTemplate: this.titleTemplate,\n      bodyTemplate: this.bodyTemplate,\n      enabled: this.enabled,\n      type: this.type,\n      lastFiredAt: this.lastFiredAt,\n      lastAttemptFailed: this.lastAttemptFailed,\n      numConsecutiveFailedAttempts: this.numConsecutiveFailedAttempts,\n      numTimesFired: this.numTimesFired,\n      createdAt: this.createdAt\n    }\n  }\n\n  setData(payload) {\n    this.id = uuidv4()\n    this.libraryId = payload.libraryId || null\n    this.eventName = payload.eventName\n    this.urls = payload.urls\n    this.titleTemplate = payload.titleTemplate\n    this.bodyTemplate = payload.bodyTemplate\n    this.enabled = !!payload.enabled\n    this.type = payload.type || null\n    this.createdAt = Date.now()\n  }\n\n  update(payload) {\n    if (!this.enabled && payload.enabled) {\n      // Reset\n      this.lastFiredAt = null\n      this.lastAttemptFailed = false\n      this.numConsecutiveFailedAttempts = 0\n    }\n\n    const keysToUpdate = ['libraryId', 'eventName', 'urls', 'titleTemplate', 'bodyTemplate', 'enabled', 'type']\n    var hasUpdated = false\n    for (const key of keysToUpdate) {\n      if (payload[key] !== undefined) {\n        if (key === 'urls') {\n          if (payload[key].join(',') !== this.urls.join(',')) {\n            this.urls = [...payload[key]]\n            hasUpdated = true\n          }\n        } else if (payload[key] !== this[key]) {\n          this[key] = payload[key]\n          hasUpdated = true\n        }\n      }\n    }\n    return hasUpdated\n  }\n\n  updateNotificationFired(success) {\n    this.lastFiredAt = Date.now()\n    this.lastAttemptFailed = !success\n    this.numConsecutiveFailedAttempts = success ? 0 : this.numConsecutiveFailedAttempts + 1\n    this.numTimesFired++\n  }\n\n  replaceVariablesInTemplate(templateText, data) {\n    const ptrn = /{{ ?([a-zA-Z]+) ?}}/mg\n\n    var match\n    var updatedTemplate = templateText\n    while ((match = ptrn.exec(templateText)) != null) {\n      if (data[match[1]]) {\n        updatedTemplate = updatedTemplate.replace(match[0], data[match[1]])\n      }\n    }\n    return updatedTemplate\n  }\n\n  parseTitleTemplate(data) {\n    return this.replaceVariablesInTemplate(this.titleTemplate, data)\n  }\n\n  parseBodyTemplate(data) {\n    return this.replaceVariablesInTemplate(this.bodyTemplate, data)\n  }\n\n  getApprisePayload(data) {\n    return {\n      urls: this.urls,\n      title: this.parseTitleTemplate(data),\n      body: this.parseBodyTemplate(data)\n    }\n  }\n}\nmodule.exports = Notification"
  },
  {
    "path": "server/objects/PlaybackSession.js",
    "content": "const date = require('../libs/dateAndTime')\nconst uuidv4 = require('uuid').v4\nconst serverVersion = require('../../package.json').version\nconst DeviceInfo = require('./DeviceInfo')\n\nclass PlaybackSession {\n  constructor(session) {\n    this.id = null\n    this.userId = null\n    this.libraryId = null\n    this.libraryItemId = null\n    this.bookId = null\n    this.episodeId = null\n\n    this.mediaType = null\n    this.mediaMetadata = null\n    this.chapters = null\n    this.displayTitle = null\n    this.displayAuthor = null\n    this.coverPath = null\n    this.duration = null\n\n    this.playMethod = null\n    this.mediaPlayer = null\n    this.deviceInfo = null\n    this.serverVersion = null\n\n    this.date = null\n    this.dayOfWeek = null\n\n    this.timeListening = null\n    this.startTime = null // media current time at start of playback\n    this.currentTime = 0 // Last current time set\n\n    this.startedAt = null\n    this.updatedAt = null\n\n    // Not saved in DB\n    this.lastSave = 0\n    this.audioTracks = []\n    this.stream = null\n    // Used for share sessions\n    this.shareSessionId = null\n    this.mediaItemShareId = null\n    this.coverAspectRatio = null\n\n    if (session) {\n      this.construct(session)\n    }\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      userId: this.userId,\n      libraryId: this.libraryId,\n      libraryItemId: this.libraryItemId,\n      bookId: this.bookId,\n      episodeId: this.episodeId,\n      mediaType: this.mediaType,\n      mediaMetadata: structuredClone(this.mediaMetadata),\n      chapters: (this.chapters || []).map((c) => ({ ...c })),\n      displayTitle: this.displayTitle,\n      displayAuthor: this.displayAuthor,\n      coverPath: this.coverPath,\n      duration: this.duration,\n      playMethod: this.playMethod,\n      mediaPlayer: this.mediaPlayer,\n      deviceInfo: this.deviceInfo?.toJSON() || null,\n      serverVersion: this.serverVersion,\n      date: this.date,\n      dayOfWeek: this.dayOfWeek,\n      timeListening: this.timeListening,\n      startTime: this.startTime,\n      currentTime: this.currentTime,\n      startedAt: this.startedAt,\n      updatedAt: this.updatedAt\n    }\n  }\n\n  /**\n   * Session data to send to clients\n   * @param {import('../models/LibraryItem')} [libraryItem]\n   * @returns\n   */\n  toJSONForClient(libraryItem) {\n    return {\n      id: this.id,\n      userId: this.userId,\n      libraryId: this.libraryId,\n      libraryItemId: this.libraryItemId,\n      bookId: this.bookId,\n      episodeId: this.episodeId,\n      mediaType: this.mediaType,\n      mediaMetadata: structuredClone(this.mediaMetadata),\n      chapters: (this.chapters || []).map((c) => ({ ...c })),\n      displayTitle: this.displayTitle,\n      displayAuthor: this.displayAuthor,\n      coverPath: this.coverPath,\n      duration: this.duration,\n      playMethod: this.playMethod,\n      mediaPlayer: this.mediaPlayer,\n      deviceInfo: this.deviceInfo?.toJSON() || null,\n      serverVersion: this.serverVersion,\n      date: this.date,\n      dayOfWeek: this.dayOfWeek,\n      timeListening: this.timeListening,\n      startTime: this.startTime,\n      currentTime: this.currentTime,\n      startedAt: this.startedAt,\n      updatedAt: this.updatedAt,\n      audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),\n      libraryItem: libraryItem?.toOldJSONExpanded() || null\n    }\n  }\n\n  construct(session) {\n    this.id = session.id\n    this.userId = session.userId\n    this.libraryId = session.libraryId || null\n    this.libraryItemId = session.libraryItemId\n    this.bookId = session.bookId || null\n    this.episodeId = session.episodeId\n    this.mediaType = session.mediaType\n    this.duration = session.duration\n    this.playMethod = session.playMethod\n    this.mediaPlayer = session.mediaPlayer || null\n\n    // Temp do not store old IDs\n    if (this.libraryId?.startsWith('lib_')) {\n      this.libraryId = null\n    }\n    if (this.libraryItemId?.startsWith('li_') || this.libraryItemId?.startsWith('local_')) {\n      this.libraryItemId = null\n    }\n    if (this.episodeId?.startsWith('ep_') || this.episodeId?.startsWith('local_')) {\n      this.episodeId = null\n    }\n\n    if (session.deviceInfo instanceof DeviceInfo) {\n      this.deviceInfo = new DeviceInfo(session.deviceInfo.toJSON())\n    } else {\n      this.deviceInfo = new DeviceInfo(session.deviceInfo)\n    }\n\n    this.serverVersion = session.serverVersion\n    this.chapters = session.chapters || []\n\n    this.mediaMetadata = session.mediaMetadata\n    this.displayTitle = session.displayTitle || ''\n    this.displayAuthor = session.displayAuthor || ''\n    this.coverPath = session.coverPath\n    this.date = session.date\n    this.dayOfWeek = session.dayOfWeek\n\n    this.timeListening = session.timeListening || null\n    this.startTime = session.startTime || 0\n    this.currentTime = session.currentTime || 0\n\n    this.startedAt = session.startedAt\n    this.updatedAt = session.updatedAt || session.startedAt\n\n    // Local playback sessions dont set this date field so set using updatedAt\n    if (!this.date && session.updatedAt) {\n      this.date = date.format(new Date(session.updatedAt), 'YYYY-MM-DD')\n      this.dayOfWeek = date.format(new Date(session.updatedAt), 'dddd')\n    }\n  }\n\n  get mediaItemId() {\n    if (this.episodeId) return `${this.libraryItemId}-${this.episodeId}`\n    return this.libraryItemId\n  }\n\n  get progress() {\n    // Value between 0 and 1\n    if (!this.duration) return 0\n    return Math.max(0, Math.min(this.currentTime / this.duration, 1))\n  }\n\n  get deviceId() {\n    return this.deviceInfo?.id\n  }\n\n  get deviceDescription() {\n    if (!this.deviceInfo) return 'No Device Info'\n    return this.deviceInfo.deviceDescription\n  }\n\n  get mediaProgressObject() {\n    return {\n      duration: this.duration,\n      currentTime: this.currentTime,\n      progress: this.progress,\n      lastUpdate: this.updatedAt\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} userId\n   * @param {*} mediaPlayer\n   * @param {*} deviceInfo\n   * @param {*} startTime\n   * @param {*} episodeId\n   */\n  setData(libraryItem, userId, mediaPlayer, deviceInfo, startTime, episodeId = null) {\n    this.id = uuidv4()\n    this.userId = userId\n    this.libraryId = libraryItem.libraryId\n    this.libraryItemId = libraryItem.id\n    this.bookId = episodeId ? null : libraryItem.media.id\n    this.episodeId = episodeId\n    this.mediaType = libraryItem.mediaType\n    this.mediaMetadata = libraryItem.media.oldMetadataToJSON()\n    this.chapters = libraryItem.media.getChapters(episodeId)\n    this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId)\n    this.displayAuthor = libraryItem.media.getPlaybackAuthor()\n    this.coverPath = libraryItem.media.coverPath\n    this.duration = libraryItem.media.getPlaybackDuration(episodeId)\n\n    this.mediaPlayer = mediaPlayer\n    this.deviceInfo = deviceInfo || new DeviceInfo()\n    this.serverVersion = serverVersion\n\n    this.timeListening = 0\n    this.startTime = startTime\n    this.currentTime = startTime\n\n    this.date = date.format(new Date(), 'YYYY-MM-DD')\n    this.dayOfWeek = date.format(new Date(), 'dddd')\n    this.startedAt = Date.now()\n    this.updatedAt = Date.now()\n  }\n\n  addListeningTime(timeListened) {\n    if (!timeListened || isNaN(timeListened)) return\n\n    if (!this.date) {\n      // Set date info on first listening update\n      this.date = date.format(new Date(), 'YYYY-MM-DD')\n      this.dayOfWeek = date.format(new Date(), 'dddd')\n    }\n\n    this.timeListening += Number.parseFloat(timeListened)\n    this.updatedAt = Date.now()\n  }\n}\nmodule.exports = PlaybackSession\n"
  },
  {
    "path": "server/objects/PodcastEpisodeDownload.js",
    "content": "const Path = require('path')\nconst uuidv4 = require('uuid').v4\nconst { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils')\nconst globals = require('../utils/globals')\n\nclass PodcastEpisodeDownload {\n  constructor() {\n    this.id = null\n    /** @type {import('../utils/podcastUtils').RssPodcastEpisode} */\n    this.rssPodcastEpisode = null\n\n    this.url = null\n    /** @type {import('../models/LibraryItem')} */\n    this.libraryItem = null\n    this.libraryId = null\n\n    this.isAutoDownload = false\n    this.isFinished = false\n    this.failed = false\n\n    this.appendRandomId = false\n\n    this.targetFilename = null\n\n    this.startedAt = null\n    this.createdAt = null\n    this.finishedAt = null\n  }\n\n  toJSONForClient() {\n    return {\n      id: this.id,\n      episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null,\n      url: this.url,\n      libraryItemId: this.libraryItemId,\n      libraryId: this.libraryId || null,\n      isFinished: this.isFinished,\n      failed: this.failed,\n      appendRandomId: this.appendRandomId,\n      startedAt: this.startedAt,\n      createdAt: this.createdAt,\n      finishedAt: this.finishedAt,\n      podcastTitle: this.libraryItem?.media.title ?? null,\n      podcastExplicit: !!this.libraryItem?.media.explicit,\n      season: this.rssPodcastEpisode?.season ?? null,\n      episode: this.rssPodcastEpisode?.episode ?? null,\n      episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',\n      publishedAt: this.rssPodcastEpisode?.publishedAt ?? null,\n      guid: this.rssPodcastEpisode?.guid ?? null\n    }\n  }\n\n  get urlFileExtension() {\n    const cleanUrl = this.url.split('?')[0] // Remove query string\n    return Path.extname(cleanUrl).substring(1).toLowerCase()\n  }\n  get fileExtension() {\n    const extname = this.urlFileExtension\n    if (globals.SupportedAudioTypes.includes(extname)) return extname\n    return 'mp3'\n  }\n  get enclosureType() {\n    const enclosureType = this.rssPodcastEpisode.enclosure.type\n    return typeof enclosureType === 'string' ? enclosureType : null\n  }\n  get episodeTitle() {\n    return this.rssPodcastEpisode.title\n  }\n  get targetPath() {\n    return filePathToPOSIX(Path.join(this.libraryItem.path, this.targetFilename))\n  }\n  get targetRelPath() {\n    return this.targetFilename\n  }\n  get libraryItemId() {\n    return this.libraryItem?.id || null\n  }\n  get pubYear() {\n    if (!this.rssPodcastEpisode.publishedAt) return null\n    return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()\n  }\n\n  /**\n   * @param {string} title\n   */\n  getSanitizedFilename(title) {\n    const appendage = this.appendRandomId ? ` (${this.id})` : ''\n    const filename = `${title.trim()}${appendage}.${this.fileExtension}`\n    return sanitizeFilename(filename)\n  }\n\n  /**\n   * @param {boolean} appendRandomId\n   */\n  setAppendRandomId(appendRandomId) {\n    this.appendRandomId = appendRandomId\n    this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '')\n  }\n\n  /**\n   *\n   * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} isAutoDownload\n   * @param {*} libraryId\n   */\n  setData(rssPodcastEpisode, libraryItem, isAutoDownload, libraryId) {\n    this.id = uuidv4()\n    this.rssPodcastEpisode = rssPodcastEpisode\n\n    const url = rssPodcastEpisode.enclosure.url\n    if (decodeURIComponent(url) !== url) {\n      // Already encoded\n      this.url = url\n    } else {\n      this.url = encodeURI(url)\n    }\n\n    this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '')\n\n    this.libraryItem = libraryItem\n    this.isAutoDownload = isAutoDownload\n    this.createdAt = Date.now()\n    this.libraryId = libraryId\n  }\n\n  setFinished(success) {\n    this.finishedAt = Date.now()\n    this.isFinished = true\n    this.failed = !success\n  }\n}\nmodule.exports = PodcastEpisodeDownload\n"
  },
  {
    "path": "server/objects/Stream.js",
    "content": "const EventEmitter = require('events')\nconst Path = require('path')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\n\nconst fs = require('../libs/fsExtra')\nconst Ffmpeg = require('../libs/fluentFfmpeg')\n\nconst { secondsToTimestamp } = require('../utils/index')\nconst { writeConcatFile } = require('../utils/ffmpegHelpers')\nconst { AudioMimeType } = require('../utils/constants')\nconst hlsPlaylistGenerator = require('../utils/generators/hlsPlaylistGenerator')\nconst AudioTrack = require('./files/AudioTrack')\n\nclass Stream extends EventEmitter {\n  constructor(sessionId, streamPath, user, libraryItem, episodeId, startTime, transcodeOptions = {}) {\n    super()\n\n    this.id = sessionId\n    this.user = user\n    /** @type {import('../models/LibraryItem')} */\n    this.libraryItem = libraryItem\n    this.episodeId = episodeId\n\n    this.transcodeOptions = transcodeOptions\n\n    this.segmentLength = 6\n    this.maxSeekBackTime = 30\n    this.streamPath = Path.join(streamPath, this.id)\n    this.concatFilesPath = Path.join(this.streamPath, 'files.txt')\n    this.playlistPath = Path.join(this.streamPath, 'output.m3u8')\n    this.finalPlaylistPath = Path.join(this.streamPath, 'final-output.m3u8')\n    this.startTime = startTime\n\n    this.ffmpeg = null\n    this.loop = null\n    this.isResetting = false\n    this.isClientInitialized = false\n    this.isTranscodeComplete = false\n    this.segmentsCreated = new Set()\n    this.furthestSegmentCreated = 0\n  }\n\n  /**\n   * @returns {import('../models/PodcastEpisode') | null}\n   */\n  get episode() {\n    if (!this.libraryItem.isPodcast) return null\n    return this.libraryItem.media.podcastEpisodes.find((ep) => ep.id === this.episodeId)\n  }\n  get mediaTitle() {\n    return this.libraryItem.media.getPlaybackTitle(this.episodeId)\n  }\n  get totalDuration() {\n    return this.libraryItem.media.getPlaybackDuration(this.episodeId)\n  }\n  get tracks() {\n    return this.libraryItem.getTrackList(this.episodeId)\n  }\n  get tracksAudioFileType() {\n    if (!this.tracks.length) return null\n    return this.tracks[0].metadata.ext.slice(1)\n  }\n  get tracksMimeType() {\n    if (!this.tracks.length) return null\n    return this.tracks[0].mimeType\n  }\n  get tracksCodec() {\n    if (!this.tracks.length) return null\n    return this.tracks[0].codec\n  }\n  get mimeTypesToForceAAC() {\n    return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]\n  }\n  get codecsToForceAAC() {\n    return ['alac', 'ac3', 'eac3']\n  }\n  get userToken() {\n    return this.user.token\n  }\n  // Fmp4 does not work on iOS devices: https://github.com/advplyr/audiobookshelf-app/issues/85\n  //   Workaround: Force AAC transcode for FLAC\n  get hlsSegmentType() {\n    return 'mpegts'\n  }\n  get segmentBasename() {\n    return 'output-%d.ts'\n  }\n  get segmentStartNumber() {\n    if (!this.startTime) return 0\n    return Math.floor(Math.max(this.startTime - this.maxSeekBackTime, 0) / this.segmentLength)\n  }\n  get numSegments() {\n    var numSegs = Math.floor(this.totalDuration / this.segmentLength)\n    if (this.totalDuration - numSegs * this.segmentLength > 0) {\n      numSegs++\n    }\n    return numSegs\n  }\n  get clientPlaylistUri() {\n    return `/hls/${this.id}/output.m3u8`\n  }\n  get isAACEncodable() {\n    return ['mp4', 'm4a', 'm4b'].includes(this.tracksAudioFileType)\n  }\n  get transcodeForceAAC() {\n    return !!this.transcodeOptions.forceAAC\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      userId: this.user.id,\n      libraryItem: this.libraryItem.toOldJSONExpanded(),\n      episode: this.episode ? this.episode.toOldJSONExpanded(this.libraryItem.id) : null,\n      segmentLength: this.segmentLength,\n      playlistPath: this.playlistPath,\n      clientPlaylistUri: this.clientPlaylistUri,\n      startTime: this.startTime,\n      segmentStartNumber: this.segmentStartNumber,\n      isTranscodeComplete: this.isTranscodeComplete\n    }\n  }\n\n  async checkSegmentNumberRequest(segNum) {\n    const segStartTime = segNum * this.segmentLength\n    if (this.segmentStartNumber > segNum) {\n      Logger.warn(`[STREAM] Segment #${segNum} Request is before starting segment number #${this.segmentStartNumber} - Reset Transcode`)\n      await this.reset(segStartTime - this.segmentLength * 5)\n      return segStartTime\n    } else if (this.isTranscodeComplete) {\n      return false\n    }\n\n    if (this.furthestSegmentCreated) {\n      const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated\n      if (distanceFromFurthestSegment > 10) {\n        Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`)\n        await this.reset(segStartTime - this.segmentLength * 5)\n        return segStartTime\n      }\n    }\n\n    return false\n  }\n\n  async generatePlaylist() {\n    await fs.ensureDir(this.streamPath)\n    await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)\n    return this.clientPlaylistUri\n  }\n\n  async checkFiles() {\n    try {\n      var files = await fs.readdir(this.streamPath)\n      files.forEach((file) => {\n        var extname = Path.extname(file)\n        if (extname === '.ts') {\n          var basename = Path.basename(file, extname)\n          var num_part = basename.split('-')[1]\n          var part_num = Number(num_part)\n          this.segmentsCreated.add(part_num)\n        }\n      })\n\n      if (!this.segmentsCreated.size) {\n        Logger.warn('No Segments')\n        return\n      }\n\n      if (this.segmentsCreated.size > 6 && !this.isClientInitialized) {\n        this.isClientInitialized = true\n        Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)\n        this.clientEmit('stream_open', this.toJSON())\n      }\n\n      var chunks = []\n      var current_chunk = []\n      var last_seg_in_chunk = -1\n\n      var segments = Array.from(this.segmentsCreated).sort((a, b) => a - b)\n      var lastSegment = segments[segments.length - 1]\n      if (lastSegment > this.furthestSegmentCreated) {\n        this.furthestSegmentCreated = lastSegment\n      }\n\n      segments.forEach((seg) => {\n        if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {\n          last_seg_in_chunk = seg\n          current_chunk.push(seg)\n        } else {\n          if (current_chunk.length === 1) chunks.push(current_chunk[0])\n          else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)\n          last_seg_in_chunk = seg\n          current_chunk = [seg]\n        }\n      })\n      if (current_chunk.length) {\n        if (current_chunk.length === 1) chunks.push(current_chunk[0])\n        else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)\n      }\n\n      var perc = ((this.segmentsCreated.size * 100) / this.numSegments).toFixed(2) + '%'\n      Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)\n      // Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', '))\n\n      this.clientEmit('stream_progress', {\n        stream: this.id,\n        percent: perc,\n        chunks,\n        numSegments: this.numSegments\n      })\n    } catch (error) {\n      Logger.error('Failed checking files', error)\n    }\n  }\n\n  startLoop() {\n    this.clientEmit('stream_progress', { stream: this.id, chunks: [], numSegments: 0, percent: '0%' })\n\n    clearInterval(this.loop)\n    var intervalId = setInterval(() => {\n      if (!this.isTranscodeComplete) {\n        this.checkFiles()\n      } else {\n        Logger.info(`[Stream] ${this.mediaTitle} sending stream_ready`)\n        this.clientEmit('stream_ready')\n        clearInterval(intervalId)\n      }\n    }, 2000)\n    this.loop = intervalId\n  }\n\n  async start() {\n    Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)\n\n    /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */\n    this.ffmpeg = Ffmpeg()\n    this.furthestSegmentCreated = 0\n\n    const adjustedStartTime = Math.max(this.startTime - this.maxSeekBackTime, 0)\n    const trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, adjustedStartTime)\n    if (trackStartTime == null) {\n      // Close stream show error\n      this.ffmpeg = null\n      this.close('Failed to write stream concat file')\n      return\n    }\n\n    this.ffmpeg.addInput(this.concatFilesPath)\n    // seek_timestamp : https://ffmpeg.org/ffmpeg.html\n    // the argument to the -ss option is considered an actual timestamp, and is not offset by the start time of the file\n    //   fixes https://github.com/advplyr/audiobookshelf/issues/116\n    this.ffmpeg.inputOption('-seek_timestamp 1')\n    this.ffmpeg.inputFormat('concat')\n    this.ffmpeg.inputOption('-safe 0')\n\n    if (adjustedStartTime > 0) {\n      const shiftedStartTime = adjustedStartTime - trackStartTime\n      // Issues using exact fractional seconds i.e. 29.49814 - changing to 29.5s\n      var startTimeS = Math.round(shiftedStartTime * 10) / 10 + 's'\n      Logger.info(`[STREAM] Starting Stream at startTime ${secondsToTimestamp(adjustedStartTime)} (User startTime ${secondsToTimestamp(this.startTime)}) and Segment #${this.segmentStartNumber}`)\n      this.ffmpeg.inputOption(`-ss ${startTimeS}`)\n\n      this.ffmpeg.inputOption('-noaccurate_seek')\n    }\n\n    const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'\n\n    let audioCodec = 'copy'\n    if (this.transcodeForceAAC || this.mimeTypesToForceAAC.includes(this.tracksMimeType) || this.codecsToForceAAC.includes(this.tracksCodec)) {\n      Logger.debug(`[Stream] Forcing AAC for tracks with mime type ${this.tracksMimeType} and codec ${this.tracksCodec}`)\n      audioCodec = 'aac'\n    }\n\n    const codecOptions = [`-loglevel ${logLevel}`, '-map 0:a']\n\n    if (['ac3', 'eac3'].includes(this.tracksCodec) && this.tracks.length > 0 && this.tracks[0].bitRate && this.tracks[0].channels) {\n      // In case for ac3/eac3 it needs to be passed the bitrate and channels to avoid ffmpeg errors\n      codecOptions.push(`-c:a ${audioCodec}`, `-b:a ${this.tracks[0].bitRate}`, `-ac ${this.tracks[0].channels}`)\n    } else {\n      codecOptions.push(`-c:a ${audioCodec}`)\n    }\n\n    this.ffmpeg.addOption(codecOptions)\n    const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']\n    this.ffmpeg.addOption(hlsOptions)\n    if (this.hlsSegmentType === 'fmp4') {\n      this.ffmpeg.addOption('-strict -2')\n      var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')\n      // var fmp4InitFilename = 'init.mp4'\n      this.ffmpeg.addOption('-hls_fmp4_init_filename', fmp4InitFilename)\n    }\n    var segmentFilename = Path.join(this.streamPath, this.segmentBasename)\n    this.ffmpeg.addOption('-hls_segment_filename', segmentFilename)\n    this.ffmpeg.output(this.finalPlaylistPath)\n\n    this.ffmpeg.on('start', (command) => {\n      Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)\n      Logger.info('')\n      if (this.isResetting) {\n        // AAC encode is much slower\n        const clearIsResettingTime = this.transcodeForceAAC ? 3000 : 500\n        setTimeout(() => {\n          Logger.info('[STREAM] Clearing isResetting')\n          this.isResetting = false\n          this.startLoop()\n        }, clearIsResettingTime)\n      } else {\n        this.startLoop()\n      }\n    })\n\n    this.ffmpeg.on('stderr', (stdErrline) => {\n      Logger.info(stdErrline)\n    })\n\n    this.ffmpeg.on('error', (err, stdout, stderr) => {\n      if (err.message && err.message.includes('SIGKILL')) {\n        // This is an intentional SIGKILL\n        Logger.info('[FFMPEG] Transcode Killed')\n        this.ffmpeg = null\n        clearInterval(this.loop)\n      } else {\n        Logger.error('Ffmpeg Err', '\"' + err.message + '\"')\n\n        // Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 and https://github.com/advplyr/audiobookshelf/issues/2157\n        const aacErrorMsg = 'ffmpeg exited with code 1'\n        const errorMessageSuggestsReEncode = err.message?.startsWith(aacErrorMsg) && !err.message?.includes('No such file or directory')\n        if (audioCodec === 'copy' && this.isAACEncodable && errorMessageSuggestsReEncode) {\n          Logger.info(`[Stream] Re-attempting stream with AAC encode`)\n          this.transcodeOptions.forceAAC = true\n          this.reset(this.startTime)\n        } else {\n          // Close stream show error\n          this.close(err.message)\n        }\n      }\n    })\n\n    this.ffmpeg.on('end', (stdout, stderr) => {\n      Logger.info('[FFMPEG] Transcoding ended')\n      // For very small fast load\n      if (!this.isClientInitialized) {\n        this.isClientInitialized = true\n\n        Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)\n        this.clientEmit('stream_open', this.toJSON())\n      }\n      this.isTranscodeComplete = true\n      this.ffmpeg = null\n      clearInterval(this.loop)\n    })\n\n    this.ffmpeg.run()\n  }\n\n  async close(errorMessage = null) {\n    clearInterval(this.loop)\n\n    Logger.info('Closing Stream', this.id)\n    if (this.ffmpeg) {\n      this.ffmpeg.kill('SIGKILL')\n    }\n\n    await fs\n      .remove(this.streamPath)\n      .then(() => {\n        Logger.info('Deleted session data', this.streamPath)\n      })\n      .catch((err) => {\n        Logger.error('Failed to delete session data', err)\n      })\n\n    if (errorMessage) this.clientEmit('stream_error', { id: this.id, error: (errorMessage || '').trim() })\n    else this.clientEmit('stream_closed', this.id)\n\n    this.emit('closed')\n  }\n\n  cancelTranscode() {\n    clearInterval(this.loop)\n    if (this.ffmpeg) {\n      this.ffmpeg.kill('SIGKILL')\n    }\n  }\n\n  async waitCancelTranscode() {\n    for (let i = 0; i < 20; i++) {\n      if (!this.ffmpeg) return true\n      await new Promise((resolve) => setTimeout(resolve, 500))\n    }\n    Logger.error('[STREAM] Transcode never closed...')\n    return false\n  }\n\n  async reset(time) {\n    if (this.isResetting) {\n      return Logger.info(`[STREAM] Stream ${this.id} already resetting`)\n    }\n    time = Math.max(0, time)\n    this.isResetting = true\n\n    if (this.ffmpeg) {\n      this.cancelTranscode()\n      await this.waitCancelTranscode()\n    }\n\n    this.isTranscodeComplete = false\n    this.startTime = time\n    // this.clientCurrentTime = this.startTime\n    Logger.info(`Stream Reset New Start Time ${secondsToTimestamp(this.startTime)}`)\n    this.start()\n  }\n\n  clientEmit(evtName, data) {\n    SocketAuthority.clientEmitter(this.user.id, evtName, data)\n  }\n\n  getAudioTrack() {\n    var newAudioTrack = new AudioTrack()\n    newAudioTrack.setFromStream(this.mediaTitle, this.totalDuration, this.clientPlaylistUri)\n    return newAudioTrack\n  }\n}\nmodule.exports = Stream\n"
  },
  {
    "path": "server/objects/Task.js",
    "content": "const uuidv4 = require('uuid').v4\n\n/**\n * @typedef TaskString\n * @property {string} text\n * @property {string} key\n * @property {string[]} [subs]\n */\n\nclass Task {\n  constructor() {\n    /** @type {string} */\n    this.id = null\n    /** @type {string} */\n    this.action = null // e.g. embed-metadata, encode-m4b, etc\n    /** @type {Object} custom data */\n    this.data = null // additional info for the action like libraryItemId\n\n    /** @type {string} */\n    this.title = null\n    /** @type {string} - Used for translation */\n    this.titleKey = null\n    /** @type {string[]} - Used for translation */\n    this.titleSubs = null\n\n    /** @type {string} */\n    this.description = null\n    /** @type {string} - Used for translation */\n    this.descriptionKey = null\n    /** @type {string[]} - Used for translation */\n    this.descriptionSubs = null\n\n    /** @type {string} */\n    this.error = null\n    /** @type {string} - Used for translation */\n    this.errorKey = null\n    /** @type {string[]} - Used for translation */\n    this.errorSubs = null\n\n    /** @type {boolean} client should keep the task visible after success */\n    this.showSuccess = false\n\n    /** @type {boolean} */\n    this.isFailed = false\n    /** @type {boolean} */\n    this.isFinished = false\n\n    /** @type {number} */\n    this.startedAt = null\n    /** @type {number} */\n    this.finishedAt = null\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      action: this.action,\n      data: this.data ? { ...this.data } : {},\n      title: this.title,\n      titleKey: this.titleKey,\n      titleSubs: this.titleSubs,\n      description: this.description,\n      descriptionKey: this.descriptionKey,\n      descriptionSubs: this.descriptionSubs,\n      error: this.error,\n      errorKey: this.errorKey,\n      errorSubs: this.errorSubs,\n      showSuccess: this.showSuccess,\n      isFailed: this.isFailed,\n      isFinished: this.isFinished,\n      startedAt: this.startedAt,\n      finishedAt: this.finishedAt\n    }\n  }\n\n  /**\n   * Set initial task data\n   *\n   * @param {string} action\n   * @param {TaskString} titleString\n   * @param {TaskString|null} descriptionString\n   * @param {boolean} showSuccess\n   * @param {Object} [data]\n   */\n  setData(action, titleString, descriptionString, showSuccess, data = {}) {\n    this.id = uuidv4()\n    this.action = action\n    this.data = { ...data }\n    this.title = titleString.text\n    this.titleKey = titleString.key || null\n    this.titleSubs = titleString.subs || null\n    this.description = descriptionString?.text || null\n    this.descriptionKey = descriptionString?.key || null\n    this.descriptionSubs = descriptionString?.subs || null\n    this.showSuccess = showSuccess\n    this.startedAt = Date.now()\n  }\n\n  /**\n   * Set task as failed\n   *\n   * @param {TaskString} messageString\n   */\n  setFailed(messageString) {\n    this.error = messageString.text\n    this.errorKey = messageString.key || null\n    this.errorSubs = messageString.subs || null\n    this.isFailed = true\n    this.failedAt = Date.now()\n    this.setFinished()\n  }\n\n  /**\n   * Set task as finished\n   *\n   * @param {TaskString} [newDescriptionString] update description\n   * @param {boolean} [clearDescription] clear description\n   */\n  setFinished(newDescriptionString = null, clearDescription = false) {\n    if (newDescriptionString) {\n      this.description = newDescriptionString.text\n      this.descriptionKey = newDescriptionString.key || null\n      this.descriptionSubs = newDescriptionString.subs || null\n    } else if (clearDescription) {\n      this.description = null\n      this.descriptionKey = null\n      this.descriptionSubs = null\n    }\n    this.isFinished = true\n    this.finishedAt = Date.now()\n  }\n}\nmodule.exports = Task\n"
  },
  {
    "path": "server/objects/TrackProgressMonitor.js",
    "content": "class TrackProgressMonitor {\n  /**\n   * @callback TrackStartedCallback\n   * @param {number} trackIndex - The index of the track that started.\n   */\n\n  /**\n   * @callback ProgressCallback\n   * @param {number} trackIndex - The index of the current track.\n   * @param {number} progressInTrack - The current track progress in percent.\n   * @param {number} totalProgress - The total progress in percent.\n   */\n\n  /**\n   * @callback TrackFinishedCallback\n   * @param {number} trackIndex - The index of the track that finished.\n   */\n\n  /**\n   * Creates a new TrackProgressMonitor.\n   * @constructor\n   * @param {number[]} trackDurations - The durations of the tracks in seconds.\n   * @param {TrackStartedCallback} trackStartedCallback - The callback to call when a track starts.\n   * @param {ProgressCallback} progressCallback - The callback to call when progress is updated.\n   * @param {TrackFinishedCallback} trackFinishedCallback - The callback to call when a track finishes.\n   */\n  constructor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback) {\n    this.trackDurations = trackDurations\n    this.totalDuration = trackDurations.reduce((total, duration) => total + duration, 0)\n    this.trackStartedCallback = trackStartedCallback\n    this.progressCallback = progressCallback\n    this.trackFinishedCallback = trackFinishedCallback\n    this.currentTrackIndex = -1\n    this.cummulativeProgress = 0\n    this.currentTrackPercentage = 0\n    this.numTracks = this.trackDurations.length\n    this.allTracksFinished = false\n    this.#moveToNextTrack()\n  }\n\n  #outsideCurrentTrack(progress) {\n    this.currentTrackProgress = progress - this.cummulativeProgress\n    return this.currentTrackProgress >= this.currentTrackPercentage\n  }\n\n  #moveToNextTrack() {\n    if (this.currentTrackIndex >= 0) this.#trackFinished()\n    this.currentTrackIndex++\n    this.cummulativeProgress += this.currentTrackPercentage\n    if (this.currentTrackIndex >= this.numTracks) {\n      this.allTracksFinished = true\n      return\n    }\n    this.currentTrackPercentage = (this.trackDurations[this.currentTrackIndex] / this.totalDuration) * 100\n    this.#trackStarted()\n  }\n\n  #trackStarted() {\n    this.trackStartedCallback(this.currentTrackIndex)\n  }\n\n  #progressUpdated(totalProgress) {\n    const progressInTrack = (this.currentTrackProgress / this.currentTrackPercentage) * 100\n    this.progressCallback(this.currentTrackIndex, progressInTrack, totalProgress)\n  }\n\n  #trackFinished() {\n    this.trackFinishedCallback(this.currentTrackIndex)\n  }\n\n  /**\n   * Updates the track progress based on the total progress.\n   * @param {number} totalProgress - The total progress in percent.\n   */\n  update(totalProgress) {\n    while (this.#outsideCurrentTrack(totalProgress) && !this.allTracksFinished) this.#moveToNextTrack()\n    if (!this.allTracksFinished) this.#progressUpdated(totalProgress)\n  }\n\n  /**\n   * Finish the track progress monitoring.\n   * Forces all remaining tracks to finish.\n   */\n  finish() {\n    this.update(101)\n  }\n}\nmodule.exports = TrackProgressMonitor\n"
  },
  {
    "path": "server/objects/files/AudioFile.js",
    "content": "const { AudioMimeType } = require('../../utils/constants')\nconst AudioMetaTags = require('../metadata/AudioMetaTags')\nconst FileMetadata = require('../metadata/FileMetadata')\n\nclass AudioFile {\n  constructor(data) {\n    this.index = null\n    this.ino = null\n    /** @type {FileMetadata} */\n    this.metadata = null\n    this.addedAt = null\n    this.updatedAt = null\n\n    this.trackNumFromMeta = null\n    this.discNumFromMeta = null\n    this.trackNumFromFilename = null\n    this.discNumFromFilename = null\n\n    this.format = null\n    this.duration = null\n    this.bitRate = null\n    this.language = null\n    this.codec = null\n    this.timeBase = null\n    this.channels = null\n    this.channelLayout = null\n    this.chapters = []\n    this.embeddedCoverArt = null\n\n    // Tags scraped from the audio file\n    /** @type {AudioMetaTags} */\n    this.metaTags = null\n\n    this.manuallyVerified = false\n    this.exclude = false\n    this.error = null\n\n    if (data) {\n      this.construct(data)\n    }\n  }\n\n  toJSON() {\n    return {\n      index: this.index,\n      ino: this.ino,\n      metadata: this.metadata.toJSON(),\n      addedAt: this.addedAt,\n      updatedAt: this.updatedAt,\n      trackNumFromMeta: this.trackNumFromMeta,\n      discNumFromMeta: this.discNumFromMeta,\n      trackNumFromFilename: this.trackNumFromFilename,\n      discNumFromFilename: this.discNumFromFilename,\n      manuallyVerified: !!this.manuallyVerified,\n      exclude: !!this.exclude,\n      error: this.error || null,\n      format: this.format,\n      duration: this.duration,\n      bitRate: this.bitRate,\n      language: this.language,\n      codec: this.codec,\n      timeBase: this.timeBase,\n      channels: this.channels,\n      channelLayout: this.channelLayout,\n      chapters: this.chapters,\n      embeddedCoverArt: this.embeddedCoverArt,\n      metaTags: this.metaTags?.toJSON() || {},\n      mimeType: this.mimeType\n    }\n  }\n\n  construct(data) {\n    this.index = data.index\n    this.ino = data.ino\n    this.metadata = new FileMetadata(data.metadata || {})\n    this.addedAt = data.addedAt\n    this.updatedAt = data.updatedAt\n    this.manuallyVerified = !!data.manuallyVerified\n    this.exclude = !!data.exclude\n    this.error = data.error || null\n\n    this.trackNumFromMeta = data.trackNumFromMeta\n    this.discNumFromMeta = data.discNumFromMeta\n    this.trackNumFromFilename = data.trackNumFromFilename\n\n    if (data.cdNumFromFilename !== undefined) this.discNumFromFilename = data.cdNumFromFilename // TEMP:Support old var name\n    else this.discNumFromFilename = data.discNumFromFilename\n\n    this.format = data.format\n    this.duration = data.duration\n    this.bitRate = data.bitRate\n    this.language = data.language\n    this.codec = data.codec || null\n    this.timeBase = data.timeBase\n    this.channels = data.channels\n    this.channelLayout = data.channelLayout\n    this.chapters = data.chapters\n    this.embeddedCoverArt = data.embeddedCoverArt || null\n\n    this.metaTags = new AudioMetaTags(data.metaTags || {})\n  }\n\n  get mimeType() {\n    const format = this.metadata.format.toUpperCase()\n    if (AudioMimeType[format]) {\n      return AudioMimeType[format]\n    } else {\n      return AudioMimeType.MP3\n    }\n  }\n\n  // New scanner creates AudioFile from AudioFileScanner\n  setDataFromProbe(libraryFile, probeData) {\n    this.ino = libraryFile.ino || null\n\n    if (libraryFile.metadata instanceof FileMetadata) {\n      this.metadata = libraryFile.metadata.clone()\n    } else {\n      this.metadata = new FileMetadata(libraryFile.metadata)\n    }\n\n    this.addedAt = Date.now()\n    this.updatedAt = Date.now()\n\n    this.format = probeData.format\n    this.duration = probeData.duration\n    this.bitRate = probeData.bitRate || null\n    this.language = probeData.language\n    this.codec = probeData.codec || null\n    this.timeBase = probeData.timeBase\n    this.channels = probeData.channels\n    this.channelLayout = probeData.channelLayout\n    this.chapters = probeData.chapters || []\n    this.metaTags = probeData.audioMetaTags\n    this.embeddedCoverArt = probeData.embeddedCoverArt\n  }\n\n  syncChapters(updatedChapters) {\n    if (this.chapters.length !== updatedChapters.length) {\n      this.chapters = updatedChapters.map(ch => ({ ...ch }))\n      return true\n    } else if (updatedChapters.length === 0) {\n      if (this.chapters.length > 0) {\n        this.chapters = []\n        return true\n      }\n      return false\n    }\n\n    let hasUpdates = false\n    for (let i = 0; i < updatedChapters.length; i++) {\n      if (JSON.stringify(updatedChapters[i]) !== JSON.stringify(this.chapters[i])) {\n        hasUpdates = true\n      }\n    }\n    if (hasUpdates) {\n      this.chapters = updatedChapters.map(ch => ({ ...ch }))\n    }\n    return hasUpdates\n  }\n\n  clone() {\n    return new AudioFile(this.toJSON())\n  }\n\n  /**\n   * \n   * @param {AudioFile} scannedAudioFile \n   * @returns {boolean} true if updates were made\n   */\n  updateFromScan(scannedAudioFile) {\n    let hasUpdated = false\n\n    const newjson = scannedAudioFile.toJSON()\n    const ignoreKeys = ['manuallyVerified', 'ctimeMs', 'addedAt', 'updatedAt']\n\n    for (const key in newjson) {\n      if (key === 'metadata') {\n        if (this.metadata.update(newjson[key])) {\n          hasUpdated = true\n        }\n      } else if (key === 'metaTags') {\n        if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metaTags)) {\n          this.metaTags = scannedAudioFile.metaTags.clone()\n          hasUpdated = true\n        }\n      } else if (key === 'chapters') {\n        if (this.syncChapters(newjson.chapters || [])) {\n          hasUpdated = true\n        }\n      } else if (!ignoreKeys.includes(key) && this[key] !== newjson[key]) {\n        this[key] = newjson[key]\n        hasUpdated = true\n      }\n    }\n    return hasUpdated\n  }\n}\nmodule.exports = AudioFile"
  },
  {
    "path": "server/objects/files/AudioTrack.js",
    "content": "class AudioTrack {\n  constructor() {\n    this.index = null\n    this.startOffset = null\n    this.duration = null\n    this.title = null\n    this.contentUrl = null\n    this.mimeType = null\n    this.codec = null\n    this.metadata = null\n  }\n\n  toJSON() {\n    return {\n      index: this.index,\n      startOffset: this.startOffset,\n      duration: this.duration,\n      title: this.title,\n      contentUrl: this.contentUrl,\n      mimeType: this.mimeType,\n      codec: this.codec,\n      metadata: this.metadata?.toJSON() || null\n    }\n  }\n\n  setData(itemId, audioFile, startOffset) {\n    this.index = audioFile.index\n    this.startOffset = startOffset\n    this.duration = audioFile.duration\n    this.title = audioFile.metadata.filename || ''\n\n    this.contentUrl = `/api/items/${itemId}/file/${audioFile.ino}`\n    this.mimeType = audioFile.mimeType\n    this.codec = audioFile.codec || null\n    this.metadata = audioFile.metadata.clone()\n  }\n\n  setFromStream(title, duration, contentUrl) {\n    this.index = 1\n    this.startOffset = 0\n    this.duration = duration\n    this.title = title\n    this.contentUrl = contentUrl\n    this.mimeType = 'application/vnd.apple.mpegurl'\n  }\n}\nmodule.exports = AudioTrack\n"
  },
  {
    "path": "server/objects/files/EBookFile.js",
    "content": "const FileMetadata = require('../metadata/FileMetadata')\n\nclass EBookFile {\n  constructor(file) {\n    this.ino = null\n    this.metadata = null\n    this.ebookFormat = null\n    this.addedAt = null\n    this.updatedAt = null\n\n    if (file) {\n      this.construct(file)\n    }\n  }\n\n  construct(file) {\n    this.ino = file.ino\n    this.metadata = new FileMetadata(file.metadata)\n    this.ebookFormat = file.ebookFormat || this.metadata.format\n    this.addedAt = file.addedAt\n    this.updatedAt = file.updatedAt\n  }\n\n  toJSON() {\n    return {\n      ino: this.ino,\n      metadata: this.metadata.toJSON(),\n      ebookFormat: this.ebookFormat,\n      addedAt: this.addedAt,\n      updatedAt: this.updatedAt\n    }\n  }\n\n  get isEpub() {\n    return this.ebookFormat === 'epub'\n  }\n\n  setData(libraryFile) {\n    this.ino = libraryFile.ino\n    this.metadata = libraryFile.metadata.clone()\n    this.ebookFormat = libraryFile.metadata.format\n    this.addedAt = Date.now()\n    this.updatedAt = Date.now()\n  }\n\n  updateFromLibraryFile(libraryFile) {\n    var hasUpdated = false\n\n    if (this.metadata.update(libraryFile.metadata)) {\n      hasUpdated = true\n    }\n\n    if (this.ebookFormat !== libraryFile.metadata.format) {\n      this.ebookFormat = libraryFile.metadata.format\n      hasUpdated = true\n    }\n\n    return hasUpdated\n  }\n}\nmodule.exports = EBookFile"
  },
  {
    "path": "server/objects/files/LibraryFile.js",
    "content": "const Path = require('path')\nconst { getFileTimestampsWithIno, filePathToPOSIX } = require('../../utils/fileUtils')\nconst globals = require('../../utils/globals')\nconst FileMetadata = require('../metadata/FileMetadata')\n\nclass LibraryFile {\n  constructor(file) {\n    this.ino = null\n    this.metadata = null\n    this.isSupplementary = null\n    this.addedAt = null\n    this.updatedAt = null\n\n    if (file) {\n      this.construct(file)\n    }\n  }\n\n  construct(file) {\n    this.ino = file.ino\n    this.metadata = new FileMetadata(file.metadata)\n    this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary\n    this.addedAt = file.addedAt\n    this.updatedAt = file.updatedAt\n  }\n\n  toJSON() {\n    return {\n      ino: this.ino,\n      metadata: this.metadata.toJSON(),\n      isSupplementary: this.isSupplementary,\n      addedAt: this.addedAt,\n      updatedAt: this.updatedAt,\n      fileType: this.fileType\n    }\n  }\n\n  clone() {\n    return new LibraryFile(this.toJSON())\n  }\n\n  get fileType() {\n    if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'\n    if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'\n    if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'\n    if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'\n    if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'\n    return 'unknown'\n  }\n\n  get isMediaFile() {\n    return this.fileType === 'audio' || this.fileType === 'ebook'\n  }\n\n  get isEBookFile() {\n    return this.fileType === 'ebook'\n  }\n\n  get isOPFFile() {\n    return this.metadata.ext === '.opf'\n  }\n\n  async setDataFromPath(path, relPath) {\n    var fileTsData = await getFileTimestampsWithIno(path)\n    var fileMetadata = new FileMetadata()\n    fileMetadata.setData(fileTsData)\n    fileMetadata.filename = Path.basename(relPath)\n    fileMetadata.path = filePathToPOSIX(path)\n    fileMetadata.relPath = filePathToPOSIX(relPath)\n    fileMetadata.ext = Path.extname(relPath)\n    this.ino = fileTsData.ino\n    this.metadata = fileMetadata\n    this.addedAt = Date.now()\n    this.updatedAt = Date.now()\n  }\n}\nmodule.exports = LibraryFile\n"
  },
  {
    "path": "server/objects/metadata/AudioMetaTags.js",
    "content": "class AudioMetaTags {\n  constructor(metadata) {\n    this.tagAlbum = null\n    this.tagAlbumSort = null\n    this.tagArtist = null\n    this.tagArtistSort = null\n    this.tagGenre = null\n    this.tagTitle = null\n    this.tagTitleSort = null\n    this.tagSeries = null\n    this.tagSeriesPart = null\n    this.tagGrouping = null\n    this.tagTrack = null\n    this.tagDisc = null\n    this.tagSubtitle = null\n    this.tagAlbumArtist = null\n    this.tagDate = null\n    this.tagComposer = null\n    this.tagPublisher = null\n    this.tagComment = null\n    this.tagDescription = null\n    this.tagEncoder = null\n    this.tagEncodedBy = null\n    this.tagIsbn = null\n    this.tagLanguage = null\n    this.tagASIN = null\n    this.tagItunesId = null\n    this.tagPodcastType = null\n    this.tagEpisodeType = null\n    this.tagOverdriveMediaMarker = null\n    this.tagOriginalYear = null\n    this.tagReleaseCountry = null\n    this.tagReleaseType = null\n    this.tagReleaseStatus = null\n    this.tagISRC = null\n    this.tagMusicBrainzTrackId = null\n    this.tagMusicBrainzAlbumId = null\n    this.tagMusicBrainzAlbumArtistId = null\n    this.tagMusicBrainzArtistId = null\n\n    if (metadata) {\n      this.construct(metadata)\n    }\n  }\n\n  toJSON() {\n    // Only return the tags that are actually set\n    const json = {}\n    for (const key in this) {\n      if (key.startsWith('tag') && this[key]) {\n        json[key] = this[key]\n      }\n    }\n    return json\n  }\n\n  get trackNumAndTotal() {\n    const data = {\n      number: null,\n      total: null\n    }\n\n    // Track ID3 tag might be \"3/10\" or just \"3\"\n    if (this.tagTrack) {\n      const trackParts = this.tagTrack.split('/').map((part) => Number(part))\n      if (trackParts.length > 0) {\n        // Fractional track numbers not supported\n        data.number = !isNaN(trackParts[0]) ? Math.trunc(trackParts[0]) : null\n      }\n      if (trackParts.length > 1) {\n        data.total = !isNaN(trackParts[1]) ? trackParts[1] : null\n      }\n    }\n\n    return data\n  }\n\n  get discNumAndTotal() {\n    const data = {\n      number: null,\n      total: null\n    }\n\n    if (this.tagDisc) {\n      const discParts = this.tagDisc.split('/').map((p) => Number(p))\n      if (discParts.length > 0) {\n        data.number = !isNaN(discParts[0]) ? Math.trunc(discParts[0]) : null\n      }\n      if (discParts.length > 1) {\n        data.total = !isNaN(discParts[1]) ? discParts[1] : null\n      }\n    }\n\n    return data\n  }\n\n  get discNumber() {\n    return this.discNumAndTotal.number\n  }\n  get discTotal() {\n    return this.discNumAndTotal.total\n  }\n  get trackNumber() {\n    return this.trackNumAndTotal.number\n  }\n  get trackTotal() {\n    return this.trackNumAndTotal.total\n  }\n\n  construct(metadata) {\n    this.tagAlbum = metadata.tagAlbum || null\n    this.tagAlbumSort = metadata.tagAlbumSort || null\n    this.tagArtist = metadata.tagArtist || null\n    this.tagArtistSort = metadata.tagArtistSort || null\n    this.tagGenre = metadata.tagGenre || null\n    this.tagTitle = metadata.tagTitle || null\n    this.tagTitleSort = metadata.tagTitleSort || null\n    this.tagSeries = metadata.tagSeries || null\n    this.tagSeriesPart = metadata.tagSeriesPart || null\n    this.tagGrouping = metadata.tagGrouping || null\n    this.tagTrack = metadata.tagTrack || null\n    this.tagDisc = metadata.tagDisc || null\n    this.tagSubtitle = metadata.tagSubtitle || null\n    this.tagAlbumArtist = metadata.tagAlbumArtist || null\n    this.tagDate = metadata.tagDate || null\n    this.tagComposer = metadata.tagComposer || null\n    this.tagPublisher = metadata.tagPublisher || null\n    this.tagComment = metadata.tagComment || null\n    this.tagDescription = metadata.tagDescription || null\n    this.tagEncoder = metadata.tagEncoder || null\n    this.tagEncodedBy = metadata.tagEncodedBy || null\n    this.tagIsbn = metadata.tagIsbn || null\n    this.tagLanguage = metadata.tagLanguage || null\n    this.tagASIN = metadata.tagASIN || null\n    this.tagItunesId = metadata.tagItunesId || null\n    this.tagPodcastType = metadata.tagPodcastType || null\n    this.tagEpisodeType = metadata.tagEpisodeType || null\n    this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null\n    this.tagOriginalYear = metadata.tagOriginalYear || null\n    this.tagReleaseCountry = metadata.tagReleaseCountry || null\n    this.tagReleaseType = metadata.tagReleaseType || null\n    this.tagReleaseStatus = metadata.tagReleaseStatus || null\n    this.tagISRC = metadata.tagISRC || null\n    this.tagMusicBrainzTrackId = metadata.tagMusicBrainzTrackId || null\n    this.tagMusicBrainzAlbumId = metadata.tagMusicBrainzAlbumId || null\n    this.tagMusicBrainzAlbumArtistId = metadata.tagMusicBrainzAlbumArtistId || null\n    this.tagMusicBrainzArtistId = metadata.tagMusicBrainzArtistId || null\n  }\n\n  // Data parsed in prober.js\n  setData(payload) {\n    this.tagAlbum = payload.file_tag_album || null\n    this.tagAlbumSort = payload.file_tag_albumsort || null\n    this.tagArtist = payload.file_tag_artist || null\n    this.tagArtistSort = payload.file_tag_artistsort || null\n    this.tagGenre = payload.file_tag_genre || null\n    this.tagTitle = payload.file_tag_title || null\n    this.tagTitleSort = payload.file_tag_titlesort || null\n    this.tagSeries = payload.file_tag_series || null\n    this.tagSeriesPart = payload.file_tag_seriespart || null\n    this.tagGrouping = payload.file_tag_grouping || null\n    this.tagTrack = payload.file_tag_track || null\n    this.tagDisc = payload.file_tag_disc || null\n    this.tagSubtitle = payload.file_tag_subtitle || null\n    this.tagAlbumArtist = payload.file_tag_albumartist || null\n    this.tagDate = payload.file_tag_date || null\n    this.tagComposer = payload.file_tag_composer || null\n    this.tagPublisher = payload.file_tag_publisher || null\n    this.tagComment = payload.file_tag_comment || null\n    this.tagDescription = payload.file_tag_description || null\n    this.tagEncoder = payload.file_tag_encoder || null\n    this.tagEncodedBy = payload.file_tag_encodedby || null\n    this.tagIsbn = payload.file_tag_isbn || null\n    this.tagLanguage = payload.file_tag_language || null\n    this.tagASIN = payload.file_tag_asin || null\n    this.tagItunesId = payload.file_tag_itunesid || null\n    this.tagPodcastType = payload.file_tag_podcasttype || null\n    this.tagEpisodeType = payload.file_tag_episodetype || null\n    this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null\n    this.tagOriginalYear = payload.file_tag_originalyear || null\n    this.tagReleaseCountry = payload.file_tag_releasecountry || null\n    this.tagReleaseType = payload.file_tag_releasetype || null\n    this.tagReleaseStatus = payload.file_tag_releasestatus || null\n    this.tagISRC = payload.file_tag_isrc || null\n    this.tagMusicBrainzTrackId = payload.file_tag_musicbrainz_trackid || null\n    this.tagMusicBrainzAlbumId = payload.file_tag_musicbrainz_albumid || null\n    this.tagMusicBrainzAlbumArtistId = payload.file_tag_musicbrainz_albumartistid || null\n    this.tagMusicBrainzArtistId = payload.file_tag_musicbrainz_artistid || null\n  }\n\n  updateData(payload) {\n    const dataMap = {\n      tagAlbum: payload.file_tag_album || null,\n      tagAlbumSort: payload.file_tag_albumsort || null,\n      tagArtist: payload.file_tag_artist || null,\n      tagArtistSort: payload.file_tag_artistsort || null,\n      tagGenre: payload.file_tag_genre || null,\n      tagTitle: payload.file_tag_title || null,\n      tagTitleSort: payload.file_tag_titlesort || null,\n      tagSeries: payload.file_tag_series || null,\n      tagSeriesPart: payload.file_tag_seriespart || null,\n      tagGrouping: payload.file_tag_grouping || null,\n      tagTrack: payload.file_tag_track || null,\n      tagDisc: payload.file_tag_disc || null,\n      tagSubtitle: payload.file_tag_subtitle || null,\n      tagAlbumArtist: payload.file_tag_albumartist || null,\n      tagDate: payload.file_tag_date || null,\n      tagComposer: payload.file_tag_composer || null,\n      tagPublisher: payload.file_tag_publisher || null,\n      tagComment: payload.file_tag_comment || null,\n      tagDescription: payload.file_tag_description || null,\n      tagEncoder: payload.file_tag_encoder || null,\n      tagEncodedBy: payload.file_tag_encodedby || null,\n      tagIsbn: payload.file_tag_isbn || null,\n      tagLanguage: payload.file_tag_language || null,\n      tagASIN: payload.file_tag_asin || null,\n      tagItunesId: payload.file_tag_itunesid || null,\n      tagPodcastType: payload.file_tag_podcasttype || null,\n      tagEpisodeType: payload.file_tag_episodetype || null,\n      tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,\n      tagOriginalYear: payload.file_tag_originalyear || null,\n      tagReleaseCountry: payload.file_tag_releasecountry || null,\n      tagReleaseType: payload.file_tag_releasetype || null,\n      tagReleaseStatus: payload.file_tag_releasestatus || null,\n      tagISRC: payload.file_tag_isrc || null,\n      tagMusicBrainzTrackId: payload.file_tag_musicbrainz_trackid || null,\n      tagMusicBrainzAlbumId: payload.file_tag_musicbrainz_albumid || null,\n      tagMusicBrainzAlbumArtistId: payload.file_tag_musicbrainz_albumartistid || null,\n      tagMusicBrainzArtistId: payload.file_tag_musicbrainz_artistid || null\n    }\n\n    let hasUpdates = false\n    for (const key in dataMap) {\n      if (dataMap[key] !== this[key]) {\n        this[key] = dataMap[key]\n        hasUpdates = true\n      }\n    }\n    return hasUpdates\n  }\n\n  clone() {\n    return new AudioMetaTags(this.toJSON())\n  }\n\n  isEqual(audioFileMetadata) {\n    if (!audioFileMetadata || !audioFileMetadata.toJSON) return false\n    for (const key in audioFileMetadata.toJSON()) {\n      if (audioFileMetadata[key] !== this[key]) return false\n    }\n    return true\n  }\n}\nmodule.exports = AudioMetaTags\n"
  },
  {
    "path": "server/objects/metadata/FileMetadata.js",
    "content": "class FileMetadata {\n  constructor(metadata) {\n    this.filename = null\n    this.ext = null\n    this.path = null\n    this.relPath = null\n    this.size = null\n    this.mtimeMs = null\n    this.ctimeMs = null\n    this.birthtimeMs = null\n\n    if (metadata) {\n      this.construct(metadata)\n    }\n\n    // Temp flag used in scans\n    this.wasModified = false\n  }\n\n  construct(metadata) {\n    this.filename = metadata.filename\n    this.ext = metadata.ext\n    this.path = metadata.path\n    this.relPath = metadata.relPath\n    this.size = metadata.size\n    this.mtimeMs = metadata.mtimeMs\n    this.ctimeMs = metadata.ctimeMs\n    this.birthtimeMs = metadata.birthtimeMs\n  }\n\n  toJSON() {\n    return {\n      filename: this.filename,\n      ext: this.ext,\n      path: this.path,\n      relPath: this.relPath,\n      size: this.size,\n      mtimeMs: this.mtimeMs,\n      ctimeMs: this.ctimeMs,\n      birthtimeMs: this.birthtimeMs\n    }\n  }\n\n  clone() {\n    return new FileMetadata(this.toJSON())\n  }\n\n  get format() {\n    if (!this.ext) return ''\n    return this.ext.slice(1).toLowerCase()\n  }\n  get filenameNoExt() {\n    return this.filename.replace(this.ext, '')\n  }\n\n  update(payload) {\n    var hasUpdates = false\n    for (const key in payload) {\n      if (this[key] !== undefined && this[key] !== payload[key]) {\n        this[key] = payload[key]\n        hasUpdates = true\n      }\n    }\n    return hasUpdates\n  }\n\n  setData(payload) {\n    for (const key in payload) {\n      if (this[key] !== undefined) {\n        this[key] = payload[key]\n      }\n    }\n  }\n}\nmodule.exports = FileMetadata"
  },
  {
    "path": "server/objects/settings/EmailSettings.js",
    "content": "const Logger = require('../../Logger')\nconst { areEquivalent, copyValue, isNullOrNaN } = require('../../utils')\n\n/**\n * @typedef EreaderDeviceObject\n * @property {string} name\n * @property {string} email\n * @property {string} availabilityOption\n * @property {string[]} users\n */\n\n// REF: https://nodemailer.com/smtp/\nclass EmailSettings {\n  constructor(settings = null) {\n    this.id = 'email-settings'\n    this.host = null\n    this.port = 465\n    this.secure = true\n    this.rejectUnauthorized = true\n    this.user = null\n    this.pass = null\n    this.testAddress = null\n    this.fromAddress = null\n\n    /** @type {EreaderDeviceObject[]} */\n    this.ereaderDevices = []\n\n    if (settings) {\n      this.construct(settings)\n    }\n  }\n\n  construct(settings) {\n    this.host = settings.host\n    this.port = settings.port\n    this.secure = !!settings.secure\n    this.rejectUnauthorized = !!settings.rejectUnauthorized\n    this.user = settings.user\n    this.pass = settings.pass\n    this.testAddress = settings.testAddress\n    this.fromAddress = settings.fromAddress\n    this.ereaderDevices = settings.ereaderDevices?.map((d) => ({ ...d })) || []\n\n    // rejectUnauthorized added after v2.10.1 - defaults to true\n    if (settings.rejectUnauthorized === undefined) {\n      this.rejectUnauthorized = true\n    }\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      host: this.host,\n      port: this.port,\n      secure: this.secure,\n      rejectUnauthorized: this.rejectUnauthorized,\n      user: this.user,\n      pass: this.pass,\n      testAddress: this.testAddress,\n      fromAddress: this.fromAddress,\n      ereaderDevices: this.ereaderDevices.map((d) => ({ ...d }))\n    }\n  }\n\n  update(payload) {\n    if (!payload) return false\n\n    if (payload.port !== undefined) {\n      if (isNullOrNaN(payload.port)) payload.port = 465\n      else payload.port = Number(payload.port)\n    }\n    if (payload.secure !== undefined) payload.secure = !!payload.secure\n    if (payload.rejectUnauthorized !== undefined) payload.rejectUnauthorized = !!payload.rejectUnauthorized\n\n    if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined\n\n    if (payload.ereaderDevices?.length) {\n      // Validate ereader devices\n      payload.ereaderDevices = payload.ereaderDevices\n        .map((device) => {\n          if (!device.name || !device.email) {\n            Logger.error(`[EmailSettings] Update ereader device is invalid`, device)\n            return null\n          }\n          if (!device.availabilityOption || !['adminOrUp', 'userOrUp', 'guestOrUp', 'specificUsers'].includes(device.availabilityOption)) {\n            device.availabilityOption = 'adminOrUp'\n          }\n          if (device.availabilityOption === 'specificUsers' && !device.users?.length) {\n            device.availabilityOption = 'adminOrUp'\n          }\n          if (device.availabilityOption !== 'specificUsers' && device.users?.length) {\n            device.users = []\n          }\n          return device\n        })\n        .filter((d) => d)\n    }\n\n    let hasUpdates = false\n\n    const json = this.toJSON()\n    for (const key in json) {\n      if (key === 'id') continue\n\n      if (payload[key] !== undefined && !areEquivalent(payload[key], json[key])) {\n        this[key] = copyValue(payload[key])\n        hasUpdates = true\n      }\n    }\n\n    return hasUpdates\n  }\n\n  getTransportObject() {\n    const payload = {\n      host: this.host,\n      secure: this.secure\n    }\n    // Only set to true for port 465 (https://nodemailer.com/smtp/#tls-options)\n    if (this.port !== 465) {\n      payload.secure = false\n    }\n    if (this.port) payload.port = this.port\n    if (this.user && this.pass !== undefined) {\n      payload.auth = {\n        user: this.user,\n        pass: this.pass\n      }\n    }\n    // Allow self-signed certs (https://nodemailer.com/smtp/#3-allow-self-signed-certificates)\n    if (!this.rejectUnauthorized) {\n      payload.tls = {\n        rejectUnauthorized: false\n      }\n    }\n\n    return payload\n  }\n\n  /**\n   *\n   * @param {EreaderDeviceObject} device\n   * @param {import('../../models/User')} user\n   * @returns {boolean}\n   */\n  checkUserCanAccessDevice(device, user) {\n    let deviceAvailability = device.availabilityOption || 'adminOrUp'\n    if (deviceAvailability === 'adminOrUp' && user.isAdminOrUp) return true\n    if (deviceAvailability === 'userOrUp' && (user.isAdminOrUp || user.isUser)) return true\n    if (deviceAvailability === 'guestOrUp') return true\n    if (deviceAvailability === 'specificUsers') {\n      let deviceUsers = device.users || []\n      return deviceUsers.includes(user.id)\n    }\n    return false\n  }\n\n  /**\n   * Get ereader devices accessible to user\n   *\n   * @param {import('../../models/User')} user\n   * @returns {EreaderDeviceObject[]}\n   */\n  getEReaderDevices(user) {\n    return this.ereaderDevices.filter((device) => this.checkUserCanAccessDevice(device, user))\n  }\n\n  /**\n   * Get ereader device by name\n   *\n   * @param {string} deviceName\n   * @returns {EreaderDeviceObject}\n   */\n  getEReaderDevice(deviceName) {\n    return this.ereaderDevices.find((d) => d.name === deviceName)\n  }\n}\nmodule.exports = EmailSettings\n"
  },
  {
    "path": "server/objects/settings/NotificationSettings.js",
    "content": "const Logger = require('../../Logger')\nconst Notification = require('../Notification')\nconst { isNullOrNaN } = require('../../utils')\n\nclass NotificationSettings {\n  constructor(settings = null) {\n    this.id = 'notification-settings'\n    this.appriseType = 'api'\n    this.appriseApiUrl = null\n    this.notifications = []\n    this.maxFailedAttempts = 5\n    this.maxNotificationQueue = 20 // once reached events will be ignored\n    this.notificationDelay = 1000 // ms delay between firing notifications\n\n    if (settings) {\n      this.construct(settings)\n    }\n  }\n\n  construct(settings) {\n    this.appriseType = settings.appriseType\n    this.appriseApiUrl = settings.appriseApiUrl || null\n    this.notifications = (settings.notifications || []).map((n) => new Notification(n))\n    this.maxFailedAttempts = settings.maxFailedAttempts || 5\n    this.maxNotificationQueue = settings.maxNotificationQueue || 20\n    this.notificationDelay = settings.notificationDelay || 1000\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      appriseType: this.appriseType,\n      appriseApiUrl: this.appriseApiUrl,\n      notifications: this.notifications.map((n) => n.toJSON()),\n      maxFailedAttempts: this.maxFailedAttempts,\n      maxNotificationQueue: this.maxNotificationQueue,\n      notificationDelay: this.notificationDelay\n    }\n  }\n\n  get isUseable() {\n    return !!this.appriseApiUrl\n  }\n\n  /**\n   * @param {string} eventName\n   * @returns {boolean} - TRUE if there are active notifications for the event\n   */\n  getHasActiveNotificationsForEvent(eventName) {\n    return this.notifications.some((n) => n.eventName === eventName && n.enabled)\n  }\n\n  /**\n   * @param {string} eventName\n   * @returns {Notification[]}\n   */\n  getActiveNotificationsForEvent(eventName) {\n    return this.notifications.filter((n) => n.eventName === eventName && n.enabled)\n  }\n\n  getNotification(id) {\n    return this.notifications.find((n) => n.id === id)\n  }\n\n  removeNotification(id) {\n    if (this.notifications.some((n) => n.id === id)) {\n      this.notifications = this.notifications.filter((n) => n.id !== id)\n      return true\n    }\n    return false\n  }\n\n  update(payload) {\n    if (!payload) return false\n\n    var hasUpdates = false\n    if (payload.appriseApiUrl !== this.appriseApiUrl) {\n      this.appriseApiUrl = payload.appriseApiUrl || null\n      hasUpdates = true\n    }\n\n    const _maxFailedAttempts = isNullOrNaN(payload.maxFailedAttempts) ? 5 : Number(payload.maxFailedAttempts)\n    if (_maxFailedAttempts !== this.maxFailedAttempts) {\n      this.maxFailedAttempts = _maxFailedAttempts\n      hasUpdates = true\n    }\n\n    const _maxNotificationQueue = isNullOrNaN(payload.maxNotificationQueue) ? 20 : Number(payload.maxNotificationQueue)\n    if (_maxNotificationQueue !== this.maxNotificationQueue) {\n      this.maxNotificationQueue = _maxNotificationQueue\n      hasUpdates = true\n    }\n\n    return hasUpdates\n  }\n\n  createNotification(payload) {\n    if (!payload) return false\n    if (!payload.eventName || !payload.urls.length) return false\n\n    const notification = new Notification()\n    notification.setData(payload)\n    this.notifications.push(notification)\n    return true\n  }\n\n  updateNotification(payload) {\n    if (!payload) return false\n    const notification = this.notifications.find((n) => n.id === payload.id)\n    if (!notification) {\n      Logger.error(`[NotificationSettings] updateNotification: Notification not found ${payload.id}`)\n      return false\n    }\n\n    return notification.update(payload)\n  }\n}\nmodule.exports = NotificationSettings\n"
  },
  {
    "path": "server/objects/settings/ServerSettings.js",
    "content": "const Path = require('path')\nconst packageJson = require('../../../package.json')\nconst { BookshelfView } = require('../../utils/constants')\nconst Logger = require('../../Logger')\nconst User = require('../../models/User')\nconst { sanitize } = require('../../utils/htmlSanitizer')\n\nclass ServerSettings {\n  constructor(settings) {\n    this.id = 'server-settings'\n    /** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */\n    this.tokenSecret = null\n\n    // Scanner\n    this.scannerParseSubtitle = false\n    this.scannerFindCovers = false\n    this.scannerCoverProvider = 'google'\n    this.scannerPreferMatchedMetadata = false\n    this.scannerDisableWatcher = false\n\n    // Metadata - choose to store inside users library item folder\n    this.storeCoverWithItem = false\n    this.storeMetadataWithItem = false\n    this.metadataFileFormat = 'json'\n\n    // Security/Rate limits\n    this.rateLimitLoginRequests = 10\n    this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes\n    this.allowIframe = false\n\n    // Backups\n    this.backupPath = Path.join(global.MetadataPath, 'backups')\n    this.backupSchedule = false // If false then auto-backups are disabled\n    this.backupsToKeep = 2\n    this.maxBackupSize = 1\n\n    // Logger\n    this.loggerDailyLogsToKeep = 7\n    this.loggerScannerLogsToKeep = 2\n\n    // Bookshelf Display\n    this.homeBookshelfView = BookshelfView.DETAIL\n    this.bookshelfView = BookshelfView.DETAIL\n\n    // Podcasts\n    this.podcastEpisodeSchedule = '0 * * * *' // Every hour\n\n    // Sorting\n    this.sortingIgnorePrefix = false\n    this.sortingPrefixes = ['the', 'a']\n\n    // Misc Flags\n    this.chromecastEnabled = false\n    this.dateFormat = 'MM/dd/yyyy'\n    this.timeFormat = 'HH:mm'\n    this.language = 'en-us'\n    this.allowedOrigins = []\n\n    this.logLevel = Logger.logLevel\n\n    this.version = packageJson.version\n    this.buildNumber = packageJson.buildNumber\n\n    // Auth settings\n    this.authLoginCustomMessage = null\n    this.authActiveAuthMethods = ['local']\n\n    // openid settings\n    this.authOpenIDIssuerURL = null\n    this.authOpenIDAuthorizationURL = null\n    this.authOpenIDTokenURL = null\n    this.authOpenIDUserInfoURL = null\n    this.authOpenIDJwksURL = null\n    this.authOpenIDLogoutURL = null\n    this.authOpenIDClientID = null\n    this.authOpenIDClientSecret = null\n    this.authOpenIDTokenSigningAlgorithm = 'RS256'\n    this.authOpenIDButtonText = 'Login with OpenId'\n    this.authOpenIDAutoLaunch = false\n    this.authOpenIDAutoRegister = false\n    this.authOpenIDMatchExistingBy = null\n    this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']\n    this.authOpenIDGroupClaim = ''\n    this.authOpenIDAdvancedPermsClaim = ''\n    this.authOpenIDSubfolderForRedirectURLs = undefined\n\n    if (settings) {\n      this.construct(settings)\n    }\n  }\n\n  construct(settings) {\n    this.tokenSecret = settings.tokenSecret\n    this.scannerFindCovers = !!settings.scannerFindCovers\n    this.scannerCoverProvider = settings.scannerCoverProvider || 'google'\n    this.scannerParseSubtitle = settings.scannerParseSubtitle\n    this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata\n    this.scannerDisableWatcher = !!settings.scannerDisableWatcher\n\n    this.storeCoverWithItem = !!settings.storeCoverWithItem\n    this.storeMetadataWithItem = !!settings.storeMetadataWithItem\n    this.metadataFileFormat = settings.metadataFileFormat || 'json'\n\n    this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10\n    this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes\n    this.allowIframe = !!settings.allowIframe\n\n    this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')\n    this.backupSchedule = settings.backupSchedule || false\n    this.backupsToKeep = settings.backupsToKeep || 2\n    this.maxBackupSize = settings.maxBackupSize === 0 ? 0 : settings.maxBackupSize || 1\n\n    this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7\n    this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2\n\n    this.homeBookshelfView = settings.homeBookshelfView || BookshelfView.STANDARD\n    this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD\n\n    this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix\n    this.sortingPrefixes = settings.sortingPrefixes || ['the']\n    this.chromecastEnabled = !!settings.chromecastEnabled\n    this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'\n    this.timeFormat = settings.timeFormat || 'HH:mm'\n    this.language = settings.language || 'en-us'\n    this.allowedOrigins = settings.allowedOrigins || []\n    this.logLevel = settings.logLevel || Logger.logLevel\n    this.version = settings.version || null\n    this.buildNumber = settings.buildNumber || 0 // Added v2.4.5\n\n    this.authLoginCustomMessage = sanitize(settings.authLoginCustomMessage) || null // Added v2.8.0\n    this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']\n\n    this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null\n    this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null\n    this.authOpenIDTokenURL = settings.authOpenIDTokenURL || null\n    this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || null\n    this.authOpenIDJwksURL = settings.authOpenIDJwksURL || null\n    this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || null\n    this.authOpenIDClientID = settings.authOpenIDClientID || null\n    this.authOpenIDClientSecret = settings.authOpenIDClientSecret || null\n    this.authOpenIDTokenSigningAlgorithm = settings.authOpenIDTokenSigningAlgorithm || 'RS256'\n    this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId'\n    this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch\n    this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister\n    this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null\n    this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']\n    this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''\n    this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''\n    this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs\n\n    if (!Array.isArray(this.authActiveAuthMethods)) {\n      this.authActiveAuthMethods = ['local']\n    }\n\n    // remove uninitialized methods\n    // OpenID\n    if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) {\n      this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)\n    }\n\n    // fallback to local\n    if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) {\n      this.authActiveAuthMethods = ['local']\n    }\n\n    // Migrations\n    if (settings.storeCoverWithBook != undefined) {\n      // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0\n      this.storeCoverWithItem = !!settings.storeCoverWithBook\n    }\n    if (settings.storeMetadataWithBook != undefined) {\n      // storeMetadataWithBook was renamed to storeMetadataWithItem in 2.0.0\n      this.storeMetadataWithItem = !!settings.storeMetadataWithBook\n    }\n    if (settings.homeBookshelfView == undefined) {\n      // homeBookshelfView was added in 2.1.3\n      this.homeBookshelfView = settings.bookshelfView\n    }\n    if (settings.metadataFileFormat == undefined) {\n      // metadataFileFormat was added in 2.2.21\n      // All users using old settings will stay abs until changed\n      this.metadataFileFormat = 'abs'\n    }\n\n    // As of v2.4.5 only json is supported\n    if (this.metadataFileFormat !== 'json') {\n      Logger.warn(`[ServerSettings] Invalid metadataFileFormat ${this.metadataFileFormat} (as of v2.4.5 only json is supported)`)\n      this.metadataFileFormat = 'json'\n    }\n\n    if (this.logLevel !== Logger.logLevel) {\n      Logger.setLogLevel(this.logLevel)\n    }\n\n    if (process.env.BACKUP_PATH && this.backupPath !== process.env.BACKUP_PATH) {\n      Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)\n      this.backupPath = process.env.BACKUP_PATH\n    }\n\n    if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) {\n      Logger.info(`[ServerSettings] Using allowIframe from environment variable`)\n      this.allowIframe = true\n    }\n  }\n\n  toJSON() {\n    // Use toJSONForBrowser if sending to client\n    return {\n      id: this.id,\n      tokenSecret: this.tokenSecret, // Do not return to client\n      scannerFindCovers: this.scannerFindCovers,\n      scannerCoverProvider: this.scannerCoverProvider,\n      scannerParseSubtitle: this.scannerParseSubtitle,\n      scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,\n      scannerDisableWatcher: this.scannerDisableWatcher,\n      storeCoverWithItem: this.storeCoverWithItem,\n      storeMetadataWithItem: this.storeMetadataWithItem,\n      metadataFileFormat: this.metadataFileFormat,\n      rateLimitLoginRequests: this.rateLimitLoginRequests,\n      rateLimitLoginWindow: this.rateLimitLoginWindow,\n      allowIframe: this.allowIframe,\n      backupPath: this.backupPath,\n      backupSchedule: this.backupSchedule,\n      backupsToKeep: this.backupsToKeep,\n      maxBackupSize: this.maxBackupSize,\n      loggerDailyLogsToKeep: this.loggerDailyLogsToKeep,\n      loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,\n      homeBookshelfView: this.homeBookshelfView,\n      bookshelfView: this.bookshelfView,\n      podcastEpisodeSchedule: this.podcastEpisodeSchedule,\n      sortingIgnorePrefix: this.sortingIgnorePrefix,\n      sortingPrefixes: [...this.sortingPrefixes],\n      chromecastEnabled: this.chromecastEnabled,\n      dateFormat: this.dateFormat,\n      timeFormat: this.timeFormat,\n      language: this.language,\n      allowedOrigins: this.allowedOrigins,\n      logLevel: this.logLevel,\n      version: this.version,\n      buildNumber: this.buildNumber,\n      authLoginCustomMessage: this.authLoginCustomMessage,\n      authActiveAuthMethods: this.authActiveAuthMethods,\n      authOpenIDIssuerURL: this.authOpenIDIssuerURL,\n      authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,\n      authOpenIDTokenURL: this.authOpenIDTokenURL,\n      authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,\n      authOpenIDJwksURL: this.authOpenIDJwksURL,\n      authOpenIDLogoutURL: this.authOpenIDLogoutURL,\n      authOpenIDClientID: this.authOpenIDClientID, // Do not return to client\n      authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client\n      authOpenIDTokenSigningAlgorithm: this.authOpenIDTokenSigningAlgorithm,\n      authOpenIDButtonText: this.authOpenIDButtonText,\n      authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,\n      authOpenIDAutoRegister: this.authOpenIDAutoRegister,\n      authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,\n      authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client\n      authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client\n      authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client\n      authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs\n    }\n  }\n\n  toJSONForBrowser() {\n    const json = this.toJSON()\n    delete json.tokenSecret\n    delete json.authOpenIDClientID\n    delete json.authOpenIDClientSecret\n    delete json.authOpenIDMobileRedirectURIs\n    delete json.authOpenIDGroupClaim\n    delete json.authOpenIDAdvancedPermsClaim\n    return json\n  }\n\n  get supportedAuthMethods() {\n    return ['local', 'openid']\n  }\n\n  /**\n   * Auth settings required for openid to be valid\n   */\n  get isOpenIDAuthSettingsValid() {\n    return this.authOpenIDIssuerURL && this.authOpenIDAuthorizationURL && this.authOpenIDTokenURL && this.authOpenIDUserInfoURL && this.authOpenIDJwksURL && this.authOpenIDClientID && this.authOpenIDClientSecret && this.authOpenIDTokenSigningAlgorithm\n  }\n\n  get authenticationSettings() {\n    return {\n      authLoginCustomMessage: this.authLoginCustomMessage,\n      authActiveAuthMethods: this.authActiveAuthMethods,\n      authOpenIDIssuerURL: this.authOpenIDIssuerURL,\n      authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,\n      authOpenIDTokenURL: this.authOpenIDTokenURL,\n      authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,\n      authOpenIDJwksURL: this.authOpenIDJwksURL,\n      authOpenIDLogoutURL: this.authOpenIDLogoutURL,\n      authOpenIDClientID: this.authOpenIDClientID, // Do not return to client\n      authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client\n      authOpenIDTokenSigningAlgorithm: this.authOpenIDTokenSigningAlgorithm,\n      authOpenIDButtonText: this.authOpenIDButtonText,\n      authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,\n      authOpenIDAutoRegister: this.authOpenIDAutoRegister,\n      authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,\n      authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client\n      authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client\n      authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client\n      authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,\n\n      authOpenIDSamplePermissions: User.getSampleAbsPermissions()\n    }\n  }\n\n  get authFormData() {\n    const clientFormData = {\n      authLoginCustomMessage: sanitize(this.authLoginCustomMessage)\n    }\n    if (this.authActiveAuthMethods.includes('openid')) {\n      clientFormData.authOpenIDButtonText = this.authOpenIDButtonText\n      clientFormData.authOpenIDAutoLaunch = this.authOpenIDAutoLaunch\n    }\n    return clientFormData\n  }\n\n  /**\n   * Update server settings\n   *\n   * @param {Object} payload\n   * @returns {boolean} true if updates were made\n   */\n  update(payload) {\n    let hasUpdates = false\n    for (const key in payload) {\n      if (key === 'authLoginCustomMessage') {\n        payload[key] = sanitize(payload[key])\n      }\n      if (key === 'sortingPrefixes') {\n        // Sorting prefixes are updated with the /api/sorting-prefixes endpoint\n        continue\n      } else if (key === 'authActiveAuthMethods') {\n        if (!payload[key]?.length) {\n          Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])\n          continue\n        }\n        this.authActiveAuthMethods.sort()\n        payload[key].sort()\n        if (payload[key].join() !== this.authActiveAuthMethods.join()) {\n          this.authActiveAuthMethods = payload[key]\n          hasUpdates = true\n        }\n      } else if (this[key] !== payload[key]) {\n        if (key === 'logLevel') {\n          Logger.setLogLevel(payload[key])\n        }\n        this[key] = payload[key]\n        hasUpdates = true\n      }\n    }\n    return hasUpdates\n  }\n}\nmodule.exports = ServerSettings\n"
  },
  {
    "path": "server/providers/Audible.js",
    "content": "const axios = require('axios').default\nconst Logger = require('../Logger')\nconst { isValidASIN } = require('../utils/index')\n\nclass Audible {\n  #responseTimeout = 10000\n\n  constructor() {\n    this.regionMap = {\n      us: '.com',\n      ca: '.ca',\n      uk: '.co.uk',\n      au: '.com.au',\n      fr: '.fr',\n      de: '.de',\n      jp: '.co.jp',\n      it: '.it',\n      in: '.in',\n      es: '.es'\n    }\n  }\n\n  /**\n   * Audible will sometimes send sequences with \"Book 1\" or \"2, Dramatized Adaptation\"\n   * @see https://github.com/advplyr/audiobookshelf/issues/2380\n   * @see https://github.com/advplyr/audiobookshelf/issues/1339\n   *\n   * @param {string} seriesName\n   * @param {string} sequence\n   * @returns {string}\n   */\n  cleanSeriesSequence(seriesName, sequence) {\n    if (!sequence) return ''\n    // match any number with optional decimal (e.g, 1 or 1.5 or .5)\n    let numberFound = sequence.match(/\\.\\d+|\\d+(?:\\.\\d+)?/)\n    let updatedSequence = numberFound ? numberFound[0] : sequence\n    if (sequence !== updatedSequence) {\n      Logger.debug(`[Audible] Series \"${seriesName}\" sequence was cleaned from \"${sequence}\" to \"${updatedSequence}\"`)\n    }\n    return updatedSequence\n  }\n\n  cleanResult(item) {\n    const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType, isbn } = item\n\n    const series = []\n    if (seriesPrimary) {\n      series.push({\n        series: seriesPrimary.name,\n        sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')\n      })\n    }\n    if (seriesSecondary) {\n      series.push({\n        series: seriesSecondary.name,\n        sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')\n      })\n    }\n\n    let genresCleaned = []\n    let tagsCleaned = []\n\n    if (genres && Array.isArray(genres)) {\n      genresCleaned = [...new Set(genres.filter((g) => g.type == 'genre').map((g) => g.name))]\n      tagsCleaned = [...new Set(genres.filter((g) => g.type == 'tag').map((g) => g.name))]\n    }\n\n    return {\n      title,\n      subtitle: subtitle || null,\n      author: authors ? authors.map(({ name }) => name).join(', ') : null,\n      narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null,\n      publisher: publisherName,\n      publishedYear: releaseDate ? releaseDate.split('-')[0] : null,\n      description: summary || null,\n      cover: image,\n      asin,\n      isbn,\n      genres: genresCleaned.length ? genresCleaned : null,\n      tags: tagsCleaned.length ? tagsCleaned : null,\n      series: series.length ? series : null,\n      language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,\n      duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,\n      region: item.region || null,\n      rating: item.rating || null,\n      abridged: formatType === 'abridged'\n    }\n  }\n\n  /**\n   *\n   * @param {string} asin\n   * @param {string} region\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object>}\n   */\n  asinSearch(asin, region, timeout = this.#responseTimeout) {\n    if (!asin) return null\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    asin = encodeURIComponent(asin.toUpperCase())\n    var regionQuery = region ? `?region=${region}` : ''\n    var url = `https://api.audnex.us/books/${asin}${regionQuery}`\n    Logger.debug(`[Audible] ASIN url: ${url}`)\n    return axios\n      .get(url, {\n        timeout\n      })\n      .then((res) => {\n        if (!res?.data?.asin) return null\n        return res.data\n      })\n      .catch((error) => {\n        Logger.error('[Audible] ASIN search error', error.message)\n        return null\n      })\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @param {string} asin\n   * @param {string} region\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object[]>}\n   */\n  async search(title, author, asin, region, timeout = this.#responseTimeout) {\n    if (region && !this.regionMap[region]) {\n      Logger.error(`[Audible] search: Invalid region ${region}`)\n      region = ''\n    }\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    let items = []\n    if (asin && isValidASIN(asin.toUpperCase())) {\n      const item = await this.asinSearch(asin, region, timeout)\n      if (item) items.push(item)\n    }\n\n    if (!items.length && isValidASIN(title.toUpperCase())) {\n      const item = await this.asinSearch(title, region, timeout)\n      if (item) items.push(item)\n    }\n\n    if (!items.length) {\n      const queryObj = {\n        num_results: '10',\n        products_sort_by: 'Relevance',\n        title: title\n      }\n      if (author) queryObj.author = author\n      const queryString = new URLSearchParams(queryObj).toString()\n      const tld = region ? this.regionMap[region] : '.com'\n      const url = `https://api.audible${tld}/1.0/catalog/products?${queryString}`\n      Logger.debug(`[Audible] Search url: ${url}`)\n      items = await axios\n        .get(url, {\n          timeout\n        })\n        .then((res) => {\n          if (!res?.data?.products) return null\n          return Promise.all(res.data.products.map((result) => this.asinSearch(result.asin, region, timeout)))\n        })\n        .catch((error) => {\n          Logger.error('[Audible] query search error', error.message)\n          return []\n        })\n    }\n    return items.filter(Boolean).map((item) => this.cleanResult(item)) || []\n  }\n}\n\nmodule.exports = Audible\n"
  },
  {
    "path": "server/providers/AudiobookCovers.js",
    "content": "const axios = require('axios')\nconst Logger = require('../Logger')\n\nclass AudiobookCovers {\n  #responseTimeout = 10000\n\n  constructor() {}\n\n  /**\n   *\n   * @param {string} search\n   * @param {number} [timeout]\n   * @returns {Promise<{cover: string}[]>}\n   */\n  async search(search, timeout = this.#responseTimeout) {\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    const url = `https://api.audiobookcovers.com/cover/bytext/`\n    const params = new URLSearchParams([['q', search]])\n    const items = await axios\n      .get(url, {\n        params,\n        timeout\n      })\n      .then((res) => res?.data || [])\n      .catch((error) => {\n        Logger.error('[AudiobookCovers] Cover search error', error.message)\n        return []\n      })\n    return items.map((item) => ({ cover: item.versions.png.original }))\n  }\n}\nmodule.exports = AudiobookCovers\n"
  },
  {
    "path": "server/providers/Audnexus.js",
    "content": "const axios = require('axios').default\nconst Throttle = require('p-throttle')\nconst Logger = require('../Logger')\nconst { levenshteinDistance } = require('../utils/index')\nconst { isValidASIN } = require('../utils/index')\n\n/**\n * @typedef AuthorSearchObj\n * @property {string} asin\n * @property {string} description\n * @property {string} image\n * @property {string} name\n */\n\nclass Audnexus {\n  static _instance = null\n\n  constructor() {\n    // ensures Audnexus class is singleton\n    if (Audnexus._instance) {\n      return Audnexus._instance\n    }\n\n    this.baseUrl = 'https://api.audnex.us'\n\n    // Rate limit is 100 requests per minute.\n    // @see https://github.com/laxamentumtech/audnexus#-deployment-\n    this.limiter = Throttle({\n      // Setting the limit to 1 allows for a short pause between requests that is imperceptible to the end user.\n      // A larger limit will grab blocks faster and then wait for the alloted time(interval) before\n      // fetching another batch, but with a discernable pause from the user perspective.\n      limit: 1,\n      strict: true,\n      interval: 150\n    })\n\n    Audnexus._instance = this\n  }\n\n  /**\n   *\n   * @param {string} name\n   * @param {string} region\n   * @returns {Promise<{asin:string, name:string}[]>}\n   */\n  authorASINsRequest(name, region) {\n    const searchParams = new URLSearchParams()\n    searchParams.set('name', name)\n\n    if (region) searchParams.set('region', region)\n\n    const authorRequestUrl = `${this.baseUrl}/authors?${searchParams.toString()}`\n    Logger.info(`[Audnexus] Searching for author \"${authorRequestUrl}\"`)\n\n    return this._processRequest(this.limiter(() => axios.get(authorRequestUrl)))\n      .then((res) => res.data || [])\n      .catch((error) => {\n        Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error.message)\n        return []\n      })\n  }\n\n  /**\n   *\n   * @param {string} asin\n   * @param {string} region\n   * @returns {Promise<AuthorSearchObj>}\n   */\n  authorRequest(asin, region) {\n    if (!isValidASIN(asin?.toUpperCase?.())) {\n      Logger.error(`[Audnexus] Invalid ASIN ${asin}`)\n      return null\n    }\n\n    asin = encodeURIComponent(asin.toUpperCase())\n\n    const authorRequestUrl = new URL(`${this.baseUrl}/authors/${asin}`)\n    if (region) authorRequestUrl.searchParams.set('region', region)\n\n    Logger.info(`[Audnexus] Searching for author \"${authorRequestUrl}\"`)\n\n    return this._processRequest(this.limiter(() => axios.get(authorRequestUrl.toString())))\n      .then((res) => res.data)\n      .catch((error) => {\n        Logger.error(`[Audnexus] Author request failed for ${asin}`, error.message)\n        return null\n      })\n  }\n\n  /**\n   *\n   * @param {string} asin\n   * @param {string} region\n   * @returns {Promise<AuthorSearchObj>}\n   */\n  async findAuthorByASIN(asin, region) {\n    const author = await this.authorRequest(asin, region)\n\n    return author\n      ? {\n          asin: author.asin,\n          description: author.description,\n          image: author.image || null,\n          name: author.name\n        }\n      : null\n  }\n\n  /**\n   *\n   * @param {string} name\n   * @param {string} region\n   * @param {number} maxLevenshtein\n   * @returns {Promise<AuthorSearchObj>}\n   */\n  async findAuthorByName(name, region, maxLevenshtein = 3) {\n    Logger.debug(`[Audnexus] Looking up author by name ${name}`)\n    const authorAsinObjs = await this.authorASINsRequest(name, region)\n\n    let closestMatch = null\n    authorAsinObjs.forEach((authorAsinObj) => {\n      authorAsinObj.levenshteinDistance = levenshteinDistance(authorAsinObj.name, name)\n      if (!closestMatch || closestMatch.levenshteinDistance > authorAsinObj.levenshteinDistance) {\n        closestMatch = authorAsinObj\n      }\n    })\n\n    if (!closestMatch || closestMatch.levenshteinDistance > maxLevenshtein) {\n      return null\n    }\n\n    const author = await this.authorRequest(closestMatch.asin, region)\n    if (!author) {\n      return null\n    }\n\n    return {\n      asin: author.asin,\n      description: author.description,\n      image: author.image || null,\n      name: author.name\n    }\n  }\n\n  /**\n   *\n   * @param {string} asin\n   * @param {string} region\n   * @returns {Promise<Object>}\n   */\n  getChaptersByASIN(asin, region) {\n    Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}/${region}`)\n\n    asin = encodeURIComponent(asin.toUpperCase())\n    const chaptersRequestUrl = new URL(`${this.baseUrl}/books/${asin}/chapters`)\n    if (region) chaptersRequestUrl.searchParams.set('region', region)\n\n    return this._processRequest(this.limiter(() => axios.get(chaptersRequestUrl.toString())))\n      .then((res) => res.data)\n      .catch((error) => {\n        Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error.message)\n        return null\n      })\n  }\n\n  /**\n   * Internal method to process requests and retry if rate limit is exceeded.\n   */\n  async _processRequest(request) {\n    try {\n      return await request()\n    } catch (error) {\n      if (error.response?.status === 429) {\n        const retryAfter = parseInt(error.response.headers?.['retry-after'], 10) || 5\n\n        Logger.warn(`[Audnexus] Rate limit exceeded. Retrying in ${retryAfter} seconds.`)\n        await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000))\n\n        return this._processRequest(request)\n      }\n\n      throw error\n    }\n  }\n}\n\nmodule.exports = Audnexus\n"
  },
  {
    "path": "server/providers/CustomProviderAdapter.js",
    "content": "const axios = require('axios').default\nconst Database = require('../Database')\nconst Logger = require('../Logger')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\nclass CustomProviderAdapter {\n  #responseTimeout = 10000\n\n  constructor() {}\n\n  /**\n   *\n   * @param {string} title\n   * @param {string} author\n   * @param {string} isbn\n   * @param {string} providerSlug\n   * @param {string} mediaType\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object[]>}\n   */\n  async search(title, author, isbn, providerSlug, mediaType, timeout = this.#responseTimeout) {\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    const providerId = providerSlug.split('custom-')[1]\n    const provider = await Database.customMetadataProviderModel.findByPk(providerId)\n\n    if (!provider) {\n      throw new Error('Custom provider not found for the given id')\n    }\n\n    // Setup query params\n    const queryObj = {\n      mediaType,\n      query: title\n    }\n    if (author) {\n      queryObj.author = author\n    }\n    if (isbn) {\n      queryObj.isbn = isbn\n    }\n    const queryString = new URLSearchParams(queryObj).toString()\n\n    const url = `${provider.url}/search?${queryString}`\n    Logger.debug(`[CustomMetadataProvider] Search url: ${url}`)\n\n    // Setup headers\n    const axiosOptions = {\n      timeout\n    }\n    if (provider.authHeaderValue) {\n      axiosOptions.headers = {\n        Authorization: provider.authHeaderValue\n      }\n    }\n\n    const matches = await axios\n      .get(url, axiosOptions)\n      .then((res) => {\n        if (!res?.data || !Array.isArray(res.data.matches)) return null\n        return res.data.matches\n      })\n      .catch((error) => {\n        Logger.error('[CustomMetadataProvider] Search error', error.message)\n        return []\n      })\n\n    if (!matches) {\n      throw new Error('Custom provider returned malformed response')\n    }\n\n    const toStringOrUndefined = (value) => {\n      if (typeof value === 'string' || typeof value === 'number') return String(value)\n      if (Array.isArray(value) && value.every((v) => typeof v === 'string' || typeof v === 'number')) return value.join(',')\n      return undefined\n    }\n    const validateSeriesArray = (series) => {\n      if (!Array.isArray(series) || !series.length) return undefined\n      return series\n        .map((s) => {\n          if (!s?.series || typeof s.series !== 'string') return undefined\n          const _series = {\n            series: s.series\n          }\n          if (s.sequence && (typeof s.sequence === 'string' || typeof s.sequence === 'number')) {\n            _series.sequence = String(s.sequence)\n          }\n          return _series\n        })\n        .filter((s) => s !== undefined)\n    }\n    /**\n     * Validates and dedupes tags/genres array\n     * Can be comma separated string or array of strings\n     * @param {string|string[]} tagsGenres\n     * @returns {string[]}\n     */\n    const validateTagsGenresArray = (tagsGenres) => {\n      if (!tagsGenres || (typeof tagsGenres !== 'string' && !Array.isArray(tagsGenres))) return undefined\n\n      // If string, split by comma and trim each item\n      if (typeof tagsGenres === 'string') tagsGenres = tagsGenres.split(',')\n      // If array, ensure all items are strings\n      else if (!tagsGenres.every((t) => typeof t === 'string')) return undefined\n\n      // Trim and filter out empty strings\n      tagsGenres = tagsGenres.map((t) => t.trim()).filter(Boolean)\n      if (!tagsGenres.length) return undefined\n\n      // Dedup\n      return [...new Set(tagsGenres)]\n    }\n\n    // re-map keys to throw out\n    return matches.map((match) => {\n      const { title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration } = match\n\n      const payload = {\n        title: toStringOrUndefined(title),\n        subtitle: toStringOrUndefined(subtitle),\n        author: toStringOrUndefined(author),\n        narrator: toStringOrUndefined(narrator),\n        publisher: toStringOrUndefined(publisher),\n        publishedYear: toStringOrUndefined(publishedYear),\n        description: description && typeof description === 'string' ? htmlSanitizer.sanitize(description) : undefined,\n        cover: toStringOrUndefined(cover),\n        isbn: toStringOrUndefined(isbn),\n        asin: toStringOrUndefined(asin),\n        genres: validateTagsGenresArray(genres),\n        tags: validateTagsGenresArray(tags),\n        series: validateSeriesArray(series),\n        language: toStringOrUndefined(language),\n        duration: !isNaN(duration) && duration !== null ? Number(duration) : undefined\n      }\n\n      // Remove undefined values\n      for (const key in payload) {\n        if (payload[key] === undefined) {\n          delete payload[key]\n        }\n      }\n\n      return payload\n    })\n  }\n}\n\nmodule.exports = CustomProviderAdapter\n"
  },
  {
    "path": "server/providers/FantLab.js",
    "content": "const axios = require('axios')\nconst Logger = require('../Logger')\n\nclass FantLab {\n  #responseTimeout = 10000\n  // 7 - other\n  // 11 - essay\n  // 12 - article\n  // 22 - disser\n  // 23 - monography\n  // 24 - study\n  // 25 - encyclopedy\n  // 26 - magazine\n  // 46 - sketch\n  // 47 - reportage\n  // 49 - excerpt\n  // 51 - interview\n  // 52 - review\n  // 55 - libretto\n  // 56 - anthology series\n  // 57 - newspaper\n  // types can get here https://api.fantlab.ru/config.json\n  _filterWorkType = [7, 11, 12, 22, 23, 24, 25, 26, 46, 47, 49, 51, 52, 55, 56, 57]\n  _baseUrl = 'https://api.fantlab.ru'\n\n  constructor() {}\n\n  /**\n   * @param {string} title\n   * @param {string} author'\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object[]>}\n   **/\n  async search(title, author, timeout = this.#responseTimeout) {\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    let searchString = encodeURIComponent(title)\n    if (author) {\n      searchString += encodeURIComponent(' ' + author)\n    }\n    const url = `${this._baseUrl}/search-works?q=${searchString}&page=1&onlymatches=1`\n    Logger.debug(`[FantLab] Search url: ${url}`)\n    const items = await axios\n      .get(url, {\n        timeout\n      })\n      .then((res) => {\n        return res.data || []\n      })\n      .catch((error) => {\n        Logger.error('[FantLab] search error', error.message)\n        return []\n      })\n\n    return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => resArray.filter(Boolean))\n  }\n\n  /**\n   * @param {Object} item\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object>}\n   **/\n  async getWork(item, timeout = this.#responseTimeout) {\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n    const { work_id, work_type_id } = item\n\n    if (this._filterWorkType.includes(work_type_id)) {\n      return null\n    }\n\n    const url = `${this._baseUrl}/work/${work_id}/extended`\n    const bookData = await axios\n      .get(url, {\n        timeout\n      })\n      .then((resp) => {\n        return resp.data || null\n      })\n      .catch((error) => {\n        Logger.error(`[FantLab] work info request for url \"${url}\" error`, error.message)\n        return null\n      })\n\n    if (!bookData) {\n      return null\n    }\n\n    return this.cleanBookData(bookData, timeout)\n  }\n\n  /**\n   *\n   * @param {Object} bookData\n   * @param {number} [timeout]\n   * @returns {Promise<Object>}\n   */\n  async cleanBookData(bookData, timeout = this.#responseTimeout) {\n    let { authors, work_name_alts, work_id, work_name, work_year, work_description, image, classificatory, editions_blocks } = bookData\n\n    const subtitle = Array.isArray(work_name_alts) ? work_name_alts[0] : null\n    const authorNames = authors.map((au) => (au.name || '').trim()).filter((au) => au)\n\n    const imageAndIsbn = await this.tryGetCoverFromEditions(editions_blocks, timeout)\n\n    const imageToUse = imageAndIsbn?.imageUrl || image\n\n    return {\n      id: work_id,\n      title: work_name,\n      subtitle: subtitle || null,\n      author: authorNames.length ? authorNames.join(', ') : null,\n      publisher: null,\n      publishedYear: work_year,\n      description: work_description,\n      cover: imageToUse ? `https://fantlab.ru${imageToUse}` : null,\n      genres: this.tryGetGenres(classificatory),\n      isbn: imageAndIsbn?.isbn || null\n    }\n  }\n\n  tryGetGenres(classificatory) {\n    if (!classificatory || !classificatory.genre_group) return []\n\n    const genresGroup = classificatory.genre_group.find((group) => group.genre_group_id == 1) // genres and subgenres\n\n    // genre_group_id=2 - General Characteristics\n    // genre_group_id=3 - Arena\n    // genre_group_id=4 - Duration of action\n    // genre_group_id=6 - Story moves\n    // genre_group_id=7 - Story linearity\n    // genre_group_id=5 - Recommended age of the reader\n\n    if (!genresGroup || !genresGroup.genre || !genresGroup.genre.length) return []\n\n    const rootGenre = genresGroup.genre[0]\n\n    const { label } = rootGenre\n\n    return [label].concat(this.tryGetSubGenres(rootGenre))\n  }\n\n  tryGetSubGenres(rootGenre) {\n    if (!rootGenre.genre || !rootGenre.genre.length) return []\n    return rootGenre.genre.map((g) => g.label).filter((g) => g)\n  }\n\n  /**\n   *\n   * @param {Object} editions\n   * @param {number} [timeout]\n   * @returns {Promise<{imageUrl: string, isbn: string}>\n   */\n  async tryGetCoverFromEditions(editions, timeout = this.#responseTimeout) {\n    if (!editions) {\n      return null\n    }\n\n    // 30 = audio, 10 = paper\n    // Prefer audio if available\n    const bookEditions = editions['30'] || editions['10']\n    if (!bookEditions || !bookEditions.list || !bookEditions.list.length) {\n      return null\n    }\n\n    const lastEdition = bookEditions.list.pop()\n\n    const editionId = lastEdition['edition_id']\n    const isbn = lastEdition['isbn'] || null // get only from paper edition\n\n    return {\n      imageUrl: await this.getCoverFromEdition(editionId, timeout),\n      isbn\n    }\n  }\n\n  /**\n   *\n   * @param {number} editionId\n   * @param {number} [timeout]\n   * @returns {Promise<string>}\n   */\n  async getCoverFromEdition(editionId, timeout = this.#responseTimeout) {\n    if (!editionId) return null\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    const url = `${this._baseUrl}/edition/${editionId}`\n\n    const editionInfo = await axios\n      .get(url, {\n        timeout\n      })\n      .then((resp) => {\n        return resp.data || null\n      })\n      .catch((error) => {\n        Logger.error(`[FantLab] search cover from edition with url \"${url}\" error`, error.message)\n        return null\n      })\n\n    return editionInfo?.image || null\n  }\n}\n\nmodule.exports = FantLab\n"
  },
  {
    "path": "server/providers/GoogleBooks.js",
    "content": "const axios = require('axios')\nconst Logger = require('../Logger')\n\nclass GoogleBooks {\n  #responseTimeout = 10000\n\n  constructor() {}\n\n  extractIsbn(industryIdentifiers) {\n    if (!industryIdentifiers || !industryIdentifiers.length) return null\n\n    var isbnObj = industryIdentifiers.find((i) => i.type === 'ISBN_13') || industryIdentifiers.find((i) => i.type === 'ISBN_10')\n    if (isbnObj && isbnObj.identifier) return isbnObj.identifier\n    return null\n  }\n\n  cleanResult(item) {\n    var { id, volumeInfo } = item\n    if (!volumeInfo) return null\n    const { title, subtitle, authors, publisher, publisherDate, description, industryIdentifiers, categories, imageLinks } = volumeInfo\n\n    let cover = null\n    // Selects the largest cover assuming the largest is the last key in the object\n    if (imageLinks && Object.keys(imageLinks).length) {\n      cover = imageLinks[Object.keys(imageLinks).pop()]\n      cover = cover?.replace(/^http:/, 'https:') || null\n    }\n\n    return {\n      id,\n      title,\n      subtitle: subtitle || null,\n      author: authors ? authors.join(', ') : null,\n      publisher,\n      publishedYear: publisherDate ? publisherDate.split('-')[0] : null,\n      description,\n      cover,\n      genres: categories && Array.isArray(categories) ? [...categories] : null,\n      isbn: this.extractIsbn(industryIdentifiers)\n    }\n  }\n\n  /**\n   * Search for a book by title and author\n   * @param {string} title\n   * @param {string} author\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object[]>}\n   **/\n  async search(title, author, timeout = this.#responseTimeout) {\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    title = encodeURIComponent(title)\n    let queryString = `q=intitle:${title}`\n    if (author) {\n      author = encodeURIComponent(author)\n      queryString += `+inauthor:${author}`\n    }\n    const url = `https://www.googleapis.com/books/v1/volumes?${queryString}`\n    Logger.debug(`[GoogleBooks] Search url: ${url}`)\n    const items = await axios\n      .get(url, {\n        timeout\n      })\n      .then((res) => {\n        if (!res || !res.data || !res.data.items) return []\n        return res.data.items\n      })\n      .catch((error) => {\n        Logger.error('[GoogleBooks] Volume search error', error.message)\n        return []\n      })\n    return items.map((item) => this.cleanResult(item))\n  }\n}\n\nmodule.exports = GoogleBooks\n"
  },
  {
    "path": "server/providers/MusicBrainz.js",
    "content": "const axios = require('axios')\nconst packageJson = require('../../package.json')\nconst Logger = require('../Logger')\nconst { isNullOrNaN } = require('../utils/index')\n\nclass MusicBrainz {\n  constructor() { }\n\n  get userAgentString() {\n    return `audiobookshelf/${packageJson.version} (https://audiobookshelf.org)`\n  }\n\n  // https://musicbrainz.org/doc/MusicBrainz_API/Search\n  searchTrack(options) {\n    let luceneParts = []\n    if (options.artist) {\n      luceneParts.push(`artist:${options.artist}`)\n    }\n    if (options.isrc) {\n      luceneParts.push(`isrc:${options.isrc}`)\n    }\n    if (options.title) {\n      luceneParts.push(`recording:${options.title}`)\n    }\n    if (options.album) {\n      luceneParts.push(`release:${options.album}`)\n    }\n    if (!luceneParts.length) {\n      Logger.error(`[MusicBrainz] Invalid search options - must have at least one of artist, isrc, title, album`)\n      return []\n    }\n\n    const query = {\n      query: luceneParts.join(' AND '),\n      limit: isNullOrNaN(options.limit) ? 15 : Number(options.limit),\n      fmt: 'json'\n    }\n    const config = {\n      headers: {\n        'User-Agent': this.userAgentString\n      }\n    }\n    return axios.get('https://musicbrainz.org/ws/2/recording', { params: query }, config).then((response) => {\n      return response.data.recordings || []\n    }).catch((error) => {\n      Logger.error(`[MusicBrainz] search request error`, error)\n      return []\n    })\n  }\n}\nmodule.exports = MusicBrainz"
  },
  {
    "path": "server/providers/OpenLibrary.js",
    "content": "const axios = require('axios').default\n\nclass OpenLibrary {\n  #responseTimeout = 10000\n\n  constructor() {\n    this.baseUrl = 'https://openlibrary.org'\n  }\n\n  /**\n   *\n   * @param {string} uri\n   * @param {number} timeout\n   * @returns {Promise<Object>}\n   */\n  get(uri, timeout = this.#responseTimeout) {\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n    return axios\n      .get(`${this.baseUrl}/${uri}`, {\n        timeout\n      })\n      .then((res) => {\n        return res.data\n      })\n      .catch((error) => {\n        console.error('Failed', error.message)\n        return null\n      })\n  }\n\n  async isbnLookup(isbn) {\n    var lookupData = await this.get(`/isbn/${isbn}`)\n    if (!lookupData) {\n      return {\n        errorCode: 404\n      }\n    }\n    return lookupData\n  }\n\n  async getWorksData(worksKey) {\n    var worksData = await this.get(`${worksKey}.json`)\n    if (!worksData) {\n      return {\n        errorMsg: 'Works Data Request failed',\n        errorCode: 500\n      }\n    }\n    if (!worksData.covers) worksData.covers = []\n    var coverImages = worksData.covers.filter((c) => c > 0).map((c) => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)\n    var description = null\n    if (worksData.description) {\n      if (typeof worksData.description === 'string') {\n        description = worksData.description\n      } else {\n        description = worksData.description.value || null\n      }\n    }\n    return {\n      id: worksKey.split('/').pop(),\n      key: worksKey,\n      covers: coverImages,\n      first_publish_date: worksData.first_publish_date,\n      description: description\n    }\n  }\n\n  parsePublishYear(doc, worksData) {\n    if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return String(doc.first_publish_year)\n    if (worksData.first_publish_date) {\n      var year = worksData.first_publish_date.split('-')[0]\n      if (!isNaN(year)) return String(year)\n    }\n    return null\n  }\n\n  async cleanSearchDoc(doc) {\n    var worksData = await this.getWorksData(doc.key)\n    return {\n      title: doc.title,\n      author: doc.author_name ? doc.author_name.join(', ') : null,\n      publishedYear: this.parsePublishYear(doc, worksData),\n      edition: doc.cover_edition_key,\n      cover: doc.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/${doc.cover_edition_key}-L.jpg` : null,\n      ...worksData\n    }\n  }\n\n  async search(query) {\n    var queryString = Object.keys(query)\n      .map((key) => key + '=' + query[key])\n      .join('&')\n    var lookupData = await this.get(`/search.json?${queryString}`)\n    if (!lookupData) {\n      return {\n        errorCode: 404\n      }\n    }\n    var searchDocs = await Promise.all(lookupData.docs.map((d) => this.cleanSearchDoc(d)))\n    return searchDocs\n  }\n\n  /**\n   *\n   * @param {string} title\n   * @param {number} timeout\n   * @returns {Promise<Object[]>}\n   */\n  async searchTitle(title, timeout = this.#responseTimeout) {\n    title = encodeURIComponent(title)\n    var lookupData = await this.get(`/search.json?title=${title}`, timeout)\n    if (!lookupData) {\n      return {\n        errorCode: 404\n      }\n    }\n    var searchDocs = await Promise.all(lookupData.docs.map((d) => this.cleanSearchDoc(d)))\n    return searchDocs\n  }\n}\nmodule.exports = OpenLibrary\n"
  },
  {
    "path": "server/providers/iTunes.js",
    "content": "const axios = require('axios')\nconst Logger = require('../Logger')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\n\n/**\n * @typedef iTunesSearchParams\n * @property {string} term\n * @property {string} country\n * @property {string} media\n * @property {string} entity\n * @property {number} limit\n */\n\n/**\n * @typedef iTunesPodcastSearchResult\n * @property {string} id\n * @property {string} artistId\n * @property {string} title\n * @property {string} artistName\n * @property {string} description\n * @property {string} descriptionPlain\n * @property {string} releaseDate\n * @property {string[]} genres\n * @property {string} cover\n * @property {string} feedUrl\n * @property {string} pageUrl\n * @property {boolean} explicit\n */\n\nclass iTunes {\n  #responseTimeout = 10000\n\n  constructor() {}\n\n  /**\n   * @see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html\n   *\n   * @param {iTunesSearchParams} options\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object[]>}\n   */\n  search(options, timeout = this.#responseTimeout) {\n    if (!options.term) {\n      Logger.error('[iTunes] Invalid search options - no term')\n      return []\n    }\n    if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout\n\n    const query = {\n      term: options.term,\n      media: options.media,\n      entity: options.entity,\n      lang: options.lang,\n      limit: options.limit,\n      country: options.country\n    }\n    return axios\n      .get('https://itunes.apple.com/search', {\n        params: query,\n        timeout\n      })\n      .then((response) => {\n        return response.data.results || []\n      })\n      .catch((error) => {\n        Logger.error(`[iTunes] search request error`, error.message)\n        return []\n      })\n  }\n\n  // Example cover art: https://is1-ssl.mzstatic.com/image/thumb/Music118/v4/cb/ea/73/cbea739b-ff3b-11c4-fb93-7889fbec7390/9781598874983_cover.jpg/100x100bb.jpg\n  // 100x100bb can be replaced by other values https://github.com/bendodson/itunes-artwork-finder\n  // Target size 600 or larger\n  getCoverArtwork(data) {\n    if (data.artworkUrl600) {\n      return data.artworkUrl600\n    }\n    // Should already be sorted from small to large\n    var artworkSizes = Object.keys(data)\n      .filter((key) => key.startsWith('artworkUrl'))\n      .map((key) => {\n        return {\n          url: data[key],\n          size: Number(key.replace('artworkUrl', ''))\n        }\n      })\n    if (!artworkSizes.length) return null\n\n    // Return next biggest size > 600\n    var nextBestSize = artworkSizes.find((size) => size.size > 600)\n    if (nextBestSize) return nextBestSize.url\n\n    // Find square artwork\n    var squareArtwork = artworkSizes.find((size) => size.url.includes(`${size.size}x${size.size}bb`))\n\n    // Square cover replace with 600x600bb\n    if (squareArtwork) {\n      return squareArtwork.url.replace(`${squareArtwork.size}x${squareArtwork.size}bb`, '600x600bb')\n    }\n\n    // Last resort just return biggest size\n    return artworkSizes[artworkSizes.length - 1].url\n  }\n\n  cleanAudiobook(data) {\n    // artistName can be \"Name1, Name2 & Name3\" so we refactor this to \"Name1, Name2, Name3\"\n    //  see: https://github.com/advplyr/audiobookshelf/issues/1022\n    const author = (data.artistName || '').split(' & ').join(', ')\n\n    return {\n      id: data.collectionId,\n      artistId: data.artistId,\n      title: data.collectionName,\n      author,\n      description: data.description || null,\n      publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null,\n      genres: data.primaryGenreName ? [data.primaryGenreName] : null,\n      cover: this.getCoverArtwork(data)\n    }\n  }\n\n  /**\n   *\n   * @param {string} term\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<Object[]>}\n   */\n  searchAudiobooks(term, timeout = this.#responseTimeout) {\n    return this.search({ term, entity: 'audiobook', media: 'audiobook' }, timeout).then((results) => {\n      return results.map(this.cleanAudiobook.bind(this))\n    })\n  }\n\n  /**\n   *\n   * @param {Object} data\n   * @returns {iTunesPodcastSearchResult}\n   */\n  cleanPodcast(data) {\n    return {\n      id: data.collectionId,\n      artistId: data.artistId || null,\n      title: data.collectionName,\n      artistName: data.artistName,\n      description: htmlSanitizer.sanitize(data.description || ''),\n      descriptionPlain: htmlSanitizer.stripAllTags(data.description || ''),\n      releaseDate: data.releaseDate,\n      genres: data.genres || [],\n      cover: this.getCoverArtwork(data),\n      trackCount: data.trackCount,\n      feedUrl: data.feedUrl,\n      pageUrl: data.collectionViewUrl,\n      explicit: data.trackExplicitness === 'explicit'\n    }\n  }\n\n  /**\n   *\n   * @param {string} term\n   * @param {{country:string}} options\n   * @param {number} [timeout] response timeout in ms\n   * @returns {Promise<iTunesPodcastSearchResult[]>}\n   */\n  searchPodcasts(term, options = {}, timeout = this.#responseTimeout) {\n    return this.search({ term, entity: 'podcast', media: 'podcast', ...options }, timeout).then((results) => {\n      return results.map(this.cleanPodcast.bind(this))\n    })\n  }\n}\nmodule.exports = iTunes\n"
  },
  {
    "path": "server/routers/ApiRouter.js",
    "content": "const express = require('express')\nconst Path = require('path')\nconst sequelize = require('sequelize')\n\nconst Logger = require('../Logger')\nconst Database = require('../Database')\nconst SocketAuthority = require('../SocketAuthority')\n\nconst fs = require('../libs/fsExtra')\nconst date = require('../libs/dateAndTime')\n\nconst CacheManager = require('../managers/CacheManager')\nconst RssFeedManager = require('../managers/RssFeedManager')\n\nconst LibraryController = require('../controllers/LibraryController')\nconst UserController = require('../controllers/UserController')\nconst CollectionController = require('../controllers/CollectionController')\nconst PlaylistController = require('../controllers/PlaylistController')\nconst MeController = require('../controllers/MeController')\nconst BackupController = require('../controllers/BackupController')\nconst LibraryItemController = require('../controllers/LibraryItemController')\nconst SeriesController = require('../controllers/SeriesController')\nconst FileSystemController = require('../controllers/FileSystemController')\nconst AuthorController = require('../controllers/AuthorController')\nconst SessionController = require('../controllers/SessionController')\nconst PodcastController = require('../controllers/PodcastController')\nconst NotificationController = require('../controllers/NotificationController')\nconst EmailController = require('../controllers/EmailController')\nconst SearchController = require('../controllers/SearchController')\nconst CacheController = require('../controllers/CacheController')\nconst ToolsController = require('../controllers/ToolsController')\nconst RSSFeedController = require('../controllers/RSSFeedController')\nconst CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')\nconst MiscController = require('../controllers/MiscController')\nconst ShareController = require('../controllers/ShareController')\nconst StatsController = require('../controllers/StatsController')\nconst ApiKeyController = require('../controllers/ApiKeyController')\n\nclass ApiRouter {\n  constructor(Server) {\n    /** @type {import('../Auth')} */\n    this.auth = Server.auth\n    /** @type {import('../managers/PlaybackSessionManager')} */\n    this.playbackSessionManager = Server.playbackSessionManager\n    /** @type {import('../managers/AbMergeManager')} */\n    this.abMergeManager = Server.abMergeManager\n    /** @type {import('../managers/BackupManager')} */\n    this.backupManager = Server.backupManager\n    /** @type {import('../managers/PodcastManager')} */\n    this.podcastManager = Server.podcastManager\n    /** @type {import('../managers/AudioMetadataManager')} */\n    this.audioMetadataManager = Server.audioMetadataManager\n    /** @type {import('../managers/CronManager')} */\n    this.cronManager = Server.cronManager\n    /** @type {import('../managers/EmailManager')} */\n    this.emailManager = Server.emailManager\n    this.apiCacheManager = Server.apiCacheManager\n\n    this.router = express()\n    this.router.disable('x-powered-by')\n    this.init()\n  }\n\n  init() {\n    //\n    // Library Routes\n    //\n    this.router.get(/^\\/libraries/, this.apiCacheManager.middleware)\n    this.router.post('/libraries', LibraryController.create.bind(this))\n    this.router.get('/libraries', LibraryController.findAll.bind(this))\n    this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))\n    this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))\n    this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))\n\n    this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))\n    this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))\n    this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))\n    this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))\n    this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))\n    this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))\n    this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))\n    this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))\n    this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))\n    this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))\n    this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))\n    this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))\n    this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))\n    this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))\n    this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))\n    this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))\n    this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))\n    this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))\n    this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))\n    this.router.post('/libraries/order', LibraryController.reorder.bind(this))\n    this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this))\n    this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this))\n    this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this))\n\n    //\n    // Item Routes\n    //\n    this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))\n    this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))\n    this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))\n    this.router.post('/items/batch/quickmatch', LibraryItemController.batchQuickMatch.bind(this))\n    this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this))\n\n    this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))\n    this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))\n    this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))\n    this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))\n    this.router.get('/items/:id/cover', LibraryItemController.getCover.bind(this))\n    this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))\n    this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this))\n    this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this))\n    this.router.post('/items/:id/match', LibraryItemController.middleware.bind(this), LibraryItemController.match.bind(this))\n    this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))\n    this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))\n    this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))\n    this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))\n    this.router.get('/items/:id/metadata-object', LibraryItemController.middleware.bind(this), LibraryItemController.getMetadataObject.bind(this))\n    this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))\n    this.router.get('/items/:id/ffprobe/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getFFprobeData.bind(this))\n    this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))\n    this.router.delete('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))\n    this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this))\n    this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))\n    this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))\n\n    //\n    // User Routes\n    //\n    this.router.post('/users', UserController.middleware.bind(this), UserController.create.bind(this))\n    this.router.get('/users', UserController.middleware.bind(this), UserController.findAll.bind(this))\n    this.router.get('/users/online', UserController.getOnlineUsers.bind(this))\n    this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this))\n    this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this))\n    this.router.delete('/users/:id', UserController.middleware.bind(this), UserController.delete.bind(this))\n    this.router.patch('/users/:id/openid-unlink', UserController.middleware.bind(this), UserController.unlinkFromOpenID.bind(this))\n    this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))\n    this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this))\n\n    //\n    // Collection Routes\n    //\n    this.router.post('/collections', CollectionController.middleware.bind(this), CollectionController.create.bind(this))\n    this.router.get('/collections', CollectionController.findAll.bind(this))\n    this.router.get('/collections/:id', CollectionController.middleware.bind(this), CollectionController.findOne.bind(this))\n    this.router.patch('/collections/:id', CollectionController.middleware.bind(this), CollectionController.update.bind(this))\n    this.router.delete('/collections/:id', CollectionController.middleware.bind(this), CollectionController.delete.bind(this))\n    this.router.post('/collections/:id/book', CollectionController.middleware.bind(this), CollectionController.addBook.bind(this))\n    this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this))\n    this.router.post('/collections/:id/batch/add', CollectionController.middleware.bind(this), CollectionController.addBatch.bind(this))\n    this.router.post('/collections/:id/batch/remove', CollectionController.middleware.bind(this), CollectionController.removeBatch.bind(this))\n\n    //\n    // Playlist Routes\n    //\n    this.router.post('/playlists', PlaylistController.create.bind(this))\n    this.router.get('/playlists', PlaylistController.findAllForUser.bind(this))\n    this.router.get('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.findOne.bind(this))\n    this.router.patch('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.update.bind(this))\n    this.router.delete('/playlists/:id', PlaylistController.middleware.bind(this), PlaylistController.delete.bind(this))\n    this.router.post('/playlists/:id/item', PlaylistController.middleware.bind(this), PlaylistController.addItem.bind(this))\n    this.router.delete('/playlists/:id/item/:libraryItemId/:episodeId?', PlaylistController.middleware.bind(this), PlaylistController.removeItem.bind(this))\n    this.router.post('/playlists/:id/batch/add', PlaylistController.middleware.bind(this), PlaylistController.addBatch.bind(this))\n    this.router.post('/playlists/:id/batch/remove', PlaylistController.middleware.bind(this), PlaylistController.removeBatch.bind(this))\n    this.router.post('/playlists/collection/:collectionId', PlaylistController.createFromCollection.bind(this))\n\n    //\n    // Current User Routes (Me)\n    //\n    this.router.get('/me', MeController.getCurrentUser.bind(this))\n    this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))\n    this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))\n    this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))\n    this.router.get('/me/progress/:id/remove-from-continue-listening', MeController.removeItemFromContinueListening.bind(this))\n    this.router.get('/me/progress/:id/:episodeId?', MeController.getMediaProgress.bind(this))\n    this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))\n    this.router.patch('/me/progress/:libraryItemId/:episodeId?', MeController.createUpdateMediaProgress.bind(this))\n    this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))\n    this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))\n    this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))\n    this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))\n    this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this))\n    this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))\n    this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))\n    this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))\n    this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))\n    this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))\n\n    //\n    // Backup Routes\n    //\n    this.router.get('/backups', BackupController.middleware.bind(this), BackupController.getAll.bind(this))\n    this.router.post('/backups', BackupController.middleware.bind(this), BackupController.create.bind(this))\n    this.router.delete('/backups/:id', BackupController.middleware.bind(this), BackupController.delete.bind(this))\n    this.router.get('/backups/:id/download', BackupController.middleware.bind(this), BackupController.download.bind(this))\n    this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this))\n    this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this))\n    this.router.patch('/backups/path', BackupController.middleware.bind(this), BackupController.updatePath.bind(this))\n\n    //\n    // File System Routes\n    //\n    this.router.get('/filesystem', FileSystemController.getPaths.bind(this))\n    this.router.post('/filesystem/pathexists', FileSystemController.checkPathExists.bind(this))\n\n    //\n    // Author Routes\n    //\n    this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))\n    this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))\n    this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))\n    this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))\n    this.router.get('/authors/:id/image', AuthorController.getImage.bind(this))\n    this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this))\n    this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this))\n\n    //\n    // Series Routes\n    //\n    this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))\n    this.router.patch('/series/:id', SeriesController.middleware.bind(this), SeriesController.update.bind(this))\n\n    //\n    // Playback Session Routes\n    //\n    this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))\n    this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))\n    this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))\n    this.router.post('/sessions/batch/delete', SessionController.batchDelete.bind(this))\n    this.router.post('/session/local', SessionController.syncLocal.bind(this))\n    this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))\n    // TODO: Update these endpoints because they are only for open playback sessions\n    this.router.get('/session/:id', SessionController.openSessionMiddleware.bind(this), SessionController.getOpenSession.bind(this))\n    this.router.post('/session/:id/sync', SessionController.openSessionMiddleware.bind(this), SessionController.sync.bind(this))\n    this.router.post('/session/:id/close', SessionController.openSessionMiddleware.bind(this), SessionController.close.bind(this))\n\n    //\n    // Podcast Routes\n    //\n    this.router.post('/podcasts', PodcastController.create.bind(this))\n    this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))\n    this.router.post('/podcasts/opml/parse', PodcastController.getFeedsFromOPMLText.bind(this))\n    this.router.post('/podcasts/opml/create', PodcastController.bulkCreatePodcastsFromOpmlFeedUrls.bind(this))\n    this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))\n    this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))\n    this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))\n    this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this))\n    this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))\n    this.router.post('/podcasts/:id/match-episodes', PodcastController.middleware.bind(this), PodcastController.quickMatchEpisodes.bind(this))\n    this.router.get('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.getEpisode.bind(this))\n    this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))\n    this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))\n\n    //\n    // Notification Routes (Admin and up)\n    //\n    this.router.get('/notifications', NotificationController.middleware.bind(this), NotificationController.get.bind(this))\n    this.router.patch('/notifications', NotificationController.middleware.bind(this), NotificationController.update.bind(this))\n    this.router.get('/notificationdata', NotificationController.middleware.bind(this), NotificationController.getData.bind(this))\n    this.router.get('/notifications/test', NotificationController.middleware.bind(this), NotificationController.fireTestEvent.bind(this))\n    this.router.post('/notifications', NotificationController.middleware.bind(this), NotificationController.createNotification.bind(this))\n    this.router.delete('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.deleteNotification.bind(this))\n    this.router.patch('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.updateNotification.bind(this))\n    this.router.get('/notifications/:id/test', NotificationController.middleware.bind(this), NotificationController.sendNotificationTest.bind(this))\n\n    //\n    // Email Routes (Admin and up)\n    //\n    this.router.get('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.getSettings.bind(this))\n    this.router.patch('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.updateSettings.bind(this))\n    this.router.post('/emails/test', EmailController.adminMiddleware.bind(this), EmailController.sendTest.bind(this))\n    this.router.post('/emails/ereader-devices', EmailController.adminMiddleware.bind(this), EmailController.updateEReaderDevices.bind(this))\n    this.router.post('/emails/send-ebook-to-device', EmailController.sendEBookToDevice.bind(this))\n\n    //\n    // Search Routes\n    //\n    this.router.get('/search/covers', SearchController.findCovers.bind(this))\n    this.router.get('/search/books', SearchController.findBooks.bind(this))\n    this.router.get('/search/podcast', SearchController.findPodcasts.bind(this))\n    this.router.get('/search/authors', SearchController.findAuthor.bind(this))\n    this.router.get('/search/chapters', SearchController.findChapters.bind(this))\n    this.router.get('/search/providers', SearchController.getAllProviders.bind(this))\n\n    //\n    // Cache Routes (Admin and up)\n    //\n    this.router.post('/cache/purge', CacheController.purgeCache.bind(this))\n    this.router.post('/cache/items/purge', CacheController.purgeItemsCache.bind(this))\n\n    //\n    // Tools Routes (Admin and up)\n    //\n    this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this))\n    this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))\n    this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))\n    this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))\n\n    //\n    // RSS Feed Routes (Admin and up)\n    //\n    this.router.get('/feeds', RSSFeedController.middleware.bind(this), RSSFeedController.getAll.bind(this))\n    this.router.post('/feeds/item/:itemId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForItem.bind(this))\n    this.router.post('/feeds/collection/:collectionId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForCollection.bind(this))\n    this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))\n    this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this))\n\n    //\n    // Custom Metadata Provider routes\n    //\n    this.router.get('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.getAll.bind(this))\n    this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this))\n    this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this))\n\n    //\n    // Share routes\n    //\n    this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))\n    this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))\n\n    //\n    // Stats Routes\n    //\n    this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))\n    this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))\n\n    //\n    // API Key Routes\n    //\n    this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))\n    this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))\n    this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))\n    this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))\n\n    //\n    // Misc Routes\n    //\n    this.router.post('/upload', MiscController.handleUpload.bind(this))\n    this.router.get('/tasks', MiscController.getTasks.bind(this))\n    this.router.patch('/settings', MiscController.updateServerSettings.bind(this))\n    this.router.patch('/sorting-prefixes', MiscController.updateSortingPrefixes.bind(this))\n    this.router.post('/authorize', MiscController.authorize.bind(this))\n    this.router.get('/tags', MiscController.getAllTags.bind(this))\n    this.router.post('/tags/rename', MiscController.renameTag.bind(this))\n    this.router.delete('/tags/:tag', MiscController.deleteTag.bind(this))\n    this.router.get('/genres', MiscController.getAllGenres.bind(this))\n    this.router.post('/genres/rename', MiscController.renameGenre.bind(this))\n    this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))\n    this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))\n    this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))\n    this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))\n    this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))\n    this.router.get('/logger-data', MiscController.getLoggerData.bind(this))\n  }\n\n  //\n  // Helper Methods\n  //\n  /**\n   * Remove library item and associated entities\n   * @param {string} libraryItemId\n   * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId\n   */\n  async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {\n    const numProgressRemoved = await Database.mediaProgressModel.destroy({\n      where: {\n        mediaItemId: mediaItemIds\n      }\n    })\n    if (numProgressRemoved > 0) {\n      Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item \"${libraryItemId}\"`)\n    }\n\n    // remove item from playlists\n    await Database.playlistModel.removeMediaItemsFromPlaylists(mediaItemIds)\n\n    // Close rss feed - remove from db and emit socket event\n    await RssFeedManager.closeFeedForEntityId(libraryItemId)\n\n    // purge cover cache\n    await CacheManager.purgeCoverCache(libraryItemId)\n\n    // Remove metadata file if in /metadata/items dir\n    if (global.MetadataPath) {\n      const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)\n      if (await fs.pathExists(itemMetadataPath)) {\n        Logger.info(`[ApiRouter] Removing item metadata at \"${itemMetadataPath}\"`)\n        await fs.remove(itemMetadataPath)\n      }\n    }\n\n    await Database.libraryItemModel.removeById(libraryItemId)\n\n    SocketAuthority.emitter('item_removed', {\n      id: libraryItemId\n    })\n  }\n\n  /**\n   * After deleting book(s), remove empty series\n   *\n   * @param {string[]} seriesIds\n   */\n  async checkRemoveEmptySeries(seriesIds) {\n    if (!seriesIds?.length) return\n\n    const transaction = await Database.sequelize.transaction()\n    try {\n      const seriesToRemove = (\n        await Database.seriesModel.findAll({\n          where: [\n            {\n              id: seriesIds\n            },\n            sequelize.where(sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 0)\n          ],\n          attributes: ['id', 'name', 'libraryId'],\n          include: {\n            model: Database.bookModel,\n            attributes: ['id'],\n            required: false // Ensure it includes series even if no books exist\n          },\n          transaction\n        })\n      ).map((s) => ({ id: s.id, name: s.name, libraryId: s.libraryId }))\n\n      if (seriesToRemove.length) {\n        await Database.seriesModel.destroy({\n          where: {\n            id: seriesToRemove.map((s) => s.id)\n          },\n          transaction\n        })\n      }\n\n      await transaction.commit()\n\n      seriesToRemove.forEach(({ id, name, libraryId }) => {\n        Logger.info(`[ApiRouter] Series \"${name}\" is now empty. Removing series`)\n\n        // Remove series from library filter data\n        Database.removeSeriesFromFilterData(libraryId, id)\n        SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })\n      })\n      // Close rss feeds - remove from db and emit socket event\n      if (seriesToRemove.length) {\n        await RssFeedManager.closeFeedsForEntityIds(seriesToRemove.map((s) => s.id))\n      }\n    } catch (error) {\n      await transaction.rollback()\n      Logger.error(`[ApiRouter] Error removing empty series: ${error.message}`)\n    }\n  }\n\n  /**\n   * Remove authors with no books and unset asin, description and imagePath\n   * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)\n   *\n   * @param {string[]} authorIds\n   * @returns {Promise<void>}\n   */\n  async checkRemoveAuthorsWithNoBooks(authorIds) {\n    if (!authorIds?.length) return\n\n    const transaction = await Database.sequelize.transaction()\n    try {\n      // Select authors with locking to prevent concurrent updates\n      const bookAuthorsToRemove = (\n        await Database.authorModel.findAll({\n          where: [\n            {\n              id: authorIds,\n              asin: {\n                [sequelize.Op.or]: [null, '']\n              },\n              description: {\n                [sequelize.Op.or]: [null, '']\n              },\n              imagePath: {\n                [sequelize.Op.or]: [null, '']\n              }\n            },\n            sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)\n          ],\n          attributes: ['id', 'name', 'libraryId'],\n          raw: true,\n          transaction\n        })\n      ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))\n\n      if (bookAuthorsToRemove.length) {\n        await Database.authorModel.destroy({\n          where: {\n            id: bookAuthorsToRemove.map((au) => au.id)\n          },\n          transaction\n        })\n      }\n\n      await transaction.commit()\n\n      // Remove all book authors after completing remove from database\n      bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {\n        Database.removeAuthorFromFilterData(libraryId, id)\n        // TODO: Clients were expecting full author in payload but its unnecessary\n        SocketAuthority.emitter('author_removed', { id, libraryId })\n        Logger.info(`[ApiRouter] Removed author \"${name}\" with no books`)\n      })\n    } catch (error) {\n      await transaction.rollback()\n      Logger.error(`[ApiRouter] Error removing authors: ${error.message}`)\n    }\n  }\n\n  async getUserListeningSessionsHelper(userId) {\n    const userSessions = await Database.getPlaybackSessions({ userId })\n    return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)\n  }\n\n  async getUserItemListeningSessionsHelper(userId, mediaItemId) {\n    const userSessions = await Database.getPlaybackSessions({ userId, mediaItemId })\n    return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)\n  }\n\n  async getUserListeningStatsHelpers(userId) {\n    const today = date.format(new Date(), 'YYYY-MM-DD')\n\n    const listeningSessions = await this.getUserListeningSessionsHelper(userId)\n    const listeningStats = {\n      totalTime: 0,\n      items: {},\n      days: {},\n      dayOfWeek: {},\n      today: 0,\n      recentSessions: listeningSessions.slice(0, 10)\n    }\n    listeningSessions.forEach((s) => {\n      let sessionTimeListening = s.timeListening\n      if (typeof sessionTimeListening == 'string') {\n        sessionTimeListening = Number(sessionTimeListening)\n      }\n\n      if (s.dayOfWeek) {\n        if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0\n        listeningStats.dayOfWeek[s.dayOfWeek] += sessionTimeListening\n      }\n      if (s.date && sessionTimeListening > 0) {\n        if (!listeningStats.days[s.date]) listeningStats.days[s.date] = 0\n        listeningStats.days[s.date] += sessionTimeListening\n\n        if (s.date === today) {\n          listeningStats.today += sessionTimeListening\n        }\n      }\n      if (!listeningStats.items[s.libraryItemId]) {\n        listeningStats.items[s.libraryItemId] = {\n          id: s.libraryItemId,\n          timeListening: sessionTimeListening,\n          mediaMetadata: s.mediaMetadata,\n          lastUpdate: s.lastUpdate\n        }\n      } else {\n        listeningStats.items[s.libraryItemId].timeListening += sessionTimeListening\n      }\n\n      listeningStats.totalTime += sessionTimeListening\n    })\n    return listeningStats\n  }\n}\nmodule.exports = ApiRouter\n"
  },
  {
    "path": "server/routers/HlsRouter.js",
    "content": "const express = require('express')\nconst Path = require('path')\n\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\n\nconst fs = require('../libs/fsExtra')\n\n\nclass HlsRouter {\n  constructor(auth, playbackSessionManager) {\n    this.auth = auth\n    this.playbackSessionManager = playbackSessionManager\n\n    this.router = express()\n    this.router.disable('x-powered-by')\n    this.init()\n  }\n\n  init() {\n    this.router.get('/:stream/:file', this.streamFileRequest.bind(this))\n  }\n\n  parseSegmentFilename(filename) {\n    var basename = Path.basename(filename, Path.extname(filename))\n    var num_part = basename.split('-')[1]\n    return Number(num_part)\n  }\n\n  /**\n   * Ensure filepath is inside streamDir\n   * Used to prevent arbitrary file reads\n   * @see https://nodejs.org/api/path.html#pathrelativefrom-to\n   * \n   * @param {string} streamDir \n   * @param {string} filepath \n   * @returns {boolean}\n   */\n  validateStreamFilePath(streamDir, filepath) {\n    const relative = Path.relative(streamDir, filepath)\n    return relative && !relative.startsWith('..') && !Path.isAbsolute(relative)\n  }\n\n  /**\n   * GET /hls/:stream/:file\n   * File must have extname .ts or .m3u8\n   * \n   * @param {express.Request} req \n   * @param {express.Response} res \n   */\n  async streamFileRequest(req, res) {\n    const streamId = req.params.stream\n    // Ensure stream is open\n    const stream = this.playbackSessionManager.getStream(streamId)\n    if (!stream) {\n      Logger.error(`[HlsRouter] Stream \"${streamId}\" does not exist`)\n      return res.sendStatus(404)\n    }\n\n    // Ensure stream filepath is valid\n    const streamDir = Path.join(this.playbackSessionManager.StreamsPath, streamId)\n    const fullFilePath = Path.join(streamDir, req.params.file)\n    if (!this.validateStreamFilePath(streamDir, fullFilePath)) {\n      Logger.error(`[HlsRouter] Invalid file parameter \"${req.params.file}\"`)\n      return res.sendStatus(400)\n    }\n\n    const fileExt = Path.extname(req.params.file)\n    if (fileExt !== '.ts' && fileExt !== '.m3u8') {\n      Logger.error(`[HlsRouter] Invalid file parameter \"${req.params.file}\" extname. Must be .ts or .m3u8`)\n      return res.sendStatus(400)\n    }\n\n    if (!(await fs.pathExists(fullFilePath))) {\n      Logger.warn('File path does not exist', fullFilePath)\n\n      if (fileExt === '.ts') {\n        const segNum = this.parseSegmentFilename(req.params.file)\n\n        if (stream.isResetting) {\n          Logger.info(`[HlsRouter] Stream ${streamId} is currently resetting`)\n        } else {\n          const startTimeForReset = await stream.checkSegmentNumberRequest(segNum)\n          if (startTimeForReset) {\n            // HLS.js will restart the stream at the new time\n            Logger.info(`[HlsRouter] Resetting Stream - notify client @${startTimeForReset}s`)\n            SocketAuthority.emitter('stream_reset', {\n              startTime: startTimeForReset,\n              streamId: stream.id\n            })\n          }\n        }\n      }\n      return res.sendStatus(404)\n    }\n\n    res.sendFile(fullFilePath)\n  }\n}\nmodule.exports = HlsRouter"
  },
  {
    "path": "server/routers/PublicRouter.js",
    "content": "const express = require('express')\nconst ShareController = require('../controllers/ShareController')\nconst SessionController = require('../controllers/SessionController')\n\nclass PublicRouter {\n  constructor(playbackSessionManager) {\n    /** @type {import('../managers/PlaybackSessionManager')} */\n    this.playbackSessionManager = playbackSessionManager\n\n    this.router = express()\n    this.router.disable('x-powered-by')\n    this.init()\n  }\n\n  init() {\n    this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))\n    this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))\n    this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))\n    this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))\n    this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))\n    this.router.get('/session/:id/track/:index', SessionController.getTrack.bind(this))\n  }\n}\nmodule.exports = PublicRouter\n"
  },
  {
    "path": "server/scanner/AbsMetadataFileScanner.js",
    "content": "const Path = require('path')\nconst fsExtra = require('../libs/fsExtra')\nconst { readTextFile } = require('../utils/fileUtils')\nconst { LogLevel } = require('../utils/constants')\nconst abmetadataGenerator = require('../utils/generators/abmetadataGenerator')\n\nclass AbsMetadataFileScanner {\n  constructor() { }\n\n  /**\n   * Check for metadata.json file and set book metadata\n   * \n   * @param {import('./LibraryScan')} libraryScan \n   * @param {import('./LibraryItemScanData')} libraryItemData \n   * @param {Object} bookMetadata \n   * @param {string} [existingLibraryItemId] \n   */\n  async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {\n    const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile\n    let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null\n    let metadataFilePath = metadataLibraryFile?.metadata.path\n\n    // When metadata file is not stored with library item then check in the /metadata/items folder for it\n    if (!metadataText && existingLibraryItemId) {\n      let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId)\n\n      metadataFilePath = Path.join(metadataPath, 'metadata.json')\n      if (await fsExtra.pathExists(metadataFilePath)) {\n        metadataText = await readTextFile(metadataFilePath)\n      }\n    }\n\n    if (metadataText) {\n      libraryScan.addLog(LogLevel.INFO, `Found metadata file \"${metadataFilePath}\"`)\n      const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}\n      for (const key in abMetadata) {\n        // TODO: When to override with null or empty arrays?\n        if (abMetadata[key] === undefined || abMetadata[key] === null) continue\n        if (key === 'authors' && !abMetadata.authors?.length) continue\n        if (key === 'genres' && !abMetadata.genres?.length) continue\n        if (key === 'tags' && !abMetadata.tags?.length) continue\n        if (key === 'chapters' && !abMetadata.chapters?.length) continue\n\n        bookMetadata[key] = abMetadata[key]\n      }\n    }\n  }\n\n  /**\n   * Check for metadata.json file and set podcast metadata\n   * \n   * @param {import('./LibraryScan')} libraryScan \n   * @param {import('./LibraryItemScanData')} libraryItemData \n   * @param {Object} podcastMetadata \n   * @param {string} [existingLibraryItemId] \n   */\n  async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {\n    const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile\n    let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null\n    let metadataFilePath = metadataLibraryFile?.metadata.path\n\n    // When metadata file is not stored with library item then check in the /metadata/items folder for it\n    if (!metadataText && existingLibraryItemId) {\n      let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId)\n\n      metadataFilePath = Path.join(metadataPath, 'metadata.json')\n      if (await fsExtra.pathExists(metadataFilePath)) {\n        metadataText = await readTextFile(metadataFilePath)\n      }\n    }\n\n    if (metadataText) {\n      libraryScan.addLog(LogLevel.INFO, `Found metadata file \"${metadataFilePath}\"`)\n      const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}\n      for (const key in abMetadata) {\n        if (abMetadata[key] === undefined || abMetadata[key] === null) continue\n        if (key === 'tags' && !abMetadata.tags?.length) continue\n\n        podcastMetadata[key] = abMetadata[key]\n      }\n    }\n  }\n}\nmodule.exports = new AbsMetadataFileScanner()"
  },
  {
    "path": "server/scanner/AudioFileScanner.js",
    "content": "const Path = require('path')\nconst Logger = require('../Logger')\nconst prober = require('../utils/prober')\nconst { LogLevel } = require('../utils/constants')\nconst { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers')\nconst parseNameString = require('../utils/parsers/parseNameString')\nconst parseSeriesString = require('../utils/parsers/parseSeriesString')\nconst LibraryItem = require('../models/LibraryItem')\nconst AudioFile = require('../objects/files/AudioFile')\n\nclass AudioFileScanner {\n  constructor() {}\n\n  /**\n   * Is array of numbers sequential, i.e. 1, 2, 3, 4\n   * @param {number[]} nums\n   * @returns {boolean}\n   */\n  isSequential(nums) {\n    if (!nums?.length) return false\n    if (nums.length === 1) return true\n    let prev = nums[0]\n    for (let i = 1; i < nums.length; i++) {\n      if (nums[i] - prev > 1) return false\n      prev = nums[i]\n    }\n    return true\n  }\n\n  /**\n   * Remove\n   * @param {number[]} nums\n   * @returns {number[]}\n   */\n  removeDupes(nums) {\n    if (!nums || !nums.length) return []\n    if (nums.length === 1) return nums\n\n    let nodupes = [nums[0]]\n    nums.forEach((num) => {\n      if (num > nodupes[nodupes.length - 1]) nodupes.push(num)\n    })\n    return nodupes\n  }\n\n  /**\n   * Order audio files by track/disc number\n   * @param {string} libraryItemRelPath\n   * @param {import('../models/Book').AudioFileObject[]} audioFiles\n   * @returns {import('../models/Book').AudioFileObject[]}\n   */\n  runSmartTrackOrder(libraryItemRelPath, audioFiles) {\n    if (!audioFiles.length) return []\n\n    let discsFromFilename = []\n    let tracksFromFilename = []\n    let discsFromMeta = []\n    let tracksFromMeta = []\n\n    audioFiles.forEach((af) => {\n      if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename)\n      if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)\n      if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)\n      if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)\n    })\n    discsFromFilename.sort((a, b) => a - b)\n    discsFromMeta.sort((a, b) => a - b)\n    tracksFromFilename.sort((a, b) => a - b)\n    tracksFromMeta.sort((a, b) => a - b)\n\n    let discKey = null\n    if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) {\n      discKey = 'discNumFromMeta'\n    } else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) {\n      discKey = 'discNumFromFilename'\n    }\n\n    let trackKey = null\n    tracksFromFilename = this.removeDupes(tracksFromFilename)\n    tracksFromMeta = this.removeDupes(tracksFromMeta)\n    if (tracksFromFilename.length > tracksFromMeta.length) {\n      trackKey = 'trackNumFromFilename'\n    } else {\n      trackKey = 'trackNumFromMeta'\n    }\n\n    if (discKey !== null) {\n      Logger.debug(`[AudioFileScanner] Smart track order for \"${libraryItemRelPath}\" using disc key ${discKey} and track key ${trackKey}`)\n      audioFiles.sort((a, b) => {\n        let Dx = a[discKey] - b[discKey]\n        if (Dx === 0) Dx = a[trackKey] - b[trackKey]\n        return Dx\n      })\n    } else {\n      Logger.debug(`[AudioFileScanner] Smart track order for \"${libraryItemRelPath}\" using track key ${trackKey}`)\n      audioFiles.sort((a, b) => a[trackKey] - b[trackKey])\n    }\n\n    for (let i = 0; i < audioFiles.length; i++) {\n      audioFiles[i].index = i + 1\n    }\n    return audioFiles\n  }\n\n  /**\n   * Get track and disc number from audio filename\n   * @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan\n   * @param {LibraryItem.LibraryFileObject} audioLibraryFile\n   * @returns {{trackNumber:number, discNumber:number}}\n   */\n  getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {\n    const { title, author, series, publishedYear } = mediaMetadataFromScan\n    const { filename, path } = audioLibraryFile.metadata\n    let partbasename = Path.basename(filename, Path.extname(filename))\n\n    // Remove title, author, series, and publishedYear from filename if there\n    if (title) partbasename = partbasename.replace(title, '')\n    if (author) partbasename = partbasename.replace(author, '')\n    if (series) partbasename = partbasename.replace(series, '')\n    if (publishedYear) partbasename = partbasename.replace(publishedYear)\n\n    // Look for disc number\n    let discNumber = null\n    const discMatch = partbasename.match(/\\b(disc|cd) ?(\\d\\d?)\\b/i)\n    if (discMatch && discMatch.length > 2 && discMatch[2]) {\n      if (!isNaN(discMatch[2])) {\n        discNumber = Number(discMatch[2])\n      }\n\n      // Remove disc number from filename\n      partbasename = partbasename.replace(/\\b(disc|cd) ?(\\d\\d?)\\b/i, '')\n    }\n\n    // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3\n    const pathdir = Path.dirname(path).split('/').pop()\n    if (pathdir && /^(cd|dis[ck])\\s*\\d{1,3}$/i.test(pathdir)) {\n      const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\\s*/i, ''))\n      if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder\n    }\n\n    const numbersinpath = partbasename.match(/\\d{1,4}/g)\n    const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null\n    return {\n      trackNumber,\n      discNumber\n    }\n  }\n\n  /**\n   *\n   * @param {string} mediaType\n   * @param {LibraryItem.LibraryFileObject} libraryFile\n   * @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan\n   * @returns {Promise<AudioFile>}\n   */\n  async scan(mediaType, libraryFile, mediaMetadataFromScan) {\n    const probeData = await prober.probe(libraryFile.metadata.path)\n\n    if (probeData.error) {\n      Logger.error(`[AudioFileScanner] ${probeData.error} : \"${libraryFile.metadata.path}\"`)\n      return null\n    }\n\n    if (!probeData.audioStream) {\n      Logger.error('[AudioFileScanner] Invalid audio file no audio stream')\n      return null\n    }\n\n    const audioFile = new AudioFile()\n    audioFile.trackNumFromMeta = probeData.audioMetaTags.trackNumber\n    audioFile.discNumFromMeta = probeData.audioMetaTags.discNumber\n    if (mediaType === 'book') {\n      const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile)\n      audioFile.trackNumFromFilename = trackNumber\n      audioFile.discNumFromFilename = discNumber\n    }\n    audioFile.setDataFromProbe(libraryFile, probeData)\n\n    return audioFile\n  }\n\n  /**\n   * Scan LibraryFiles and return AudioFiles\n   * @param {string} mediaType\n   * @param {import('./LibraryItemScanData')} libraryItemScanData\n   * @param {LibraryItem.LibraryFileObject[]} audioLibraryFiles\n   * @returns {Promise<AudioFile[]>}\n   */\n  async executeMediaFileScans(mediaType, libraryItemScanData, audioLibraryFiles) {\n    const batchSize = 32\n    const results = []\n    for (let batch = 0; batch < audioLibraryFiles.length; batch += batchSize) {\n      const proms = []\n      for (let i = batch; i < Math.min(batch + batchSize, audioLibraryFiles.length); i++) {\n        proms.push(this.scan(mediaType, audioLibraryFiles[i], libraryItemScanData.mediaMetadata))\n      }\n      results.push(...(await Promise.all(proms).then((scanResults) => scanResults.filter((sr) => sr))))\n    }\n\n    return results\n  }\n\n  /**\n   *\n   * @param {string} audioFilePath\n   * @returns {object}\n   */\n  probeAudioFile(audioFilePath) {\n    Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at \"${audioFilePath}\"`)\n    return prober.rawProbe(audioFilePath)\n  }\n\n  /**\n   * Set book metadata & chapters from audio file meta tags\n   *\n   * @param {string} bookTitle\n   * @param {import('../models/Book').AudioFileObject} audioFile\n   * @param {Object} bookMetadata\n   * @param {import('./LibraryScan')} libraryScan\n   */\n  setBookMetadataFromAudioMetaTags(bookTitle, audioFiles, bookMetadata, libraryScan) {\n    const MetadataMapArray = [\n      {\n        tag: 'tagComposer',\n        key: 'narrators'\n      },\n      {\n        tag: 'tagDescription',\n        altTag: 'tagComment',\n        key: 'description'\n      },\n      {\n        tag: 'tagPublisher',\n        key: 'publisher'\n      },\n      {\n        tag: 'tagDate',\n        key: 'publishedYear'\n      },\n      {\n        tag: 'tagSubtitle',\n        key: 'subtitle'\n      },\n      {\n        tag: 'tagAlbum',\n        altTag: 'tagTitle',\n        key: 'title'\n      },\n      {\n        tag: 'tagArtist',\n        altTag: 'tagAlbumArtist',\n        key: 'authors'\n      },\n      {\n        tag: 'tagGenre',\n        key: 'genres'\n      },\n      {\n        tag: 'tagSeries',\n        altTag: 'tagGrouping',\n        key: 'series'\n      },\n      {\n        tag: 'tagIsbn',\n        key: 'isbn'\n      },\n      {\n        tag: 'tagLanguage',\n        key: 'language'\n      },\n      {\n        tag: 'tagASIN',\n        key: 'asin'\n      }\n    ]\n\n    const firstScannedFile = audioFiles[0]\n    const audioFileMetaTags = firstScannedFile.metaTags\n    MetadataMapArray.forEach((mapping) => {\n      let value = audioFileMetaTags[mapping.tag]\n      let isAltTag = false\n      if (!value && mapping.altTag) {\n        value = audioFileMetaTags[mapping.altTag]\n        isAltTag = true\n      }\n\n      if (value && typeof value === 'string') {\n        value = value.trim() // Trim whitespace\n\n        if (mapping.key === 'narrators') {\n          bookMetadata.narrators = parseNameString.parse(value)?.names || []\n        } else if (mapping.key === 'authors') {\n          bookMetadata.authors = parseNameString.parse(value)?.names || []\n        } else if (mapping.key === 'genres') {\n          bookMetadata.genres = this.parseGenresString(value)\n        } else if (mapping.key === 'series') {\n          // If series was embedded in the grouping tag, then parse it with semicolon separator and sequence in the same string\n          // e.g. \"Test Series; Series Name #1; Other Series #2\"\n          if (isAltTag) {\n            const series = value\n              .split(';')\n              .map((seriesWithPart) => {\n                seriesWithPart = seriesWithPart.trim()\n                return parseSeriesString.parse(seriesWithPart)\n              })\n              .filter(Boolean)\n            if (series.length) {\n              bookMetadata.series = series\n            }\n          } else {\n            // Detect if multiple series are in the series & series-part tags.\n            // Note: This requires that every series has a sequence and that they are separated by a semicolon.\n            if (value.includes(';') && audioFileMetaTags.tagSeriesPart?.includes(';')) {\n              const seriesSplit = value\n                .split(';')\n                .map((s) => s.trim())\n                .filter(Boolean)\n              const seriesSequenceSplit = audioFileMetaTags.tagSeriesPart\n                .split(';')\n                .map((s) => s.trim())\n                .filter(Boolean)\n              if (seriesSplit.length > 1 && seriesSplit.length === seriesSequenceSplit.length) {\n                bookMetadata.series = seriesSplit.map((series, index) => ({\n                  name: series,\n                  sequence: seriesSequenceSplit[index] || null\n                }))\n                libraryScan.addLog(LogLevel.DEBUG, `Detected multiple series in series/series-part tags: ${bookMetadata.series.map((s) => `${s.name} #${s.sequence}`).join(', ')}`)\n                return\n              }\n            }\n\n            // Original embed used \"series\" and \"series-part\" tags\n            bookMetadata.series = [\n              {\n                name: value,\n                sequence: audioFileMetaTags.tagSeriesPart || null\n              }\n            ]\n          }\n        } else {\n          bookMetadata[mapping.key] = value\n        }\n      }\n    })\n\n    // Set chapters\n    const chapters = this.getBookChaptersFromAudioFiles(bookTitle, audioFiles, libraryScan)\n    if (chapters.length) {\n      bookMetadata.chapters = chapters\n    }\n  }\n\n  /**\n   * Set podcast metadata from first audio file\n   *\n   * @param {import('../models/Book').AudioFileObject} audioFile\n   * @param {Object} podcastMetadata\n   * @param {import('./LibraryScan')} libraryScan\n   */\n  setPodcastMetadataFromAudioMetaTags(audioFile, podcastMetadata, libraryScan) {\n    const audioFileMetaTags = audioFile.metaTags\n\n    const MetadataMapArray = [\n      {\n        tag: 'tagAlbum',\n        altTag: 'tagSeries',\n        key: 'title'\n      },\n      {\n        tag: 'tagAlbumArtist',\n        altTag: 'tagArtist',\n        key: 'author'\n      },\n      {\n        tag: 'tagGenre',\n        key: 'genres'\n      },\n      {\n        tag: 'tagLanguage',\n        key: 'language'\n      },\n      {\n        tag: 'tagItunesId',\n        key: 'itunesId'\n      },\n      {\n        tag: 'tagPodcastType',\n        key: 'podcastType'\n      }\n    ]\n\n    MetadataMapArray.forEach((mapping) => {\n      let value = audioFileMetaTags[mapping.tag]\n      let tagToUse = mapping.tag\n      if (!value && mapping.altTag) {\n        value = audioFileMetaTags[mapping.altTag]\n        tagToUse = mapping.altTag\n      }\n\n      if (value && typeof value === 'string') {\n        value = value.trim() // Trim whitespace\n\n        if (mapping.key === 'genres') {\n          podcastMetadata.genres = this.parseGenresString(value)\n          libraryScan.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastMetadata.genres.join(', ')}`)\n        } else {\n          podcastMetadata[mapping.key] = value\n          libraryScan.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastMetadata[mapping.key]}`)\n        }\n      }\n    })\n  }\n\n  /**\n   *\n   * @param {import('../models/PodcastEpisode')} podcastEpisode Not the model when creating new podcast\n   * @param {import('./ScanLogger')} scanLogger\n   */\n  setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, scanLogger) {\n    const MetadataMapArray = [\n      {\n        tag: 'tagComment',\n        altTag: 'tagDescription',\n        key: 'description'\n      },\n      {\n        tag: 'tagSubtitle',\n        key: 'subtitle'\n      },\n      {\n        tag: 'tagDate',\n        key: 'pubDate'\n      },\n      {\n        tag: 'tagDisc',\n        key: 'season'\n      },\n      {\n        tag: 'tagTrack',\n        altTag: 'tagSeriesPart',\n        key: 'episode'\n      },\n      {\n        tag: 'tagTitle',\n        key: 'title'\n      },\n      {\n        tag: 'tagEpisodeType',\n        key: 'episodeType'\n      }\n    ]\n\n    const audioFileMetaTags = podcastEpisode.audioFile.metaTags\n    MetadataMapArray.forEach((mapping) => {\n      let value = audioFileMetaTags[mapping.tag]\n      let tagToUse = mapping.tag\n      if (!value && mapping.altTag) {\n        tagToUse = mapping.altTag\n        value = audioFileMetaTags[mapping.altTag]\n      }\n\n      if (value && typeof value === 'string') {\n        value = value.trim() // Trim whitespace\n\n        if (mapping.key === 'pubDate') {\n          const pubJsDate = new Date(value)\n          if (pubJsDate && !isNaN(pubJsDate)) {\n            podcastEpisode.publishedAt = pubJsDate.valueOf()\n            podcastEpisode.pubDate = value\n            scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)\n          } else {\n            scanLogger.addLog(LogLevel.WARN, `Mapping pubDate with tag ${tagToUse} has invalid date \"${value}\"`)\n          }\n        } else if (mapping.key === 'episodeType') {\n          if (['full', 'trailer', 'bonus'].includes(value)) {\n            podcastEpisode.episodeType = value\n            scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)\n          } else {\n            scanLogger.addLog(LogLevel.WARN, `Mapping episodeType with invalid value \"${value}\". Must be one of [full, trailer, bonus].`)\n          }\n        } else {\n          podcastEpisode[mapping.key] = value\n          scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)\n        }\n      }\n    })\n  }\n\n  /**\n   * @param {string} bookTitle\n   * @param {AudioFile[]} audioFiles\n   * @param {import('./LibraryScan')} libraryScan\n   * @returns {import('../models/Book').ChapterObject[]}\n   */\n  getBookChaptersFromAudioFiles(bookTitle, audioFiles, libraryScan) {\n    // If overdrive media markers are present then use those instead\n    const overdriveChapters = parseOverdriveMediaMarkersAsChapters(audioFiles)\n    if (overdriveChapters?.length) {\n      libraryScan.addLog(LogLevel.DEBUG, 'Overdrive Media Markers and preference found! Using these for chapter definitions')\n\n      return overdriveChapters\n    }\n\n    let chapters = []\n\n    // If first audio file has embedded chapters then use embedded chapters\n    if (audioFiles[0].chapters?.length) {\n      // If all files chapters are the same, then only make chapters for the first file\n      if (audioFiles.length === 1 || (audioFiles.length > 1 && audioFiles[0].chapters.length === audioFiles[1].chapters?.length && audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title && c.start === audioFiles[1].chapters[i].start))) {\n        libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`)\n        chapters = audioFiles[0].chapters.map((c) => ({ ...c }))\n      } else {\n        libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters from all audio files ${audioFiles[0].metadata?.path}`)\n        let currChapterId = 0\n        let currStartTime = 0\n\n        audioFiles.forEach((file) => {\n          if (file.duration) {\n            // Multi-file audiobook may include the previous and next chapters embedded with close to 0 duration\n            // Filter these out and log a warning\n            // See https://github.com/advplyr/audiobookshelf/issues/3361\n            const afChaptersCleaned =\n              file.chapters?.filter((c, i) => {\n                if (c.end - c.start < 0.1) {\n                  libraryScan.addLog(LogLevel.WARN, `Audio file \"${file.metadata.filename}\" Chapter \"${c.title}\" (index ${i}) has invalid duration of ${c.end - c.start} seconds. Skipping this chapter.`)\n                  return false\n                }\n                return true\n              }) || []\n\n            const afChapters = afChaptersCleaned.map((c, i) => ({\n              ...c,\n              id: currChapterId + i,\n              start: c.start + currStartTime,\n              end: c.end + currStartTime\n            }))\n            chapters = chapters.concat(afChapters)\n\n            currChapterId += afChaptersCleaned.length ?? 0\n            currStartTime += file.duration\n          }\n        })\n        return chapters\n      }\n    } else if (audioFiles.length > 1) {\n      // In some cases the ID3 title tag for each file is the chapter title, the criteria to determine if this will be used\n      // 1. Every audio file has an ID3 title tag set\n      // 2. None of the title tags are the same as the book title\n      // 3. Every ID3 title tag is unique\n      const metaTagTitlesFound = [...new Set(audioFiles.map((af) => af.metaTags?.tagTitle).filter((tagTitle) => !!tagTitle && tagTitle !== bookTitle))]\n      const useMetaTagAsTitle = metaTagTitlesFound.length === audioFiles.length\n\n      // Build chapters from audio files\n      let currChapterId = 0\n      let currStartTime = 0\n      audioFiles.forEach((file) => {\n        if (file.duration) {\n          let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`\n          if (useMetaTagAsTitle) {\n            title = file.metaTags.tagTitle\n          }\n\n          chapters.push({\n            id: currChapterId++,\n            start: currStartTime,\n            end: currStartTime + file.duration,\n            title\n          })\n          currStartTime += file.duration\n        }\n      })\n    }\n    return chapters\n  }\n\n  /**\n   * Parse a genre string into multiple genres\n   * @example \"Fantasy;Sci-Fi;History\" => [\"Fantasy\", \"Sci-Fi\", \"History\"]\n   *\n   * @param {string} genreTag\n   * @returns {string[]}\n   */\n  parseGenresString(genreTag) {\n    if (!genreTag?.length) return []\n    const separators = ['/', '//', ';']\n    for (let i = 0; i < separators.length; i++) {\n      if (genreTag.includes(separators[i])) {\n        return genreTag\n          .split(separators[i])\n          .map((genre) => genre.trim())\n          .filter((g) => !!g)\n      }\n    }\n    return [genreTag]\n  }\n}\nmodule.exports = new AudioFileScanner()\n"
  },
  {
    "path": "server/scanner/BookScanner.js",
    "content": "const uuidv4 = require('uuid').v4\nconst Path = require('path')\nconst sequelize = require('sequelize')\nconst { LogLevel } = require('../utils/constants')\nconst { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')\nconst parseNameString = require('../utils/parsers/parseNameString')\nconst parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')\nconst globals = require('../utils/globals')\nconst { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')\n\nconst AudioFileScanner = require('./AudioFileScanner')\nconst Database = require('../Database')\nconst SocketAuthority = require('../SocketAuthority')\nconst BookFinder = require('../finders/BookFinder')\nconst fsExtra = require('../libs/fsExtra')\nconst EBookFile = require('../objects/files/EBookFile')\nconst AudioFile = require('../objects/files/AudioFile')\nconst LibraryFile = require('../objects/files/LibraryFile')\n\nconst RssFeedManager = require('../managers/RssFeedManager')\nconst CoverManager = require('../managers/CoverManager')\n\nconst LibraryScan = require('./LibraryScan')\nconst OpfFileScanner = require('./OpfFileScanner')\nconst NfoFileScanner = require('./NfoFileScanner')\nconst AbsMetadataFileScanner = require('./AbsMetadataFileScanner')\n\n/**\n * Metadata for books pulled from files\n * @typedef BookMetadataObject\n * @property {string} title\n * @property {string} titleIgnorePrefix\n * @property {string} subtitle\n * @property {string} publishedYear\n * @property {string} publisher\n * @property {string} description\n * @property {string} isbn\n * @property {string} asin\n * @property {string} language\n * @property {string[]} narrators\n * @property {string[]} genres\n * @property {string[]} tags\n * @property {string[]} authors\n * @property {{name:string, sequence:string}[]} series\n * @property {{id:number, start:number, end:number, title:string}[]} chapters\n * @property {boolean} explicit\n * @property {boolean} abridged\n * @property {string} coverPath\n */\n\nclass BookScanner {\n  constructor() {}\n\n  /**\n   * @param {import('../models/LibraryItem')} existingLibraryItem\n   * @param {import('./LibraryItemScanData')} libraryItemData\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}\n   */\n  async rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {\n    /** @type {import('../models/Book')} */\n    const media = await existingLibraryItem.getMedia({\n      include: [\n        {\n          model: Database.authorModel,\n          through: {\n            attributes: ['id', 'createdAt']\n          }\n        },\n        {\n          model: Database.seriesModel,\n          through: {\n            attributes: ['id', 'sequence', 'createdAt']\n          }\n        }\n      ],\n      order: [\n        [Database.authorModel, Database.bookAuthorModel, 'createdAt', 'ASC'],\n        [Database.seriesModel, 'bookSeries', 'createdAt', 'ASC']\n      ]\n    })\n\n    let hasMediaChanges = libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== media.audioFiles.length\n    if (hasMediaChanges) {\n      // Filter out audio files that were removed\n      media.audioFiles = media.audioFiles.filter((af) => !libraryItemData.checkAudioFileRemoved(af))\n\n      // Update audio files that were modified\n      if (libraryItemData.audioLibraryFilesModified.length) {\n        let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(\n          existingLibraryItem.mediaType,\n          libraryItemData,\n          libraryItemData.audioLibraryFilesModified.map((lf) => lf.new)\n        )\n        media.audioFiles = media.audioFiles.map((audioFileObj) => {\n          let matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.metadata.path === audioFileObj.metadata.path)\n          if (!matchedScannedAudioFile) {\n            matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === audioFileObj.ino)\n          }\n\n          if (matchedScannedAudioFile) {\n            scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile)\n            const audioFile = new AudioFile(audioFileObj)\n            audioFile.updateFromScan(matchedScannedAudioFile)\n            return audioFile.toJSON()\n          }\n          return audioFileObj\n        })\n        // Modified audio files that were not found on the book\n        if (scannedAudioFiles.length) {\n          media.audioFiles.push(...scannedAudioFiles)\n        }\n      }\n\n      // Add new audio files scanned in\n      if (libraryItemData.audioLibraryFilesAdded.length) {\n        const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded)\n        media.audioFiles.push(...scannedAudioFiles)\n      }\n\n      // Add audio library files that are not already set on the book (safety check)\n      let audioLibraryFilesToAdd = []\n      for (const audioLibraryFile of libraryItemData.audioLibraryFiles) {\n        if (!media.audioFiles.some((af) => af.ino === audioLibraryFile.ino)) {\n          libraryScan.addLog(LogLevel.DEBUG, `Existing audio library file \"${audioLibraryFile.metadata.relPath}\" was not set on book \"${media.title}\" so setting it now`)\n\n          audioLibraryFilesToAdd.push(audioLibraryFile)\n        }\n      }\n      if (audioLibraryFilesToAdd.length) {\n        const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, audioLibraryFilesToAdd)\n        media.audioFiles.push(...scannedAudioFiles)\n      }\n\n      media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles)\n\n      media.duration = 0\n      media.audioFiles.forEach((af) => {\n        if (!isNaN(af.duration)) {\n          media.duration += af.duration\n        }\n      })\n\n      media.changed('audioFiles', true)\n    }\n\n    // Check if cover was removed\n    if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some((lf) => lf.metadata.path === media.coverPath) && !(await fsExtra.pathExists(media.coverPath))) {\n      media.coverPath = null\n      hasMediaChanges = true\n    }\n\n    // Update cover if it was modified\n    if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) {\n      let coverMatch = libraryItemData.imageLibraryFilesModified.find((iFile) => iFile.old.metadata.path === media.coverPath)\n      if (coverMatch) {\n        const coverPath = coverMatch.new.metadata.path\n        if (coverPath !== media.coverPath) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book cover \"${media.coverPath}\" => \"${coverPath}\" for book \"${media.title}\"`)\n          media.coverPath = coverPath\n          media.changed('coverPath', true)\n          hasMediaChanges = true\n        }\n      }\n    }\n\n    // Check if cover is not set and image files were found\n    if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {\n      // Prefer using a cover image with the name \"cover\" otherwise use the first image\n      const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\\/cover\\.[^.\\/]*$/.test(iFile.metadata.path))\n      media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path\n      hasMediaChanges = true\n    }\n\n    // Check if ebook was removed\n    if (media.ebookFile && (librarySettings.audiobooksOnly || libraryItemData.checkEbookFileRemoved(media.ebookFile))) {\n      media.ebookFile = null\n      hasMediaChanges = true\n    }\n\n    // Update ebook if it was modified\n    if (media.ebookFile && libraryItemData.ebookLibraryFilesModified.length) {\n      let ebookMatch = libraryItemData.ebookLibraryFilesModified.find((eFile) => eFile.old.metadata.path === media.ebookFile.metadata.path)\n      if (ebookMatch) {\n        const ebookFile = new EBookFile(ebookMatch.new)\n        ebookFile.ebookFormat = ebookFile.metadata.ext.slice(1).toLowerCase()\n        libraryScan.addLog(LogLevel.DEBUG, `Updating book ebook file \"${media.ebookFile.metadata.path}\" => \"${ebookFile.metadata.path}\" for book \"${media.title}\"`)\n        media.ebookFile = ebookFile.toJSON()\n        media.changed('ebookFile', true)\n        hasMediaChanges = true\n      }\n    }\n\n    // Check if ebook is not set and ebooks were found\n    if (!media.ebookFile && !librarySettings.audiobooksOnly && libraryItemData.ebookLibraryFiles.length) {\n      // Prefer to use an epub ebook then fallback to the first ebook found\n      let ebookLibraryFile = libraryItemData.ebookLibraryFiles.find((lf) => lf.metadata.ext.slice(1).toLowerCase() === 'epub')\n      if (!ebookLibraryFile) ebookLibraryFile = libraryItemData.ebookLibraryFiles[0]\n      ebookLibraryFile = ebookLibraryFile.toJSON()\n      // Ebook file is the same as library file except for additional `ebookFormat`\n      ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()\n      media.ebookFile = ebookLibraryFile\n      media.changed('ebookFile', true)\n      hasMediaChanges = true\n    }\n\n    const ebookFileScanData = await parseEbookMetadata.parse(media.ebookFile)\n\n    const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id)\n    let authorsUpdated = false\n    const bookAuthorsRemoved = []\n    let seriesUpdated = false\n    const bookSeriesRemoved = []\n\n    for (const key in bookMetadata) {\n      // Ignore unset metadata and empty arrays\n      if (bookMetadata[key] === undefined || (Array.isArray(bookMetadata[key]) && !bookMetadata[key].length)) continue\n\n      if (key === 'authors') {\n        // Check for authors added\n        for (const authorName of bookMetadata.authors) {\n          if (!media.authors.some((au) => au.name === authorName)) {\n            const existingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)\n            if (existingAuthorId) {\n              await Database.bookAuthorModel.create({\n                bookId: media.id,\n                authorId: existingAuthorId\n              })\n              libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" added author \"${authorName}\"`)\n              authorsUpdated = true\n            } else {\n              const newAuthor = await Database.authorModel.create({\n                name: authorName,\n                lastFirst: Database.authorModel.getLastFirst(authorName),\n                libraryId: libraryItemData.libraryId\n              })\n              await media.addAuthor(newAuthor)\n              Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)\n              libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" added new author \"${authorName}\"`)\n              authorsUpdated = true\n            }\n          }\n        }\n        // Check for authors removed\n        for (const author of media.authors) {\n          if (!bookMetadata.authors.includes(author.name)) {\n            await author.bookAuthor.destroy()\n            libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" removed author \"${author.name}\"`)\n            authorsUpdated = true\n            bookAuthorsRemoved.push(author.id)\n          }\n        }\n      } else if (key === 'series') {\n        // Check for series added\n        for (const seriesObj of bookMetadata.series) {\n          const existingBookSeries = media.series.find((se) => se.name === seriesObj.name)\n          if (!existingBookSeries) {\n            const existingSeriesId = await Database.getSeriesIdByName(libraryItemData.libraryId, seriesObj.name)\n            if (existingSeriesId) {\n              await Database.bookSeriesModel.create({\n                bookId: media.id,\n                seriesId: existingSeriesId,\n                sequence: seriesObj.sequence\n              })\n              libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" added series \"${seriesObj.name}\"${seriesObj.sequence ? ` with sequence \"${seriesObj.sequence}\"` : ''}`)\n              seriesUpdated = true\n            } else {\n              const newSeries = await Database.seriesModel.create({\n                name: seriesObj.name,\n                nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),\n                libraryId: libraryItemData.libraryId\n              })\n              await media.addSeries(newSeries, { through: { sequence: seriesObj.sequence } })\n              Database.addSeriesToFilterData(libraryItemData.libraryId, newSeries.name, newSeries.id)\n              libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" added new series \"${seriesObj.name}\"${seriesObj.sequence ? ` with sequence \"${seriesObj.sequence}\"` : ''}`)\n              seriesUpdated = true\n            }\n          } else if (seriesObj.sequence && existingBookSeries.bookSeries.sequence !== seriesObj.sequence) {\n            libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" series \"${seriesObj.name}\" sequence \"${existingBookSeries.bookSeries.sequence || ''}\" => \"${seriesObj.sequence}\"`)\n            seriesUpdated = true\n            existingBookSeries.bookSeries.sequence = seriesObj.sequence\n            await existingBookSeries.bookSeries.save()\n          }\n        }\n        // Check for series removed\n        for (const series of media.series) {\n          if (!bookMetadata.series.some((se) => se.name === series.name)) {\n            await series.bookSeries.destroy()\n            libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" removed series \"${series.name}\"`)\n            seriesUpdated = true\n            bookSeriesRemoved.push(series.id)\n          }\n        }\n      } else if (key === 'genres') {\n        const existingGenres = media.genres || []\n        if (bookMetadata.genres.some((g) => !existingGenres.includes(g)) || existingGenres.some((g) => !bookMetadata.genres.includes(g))) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book genres \"${existingGenres.join(',')}\" => \"${bookMetadata.genres.join(',')}\" for book \"${bookMetadata.title}\"`)\n          media.genres = bookMetadata.genres\n          hasMediaChanges = true\n        }\n      } else if (key === 'tags') {\n        const existingTags = media.tags || []\n        if (bookMetadata.tags.some((t) => !existingTags.includes(t)) || existingTags.some((t) => !bookMetadata.tags.includes(t))) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book tags \"${existingTags.join(',')}\" => \"${bookMetadata.tags.join(',')}\" for book \"${bookMetadata.title}\"`)\n          media.tags = bookMetadata.tags\n          hasMediaChanges = true\n        }\n      } else if (key === 'narrators') {\n        const existingNarrators = media.narrators || []\n        if (bookMetadata.narrators.some((t) => !existingNarrators.includes(t)) || existingNarrators.some((t) => !bookMetadata.narrators.includes(t))) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book narrators \"${existingNarrators.join(',')}\" => \"${bookMetadata.narrators.join(',')}\" for book \"${bookMetadata.title}\"`)\n          media.narrators = bookMetadata.narrators\n          hasMediaChanges = true\n        }\n      } else if (key === 'chapters') {\n        if (!areEquivalent(media.chapters, bookMetadata.chapters)) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book chapters for book \"${bookMetadata.title}\"`)\n          media.chapters = bookMetadata.chapters\n          hasMediaChanges = true\n        }\n      } else if (key === 'coverPath') {\n        if (media.coverPath && media.coverPath !== bookMetadata.coverPath && !(await fsExtra.pathExists(media.coverPath))) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book cover \"${media.coverPath}\" => \"${bookMetadata.coverPath}\" for book \"${bookMetadata.title}\" - original cover path does not exist`)\n          media.coverPath = bookMetadata.coverPath\n          hasMediaChanges = true\n        } else if (!media.coverPath) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book cover \"unset\" => \"${bookMetadata.coverPath}\" for book \"${bookMetadata.title}\"`)\n          media.coverPath = bookMetadata.coverPath\n          hasMediaChanges = true\n        }\n      } else if (bookMetadata[key] !== media[key]) {\n        libraryScan.addLog(LogLevel.DEBUG, `Updating book ${key} \"${media[key]}\" => \"${bookMetadata[key]}\" for book \"${bookMetadata.title}\"`)\n        media[key] = bookMetadata[key]\n        hasMediaChanges = true\n      }\n    }\n\n    // Load authors/series again if updated (for sending back to client)\n    if (authorsUpdated) {\n      media.authors = await media.getAuthors({\n        joinTableAttributes: ['createdAt'],\n        order: [sequelize.literal(`bookAuthor.createdAt ASC`)]\n      })\n    }\n    if (seriesUpdated) {\n      media.series = await media.getSeries({\n        joinTableAttributes: ['sequence', 'createdAt'],\n        order: [sequelize.literal(`bookSeries.createdAt ASC`)]\n      })\n    }\n\n    // If no cover then extract cover from audio file OR from ebook\n    const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path\n    if (!media.coverPath) {\n      let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)\n      if (extractedCoverPath) {\n        libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" extracted embedded cover art from audio file to path \"${extractedCoverPath}\"`)\n        media.coverPath = extractedCoverPath\n        hasMediaChanges = true\n      } else if (ebookFileScanData?.ebookCoverPath) {\n        extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, existingLibraryItem.id, libraryItemDir)\n        if (extractedCoverPath) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating book \"${bookMetadata.title}\" extracted embedded cover art from ebook file to path \"${extractedCoverPath}\"`)\n          media.coverPath = extractedCoverPath\n          hasMediaChanges = true\n        }\n      }\n    }\n\n    // If no cover then search for cover if enabled in server settings\n    if (!media.coverPath && Database.serverSettings.scannerFindCovers) {\n      const authorName = media.authors\n        .map((au) => au.name)\n        .filter((au) => au)\n        .join(', ')\n      const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan)\n      if (coverPath) {\n        media.coverPath = coverPath\n        hasMediaChanges = true\n      }\n    }\n\n    existingLibraryItem.media = media\n\n    let libraryItemUpdated = false\n\n    // Save Book changes to db\n    if (hasMediaChanges) {\n      await media.save()\n      await this.saveMetadataFile(existingLibraryItem, libraryScan)\n      libraryItemUpdated = global.ServerSettings.storeMetadataWithItem && !existingLibraryItem.isFile\n    }\n\n    // If book has no audio files and no ebook then it is considered missing\n    if (!media.audioFiles.length && !media.ebookFile) {\n      if (!existingLibraryItem.isMissing) {\n        libraryScan.addLog(LogLevel.INFO, `Book \"${bookMetadata.title}\" has no audio files and no ebook file. Setting library item as missing`)\n        existingLibraryItem.isMissing = true\n        libraryItemUpdated = true\n      }\n    } else if (existingLibraryItem.isMissing) {\n      libraryScan.addLog(LogLevel.INFO, `Book \"${bookMetadata.title}\" was missing but now has media files. Setting library item as NOT missing`)\n      existingLibraryItem.isMissing = false\n      libraryItemUpdated = true\n    }\n\n    // Check/update the isSupplementary flag on libraryFiles for the LibraryItem\n    for (const libraryFile of existingLibraryItem.libraryFiles) {\n      if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) {\n        if (media.ebookFile && libraryFile.ino === media.ebookFile.ino) {\n          if (libraryFile.isSupplementary !== false) {\n            libraryFile.isSupplementary = false\n            libraryItemUpdated = true\n          }\n        } else if (libraryFile.isSupplementary !== true) {\n          libraryFile.isSupplementary = true\n          libraryItemUpdated = true\n        }\n      }\n    }\n    if (libraryItemUpdated) {\n      existingLibraryItem.changed('libraryFiles', true)\n      await existingLibraryItem.save()\n    }\n\n    libraryScan.seriesRemovedFromBooks.push(...bookSeriesRemoved)\n    libraryScan.authorsRemovedFromBooks.push(...bookAuthorsRemoved)\n\n    return {\n      libraryItem: existingLibraryItem,\n      wasUpdated: hasMediaChanges || libraryItemUpdated || seriesUpdated || authorsUpdated\n    }\n  }\n\n  /**\n   *\n   * @param {import('./LibraryItemScanData')} libraryItemData\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise<import('../models/LibraryItem')>}\n   */\n  async scanNewBookLibraryItem(libraryItemData, librarySettings, libraryScan) {\n    // Scan audio files found\n    let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(libraryItemData.mediaType, libraryItemData, libraryItemData.audioLibraryFiles)\n    scannedAudioFiles = AudioFileScanner.runSmartTrackOrder(libraryItemData.relPath, scannedAudioFiles)\n\n    // Find ebook file (prefer epub)\n    let ebookLibraryFile = librarySettings.audiobooksOnly ? null : libraryItemData.ebookLibraryFiles.find((lf) => lf.metadata.ext.slice(1).toLowerCase() === 'epub') || libraryItemData.ebookLibraryFiles[0]\n\n    // Do not add library items that have no valid audio files and no ebook file\n    if (!ebookLibraryFile && !scannedAudioFiles.length) {\n      libraryScan.addLog(LogLevel.WARN, `Library item at path \"${libraryItemData.relPath}\" has no audio files and no ebook file - ignoring`)\n      return null\n    }\n\n    let ebookFileScanData = null\n    if (ebookLibraryFile) {\n      ebookLibraryFile = ebookLibraryFile.toJSON()\n      ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()\n      ebookFileScanData = await parseEbookMetadata.parse(ebookLibraryFile)\n    }\n\n    const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings)\n    bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean\n    bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean\n\n    let duration = 0\n    scannedAudioFiles.forEach((af) => (duration += !isNaN(af.duration) ? Number(af.duration) : 0))\n    const bookObject = {\n      ...bookMetadata,\n      audioFiles: scannedAudioFiles,\n      ebookFile: ebookLibraryFile || null,\n      duration,\n      bookAuthors: [],\n      bookSeries: []\n    }\n\n    const createdAtTimestamp = new Date().getTime()\n    if (bookMetadata.authors.length) {\n      for (const authorName of bookMetadata.authors) {\n        const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)\n        if (matchingAuthorId) {\n          bookObject.bookAuthors.push({\n            authorId: matchingAuthorId\n          })\n        } else {\n          // New author\n          bookObject.bookAuthors.push({\n            // Ensures authors are in a set order\n            createdAt: createdAtTimestamp + bookObject.bookAuthors.length,\n            author: {\n              libraryId: libraryItemData.libraryId,\n              name: authorName,\n              lastFirst: Database.authorModel.getLastFirst(authorName)\n            }\n          })\n        }\n      }\n    }\n    if (bookMetadata.series.length) {\n      for (const seriesObj of bookMetadata.series) {\n        if (!seriesObj.name) continue\n        const matchingSeriesId = await Database.getSeriesIdByName(libraryItemData.libraryId, seriesObj.name)\n        if (matchingSeriesId) {\n          bookObject.bookSeries.push({\n            seriesId: matchingSeriesId,\n            sequence: seriesObj.sequence\n          })\n        } else {\n          bookObject.bookSeries.push({\n            sequence: seriesObj.sequence,\n            series: {\n              name: seriesObj.name,\n              nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),\n              libraryId: libraryItemData.libraryId\n            }\n          })\n        }\n      }\n    }\n\n    const libraryItemObj = libraryItemData.libraryItemObject\n    libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image\n    libraryItemObj.isMissing = false\n    libraryItemObj.isInvalid = false\n    libraryItemObj.extraData = {}\n    libraryItemObj.title = bookMetadata.title\n    libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)\n    libraryItemObj.authorNamesFirstLast = bookMetadata.authors.join(', ')\n    libraryItemObj.authorNamesLastFirst = bookMetadata.authors.map((author) => Database.authorModel.getLastFirst(author)).join(', ')\n\n    // Set isSupplementary flag on ebook library files\n    for (const libraryFile of libraryItemObj.libraryFiles) {\n      if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) {\n        libraryFile.isSupplementary = libraryFile.ino !== ebookLibraryFile?.ino\n      }\n    }\n\n    // If cover was not found in folder then check embedded covers in audio files OR ebook file\n    const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path\n    if (!bookObject.coverPath) {\n      let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)\n      if (extractedCoverPath) {\n        libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at \"${extractedCoverPath}\" for book \"${bookObject.title}\"`)\n        bookObject.coverPath = extractedCoverPath\n      } else if (ebookFileScanData?.ebookCoverPath) {\n        extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, libraryItemObj.id, libraryItemDir)\n        if (extractedCoverPath) {\n          libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from ebook file at \"${extractedCoverPath}\" for book \"${bookObject.title}\"`)\n          bookObject.coverPath = extractedCoverPath\n        }\n      }\n    }\n\n    // If cover not found then search for cover if enabled in settings\n    if (!bookObject.coverPath && Database.serverSettings.scannerFindCovers) {\n      const authorName = bookMetadata.authors.join(', ')\n      bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan)\n    }\n\n    libraryItemObj.book = bookObject\n    const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {\n      include: {\n        model: Database.bookModel,\n        include: [\n          {\n            model: Database.bookSeriesModel,\n            include: {\n              model: Database.seriesModel\n            }\n          },\n          {\n            model: Database.bookAuthorModel,\n            include: {\n              model: Database.authorModel\n            }\n          }\n        ]\n      }\n    })\n\n    // Update library filter data\n    if (libraryItem.book.bookSeries?.length) {\n      for (const bs of libraryItem.book.bookSeries) {\n        if (bs.series) {\n          Database.addSeriesToFilterData(libraryItemData.libraryId, bs.series.name, bs.series.id)\n        }\n      }\n    }\n    if (libraryItem.book.bookAuthors?.length) {\n      for (const ba of libraryItem.book.bookAuthors) {\n        if (ba.author) {\n          Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)\n        }\n      }\n    }\n    Database.addNarratorsToFilterData(libraryItemData.libraryId, libraryItem.book.narrators)\n    Database.addGenresToFilterData(libraryItemData.libraryId, libraryItem.book.genres)\n    Database.addTagsToFilterData(libraryItemData.libraryId, libraryItem.book.tags)\n    Database.addPublisherToFilterData(libraryItemData.libraryId, libraryItem.book.publisher)\n    Database.addLanguageToFilterData(libraryItemData.libraryId, libraryItem.book.language)\n\n    const publishedYear = libraryItem.book.publishedYear\n    const decade = publishedYear ? `${Math.floor(publishedYear / 10) * 10}` : null\n    Database.addPublishedDecadeToFilterData(libraryItemData.libraryId, decade)\n\n    // Load for emitting to client\n    libraryItem.media = await libraryItem.getMedia({\n      include: [\n        {\n          model: Database.authorModel,\n          through: {\n            attributes: ['id', 'createdAt']\n          }\n        },\n        {\n          model: Database.seriesModel,\n          through: {\n            attributes: ['id', 'sequence', 'createdAt']\n          }\n        }\n      ],\n      order: [\n        [Database.authorModel, Database.bookAuthorModel, 'createdAt', 'ASC'],\n        [Database.seriesModel, 'bookSeries', 'createdAt', 'ASC']\n      ]\n    })\n\n    await this.saveMetadataFile(libraryItem, libraryScan)\n    if (global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile) {\n      libraryItem.changed('libraryFiles', true)\n      await libraryItem.save()\n    }\n\n    return libraryItem\n  }\n\n  /**\n   *\n   * @param {import('../models/Book').AudioFileObject[]} audioFiles\n   * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData\n   * @param {import('./LibraryItemScanData')} libraryItemData\n   * @param {LibraryScan} libraryScan\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {string} [existingLibraryItemId]\n   * @returns {Promise<BookMetadataObject>}\n   */\n  async getBookMetadataFromScanData(audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) {\n    // First set book metadata from folder/file names\n    const bookMetadata = {\n      title: libraryItemData.mediaMetadata.title, // required\n      titleIgnorePrefix: undefined,\n      subtitle: undefined,\n      publishedYear: undefined,\n      publisher: undefined,\n      description: undefined,\n      isbn: undefined,\n      asin: undefined,\n      language: undefined,\n      narrators: [],\n      genres: [],\n      tags: [],\n      authors: [],\n      series: [],\n      chapters: [],\n      explicit: undefined,\n      abridged: undefined,\n      coverPath: undefined\n    }\n\n    const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId)\n    const metadataPrecedence = librarySettings.metadataPrecedence || Database.libraryModel.defaultMetadataPrecedence\n    libraryScan.addLog(LogLevel.DEBUG, `\"${bookMetadata.title}\" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`)\n    for (const metadataSource of metadataPrecedence) {\n      if (bookMetadataSourceHandler[metadataSource]) {\n        await bookMetadataSourceHandler[metadataSource]()\n      } else {\n        libraryScan.addLog(LogLevel.ERROR, `Invalid metadata source \"${metadataSource}\"`)\n      }\n    }\n\n    // Set cover from library file if one is found otherwise check audiofile\n    if (libraryItemData.imageLibraryFiles.length) {\n      const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\\/cover\\.[^.\\/]*$/.test(iFile.metadata.path))\n      bookMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path\n    }\n\n    bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)\n\n    return bookMetadata\n  }\n\n  static BookMetadataSourceHandler = class {\n    /**\n     *\n     * @param {Object} bookMetadata\n     * @param {import('../models/Book').AudioFileObject[]} audioFiles\n     * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData\n     * @param {import('./LibraryItemScanData')} libraryItemData\n     * @param {LibraryScan} libraryScan\n     * @param {string} existingLibraryItemId\n     */\n    constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) {\n      this.bookMetadata = bookMetadata\n      this.audioFiles = audioFiles\n      this.ebookFileScanData = ebookFileScanData\n      this.libraryItemData = libraryItemData\n      this.libraryScan = libraryScan\n      this.existingLibraryItemId = existingLibraryItemId\n    }\n\n    /**\n     * Metadata parsed from folder names/structure\n     */\n    folderStructure() {\n      this.libraryItemData.setBookMetadataFromFilenames(this.bookMetadata)\n    }\n\n    /**\n     * Metadata from audio file meta tags OR metadata from ebook file\n     */\n    audioMetatags() {\n      if (this.audioFiles.length) {\n        // Modifies bookMetadata with metadata mapped from audio file meta tags\n        const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title\n        AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)\n      } else if (this.ebookFileScanData) {\n        const ebookMetdataObject = this.ebookFileScanData.metadata || {}\n        for (const key in ebookMetdataObject) {\n          if (key === 'tags') {\n            if (ebookMetdataObject.tags.length) {\n              this.bookMetadata.tags = ebookMetdataObject.tags\n            }\n          } else if (key === 'genres') {\n            if (ebookMetdataObject.genres.length) {\n              this.bookMetadata.genres = ebookMetdataObject.genres\n            }\n          } else if (key === 'authors') {\n            if (ebookMetdataObject.authors?.length) {\n              this.bookMetadata.authors = ebookMetdataObject.authors\n            }\n          } else if (key === 'narrators') {\n            if (ebookMetdataObject.narrators?.length) {\n              this.bookMetadata.narrators = ebookMetdataObject.narrators\n            }\n          } else if (key === 'series') {\n            if (ebookMetdataObject.series?.length) {\n              this.bookMetadata.series = ebookMetdataObject.series\n            }\n          } else if (ebookMetdataObject[key] && key !== 'sequence') {\n            this.bookMetadata[key] = ebookMetdataObject[key]\n          }\n        }\n      }\n      return null\n    }\n\n    /**\n     * Metadata from .nfo file\n     */\n    async nfoFile() {\n      if (!this.libraryItemData.metadataNfoLibraryFile) return\n      await NfoFileScanner.scanBookNfoFile(this.libraryItemData.metadataNfoLibraryFile, this.bookMetadata)\n    }\n\n    /**\n     * Description from desc.txt and narrator from reader.txt\n     */\n    async txtFiles() {\n      // If desc.txt in library item folder then use this for description\n      if (this.libraryItemData.descTxtLibraryFile) {\n        const description = await readTextFile(this.libraryItemData.descTxtLibraryFile.metadata.path)\n        if (description.trim()) this.bookMetadata.description = description.trim()\n      }\n\n      // If reader.txt in library item folder then use this for narrator\n      if (this.libraryItemData.readerTxtLibraryFile) {\n        let narrator = await readTextFile(this.libraryItemData.readerTxtLibraryFile.metadata.path)\n        narrator = narrator.split(/\\r?\\n/)[0]?.trim() || '' // Only use first line\n        if (narrator) {\n          this.bookMetadata.narrators = parseNameString.parse(narrator)?.names || []\n        }\n      }\n    }\n\n    /**\n     * Metadata from opf file\n     */\n    async opfFile() {\n      if (!this.libraryItemData.metadataOpfLibraryFile) return\n      await OpfFileScanner.scanBookOpfFile(this.libraryItemData.metadataOpfLibraryFile, this.bookMetadata)\n    }\n\n    /**\n     * Metadata from metadata.json\n     */\n    async absMetadata() {\n      // If metadata.json use this for metadata\n      await AbsMetadataFileScanner.scanBookMetadataFile(this.libraryScan, this.libraryItemData, this.bookMetadata, this.existingLibraryItemId)\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise}\n   */\n  async saveMetadataFile(libraryItem, libraryScan) {\n    let metadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)\n    let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem\n    if (storeMetadataWithItem && !libraryItem.isFile) {\n      metadataPath = libraryItem.path\n    } else {\n      // Make sure metadata book dir exists\n      storeMetadataWithItem = false\n      await fsExtra.ensureDir(metadataPath)\n    }\n\n    const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)\n\n    const jsonObject = {\n      tags: libraryItem.media.tags || [],\n      chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],\n      title: libraryItem.media.title,\n      subtitle: libraryItem.media.subtitle,\n      authors: libraryItem.media.authors.map((a) => a.name),\n      narrators: libraryItem.media.narrators,\n      series: libraryItem.media.series.map((se) => {\n        const sequence = se.bookSeries?.sequence || ''\n        if (!sequence) return se.name\n        return `${se.name} #${sequence}`\n      }),\n      genres: libraryItem.media.genres || [],\n      publishedYear: libraryItem.media.publishedYear,\n      publishedDate: libraryItem.media.publishedDate,\n      publisher: libraryItem.media.publisher,\n      description: libraryItem.media.description,\n      isbn: libraryItem.media.isbn,\n      asin: libraryItem.media.asin,\n      language: libraryItem.media.language,\n      explicit: !!libraryItem.media.explicit,\n      abridged: !!libraryItem.media.abridged\n    }\n    return fsExtra\n      .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))\n      .then(async () => {\n        // Add metadata.json to libraryFiles array if it is new\n        let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))\n        if (storeMetadataWithItem) {\n          if (!metadataLibraryFile) {\n            const newLibraryFile = new LibraryFile()\n            await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)\n            metadataLibraryFile = newLibraryFile.toJSON()\n            libraryItem.libraryFiles.push(metadataLibraryFile)\n          } else {\n            const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)\n            if (fileTimestamps) {\n              metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs\n              metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs\n              metadataLibraryFile.metadata.size = fileTimestamps.size\n              metadataLibraryFile.ino = fileTimestamps.ino\n            }\n          }\n          const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)\n          if (libraryItemDirTimestamps) {\n            libraryItem.mtime = libraryItemDirTimestamps.mtimeMs\n            libraryItem.ctime = libraryItemDirTimestamps.ctimeMs\n            let size = 0\n            libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))\n            libraryItem.size = size\n          }\n        }\n\n        libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to \"${metadataFilePath}\"`)\n\n        return metadataLibraryFile\n      })\n      .catch((error) => {\n        libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at \"${metadataFilePath}\"`, error)\n        return null\n      })\n  }\n\n  /**\n   * Check authors that were removed from a book and remove them if they no longer have any books\n   * keep authors without books that have a asin, description or imagePath\n   * @param {string} libraryId\n   * @param {import('./ScanLogger')} scanLogger\n   * @returns {Promise}\n   */\n  async checkAuthorsRemovedFromBooks(libraryId, scanLogger) {\n    const bookAuthorsToRemove = (\n      await Database.authorModel.findAll({\n        where: [\n          {\n            id: scanLogger.authorsRemovedFromBooks,\n            asin: {\n              [sequelize.Op.or]: [null, '']\n            },\n            description: {\n              [sequelize.Op.or]: [null, '']\n            },\n            imagePath: {\n              [sequelize.Op.or]: [null, '']\n            }\n          },\n          sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)\n        ],\n        attributes: ['id'],\n        raw: true\n      })\n    ).map((au) => au.id)\n    if (bookAuthorsToRemove.length) {\n      await Database.authorModel.destroy({\n        where: {\n          id: bookAuthorsToRemove\n        }\n      })\n      bookAuthorsToRemove.forEach((authorId) => {\n        Database.removeAuthorFromFilterData(libraryId, authorId)\n        // TODO: Clients were expecting full author in payload but its unnecessary\n        SocketAuthority.emitter('author_removed', { id: authorId, libraryId })\n      })\n      scanLogger.addLog(LogLevel.INFO, `Removed ${bookAuthorsToRemove.length} authors`)\n    }\n  }\n\n  /**\n   * Check series that were removed from books and remove them if they no longer have any books\n   * @param {string} libraryId\n   * @param {import('./ScanLogger')} scanLogger\n   * @returns {Promise}\n   */\n  async checkSeriesRemovedFromBooks(libraryId, scanLogger) {\n    const bookSeriesToRemove = (\n      await Database.seriesModel.findAll({\n        where: [\n          {\n            id: scanLogger.seriesRemovedFromBooks\n          },\n          sequelize.where(sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 0)\n        ],\n        attributes: ['id'],\n        raw: true\n      })\n    ).map((se) => se.id)\n    if (bookSeriesToRemove.length) {\n      await Database.seriesModel.destroy({\n        where: {\n          id: bookSeriesToRemove\n        }\n      })\n      // Close any open feeds for series\n      await RssFeedManager.closeFeedsForEntityIds(bookSeriesToRemove)\n\n      bookSeriesToRemove.forEach((seriesId) => {\n        Database.removeSeriesFromFilterData(libraryId, seriesId)\n        SocketAuthority.emitter('series_removed', { id: seriesId, libraryId })\n      })\n      scanLogger.addLog(LogLevel.INFO, `Removed ${bookSeriesToRemove.length} series`)\n    }\n  }\n\n  /**\n   * Search cover provider for matching cover\n   * @param {string} libraryItemId\n   * @param {string} libraryItemPath null if book isFile\n   * @param {string} title\n   * @param {string} author\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise<string>} path to downloaded cover or null if no cover found\n   */\n  async searchForCover(libraryItemId, libraryItemPath, title, author, libraryScan) {\n    const options = {\n      titleDistance: 2,\n      authorDistance: 2\n    }\n    const results = await BookFinder.findCovers(Database.serverSettings.scannerCoverProvider, title, author, options)\n    if (results.length) {\n      libraryScan.addLog(LogLevel.DEBUG, `Found best cover for \"${title}\"`)\n\n      // If the first cover result fails, attempt to download the second\n      for (let i = 0; i < results.length && i < 2; i++) {\n        // Downloads and updates the book cover\n        const result = await CoverManager.downloadCoverFromUrlNew(results[i], libraryItemId, libraryItemPath)\n\n        if (result.error) {\n          libraryScan.addLog(LogLevel.ERROR, `Failed to download cover from url \"${results[i]}\" | Attempt ${i + 1}`, result.error)\n        } else if (result.cover) {\n          return result.cover\n        }\n      }\n    }\n    return null\n  }\n}\nmodule.exports = new BookScanner()\n"
  },
  {
    "path": "server/scanner/LibraryItemScanData.js",
    "content": "const packageJson = require('../../package.json')\nconst { LogLevel } = require('../utils/constants')\nconst LibraryItem = require('../models/LibraryItem')\nconst globals = require('../utils/globals')\n\nclass LibraryItemScanData {\n  /**\n   * @typedef LibraryFileModifiedObject\n   * @property {LibraryItem.LibraryFileObject} old\n   * @property {LibraryItem.LibraryFileObject} new\n   */\n\n  constructor(data) {\n    /** @type {string} */\n    this.libraryFolderId = data.libraryFolderId\n    /** @type {string} */\n    this.libraryId = data.libraryId\n    /** @type {string} */\n    this.mediaType = data.mediaType\n    /** @type {string} */\n    this.ino = data.ino\n    /** @type {number} */\n    this.mtimeMs = data.mtimeMs\n    /** @type {number} */\n    this.ctimeMs = data.ctimeMs\n    /** @type {number} */\n    this.birthtimeMs = data.birthtimeMs\n    /** @type {string} */\n    this.path = data.path\n    /** @type {string} */\n    this.relPath = data.relPath\n    /** @type {boolean} */\n    this.isFile = data.isFile\n    /** @type {import('../utils/scandir').LibraryItemFilenameMetadata} */\n    this.mediaMetadata = data.mediaMetadata\n    /** @type {import('../objects/files/LibraryFile')[]} */\n    this.libraryFiles = data.libraryFiles\n\n    // Set after check\n    /** @type {boolean} */\n    this.hasChanges\n    /** @type {boolean} */\n    this.hasPathChange\n    /** @type {LibraryItem.LibraryFileObject[]} */\n    this.libraryFilesRemoved = []\n    /** @type {LibraryItem.LibraryFileObject[]} */\n    this.libraryFilesAdded = []\n    /** @type {LibraryFileModifiedObject[]} */\n    this.libraryFilesModified = []\n  }\n\n  /**\n   * Used to create a library item\n   */\n  get libraryItemObject() {\n    let size = 0\n    this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))\n    return {\n      ino: this.ino,\n      path: this.path,\n      relPath: this.relPath,\n      mediaType: this.mediaType,\n      isFile: this.isFile,\n      mtime: this.mtimeMs,\n      ctime: this.ctimeMs,\n      birthtime: this.birthtimeMs,\n      lastScan: Date.now(),\n      lastScanVersion: packageJson.version,\n      libraryFiles: this.libraryFiles,\n      libraryId: this.libraryId,\n      libraryFolderId: this.libraryFolderId,\n      size\n    }\n  }\n\n  /** @type {boolean} */\n  get hasLibraryFileChanges() {\n    return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length\n  }\n\n  /** @type {boolean} */\n  get hasAudioFileChanges() {\n    return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0\n  }\n\n  /** @type {LibraryFileModifiedObject[]} */\n  get audioLibraryFilesModified() {\n    return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get audioLibraryFilesRemoved() {\n    return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get audioLibraryFilesAdded() {\n    return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get audioLibraryFiles() {\n    return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryFileModifiedObject[]} */\n  get imageLibraryFilesModified() {\n    return this.libraryFilesModified.filter(lf => globals.SupportedImageTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get imageLibraryFilesRemoved() {\n    return this.libraryFilesRemoved.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get imageLibraryFilesAdded() {\n    return this.libraryFilesAdded.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get imageLibraryFiles() {\n    return this.libraryFiles.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryFileModifiedObject[]} */\n  get ebookLibraryFilesModified() {\n    return this.libraryFilesModified.filter(lf => globals.SupportedEbookTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get ebookLibraryFilesRemoved() {\n    return this.libraryFilesRemoved.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get ebookLibraryFilesAdded() {\n    return this.libraryFilesAdded.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject[]} */\n  get ebookLibraryFiles() {\n    return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))\n  }\n\n  /** @type {LibraryItem.LibraryFileObject} */\n  get descTxtLibraryFile() {\n    return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt')\n  }\n\n  /** @type {LibraryItem.LibraryFileObject} */\n  get readerTxtLibraryFile() {\n    return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt')\n  }\n\n  /** @type {LibraryItem.LibraryFileObject} */\n  get metadataAbsLibraryFile() {\n    return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs')\n  }\n\n  /** @type {LibraryItem.LibraryFileObject} */\n  get metadataJsonLibraryFile() {\n    return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json')\n  }\n\n  /** @type {LibraryItem.LibraryFileObject} */\n  get metadataOpfLibraryFile() {\n    return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')\n  }\n\n  /** @type {LibraryItem.LibraryFileObject} */\n  get metadataNfoLibraryFile() {\n    return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo')\n  }\n\n  /**\n   * \n   * @param {LibraryItem} existingLibraryItem \n   * @param {import('./LibraryScan')} libraryScan\n   * @returns {boolean} true if changes found\n   */\n  async checkLibraryItemData(existingLibraryItem, libraryScan) {\n    const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']\n    this.hasChanges = false\n    this.hasPathChange = false\n    for (const key of keysToCompare) {\n      if (existingLibraryItem[key] !== this[key]) {\n        libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" key \"${key}\" changed from \"${existingLibraryItem[key]}\" to \"${this[key]}\"`)\n        existingLibraryItem[key] = this[key]\n        this.hasChanges = true\n\n        if (key === 'relPath' || key === 'path') {\n          this.hasPathChange = true\n        }\n      }\n    }\n\n    // Check mtime, ctime and birthtime\n    if (existingLibraryItem.mtime?.valueOf() !== this.mtimeMs) {\n      libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" key \"mtime\" changed from \"${existingLibraryItem.mtime?.valueOf()}\" to \"${this.mtimeMs}\"`)\n      existingLibraryItem.mtime = this.mtimeMs\n      this.hasChanges = true\n    }\n    if (existingLibraryItem.birthtime?.valueOf() !== this.birthtimeMs) {\n      libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" key \"birthtime\" changed from \"${existingLibraryItem.birthtime?.valueOf()}\" to \"${this.birthtimeMs}\"`)\n      existingLibraryItem.birthtime = this.birthtimeMs\n      this.hasChanges = true\n    }\n    if (existingLibraryItem.ctime?.valueOf() !== this.ctimeMs) {\n      libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" key \"ctime\" changed from \"${existingLibraryItem.ctime?.valueOf()}\" to \"${this.ctimeMs}\"`)\n      existingLibraryItem.ctime = this.ctimeMs\n      this.hasChanges = true\n    }\n    if (existingLibraryItem.isMissing) {\n      libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" was missing but now found`)\n      existingLibraryItem.isMissing = false\n      this.hasChanges = true\n    }\n\n    this.libraryFilesRemoved = []\n    this.libraryFilesModified = []\n    let libraryFilesAdded = this.libraryFiles.map(lf => lf)\n\n    for (const existingLibraryFile of existingLibraryItem.libraryFiles) {\n      // Find matching library file using path first and fallback to using inode value\n      let matchingLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === existingLibraryFile.metadata.path)\n      if (!matchingLibraryFile) {\n        matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino)\n        if (matchingLibraryFile) {\n          libraryScan.addLog(LogLevel.INFO, `Library file with path \"${existingLibraryFile.metadata.path}\" not found, but found file with matching inode value \"${existingLibraryFile.ino}\" at path \"${matchingLibraryFile.metadata.path}\"`)\n        }\n      }\n\n      if (!matchingLibraryFile) { // Library file removed\n        libraryScan.addLog(LogLevel.INFO, `Library file \"${existingLibraryFile.metadata.path}\" was removed from library item \"${existingLibraryItem.relPath}\"`)\n        this.libraryFilesRemoved.push(existingLibraryFile)\n        existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile)\n        this.hasChanges = true\n      } else {\n        libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile)\n        let existingLibraryFileBefore = structuredClone(existingLibraryFile)\n        if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {\n          this.libraryFilesModified.push({old: existingLibraryFileBefore, new: existingLibraryFile})\n          this.hasChanges = true\n        }\n      }\n    }\n\n    // Log new library files found\n    if (libraryFilesAdded.length) {\n      this.hasChanges = true\n      for (const libraryFile of libraryFilesAdded) {\n        libraryScan.addLog(LogLevel.INFO, `New library file found with path \"${libraryFile.metadata.path}\" for library item \"${existingLibraryItem.relPath}\"`)\n        if (libraryFile.isEBookFile) {\n          // Set all new ebook files as supplementary\n          libraryFile.isSupplementary = true\n        }\n        existingLibraryItem.libraryFiles.push(libraryFile.toJSON())\n      }\n    }\n\n    this.libraryFilesAdded = libraryFilesAdded\n\n    if (this.hasChanges) {\n      existingLibraryItem.size = 0\n      existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size)\n\n      existingLibraryItem.lastScan = Date.now()\n      existingLibraryItem.lastScanVersion = packageJson.version\n\n      libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`)\n\n      if (this.hasLibraryFileChanges) {\n        existingLibraryItem.changed('libraryFiles', true)\n      }\n      await existingLibraryItem.save()\n      return true\n    }\n\n    return false\n  }\n\n  /**\n   * Update existing library file with scanned in library file data\n   * @param {string} libraryItemPath\n   * @param {LibraryItem.LibraryFileObject} existingLibraryFile \n   * @param {import('../objects/files/LibraryFile')} scannedLibraryFile \n   * @param {import('./LibraryScan')} libraryScan\n   * @returns {boolean} false if no changes\n   */\n  compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {\n    let hasChanges = false\n\n    if (existingLibraryFile.ino !== scannedLibraryFile.ino) {\n      existingLibraryFile.ino = scannedLibraryFile.ino\n      hasChanges = true\n    }\n\n    for (const key in existingLibraryFile.metadata) {\n      if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) {\n        if (key !== 'path' && key !== 'relPath') {\n          libraryScan.addLog(LogLevel.DEBUG, `Library file \"${existingLibraryFile.metadata.relPath}\" for library item \"${libraryItemPath}\" key \"${key}\" changed from \"${existingLibraryFile.metadata[key]}\" to \"${scannedLibraryFile.metadata[key]}\"`)\n        } else {\n          libraryScan.addLog(LogLevel.DEBUG, `Library file for library item \"${libraryItemPath}\" key \"${key}\" changed from \"${existingLibraryFile.metadata[key]}\" to \"${scannedLibraryFile.metadata[key]}\"`)\n        }\n        existingLibraryFile.metadata[key] = scannedLibraryFile.metadata[key]\n        hasChanges = true\n      }\n    }\n\n    if (hasChanges) {\n      existingLibraryFile.updatedAt = Date.now()\n    }\n\n    return hasChanges\n  }\n\n  /**\n   * Check if existing audio file on Book was removed\n   * @param {import('../models/Book').AudioFileObject} existingAudioFile \n   * @returns {boolean} true if audio file was removed\n   */\n  checkAudioFileRemoved(existingAudioFile) {\n    if (!this.audioLibraryFilesRemoved.length) return false\n    // First check exact path\n    if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) {\n      return true\n    }\n    // Fallback to check inode value\n    return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino)\n  }\n\n  /**\n   * Check if existing ebook file on Book was removed\n   * @param {import('../models/Book').EBookFileObject} ebookFile \n   * @returns {boolean} true if ebook file was removed\n   */\n  checkEbookFileRemoved(ebookFile) {\n    if (!this.ebookLibraryFiles.length) return true\n\n    if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) {\n      return false\n    }\n\n    return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)\n  }\n\n  /**\n   * Set data parsed from filenames\n   * \n   * @param {Object} bookMetadata \n   */\n  setBookMetadataFromFilenames(bookMetadata) {\n    const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin']\n    for (const key in this.mediaMetadata) {\n      if (keysToMap.includes(key) && this.mediaMetadata[key]) {\n        bookMetadata[key] = this.mediaMetadata[key]\n      }\n    }\n\n    if (this.mediaMetadata.authors?.length) {\n      bookMetadata.authors = this.mediaMetadata.authors\n    }\n    if (this.mediaMetadata.narrators?.length) {\n      bookMetadata.narrators = this.mediaMetadata.narrators\n    }\n    if (this.mediaMetadata.seriesName) {\n      bookMetadata.series = [\n        {\n          name: this.mediaMetadata.seriesName,\n          sequence: this.mediaMetadata.seriesSequence || null\n        }\n      ]\n    }\n  }\n}\nmodule.exports = LibraryItemScanData"
  },
  {
    "path": "server/scanner/LibraryItemScanner.js",
    "content": "const Path = require('path')\nconst { LogLevel, ScanResult } = require('../utils/constants')\n\nconst fileUtils = require('../utils/fileUtils')\nconst scanUtils = require('../utils/scandir')\nconst libraryFilters = require('../utils/queries/libraryFilters')\nconst Logger = require('../Logger')\nconst Database = require('../Database')\nconst Watcher = require('../Watcher')\nconst LibraryScan = require('./LibraryScan')\nconst LibraryItemScanData = require('./LibraryItemScanData')\nconst BookScanner = require('./BookScanner')\nconst PodcastScanner = require('./PodcastScanner')\nconst ScanLogger = require('./ScanLogger')\nconst LibraryItem = require('../models/LibraryItem')\nconst LibraryFile = require('../objects/files/LibraryFile')\nconst SocketAuthority = require('../SocketAuthority')\n\nclass LibraryItemScanner {\n  constructor() {}\n\n  /**\n   * Scan single library item\n   *\n   * @param {string} libraryItemId\n   * @param {{relPath:string, path:string}} [updateLibraryItemDetails] used by watcher when item folder was renamed\n   * @returns {number} ScanResult\n   */\n  async scanLibraryItem(libraryItemId, updateLibraryItemDetails = null) {\n    // TODO: Add task manager\n    const libraryItem = await Database.libraryItemModel.findByPk(libraryItemId)\n    if (!libraryItem) {\n      Logger.error(`[LibraryItemScanner] Library item not found \"${libraryItemId}\"`)\n      return ScanResult.NOTHING\n    }\n\n    const libraryFolderId = updateLibraryItemDetails?.libraryFolderId || libraryItem.libraryFolderId\n    const library = await Database.libraryModel.findByPk(libraryItem.libraryId, {\n      include: {\n        model: Database.libraryFolderModel,\n        where: {\n          id: libraryFolderId\n        }\n      }\n    })\n    if (!library) {\n      Logger.error(`[LibraryItemScanner] Library \"${libraryItem.libraryId}\" not found for library item \"${libraryItem.id}\"`)\n      return ScanResult.NOTHING\n    }\n\n    // Make sure library filter data is set\n    //   this is used to check for existing authors & series\n    await libraryFilters.getFilterData(library.mediaType, library.id)\n\n    const scanLogger = new ScanLogger()\n    scanLogger.verbose = true\n    scanLogger.setData('libraryItem', updateLibraryItemDetails?.relPath || libraryItem.relPath)\n\n    const libraryItemPath = updateLibraryItemDetails?.path || fileUtils.filePathToPOSIX(libraryItem.path)\n    const folder = library.libraryFolders[0]\n    const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, updateLibraryItemDetails?.isFile || false)\n\n    let libraryItemDataUpdated = await libraryItemScanData.checkLibraryItemData(libraryItem, scanLogger)\n\n    const { libraryItem: expandedLibraryItem, wasUpdated } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger)\n    if (libraryItemDataUpdated || wasUpdated) {\n      SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)\n\n      await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)\n\n      return ScanResult.UPDATED\n    }\n\n    scanLogger.addLog(LogLevel.DEBUG, `Library item is up-to-date`)\n    return ScanResult.UPTODATE\n  }\n\n  /**\n   * Remove empty authors and series\n   * @param {string} libraryId\n   * @param {ScanLogger} scanLogger\n   * @returns {Promise}\n   */\n  async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) {\n    if (scanLogger.authorsRemovedFromBooks.length) {\n      await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)\n    }\n    if (scanLogger.seriesRemovedFromBooks.length) {\n      await BookScanner.checkSeriesRemovedFromBooks(libraryId, scanLogger)\n    }\n  }\n\n  /**\n   *\n   * @param {string} libraryItemPath\n   * @param {import('../models/Library')} library\n   * @param {import('../models/LibraryFolder')} folder\n   * @param {boolean} isSingleMediaItem\n   * @returns {Promise<LibraryItemScanData>}\n   */\n  async getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) {\n    const libraryFolderPath = fileUtils.filePathToPOSIX(folder.path)\n    const libraryItemDir = libraryItemPath.replace(libraryFolderPath, '').slice(1)\n\n    let libraryItemData = {}\n\n    let fileItems = []\n\n    if (isSingleMediaItem) {\n      // Single media item in root of folder\n      fileItems = [\n        {\n          fullpath: libraryItemPath,\n          path: libraryItemDir // actually the relPath (only filename here)\n        }\n      ]\n      libraryItemData = {\n        path: libraryItemPath, // full path\n        relPath: libraryItemDir, // only filename\n        mediaMetadata: {\n          title: Path.basename(libraryItemDir, Path.extname(libraryItemDir))\n        }\n      }\n    } else {\n      fileItems = await fileUtils.recurseFiles(libraryItemPath)\n      libraryItemData = scanUtils.getDataFromMediaDir(library.mediaType, libraryFolderPath, libraryItemDir)\n    }\n\n    const libraryFiles = []\n    for (let i = 0; i < fileItems.length; i++) {\n      const fileItem = fileItems[i]\n\n      if (Watcher.checkShouldIgnoreFilePath(fileItem.fullpath)) {\n        // Skip file if it's pending\n        Logger.info(`[LibraryItemScanner] Skipping watcher pending file \"${fileItem.fullpath}\" during scan of library item path \"${libraryItemPath}\"`)\n        continue\n      }\n\n      const newLibraryFile = new LibraryFile()\n      // fileItem.path is the relative path\n      await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)\n      libraryFiles.push(newLibraryFile)\n    }\n\n    const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)\n    return new LibraryItemScanData({\n      libraryFolderId: folder.id,\n      libraryId: library.id,\n      mediaType: library.mediaType,\n      ino: libraryItemStats.ino,\n      mtimeMs: libraryItemStats.mtimeMs || 0,\n      ctimeMs: libraryItemStats.ctimeMs || 0,\n      birthtimeMs: libraryItemStats.birthtimeMs || 0,\n      path: libraryItemData.path,\n      relPath: libraryItemData.relPath,\n      isFile: isSingleMediaItem,\n      mediaMetadata: libraryItemData.mediaMetadata || null,\n      libraryFiles\n    })\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} existingLibraryItem\n   * @param {LibraryItemScanData} libraryItemData\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise<{libraryItem:LibraryItem, wasUpdated:boolean}>}\n   */\n  rescanLibraryItemMedia(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {\n    if (existingLibraryItem.mediaType === 'book') {\n      return BookScanner.rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)\n    } else {\n      return PodcastScanner.rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)\n    }\n  }\n\n  /**\n   *\n   * @param {LibraryItemScanData} libraryItemData\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise<LibraryItem>}\n   */\n  async scanNewLibraryItem(libraryItemData, librarySettings, libraryScan) {\n    let newLibraryItem = null\n    if (libraryItemData.mediaType === 'book') {\n      newLibraryItem = await BookScanner.scanNewBookLibraryItem(libraryItemData, librarySettings, libraryScan)\n    } else {\n      newLibraryItem = await PodcastScanner.scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan)\n    }\n    if (newLibraryItem) {\n      libraryScan.addLog(LogLevel.INFO, `Created new library item \"${newLibraryItem.relPath}\" with id \"${newLibraryItem.id}\"`)\n    }\n    return newLibraryItem\n  }\n\n  /**\n   * Scan library item folder coming from Watcher\n   * @param {string} libraryItemPath\n   * @param {import('../models/Library')} library\n   * @param {import('../models/LibraryFolder')} folder\n   * @param {boolean} isSingleMediaItem\n   * @returns {Promise<LibraryItem>} ScanResult\n   */\n  async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {\n    const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)\n\n    if (!libraryItemScanData.libraryFiles.length) {\n      Logger.info(`[LibraryItemScanner] Library item at path \"${libraryItemPath}\" has no files - ignoring`)\n      return null\n    }\n\n    const scanLogger = new ScanLogger()\n    scanLogger.verbose = true\n    scanLogger.setData('libraryItem', libraryItemScanData.relPath)\n\n    return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)\n  }\n}\nmodule.exports = new LibraryItemScanner()\n"
  },
  {
    "path": "server/scanner/LibraryScan.js",
    "content": "const Path = require('path')\nconst uuidv4 = require('uuid').v4\nconst fs = require('../libs/fsExtra')\nconst date = require('../libs/dateAndTime')\n\nconst Logger = require('../Logger')\nconst { secondsToTimestamp, elapsedPretty } = require('../utils/index')\n\nclass LibraryScan {\n  constructor() {\n    this.id = null\n    this.type = null\n    /** @type {import('../models/Library')} */\n    this.library = null\n    this.verbose = false\n\n    this.startedAt = null\n    this.finishedAt = null\n    this.elapsed = null\n\n    this.resultsMissing = 0\n    this.resultsAdded = 0\n    this.resultsUpdated = 0\n\n    /** @type {string[]} */\n    this.authorsRemovedFromBooks = []\n    /** @type {string[]} */\n    this.seriesRemovedFromBooks = []\n\n    this.logs = []\n  }\n\n  get libraryId() {\n    return this.library.id\n  }\n  get libraryName() {\n    return this.library.name\n  }\n  get libraryMediaType() {\n    return this.library.mediaType\n  }\n  get libraryFolders() {\n    return this.library.libraryFolders\n  }\n\n  get timestamp() {\n    return new Date().toISOString()\n  }\n\n  get resultStats() {\n    return `${this.resultsAdded} Added | ${this.resultsUpdated} Updated | ${this.resultsMissing} Missing`\n  }\n  get elapsedTimestamp() {\n    return secondsToTimestamp(this.elapsed / 1000)\n  }\n  get logFilename() {\n    return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt'\n  }\n  get scanResultsString() {\n    const strs = []\n    if (this.resultsAdded) strs.push(`${this.resultsAdded} added`)\n    if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`)\n    if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`)\n    const changesDetected = strs.length > 0 ? strs.join(', ') : 'No changes needed'\n    const timeElapsed = `(${elapsedPretty(this.elapsed / 1000)})`\n    return `${changesDetected} ${timeElapsed}`\n  }\n\n  get scanResults() {\n    return {\n      added: this.resultsAdded,\n      updated: this.resultsUpdated,\n      missing: this.resultsMissing,\n      elapsed: this.elapsed,\n      text: this.scanResultsString\n    }\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      type: this.type,\n      library: this.library.toJSON(),\n      startedAt: this.startedAt,\n      finishedAt: this.finishedAt,\n      elapsed: this.elapsed,\n      resultsAdded: this.resultsAdded,\n      resultsUpdated: this.resultsUpdated,\n      resultsMissing: this.resultsMissing\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/Library')} library\n   * @param {string} type\n   */\n  setData(library, type = 'scan') {\n    this.id = uuidv4()\n    this.type = type\n    this.library = library\n\n    this.startedAt = Date.now()\n  }\n\n  setComplete() {\n    this.finishedAt = Date.now()\n    this.elapsed = this.finishedAt - this.startedAt\n  }\n\n  addLog(level, ...args) {\n    const logObj = {\n      timestamp: this.timestamp,\n      message: args.join(' '),\n      levelName: Logger.getLogLevelString(level),\n      level\n    }\n\n    if (this.verbose) {\n      Logger.debug(`[LibraryScan] \"${this.libraryName}\":`, ...args)\n    }\n    this.logs.push(logObj)\n  }\n\n  async saveLog() {\n    const scanLogDir = Path.join(global.MetadataPath, 'logs', 'scans')\n\n    if (!(await fs.pathExists(scanLogDir))) {\n      await fs.mkdir(scanLogDir)\n    }\n\n    const outputPath = Path.join(scanLogDir, this.logFilename)\n    const logLines = [JSON.stringify(this.toJSON())]\n    this.logs.forEach((l) => {\n      logLines.push(JSON.stringify(l))\n    })\n    await fs.writeFile(outputPath, logLines.join('\\n') + '\\n')\n\n    Logger.info(`[LibraryScan] Scan log saved \"${outputPath}\"`)\n  }\n}\nmodule.exports = LibraryScan\n"
  },
  {
    "path": "server/scanner/LibraryScanner.js",
    "content": "const sequelize = require('sequelize')\nconst Path = require('path')\nconst packageJson = require('../../package.json')\nconst Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst fs = require('../libs/fsExtra')\nconst fileUtils = require('../utils/fileUtils')\nconst scanUtils = require('../utils/scandir')\nconst { LogLevel, ScanResult } = require('../utils/constants')\nconst libraryFilters = require('../utils/queries/libraryFilters')\nconst TaskManager = require('../managers/TaskManager')\nconst LibraryItemScanner = require('./LibraryItemScanner')\nconst LibraryScan = require('./LibraryScan')\nconst LibraryItemScanData = require('./LibraryItemScanData')\nconst Task = require('../objects/Task')\n\nclass LibraryScanner {\n  constructor() {\n    this.cancelLibraryScan = {}\n    /** @type {string[]} - library ids */\n    this.librariesScanning = []\n\n    this.scanningFilesChanged = false\n    /** @type {[import('../Watcher').PendingFileUpdate[], Task][]} */\n    this.pendingFileUpdatesToScan = []\n  }\n\n  /**\n   * @param {string} libraryId\n   * @returns {boolean}\n   */\n  isLibraryScanning(libraryId) {\n    return this.librariesScanning.some((lid) => lid === libraryId)\n  }\n\n  /**\n   *\n   * @param {string} libraryId\n   */\n  setCancelLibraryScan(libraryId) {\n    if (!this.isLibraryScanning(libraryId)) return\n    this.cancelLibraryScan[libraryId] = true\n  }\n\n  /**\n   *\n   * @param {import('../models/Library')} library\n   * @param {boolean} [forceRescan]\n   */\n  async scan(library, forceRescan = false) {\n    if (this.isLibraryScanning(library.id)) {\n      Logger.error(`[LibraryScanner] Already scanning ${library.id}`)\n      return\n    }\n\n    if (!library.libraryFolders.length) {\n      Logger.warn(`[LibraryScanner] Library has no folders to scan \"${library.name}\"`)\n      return\n    }\n\n    const metadataPrecedence = library.settings.metadataPrecedence || Database.libraryModel.defaultMetadataPrecedence\n    if (library.isBook && metadataPrecedence.join() !== library.lastScanMetadataPrecedence.join()) {\n      const lastScanMetadataPrecedence = library.lastScanMetadataPrecedence?.join() || 'Unset'\n      Logger.info(`[LibraryScanner] Library metadata precedence changed since last scan. From [${lastScanMetadataPrecedence}] to [${metadataPrecedence.join()}]`)\n      forceRescan = true\n    }\n\n    const libraryScan = new LibraryScan()\n    libraryScan.setData(library)\n    libraryScan.verbose = true\n    this.librariesScanning.push(libraryScan.libraryId)\n\n    const taskData = {\n      libraryId: library.id,\n      libraryName: library.name,\n      libraryMediaType: library.mediaType\n    }\n    const taskTitleString = {\n      text: `Scanning \"${library.name}\" library`,\n      key: 'MessageTaskScanningLibrary',\n      subs: [library.name]\n    }\n    const task = TaskManager.createAndAddTask('library-scan', taskTitleString, null, true, taskData)\n\n    Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`)\n\n    try {\n      const canceled = await this.scanLibrary(libraryScan, forceRescan)\n      libraryScan.setComplete()\n\n      Logger.info(`[LibraryScanner] Library scan \"${libraryScan.id}\" ${canceled ? 'canceled after' : 'completed in'} ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)\n\n      if (!canceled) {\n        library.lastScan = Date.now()\n        library.lastScanVersion = packageJson.version\n        if (library.isBook) {\n          const newExtraData = library.extraData || {}\n          newExtraData.lastScanMetadataPrecedence = metadataPrecedence\n          library.extraData = newExtraData\n          library.changed('extraData', true)\n        }\n        await library.save()\n      }\n\n      task.data.scanResults = libraryScan.scanResults\n      if (canceled) {\n        const taskFinishedString = {\n          text: 'Task canceled by user',\n          key: 'MessageTaskCanceledByUser'\n        }\n        task.setFinished(taskFinishedString)\n      } else {\n        task.setFinished(null, true)\n      }\n    } catch (err) {\n      libraryScan.setComplete()\n\n      Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err)\n\n      task.data.scanResults = libraryScan.scanResults\n      const taskFailedString = {\n        text: 'Failed',\n        key: 'MessageTaskFailed'\n      }\n      task.setFailed(taskFailedString)\n    }\n\n    if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId]\n    this.librariesScanning = this.librariesScanning.filter((lid) => lid !== library.id)\n\n    TaskManager.taskFinished(task)\n\n    libraryScan.saveLog()\n  }\n\n  /**\n   *\n   * @param {import('./LibraryScan')} libraryScan\n   * @param {boolean} forceRescan\n   * @returns {Promise<boolean>} true if scan canceled\n   */\n  async scanLibrary(libraryScan, forceRescan) {\n    // Make sure library filter data is set\n    //   this is used to check for existing authors & series\n    await libraryFilters.getFilterData(libraryScan.libraryMediaType, libraryScan.libraryId)\n\n    /** @type {LibraryItemScanData[]} */\n    let libraryItemDataFound = []\n\n    // Scan each library folder\n    for (let i = 0; i < libraryScan.libraryFolders.length; i++) {\n      const folder = libraryScan.libraryFolders[i]\n      const itemDataFoundInFolder = await this.scanFolder(libraryScan.library, folder)\n      libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder \"${folder.path}\"`)\n      libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)\n    }\n\n    if (this.shouldCancelScan(libraryScan)) return true\n\n    const existingLibraryItems = await Database.libraryItemModel.findAll({\n      where: {\n        libraryId: libraryScan.libraryId\n      }\n    })\n\n    if (this.shouldCancelScan(libraryScan)) return true\n\n    const libraryItemIdsMissing = []\n    let libraryItemsUpdated = []\n    for (const existingLibraryItem of existingLibraryItems) {\n      // First try to find matching library item with exact file path\n      let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path)\n      if (!libraryItemData) {\n        // Fallback to finding matching library item with matching inode value\n        libraryItemData = libraryItemDataFound.find((lid) => ItemToItemInoMatch(lid, existingLibraryItem) || ItemToFileInoMatch(lid, existingLibraryItem) || ItemToFileInoMatch(existingLibraryItem, lid))\n        if (libraryItemData) {\n          libraryScan.addLog(LogLevel.INFO, `Library item with path \"${existingLibraryItem.path}\" was not found, but library item inode \"${existingLibraryItem.ino}\" was found at path \"${libraryItemData.path}\"`)\n        }\n      }\n\n      if (!libraryItemData) {\n        // Podcast folder can have no episodes and still be valid\n        if (libraryScan.libraryMediaType === 'podcast' && (await fs.pathExists(existingLibraryItem.path))) {\n          libraryScan.addLog(LogLevel.INFO, `Library item \"${existingLibraryItem.relPath}\" folder exists but has no episodes`)\n        } else {\n          libraryScan.addLog(LogLevel.WARN, `Library Item \"${existingLibraryItem.path}\" (inode: ${existingLibraryItem.ino}) is missing`)\n          libraryScan.resultsMissing++\n          if (!existingLibraryItem.isMissing) {\n            libraryItemIdsMissing.push(existingLibraryItem.id)\n\n            // TODO: Temporary while using old model to socket emit\n            const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)\n            if (libraryItem) {\n              libraryItem.isMissing = true\n              await libraryItem.save()\n              libraryItemsUpdated.push(libraryItem)\n            }\n          }\n        }\n      } else {\n        libraryItemDataFound = libraryItemDataFound.filter((lidf) => lidf !== libraryItemData)\n        let libraryItemDataUpdated = await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)\n        if (libraryItemDataUpdated || forceRescan) {\n          if (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {\n            const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)\n            if (!forceRescan || wasUpdated) {\n              libraryScan.resultsUpdated++\n              libraryItemsUpdated.push(libraryItem)\n            } else {\n              libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" is up-to-date`)\n            }\n          } else {\n            libraryScan.resultsUpdated++\n            // TODO: Temporary while using old model to socket emit\n            const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)\n            libraryItemsUpdated.push(libraryItem)\n          }\n        } else {\n          libraryScan.addLog(LogLevel.DEBUG, `Library item \"${existingLibraryItem.relPath}\" is up-to-date`)\n        }\n      }\n\n      // Emit item updates in chunks of 10 to client\n      if (libraryItemsUpdated.length === 10) {\n        SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)\n        libraryItemsUpdated = []\n      }\n\n      if (this.shouldCancelScan(libraryScan)) return true\n    }\n    // Emit item updates to client\n    if (libraryItemsUpdated.length) {\n      SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)\n    }\n\n    // Authors and series that were removed from books should be removed if they are now empty\n    await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)\n\n    // Update missing library items\n    if (libraryItemIdsMissing.length) {\n      libraryScan.addLog(LogLevel.INFO, `Updating ${libraryItemIdsMissing.length} library items missing`)\n      await Database.libraryItemModel.update(\n        {\n          isMissing: true,\n          lastScan: Date.now(),\n          lastScanVersion: packageJson.version\n        },\n        {\n          where: {\n            id: libraryItemIdsMissing\n          }\n        }\n      )\n    }\n\n    if (this.shouldCancelScan(libraryScan)) return true\n\n    // Add new library items\n    if (libraryItemDataFound.length) {\n      let newLibraryItems = []\n      for (const libraryItemData of libraryItemDataFound) {\n        const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan)\n        if (newLibraryItem) {\n          newLibraryItems.push(newLibraryItem)\n\n          libraryScan.resultsAdded++\n        }\n\n        // Emit new items in chunks of 10 to client\n        if (newLibraryItems.length === 10) {\n          SocketAuthority.libraryItemsEmitter('items_added', newLibraryItems)\n          newLibraryItems = []\n        }\n\n        if (this.shouldCancelScan(libraryScan)) return true\n      }\n      // Emit new items to client\n      if (newLibraryItems.length) {\n        SocketAuthority.libraryItemsEmitter('items_added', newLibraryItems)\n      }\n    }\n\n    libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)\n    return false\n  }\n\n  shouldCancelScan(libraryScan) {\n    if (this.cancelLibraryScan[libraryScan.libraryId]) {\n      libraryScan.addLog(LogLevel.INFO, `Scan canceled. ${libraryScan.resultStats}`)\n      return true\n    }\n    return false\n  }\n\n  /**\n   * Get scan data for library folder\n   * @param {import('../models/Library')} library\n   * @param {import('../models/LibraryFolder')} folder\n   * @returns {LibraryItemScanData[]}\n   */\n  async scanFolder(library, folder) {\n    const folderPath = fileUtils.filePathToPOSIX(folder.path)\n\n    const pathExists = await fs.pathExists(folderPath)\n    if (!pathExists) {\n      Logger.error(`[scandir] Invalid folder path does not exist \"${folderPath}\"`)\n      return []\n    }\n\n    const fileItems = await fileUtils.recurseFiles(folderPath)\n    const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, fileItems, library.settings.audiobooksOnly)\n\n    if (!Object.keys(libraryItemGrouping).length) {\n      Logger.error(`Root path has no media folders: ${folderPath}`)\n      return []\n    }\n\n    const items = []\n    for (const libraryItemPath in libraryItemGrouping) {\n      let isFile = false // item is not in a folder\n      let libraryItemData = null\n      let fileObjs = []\n      if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {\n        // Media file in root only get title\n        libraryItemData = {\n          mediaMetadata: {\n            title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))\n          },\n          path: Path.posix.join(folderPath, libraryItemPath),\n          relPath: libraryItemPath\n        }\n        fileObjs = await scanUtils.buildLibraryFile(folderPath, [libraryItemPath])\n        isFile = true\n      } else {\n        libraryItemData = scanUtils.getDataFromMediaDir(library.mediaType, folderPath, libraryItemPath)\n        fileObjs = await scanUtils.buildLibraryFile(libraryItemData.path, libraryItemGrouping[libraryItemPath])\n      }\n\n      const libraryItemFolderStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)\n\n      if (!libraryItemFolderStats.ino) {\n        Logger.warn(`[LibraryScanner] Library item folder \"${libraryItemData.path}\" has no inode value`)\n        continue\n      }\n\n      items.push(\n        new LibraryItemScanData({\n          libraryFolderId: folder.id,\n          libraryId: folder.libraryId,\n          mediaType: library.mediaType,\n          ino: libraryItemFolderStats.ino,\n          mtimeMs: libraryItemFolderStats.mtimeMs || 0,\n          ctimeMs: libraryItemFolderStats.ctimeMs || 0,\n          birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,\n          path: libraryItemData.path,\n          relPath: libraryItemData.relPath,\n          isFile,\n          mediaMetadata: libraryItemData.mediaMetadata || null,\n          libraryFiles: fileObjs\n        })\n      )\n    }\n    return items\n  }\n\n  /**\n   * Scan files changed from Watcher\n   * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates\n   * @param {Task} pendingTask\n   */\n  async scanFilesChanged(fileUpdates, pendingTask) {\n    if (!fileUpdates?.length) return\n\n    // If already scanning files from watcher then add these updates to queue\n    if (this.scanningFilesChanged) {\n      this.pendingFileUpdatesToScan.push([fileUpdates, pendingTask])\n      Logger.debug(`[LibraryScanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`)\n      return\n    }\n    this.scanningFilesChanged = true\n\n    const results = {\n      added: 0,\n      updated: 0,\n      removed: 0\n    }\n\n    // files grouped by folder\n    const folderGroups = this.getFileUpdatesGrouped(fileUpdates)\n\n    for (const folderId in folderGroups) {\n      const libraryId = folderGroups[folderId].libraryId\n\n      const library = await Database.libraryModel.findByPk(libraryId, {\n        include: {\n          model: Database.libraryFolderModel,\n          where: {\n            id: folderId\n          }\n        }\n      })\n      if (!library) {\n        Logger.error(`[LibraryScanner] Library \"${libraryId}\" not found in files changed ${libraryId}`)\n        continue\n      }\n      const folder = library.libraryFolders[0]\n\n      const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))\n      const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly, true)\n\n      if (!Object.keys(fileUpdateGroup).length) {\n        Logger.info(`[LibraryScanner] No important changes to scan for in folder \"${folderId}\"`)\n        continue\n      }\n      const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)\n      Logger.debug(`[LibraryScanner] Folder scan results`, folderScanResults)\n\n      // Tally results to share with client\n      let resetFilterData = false\n      Object.values(folderScanResults).forEach((scanResult) => {\n        if (scanResult === ScanResult.ADDED) {\n          resetFilterData = true\n          results.added++\n        } else if (scanResult === ScanResult.REMOVED) {\n          resetFilterData = true\n          results.removed++\n        } else if (scanResult === ScanResult.UPDATED) {\n          resetFilterData = true\n          results.updated++\n        }\n      })\n\n      // If something was updated then reset numIssues filter data for library\n      if (resetFilterData) {\n        await Database.resetLibraryIssuesFilterData(libraryId)\n      }\n    }\n\n    // Complete task and send results to client\n    const resultStrs = []\n    if (results.added) resultStrs.push(`${results.added} added`)\n    if (results.updated) resultStrs.push(`${results.updated} updated`)\n    if (results.removed) resultStrs.push(`${results.removed} missing`)\n    let scanResultStr = 'No changes needed'\n    if (resultStrs.length) scanResultStr = resultStrs.join(', ')\n\n    pendingTask.data.scanResults = {\n      ...results,\n      text: scanResultStr,\n      elapsed: Date.now() - pendingTask.startedAt\n    }\n    pendingTask.setFinished(null, true)\n    TaskManager.taskFinished(pendingTask)\n\n    this.scanningFilesChanged = false\n\n    if (this.pendingFileUpdatesToScan.length) {\n      Logger.debug(`[LibraryScanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`)\n      this.scanFilesChanged(...this.pendingFileUpdatesToScan.shift())\n    }\n  }\n\n  /**\n   * Group array of PendingFileUpdate from Watcher by folder\n   * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates\n   * @returns {Record<string,{libraryId:string, folderId:string, fileUpdates:import('../Watcher').PendingFileUpdate[]}>}\n   */\n  getFileUpdatesGrouped(fileUpdates) {\n    const folderGroups = {}\n    fileUpdates.forEach((file) => {\n      if (folderGroups[file.folderId]) {\n        folderGroups[file.folderId].fileUpdates.push(file)\n      } else {\n        folderGroups[file.folderId] = {\n          libraryId: file.libraryId,\n          folderId: file.folderId,\n          fileUpdates: [file]\n        }\n      }\n    })\n    return folderGroups\n  }\n\n  /**\n   * Scan grouped paths for library folder coming from Watcher\n   * @param {import('../models/Library')} library\n   * @param {import('../models/LibraryFolder')} folder\n   * @param {Record<string, string[]>} fileUpdateGroup\n   * @returns {Promise<Record<string,number>>}\n   */\n  async scanFolderUpdates(library, folder, fileUpdateGroup) {\n    // Make sure library filter data is set\n    //   this is used to check for existing authors & series\n    await libraryFilters.getFilterData(library.mediaType, library.id)\n    Logger.debug(`[Scanner] Scanning file update groups in folder \"${folder.id}\" of library \"${library.name}\"`)\n    Logger.debug(`[Scanner] scanFolderUpdates fileUpdateGroup`, fileUpdateGroup)\n\n    // First pass - Remove files in parent dirs of items and remap the fileupdate group\n    //    Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item\n    const updateGroup = { ...fileUpdateGroup }\n    for (const itemDir in updateGroup) {\n      if (isSingleMediaFile(fileUpdateGroup, itemDir)) continue // Media in root path\n\n      const itemDirNestedFiles = fileUpdateGroup[itemDir].filter((b) => b.includes('/'))\n      if (!itemDirNestedFiles.length) continue\n\n      const firstNest = itemDirNestedFiles[0].split('/').shift()\n      const altDir = `${itemDir}/${firstNest}`\n\n      const fullPath = Path.posix.join(fileUtils.filePathToPOSIX(folder.path), itemDir)\n      const childLibraryItem = await Database.libraryItemModel.findOne({\n        attributes: ['id', 'path'],\n        where: {\n          path: {\n            [sequelize.Op.not]: fullPath\n          },\n          path: {\n            [sequelize.Op.startsWith]: fullPath\n          }\n        }\n      })\n      if (!childLibraryItem) {\n        continue\n      }\n\n      const altFullPath = Path.posix.join(fileUtils.filePathToPOSIX(folder.path), altDir)\n      const altChildLibraryItem = await Database.libraryItemModel.findOne({\n        attributes: ['id', 'path'],\n        where: {\n          path: {\n            [sequelize.Op.not]: altFullPath\n          },\n          path: {\n            [sequelize.Op.startsWith]: altFullPath\n          }\n        }\n      })\n      if (altChildLibraryItem) {\n        continue\n      }\n\n      delete fileUpdateGroup[itemDir]\n      fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/'))\n      Logger.warn(`[LibraryScanner] Some files were modified in a parent directory of a library item \"${childLibraryItem.path}\" - ignoring`)\n    }\n\n    // Second pass: Check for new/updated/removed items\n    const itemGroupingResults = {}\n    for (const itemDir in fileUpdateGroup) {\n      const fullPath = Path.posix.join(fileUtils.filePathToPOSIX(folder.path), itemDir)\n\n      const itemDirParts = itemDir.split('/').slice(0, -1)\n\n      const potentialChildDirs = [fullPath]\n      for (let i = 0; i < itemDirParts.length; i++) {\n        potentialChildDirs.push(\n          Path.posix.join(\n            fileUtils.filePathToPOSIX(folder.path),\n            itemDir\n              .split('/')\n              .slice(0, -1 - i)\n              .join('/')\n          )\n        )\n      }\n\n      // Check if book dir group is already an item\n      let existingLibraryItem = await Database.libraryItemModel.findOneExpanded({\n        libraryId: library.id,\n        path: potentialChildDirs\n      })\n\n      let updatedLibraryItemDetails = {}\n      if (!existingLibraryItem) {\n        const isSingleMedia = isSingleMediaFile(fileUpdateGroup, itemDir)\n        existingLibraryItem = (await findLibraryItemByItemToItemInoMatch(library.id, fullPath)) || (await findLibraryItemByItemToFileInoMatch(library.id, fullPath, isSingleMedia)) || (await findLibraryItemByFileToItemInoMatch(library.id, fullPath, isSingleMedia, fileUpdateGroup[itemDir]))\n        if (existingLibraryItem) {\n          // Update library item paths for scan\n          existingLibraryItem.path = fullPath\n          existingLibraryItem.relPath = itemDir\n          updatedLibraryItemDetails.path = fullPath\n          updatedLibraryItemDetails.relPath = itemDir\n          updatedLibraryItemDetails.libraryFolderId = folder.id\n          updatedLibraryItemDetails.isFile = isSingleMedia\n        }\n      }\n      if (existingLibraryItem) {\n        // Is the item exactly - check if was deleted\n        if (existingLibraryItem.path === fullPath) {\n          const exists = await fs.pathExists(fullPath)\n          if (!exists) {\n            Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted \"${existingLibraryItem.media.title}\" - marking as missing`)\n            existingLibraryItem.isMissing = true\n            await existingLibraryItem.save()\n            SocketAuthority.libraryItemEmitter('item_updated', existingLibraryItem)\n\n            itemGroupingResults[itemDir] = ScanResult.REMOVED\n            continue\n          }\n        }\n        // Scan library item for updates\n        Logger.debug(`[LibraryScanner] Folder update for relative path \"${itemDir}\" is in library item \"${existingLibraryItem.media.title}\" with id \"${existingLibraryItem.id}\" - scan for updates`)\n        itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)\n        continue\n      } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {\n        Logger.debug(`[LibraryScanner] Folder update for relative path \"${itemDir}\" has no audio files`)\n        continue\n      } else if (!(await fs.pathExists(fullPath))) {\n        Logger.info(`[LibraryScanner] File update group \"${itemDir}\" does not exist - ignoring`)\n\n        itemGroupingResults[itemDir] = ScanResult.NOTHING\n        continue\n      }\n\n      // Check if a library item is a subdirectory of this dir\n      const childItem = await Database.libraryItemModel.findOne({\n        attributes: ['id', 'path'],\n        where: {\n          path: {\n            [sequelize.Op.startsWith]: fullPath + '/'\n          }\n        }\n      })\n      if (childItem) {\n        Logger.warn(`[LibraryScanner] Files were modified in a parent directory of a library item \"${childItem.path}\" - ignoring`)\n        itemGroupingResults[itemDir] = ScanResult.NOTHING\n        continue\n      }\n\n      Logger.debug(`[LibraryScanner] Folder update group must be a new item \"${itemDir}\" in library \"${library.name}\"`)\n      const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir)\n      const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)\n      if (newLibraryItem) {\n        SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)\n      }\n      itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING\n    }\n\n    return itemGroupingResults\n  }\n}\nmodule.exports = new LibraryScanner()\n\nfunction ItemToFileInoMatch(libraryItem1, libraryItem2) {\n  return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino)\n}\n\nfunction ItemToItemInoMatch(libraryItem1, libraryItem2) {\n  return libraryItem1.ino === libraryItem2.ino\n}\n\nfunction hasAudioFiles(fileUpdateGroup, itemDir) {\n  return isSingleMediaFile(fileUpdateGroup, itemDir) ? scanUtils.checkFilepathIsAudioFile(fileUpdateGroup[itemDir]) : fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile)\n}\n\nfunction isSingleMediaFile(fileUpdateGroup, itemDir) {\n  return itemDir === fileUpdateGroup[itemDir]\n}\n\nasync function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {\n  const ino = await fileUtils.getIno(fullPath)\n  if (!ino) return null\n  const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({\n    libraryId: libraryId,\n    ino: ino\n  })\n  if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with matching inode \"${ino}\" at path \"${existingLibraryItem.path}\"`)\n  return existingLibraryItem\n}\n\nasync function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingleMedia) {\n  if (!isSingleMedia) return null\n  // check if it was moved from another folder by comparing the ino to the library files\n  const ino = await fileUtils.getIno(fullPath)\n  if (!ino) return null\n  const existingLibraryItem = await Database.libraryItemModel.findOneExpanded(\n    [\n      {\n        libraryId: libraryId\n      },\n      sequelize.where(sequelize.literal('(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>\"$.ino\" = :inode)'), {\n        [sequelize.Op.gt]: 0\n      })\n    ],\n    {\n      inode: ino\n    }\n  )\n  if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with a library file matching inode \"${ino}\" at path \"${existingLibraryItem.path}\"`)\n  return existingLibraryItem\n}\n\nasync function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingleMedia, itemFiles) {\n  if (isSingleMedia) return null\n  // check if it was moved from the root folder by comparing the ino to the ino of the scanned files\n  let itemFileInos = []\n  for (const itemFile of itemFiles) {\n    const ino = await fileUtils.getIno(Path.posix.join(fullPath, itemFile))\n    if (ino) itemFileInos.push(ino)\n  }\n  if (!itemFileInos.length) return null\n  const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({\n    libraryId: libraryId,\n    ino: {\n      [sequelize.Op.in]: itemFileInos\n    }\n  })\n  if (existingLibraryItem) Logger.debug(`[LibraryScanner] Found library item with inode matching one of \"${itemFileInos.join(',')}\" at path \"${existingLibraryItem.path}\"`)\n  return existingLibraryItem\n}\n"
  },
  {
    "path": "server/scanner/MediaProbeData.js",
    "content": "const AudioMetaTags = require('../objects/metadata/AudioMetaTags')\n\nclass MediaProbeData {\n  constructor(probeData) {\n    this.embeddedCoverArt = null\n    this.format = null\n    this.duration = null\n    this.size = null\n\n    this.audioStream = null\n    this.videoStream = null\n\n    this.bitRate = null\n    this.codec = null\n    this.timeBase = null\n    this.language = null\n    this.channelLayout = null\n    this.channels = null\n    this.sampleRate = null\n    this.chapters = []\n\n    this.audioMetaTags = null\n\n    this.trackNumber = null\n    this.trackTotal = null\n\n    this.discNumber = null\n    this.discTotal = null\n\n    if (probeData) {\n      this.construct(probeData)\n    }\n  }\n\n  construct(probeData) {\n    for (const key in probeData) {\n      if (key === 'audioMetaTags' && probeData[key]) {\n        this[key] = new AudioMetaTags(probeData[key])\n      } else if (this[key] !== undefined) {\n        this[key] = probeData[key]\n      }\n    }\n  }\n\n  setData(data) {\n    this.embeddedCoverArt = data.video_stream?.codec || null\n    this.format = data.format\n    this.duration = data.duration\n    this.size = data.size\n\n    this.audioStream = data.audio_stream\n    this.videoStream = this.embeddedCoverArt ? null : data.video_stream || null\n\n    this.bitRate = this.audioStream.bit_rate || data.bit_rate\n    this.codec = this.audioStream.codec\n    this.timeBase = this.audioStream.time_base\n    this.language = this.audioStream.language\n    this.channelLayout = this.audioStream.channel_layout\n    this.channels = this.audioStream.channels\n    this.sampleRate = this.audioStream.sample_rate\n    this.chapters = data.chapters || []\n\n    this.audioMetaTags = new AudioMetaTags()\n    this.audioMetaTags.setData(data.tags)\n  }\n}\nmodule.exports = MediaProbeData\n"
  },
  {
    "path": "server/scanner/NfoFileScanner.js",
    "content": "const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata')\nconst { readTextFile } = require('../utils/fileUtils')\n\nclass NfoFileScanner {\n  constructor() {}\n\n  /**\n   * Parse metadata from .nfo file found in library scan and update bookMetadata\n   *\n   * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj\n   * @param {Object} bookMetadata\n   */\n  async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) {\n    const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path)\n    const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null\n    if (nfoMetadata) {\n      for (const key in nfoMetadata) {\n        if (key === 'tags') {\n          // Add tags only if tags are empty\n          if (nfoMetadata.tags.length) {\n            bookMetadata.tags = nfoMetadata.tags\n          }\n        } else if (key === 'genres') {\n          // Add genres only if genres are empty\n          if (nfoMetadata.genres.length) {\n            bookMetadata.genres = nfoMetadata.genres\n          }\n        } else if (key === 'authors') {\n          if (nfoMetadata.authors?.length) {\n            bookMetadata.authors = nfoMetadata.authors\n          }\n        } else if (key === 'narrators') {\n          if (nfoMetadata.narrators?.length) {\n            bookMetadata.narrators = nfoMetadata.narrators\n          }\n        } else if (key === 'series') {\n          if (nfoMetadata.series) {\n            bookMetadata.series = [\n              {\n                name: nfoMetadata.series,\n                sequence: nfoMetadata.sequence || null\n              }\n            ]\n          }\n        } else if (nfoMetadata[key] && key !== 'sequence') {\n          bookMetadata[key] = nfoMetadata[key]\n        }\n      }\n    }\n  }\n}\nmodule.exports = new NfoFileScanner()\n"
  },
  {
    "path": "server/scanner/OpfFileScanner.js",
    "content": "const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')\nconst { readTextFile } = require('../utils/fileUtils')\n\nclass OpfFileScanner {\n  constructor() {}\n\n  /**\n   * Parse metadata from .opf file found in library scan and update bookMetadata\n   *\n   * @param {import('../models/LibraryItem').LibraryFileObject} opfLibraryFileObj\n   * @param {Object} bookMetadata\n   */\n  async scanBookOpfFile(opfLibraryFileObj, bookMetadata) {\n    const xmlText = await readTextFile(opfLibraryFileObj.metadata.path)\n    const opfMetadata = xmlText ? await parseOpfMetadataXML(xmlText) : null\n    if (opfMetadata) {\n      for (const key in opfMetadata) {\n        if (key === 'tags') {\n          // Add tags only if tags are empty\n          if (opfMetadata.tags.length) {\n            bookMetadata.tags = opfMetadata.tags\n          }\n        } else if (key === 'genres') {\n          // Add genres only if genres are empty\n          if (opfMetadata.genres.length) {\n            bookMetadata.genres = opfMetadata.genres\n          }\n        } else if (key === 'authors') {\n          if (opfMetadata.authors?.length) {\n            bookMetadata.authors = opfMetadata.authors\n          }\n        } else if (key === 'narrators') {\n          if (opfMetadata.narrators?.length) {\n            bookMetadata.narrators = opfMetadata.narrators\n          }\n        } else if (key === 'series') {\n          if (opfMetadata.series?.length) {\n            bookMetadata.series = opfMetadata.series\n          }\n        } else if (opfMetadata[key] && key !== 'sequence') {\n          bookMetadata[key] = opfMetadata[key]\n        }\n      }\n    }\n  }\n}\nmodule.exports = new OpfFileScanner()\n"
  },
  {
    "path": "server/scanner/PodcastScanner.js",
    "content": "const uuidv4 = require('uuid').v4\nconst Path = require('path')\nconst { LogLevel } = require('../utils/constants')\nconst { getTitleIgnorePrefix } = require('../utils/index')\nconst AudioFileScanner = require('./AudioFileScanner')\nconst Database = require('../Database')\nconst { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')\nconst AudioFile = require('../objects/files/AudioFile')\nconst CoverManager = require('../managers/CoverManager')\nconst LibraryFile = require('../objects/files/LibraryFile')\nconst fsExtra = require('../libs/fsExtra')\nconst PodcastEpisode = require('../models/PodcastEpisode')\nconst AbsMetadataFileScanner = require('./AbsMetadataFileScanner')\n\n/**\n * Metadata for podcasts pulled from files\n * @typedef PodcastMetadataObject\n * @property {string} title\n * @property {string} titleIgnorePrefix\n * @property {string} author\n * @property {string} releaseDate\n * @property {string} feedURL\n * @property {string} imageURL\n * @property {string} description\n * @property {string} itunesPageURL\n * @property {string} itunesId\n * @property {string} language\n * @property {string} podcastType\n * @property {string[]} genres\n * @property {string[]} tags\n * @property {boolean} explicit\n */\n\nclass PodcastScanner {\n  constructor() {}\n\n  /**\n   * @param {import('../models/LibraryItem')} existingLibraryItem\n   * @param {import('./LibraryItemScanData')} libraryItemData\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {import('./LibraryScan')} libraryScan\n   * @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}\n   */\n  async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {\n    /** @type {import('../models/Podcast')} */\n    const media = await existingLibraryItem.getMedia({\n      include: [\n        {\n          model: Database.podcastEpisodeModel\n        }\n      ]\n    })\n\n    /** @type {import('../models/PodcastEpisode')[]} */\n    let existingPodcastEpisodes = media.podcastEpisodes\n\n    /** @type {AudioFile[]} */\n    let newAudioFiles = []\n\n    if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {\n      // Filter out and destroy episodes that were removed\n      const episodesToRemove = []\n      existingPodcastEpisodes = existingPodcastEpisodes.filter((ep) => {\n        if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {\n          episodesToRemove.push(ep)\n          return false\n        }\n        return true\n      })\n\n      if (episodesToRemove.length) {\n        // Remove episodes from playlists and media progress\n        const episodeIds = episodesToRemove.map((ep) => ep.id)\n        await Database.playlistModel.removeMediaItemsFromPlaylists(episodeIds)\n        const mediaProgressRemoved = await Database.mediaProgressModel.destroy({\n          where: {\n            mediaItemId: episodeIds\n          }\n        })\n        if (mediaProgressRemoved) {\n          libraryScan.addLog(LogLevel.INFO, `Removed ${mediaProgressRemoved} media progress for episodes`)\n        }\n\n        // Remove episodes\n        await Promise.all(\n          episodesToRemove.map(async (ep) => {\n            await ep.destroy()\n            libraryScan.addLog(LogLevel.INFO, `Podcast episode \"${ep.title}\" audio file was removed`)\n          })\n        )\n      }\n\n      // Update audio files that were modified\n      if (libraryItemData.audioLibraryFilesModified.length) {\n        let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(\n          existingLibraryItem.mediaType,\n          libraryItemData,\n          libraryItemData.audioLibraryFilesModified.map((lf) => lf.new)\n        )\n\n        for (const podcastEpisode of existingPodcastEpisodes) {\n          let matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.metadata.path === podcastEpisode.audioFile.metadata.path)\n          if (!matchedScannedAudioFile) {\n            matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === podcastEpisode.audioFile.ino)\n          }\n\n          if (matchedScannedAudioFile) {\n            scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile)\n            const audioFile = new AudioFile(podcastEpisode.audioFile)\n            audioFile.updateFromScan(matchedScannedAudioFile)\n            podcastEpisode.audioFile = audioFile.toJSON()\n            podcastEpisode.changed('audioFile', true)\n\n            // Set metadata and save episode\n            AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, libraryScan)\n            libraryScan.addLog(LogLevel.INFO, `Podcast episode \"${podcastEpisode.title}\" keys changed [${podcastEpisode.changed()?.join(', ')}]`)\n            await podcastEpisode.save()\n          }\n        }\n\n        // Modified audio files that were not found as a podcast episode\n        if (scannedAudioFiles.length) {\n          newAudioFiles.push(...scannedAudioFiles)\n        }\n      }\n\n      // Add new audio files scanned in\n      if (libraryItemData.audioLibraryFilesAdded.length) {\n        const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded)\n        newAudioFiles.push(...scannedAudioFiles)\n      }\n\n      // Create new podcast episodes from new found audio files\n      for (const newAudioFile of newAudioFiles) {\n        // Podcast episode audio files always have index 1\n        newAudioFile.index = 1\n\n        const newEpisode = {\n          title: newAudioFile.metaTags.tagTitle || newAudioFile.metadata.filenameNoExt,\n          subtitle: null,\n          season: null,\n          episode: null,\n          episodeType: null,\n          pubDate: null,\n          publishedAt: null,\n          description: null,\n          audioFile: newAudioFile.toJSON(),\n          chapters: newAudioFile.chapters || [],\n          podcastId: media.id\n        }\n        const newPodcastEpisode = Database.podcastEpisodeModel.build(newEpisode)\n        // Set metadata and save new episode\n        AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(newPodcastEpisode, libraryScan)\n        libraryScan.addLog(LogLevel.INFO, `New Podcast episode \"${newPodcastEpisode.title}\" added`)\n        await newPodcastEpisode.save()\n        existingPodcastEpisodes.push(newPodcastEpisode)\n      }\n    }\n\n    let hasMediaChanges = false\n    if (existingPodcastEpisodes.length !== media.numEpisodes) {\n      media.numEpisodes = existingPodcastEpisodes.length\n      hasMediaChanges = true\n    }\n\n    // Check if cover was removed\n    if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some((lf) => lf.metadata.path === media.coverPath)) {\n      media.coverPath = null\n      hasMediaChanges = true\n    }\n\n    // Update cover if it was modified\n    if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) {\n      let coverMatch = libraryItemData.imageLibraryFilesModified.find((iFile) => iFile.old.metadata.path === media.coverPath)\n      if (coverMatch) {\n        const coverPath = coverMatch.new.metadata.path\n        if (coverPath !== media.coverPath) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating podcast cover \"${media.coverPath}\" => \"${coverPath}\" for podcast \"${media.title}\"`)\n          media.coverPath = coverPath\n          media.changed('coverPath', true)\n          hasMediaChanges = true\n        }\n      }\n    }\n\n    // Check if cover is not set and image files were found\n    if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {\n      // Prefer using a cover image with the name \"cover\" otherwise use the first image\n      const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\\/cover\\.[^.\\/]*$/.test(iFile.metadata.path))\n      media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path\n      hasMediaChanges = true\n    }\n\n    const podcastMetadata = await this.getPodcastMetadataFromScanData(existingPodcastEpisodes, libraryItemData, libraryScan, existingLibraryItem.id)\n\n    for (const key in podcastMetadata) {\n      // Ignore unset metadata and empty arrays\n      if (podcastMetadata[key] === undefined || (Array.isArray(podcastMetadata[key]) && !podcastMetadata[key].length)) continue\n\n      if (key === 'genres') {\n        const existingGenres = media.genres || []\n        if (podcastMetadata.genres.some((g) => !existingGenres.includes(g)) || existingGenres.some((g) => !podcastMetadata.genres.includes(g))) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres \"${existingGenres.join(',')}\" => \"${podcastMetadata.genres.join(',')}\" for podcast \"${podcastMetadata.title}\"`)\n          media.genres = podcastMetadata.genres\n          media.changed('genres', true)\n          hasMediaChanges = true\n        }\n      } else if (key === 'tags') {\n        const existingTags = media.tags || []\n        if (podcastMetadata.tags.some((t) => !existingTags.includes(t)) || existingTags.some((t) => !podcastMetadata.tags.includes(t))) {\n          libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags \"${existingTags.join(',')}\" => \"${podcastMetadata.tags.join(',')}\" for podcast \"${podcastMetadata.title}\"`)\n          media.tags = podcastMetadata.tags\n          media.changed('tags', true)\n          hasMediaChanges = true\n        }\n      } else if (podcastMetadata[key] !== media[key]) {\n        libraryScan.addLog(LogLevel.DEBUG, `Updating podcast ${key} \"${media[key]}\" => \"${podcastMetadata[key]}\" for podcast \"${podcastMetadata.title}\"`)\n        media[key] = podcastMetadata[key]\n        hasMediaChanges = true\n      }\n    }\n\n    // If no cover then extract cover from audio file if available\n    if (!media.coverPath && existingPodcastEpisodes.length) {\n      const audioFiles = existingPodcastEpisodes.map((ep) => ep.audioFile)\n      const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(audioFiles, existingLibraryItem.id, existingLibraryItem.path)\n      if (extractedCoverPath) {\n        libraryScan.addLog(LogLevel.DEBUG, `Updating podcast \"${podcastMetadata.title}\" extracted embedded cover art from audio file to path \"${extractedCoverPath}\"`)\n        media.coverPath = extractedCoverPath\n        hasMediaChanges = true\n      }\n    }\n\n    existingLibraryItem.media = media\n\n    let libraryItemUpdated = false\n\n    // Save Podcast changes to db\n    if (hasMediaChanges) {\n      await media.save()\n      await this.saveMetadataFile(existingLibraryItem, libraryScan)\n      libraryItemUpdated = global.ServerSettings.storeMetadataWithItem\n    }\n\n    if (libraryItemUpdated) {\n      existingLibraryItem.changed('libraryFiles', true)\n      await existingLibraryItem.save()\n    }\n\n    return {\n      libraryItem: existingLibraryItem,\n      wasUpdated: hasMediaChanges || libraryItemUpdated\n    }\n  }\n\n  /**\n   *\n   * @param {import('./LibraryItemScanData')} libraryItemData\n   * @param {import('../models/Library').LibrarySettingsObject} librarySettings\n   * @param {import('./LibraryScan')} libraryScan\n   * @returns {Promise<import('../models/LibraryItem')>}\n   */\n  async scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) {\n    // Scan audio files found\n    let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(libraryItemData.mediaType, libraryItemData, libraryItemData.audioLibraryFiles)\n\n    // Do not add library items that have no valid audio files\n    if (!scannedAudioFiles.length) {\n      libraryScan.addLog(LogLevel.WARN, `Library item at path \"${libraryItemData.relPath}\" has no audio files - ignoring`)\n      return null\n    }\n\n    const newPodcastEpisodes = []\n\n    // Create podcast episodes from audio files\n    for (const audioFile of scannedAudioFiles) {\n      // Podcast episode audio files always have index 1\n      audioFile.index = 1\n\n      const newEpisode = {\n        title: audioFile.metaTags.tagTitle || audioFile.metadata.filenameNoExt,\n        subtitle: null,\n        season: null,\n        episode: null,\n        episodeType: null,\n        pubDate: null,\n        publishedAt: null,\n        description: null,\n        audioFile: audioFile.toJSON(),\n        chapters: audioFile.chapters || []\n      }\n\n      // Set metadata and save new episode\n      AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(newEpisode, libraryScan)\n      libraryScan.addLog(LogLevel.INFO, `New Podcast episode \"${newEpisode.title}\" found`)\n      newPodcastEpisodes.push(newEpisode)\n    }\n\n    const podcastMetadata = await this.getPodcastMetadataFromScanData(newPodcastEpisodes, libraryItemData, libraryScan)\n    podcastMetadata.explicit = !!podcastMetadata.explicit // Ensure boolean\n\n    // Set cover image from library file\n    if (libraryItemData.imageLibraryFiles.length) {\n      // Prefer using a cover image with the name \"cover\" otherwise use the first image\n      const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\\/cover\\.[^.\\/]*$/.test(iFile.metadata.path))\n      podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path\n    }\n\n    // Set default podcastType to episodic\n    if (!podcastMetadata.podcastType) {\n      podcastMetadata.podcastType = 'episodic'\n    }\n\n    const podcastObject = {\n      ...podcastMetadata,\n      autoDownloadEpisodes: false,\n      autoDownloadSchedule: '0 * * * *',\n      lastEpisodeCheck: 0,\n      maxEpisodesToKeep: 0,\n      maxNewEpisodesToDownload: 3,\n      podcastEpisodes: newPodcastEpisodes,\n      numEpisodes: newPodcastEpisodes.length\n    }\n\n    const libraryItemObj = libraryItemData.libraryItemObject\n    libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image\n    libraryItemObj.isMissing = false\n    libraryItemObj.isInvalid = false\n    libraryItemObj.extraData = {}\n    libraryItemObj.title = podcastObject.title\n    libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(podcastObject.title)\n\n    // If cover was not found in folder then check embedded covers in audio files\n    if (!podcastObject.coverPath && scannedAudioFiles.length) {\n      // Extract and save embedded cover art\n      podcastObject.coverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemObj.path)\n    }\n\n    libraryItemObj.podcast = podcastObject\n    const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {\n      include: {\n        model: Database.podcastModel,\n        include: Database.podcastEpisodeModel\n      }\n    })\n\n    Database.addGenresToFilterData(libraryItemData.libraryId, libraryItem.podcast.genres)\n    Database.addTagsToFilterData(libraryItemData.libraryId, libraryItem.podcast.tags)\n\n    // Load for emitting to client\n    libraryItem.media = await libraryItem.getMedia({\n      include: Database.podcastEpisodeModel\n    })\n\n    await this.saveMetadataFile(libraryItem, libraryScan)\n    if (global.ServerSettings.storeMetadataWithItem) {\n      libraryItem.changed('libraryFiles', true)\n      await libraryItem.save()\n    }\n\n    return libraryItem\n  }\n\n  /**\n   *\n   * @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts\n   * @param {import('./LibraryItemScanData')} libraryItemData\n   * @param {import('./LibraryScan')} libraryScan\n   * @param {string} [existingLibraryItemId]\n   * @returns {Promise<PodcastMetadataObject>}\n   */\n  async getPodcastMetadataFromScanData(podcastEpisodes, libraryItemData, libraryScan, existingLibraryItemId = null) {\n    const podcastMetadata = {\n      title: libraryItemData.mediaMetadata.title,\n      titleIgnorePrefix: undefined,\n      author: undefined,\n      releaseDate: undefined,\n      feedURL: undefined,\n      imageURL: undefined,\n      description: undefined,\n      itunesPageURL: undefined,\n      itunesId: undefined,\n      itunesArtistId: undefined,\n      language: undefined,\n      podcastType: undefined,\n      explicit: undefined,\n      tags: [],\n      genres: []\n    }\n\n    // Use audio meta tags\n    if (podcastEpisodes.length) {\n      AudioFileScanner.setPodcastMetadataFromAudioMetaTags(podcastEpisodes[0].audioFile, podcastMetadata, libraryScan)\n    }\n\n    // Use metadata.json file\n    await AbsMetadataFileScanner.scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId)\n\n    podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)\n\n    return podcastMetadata\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {import('./LibraryScan')} libraryScan\n   * @returns {Promise}\n   */\n  async saveMetadataFile(libraryItem, libraryScan) {\n    let metadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id)\n    let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem\n    if (storeMetadataWithItem) {\n      metadataPath = libraryItem.path\n    } else {\n      // Make sure metadata book dir exists\n      storeMetadataWithItem = false\n      await fsExtra.ensureDir(metadataPath)\n    }\n\n    const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)\n\n    const jsonObject = {\n      tags: libraryItem.media.tags || [],\n      title: libraryItem.media.title,\n      author: libraryItem.media.author,\n      description: libraryItem.media.description,\n      releaseDate: libraryItem.media.releaseDate,\n      genres: libraryItem.media.genres || [],\n      feedURL: libraryItem.media.feedURL,\n      imageURL: libraryItem.media.imageURL,\n      itunesPageURL: libraryItem.media.itunesPageURL,\n      itunesId: libraryItem.media.itunesId,\n      itunesArtistId: libraryItem.media.itunesArtistId,\n      asin: libraryItem.media.asin,\n      language: libraryItem.media.language,\n      explicit: !!libraryItem.media.explicit,\n      podcastType: libraryItem.media.podcastType\n    }\n    return fsExtra\n      .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))\n      .then(async () => {\n        // Add metadata.json to libraryFiles array if it is new\n        let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))\n        if (storeMetadataWithItem) {\n          if (!metadataLibraryFile) {\n            const newLibraryFile = new LibraryFile()\n            await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)\n            metadataLibraryFile = newLibraryFile.toJSON()\n            libraryItem.libraryFiles.push(metadataLibraryFile)\n          } else {\n            const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)\n            if (fileTimestamps) {\n              metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs\n              metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs\n              metadataLibraryFile.metadata.size = fileTimestamps.size\n              metadataLibraryFile.ino = fileTimestamps.ino\n            }\n          }\n          const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)\n          if (libraryItemDirTimestamps) {\n            libraryItem.mtime = libraryItemDirTimestamps.mtimeMs\n            libraryItem.ctime = libraryItemDirTimestamps.ctimeMs\n            let size = 0\n            libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))\n            libraryItem.size = size\n          }\n        }\n\n        libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to \"${metadataFilePath}\"`)\n\n        return metadataLibraryFile\n      })\n      .catch((error) => {\n        libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at \"${metadataFilePath}\"`, error)\n        return null\n      })\n  }\n}\nmodule.exports = new PodcastScanner()\n"
  },
  {
    "path": "server/scanner/ScanLogger.js",
    "content": "const uuidv4 = require('uuid').v4\nconst Logger = require('../Logger')\n\nclass ScanLogger {\n  constructor() {\n    this.id = null\n    this.type = null\n    this.name = null\n    this.verbose = false\n\n    this.startedAt = null\n    this.finishedAt = null\n    this.elapsed = null\n\n    /** @type {string[]} */\n    this.authorsRemovedFromBooks = []\n    /** @type {string[]} */\n    this.seriesRemovedFromBooks = []\n\n    this.logs = []\n  }\n\n  toJSON() {\n    return {\n      id: this.id,\n      type: this.type,\n      name: this.name,\n      startedAt: this.startedAt,\n      finishedAt: this.finishedAt,\n      elapsed: this.elapsed\n    }\n  }\n\n  setData(type, name) {\n    this.id = uuidv4()\n    this.type = type\n    this.name = name\n    this.startedAt = Date.now()\n  }\n\n  setComplete() {\n    this.finishedAt = Date.now()\n    this.elapsed = this.finishedAt - this.startedAt\n  }\n\n  addLog(level, ...args) {\n    const logObj = {\n      timestamp: new Date().toISOString(),\n      message: args.join(' '),\n      levelName: Logger.getLogLevelString(level),\n      level\n    }\n\n    if (this.verbose) {\n      Logger.debug(`[Scan] \"${this.name}\":`, ...args)\n    }\n    this.logs.push(logObj)\n  }\n}\nmodule.exports = ScanLogger\n"
  },
  {
    "path": "server/scanner/Scanner.js",
    "content": "const Logger = require('../Logger')\nconst SocketAuthority = require('../SocketAuthority')\nconst Database = require('../Database')\nconst { getTitleIgnorePrefix } = require('../utils/index')\n\n// Utils\nconst { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcastUtils')\n\nconst BookFinder = require('../finders/BookFinder')\nconst PodcastFinder = require('../finders/PodcastFinder')\nconst LibraryScan = require('./LibraryScan')\nconst LibraryScanner = require('./LibraryScanner')\nconst CoverManager = require('../managers/CoverManager')\nconst TaskManager = require('../managers/TaskManager')\n\n/**\n * @typedef QuickMatchOptions\n * @property {string} [provider]\n * @property {string} [title]\n * @property {string} [author]\n * @property {string} [isbn] - This override is currently unused in Abs clients\n * @property {string} [asin] - This override is currently unused in Abs clients\n * @property {boolean} [overrideCover]\n * @property {boolean} [overrideDetails]\n */\n\nclass Scanner {\n  constructor() {}\n\n  /**\n   *\n   * @param {import('../routers/ApiRouter')} apiRouterCtx\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {QuickMatchOptions} options\n   * @returns {Promise<{updated: boolean, libraryItem: Object}>}\n   */\n  async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {\n    const provider = options.provider || 'google'\n    const searchTitle = options.title || libraryItem.media.title\n    const searchAuthor = options.author || libraryItem.media.authorName\n\n    // If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override\n    if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {\n      options.overrideCover = true\n      options.overrideDetails = true\n    }\n\n    let updatePayload = {}\n    let hasUpdated = false\n\n    if (libraryItem.isBook) {\n      const searchISBN = options.isbn || libraryItem.media.isbn\n      const searchASIN = options.asin || libraryItem.media.asin\n\n      const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })\n      if (!results.length) {\n        return {\n          warning: `No ${provider} match found`\n        }\n      }\n      const matchData = results[0]\n\n      // Update cover if not set OR overrideCover flag\n      if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {\n        Logger.debug(`[Scanner] Updating cover \"${matchData.cover}\"`)\n        const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.isFile ? null : libraryItem.path)\n        if (coverResult.error) {\n          Logger.warn(`[Scanner] Match cover \"${matchData.cover}\" failed to use: ${coverResult.error}`)\n        } else {\n          libraryItem.media.coverPath = coverResult.cover\n          libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update\n          hasUpdated = true\n        }\n      }\n\n      const bookBuildUpdateData = await this.quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options)\n      updatePayload = bookBuildUpdateData.updatePayload\n      if (bookBuildUpdateData.hasSeriesUpdates || bookBuildUpdateData.hasAuthorUpdates) {\n        hasUpdated = true\n      }\n    } else if (libraryItem.isPodcast) {\n      // Podcast quick match\n      const results = await PodcastFinder.search(searchTitle)\n      if (!results.length) {\n        return {\n          warning: `No ${provider} match found`\n        }\n      }\n      const matchData = results[0]\n\n      // Update cover if not set OR overrideCover flag\n      if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {\n        Logger.debug(`[Scanner] Updating cover \"${matchData.cover}\"`)\n        const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.path)\n        if (coverResult.error) {\n          Logger.warn(`[Scanner] Match cover \"${matchData.cover}\" failed to use: ${coverResult.error}`)\n        } else {\n          libraryItem.media.coverPath = coverResult.cover\n          libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update\n          hasUpdated = true\n        }\n      }\n\n      updatePayload = this.quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options)\n    }\n\n    if (Object.keys(updatePayload).length) {\n      Logger.debug('[Scanner] Updating details with payload', updatePayload)\n      libraryItem.media.set(updatePayload)\n      if (libraryItem.media.changed()) {\n        Logger.debug(`[Scanner] Updating library item \"${libraryItem.media.title}\" keys`, libraryItem.media.changed())\n        hasUpdated = true\n      }\n    }\n\n    if (hasUpdated) {\n      if (libraryItem.isPodcast && libraryItem.media.feedURL) {\n        // Quick match all unmatched podcast episodes\n        await this.quickMatchPodcastEpisodes(libraryItem, options)\n      }\n\n      await libraryItem.media.save()\n\n      libraryItem.changed('updatedAt', true)\n      await libraryItem.save()\n\n      await libraryItem.saveMetadataFile()\n\n      SocketAuthority.libraryItemEmitter('item_updated', libraryItem)\n    }\n\n    return {\n      updated: hasUpdated,\n      libraryItem: libraryItem.toOldJSONExpanded()\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} matchData\n   * @param {QuickMatchOptions} options\n   * @returns {Map<string, any>} - Update payload\n   */\n  quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {\n    const updatePayload = {}\n\n    const matchDataTransformed = {\n      title: matchData.title || null,\n      author: matchData.artistName || null,\n      genres: matchData.genres || [],\n      itunesId: matchData.id || null,\n      itunesPageUrl: matchData.pageUrl || null,\n      itunesArtistId: matchData.artistId || null,\n      releaseDate: matchData.releaseDate || null,\n      imageUrl: matchData.cover || null,\n      feedUrl: matchData.feedUrl || null,\n      description: matchData.descriptionPlain || null\n    }\n\n    for (const key in matchDataTransformed) {\n      if (matchDataTransformed[key]) {\n        if (key === 'genres') {\n          if (!libraryItem.media.genres.length || options.overrideDetails) {\n            var genresArray = []\n            if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]\n            else {\n              // Genres should always be passed in as an array but just incase handle a string\n              Logger.warn(`[Scanner] quickMatch genres is not an array ${matchDataTransformed[key]}`)\n              genresArray = matchDataTransformed[key]\n                .split(',')\n                .map((v) => v.trim())\n                .filter((v) => !!v)\n            }\n            updatePayload[key] = genresArray\n          }\n        } else if (libraryItem.media[key] !== matchDataTransformed[key] && (!libraryItem.media[key] || options.overrideDetails)) {\n          updatePayload[key] = matchDataTransformed[key]\n        }\n      }\n    }\n\n    return updatePayload\n  }\n\n  /**\n   *\n   * @param {import('../routers/ApiRouter')} apiRouterCtx\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {*} matchData\n   * @param {QuickMatchOptions} options\n   * @returns {Promise<{updatePayload: Map<string, any>, seriesIdsRemoved: string[], hasSeriesUpdates: boolean, authorIdsRemoved: string[], hasAuthorUpdates: boolean}>}\n   */\n  async quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) {\n    // Update media metadata if not set OR overrideDetails flag\n    const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']\n    const updatePayload = {}\n\n    for (const key in matchData) {\n      if (matchData[key] && detailKeysToUpdate.includes(key)) {\n        if (key === 'narrator') {\n          if (!libraryItem.media.narrators?.length || options.overrideDetails) {\n            updatePayload.narrators = matchData[key]\n              .split(',')\n              .map((v) => v.trim())\n              .filter((v) => !!v)\n          }\n        } else if (key === 'genres') {\n          if (!libraryItem.media.genres.length || options.overrideDetails) {\n            let genresArray = []\n            if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]\n            else {\n              // Genres should always be passed in as an array but just incase handle a string\n              Logger.warn(`[Scanner] quickMatch genres is not an array ${matchData[key]}`)\n              genresArray = matchData[key]\n                .split(',')\n                .map((v) => v.trim())\n                .filter((v) => !!v)\n            }\n            updatePayload[key] = genresArray\n          }\n        } else if (key === 'tags') {\n          if (!libraryItem.media.tags.length || options.overrideDetails) {\n            let tagsArray = []\n            if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]\n            else\n              tagsArray = matchData[key]\n                .split(',')\n                .map((v) => v.trim())\n                .filter((v) => !!v)\n            updatePayload[key] = tagsArray\n          }\n        } else if (!libraryItem.media[key] || options.overrideDetails) {\n          updatePayload[key] = matchData[key]\n        }\n      }\n    }\n\n    // Add or set author if not set\n    let hasAuthorUpdates = false\n    if (matchData.author && (!libraryItem.media.authorName || options.overrideDetails)) {\n      if (!Array.isArray(matchData.author)) {\n        matchData.author = matchData.author\n          .split(',')\n          .map((au) => au.trim())\n          .filter((au) => !!au)\n      }\n      const authorIdsRemoved = []\n      for (const authorName of matchData.author) {\n        const existingAuthor = libraryItem.media.authors.find((a) => a.name.toLowerCase() === authorName.toLowerCase())\n        if (!existingAuthor) {\n          let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId)\n          if (!author) {\n            author = await Database.authorModel.create({\n              name: authorName,\n              lastFirst: Database.authorModel.getLastFirst(authorName),\n              libraryId: libraryItem.libraryId\n            })\n            SocketAuthority.emitter('author_added', author.toOldJSON())\n            // Update filter data\n            Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)\n          }\n          await Database.bookAuthorModel\n            .create({\n              authorId: author.id,\n              bookId: libraryItem.media.id\n            })\n            .then(() => {\n              Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author \"${author.name}\" to \"${libraryItem.media.title}\"`)\n              libraryItem.media.authors.push(author)\n              hasAuthorUpdates = true\n            })\n        }\n        const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase()))\n        if (authorsRemoved.length) {\n          for (const author of authorsRemoved) {\n            await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } })\n            libraryItem.media.authors = libraryItem.media.authors.filter((a) => a.id !== author.id)\n            authorIdsRemoved.push(author.id)\n            Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed author \"${author.name}\" from \"${libraryItem.media.title}\"`)\n          }\n          hasAuthorUpdates = true\n        }\n      }\n\n      // For all authors removed from book, check if they are empty now and should be removed\n      if (authorIdsRemoved.length) {\n        await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)\n      }\n    }\n\n    // Add or set series if not set\n    let hasSeriesUpdates = false\n    if (matchData.series && (!libraryItem.media.seriesName || options.overrideDetails)) {\n      if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]\n      const seriesIdsRemoved = []\n      for (const seriesMatchItem of matchData.series) {\n        const existingSeries = libraryItem.media.series.find((s) => s.name.toLowerCase() === seriesMatchItem.series.toLowerCase())\n        if (existingSeries) {\n          if (existingSeries.bookSeries.sequence !== seriesMatchItem.sequence) {\n            existingSeries.bookSeries.sequence = seriesMatchItem.sequence\n            await existingSeries.bookSeries.save()\n            Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for \"${existingSeries.name}\" to ${seriesMatchItem.sequence} in \"${libraryItem.media.title}\"`)\n            hasSeriesUpdates = true\n          }\n        } else {\n          let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)\n          if (!seriesItem) {\n            seriesItem = await Database.seriesModel.create({\n              name: seriesMatchItem.series,\n              nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),\n              libraryId: libraryItem.libraryId\n            })\n            // Update filter data\n            Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)\n            SocketAuthority.emitter('series_added', seriesItem.toOldJSON())\n          }\n          const bookSeries = await Database.bookSeriesModel.create({\n            seriesId: seriesItem.id,\n            bookId: libraryItem.media.id,\n            sequence: seriesMatchItem.sequence\n          })\n          seriesItem.bookSeries = bookSeries\n          libraryItem.media.series.push(seriesItem)\n          Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added series \"${seriesItem.name}\" to \"${libraryItem.media.title}\"`)\n          hasSeriesUpdates = true\n        }\n        const seriesRemoved = libraryItem.media.series.filter((s) => !matchData.series.find((ms) => ms.series.toLowerCase() === s.name.toLowerCase()))\n        if (seriesRemoved.length) {\n          for (const series of seriesRemoved) {\n            await series.bookSeries.destroy()\n            libraryItem.media.series = libraryItem.media.series.filter((s) => s.id !== series.id)\n            seriesIdsRemoved.push(series.id)\n            Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed series \"${series.name}\" from \"${libraryItem.media.title}\"`)\n          }\n          hasSeriesUpdates = true\n        }\n      }\n\n      // For all series removed from book, check if it is empty now and should be removed\n      if (seriesIdsRemoved.length) {\n        await apiRouterCtx.checkRemoveEmptySeries(seriesIdsRemoved)\n      }\n    }\n\n    return {\n      updatePayload,\n      hasSeriesUpdates,\n      hasAuthorUpdates\n    }\n  }\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')} libraryItem\n   * @param {QuickMatchOptions} options\n   * @returns {Promise<number>} - Number of episodes updated\n   */\n  async quickMatchPodcastEpisodes(libraryItem, options = {}) {\n    /** @type {import('../models/PodcastEpisode')[]} */\n    const episodesToQuickMatch = libraryItem.media.podcastEpisodes.filter((ep) => !ep.enclosureURL) // Only quick match episodes that are not already matched\n    if (!episodesToQuickMatch.length) return 0\n\n    const feed = await getPodcastFeed(libraryItem.media.feedURL)\n    if (!feed) {\n      Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for \"${libraryItem.media.feedURL}\"`)\n      return 0\n    }\n\n    let numEpisodesUpdated = 0\n    for (const episode of episodesToQuickMatch) {\n      const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title, 0.1)\n      if (episodeMatches?.length) {\n        const wasUpdated = await this.updateEpisodeWithMatch(episode, episodeMatches[0].episode, options)\n        if (wasUpdated) numEpisodesUpdated++\n      }\n    }\n    if (numEpisodesUpdated) {\n      Logger.info(`[Scanner] quickMatchPodcastEpisodes: Updated ${numEpisodesUpdated} episodes for \"${libraryItem.media.title}\"`)\n    }\n    return numEpisodesUpdated\n  }\n\n  /**\n   *\n   * @param {import('../models/PodcastEpisode')} episode\n   * @param {import('../utils/podcastUtils').RssPodcastEpisode} episodeToMatch\n   * @param {QuickMatchOptions} options\n   * @returns {Promise<boolean>} - true if episode was updated\n   */\n  async updateEpisodeWithMatch(episode, episodeToMatch, options = {}) {\n    Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for \"${episode.title}\" => ${episodeToMatch.title}`)\n    const matchDataTransformed = {\n      title: episodeToMatch.title || '',\n      subtitle: episodeToMatch.subtitle || '',\n      description: episodeToMatch.description || '',\n      enclosureURL: episodeToMatch.enclosure?.url || null,\n      enclosureSize: episodeToMatch.enclosure?.length || null,\n      enclosureType: episodeToMatch.enclosure?.type || null,\n      episode: episodeToMatch.episode || '',\n      episodeType: episodeToMatch.episodeType || 'full',\n      season: episodeToMatch.season || '',\n      pubDate: episodeToMatch.pubDate || '',\n      publishedAt: episodeToMatch.publishedAt\n    }\n    const updatePayload = {}\n    for (const key in matchDataTransformed) {\n      if (matchDataTransformed[key]) {\n        if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {\n          updatePayload[key] = matchDataTransformed[key]\n        }\n      }\n    }\n\n    if (Object.keys(updatePayload).length) {\n      episode.set(updatePayload)\n      if (episode.changed()) {\n        Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Updating episode \"${episode.title}\" keys`, episode.changed())\n        await episode.save()\n        return true\n      }\n    }\n    return false\n  }\n\n  /**\n   * Quick match library items\n   *\n   * @param {import('../routers/ApiRouter')} apiRouterCtx\n   * @param {import('../models/Library')} library\n   * @param {import('../models/LibraryItem')[]} libraryItems\n   * @param {LibraryScan} libraryScan\n   * @returns {Promise<boolean>} false if scan canceled\n   */\n  async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) {\n    for (let i = 0; i < libraryItems.length; i++) {\n      const libraryItem = libraryItems[i]\n\n      if (libraryItem.media.asin && library.settings.skipMatchingMediaWithAsin) {\n        Logger.debug(`[Scanner] matchLibraryItems: Skipping \"${libraryItem.media.title}\" because it already has an ASIN (${i + 1} of ${libraryItems.length})`)\n        continue\n      }\n\n      if (libraryItem.media.isbn && library.settings.skipMatchingMediaWithIsbn) {\n        Logger.debug(`[Scanner] matchLibraryItems: Skipping \"${libraryItem.media.title}\" because it already has an ISBN (${i + 1} of ${libraryItems.length})`)\n        continue\n      }\n\n      Logger.debug(`[Scanner] matchLibraryItems: Quick matching \"${libraryItem.media.title}\" (${i + 1} of ${libraryItems.length})`)\n      const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider })\n      if (result.warning) {\n        Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item \"${libraryItem.media.title}\"`)\n      } else if (result.updated) {\n        libraryScan.resultsUpdated++\n      }\n\n      if (LibraryScanner.cancelLibraryScan[libraryScan.libraryId]) {\n        Logger.info(`[Scanner] matchLibraryItems: Library match scan canceled for \"${libraryScan.libraryName}\"`)\n        return false\n      }\n    }\n\n    return true\n  }\n\n  /**\n   * Quick match all library items for library\n   *\n   * @param {import('../routers/ApiRouter')} apiRouterCtx\n   * @param {import('../models/Library')} library\n   */\n  async matchLibraryItems(apiRouterCtx, library) {\n    if (library.mediaType === 'podcast') {\n      Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)\n      return\n    }\n\n    if (LibraryScanner.isLibraryScanning(library.id)) {\n      Logger.error(`[Scanner] Library \"${library.name}\" is already scanning`)\n      return\n    }\n\n    const limit = 100\n    let offset = 0\n\n    const libraryScan = new LibraryScan()\n    libraryScan.setData(library, 'match')\n    LibraryScanner.librariesScanning.push(libraryScan.libraryId)\n    const taskData = {\n      libraryId: library.id\n    }\n    const taskTitleString = {\n      text: `Matching books in \"${library.name}\"`,\n      key: 'MessageTaskMatchingBooksInLibrary',\n      subs: [library.name]\n    }\n    const task = TaskManager.createAndAddTask('library-match-all', taskTitleString, null, true, taskData)\n    Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`)\n\n    let hasMoreChunks = true\n    let isCanceled = false\n    while (hasMoreChunks) {\n      const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, limit, { libraryId: library.id })\n      if (!libraryItems.length) {\n        break\n      }\n\n      offset += limit\n      hasMoreChunks = libraryItems.length === limit\n\n      const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan)\n      if (!shouldContinue) {\n        isCanceled = true\n        break\n      }\n    }\n\n    if (offset === 0) {\n      Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`)\n      libraryScan.setComplete()\n      const taskFailedString = {\n        text: 'No items found',\n        key: 'MessageNoItemsFound'\n      }\n      task.setFailed(taskFailedString)\n    } else {\n      libraryScan.setComplete()\n\n      task.data.scanResults = libraryScan.scanResults\n      if (isCanceled) {\n        const taskFinishedString = {\n          text: 'Task canceled by user',\n          key: 'MessageTaskCanceledByUser'\n        }\n        task.setFinished(taskFinishedString)\n      } else {\n        task.setFinished(null, true)\n      }\n    }\n\n    delete LibraryScanner.cancelLibraryScan[libraryScan.libraryId]\n    LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter((lid) => lid !== library.id)\n    TaskManager.taskFinished(task)\n  }\n}\nmodule.exports = new Scanner()\n"
  },
  {
    "path": "server/utils/areEquivalent.js",
    "content": "/**\n * https://gist.github.com/DLiblik/96801665f9b6c935f12c1071d37eae95\n Compares two items (values or references) for nested equivalency, meaning that\n at root and at each key or index they are equivalent as follows:\n - If a value type, values are either hard equal (===) or are both NaN\n     (different than JS where NaN !== NaN)\n - If functions, they are the same function instance or have the same value\n     when converted to string via `toString()`\n - If Date objects, both have the same getTime() or are both NaN (invalid)\n - If arrays, both are same length, and all contained values areEquivalent\n     recursively - only contents by numeric key are checked\n - If other object types, enumerable keys are the same (the keys themselves)\n     and values at every key areEquivalent recursively\n Author: Dathan Liblik\n License: Free to use anywhere by anyone, as-is, no guarantees of any kind.\n @param value1 First item to compare\n @param value2 Other item to compare\n @param stack Used internally to track circular refs - don't set it\n */\nmodule.exports = function areEquivalent(value1, value2, numToString = false, stack = []) {\n  if (numToString) {\n    if (value1 !== null && !isNaN(value1)) value1 = String(value1)\n    if (value2 !== null && !isNaN(value2)) value2 = String(value2)\n  }\n\n  // Numbers, strings, null, undefined, symbols, functions, booleans.\n  // Also: objects (incl. arrays) that are actually the same instance\n  if (value1 === value2) {\n    // Fast and done\n    return true\n  }\n\n  // Truthy check to handle value1=null, value2=Object\n  if ((value1 && !value2) || (!value1 && value2)) {\n    // console.log('value1/value2 falsy mismatch', value1, value2)\n    return false\n  }\n\n  const type1 = typeof value1\n\n  // Ensure types match\n  if (type1 !== typeof value2) {\n    // console.log('type diff', type1, typeof value2)\n    return false\n  }\n\n  // Special case for number: check for NaN on both sides\n  // (only way they can still be equivalent but not equal)\n  if (type1 === 'number') {\n    // Failed initial equals test, but could still both be NaN\n    return (isNaN(value1) && isNaN(value2));\n  }\n\n  // Special case for function: check for toString() equivalence\n  if (type1 === 'function') {\n    // Failed initial equals test, but could still have equivalent\n    // implementations - note, will match on functions that have same name\n    // and are native code: `function abc() { [native code] }`\n    return value1.toString() === value2.toString()\n  }\n\n  // For these types, cannot still be equal at this point, so fast-fail\n  if (type1 === 'bigint' || type1 === 'boolean' ||\n    type1 === 'function' || type1 === 'string' ||\n    type1 === 'symbol') {\n    // console.log('no match for values', value1, value2)\n    return false\n  }\n\n  // For dates, cast to number and ensure equal or both NaN (note, if same\n  // exact instance then we're not here - that was checked above)\n  if (value1 instanceof Date) {\n    if (!(value2 instanceof Date)) {\n      return false\n    }\n    // Convert to number to compare\n    const asNum1 = +value1, asNum2 = +value2\n    // Check if both invalid (NaN) or are same value\n    return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2))\n  }\n\n  // At this point, it's a reference type and could be circular, so\n  // make sure we haven't been here before... note we only need to track value1\n  // since value1 being un-circular means value2 will either be equal (and not\n  // circular too) or unequal whether circular or not.\n  if (stack.includes(value1)) {\n    throw new Error(`areEquivalent value1 is circular`);\n  }\n\n  // breadcrumb\n  stack.push(value1)\n\n  // Handle arrays\n  if (Array.isArray(value1)) {\n    if (!Array.isArray(value2)) {\n      return false\n    }\n\n    const length = value1.length\n\n    if (length !== value2.length) {\n      return false\n    }\n\n    for (let i = 0; i < length; i++) {\n      if (!areEquivalent(value1[i], value2[i], numToString, stack)) {\n        return false\n      }\n    }\n    return true\n  }\n\n  // Final case: object\n\n  // get both key lists and check length\n  const keys1 = Object.keys(value1)\n  const keys2 = Object.keys(value2)\n  const numKeys = keys1.length\n\n  if (keys2.length !== numKeys) {\n    return false\n  }\n\n  // Empty object on both sides?\n  if (numKeys === 0) {\n    return true\n  }\n\n  // sort is a native call so it's very fast - much faster than comparing the\n  // values at each key if it can be avoided, so do the sort and then\n  // ensure every key matches at every index\n  keys1.sort()\n  keys2.sort()\n\n  // Ensure perfect match across all keys\n  for (let i = 0; i < numKeys; i++) {\n    if (keys1[i] !== keys2[i]) {\n      // console.log('object key is not equiv', keys1[i], keys2[i])\n      return false\n    }\n  }\n\n  // Ensure perfect match across all values\n  for (let i = 0; i < numKeys; i++) {\n    if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], numToString, stack)) {\n      // console.log('2 subobjects not equiv', keys1[i], value1[keys1[i]], value2[keys1[i]])\n      return false\n    }\n  }\n\n  // back up\n  stack.pop();\n\n  // Walk the same, talk the same - matching ducks. Quack.\n  // 🦆🦆\n  return true;\n}"
  },
  {
    "path": "server/utils/comicBookExtractors.js",
    "content": "const Path = require('path')\nconst os = require('os')\nconst unrar = require('node-unrar-js')\nconst Logger = require('../Logger')\nconst fs = require('../libs/fsExtra')\nconst StreamZip = require('../libs/nodeStreamZip')\nconst Archive = require('../libs/libarchive/archive')\nconst { isWritable } = require('./fileUtils')\n\nclass AbstractComicBookExtractor {\n  constructor(comicPath) {\n    this.comicPath = comicPath\n  }\n\n  async getBuffer() {\n    if (!(await fs.pathExists(this.comicPath))) {\n      Logger.error(`[parseComicMetadata] Comic path does not exist \"${this.comicPath}\"`)\n      return null\n    }\n    try {\n      return fs.readFile(this.comicPath)\n    } catch (error) {\n      Logger.error(`[parseComicMetadata] Failed to read comic at \"${this.comicPath}\"`, error)\n      return null\n    }\n  }\n\n  async open() {\n    throw new Error('Not implemented')\n  }\n\n  async getFilePaths() {\n    throw new Error('Not implemented')\n  }\n\n  async extractToFile(filePath, outputFilePath) {\n    throw new Error('Not implemented')\n  }\n\n  async extractToBuffer(filePath) {\n    throw new Error('Not implemented')\n  }\n\n  close() {\n    throw new Error('Not implemented')\n  }\n}\n\nclass CbrComicBookExtractor extends AbstractComicBookExtractor {\n  constructor(comicPath) {\n    super(comicPath)\n    this.archive = null\n    this.tmpDir = null\n  }\n\n  async open() {\n    this.tmpDir = global.MetadataPath ? Path.join(global.MetadataPath, 'tmp') : os.tmpdir()\n    await fs.ensureDir(this.tmpDir)\n    if (!(await isWritable(this.tmpDir))) throw new Error(`[CbrComicBookExtractor] Temp directory \"${this.tmpDir}\" is not writable`)\n    this.archive = await unrar.createExtractorFromFile({ filepath: this.comicPath, targetPath: this.tmpDir })\n    Logger.debug(`[CbrComicBookExtractor] Opened comic book \"${this.comicPath}\". Using temp directory \"${this.tmpDir}\" for extraction.`)\n  }\n\n  async getFilePaths() {\n    if (!this.archive) return null\n    const list = this.archive.getFileList()\n    const fileHeaders = [...list.fileHeaders]\n    const filePaths = fileHeaders.filter((fh) => !fh.flags.directory).map((fh) => fh.name)\n    Logger.debug(`[CbrComicBookExtractor] Found ${filePaths.length} files in comic book \"${this.comicPath}\"`)\n    return filePaths\n  }\n\n  async removeEmptyParentDirs(file) {\n    let dir = Path.dirname(file)\n    while (dir !== '.') {\n      const fullDirPath = Path.join(this.tmpDir, dir)\n      const files = await fs.readdir(fullDirPath)\n      if (files.length > 0) break\n      await fs.remove(fullDirPath)\n      dir = Path.dirname(dir)\n    }\n  }\n\n  async extractToBuffer(file) {\n    if (!this.archive) return null\n    const extracted = this.archive.extract({ files: [file] })\n    const files = [...extracted.files]\n    const filePath = Path.join(this.tmpDir, files[0].fileHeader.name)\n    const fileData = await fs.readFile(filePath)\n    await fs.remove(filePath)\n    await this.removeEmptyParentDirs(files[0].fileHeader.name)\n    Logger.debug(`[CbrComicBookExtractor] Extracted file \"${file}\" from comic book \"${this.comicPath}\" to buffer, size: ${fileData.length}`)\n    return fileData\n  }\n\n  async extractToFile(file, outputFilePath) {\n    if (!this.archive) return false\n    const extracted = this.archive.extract({ files: [file] })\n    const files = [...extracted.files]\n    const extractedFilePath = Path.join(this.tmpDir, files[0].fileHeader.name)\n    await fs.move(extractedFilePath, outputFilePath, { overwrite: true })\n    await this.removeEmptyParentDirs(files[0].fileHeader.name)\n    Logger.debug(`[CbrComicBookExtractor] Extracted file \"${file}\" from comic book \"${this.comicPath}\" to \"${outputFilePath}\"`)\n    return true\n  }\n\n  close() {\n    Logger.debug(`[CbrComicBookExtractor] Closed comic book \"${this.comicPath}\"`)\n  }\n}\n\nclass CbzComicBookExtractor extends AbstractComicBookExtractor {\n  constructor(comicPath) {\n    super(comicPath)\n    this.archive = null\n  }\n\n  async open() {\n    const buffer = await this.getBuffer()\n    this.archive = await Archive.open(buffer)\n    Logger.debug(`[CbzComicBookExtractor] Opened comic book \"${this.comicPath}\"`)\n  }\n\n  async getFilePaths() {\n    if (!this.archive) return null\n    const list = await this.archive.getFilesArray()\n    const fileNames = list.map((fo) => fo.file._path)\n    Logger.debug(`[CbzComicBookExtractor] Found ${fileNames.length} files in comic book \"${this.comicPath}\"`)\n    return fileNames\n  }\n\n  async extractToBuffer(file) {\n    if (!this.archive) return null\n    const extracted = await this.archive.extractSingleFile(file)\n    Logger.debug(`[CbzComicBookExtractor] Extracted file \"${file}\" from comic book \"${this.comicPath}\" to buffer, size: ${extracted?.fileData.length}`)\n    return extracted?.fileData\n  }\n\n  async extractToFile(file, outputFilePath) {\n    const data = await this.extractToBuffer(file)\n    if (!data) return false\n    await fs.writeFile(outputFilePath, data)\n    Logger.debug(`[CbzComicBookExtractor] Extracted file \"${file}\" from comic book \"${this.comicPath}\" to \"${outputFilePath}\"`)\n    return true\n  }\n\n  close() {\n    this.archive?.close()\n    Logger.debug(`[CbzComicBookExtractor] Closed comic book \"${this.comicPath}\"`)\n  }\n}\n\nclass CbzStreamZipComicBookExtractor extends AbstractComicBookExtractor {\n  constructor(comicPath) {\n    super(comicPath)\n    this.archive = null\n  }\n\n  async open() {\n    this.archive = new StreamZip.async({ file: this.comicPath })\n    Logger.debug(`[CbzStreamZipComicBookExtractor] Opened comic book \"${this.comicPath}\"`)\n  }\n\n  async getFilePaths() {\n    if (!this.archive) return null\n    const entries = await this.archive.entries()\n    const fileNames = Object.keys(entries).filter((entry) => !entries[entry].isDirectory)\n    Logger.debug(`[CbzStreamZipComicBookExtractor] Found ${fileNames.length} files in comic book \"${this.comicPath}\"`)\n    return fileNames\n  }\n\n  async extractToBuffer(file) {\n    if (!this.archive) return null\n    const extracted = await this.archive?.entryData(file)\n    Logger.debug(`[CbzStreamZipComicBookExtractor] Extracted file \"${file}\" from comic book \"${this.comicPath}\" to buffer, size: ${extracted.length}`)\n    return extracted\n  }\n\n  async extractToFile(file, outputFilePath) {\n    if (!this.archive) return false\n    try {\n      await this.archive.extract(file, outputFilePath)\n      Logger.debug(`[CbzStreamZipComicBookExtractor] Extracted file \"${file}\" from comic book \"${this.comicPath}\" to \"${outputFilePath}\"`)\n      return true\n    } catch (error) {\n      Logger.error(`[CbzStreamZipComicBookExtractor] Failed to extract file \"${file}\" to \"${outputFilePath}\"`, error)\n      return false\n    }\n  }\n\n  close() {\n    this.archive\n      ?.close()\n      .then(() => {\n        Logger.debug(`[CbzStreamZipComicBookExtractor] Closed comic book \"${this.comicPath}\"`)\n      })\n      .catch((error) => {\n        Logger.error(`[CbzStreamZipComicBookExtractor] Failed to close comic book \"${this.comicPath}\"`, error)\n      })\n  }\n}\n\nfunction createComicBookExtractor(comicPath) {\n  const ext = Path.extname(comicPath).toLowerCase()\n  if (ext === '.cbr') {\n    return new CbrComicBookExtractor(comicPath)\n  } else if (ext === '.cbz') {\n    return new CbzStreamZipComicBookExtractor(comicPath)\n  } else {\n    throw new Error(`Unsupported comic book format \"${ext}\"`)\n  }\n}\nmodule.exports = { createComicBookExtractor }\n"
  },
  {
    "path": "server/utils/constants.js",
    "content": "module.exports.ScanResult = {\n  NOTHING: 0,\n  ADDED: 1,\n  UPDATED: 2,\n  REMOVED: 3,\n  UPTODATE: 4\n}\n\nmodule.exports.BookCoverAspectRatio = {\n  STANDARD: 0, // 1.6:1\n  SQUARE: 1\n}\n\nmodule.exports.BookshelfView = {\n  STANDARD: 0,\n  DETAIL: 1\n}\n\nmodule.exports.LogLevel = {\n  TRACE: 0,\n  DEBUG: 1,\n  INFO: 2,\n  WARN: 3,\n  ERROR: 4,\n  FATAL: 5,\n  NOTE: 6\n}\n\nmodule.exports.PlayMethod = {\n  DIRECTPLAY: 0,\n  DIRECTSTREAM: 1,\n  TRANSCODE: 2,\n  LOCAL: 3\n}\n\nmodule.exports.AudioMimeType = {\n  MP3: 'audio/mpeg',\n  M4B: 'audio/mp4',\n  M4A: 'audio/mp4',\n  MP4: 'audio/mp4',\n  OGG: 'audio/ogg',\n  OGA: 'audio/ogg',\n  OPUS: 'audio/ogg',\n  AAC: 'audio/aac',\n  FLAC: 'audio/flac',\n  WMA: 'audio/x-ms-wma',\n  AIFF: 'audio/x-aiff',\n  AIF: 'audio/x-aiff',\n  WEBM: 'audio/webm',\n  WEBMA: 'audio/webm',\n  MKA: 'audio/x-matroska',\n  AWB: 'audio/amr-wb',\n  CAF: 'audio/x-caf',\n  MPEG: 'audio/mpeg',\n  MPG: 'audio/mpeg'\n}\n"
  },
  {
    "path": "server/utils/ffmpegHelpers.js",
    "content": "const axios = require('axios')\nconst Ffmpeg = require('../libs/fluentFfmpeg')\nconst ffmpgegUtils = require('../libs/fluentFfmpeg/utils')\nconst fs = require('../libs/fsExtra')\nconst Path = require('path')\nconst Logger = require('../Logger')\nconst { filePathToPOSIX, copyToExisting } = require('./fileUtils')\n\nfunction escapeSingleQuotes(path) {\n  // A ' within a quoted string is escaped with '\\'' in ffmpeg (see https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping)\n  return filePathToPOSIX(path).replace(/'/g, \"'\\\\''\")\n}\n\n// Returns first track start time\n// startTime is for streams starting an encode part-way through an audiobook\nasync function writeConcatFile(tracks, outputPath, startTime = 0) {\n  var trackToStartWithIndex = 0\n  var firstTrackStartTime = 0\n\n  // Find first track greater than startTime\n  if (startTime > 0) {\n    var currTrackEnd = 0\n    var startingTrack = tracks.find((t) => {\n      currTrackEnd += t.duration\n      return startTime < currTrackEnd\n    })\n    if (startingTrack) {\n      firstTrackStartTime = currTrackEnd - startingTrack.duration\n      trackToStartWithIndex = startingTrack.index\n    }\n  }\n\n  var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex)\n  var trackPaths = tracksToInclude.map((t) => {\n    var line = \"file '\" + escapeSingleQuotes(t.metadata.path) + \"'\\n\" + `duration ${t.duration}`\n    return line\n  })\n  var inputstr = trackPaths.join('\\n\\n')\n\n  try {\n    await fs.writeFile(outputPath, inputstr)\n    return firstTrackStartTime\n  } catch (error) {\n    Logger.error(`[ffmpegHelpers] Failed to write stream concat file at \"${outputPath}\"`, error)\n    return null\n  }\n}\nmodule.exports.writeConcatFile = writeConcatFile\n\nasync function extractCoverArt(filepath, outputpath) {\n  var dirname = Path.dirname(outputpath)\n  await fs.ensureDir(dirname)\n\n  return new Promise((resolve) => {\n    /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */\n    var ffmpeg = Ffmpeg(filepath)\n    ffmpeg.addOption(['-map 0:v:0', '-frames:v 1'])\n    ffmpeg.output(outputpath)\n\n    ffmpeg.on('start', (cmd) => {\n      Logger.debug(`[FfmpegHelpers] Extract Cover Cmd: ${cmd}`)\n    })\n    ffmpeg.on('error', (err, stdout, stderr) => {\n      Logger.error(`[FfmpegHelpers] Extract Cover Error ${err}`)\n      resolve(false)\n    })\n    ffmpeg.on('end', () => {\n      Logger.debug(`[FfmpegHelpers] Cover Art Extracted Successfully`)\n      resolve(outputpath)\n    })\n    ffmpeg.run()\n  })\n}\nmodule.exports.extractCoverArt = extractCoverArt\n\n//This should convert based on the output file extension as well\nasync function resizeImage(filePath, outputPath, width, height) {\n  return new Promise((resolve) => {\n    /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */\n    var ffmpeg = Ffmpeg(filePath)\n    ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])\n    ffmpeg.addOutput(outputPath)\n    ffmpeg.on('start', (cmd) => {\n      Logger.debug(`[FfmpegHelpers] Resize Image Cmd: ${cmd}`)\n    })\n    ffmpeg.on('error', (err, stdout, stderr) => {\n      Logger.error(`[FfmpegHelpers] Resize Image Error ${err} ${stdout} ${stderr}`)\n      resolve(false)\n    })\n    ffmpeg.on('end', () => {\n      Logger.debug(`[FfmpegHelpers] Image resized Successfully`)\n      resolve(outputPath)\n    })\n    ffmpeg.run()\n  })\n}\nmodule.exports.resizeImage = resizeImage\n\n/**\n *\n * @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload\n * @returns {Promise<{success: boolean, isRequestError?: boolean}>}\n */\nmodule.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {\n  return new Promise(async (resolve) => {\n    // Some podcasts fail due to user agent strings\n    // See: https://github.com/advplyr/audiobookshelf/issues/3246 (requires iTMS user agent)\n    // See: https://github.com/advplyr/audiobookshelf/issues/4401 (requires no iTMS user agent)\n    const userAgents = ['audiobookshelf (+https://audiobookshelf.org; like iTMS)', 'audiobookshelf (+https://audiobookshelf.org)']\n\n    let response = null\n    let lastError = null\n\n    for (const userAgent of userAgents) {\n      try {\n        response = await axios({\n          url: podcastEpisodeDownload.url,\n          method: 'GET',\n          responseType: 'stream',\n          headers: {\n            Accept: '*/*',\n            'User-Agent': userAgent\n          },\n          timeout: global.PodcastDownloadTimeout\n        })\n\n        Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)\n        break\n      } catch (error) {\n        lastError = error\n        Logger.warn(`[ffmpegHelpers] Failed to download podcast episode with User-Agent \"${userAgent}\" for url \"${podcastEpisodeDownload.url}\"`, error.message)\n\n        // If this is the last attempt, log the full error\n        if (userAgent === userAgents[userAgents.length - 1]) {\n          Logger.error(`[ffmpegHelpers] All User-Agent attempts failed for url \"${podcastEpisodeDownload.url}\"`, lastError)\n        }\n      }\n    }\n\n    if (!response) {\n      return resolve({\n        success: false,\n        isRequestError: true\n      })\n    }\n\n    /** @type {import('../libs/fluentFfmpeg/index').FfmpegCommand} */\n    const ffmpeg = Ffmpeg(response.data)\n    ffmpeg.addOption('-loglevel debug') // Debug logs printed on error\n    ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')\n\n    /** @type {import('../models/Podcast')} */\n    const podcast = podcastEpisodeDownload.libraryItem.media\n    const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode\n    const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)\n\n    const taggings = {\n      album: podcast.title,\n      'album-sort': podcast.title,\n      artist: podcast.author,\n      'artist-sort': podcast.author,\n      comment: podcastEpisode.description,\n      subtitle: podcastEpisode.subtitle,\n      disc: podcastEpisode.season,\n      genre: podcast.genres.length ? podcast.genres.join(';') : null,\n      language: podcast.language,\n      MVNM: podcast.title,\n      MVIN: podcastEpisode.episode,\n      track: podcastEpisode.episode,\n      'series-part': podcastEpisode.episode,\n      title: podcastEpisode.title,\n      'title-sort': podcastEpisode.title,\n      year: podcastEpisodeDownload.pubYear,\n      date: podcastEpisode.pubDate,\n      releasedate: podcastEpisode.pubDate,\n      'itunes-id': podcast.itunesId,\n      'podcast-type': podcast.podcastType,\n      'episode-type': podcastEpisode.episodeType\n    }\n\n    for (const tag in taggings) {\n      if (taggings[tag]) {\n        if (taggings[tag].length > 10000) {\n          Logger.warn(`[ffmpegHelpers] Episode download tag \"${tag}\" is too long (${taggings[tag].length} characters) - trimming it down`)\n          taggings[tag] = taggings[tag].slice(0, 10000)\n        }\n        ffmpeg.addOption('-metadata', `${tag}=${taggings[tag]}`)\n      }\n    }\n\n    ffmpeg.addOutput(podcastEpisodeDownload.targetPath)\n\n    const stderrLines = []\n    ffmpeg.on('stderr', (stderrLine) => {\n      if (typeof stderrLine === 'string') {\n        stderrLines.push(stderrLine)\n      }\n    })\n    ffmpeg.on('start', (cmd) => {\n      Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Cmd: ${cmd}`)\n    })\n    ffmpeg.on('error', (err) => {\n      Logger.error(`[FfmpegHelpers] downloadPodcastEpisode: Error ${err}`)\n      if (stderrLines.length) {\n        Logger.error(`Full stderr dump for episode url \"${podcastEpisodeDownload.url}\": ${stderrLines.join('\\n')}`)\n      }\n      resolve({\n        success: false\n      })\n    })\n    ffmpeg.on('progress', (progress) => {\n      let progressPercent = 0\n      if (finalSizeInBytes && progress.targetSize && !isNaN(progress.targetSize)) {\n        const finalSizeInKb = Math.floor(finalSizeInBytes / 1000)\n        progressPercent = Math.min(1, progress.targetSize / finalSizeInKb) * 100\n      }\n      Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Progress estimate ${progressPercent.toFixed(0)}% (${progress?.targetSize || 'N/A'} KB) for \"${podcastEpisodeDownload.url}\"`)\n    })\n    ffmpeg.on('end', () => {\n      Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)\n      resolve({\n        success: true\n      })\n    })\n    ffmpeg.run()\n  })\n}\n\n/**\n * Generates ffmetadata file content from the provided metadata object and chapters array.\n * @param {Object} metadata - The input metadata object.\n * @param {Array|null} chapters - An array of chapter objects.\n * @returns {string} - The ffmetadata file content.\n */\nfunction generateFFMetadata(metadata, chapters) {\n  let ffmetadataContent = ';FFMETADATA1\\n'\n\n  // Add global metadata\n  for (const key in metadata) {\n    if (metadata[key]) {\n      ffmetadataContent += `${key}=${escapeFFMetadataValue(metadata[key])}\\n`\n    }\n  }\n\n  // Add chapters\n  if (chapters) {\n    chapters.forEach((chapter) => {\n      ffmetadataContent += '\\n[CHAPTER]\\n'\n      ffmetadataContent += `TIMEBASE=1/1000\\n`\n      ffmetadataContent += `START=${Math.floor(chapter.start * 1000)}\\n`\n      ffmetadataContent += `END=${Math.floor(chapter.end * 1000)}\\n`\n      if (chapter.title) {\n        ffmetadataContent += `title=${escapeFFMetadataValue(chapter.title)}\\n`\n      }\n    })\n  }\n\n  return ffmetadataContent\n}\n\nmodule.exports.generateFFMetadata = generateFFMetadata\n\n/**\n * Writes FFmpeg metadata file with the given metadata and chapters.\n *\n * @param {Object} metadata - The metadata object.\n * @param {Array} chapters - The array of chapter objects.\n * @param {string} ffmetadataPath - The path to the FFmpeg metadata file.\n * @returns {Promise<boolean>} - A promise that resolves to true if the file was written successfully, false otherwise.\n */\nasync function writeFFMetadataFile(metadata, chapters, ffmetadataPath) {\n  try {\n    await fs.writeFile(ffmetadataPath, generateFFMetadata(metadata, chapters))\n    Logger.debug(`[ffmpegHelpers] Wrote ${ffmetadataPath}`)\n    return true\n  } catch (error) {\n    Logger.error(`[ffmpegHelpers] Write ${ffmetadataPath} failed`, error)\n    return false\n  }\n}\n\nmodule.exports.writeFFMetadataFile = writeFFMetadataFile\n\n/**\n * Adds an ffmetadata and optionally a cover image to an audio file using fluent-ffmpeg.\n *\n * @param {string} audioFilePath - Path to the input audio file.\n * @param {string|null} coverFilePath - Path to the cover image file.\n * @param {string} metadataFilePath - Path to the ffmetadata file.\n * @param {number} track - The track number to embed in the audio file.\n * @param {string} mimeType - The MIME type of the audio file.\n * @param {function(number): void|null} progressCB - A callback function to report progress.\n * @param {import('../libs/fluentFfmpeg/index').FfmpegCommand} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.\n * @param {function(string, string): Promise<void>} copyFunc - The function to use for copying files (optional). Used for dependency injection in tests.\n * @returns {Promise<void>} A promise that resolves if the operation is successful, rejects otherwise.\n */\nasync function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, progressCB = null, ffmpeg = Ffmpeg(), copyFunc = copyToExisting) {\n  const isMp4 = mimeType === 'audio/mp4'\n  const isMp3 = mimeType === 'audio/mpeg'\n\n  const audioFileDir = Path.dirname(audioFilePath)\n  const audioFileExt = Path.extname(audioFilePath)\n  const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)\n  const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))\n\n  return new Promise((resolve, reject) => {\n    ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([\n      '-map 0:a', // map audio stream from input file\n      '-map_metadata 1', // map metadata tags from metadata file first\n      '-map_metadata 0', // add additional metadata tags from input file\n      '-map_chapters 1', // map chapters from metadata file\n      '-c copy' // copy streams\n    ])\n\n    if (track && !isNaN(track)) {\n      ffmpeg.outputOptions(['-metadata track=' + track])\n    }\n\n    if (isMp4) {\n      ffmpeg.outputOptions([\n        '-f mp4' // force output format to mp4\n      ])\n    } else if (isMp3) {\n      ffmpeg.outputOptions([\n        '-id3v2_version 3' // set ID3v2 version to 3\n      ])\n    }\n\n    if (coverFilePath) {\n      ffmpeg.input(coverFilePath).outputOptions([\n        '-map 2:v', // map video stream from cover image file\n        '-disposition:v:0 attached_pic', // set cover image as attached picture\n        '-metadata:s:v',\n        'title=Cover', // add title metadata to cover image stream\n        '-metadata:s:v',\n        'comment=Cover' // add comment metadata to cover image stream\n      ])\n      const ext = Path.extname(coverFilePath).toLowerCase()\n      if (ext === '.webp') {\n        ffmpeg.outputOptions([\n          '-c:v mjpeg' // convert webp images to jpeg\n        ])\n      }\n    } else {\n      ffmpeg.outputOptions([\n        '-map 0:v?' // retain video stream from input file if exists\n      ])\n    }\n\n    ffmpeg\n      .output(tempFilePath)\n      .on('start', (commandLine) => {\n        Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + commandLine)\n      })\n      .on('progress', (progress) => {\n        if (!progressCB || !progress.percent) return\n        Logger.debug(`[ffmpegHelpers] Progress: ${progress.percent}%`)\n        progressCB(progress.percent)\n      })\n      .on('end', async (stdout, stderr) => {\n        Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)\n        Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)\n        Logger.debug('[ffmpegHelpers] Moving temp file to audio file path:', `\"${tempFilePath}\"`, '->', `\"${audioFilePath}\"`)\n        try {\n          await copyFunc(tempFilePath, audioFilePath)\n          await fs.remove(tempFilePath)\n          resolve()\n        } catch (error) {\n          Logger.error(`[ffmpegHelpers] Failed to move temp file to audio file path: \"${tempFilePath}\" -> \"${audioFilePath}\"`, error)\n          reject(error)\n        }\n      })\n      .on('error', (err, stdout, stderr) => {\n        if (err.message && err.message.includes('SIGKILL')) {\n          Logger.info(`[ffmpegHelpers] addCoverAndMetadataToFile Killed by User`)\n          reject(new Error('FFMPEG_CANCELED'))\n        } else {\n          Logger.error('Error adding cover image and metadata:', err)\n          Logger.error('ffmpeg stdout:', stdout)\n          Logger.error('ffmpeg stderr:', stderr)\n          reject(err)\n        }\n      })\n\n    ffmpeg.run()\n  })\n}\n\nmodule.exports.addCoverAndMetadataToFile = addCoverAndMetadataToFile\n\nfunction escapeFFMetadataValue(value) {\n  return value.replace(/([;=\\n\\\\#])/g, '\\\\$1')\n}\n\n/**\n * Retrieves the FFmpeg metadata object for a given library item.\n *\n * @param {import('../models/LibraryItem')} libraryItem - The library item containing the media metadata.\n * @param {number} audioFilesLength - The length of the audio files.\n * @returns {Object} - The FFmpeg metadata object.\n */\nfunction getFFMetadataObject(libraryItem, audioFilesLength) {\n  const ffmetadata = {\n    title: libraryItem.media.title,\n    artist: libraryItem.media.authorName,\n    album_artist: libraryItem.media.authorName,\n    album: (libraryItem.media.title || '') + (libraryItem.media.subtitle ? `: ${libraryItem.media.subtitle}` : ''),\n    TIT3: libraryItem.media.subtitle, // mp3 only\n    genre: libraryItem.media.genres?.join('; '),\n    date: libraryItem.media.publishedYear,\n    comment: libraryItem.media.description,\n    description: libraryItem.media.description,\n    composer: (libraryItem.media.narrators || []).join(', '),\n    copyright: libraryItem.media.publisher,\n    publisher: libraryItem.media.publisher, // mp3 only\n    TRACKTOTAL: `${audioFilesLength}`, // mp3 only\n    grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ')\n  }\n  Object.keys(ffmetadata).forEach((key) => {\n    if (!ffmetadata[key]) {\n      delete ffmetadata[key]\n    }\n  })\n\n  return ffmetadata\n}\n\nmodule.exports.getFFMetadataObject = getFFMetadataObject\n\n/**\n * Merges audio files into a single output file using FFmpeg.\n *\n * @param {import('../models/Book').AudioFileObject} audioTracks - The audio tracks to merge.\n * @param {number} duration - The total duration of the audio tracks.\n * @param {string} itemCachePath - The path to the item cache.\n * @param {string} outputFilePath - The path to the output file.\n * @param {import('../managers/AbMergeManager').AbMergeEncodeOptions} encodingOptions - The options for encoding the audio.\n * @param {Function} [progressCB=null] - The callback function to track the progress of the merge.\n * @param {import('../libs/fluentFfmpeg/index').FfmpegCommand} [ffmpeg=Ffmpeg()] - The FFmpeg instance to use for merging.\n * @returns {Promise<void>} A promise that resolves when the audio files are merged successfully.\n */\nasync function mergeAudioFiles(audioTracks, duration, itemCachePath, outputFilePath, encodingOptions, progressCB = null, ffmpeg = Ffmpeg()) {\n  const audioBitrate = encodingOptions.bitrate || '128k'\n  const audioCodec = encodingOptions.codec || 'aac'\n  const audioChannels = encodingOptions.channels || 2\n\n  // TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.\n  // const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'\n  const audioRequiresEncode = true\n\n  const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'\n  const isOneTrack = audioTracks.length === 1\n\n  let concatFilePath = null\n  if (!isOneTrack) {\n    concatFilePath = Path.join(itemCachePath, 'files.txt')\n    if ((await writeConcatFile(audioTracks, concatFilePath)) == null) {\n      throw new Error('Failed to write concat file')\n    }\n    ffmpeg.input(concatFilePath).inputOptions(['-safe 0', '-f concat'])\n  } else {\n    ffmpeg.input(audioTracks[0].metadata.path).inputOptions(firstTrackIsM4b ? ['-f mp4'] : [])\n  }\n\n  //const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'\n  ffmpeg.outputOptions(['-f mp4'])\n\n  if (audioRequiresEncode) {\n    ffmpeg.outputOptions(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])\n  } else {\n    ffmpeg.outputOptions(['-max_muxing_queue_size 1000'])\n\n    if (isOneTrack && firstTrackIsM4b) {\n      ffmpeg.outputOptions(['-c copy'])\n    } else {\n      ffmpeg.outputOptions(['-c:a copy'])\n    }\n  }\n\n  ffmpeg.output(outputFilePath)\n\n  return new Promise((resolve, reject) => {\n    ffmpeg\n      .on('start', (cmd) => {\n        Logger.debug(`[ffmpegHelpers] Merge Audio Files ffmpeg command: ${cmd}`)\n      })\n      .on('progress', (progress) => {\n        if (!progressCB || !progress.timemark || !duration) return\n        // Cannot rely on progress.percent as it is not accurate for concat\n        const percent = (ffmpgegUtils.timemarkToSeconds(progress.timemark) / duration) * 100\n        progressCB(percent)\n      })\n      .on('end', async (stdout, stderr) => {\n        if (concatFilePath) await fs.remove(concatFilePath)\n        Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)\n        Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)\n        Logger.debug(`[ffmpegHelpers] Audio Files Merged Successfully`)\n        resolve()\n      })\n      .on('error', async (err, stdout, stderr) => {\n        if (concatFilePath) await fs.remove(concatFilePath)\n        if (err.message && err.message.includes('SIGKILL')) {\n          Logger.info(`[ffmpegHelpers] Merge Audio Files Killed by User`)\n          reject(new Error('FFMPEG_CANCELED'))\n        } else {\n          Logger.error(`[ffmpegHelpers] Merge Audio Files Error ${err}`)\n          Logger.error('ffmpeg stdout:', stdout)\n          Logger.error('ffmpeg stderr:', stderr)\n          reject(err)\n        }\n      })\n\n    ffmpeg.run()\n  })\n}\n\nmodule.exports.mergeAudioFiles = mergeAudioFiles\n"
  },
  {
    "path": "server/utils/fileUtils.js",
    "content": "const axios = require('axios')\nconst Path = require('path')\nconst ssrfFilter = require('ssrf-req-filter')\nconst exec = require('child_process').exec\nconst fs = require('../libs/fsExtra')\nconst rra = require('../libs/recursiveReaddirAsync')\nconst Logger = require('../Logger')\nconst { AudioMimeType } = require('./constants')\n\n/**\n * Make sure folder separator is POSIX for Windows file paths. e.g. \"C:\\Users\\Abs\" becomes \"C:/Users/Abs\"\n *\n * @param {String} path - Ugly file path\n * @return {String} Pretty posix file path\n */\nconst filePathToPOSIX = (path) => {\n  if (!global.isWin || !path) return path\n  return path.startsWith('\\\\\\\\') ? '\\\\\\\\' + path.slice(2).replace(/\\\\/g, '/') : path.replace(/\\\\/g, '/')\n}\nmodule.exports.filePathToPOSIX = filePathToPOSIX\n\n/**\n * Check path is a child of or equal to another path\n *\n * @param {string} parentPath\n * @param {string} childPath\n * @returns {boolean}\n */\nfunction isSameOrSubPath(parentPath, childPath) {\n  parentPath = filePathToPOSIX(parentPath)\n  childPath = filePathToPOSIX(childPath)\n  if (parentPath === childPath) return true\n  const relativePath = Path.relative(parentPath, childPath)\n  return (\n    relativePath === '' || // Same path (e.g. parentPath = '/a/b/', childPath = '/a/b')\n    (!relativePath.startsWith('..') && !Path.isAbsolute(relativePath)) // Sub path\n  )\n}\nmodule.exports.isSameOrSubPath = isSameOrSubPath\n\nfunction getFileStat(path) {\n  try {\n    return fs.stat(path)\n  } catch (err) {\n    Logger.error('[fileUtils] Failed to stat', err)\n    return null\n  }\n}\n\nasync function getFileTimestampsWithIno(path) {\n  try {\n    var stat = await fs.stat(path, { bigint: true })\n    return {\n      size: Number(stat.size),\n      mtimeMs: Number(stat.mtimeMs),\n      ctimeMs: Number(stat.ctimeMs),\n      birthtimeMs: Number(stat.birthtimeMs),\n      ino: String(stat.ino)\n    }\n  } catch (err) {\n    Logger.error(`[fileUtils] Failed to getFileTimestampsWithIno for path \"${path}\"`, err)\n    return false\n  }\n}\nmodule.exports.getFileTimestampsWithIno = getFileTimestampsWithIno\n\n/**\n * Get file size\n *\n * @param {string} path\n * @returns {Promise<number>}\n */\nmodule.exports.getFileSize = async (path) => {\n  return (await getFileStat(path))?.size || 0\n}\n\n/**\n * Get file mtimeMs\n *\n * @param {string} path\n * @returns {Promise<number>} epoch timestamp\n */\nmodule.exports.getFileMTimeMs = async (path) => {\n  try {\n    return (await getFileStat(path))?.mtimeMs || 0\n  } catch (err) {\n    Logger.error(`[fileUtils] Failed to getFileMtimeMs`, err)\n    return 0\n  }\n}\n\n/**\n *\n * @param {string} filepath\n * @returns {boolean}\n */\nasync function checkPathIsFile(filepath) {\n  try {\n    const stat = await fs.stat(filepath)\n    return stat.isFile()\n  } catch (err) {\n    return false\n  }\n}\nmodule.exports.checkPathIsFile = checkPathIsFile\n\nfunction getIno(path) {\n  return fs\n    .stat(path, { bigint: true })\n    .then((data) => String(data.ino))\n    .catch((err) => {\n      Logger.warn(`[Utils] Failed to get ino for path \"${path}\"`, err)\n      return null\n    })\n}\nmodule.exports.getIno = getIno\n\n/**\n * Read contents of file\n * @param {string} path\n * @returns {string}\n */\nasync function readTextFile(path) {\n  try {\n    var data = await fs.readFile(path)\n    return String(data)\n  } catch (error) {\n    Logger.error(`[FileUtils] ReadTextFile error ${error}`)\n    return ''\n  }\n}\nmodule.exports.readTextFile = readTextFile\n\n/**\n * Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored\n *\n * @param {string} path\n * @returns {string}\n */\nmodule.exports.shouldIgnoreFile = (path) => {\n  // Check if directory or file name starts with \".\"\n  if (Path.basename(path).startsWith('.')) {\n    return 'dotfile'\n  }\n  if (path.split('/').find((p) => p.startsWith('.'))) {\n    return 'dotpath'\n  }\n\n  // If these strings exist anywhere in the filename or directory name, ignore. Vendor specific hidden directories\n  const includeAnywhereIgnore = ['@eaDir']\n  const filteredInclude = includeAnywhereIgnore.filter((str) => path.includes(str))\n  if (filteredInclude.length) {\n    return `${filteredInclude[0]} directory`\n  }\n\n  const extensionIgnores = ['.part', '.tmp', '.crdownload', '.download', '.bak', '.old', '.temp', '.tempfile', '.tempfile~']\n\n  // Check extension\n  if (extensionIgnores.includes(Path.extname(path).toLowerCase())) {\n    // Return the extension that is ignored\n    return `${Path.extname(path)} file`\n  }\n\n  // Should not ignore this file or directory\n  return null\n}\n\n/**\n * @typedef FilePathItem\n * @property {string} name - file name e.g. \"audiofile.m4b\"\n * @property {string} path - fullpath excluding folder e.g. \"Author/Book/audiofile.m4b\"\n * @property {string} reldirpath - path excluding file name e.g. \"Author/Book\"\n * @property {string} fullpath - full path e.g. \"/audiobooks/Author/Book/audiofile.m4b\"\n * @property {string} extension - file extension e.g. \".m4b\"\n * @property {number} deep - depth of file in directory (0 is file in folder root)\n */\n\n/**\n * Get array of files inside dir\n * @param {string} path\n * @param {string} [relPathToReplace]\n * @returns {FilePathItem[]}\n */\nmodule.exports.recurseFiles = async (path, relPathToReplace = null) => {\n  path = filePathToPOSIX(path)\n  if (!path.endsWith('/')) path = path + '/'\n\n  if (relPathToReplace) {\n    relPathToReplace = filePathToPOSIX(relPathToReplace)\n    if (!relPathToReplace.endsWith('/')) relPathToReplace += '/'\n  } else {\n    relPathToReplace = path\n  }\n\n  const options = {\n    mode: rra.LIST,\n    recursive: true,\n    stats: false,\n    ignoreFolders: true,\n    extensions: true,\n    deep: true,\n    realPath: true,\n    normalizePath: false\n  }\n  let list = await rra.list(path, options)\n  if (list.error) {\n    Logger.error('[fileUtils] Recurse files error', list.error)\n    return []\n  }\n\n  const directoriesToIgnore = []\n\n  list = list\n    .filter((item) => {\n      if (item.error) {\n        Logger.error(`[fileUtils] Recurse files file \"${item.fullname}\" has error`, item.error)\n        return false\n      }\n\n      item.fullname = filePathToPOSIX(item.fullname)\n      item.path = filePathToPOSIX(item.path)\n      const relpath = item.fullname.replace(relPathToReplace, '')\n      let reldirname = Path.dirname(relpath)\n      if (reldirname === '.') reldirname = ''\n      const dirname = Path.dirname(item.fullname)\n\n      // Directory has a file named \".ignore\" flag directory and ignore\n      if (item.name === '.ignore' && reldirname && reldirname !== '.' && !directoriesToIgnore.includes(dirname)) {\n        Logger.debug(`[fileUtils] .ignore found - ignoring directory \"${reldirname}\"`)\n        directoriesToIgnore.push(dirname)\n        return false\n      }\n\n      // Check for ignored extensions or directories\n      const shouldIgnore = this.shouldIgnoreFile(relpath)\n      if (shouldIgnore) {\n        Logger.debug(`[fileUtils] Ignoring ${shouldIgnore} - \"${relpath}\"`)\n        return false\n      }\n\n      return true\n    })\n    .filter((item) => {\n      // Filter out items in ignore directories\n      if (directoriesToIgnore.some((dir) => item.fullname.startsWith(dir + '/'))) {\n        Logger.debug(`[fileUtils] Ignoring path in dir with .ignore \"${item.fullname}\"`)\n        return false\n      }\n      return true\n    })\n    .map((item) => {\n      var isInRoot = item.path + '/' === relPathToReplace\n      return {\n        name: item.name,\n        path: item.fullname.replace(relPathToReplace, ''),\n        reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),\n        fullpath: item.fullname,\n        extension: item.extension,\n        deep: item.deep\n      }\n    })\n\n  // Sort from least deep to most\n  list.sort((a, b) => a.deep - b.deep)\n\n  return list\n}\n\n/**\n *\n * @param {import('../Watcher').PendingFileUpdate} fileUpdate\n * @returns {FilePathItem}\n */\nmodule.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {\n  let relPath = fileUpdate.relPath\n  if (relPath.startsWith('/')) relPath = relPath.slice(1)\n\n  const dirname = Path.dirname(relPath)\n  return {\n    name: Path.basename(relPath),\n    path: relPath,\n    reldirpath: dirname === '.' ? '' : dirname,\n    fullpath: fileUpdate.path,\n    extension: Path.extname(relPath),\n    deep: relPath.split('/').length - 1\n  }\n}\n\n/**\n * Download file from web to local file system\n * Uses SSRF filter to prevent internal URLs\n *\n * @param {string} url\n * @param {string} filepath path to download the file to\n * @param {Function} [contentTypeFilter] validate content type before writing\n * @returns {Promise}\n */\nmodule.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {\n  return new Promise(async (resolve, reject) => {\n    Logger.debug(`[fileUtils] Downloading file to ${filepath}`)\n    axios({\n      url,\n      method: 'GET',\n      responseType: 'stream',\n      headers: {\n        'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'\n      },\n      timeout: 30000,\n      httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url),\n      httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url)\n    })\n      .then((response) => {\n        // Validate content type\n        if (contentTypeFilter && !contentTypeFilter?.(response.headers?.['content-type'])) {\n          return reject(new Error(`Invalid content type \"${response.headers?.['content-type'] || ''}\"`))\n        }\n\n        const totalSize = parseInt(response.headers['content-length'], 10)\n        let downloadedSize = 0\n\n        // Write to filepath\n        const writer = fs.createWriteStream(filepath)\n        response.data.pipe(writer)\n\n        let lastProgress = 0\n        response.data.on('data', (chunk) => {\n          downloadedSize += chunk.length\n          const progress = totalSize ? Math.round((downloadedSize / totalSize) * 100) : 0\n          if (progress >= lastProgress + 5) {\n            Logger.debug(`[fileUtils] File \"${Path.basename(filepath)}\" download progress: ${progress}% (${downloadedSize}/${totalSize} bytes)`)\n            lastProgress = progress\n          }\n        })\n\n        writer.on('finish', resolve)\n        writer.on('error', reject)\n      })\n      .catch((err) => {\n        Logger.error(`[fileUtils] Failed to download file \"${filepath}\"`, err)\n        reject(err)\n      })\n  })\n}\n\n/**\n * Download image file from web to local file system\n * Response header must have content-type of image/ (excluding svg)\n *\n * @param {string} url\n * @param {string} filepath\n * @returns {Promise}\n */\nmodule.exports.downloadImageFile = (url, filepath) => {\n  const contentTypeFilter = (contentType) => {\n    return contentType?.startsWith('image/') && contentType !== 'image/svg+xml'\n  }\n  return this.downloadFile(url, filepath, contentTypeFilter)\n}\n\nmodule.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {\n  if (typeof filename !== 'string') {\n    return false\n  }\n\n  // Normalize the string first to ensure consistent byte calculations\n  filename = filename.normalize('NFC')\n\n  // Most file systems use number of bytes for max filename\n  //   to support most filesystems we will use max of 255 bytes in utf-16\n  //   Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html\n  //   Issue: https://github.com/advplyr/audiobookshelf/issues/1261\n  const MAX_FILENAME_BYTES = 255\n\n  const replacement = ''\n  const illegalRe = /[\\/\\?<>\\\\:\\*\\|\"]/g\n  const controlRe = /[\\x00-\\x1f\\x80-\\x9f]/g\n  const reservedRe = /^\\.+$/\n  const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\\..*)?$/i\n  const windowsTrailingRe = /[\\. ]+$/\n  const lineBreaks = /[\\n\\r]/g\n\n  let sanitized = filename\n    .replace(':', colonReplacement) // Replace first occurrence of a colon\n    .replace(illegalRe, replacement)\n    .replace(controlRe, replacement)\n    .replace(reservedRe, replacement)\n    .replace(lineBreaks, replacement)\n    .replace(windowsReservedRe, replacement)\n    .replace(windowsTrailingRe, replacement)\n    .replace(/\\s+/g, ' ') // Replace consecutive spaces with a single space\n\n  // Check if basename is too many bytes\n  const ext = Path.extname(sanitized) // separate out file extension\n  const basename = Path.basename(sanitized, ext)\n  const extByteLength = Buffer.byteLength(ext, 'utf16le')\n\n  const basenameByteLength = Buffer.byteLength(basename, 'utf16le')\n  if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {\n    Logger.debug(`[fileUtils] Filename \"${filename}\" is too long (${basenameByteLength + extByteLength} bytes), trimming basename to ${MAX_FILENAME_BYTES - extByteLength} bytes.`)\n\n    const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength\n    let totalBytes = 0\n    let trimmedBasename = ''\n\n    // Add chars until max bytes is reached\n    for (const char of basename) {\n      totalBytes += Buffer.byteLength(char, 'utf16le')\n      if (totalBytes > MaxBytesForBasename) break\n      else trimmedBasename += char\n    }\n\n    trimmedBasename = trimmedBasename.trim()\n    sanitized = trimmedBasename + ext\n  }\n\n  if (filename !== sanitized) {\n    Logger.debug(`[fileUtils] Sanitized filename \"${filename}\" to \"${sanitized}\" (${Buffer.byteLength(sanitized, 'utf16le')} bytes)`)\n  }\n\n  return sanitized\n}\n\n// Returns null if extname is not in our defined list of audio extnames\nmodule.exports.getAudioMimeTypeFromExtname = (extname) => {\n  if (!extname || !extname.length) return null\n  const formatUpper = extname.slice(1).toUpperCase()\n  if (AudioMimeType[formatUpper]) return AudioMimeType[formatUpper]\n  return null\n}\n\nmodule.exports.removeFile = (path) => {\n  if (!path) return false\n  return fs\n    .remove(path)\n    .then(() => true)\n    .catch((error) => {\n      Logger.error(`[fileUtils] Failed remove file \"${path}\"`, error)\n      return false\n    })\n}\n\nmodule.exports.encodeUriPath = (path) => {\n  const uri = new URL('/', 'file://')\n  // we assign the path here to assure that URL control characters like # are\n  // actually interpreted as part of the URL path\n  uri.pathname = path\n  return uri.pathname\n}\n\n/**\n * Check if directory is writable.\n * This method is necessary because fs.access(directory, fs.constants.W_OK) does not work on Windows\n *\n * @param {string} directory\n * @returns {Promise<boolean>}\n */\nmodule.exports.isWritable = async (directory) => {\n  try {\n    const accessTestFile = Path.join(directory, 'accessTest')\n    await fs.writeFile(accessTestFile, '')\n    await fs.remove(accessTestFile)\n    return true\n  } catch (err) {\n    Logger.info(`[fileUtils] Directory is not writable \"${directory}\"`, err)\n    return false\n  }\n}\n\n/**\n * Get Windows drives as array e.g. [\"C:/\", \"F:/\"]\n *\n * @returns {Promise<string[]>}\n */\nmodule.exports.getWindowsDrives = async () => {\n  if (!global.isWin) {\n    return []\n  }\n  return new Promise((resolve, reject) => {\n    exec('powershell -Command \"(Get-PSDrive -PSProvider FileSystem).Name\"', async (error, stdout, stderr) => {\n      if (error) {\n        reject(error)\n        return\n      }\n      let drives = stdout\n        ?.split(/\\r?\\n/)\n        .map((line) => line.trim())\n        .filter((line) => line)\n      const validDrives = []\n      for (const drive of drives) {\n        let drivepath = drive + ':/'\n        if (await fs.pathExists(drivepath)) {\n          validDrives.push(drivepath)\n        } else {\n          Logger.error(`Invalid drive ${drivepath}`)\n        }\n      }\n      resolve(validDrives)\n    })\n  })\n}\n\n/**\n * Get array of directory paths in a directory\n *\n * @param {string} dirPath\n * @param {number} level\n * @returns {Promise<{ path:string, dirname:string, level:number }[]>}\n */\nmodule.exports.getDirectoriesInPath = async (dirPath, level) => {\n  try {\n    const paths = await fs.readdir(dirPath)\n    let dirs = await Promise.all(\n      paths.map(async (dirname) => {\n        const fullPath = Path.join(dirPath, dirname)\n\n        const lstat = await fs.lstat(fullPath).catch((error) => {\n          Logger.debug(`Failed to lstat \"${fullPath}\"`, error)\n          return null\n        })\n        if (!lstat?.isDirectory()) return null\n\n        return {\n          path: this.filePathToPOSIX(fullPath),\n          dirname,\n          level\n        }\n      })\n    )\n    dirs = dirs.filter((d) => d)\n    return dirs\n  } catch (error) {\n    Logger.error('Failed to readdir', dirPath, error)\n    return []\n  }\n}\n\n/**\n * Copies a file from the source path to an existing destination path, preserving the destination's permissions.\n *\n * @param {string} srcPath - The path of the source file.\n * @param {string} destPath - The path of the existing destination file.\n * @returns {Promise<void>} A promise that resolves when the file has been successfully copied.\n * @throws {Error} If there is an error reading the source file or writing the destination file.\n */\nasync function copyToExisting(srcPath, destPath) {\n  return new Promise((resolve, reject) => {\n    // Create a readable stream from the source file\n    const readStream = fs.createReadStream(srcPath)\n\n    // Create a writable stream to the destination file\n    const writeStream = fs.createWriteStream(destPath, { flags: 'w' })\n\n    // Pipe the read stream to the write stream\n    readStream.pipe(writeStream)\n\n    // Handle the end of the stream\n    writeStream.on('finish', () => {\n      Logger.debug(`[copyToExisting] Successfully copied file from ${srcPath} to ${destPath}`)\n      resolve()\n    })\n\n    // Handle errors\n    readStream.on('error', (error) => {\n      Logger.error(`[copyToExisting] Error reading from source file ${srcPath}: ${error.message}`)\n      readStream.close()\n      writeStream.close()\n      reject(error)\n    })\n\n    writeStream.on('error', (error) => {\n      Logger.error(`[copyToExisting] Error writing to destination file ${destPath}: ${error.message}`)\n      readStream.close()\n      writeStream.close()\n      reject(error)\n    })\n  })\n}\nmodule.exports.copyToExisting = copyToExisting\n"
  },
  {
    "path": "server/utils/generators/abmetadataGenerator.js",
    "content": "const Logger = require('../../Logger')\nconst parseSeriesString = require('../parsers/parseSeriesString')\n\nfunction parseJsonMetadataText(text) {\n  try {\n    const abmetadataData = JSON.parse(text)\n\n    // Old metadata.json used nested \"metadata\"\n    if (abmetadataData.metadata) {\n      for (const key in abmetadataData.metadata) {\n        if (abmetadataData.metadata[key] === undefined) continue\n        let newModelKey = key\n        if (key === 'feedUrl') newModelKey = 'feedURL'\n        else if (key === 'imageUrl') newModelKey = 'imageURL'\n        else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL'\n        else if (key === 'type') newModelKey = 'podcastType'\n        abmetadataData[newModelKey] = abmetadataData.metadata[key]\n      }\n    }\n    delete abmetadataData.metadata\n\n    if (abmetadataData.series?.length) {\n      abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]\n      abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))\n    }\n    // clean tags & remove dupes\n    if (abmetadataData.tags?.length) {\n      abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]\n    }\n    if (abmetadataData.chapters?.length) {\n      abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)\n    }\n    // clean remove dupes\n    if (abmetadataData.authors?.length) {\n      abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]\n    }\n    if (abmetadataData.narrators?.length) {\n      abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]\n    }\n    if (abmetadataData.genres?.length) {\n      abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]\n    }\n    return abmetadataData\n  } catch (error) {\n    Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)\n    return null\n  }\n}\nmodule.exports.parseJson = parseJsonMetadataText\n\nfunction cleanChaptersArray(chaptersArray, mediaTitle) {\n  const chapters = []\n  let index = 0\n  for (const chap of chaptersArray) {\n    if (chap.start === null || isNaN(chap.start)) {\n      Logger.error(`[abmetadataGenerator] Invalid chapter start time ${chap.start} for \"${mediaTitle}\" metadata file`)\n      return null\n    }\n    if (chap.end === null || isNaN(chap.end)) {\n      Logger.error(`[abmetadataGenerator] Invalid chapter end time ${chap.end} for \"${mediaTitle}\" metadata file`)\n      return null\n    }\n    if (!chap.title || typeof chap.title !== 'string') {\n      Logger.error(`[abmetadataGenerator] Invalid chapter title ${chap.title} for \"${mediaTitle}\" metadata file`)\n      return null\n    }\n\n    chapters.push({\n      id: index++,\n      start: chap.start,\n      end: chap.end,\n      title: chap.title\n    })\n  }\n  return chapters\n}\n"
  },
  {
    "path": "server/utils/generators/hlsPlaylistGenerator.js",
    "content": "const fs = require('../../libs/fsExtra')\n\nfunction getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType) {\n  var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'\n\n  var lines = [\n    '#EXTM3U',\n    '#EXT-X-VERSION:3',\n    '#EXT-X-ALLOW-CACHE:NO',\n    '#EXT-X-TARGETDURATION:6',\n    '#EXT-X-MEDIA-SEQUENCE:0',\n    '#EXT-X-PLAYLIST-TYPE:VOD'\n  ]\n  if (hlsSegmentType === 'fmp4') {\n    lines.push('#EXT-X-MAP:URI=\"init.mp4\"')\n  }\n  var numSegments = Math.floor(duration / segmentLength)\n  var lastSegment = duration - (numSegments * segmentLength)\n  for (let i = 0; i < numSegments; i++) {\n    lines.push(`#EXTINF:6,`)\n    lines.push(`${segmentName}-${i}.${ext}`)\n  }\n  if (lastSegment > 0) {\n    lines.push(`#EXTINF:${lastSegment},`)\n    lines.push(`${segmentName}-${numSegments}.${ext}`)\n  }\n  lines.push('#EXT-X-ENDLIST')\n  return lines.join('\\n')\n}\n\nfunction generatePlaylist(outputPath, segmentName, duration, segmentLength, hlsSegmentType) {\n  var playlistStr = getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType)\n  return fs.writeFile(outputPath, playlistStr)\n}\nmodule.exports = generatePlaylist"
  },
  {
    "path": "server/utils/generators/opmlGenerator.js",
    "content": "const xml = require('../../libs/xml')\nconst escapeForXML = require('../../libs/xml/escapeForXML')\n\n/**\n * Generate OPML file string for podcasts in a library\n * @param {import('../../models/Podcast')[]} podcasts \n * @param {boolean} [indent=true] \n * @returns {string}\n */\nmodule.exports.generate = (podcasts, indent = true) => {\n  const bodyItems = []\n  podcasts.forEach((podcast) => {\n    if (!podcast.feedURL) return\n    const feedAttributes = {\n      type: 'rss',\n      text: escapeForXML(podcast.title),\n      title: escapeForXML(podcast.title),\n      xmlUrl: escapeForXML(podcast.feedURL)\n    }\n    if (podcast.description) {\n      feedAttributes.description = escapeForXML(podcast.description)\n    }\n    if (podcast.itunesPageUrl) {\n      feedAttributes.htmlUrl = escapeForXML(podcast.itunesPageUrl)\n    }\n    if (podcast.language) {\n      feedAttributes.language = escapeForXML(podcast.language)\n    }\n    bodyItems.push({\n      outline: {\n        _attr: feedAttributes\n      }\n    })\n  })\n\n  const data = [\n    {\n      opml: [\n        {\n          _attr: {\n            version: '1.0'\n          }\n        },\n        {\n          head: [\n            {\n              title: 'Audiobookshelf Podcast Subscriptions'\n            }\n          ]\n        },\n        {\n          body: bodyItems\n        }\n      ]\n    }\n  ]\n\n  return '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' + xml(data, indent)\n}"
  },
  {
    "path": "server/utils/globals.js",
    "content": "const globals = {\n  SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],\n  SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'aif', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'],\n  SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],\n  TextFileTypes: ['txt', 'nfo'],\n  MetadataFileTypes: ['opf', 'abs', 'xml', 'json']\n}\n\nmodule.exports = globals\n"
  },
  {
    "path": "server/utils/htmlEntities.js",
    "content": "\nconst entities =  {\n    \"&AElig\": \"Æ\",\n    \"&AElig;\": \"Æ\",\n    \"&AMP\": \"&\",\n    \"&AMP;\": \"&\",\n    \"&Aacute\": \"Á\",\n    \"&Aacute;\": \"Á\",\n    \"&Abreve;\": \"Ă\",\n    \"&Acirc\": \"Â\",\n    \"&Acirc;\": \"Â\",\n    \"&Acy;\": \"А\",\n    \"&Afr;\": \"𝔄\",\n    \"&Agrave\": \"À\",\n    \"&Agrave;\": \"À\",\n    \"&Alpha;\": \"Α\",\n    \"&Amacr;\": \"Ā\",\n    \"&And;\": \"⩓\",\n    \"&Aogon;\": \"Ą\",\n    \"&Aopf;\": \"𝔸\",\n    \"&ApplyFunction;\": \"⁡\",\n    \"&Aring\": \"Å\",\n    \"&Aring;\": \"Å\",\n    \"&Ascr;\": \"𝒜\",\n    \"&Assign;\": \"≔\",\n    \"&Atilde\": \"Ã\",\n    \"&Atilde;\": \"Ã\",\n    \"&Auml\": \"Ä\",\n    \"&Auml;\": \"Ä\",\n    \"&Backslash;\": \"∖\",\n    \"&Barv;\": \"⫧\",\n    \"&Barwed;\": \"⌆\",\n    \"&Bcy;\": \"Б\",\n    \"&Because;\": \"∵\",\n    \"&Bernoullis;\": \"ℬ\",\n    \"&Beta;\": \"Β\",\n    \"&Bfr;\": \"𝔅\",\n    \"&Bopf;\": \"𝔹\",\n    \"&Breve;\": \"˘\",\n    \"&Bscr;\": \"ℬ\",\n    \"&Bumpeq;\": \"≎\",\n    \"&CHcy;\": \"Ч\",\n    \"&COPY\": \"©\",\n    \"&COPY;\": \"©\",\n    \"&Cacute;\": \"Ć\",\n    \"&Cap;\": \"⋒\",\n    \"&CapitalDifferentialD;\": \"ⅅ\",\n    \"&Cayleys;\": \"ℭ\",\n    \"&Ccaron;\": \"Č\",\n    \"&Ccedil\": \"Ç\",\n    \"&Ccedil;\": \"Ç\",\n    \"&Ccirc;\": \"Ĉ\",\n    \"&Cconint;\": \"∰\",\n    \"&Cdot;\": \"Ċ\",\n    \"&Cedilla;\": \"¸\",\n    \"&CenterDot;\": \"·\",\n    \"&Cfr;\": \"ℭ\",\n    \"&Chi;\": \"Χ\",\n    \"&CircleDot;\": \"⊙\",\n    \"&CircleMinus;\": \"⊖\",\n    \"&CirclePlus;\": \"⊕\",\n    \"&CircleTimes;\": \"⊗\",\n    \"&ClockwiseContourIntegral;\": \"∲\",\n    \"&CloseCurlyDoubleQuote;\": \"”\",\n    \"&CloseCurlyQuote;\": \"’\",\n    \"&Colon;\": \"∷\",\n    \"&Colone;\": \"⩴\",\n    \"&Congruent;\": \"≡\",\n    \"&Conint;\": \"∯\",\n    \"&ContourIntegral;\": \"∮\",\n    \"&Copf;\": \"ℂ\",\n    \"&Coproduct;\": \"∐\",\n    \"&CounterClockwiseContourIntegral;\": \"∳\",\n    \"&Cross;\": \"⨯\",\n    \"&Cscr;\": \"𝒞\",\n    \"&Cup;\": \"⋓\",\n    \"&CupCap;\": \"≍\",\n    \"&DD;\": \"ⅅ\",\n    \"&DDotrahd;\": \"⤑\",\n    \"&DJcy;\": \"Ђ\",\n    \"&DScy;\": \"Ѕ\",\n    \"&DZcy;\": \"Џ\",\n    \"&Dagger;\": \"‡\",\n    \"&Darr;\": \"↡\",\n    \"&Dashv;\": \"⫤\",\n    \"&Dcaron;\": \"Ď\",\n    \"&Dcy;\": \"Д\",\n    \"&Del;\": \"∇\",\n    \"&Delta;\": \"Δ\",\n    \"&Dfr;\": \"𝔇\",\n    \"&DiacriticalAcute;\": \"´\",\n    \"&DiacriticalDot;\": \"˙\",\n    \"&DiacriticalDoubleAcute;\": \"˝\",\n    \"&DiacriticalGrave;\": \"`\",\n    \"&DiacriticalTilde;\": \"˜\",\n    \"&Diamond;\": \"⋄\",\n    \"&DifferentialD;\": \"ⅆ\",\n    \"&Dopf;\": \"𝔻\",\n    \"&Dot;\": \"¨\",\n    \"&DotDot;\": \"⃜\",\n    \"&DotEqual;\": \"≐\",\n    \"&DoubleContourIntegral;\": \"∯\",\n    \"&DoubleDot;\": \"¨\",\n    \"&DoubleDownArrow;\": \"⇓\",\n    \"&DoubleLeftArrow;\": \"⇐\",\n    \"&DoubleLeftRightArrow;\": \"⇔\",\n    \"&DoubleLeftTee;\": \"⫤\",\n    \"&DoubleLongLeftArrow;\": \"⟸\",\n    \"&DoubleLongLeftRightArrow;\": \"⟺\",\n    \"&DoubleLongRightArrow;\": \"⟹\",\n    \"&DoubleRightArrow;\": \"⇒\",\n    \"&DoubleRightTee;\": \"⊨\",\n    \"&DoubleUpArrow;\": \"⇑\",\n    \"&DoubleUpDownArrow;\": \"⇕\",\n    \"&DoubleVerticalBar;\": \"∥\",\n    \"&DownArrow;\": \"↓\",\n    \"&DownArrowBar;\": \"⤓\",\n    \"&DownArrowUpArrow;\": \"⇵\",\n    \"&DownBreve;\": \"̑\",\n    \"&DownLeftRightVector;\": \"⥐\",\n    \"&DownLeftTeeVector;\": \"⥞\",\n    \"&DownLeftVector;\": \"↽\",\n    \"&DownLeftVectorBar;\": \"⥖\",\n    \"&DownRightTeeVector;\": \"⥟\",\n    \"&DownRightVector;\": \"⇁\",\n    \"&DownRightVectorBar;\": \"⥗\",\n    \"&DownTee;\": \"⊤\",\n    \"&DownTeeArrow;\": \"↧\",\n    \"&Downarrow;\": \"⇓\",\n    \"&Dscr;\": \"𝒟\",\n    \"&Dstrok;\": \"Đ\",\n    \"&ENG;\": \"Ŋ\",\n    \"&ETH\": \"Ð\",\n    \"&ETH;\": \"Ð\",\n    \"&Eacute\": \"É\",\n    \"&Eacute;\": \"É\",\n    \"&Ecaron;\": \"Ě\",\n    \"&Ecirc\": \"Ê\",\n    \"&Ecirc;\": \"Ê\",\n    \"&Ecy;\": \"Э\",\n    \"&Edot;\": \"Ė\",\n    \"&Efr;\": \"𝔈\",\n    \"&Egrave\": \"È\",\n    \"&Egrave;\": \"È\",\n    \"&Element;\": \"∈\",\n    \"&Emacr;\": \"Ē\",\n    \"&EmptySmallSquare;\": \"◻\",\n    \"&EmptyVerySmallSquare;\": \"▫\",\n    \"&Eogon;\": \"Ę\",\n    \"&Eopf;\": \"𝔼\",\n    \"&Epsilon;\": \"Ε\",\n    \"&Equal;\": \"⩵\",\n    \"&EqualTilde;\": \"≂\",\n    \"&Equilibrium;\": \"⇌\",\n    \"&Escr;\": \"ℰ\",\n    \"&Esim;\": \"⩳\",\n    \"&Eta;\": \"Η\",\n    \"&Euml\": \"Ë\",\n    \"&Euml;\": \"Ë\",\n    \"&Exists;\": \"∃\",\n    \"&ExponentialE;\": \"ⅇ\",\n    \"&Fcy;\": \"Ф\",\n    \"&Ffr;\": \"𝔉\",\n    \"&FilledSmallSquare;\": \"◼\",\n    \"&FilledVerySmallSquare;\": \"▪\",\n    \"&Fopf;\": \"𝔽\",\n    \"&ForAll;\": \"∀\",\n    \"&Fouriertrf;\": \"ℱ\",\n    \"&Fscr;\": \"ℱ\",\n    \"&GJcy;\": \"Ѓ\",\n    \"&GT\": \">\",\n    \"&GT;\": \">\",\n    \"&Gamma;\": \"Γ\",\n    \"&Gammad;\": \"Ϝ\",\n    \"&Gbreve;\": \"Ğ\",\n    \"&Gcedil;\": \"Ģ\",\n    \"&Gcirc;\": \"Ĝ\",\n    \"&Gcy;\": \"Г\",\n    \"&Gdot;\": \"Ġ\",\n    \"&Gfr;\": \"𝔊\",\n    \"&Gg;\": \"⋙\",\n    \"&Gopf;\": \"𝔾\",\n    \"&GreaterEqual;\": \"≥\",\n    \"&GreaterEqualLess;\": \"⋛\",\n    \"&GreaterFullEqual;\": \"≧\",\n    \"&GreaterGreater;\": \"⪢\",\n    \"&GreaterLess;\": \"≷\",\n    \"&GreaterSlantEqual;\": \"⩾\",\n    \"&GreaterTilde;\": \"≳\",\n    \"&Gscr;\": \"𝒢\",\n    \"&Gt;\": \"≫\",\n    \"&HARDcy;\": \"Ъ\",\n    \"&Hacek;\": \"ˇ\",\n    \"&Hat;\": \"^\",\n    \"&Hcirc;\": \"Ĥ\",\n    \"&Hfr;\": \"ℌ\",\n    \"&HilbertSpace;\": \"ℋ\",\n    \"&Hopf;\": \"ℍ\",\n    \"&HorizontalLine;\": \"─\",\n    \"&Hscr;\": \"ℋ\",\n    \"&Hstrok;\": \"Ħ\",\n    \"&HumpDownHump;\": \"≎\",\n    \"&HumpEqual;\": \"≏\",\n    \"&IEcy;\": \"Е\",\n    \"&IJlig;\": \"Ĳ\",\n    \"&IOcy;\": \"Ё\",\n    \"&Iacute\": \"Í\",\n    \"&Iacute;\": \"Í\",\n    \"&Icirc\": \"Î\",\n    \"&Icirc;\": \"Î\",\n    \"&Icy;\": \"И\",\n    \"&Idot;\": \"İ\",\n    \"&Ifr;\": \"ℑ\",\n    \"&Igrave\": \"Ì\",\n    \"&Igrave;\": \"Ì\",\n    \"&Im;\": \"ℑ\",\n    \"&Imacr;\": \"Ī\",\n    \"&ImaginaryI;\": \"ⅈ\",\n    \"&Implies;\": \"⇒\",\n    \"&Int;\": \"∬\",\n    \"&Integral;\": \"∫\",\n    \"&Intersection;\": \"⋂\",\n    \"&InvisibleComma;\": \"⁣\",\n    \"&InvisibleTimes;\": \"⁢\",\n    \"&Iogon;\": \"Į\",\n    \"&Iopf;\": \"𝕀\",\n    \"&Iota;\": \"Ι\",\n    \"&Iscr;\": \"ℐ\",\n    \"&Itilde;\": \"Ĩ\",\n    \"&Iukcy;\": \"І\",\n    \"&Iuml\": \"Ï\",\n    \"&Iuml;\": \"Ï\",\n    \"&Jcirc;\": \"Ĵ\",\n    \"&Jcy;\": \"Й\",\n    \"&Jfr;\": \"𝔍\",\n    \"&Jopf;\": \"𝕁\",\n    \"&Jscr;\": \"𝒥\",\n    \"&Jsercy;\": \"Ј\",\n    \"&Jukcy;\": \"Є\",\n    \"&KHcy;\": \"Х\",\n    \"&KJcy;\": \"Ќ\",\n    \"&Kappa;\": \"Κ\",\n    \"&Kcedil;\": \"Ķ\",\n    \"&Kcy;\": \"К\",\n    \"&Kfr;\": \"𝔎\",\n    \"&Kopf;\": \"𝕂\",\n    \"&Kscr;\": \"𝒦\",\n    \"&LJcy;\": \"Љ\",\n    \"&LT\": \"<\",\n    \"&LT;\": \"<\",\n    \"&Lacute;\": \"Ĺ\",\n    \"&Lambda;\": \"Λ\",\n    \"&Lang;\": \"⟪\",\n    \"&Laplacetrf;\": \"ℒ\",\n    \"&Larr;\": \"↞\",\n    \"&Lcaron;\": \"Ľ\",\n    \"&Lcedil;\": \"Ļ\",\n    \"&Lcy;\": \"Л\",\n    \"&LeftAngleBracket;\": \"⟨\",\n    \"&LeftArrow;\": \"←\",\n    \"&LeftArrowBar;\": \"⇤\",\n    \"&LeftArrowRightArrow;\": \"⇆\",\n    \"&LeftCeiling;\": \"⌈\",\n    \"&LeftDoubleBracket;\": \"⟦\",\n    \"&LeftDownTeeVector;\": \"⥡\",\n    \"&LeftDownVector;\": \"⇃\",\n    \"&LeftDownVectorBar;\": \"⥙\",\n    \"&LeftFloor;\": \"⌊\",\n    \"&LeftRightArrow;\": \"↔\",\n    \"&LeftRightVector;\": \"⥎\",\n    \"&LeftTee;\": \"⊣\",\n    \"&LeftTeeArrow;\": \"↤\",\n    \"&LeftTeeVector;\": \"⥚\",\n    \"&LeftTriangle;\": \"⊲\",\n    \"&LeftTriangleBar;\": \"⧏\",\n    \"&LeftTriangleEqual;\": \"⊴\",\n    \"&LeftUpDownVector;\": \"⥑\",\n    \"&LeftUpTeeVector;\": \"⥠\",\n    \"&LeftUpVector;\": \"↿\",\n    \"&LeftUpVectorBar;\": \"⥘\",\n    \"&LeftVector;\": \"↼\",\n    \"&LeftVectorBar;\": \"⥒\",\n    \"&Leftarrow;\": \"⇐\",\n    \"&Leftrightarrow;\": \"⇔\",\n    \"&LessEqualGreater;\": \"⋚\",\n    \"&LessFullEqual;\": \"≦\",\n    \"&LessGreater;\": \"≶\",\n    \"&LessLess;\": \"⪡\",\n    \"&LessSlantEqual;\": \"⩽\",\n    \"&LessTilde;\": \"≲\",\n    \"&Lfr;\": \"𝔏\",\n    \"&Ll;\": \"⋘\",\n    \"&Lleftarrow;\": \"⇚\",\n    \"&Lmidot;\": \"Ŀ\",\n    \"&LongLeftArrow;\": \"⟵\",\n    \"&LongLeftRightArrow;\": \"⟷\",\n    \"&LongRightArrow;\": \"⟶\",\n    \"&Longleftarrow;\": \"⟸\",\n    \"&Longleftrightarrow;\": \"⟺\",\n    \"&Longrightarrow;\": \"⟹\",\n    \"&Lopf;\": \"𝕃\",\n    \"&LowerLeftArrow;\": \"↙\",\n    \"&LowerRightArrow;\": \"↘\",\n    \"&Lscr;\": \"ℒ\",\n    \"&Lsh;\": \"↰\",\n    \"&Lstrok;\": \"Ł\",\n    \"&Lt;\": \"≪\",\n    \"&Map;\": \"⤅\",\n    \"&Mcy;\": \"М\",\n    \"&MediumSpace;\": \" \",\n    \"&Mellintrf;\": \"ℳ\",\n    \"&Mfr;\": \"𝔐\",\n    \"&MinusPlus;\": \"∓\",\n    \"&Mopf;\": \"𝕄\",\n    \"&Mscr;\": \"ℳ\",\n    \"&Mu;\": \"Μ\",\n    \"&NJcy;\": \"Њ\",\n    \"&Nacute;\": \"Ń\",\n    \"&Ncaron;\": \"Ň\",\n    \"&Ncedil;\": \"Ņ\",\n    \"&Ncy;\": \"Н\",\n    \"&NegativeMediumSpace;\": \"​\",\n    \"&NegativeThickSpace;\": \"​\",\n    \"&NegativeThinSpace;\": \"​\",\n    \"&NegativeVeryThinSpace;\": \"​\",\n    \"&NestedGreaterGreater;\": \"≫\",\n    \"&NestedLessLess;\": \"≪\",\n    \"&NewLine;\": \"\\n\",\n    \"&Nfr;\": \"𝔑\",\n    \"&NoBreak;\": \"⁠\",\n    \"&NonBreakingSpace;\": \" \",\n    \"&Nopf;\": \"ℕ\",\n    \"&Not;\": \"⫬\",\n    \"&NotCongruent;\": \"≢\",\n    \"&NotCupCap;\": \"≭\",\n    \"&NotDoubleVerticalBar;\": \"∦\",\n    \"&NotElement;\": \"∉\",\n    \"&NotEqual;\": \"≠\",\n    \"&NotEqualTilde;\": \"≂̸\",\n    \"&NotExists;\": \"∄\",\n    \"&NotGreater;\": \"≯\",\n    \"&NotGreaterEqual;\": \"≱\",\n    \"&NotGreaterFullEqual;\": \"≧̸\",\n    \"&NotGreaterGreater;\": \"≫̸\",\n    \"&NotGreaterLess;\": \"≹\",\n    \"&NotGreaterSlantEqual;\": \"⩾̸\",\n    \"&NotGreaterTilde;\": \"≵\",\n    \"&NotHumpDownHump;\": \"≎̸\",\n    \"&NotHumpEqual;\": \"≏̸\",\n    \"&NotLeftTriangle;\": \"⋪\",\n    \"&NotLeftTriangleBar;\": \"⧏̸\",\n    \"&NotLeftTriangleEqual;\": \"⋬\",\n    \"&NotLess;\": \"≮\",\n    \"&NotLessEqual;\": \"≰\",\n    \"&NotLessGreater;\": \"≸\",\n    \"&NotLessLess;\": \"≪̸\",\n    \"&NotLessSlantEqual;\": \"⩽̸\",\n    \"&NotLessTilde;\": \"≴\",\n    \"&NotNestedGreaterGreater;\": \"⪢̸\",\n    \"&NotNestedLessLess;\": \"⪡̸\",\n    \"&NotPrecedes;\": \"⊀\",\n    \"&NotPrecedesEqual;\": \"⪯̸\",\n    \"&NotPrecedesSlantEqual;\": \"⋠\",\n    \"&NotReverseElement;\": \"∌\",\n    \"&NotRightTriangle;\": \"⋫\",\n    \"&NotRightTriangleBar;\": \"⧐̸\",\n    \"&NotRightTriangleEqual;\": \"⋭\",\n    \"&NotSquareSubset;\": \"⊏̸\",\n    \"&NotSquareSubsetEqual;\": \"⋢\",\n    \"&NotSquareSuperset;\": \"⊐̸\",\n    \"&NotSquareSupersetEqual;\": \"⋣\",\n    \"&NotSubset;\": \"⊂⃒\",\n    \"&NotSubsetEqual;\": \"⊈\",\n    \"&NotSucceeds;\": \"⊁\",\n    \"&NotSucceedsEqual;\": \"⪰̸\",\n    \"&NotSucceedsSlantEqual;\": \"⋡\",\n    \"&NotSucceedsTilde;\": \"≿̸\",\n    \"&NotSuperset;\": \"⊃⃒\",\n    \"&NotSupersetEqual;\": \"⊉\",\n    \"&NotTilde;\": \"≁\",\n    \"&NotTildeEqual;\": \"≄\",\n    \"&NotTildeFullEqual;\": \"≇\",\n    \"&NotTildeTilde;\": \"≉\",\n    \"&NotVerticalBar;\": \"∤\",\n    \"&Nscr;\": \"𝒩\",\n    \"&Ntilde\": \"Ñ\",\n    \"&Ntilde;\": \"Ñ\",\n    \"&Nu;\": \"Ν\",\n    \"&OElig;\": \"Œ\",\n    \"&Oacute\": \"Ó\",\n    \"&Oacute;\": \"Ó\",\n    \"&Ocirc\": \"Ô\",\n    \"&Ocirc;\": \"Ô\",\n    \"&Ocy;\": \"О\",\n    \"&Odblac;\": \"Ő\",\n    \"&Ofr;\": \"𝔒\",\n    \"&Ograve\": \"Ò\",\n    \"&Ograve;\": \"Ò\",\n    \"&Omacr;\": \"Ō\",\n    \"&Omega;\": \"Ω\",\n    \"&Omicron;\": \"Ο\",\n    \"&Oopf;\": \"𝕆\",\n    \"&OpenCurlyDoubleQuote;\": \"“\",\n    \"&OpenCurlyQuote;\": \"‘\",\n    \"&Or;\": \"⩔\",\n    \"&Oscr;\": \"𝒪\",\n    \"&Oslash\": \"Ø\",\n    \"&Oslash;\": \"Ø\",\n    \"&Otilde\": \"Õ\",\n    \"&Otilde;\": \"Õ\",\n    \"&Otimes;\": \"⨷\",\n    \"&Ouml\": \"Ö\",\n    \"&Ouml;\": \"Ö\",\n    \"&OverBar;\": \"‾\",\n    \"&OverBrace;\": \"⏞\",\n    \"&OverBracket;\": \"⎴\",\n    \"&OverParenthesis;\": \"⏜\",\n    \"&PartialD;\": \"∂\",\n    \"&Pcy;\": \"П\",\n    \"&Pfr;\": \"𝔓\",\n    \"&Phi;\": \"Φ\",\n    \"&Pi;\": \"Π\",\n    \"&PlusMinus;\": \"±\",\n    \"&Poincareplane;\": \"ℌ\",\n    \"&Popf;\": \"ℙ\",\n    \"&Pr;\": \"⪻\",\n    \"&Precedes;\": \"≺\",\n    \"&PrecedesEqual;\": \"⪯\",\n    \"&PrecedesSlantEqual;\": \"≼\",\n    \"&PrecedesTilde;\": \"≾\",\n    \"&Prime;\": \"″\",\n    \"&Product;\": \"∏\",\n    \"&Proportion;\": \"∷\",\n    \"&Proportional;\": \"∝\",\n    \"&Pscr;\": \"𝒫\",\n    \"&Psi;\": \"Ψ\",\n    \"&QUOT\": \"\\\"\",\n    \"&QUOT;\": \"\\\"\",\n    \"&Qfr;\": \"𝔔\",\n    \"&Qopf;\": \"ℚ\",\n    \"&Qscr;\": \"𝒬\",\n    \"&RBarr;\": \"⤐\",\n    \"&REG\": \"®\",\n    \"&REG;\": \"®\",\n    \"&Racute;\": \"Ŕ\",\n    \"&Rang;\": \"⟫\",\n    \"&Rarr;\": \"↠\",\n    \"&Rarrtl;\": \"⤖\",\n    \"&Rcaron;\": \"Ř\",\n    \"&Rcedil;\": \"Ŗ\",\n    \"&Rcy;\": \"Р\",\n    \"&Re;\": \"ℜ\",\n    \"&ReverseElement;\": \"∋\",\n    \"&ReverseEquilibrium;\": \"⇋\",\n    \"&ReverseUpEquilibrium;\": \"⥯\",\n    \"&Rfr;\": \"ℜ\",\n    \"&Rho;\": \"Ρ\",\n    \"&RightAngleBracket;\": \"⟩\",\n    \"&RightArrow;\": \"→\",\n    \"&RightArrowBar;\": \"⇥\",\n    \"&RightArrowLeftArrow;\": \"⇄\",\n    \"&RightCeiling;\": \"⌉\",\n    \"&RightDoubleBracket;\": \"⟧\",\n    \"&RightDownTeeVector;\": \"⥝\",\n    \"&RightDownVector;\": \"⇂\",\n    \"&RightDownVectorBar;\": \"⥕\",\n    \"&RightFloor;\": \"⌋\",\n    \"&RightTee;\": \"⊢\",\n    \"&RightTeeArrow;\": \"↦\",\n    \"&RightTeeVector;\": \"⥛\",\n    \"&RightTriangle;\": \"⊳\",\n    \"&RightTriangleBar;\": \"⧐\",\n    \"&RightTriangleEqual;\": \"⊵\",\n    \"&RightUpDownVector;\": \"⥏\",\n    \"&RightUpTeeVector;\": \"⥜\",\n    \"&RightUpVector;\": \"↾\",\n    \"&RightUpVectorBar;\": \"⥔\",\n    \"&RightVector;\": \"⇀\",\n    \"&RightVectorBar;\": \"⥓\",\n    \"&Rightarrow;\": \"⇒\",\n    \"&Ropf;\": \"ℝ\",\n    \"&RoundImplies;\": \"⥰\",\n    \"&Rrightarrow;\": \"⇛\",\n    \"&Rscr;\": \"ℛ\",\n    \"&Rsh;\": \"↱\",\n    \"&RuleDelayed;\": \"⧴\",\n    \"&SHCHcy;\": \"Щ\",\n    \"&SHcy;\": \"Ш\",\n    \"&SOFTcy;\": \"Ь\",\n    \"&Sacute;\": \"Ś\",\n    \"&Sc;\": \"⪼\",\n    \"&Scaron;\": \"Š\",\n    \"&Scedil;\": \"Ş\",\n    \"&Scirc;\": \"Ŝ\",\n    \"&Scy;\": \"С\",\n    \"&Sfr;\": \"𝔖\",\n    \"&ShortDownArrow;\": \"↓\",\n    \"&ShortLeftArrow;\": \"←\",\n    \"&ShortRightArrow;\": \"→\",\n    \"&ShortUpArrow;\": \"↑\",\n    \"&Sigma;\": \"Σ\",\n    \"&SmallCircle;\": \"∘\",\n    \"&Sopf;\": \"𝕊\",\n    \"&Sqrt;\": \"√\",\n    \"&Square;\": \"□\",\n    \"&SquareIntersection;\": \"⊓\",\n    \"&SquareSubset;\": \"⊏\",\n    \"&SquareSubsetEqual;\": \"⊑\",\n    \"&SquareSuperset;\": \"⊐\",\n    \"&SquareSupersetEqual;\": \"⊒\",\n    \"&SquareUnion;\": \"⊔\",\n    \"&Sscr;\": \"𝒮\",\n    \"&Star;\": \"⋆\",\n    \"&Sub;\": \"⋐\",\n    \"&Subset;\": \"⋐\",\n    \"&SubsetEqual;\": \"⊆\",\n    \"&Succeeds;\": \"≻\",\n    \"&SucceedsEqual;\": \"⪰\",\n    \"&SucceedsSlantEqual;\": \"≽\",\n    \"&SucceedsTilde;\": \"≿\",\n    \"&SuchThat;\": \"∋\",\n    \"&Sum;\": \"∑\",\n    \"&Sup;\": \"⋑\",\n    \"&Superset;\": \"⊃\",\n    \"&SupersetEqual;\": \"⊇\",\n    \"&Supset;\": \"⋑\",\n    \"&THORN\": \"Þ\",\n    \"&THORN;\": \"Þ\",\n    \"&TRADE;\": \"™\",\n    \"&TSHcy;\": \"Ћ\",\n    \"&TScy;\": \"Ц\",\n    \"&Tab;\": \"\\t\",\n    \"&Tau;\": \"Τ\",\n    \"&Tcaron;\": \"Ť\",\n    \"&Tcedil;\": \"Ţ\",\n    \"&Tcy;\": \"Т\",\n    \"&Tfr;\": \"𝔗\",\n    \"&Therefore;\": \"∴\",\n    \"&Theta;\": \"Θ\",\n    \"&ThickSpace;\": \"  \",\n    \"&ThinSpace;\": \" \",\n    \"&Tilde;\": \"∼\",\n    \"&TildeEqual;\": \"≃\",\n    \"&TildeFullEqual;\": \"≅\",\n    \"&TildeTilde;\": \"≈\",\n    \"&Topf;\": \"𝕋\",\n    \"&TripleDot;\": \"⃛\",\n    \"&Tscr;\": \"𝒯\",\n    \"&Tstrok;\": \"Ŧ\",\n    \"&Uacute\": \"Ú\",\n    \"&Uacute;\": \"Ú\",\n    \"&Uarr;\": \"↟\",\n    \"&Uarrocir;\": \"⥉\",\n    \"&Ubrcy;\": \"Ў\",\n    \"&Ubreve;\": \"Ŭ\",\n    \"&Ucirc\": \"Û\",\n    \"&Ucirc;\": \"Û\",\n    \"&Ucy;\": \"У\",\n    \"&Udblac;\": \"Ű\",\n    \"&Ufr;\": \"𝔘\",\n    \"&Ugrave\": \"Ù\",\n    \"&Ugrave;\": \"Ù\",\n    \"&Umacr;\": \"Ū\",\n    \"&UnderBar;\": \"_\",\n    \"&UnderBrace;\": \"⏟\",\n    \"&UnderBracket;\": \"⎵\",\n    \"&UnderParenthesis;\": \"⏝\",\n    \"&Union;\": \"⋃\",\n    \"&UnionPlus;\": \"⊎\",\n    \"&Uogon;\": \"Ų\",\n    \"&Uopf;\": \"𝕌\",\n    \"&UpArrow;\": \"↑\",\n    \"&UpArrowBar;\": \"⤒\",\n    \"&UpArrowDownArrow;\": \"⇅\",\n    \"&UpDownArrow;\": \"↕\",\n    \"&UpEquilibrium;\": \"⥮\",\n    \"&UpTee;\": \"⊥\",\n    \"&UpTeeArrow;\": \"↥\",\n    \"&Uparrow;\": \"⇑\",\n    \"&Updownarrow;\": \"⇕\",\n    \"&UpperLeftArrow;\": \"↖\",\n    \"&UpperRightArrow;\": \"↗\",\n    \"&Upsi;\": \"ϒ\",\n    \"&Upsilon;\": \"Υ\",\n    \"&Uring;\": \"Ů\",\n    \"&Uscr;\": \"𝒰\",\n    \"&Utilde;\": \"Ũ\",\n    \"&Uuml\": \"Ü\",\n    \"&Uuml;\": \"Ü\",\n    \"&VDash;\": \"⊫\",\n    \"&Vbar;\": \"⫫\",\n    \"&Vcy;\": \"В\",\n    \"&Vdash;\": \"⊩\",\n    \"&Vdashl;\": \"⫦\",\n    \"&Vee;\": \"⋁\",\n    \"&Verbar;\": \"‖\",\n    \"&Vert;\": \"‖\",\n    \"&VerticalBar;\": \"∣\",\n    \"&VerticalLine;\": \"|\",\n    \"&VerticalSeparator;\": \"❘\",\n    \"&VerticalTilde;\": \"≀\",\n    \"&VeryThinSpace;\": \" \",\n    \"&Vfr;\": \"𝔙\",\n    \"&Vopf;\": \"𝕍\",\n    \"&Vscr;\": \"𝒱\",\n    \"&Vvdash;\": \"⊪\",\n    \"&Wcirc;\": \"Ŵ\",\n    \"&Wedge;\": \"⋀\",\n    \"&Wfr;\": \"𝔚\",\n    \"&Wopf;\": \"𝕎\",\n    \"&Wscr;\": \"𝒲\",\n    \"&Xfr;\": \"𝔛\",\n    \"&Xi;\": \"Ξ\",\n    \"&Xopf;\": \"𝕏\",\n    \"&Xscr;\": \"𝒳\",\n    \"&YAcy;\": \"Я\",\n    \"&YIcy;\": \"Ї\",\n    \"&YUcy;\": \"Ю\",\n    \"&Yacute\": \"Ý\",\n    \"&Yacute;\": \"Ý\",\n    \"&Ycirc;\": \"Ŷ\",\n    \"&Ycy;\": \"Ы\",\n    \"&Yfr;\": \"𝔜\",\n    \"&Yopf;\": \"𝕐\",\n    \"&Yscr;\": \"𝒴\",\n    \"&Yuml;\": \"Ÿ\",\n    \"&ZHcy;\": \"Ж\",\n    \"&Zacute;\": \"Ź\",\n    \"&Zcaron;\": \"Ž\",\n    \"&Zcy;\": \"З\",\n    \"&Zdot;\": \"Ż\",\n    \"&ZeroWidthSpace;\": \"​\",\n    \"&Zeta;\": \"Ζ\",\n    \"&Zfr;\": \"ℨ\",\n    \"&Zopf;\": \"ℤ\",\n    \"&Zscr;\": \"𝒵\",\n    \"&aacute\": \"á\",\n    \"&aacute;\": \"á\",\n    \"&abreve;\": \"ă\",\n    \"&ac;\": \"∾\",\n    \"&acE;\": \"∾̳\",\n    \"&acd;\": \"∿\",\n    \"&acirc\": \"â\",\n    \"&acirc;\": \"â\",\n    \"&acute\": \"´\",\n    \"&acute;\": \"´\",\n    \"&acy;\": \"а\",\n    \"&aelig\": \"æ\",\n    \"&aelig;\": \"æ\",\n    \"&af;\": \"⁡\",\n    \"&afr;\": \"𝔞\",\n    \"&agrave\": \"à\",\n    \"&agrave;\": \"à\",\n    \"&alefsym;\": \"ℵ\",\n    \"&aleph;\": \"ℵ\",\n    \"&alpha;\": \"α\",\n    \"&amacr;\": \"ā\",\n    \"&amalg;\": \"⨿\",\n    \"&amp\": \"&\",\n    \"&amp;\": \"&\",\n    \"&and;\": \"∧\",\n    \"&andand;\": \"⩕\",\n    \"&andd;\": \"⩜\",\n    \"&andslope;\": \"⩘\",\n    \"&andv;\": \"⩚\",\n    \"&ang;\": \"∠\",\n    \"&ange;\": \"⦤\",\n    \"&angle;\": \"∠\",\n    \"&angmsd;\": \"∡\",\n    \"&angmsdaa;\": \"⦨\",\n    \"&angmsdab;\": \"⦩\",\n    \"&angmsdac;\": \"⦪\",\n    \"&angmsdad;\": \"⦫\",\n    \"&angmsdae;\": \"⦬\",\n    \"&angmsdaf;\": \"⦭\",\n    \"&angmsdag;\": \"⦮\",\n    \"&angmsdah;\": \"⦯\",\n    \"&angrt;\": \"∟\",\n    \"&angrtvb;\": \"⊾\",\n    \"&angrtvbd;\": \"⦝\",\n    \"&angsph;\": \"∢\",\n    \"&angst;\": \"Å\",\n    \"&angzarr;\": \"⍼\",\n    \"&aogon;\": \"ą\",\n    \"&aopf;\": \"𝕒\",\n    \"&ap;\": \"≈\",\n    \"&apE;\": \"⩰\",\n    \"&apacir;\": \"⩯\",\n    \"&ape;\": \"≊\",\n    \"&apid;\": \"≋\",\n    \"&apos;\": \"'\",\n    \"&approx;\": \"≈\",\n    \"&approxeq;\": \"≊\",\n    \"&aring\": \"å\",\n    \"&aring;\": \"å\",\n    \"&ascr;\": \"𝒶\",\n    \"&ast;\": \"*\",\n    \"&asymp;\": \"≈\",\n    \"&asympeq;\": \"≍\",\n    \"&atilde\": \"ã\",\n    \"&atilde;\": \"ã\",\n    \"&auml\": \"ä\",\n    \"&auml;\": \"ä\",\n    \"&awconint;\": \"∳\",\n    \"&awint;\": \"⨑\",\n    \"&bNot;\": \"⫭\",\n    \"&backcong;\": \"≌\",\n    \"&backepsilon;\": \"϶\",\n    \"&backprime;\": \"‵\",\n    \"&backsim;\": \"∽\",\n    \"&backsimeq;\": \"⋍\",\n    \"&barvee;\": \"⊽\",\n    \"&barwed;\": \"⌅\",\n    \"&barwedge;\": \"⌅\",\n    \"&bbrk;\": \"⎵\",\n    \"&bbrktbrk;\": \"⎶\",\n    \"&bcong;\": \"≌\",\n    \"&bcy;\": \"б\",\n    \"&bdquo;\": \"„\",\n    \"&becaus;\": \"∵\",\n    \"&because;\": \"∵\",\n    \"&bemptyv;\": \"⦰\",\n    \"&bepsi;\": \"϶\",\n    \"&bernou;\": \"ℬ\",\n    \"&beta;\": \"β\",\n    \"&beth;\": \"ℶ\",\n    \"&between;\": \"≬\",\n    \"&bfr;\": \"𝔟\",\n    \"&bigcap;\": \"⋂\",\n    \"&bigcirc;\": \"◯\",\n    \"&bigcup;\": \"⋃\",\n    \"&bigodot;\": \"⨀\",\n    \"&bigoplus;\": \"⨁\",\n    \"&bigotimes;\": \"⨂\",\n    \"&bigsqcup;\": \"⨆\",\n    \"&bigstar;\": \"★\",\n    \"&bigtriangledown;\": \"▽\",\n    \"&bigtriangleup;\": \"△\",\n    \"&biguplus;\": \"⨄\",\n    \"&bigvee;\": \"⋁\",\n    \"&bigwedge;\": \"⋀\",\n    \"&bkarow;\": \"⤍\",\n    \"&blacklozenge;\": \"⧫\",\n    \"&blacksquare;\": \"▪\",\n    \"&blacktriangle;\": \"▴\",\n    \"&blacktriangledown;\": \"▾\",\n    \"&blacktriangleleft;\": \"◂\",\n    \"&blacktriangleright;\": \"▸\",\n    \"&blank;\": \"␣\",\n    \"&blk12;\": \"▒\",\n    \"&blk14;\": \"░\",\n    \"&blk34;\": \"▓\",\n    \"&block;\": \"█\",\n    \"&bne;\": \"=⃥\",\n    \"&bnequiv;\": \"≡⃥\",\n    \"&bnot;\": \"⌐\",\n    \"&bopf;\": \"𝕓\",\n    \"&bot;\": \"⊥\",\n    \"&bottom;\": \"⊥\",\n    \"&bowtie;\": \"⋈\",\n    \"&boxDL;\": \"╗\",\n    \"&boxDR;\": \"╔\",\n    \"&boxDl;\": \"╖\",\n    \"&boxDr;\": \"╓\",\n    \"&boxH;\": \"═\",\n    \"&boxHD;\": \"╦\",\n    \"&boxHU;\": \"╩\",\n    \"&boxHd;\": \"╤\",\n    \"&boxHu;\": \"╧\",\n    \"&boxUL;\": \"╝\",\n    \"&boxUR;\": \"╚\",\n    \"&boxUl;\": \"╜\",\n    \"&boxUr;\": \"╙\",\n    \"&boxV;\": \"║\",\n    \"&boxVH;\": \"╬\",\n    \"&boxVL;\": \"╣\",\n    \"&boxVR;\": \"╠\",\n    \"&boxVh;\": \"╫\",\n    \"&boxVl;\": \"╢\",\n    \"&boxVr;\": \"╟\",\n    \"&boxbox;\": \"⧉\",\n    \"&boxdL;\": \"╕\",\n    \"&boxdR;\": \"╒\",\n    \"&boxdl;\": \"┐\",\n    \"&boxdr;\": \"┌\",\n    \"&boxh;\": \"─\",\n    \"&boxhD;\": \"╥\",\n    \"&boxhU;\": \"╨\",\n    \"&boxhd;\": \"┬\",\n    \"&boxhu;\": \"┴\",\n    \"&boxminus;\": \"⊟\",\n    \"&boxplus;\": \"⊞\",\n    \"&boxtimes;\": \"⊠\",\n    \"&boxuL;\": \"╛\",\n    \"&boxuR;\": \"╘\",\n    \"&boxul;\": \"┘\",\n    \"&boxur;\": \"└\",\n    \"&boxv;\": \"│\",\n    \"&boxvH;\": \"╪\",\n    \"&boxvL;\": \"╡\",\n    \"&boxvR;\": \"╞\",\n    \"&boxvh;\": \"┼\",\n    \"&boxvl;\": \"┤\",\n    \"&boxvr;\": \"├\",\n    \"&bprime;\": \"‵\",\n    \"&breve;\": \"˘\",\n    \"&brvbar\": \"¦\",\n    \"&brvbar;\": \"¦\",\n    \"&bscr;\": \"𝒷\",\n    \"&bsemi;\": \"⁏\",\n    \"&bsim;\": \"∽\",\n    \"&bsime;\": \"⋍\",\n    \"&bsol;\": \"\\\\\",\n    \"&bsolb;\": \"⧅\",\n    \"&bsolhsub;\": \"⟈\",\n    \"&bull;\": \"•\",\n    \"&bullet;\": \"•\",\n    \"&bump;\": \"≎\",\n    \"&bumpE;\": \"⪮\",\n    \"&bumpe;\": \"≏\",\n    \"&bumpeq;\": \"≏\",\n    \"&cacute;\": \"ć\",\n    \"&cap;\": \"∩\",\n    \"&capand;\": \"⩄\",\n    \"&capbrcup;\": \"⩉\",\n    \"&capcap;\": \"⩋\",\n    \"&capcup;\": \"⩇\",\n    \"&capdot;\": \"⩀\",\n    \"&caps;\": \"∩︀\",\n    \"&caret;\": \"⁁\",\n    \"&caron;\": \"ˇ\",\n    \"&ccaps;\": \"⩍\",\n    \"&ccaron;\": \"č\",\n    \"&ccedil\": \"ç\",\n    \"&ccedil;\": \"ç\",\n    \"&ccirc;\": \"ĉ\",\n    \"&ccups;\": \"⩌\",\n    \"&ccupssm;\": \"⩐\",\n    \"&cdot;\": \"ċ\",\n    \"&cedil\": \"¸\",\n    \"&cedil;\": \"¸\",\n    \"&cemptyv;\": \"⦲\",\n    \"&cent\": \"¢\",\n    \"&cent;\": \"¢\",\n    \"&centerdot;\": \"·\",\n    \"&cfr;\": \"𝔠\",\n    \"&chcy;\": \"ч\",\n    \"&check;\": \"✓\",\n    \"&checkmark;\": \"✓\",\n    \"&chi;\": \"χ\",\n    \"&cir;\": \"○\",\n    \"&cirE;\": \"⧃\",\n    \"&circ;\": \"ˆ\",\n    \"&circeq;\": \"≗\",\n    \"&circlearrowleft;\": \"↺\",\n    \"&circlearrowright;\": \"↻\",\n    \"&circledR;\": \"®\",\n    \"&circledS;\": \"Ⓢ\",\n    \"&circledast;\": \"⊛\",\n    \"&circledcirc;\": \"⊚\",\n    \"&circleddash;\": \"⊝\",\n    \"&cire;\": \"≗\",\n    \"&cirfnint;\": \"⨐\",\n    \"&cirmid;\": \"⫯\",\n    \"&cirscir;\": \"⧂\",\n    \"&clubs;\": \"♣\",\n    \"&clubsuit;\": \"♣\",\n    \"&colon;\": \":\",\n    \"&colone;\": \"≔\",\n    \"&coloneq;\": \"≔\",\n    \"&comma;\": \",\",\n    \"&commat;\": \"@\",\n    \"&comp;\": \"∁\",\n    \"&compfn;\": \"∘\",\n    \"&complement;\": \"∁\",\n    \"&complexes;\": \"ℂ\",\n    \"&cong;\": \"≅\",\n    \"&congdot;\": \"⩭\",\n    \"&conint;\": \"∮\",\n    \"&copf;\": \"𝕔\",\n    \"&coprod;\": \"∐\",\n    \"&copy\": \"©\",\n    \"&copy;\": \"©\",\n    \"&copysr;\": \"℗\",\n    \"&crarr;\": \"↵\",\n    \"&cross;\": \"✗\",\n    \"&cscr;\": \"𝒸\",\n    \"&csub;\": \"⫏\",\n    \"&csube;\": \"⫑\",\n    \"&csup;\": \"⫐\",\n    \"&csupe;\": \"⫒\",\n    \"&ctdot;\": \"⋯\",\n    \"&cudarrl;\": \"⤸\",\n    \"&cudarrr;\": \"⤵\",\n    \"&cuepr;\": \"⋞\",\n    \"&cuesc;\": \"⋟\",\n    \"&cularr;\": \"↶\",\n    \"&cularrp;\": \"⤽\",\n    \"&cup;\": \"∪\",\n    \"&cupbrcap;\": \"⩈\",\n    \"&cupcap;\": \"⩆\",\n    \"&cupcup;\": \"⩊\",\n    \"&cupdot;\": \"⊍\",\n    \"&cupor;\": \"⩅\",\n    \"&cups;\": \"∪︀\",\n    \"&curarr;\": \"↷\",\n    \"&curarrm;\": \"⤼\",\n    \"&curlyeqprec;\": \"⋞\",\n    \"&curlyeqsucc;\": \"⋟\",\n    \"&curlyvee;\": \"⋎\",\n    \"&curlywedge;\": \"⋏\",\n    \"&curren\": \"¤\",\n    \"&curren;\": \"¤\",\n    \"&curvearrowleft;\": \"↶\",\n    \"&curvearrowright;\": \"↷\",\n    \"&cuvee;\": \"⋎\",\n    \"&cuwed;\": \"⋏\",\n    \"&cwconint;\": \"∲\",\n    \"&cwint;\": \"∱\",\n    \"&cylcty;\": \"⌭\",\n    \"&dArr;\": \"⇓\",\n    \"&dHar;\": \"⥥\",\n    \"&dagger;\": \"†\",\n    \"&daleth;\": \"ℸ\",\n    \"&darr;\": \"↓\",\n    \"&dash;\": \"‐\",\n    \"&dashv;\": \"⊣\",\n    \"&dbkarow;\": \"⤏\",\n    \"&dblac;\": \"˝\",\n    \"&dcaron;\": \"ď\",\n    \"&dcy;\": \"д\",\n    \"&dd;\": \"ⅆ\",\n    \"&ddagger;\": \"‡\",\n    \"&ddarr;\": \"⇊\",\n    \"&ddotseq;\": \"⩷\",\n    \"&deg\": \"°\",\n    \"&deg;\": \"°\",\n    \"&delta;\": \"δ\",\n    \"&demptyv;\": \"⦱\",\n    \"&dfisht;\": \"⥿\",\n    \"&dfr;\": \"𝔡\",\n    \"&dharl;\": \"⇃\",\n    \"&dharr;\": \"⇂\",\n    \"&diam;\": \"⋄\",\n    \"&diamond;\": \"⋄\",\n    \"&diamondsuit;\": \"♦\",\n    \"&diams;\": \"♦\",\n    \"&die;\": \"¨\",\n    \"&digamma;\": \"ϝ\",\n    \"&disin;\": \"⋲\",\n    \"&div;\": \"÷\",\n    \"&divide\": \"÷\",\n    \"&divide;\": \"÷\",\n    \"&divideontimes;\": \"⋇\",\n    \"&divonx;\": \"⋇\",\n    \"&djcy;\": \"ђ\",\n    \"&dlcorn;\": \"⌞\",\n    \"&dlcrop;\": \"⌍\",\n    \"&dollar;\": \"$\",\n    \"&dopf;\": \"𝕕\",\n    \"&dot;\": \"˙\",\n    \"&doteq;\": \"≐\",\n    \"&doteqdot;\": \"≑\",\n    \"&dotminus;\": \"∸\",\n    \"&dotplus;\": \"∔\",\n    \"&dotsquare;\": \"⊡\",\n    \"&doublebarwedge;\": \"⌆\",\n    \"&downarrow;\": \"↓\",\n    \"&downdownarrows;\": \"⇊\",\n    \"&downharpoonleft;\": \"⇃\",\n    \"&downharpoonright;\": \"⇂\",\n    \"&drbkarow;\": \"⤐\",\n    \"&drcorn;\": \"⌟\",\n    \"&drcrop;\": \"⌌\",\n    \"&dscr;\": \"𝒹\",\n    \"&dscy;\": \"ѕ\",\n    \"&dsol;\": \"⧶\",\n    \"&dstrok;\": \"đ\",\n    \"&dtdot;\": \"⋱\",\n    \"&dtri;\": \"▿\",\n    \"&dtrif;\": \"▾\",\n    \"&duarr;\": \"⇵\",\n    \"&duhar;\": \"⥯\",\n    \"&dwangle;\": \"⦦\",\n    \"&dzcy;\": \"џ\",\n    \"&dzigrarr;\": \"⟿\",\n    \"&eDDot;\": \"⩷\",\n    \"&eDot;\": \"≑\",\n    \"&eacute\": \"é\",\n    \"&eacute;\": \"é\",\n    \"&easter;\": \"⩮\",\n    \"&ecaron;\": \"ě\",\n    \"&ecir;\": \"≖\",\n    \"&ecirc\": \"ê\",\n    \"&ecirc;\": \"ê\",\n    \"&ecolon;\": \"≕\",\n    \"&ecy;\": \"э\",\n    \"&edot;\": \"ė\",\n    \"&ee;\": \"ⅇ\",\n    \"&efDot;\": \"≒\",\n    \"&efr;\": \"𝔢\",\n    \"&eg;\": \"⪚\",\n    \"&egrave\": \"è\",\n    \"&egrave;\": \"è\",\n    \"&egs;\": \"⪖\",\n    \"&egsdot;\": \"⪘\",\n    \"&el;\": \"⪙\",\n    \"&elinters;\": \"⏧\",\n    \"&ell;\": \"ℓ\",\n    \"&els;\": \"⪕\",\n    \"&elsdot;\": \"⪗\",\n    \"&emacr;\": \"ē\",\n    \"&empty;\": \"∅\",\n    \"&emptyset;\": \"∅\",\n    \"&emptyv;\": \"∅\",\n    \"&emsp13;\": \" \",\n    \"&emsp14;\": \" \",\n    \"&emsp;\": \" \",\n    \"&eng;\": \"ŋ\",\n    \"&ensp;\": \" \",\n    \"&eogon;\": \"ę\",\n    \"&eopf;\": \"𝕖\",\n    \"&epar;\": \"⋕\",\n    \"&eparsl;\": \"⧣\",\n    \"&eplus;\": \"⩱\",\n    \"&epsi;\": \"ε\",\n    \"&epsilon;\": \"ε\",\n    \"&epsiv;\": \"ϵ\",\n    \"&eqcirc;\": \"≖\",\n    \"&eqcolon;\": \"≕\",\n    \"&eqsim;\": \"≂\",\n    \"&eqslantgtr;\": \"⪖\",\n    \"&eqslantless;\": \"⪕\",\n    \"&equals;\": \"=\",\n    \"&equest;\": \"≟\",\n    \"&equiv;\": \"≡\",\n    \"&equivDD;\": \"⩸\",\n    \"&eqvparsl;\": \"⧥\",\n    \"&erDot;\": \"≓\",\n    \"&erarr;\": \"⥱\",\n    \"&escr;\": \"ℯ\",\n    \"&esdot;\": \"≐\",\n    \"&esim;\": \"≂\",\n    \"&eta;\": \"η\",\n    \"&eth\": \"ð\",\n    \"&eth;\": \"ð\",\n    \"&euml\": \"ë\",\n    \"&euml;\": \"ë\",\n    \"&euro;\": \"€\",\n    \"&excl;\": \"!\",\n    \"&exist;\": \"∃\",\n    \"&expectation;\": \"ℰ\",\n    \"&exponentiale;\": \"ⅇ\",\n    \"&fallingdotseq;\": \"≒\",\n    \"&fcy;\": \"ф\",\n    \"&female;\": \"♀\",\n    \"&ffilig;\": \"ﬃ\",\n    \"&fflig;\": \"ﬀ\",\n    \"&ffllig;\": \"ﬄ\",\n    \"&ffr;\": \"𝔣\",\n    \"&filig;\": \"ﬁ\",\n    \"&fjlig;\": \"fj\",\n    \"&flat;\": \"♭\",\n    \"&fllig;\": \"ﬂ\",\n    \"&fltns;\": \"▱\",\n    \"&fnof;\": \"ƒ\",\n    \"&fopf;\": \"𝕗\",\n    \"&forall;\": \"∀\",\n    \"&fork;\": \"⋔\",\n    \"&forkv;\": \"⫙\",\n    \"&fpartint;\": \"⨍\",\n    \"&frac12\": \"½\",\n    \"&frac12;\": \"½\",\n    \"&frac13;\": \"⅓\",\n    \"&frac14\": \"¼\",\n    \"&frac14;\": \"¼\",\n    \"&frac15;\": \"⅕\",\n    \"&frac16;\": \"⅙\",\n    \"&frac18;\": \"⅛\",\n    \"&frac23;\": \"⅔\",\n    \"&frac25;\": \"⅖\",\n    \"&frac34\": \"¾\",\n    \"&frac34;\": \"¾\",\n    \"&frac35;\": \"⅗\",\n    \"&frac38;\": \"⅜\",\n    \"&frac45;\": \"⅘\",\n    \"&frac56;\": \"⅚\",\n    \"&frac58;\": \"⅝\",\n    \"&frac78;\": \"⅞\",\n    \"&frasl;\": \"⁄\",\n    \"&frown;\": \"⌢\",\n    \"&fscr;\": \"𝒻\",\n    \"&gE;\": \"≧\",\n    \"&gEl;\": \"⪌\",\n    \"&gacute;\": \"ǵ\",\n    \"&gamma;\": \"γ\",\n    \"&gammad;\": \"ϝ\",\n    \"&gap;\": \"⪆\",\n    \"&gbreve;\": \"ğ\",\n    \"&gcirc;\": \"ĝ\",\n    \"&gcy;\": \"г\",\n    \"&gdot;\": \"ġ\",\n    \"&ge;\": \"≥\",\n    \"&gel;\": \"⋛\",\n    \"&geq;\": \"≥\",\n    \"&geqq;\": \"≧\",\n    \"&geqslant;\": \"⩾\",\n    \"&ges;\": \"⩾\",\n    \"&gescc;\": \"⪩\",\n    \"&gesdot;\": \"⪀\",\n    \"&gesdoto;\": \"⪂\",\n    \"&gesdotol;\": \"⪄\",\n    \"&gesl;\": \"⋛︀\",\n    \"&gesles;\": \"⪔\",\n    \"&gfr;\": \"𝔤\",\n    \"&gg;\": \"≫\",\n    \"&ggg;\": \"⋙\",\n    \"&gimel;\": \"ℷ\",\n    \"&gjcy;\": \"ѓ\",\n    \"&gl;\": \"≷\",\n    \"&glE;\": \"⪒\",\n    \"&gla;\": \"⪥\",\n    \"&glj;\": \"⪤\",\n    \"&gnE;\": \"≩\",\n    \"&gnap;\": \"⪊\",\n    \"&gnapprox;\": \"⪊\",\n    \"&gne;\": \"⪈\",\n    \"&gneq;\": \"⪈\",\n    \"&gneqq;\": \"≩\",\n    \"&gnsim;\": \"⋧\",\n    \"&gopf;\": \"𝕘\",\n    \"&grave;\": \"`\",\n    \"&gscr;\": \"ℊ\",\n    \"&gsim;\": \"≳\",\n    \"&gsime;\": \"⪎\",\n    \"&gsiml;\": \"⪐\",\n    \"&gt\": \">\",\n    \"&gt;\": \">\",\n    \"&gtcc;\": \"⪧\",\n    \"&gtcir;\": \"⩺\",\n    \"&gtdot;\": \"⋗\",\n    \"&gtlPar;\": \"⦕\",\n    \"&gtquest;\": \"⩼\",\n    \"&gtrapprox;\": \"⪆\",\n    \"&gtrarr;\": \"⥸\",\n    \"&gtrdot;\": \"⋗\",\n    \"&gtreqless;\": \"⋛\",\n    \"&gtreqqless;\": \"⪌\",\n    \"&gtrless;\": \"≷\",\n    \"&gtrsim;\": \"≳\",\n    \"&gvertneqq;\": \"≩︀\",\n    \"&gvnE;\": \"≩︀\",\n    \"&hArr;\": \"⇔\",\n    \"&hairsp;\": \" \",\n    \"&half;\": \"½\",\n    \"&hamilt;\": \"ℋ\",\n    \"&hardcy;\": \"ъ\",\n    \"&harr;\": \"↔\",\n    \"&harrcir;\": \"⥈\",\n    \"&harrw;\": \"↭\",\n    \"&hbar;\": \"ℏ\",\n    \"&hcirc;\": \"ĥ\",\n    \"&hearts;\": \"♥\",\n    \"&heartsuit;\": \"♥\",\n    \"&hellip;\": \"…\",\n    \"&hercon;\": \"⊹\",\n    \"&hfr;\": \"𝔥\",\n    \"&hksearow;\": \"⤥\",\n    \"&hkswarow;\": \"⤦\",\n    \"&hoarr;\": \"⇿\",\n    \"&homtht;\": \"∻\",\n    \"&hookleftarrow;\": \"↩\",\n    \"&hookrightarrow;\": \"↪\",\n    \"&hopf;\": \"𝕙\",\n    \"&horbar;\": \"―\",\n    \"&hscr;\": \"𝒽\",\n    \"&hslash;\": \"ℏ\",\n    \"&hstrok;\": \"ħ\",\n    \"&hybull;\": \"⁃\",\n    \"&hyphen;\": \"‐\",\n    \"&iacute\": \"í\",\n    \"&iacute;\": \"í\",\n    \"&ic;\": \"⁣\",\n    \"&icirc\": \"î\",\n    \"&icirc;\": \"î\",\n    \"&icy;\": \"и\",\n    \"&iecy;\": \"е\",\n    \"&iexcl\": \"¡\",\n    \"&iexcl;\": \"¡\",\n    \"&iff;\": \"⇔\",\n    \"&ifr;\": \"𝔦\",\n    \"&igrave\": \"ì\",\n    \"&igrave;\": \"ì\",\n    \"&ii;\": \"ⅈ\",\n    \"&iiiint;\": \"⨌\",\n    \"&iiint;\": \"∭\",\n    \"&iinfin;\": \"⧜\",\n    \"&iiota;\": \"℩\",\n    \"&ijlig;\": \"ĳ\",\n    \"&imacr;\": \"ī\",\n    \"&image;\": \"ℑ\",\n    \"&imagline;\": \"ℐ\",\n    \"&imagpart;\": \"ℑ\",\n    \"&imath;\": \"ı\",\n    \"&imof;\": \"⊷\",\n    \"&imped;\": \"Ƶ\",\n    \"&in;\": \"∈\",\n    \"&incare;\": \"℅\",\n    \"&infin;\": \"∞\",\n    \"&infintie;\": \"⧝\",\n    \"&inodot;\": \"ı\",\n    \"&int;\": \"∫\",\n    \"&intcal;\": \"⊺\",\n    \"&integers;\": \"ℤ\",\n    \"&intercal;\": \"⊺\",\n    \"&intlarhk;\": \"⨗\",\n    \"&intprod;\": \"⨼\",\n    \"&iocy;\": \"ё\",\n    \"&iogon;\": \"į\",\n    \"&iopf;\": \"𝕚\",\n    \"&iota;\": \"ι\",\n    \"&iprod;\": \"⨼\",\n    \"&iquest\": \"¿\",\n    \"&iquest;\": \"¿\",\n    \"&iscr;\": \"𝒾\",\n    \"&isin;\": \"∈\",\n    \"&isinE;\": \"⋹\",\n    \"&isindot;\": \"⋵\",\n    \"&isins;\": \"⋴\",\n    \"&isinsv;\": \"⋳\",\n    \"&isinv;\": \"∈\",\n    \"&it;\": \"⁢\",\n    \"&itilde;\": \"ĩ\",\n    \"&iukcy;\": \"і\",\n    \"&iuml\": \"ï\",\n    \"&iuml;\": \"ï\",\n    \"&jcirc;\": \"ĵ\",\n    \"&jcy;\": \"й\",\n    \"&jfr;\": \"𝔧\",\n    \"&jmath;\": \"ȷ\",\n    \"&jopf;\": \"𝕛\",\n    \"&jscr;\": \"𝒿\",\n    \"&jsercy;\": \"ј\",\n    \"&jukcy;\": \"є\",\n    \"&kappa;\": \"κ\",\n    \"&kappav;\": \"ϰ\",\n    \"&kcedil;\": \"ķ\",\n    \"&kcy;\": \"к\",\n    \"&kfr;\": \"𝔨\",\n    \"&kgreen;\": \"ĸ\",\n    \"&khcy;\": \"х\",\n    \"&kjcy;\": \"ќ\",\n    \"&kopf;\": \"𝕜\",\n    \"&kscr;\": \"𝓀\",\n    \"&lAarr;\": \"⇚\",\n    \"&lArr;\": \"⇐\",\n    \"&lAtail;\": \"⤛\",\n    \"&lBarr;\": \"⤎\",\n    \"&lE;\": \"≦\",\n    \"&lEg;\": \"⪋\",\n    \"&lHar;\": \"⥢\",\n    \"&lacute;\": \"ĺ\",\n    \"&laemptyv;\": \"⦴\",\n    \"&lagran;\": \"ℒ\",\n    \"&lambda;\": \"λ\",\n    \"&lang;\": \"⟨\",\n    \"&langd;\": \"⦑\",\n    \"&langle;\": \"⟨\",\n    \"&lap;\": \"⪅\",\n    \"&laquo\": \"«\",\n    \"&laquo;\": \"«\",\n    \"&larr;\": \"←\",\n    \"&larrb;\": \"⇤\",\n    \"&larrbfs;\": \"⤟\",\n    \"&larrfs;\": \"⤝\",\n    \"&larrhk;\": \"↩\",\n    \"&larrlp;\": \"↫\",\n    \"&larrpl;\": \"⤹\",\n    \"&larrsim;\": \"⥳\",\n    \"&larrtl;\": \"↢\",\n    \"&lat;\": \"⪫\",\n    \"&latail;\": \"⤙\",\n    \"&late;\": \"⪭\",\n    \"&lates;\": \"⪭︀\",\n    \"&lbarr;\": \"⤌\",\n    \"&lbbrk;\": \"❲\",\n    \"&lbrace;\": \"{\",\n    \"&lbrack;\": \"[\",\n    \"&lbrke;\": \"⦋\",\n    \"&lbrksld;\": \"⦏\",\n    \"&lbrkslu;\": \"⦍\",\n    \"&lcaron;\": \"ľ\",\n    \"&lcedil;\": \"ļ\",\n    \"&lceil;\": \"⌈\",\n    \"&lcub;\": \"{\",\n    \"&lcy;\": \"л\",\n    \"&ldca;\": \"⤶\",\n    \"&ldquo;\": \"“\",\n    \"&ldquor;\": \"„\",\n    \"&ldrdhar;\": \"⥧\",\n    \"&ldrushar;\": \"⥋\",\n    \"&ldsh;\": \"↲\",\n    \"&le;\": \"≤\",\n    \"&leftarrow;\": \"←\",\n    \"&leftarrowtail;\": \"↢\",\n    \"&leftharpoondown;\": \"↽\",\n    \"&leftharpoonup;\": \"↼\",\n    \"&leftleftarrows;\": \"⇇\",\n    \"&leftrightarrow;\": \"↔\",\n    \"&leftrightarrows;\": \"⇆\",\n    \"&leftrightharpoons;\": \"⇋\",\n    \"&leftrightsquigarrow;\": \"↭\",\n    \"&leftthreetimes;\": \"⋋\",\n    \"&leg;\": \"⋚\",\n    \"&leq;\": \"≤\",\n    \"&leqq;\": \"≦\",\n    \"&leqslant;\": \"⩽\",\n    \"&les;\": \"⩽\",\n    \"&lescc;\": \"⪨\",\n    \"&lesdot;\": \"⩿\",\n    \"&lesdoto;\": \"⪁\",\n    \"&lesdotor;\": \"⪃\",\n    \"&lesg;\": \"⋚︀\",\n    \"&lesges;\": \"⪓\",\n    \"&lessapprox;\": \"⪅\",\n    \"&lessdot;\": \"⋖\",\n    \"&lesseqgtr;\": \"⋚\",\n    \"&lesseqqgtr;\": \"⪋\",\n    \"&lessgtr;\": \"≶\",\n    \"&lesssim;\": \"≲\",\n    \"&lfisht;\": \"⥼\",\n    \"&lfloor;\": \"⌊\",\n    \"&lfr;\": \"𝔩\",\n    \"&lg;\": \"≶\",\n    \"&lgE;\": \"⪑\",\n    \"&lhard;\": \"↽\",\n    \"&lharu;\": \"↼\",\n    \"&lharul;\": \"⥪\",\n    \"&lhblk;\": \"▄\",\n    \"&ljcy;\": \"љ\",\n    \"&ll;\": \"≪\",\n    \"&llarr;\": \"⇇\",\n    \"&llcorner;\": \"⌞\",\n    \"&llhard;\": \"⥫\",\n    \"&lltri;\": \"◺\",\n    \"&lmidot;\": \"ŀ\",\n    \"&lmoust;\": \"⎰\",\n    \"&lmoustache;\": \"⎰\",\n    \"&lnE;\": \"≨\",\n    \"&lnap;\": \"⪉\",\n    \"&lnapprox;\": \"⪉\",\n    \"&lne;\": \"⪇\",\n    \"&lneq;\": \"⪇\",\n    \"&lneqq;\": \"≨\",\n    \"&lnsim;\": \"⋦\",\n    \"&loang;\": \"⟬\",\n    \"&loarr;\": \"⇽\",\n    \"&lobrk;\": \"⟦\",\n    \"&longleftarrow;\": \"⟵\",\n    \"&longleftrightarrow;\": \"⟷\",\n    \"&longmapsto;\": \"⟼\",\n    \"&longrightarrow;\": \"⟶\",\n    \"&looparrowleft;\": \"↫\",\n    \"&looparrowright;\": \"↬\",\n    \"&lopar;\": \"⦅\",\n    \"&lopf;\": \"𝕝\",\n    \"&loplus;\": \"⨭\",\n    \"&lotimes;\": \"⨴\",\n    \"&lowast;\": \"∗\",\n    \"&lowbar;\": \"_\",\n    \"&loz;\": \"◊\",\n    \"&lozenge;\": \"◊\",\n    \"&lozf;\": \"⧫\",\n    \"&lpar;\": \"(\",\n    \"&lparlt;\": \"⦓\",\n    \"&lrarr;\": \"⇆\",\n    \"&lrcorner;\": \"⌟\",\n    \"&lrhar;\": \"⇋\",\n    \"&lrhard;\": \"⥭\",\n    \"&lrm;\": \"‎\",\n    \"&lrtri;\": \"⊿\",\n    \"&lsaquo;\": \"‹\",\n    \"&lscr;\": \"𝓁\",\n    \"&lsh;\": \"↰\",\n    \"&lsim;\": \"≲\",\n    \"&lsime;\": \"⪍\",\n    \"&lsimg;\": \"⪏\",\n    \"&lsqb;\": \"[\",\n    \"&lsquo;\": \"‘\",\n    \"&lsquor;\": \"‚\",\n    \"&lstrok;\": \"ł\",\n    \"&lt\": \"<\",\n    \"&lt;\": \"<\",\n    \"&ltcc;\": \"⪦\",\n    \"&ltcir;\": \"⩹\",\n    \"&ltdot;\": \"⋖\",\n    \"&lthree;\": \"⋋\",\n    \"&ltimes;\": \"⋉\",\n    \"&ltlarr;\": \"⥶\",\n    \"&ltquest;\": \"⩻\",\n    \"&ltrPar;\": \"⦖\",\n    \"&ltri;\": \"◃\",\n    \"&ltrie;\": \"⊴\",\n    \"&ltrif;\": \"◂\",\n    \"&lurdshar;\": \"⥊\",\n    \"&luruhar;\": \"⥦\",\n    \"&lvertneqq;\": \"≨︀\",\n    \"&lvnE;\": \"≨︀\",\n    \"&mDDot;\": \"∺\",\n    \"&macr\": \"¯\",\n    \"&macr;\": \"¯\",\n    \"&male;\": \"♂\",\n    \"&malt;\": \"✠\",\n    \"&maltese;\": \"✠\",\n    \"&map;\": \"↦\",\n    \"&mapsto;\": \"↦\",\n    \"&mapstodown;\": \"↧\",\n    \"&mapstoleft;\": \"↤\",\n    \"&mapstoup;\": \"↥\",\n    \"&marker;\": \"▮\",\n    \"&mcomma;\": \"⨩\",\n    \"&mcy;\": \"м\",\n    \"&mdash;\": \"—\",\n    \"&measuredangle;\": \"∡\",\n    \"&mfr;\": \"𝔪\",\n    \"&mho;\": \"℧\",\n    \"&micro\": \"µ\",\n    \"&micro;\": \"µ\",\n    \"&mid;\": \"∣\",\n    \"&midast;\": \"*\",\n    \"&midcir;\": \"⫰\",\n    \"&middot\": \"·\",\n    \"&middot;\": \"·\",\n    \"&minus;\": \"−\",\n    \"&minusb;\": \"⊟\",\n    \"&minusd;\": \"∸\",\n    \"&minusdu;\": \"⨪\",\n    \"&mlcp;\": \"⫛\",\n    \"&mldr;\": \"…\",\n    \"&mnplus;\": \"∓\",\n    \"&models;\": \"⊧\",\n    \"&mopf;\": \"𝕞\",\n    \"&mp;\": \"∓\",\n    \"&mscr;\": \"𝓂\",\n    \"&mstpos;\": \"∾\",\n    \"&mu;\": \"μ\",\n    \"&multimap;\": \"⊸\",\n    \"&mumap;\": \"⊸\",\n    \"&nGg;\": \"⋙̸\",\n    \"&nGt;\": \"≫⃒\",\n    \"&nGtv;\": \"≫̸\",\n    \"&nLeftarrow;\": \"⇍\",\n    \"&nLeftrightarrow;\": \"⇎\",\n    \"&nLl;\": \"⋘̸\",\n    \"&nLt;\": \"≪⃒\",\n    \"&nLtv;\": \"≪̸\",\n    \"&nRightarrow;\": \"⇏\",\n    \"&nVDash;\": \"⊯\",\n    \"&nVdash;\": \"⊮\",\n    \"&nabla;\": \"∇\",\n    \"&nacute;\": \"ń\",\n    \"&nang;\": \"∠⃒\",\n    \"&nap;\": \"≉\",\n    \"&napE;\": \"⩰̸\",\n    \"&napid;\": \"≋̸\",\n    \"&napos;\": \"ŉ\",\n    \"&napprox;\": \"≉\",\n    \"&natur;\": \"♮\",\n    \"&natural;\": \"♮\",\n    \"&naturals;\": \"ℕ\",\n    \"&nbsp\": \" \",\n    \"&nbsp;\": \" \",\n    \"&nbump;\": \"≎̸\",\n    \"&nbumpe;\": \"≏̸\",\n    \"&ncap;\": \"⩃\",\n    \"&ncaron;\": \"ň\",\n    \"&ncedil;\": \"ņ\",\n    \"&ncong;\": \"≇\",\n    \"&ncongdot;\": \"⩭̸\",\n    \"&ncup;\": \"⩂\",\n    \"&ncy;\": \"н\",\n    \"&ndash;\": \"–\",\n    \"&ne;\": \"≠\",\n    \"&neArr;\": \"⇗\",\n    \"&nearhk;\": \"⤤\",\n    \"&nearr;\": \"↗\",\n    \"&nearrow;\": \"↗\",\n    \"&nedot;\": \"≐̸\",\n    \"&nequiv;\": \"≢\",\n    \"&nesear;\": \"⤨\",\n    \"&nesim;\": \"≂̸\",\n    \"&nexist;\": \"∄\",\n    \"&nexists;\": \"∄\",\n    \"&nfr;\": \"𝔫\",\n    \"&ngE;\": \"≧̸\",\n    \"&nge;\": \"≱\",\n    \"&ngeq;\": \"≱\",\n    \"&ngeqq;\": \"≧̸\",\n    \"&ngeqslant;\": \"⩾̸\",\n    \"&nges;\": \"⩾̸\",\n    \"&ngsim;\": \"≵\",\n    \"&ngt;\": \"≯\",\n    \"&ngtr;\": \"≯\",\n    \"&nhArr;\": \"⇎\",\n    \"&nharr;\": \"↮\",\n    \"&nhpar;\": \"⫲\",\n    \"&ni;\": \"∋\",\n    \"&nis;\": \"⋼\",\n    \"&nisd;\": \"⋺\",\n    \"&niv;\": \"∋\",\n    \"&njcy;\": \"њ\",\n    \"&nlArr;\": \"⇍\",\n    \"&nlE;\": \"≦̸\",\n    \"&nlarr;\": \"↚\",\n    \"&nldr;\": \"‥\",\n    \"&nle;\": \"≰\",\n    \"&nleftarrow;\": \"↚\",\n    \"&nleftrightarrow;\": \"↮\",\n    \"&nleq;\": \"≰\",\n    \"&nleqq;\": \"≦̸\",\n    \"&nleqslant;\": \"⩽̸\",\n    \"&nles;\": \"⩽̸\",\n    \"&nless;\": \"≮\",\n    \"&nlsim;\": \"≴\",\n    \"&nlt;\": \"≮\",\n    \"&nltri;\": \"⋪\",\n    \"&nltrie;\": \"⋬\",\n    \"&nmid;\": \"∤\",\n    \"&nopf;\": \"𝕟\",\n    \"&not\": \"¬\",\n    \"&not;\": \"¬\",\n    \"&notin;\": \"∉\",\n    \"&notinE;\": \"⋹̸\",\n    \"&notindot;\": \"⋵̸\",\n    \"&notinva;\": \"∉\",\n    \"&notinvb;\": \"⋷\",\n    \"&notinvc;\": \"⋶\",\n    \"&notni;\": \"∌\",\n    \"&notniva;\": \"∌\",\n    \"&notnivb;\": \"⋾\",\n    \"&notnivc;\": \"⋽\",\n    \"&npar;\": \"∦\",\n    \"&nparallel;\": \"∦\",\n    \"&nparsl;\": \"⫽⃥\",\n    \"&npart;\": \"∂̸\",\n    \"&npolint;\": \"⨔\",\n    \"&npr;\": \"⊀\",\n    \"&nprcue;\": \"⋠\",\n    \"&npre;\": \"⪯̸\",\n    \"&nprec;\": \"⊀\",\n    \"&npreceq;\": \"⪯̸\",\n    \"&nrArr;\": \"⇏\",\n    \"&nrarr;\": \"↛\",\n    \"&nrarrc;\": \"⤳̸\",\n    \"&nrarrw;\": \"↝̸\",\n    \"&nrightarrow;\": \"↛\",\n    \"&nrtri;\": \"⋫\",\n    \"&nrtrie;\": \"⋭\",\n    \"&nsc;\": \"⊁\",\n    \"&nsccue;\": \"⋡\",\n    \"&nsce;\": \"⪰̸\",\n    \"&nscr;\": \"𝓃\",\n    \"&nshortmid;\": \"∤\",\n    \"&nshortparallel;\": \"∦\",\n    \"&nsim;\": \"≁\",\n    \"&nsime;\": \"≄\",\n    \"&nsimeq;\": \"≄\",\n    \"&nsmid;\": \"∤\",\n    \"&nspar;\": \"∦\",\n    \"&nsqsube;\": \"⋢\",\n    \"&nsqsupe;\": \"⋣\",\n    \"&nsub;\": \"⊄\",\n    \"&nsubE;\": \"⫅̸\",\n    \"&nsube;\": \"⊈\",\n    \"&nsubset;\": \"⊂⃒\",\n    \"&nsubseteq;\": \"⊈\",\n    \"&nsubseteqq;\": \"⫅̸\",\n    \"&nsucc;\": \"⊁\",\n    \"&nsucceq;\": \"⪰̸\",\n    \"&nsup;\": \"⊅\",\n    \"&nsupE;\": \"⫆̸\",\n    \"&nsupe;\": \"⊉\",\n    \"&nsupset;\": \"⊃⃒\",\n    \"&nsupseteq;\": \"⊉\",\n    \"&nsupseteqq;\": \"⫆̸\",\n    \"&ntgl;\": \"≹\",\n    \"&ntilde\": \"ñ\",\n    \"&ntilde;\": \"ñ\",\n    \"&ntlg;\": \"≸\",\n    \"&ntriangleleft;\": \"⋪\",\n    \"&ntrianglelefteq;\": \"⋬\",\n    \"&ntriangleright;\": \"⋫\",\n    \"&ntrianglerighteq;\": \"⋭\",\n    \"&nu;\": \"ν\",\n    \"&num;\": \"#\",\n    \"&numero;\": \"№\",\n    \"&numsp;\": \" \",\n    \"&nvDash;\": \"⊭\",\n    \"&nvHarr;\": \"⤄\",\n    \"&nvap;\": \"≍⃒\",\n    \"&nvdash;\": \"⊬\",\n    \"&nvge;\": \"≥⃒\",\n    \"&nvgt;\": \">⃒\",\n    \"&nvinfin;\": \"⧞\",\n    \"&nvlArr;\": \"⤂\",\n    \"&nvle;\": \"≤⃒\",\n    \"&nvlt;\": \"<⃒\",\n    \"&nvltrie;\": \"⊴⃒\",\n    \"&nvrArr;\": \"⤃\",\n    \"&nvrtrie;\": \"⊵⃒\",\n    \"&nvsim;\": \"∼⃒\",\n    \"&nwArr;\": \"⇖\",\n    \"&nwarhk;\": \"⤣\",\n    \"&nwarr;\": \"↖\",\n    \"&nwarrow;\": \"↖\",\n    \"&nwnear;\": \"⤧\",\n    \"&oS;\": \"Ⓢ\",\n    \"&oacute\": \"ó\",\n    \"&oacute;\": \"ó\",\n    \"&oast;\": \"⊛\",\n    \"&ocir;\": \"⊚\",\n    \"&ocirc\": \"ô\",\n    \"&ocirc;\": \"ô\",\n    \"&ocy;\": \"о\",\n    \"&odash;\": \"⊝\",\n    \"&odblac;\": \"ő\",\n    \"&odiv;\": \"⨸\",\n    \"&odot;\": \"⊙\",\n    \"&odsold;\": \"⦼\",\n    \"&oelig;\": \"œ\",\n    \"&ofcir;\": \"⦿\",\n    \"&ofr;\": \"𝔬\",\n    \"&ogon;\": \"˛\",\n    \"&ograve\": \"ò\",\n    \"&ograve;\": \"ò\",\n    \"&ogt;\": \"⧁\",\n    \"&ohbar;\": \"⦵\",\n    \"&ohm;\": \"Ω\",\n    \"&oint;\": \"∮\",\n    \"&olarr;\": \"↺\",\n    \"&olcir;\": \"⦾\",\n    \"&olcross;\": \"⦻\",\n    \"&oline;\": \"‾\",\n    \"&olt;\": \"⧀\",\n    \"&omacr;\": \"ō\",\n    \"&omega;\": \"ω\",\n    \"&omicron;\": \"ο\",\n    \"&omid;\": \"⦶\",\n    \"&ominus;\": \"⊖\",\n    \"&oopf;\": \"𝕠\",\n    \"&opar;\": \"⦷\",\n    \"&operp;\": \"⦹\",\n    \"&oplus;\": \"⊕\",\n    \"&or;\": \"∨\",\n    \"&orarr;\": \"↻\",\n    \"&ord;\": \"⩝\",\n    \"&order;\": \"ℴ\",\n    \"&orderof;\": \"ℴ\",\n    \"&ordf\": \"ª\",\n    \"&ordf;\": \"ª\",\n    \"&ordm\": \"º\",\n    \"&ordm;\": \"º\",\n    \"&origof;\": \"⊶\",\n    \"&oror;\": \"⩖\",\n    \"&orslope;\": \"⩗\",\n    \"&orv;\": \"⩛\",\n    \"&oscr;\": \"ℴ\",\n    \"&oslash\": \"ø\",\n    \"&oslash;\": \"ø\",\n    \"&osol;\": \"⊘\",\n    \"&otilde\": \"õ\",\n    \"&otilde;\": \"õ\",\n    \"&otimes;\": \"⊗\",\n    \"&otimesas;\": \"⨶\",\n    \"&ouml\": \"ö\",\n    \"&ouml;\": \"ö\",\n    \"&ovbar;\": \"⌽\",\n    \"&par;\": \"∥\",\n    \"&para\": \"¶\",\n    \"&para;\": \"¶\",\n    \"&parallel;\": \"∥\",\n    \"&parsim;\": \"⫳\",\n    \"&parsl;\": \"⫽\",\n    \"&part;\": \"∂\",\n    \"&pcy;\": \"п\",\n    \"&percnt;\": \"%\",\n    \"&period;\": \".\",\n    \"&permil;\": \"‰\",\n    \"&perp;\": \"⊥\",\n    \"&pertenk;\": \"‱\",\n    \"&pfr;\": \"𝔭\",\n    \"&phi;\": \"φ\",\n    \"&phiv;\": \"ϕ\",\n    \"&phmmat;\": \"ℳ\",\n    \"&phone;\": \"☎\",\n    \"&pi;\": \"π\",\n    \"&pitchfork;\": \"⋔\",\n    \"&piv;\": \"ϖ\",\n    \"&planck;\": \"ℏ\",\n    \"&planckh;\": \"ℎ\",\n    \"&plankv;\": \"ℏ\",\n    \"&plus;\": \"+\",\n    \"&plusacir;\": \"⨣\",\n    \"&plusb;\": \"⊞\",\n    \"&pluscir;\": \"⨢\",\n    \"&plusdo;\": \"∔\",\n    \"&plusdu;\": \"⨥\",\n    \"&pluse;\": \"⩲\",\n    \"&plusmn\": \"±\",\n    \"&plusmn;\": \"±\",\n    \"&plussim;\": \"⨦\",\n    \"&plustwo;\": \"⨧\",\n    \"&pm;\": \"±\",\n    \"&pointint;\": \"⨕\",\n    \"&popf;\": \"𝕡\",\n    \"&pound\": \"£\",\n    \"&pound;\": \"£\",\n    \"&pr;\": \"≺\",\n    \"&prE;\": \"⪳\",\n    \"&prap;\": \"⪷\",\n    \"&prcue;\": \"≼\",\n    \"&pre;\": \"⪯\",\n    \"&prec;\": \"≺\",\n    \"&precapprox;\": \"⪷\",\n    \"&preccurlyeq;\": \"≼\",\n    \"&preceq;\": \"⪯\",\n    \"&precnapprox;\": \"⪹\",\n    \"&precneqq;\": \"⪵\",\n    \"&precnsim;\": \"⋨\",\n    \"&precsim;\": \"≾\",\n    \"&prime;\": \"′\",\n    \"&primes;\": \"ℙ\",\n    \"&prnE;\": \"⪵\",\n    \"&prnap;\": \"⪹\",\n    \"&prnsim;\": \"⋨\",\n    \"&prod;\": \"∏\",\n    \"&profalar;\": \"⌮\",\n    \"&profline;\": \"⌒\",\n    \"&profsurf;\": \"⌓\",\n    \"&prop;\": \"∝\",\n    \"&propto;\": \"∝\",\n    \"&prsim;\": \"≾\",\n    \"&prurel;\": \"⊰\",\n    \"&pscr;\": \"𝓅\",\n    \"&psi;\": \"ψ\",\n    \"&puncsp;\": \" \",\n    \"&qfr;\": \"𝔮\",\n    \"&qint;\": \"⨌\",\n    \"&qopf;\": \"𝕢\",\n    \"&qprime;\": \"⁗\",\n    \"&qscr;\": \"𝓆\",\n    \"&quaternions;\": \"ℍ\",\n    \"&quatint;\": \"⨖\",\n    \"&quest;\": \"?\",\n    \"&questeq;\": \"≟\",\n    \"&quot\": \"\\\"\",\n    \"&quot;\": \"\\\"\",\n    \"&rAarr;\": \"⇛\",\n    \"&rArr;\": \"⇒\",\n    \"&rAtail;\": \"⤜\",\n    \"&rBarr;\": \"⤏\",\n    \"&rHar;\": \"⥤\",\n    \"&race;\": \"∽̱\",\n    \"&racute;\": \"ŕ\",\n    \"&radic;\": \"√\",\n    \"&raemptyv;\": \"⦳\",\n    \"&rang;\": \"⟩\",\n    \"&rangd;\": \"⦒\",\n    \"&range;\": \"⦥\",\n    \"&rangle;\": \"⟩\",\n    \"&raquo\": \"»\",\n    \"&raquo;\": \"»\",\n    \"&rarr;\": \"→\",\n    \"&rarrap;\": \"⥵\",\n    \"&rarrb;\": \"⇥\",\n    \"&rarrbfs;\": \"⤠\",\n    \"&rarrc;\": \"⤳\",\n    \"&rarrfs;\": \"⤞\",\n    \"&rarrhk;\": \"↪\",\n    \"&rarrlp;\": \"↬\",\n    \"&rarrpl;\": \"⥅\",\n    \"&rarrsim;\": \"⥴\",\n    \"&rarrtl;\": \"↣\",\n    \"&rarrw;\": \"↝\",\n    \"&ratail;\": \"⤚\",\n    \"&ratio;\": \"∶\",\n    \"&rationals;\": \"ℚ\",\n    \"&rbarr;\": \"⤍\",\n    \"&rbbrk;\": \"❳\",\n    \"&rbrace;\": \"}\",\n    \"&rbrack;\": \"]\",\n    \"&rbrke;\": \"⦌\",\n    \"&rbrksld;\": \"⦎\",\n    \"&rbrkslu;\": \"⦐\",\n    \"&rcaron;\": \"ř\",\n    \"&rcedil;\": \"ŗ\",\n    \"&rceil;\": \"⌉\",\n    \"&rcub;\": \"}\",\n    \"&rcy;\": \"р\",\n    \"&rdca;\": \"⤷\",\n    \"&rdldhar;\": \"⥩\",\n    \"&rdquo;\": \"”\",\n    \"&rdquor;\": \"”\",\n    \"&rdsh;\": \"↳\",\n    \"&real;\": \"ℜ\",\n    \"&realine;\": \"ℛ\",\n    \"&realpart;\": \"ℜ\",\n    \"&reals;\": \"ℝ\",\n    \"&rect;\": \"▭\",\n    \"&reg\": \"®\",\n    \"&reg;\": \"®\",\n    \"&rfisht;\": \"⥽\",\n    \"&rfloor;\": \"⌋\",\n    \"&rfr;\": \"𝔯\",\n    \"&rhard;\": \"⇁\",\n    \"&rharu;\": \"⇀\",\n    \"&rharul;\": \"⥬\",\n    \"&rho;\": \"ρ\",\n    \"&rhov;\": \"ϱ\",\n    \"&rightarrow;\": \"→\",\n    \"&rightarrowtail;\": \"↣\",\n    \"&rightharpoondown;\": \"⇁\",\n    \"&rightharpoonup;\": \"⇀\",\n    \"&rightleftarrows;\": \"⇄\",\n    \"&rightleftharpoons;\": \"⇌\",\n    \"&rightrightarrows;\": \"⇉\",\n    \"&rightsquigarrow;\": \"↝\",\n    \"&rightthreetimes;\": \"⋌\",\n    \"&ring;\": \"˚\",\n    \"&risingdotseq;\": \"≓\",\n    \"&rlarr;\": \"⇄\",\n    \"&rlhar;\": \"⇌\",\n    \"&rlm;\": \"‏\",\n    \"&rmoust;\": \"⎱\",\n    \"&rmoustache;\": \"⎱\",\n    \"&rnmid;\": \"⫮\",\n    \"&roang;\": \"⟭\",\n    \"&roarr;\": \"⇾\",\n    \"&robrk;\": \"⟧\",\n    \"&ropar;\": \"⦆\",\n    \"&ropf;\": \"𝕣\",\n    \"&roplus;\": \"⨮\",\n    \"&rotimes;\": \"⨵\",\n    \"&rpar;\": \")\",\n    \"&rpargt;\": \"⦔\",\n    \"&rppolint;\": \"⨒\",\n    \"&rrarr;\": \"⇉\",\n    \"&rsaquo;\": \"›\",\n    \"&rscr;\": \"𝓇\",\n    \"&rsh;\": \"↱\",\n    \"&rsqb;\": \"]\",\n    \"&rsquo;\": \"’\",\n    \"&rsquor;\": \"’\",\n    \"&rthree;\": \"⋌\",\n    \"&rtimes;\": \"⋊\",\n    \"&rtri;\": \"▹\",\n    \"&rtrie;\": \"⊵\",\n    \"&rtrif;\": \"▸\",\n    \"&rtriltri;\": \"⧎\",\n    \"&ruluhar;\": \"⥨\",\n    \"&rx;\": \"℞\",\n    \"&sacute;\": \"ś\",\n    \"&sbquo;\": \"‚\",\n    \"&sc;\": \"≻\",\n    \"&scE;\": \"⪴\",\n    \"&scap;\": \"⪸\",\n    \"&scaron;\": \"š\",\n    \"&sccue;\": \"≽\",\n    \"&sce;\": \"⪰\",\n    \"&scedil;\": \"ş\",\n    \"&scirc;\": \"ŝ\",\n    \"&scnE;\": \"⪶\",\n    \"&scnap;\": \"⪺\",\n    \"&scnsim;\": \"⋩\",\n    \"&scpolint;\": \"⨓\",\n    \"&scsim;\": \"≿\",\n    \"&scy;\": \"с\",\n    \"&sdot;\": \"⋅\",\n    \"&sdotb;\": \"⊡\",\n    \"&sdote;\": \"⩦\",\n    \"&seArr;\": \"⇘\",\n    \"&searhk;\": \"⤥\",\n    \"&searr;\": \"↘\",\n    \"&searrow;\": \"↘\",\n    \"&sect\": \"§\",\n    \"&sect;\": \"§\",\n    \"&semi;\": \";\",\n    \"&seswar;\": \"⤩\",\n    \"&setminus;\": \"∖\",\n    \"&setmn;\": \"∖\",\n    \"&sext;\": \"✶\",\n    \"&sfr;\": \"𝔰\",\n    \"&sfrown;\": \"⌢\",\n    \"&sharp;\": \"♯\",\n    \"&shchcy;\": \"щ\",\n    \"&shcy;\": \"ш\",\n    \"&shortmid;\": \"∣\",\n    \"&shortparallel;\": \"∥\",\n    \"&shy\": \"­\",\n    \"&shy;\": \"­\",\n    \"&sigma;\": \"σ\",\n    \"&sigmaf;\": \"ς\",\n    \"&sigmav;\": \"ς\",\n    \"&sim;\": \"∼\",\n    \"&simdot;\": \"⩪\",\n    \"&sime;\": \"≃\",\n    \"&simeq;\": \"≃\",\n    \"&simg;\": \"⪞\",\n    \"&simgE;\": \"⪠\",\n    \"&siml;\": \"⪝\",\n    \"&simlE;\": \"⪟\",\n    \"&simne;\": \"≆\",\n    \"&simplus;\": \"⨤\",\n    \"&simrarr;\": \"⥲\",\n    \"&slarr;\": \"←\",\n    \"&smallsetminus;\": \"∖\",\n    \"&smashp;\": \"⨳\",\n    \"&smeparsl;\": \"⧤\",\n    \"&smid;\": \"∣\",\n    \"&smile;\": \"⌣\",\n    \"&smt;\": \"⪪\",\n    \"&smte;\": \"⪬\",\n    \"&smtes;\": \"⪬︀\",\n    \"&softcy;\": \"ь\",\n    \"&sol;\": \"/\",\n    \"&solb;\": \"⧄\",\n    \"&solbar;\": \"⌿\",\n    \"&sopf;\": \"𝕤\",\n    \"&spades;\": \"♠\",\n    \"&spadesuit;\": \"♠\",\n    \"&spar;\": \"∥\",\n    \"&sqcap;\": \"⊓\",\n    \"&sqcaps;\": \"⊓︀\",\n    \"&sqcup;\": \"⊔\",\n    \"&sqcups;\": \"⊔︀\",\n    \"&sqsub;\": \"⊏\",\n    \"&sqsube;\": \"⊑\",\n    \"&sqsubset;\": \"⊏\",\n    \"&sqsubseteq;\": \"⊑\",\n    \"&sqsup;\": \"⊐\",\n    \"&sqsupe;\": \"⊒\",\n    \"&sqsupset;\": \"⊐\",\n    \"&sqsupseteq;\": \"⊒\",\n    \"&squ;\": \"□\",\n    \"&square;\": \"□\",\n    \"&squarf;\": \"▪\",\n    \"&squf;\": \"▪\",\n    \"&srarr;\": \"→\",\n    \"&sscr;\": \"𝓈\",\n    \"&ssetmn;\": \"∖\",\n    \"&ssmile;\": \"⌣\",\n    \"&sstarf;\": \"⋆\",\n    \"&star;\": \"☆\",\n    \"&starf;\": \"★\",\n    \"&straightepsilon;\": \"ϵ\",\n    \"&straightphi;\": \"ϕ\",\n    \"&strns;\": \"¯\",\n    \"&sub;\": \"⊂\",\n    \"&subE;\": \"⫅\",\n    \"&subdot;\": \"⪽\",\n    \"&sube;\": \"⊆\",\n    \"&subedot;\": \"⫃\",\n    \"&submult;\": \"⫁\",\n    \"&subnE;\": \"⫋\",\n    \"&subne;\": \"⊊\",\n    \"&subplus;\": \"⪿\",\n    \"&subrarr;\": \"⥹\",\n    \"&subset;\": \"⊂\",\n    \"&subseteq;\": \"⊆\",\n    \"&subseteqq;\": \"⫅\",\n    \"&subsetneq;\": \"⊊\",\n    \"&subsetneqq;\": \"⫋\",\n    \"&subsim;\": \"⫇\",\n    \"&subsub;\": \"⫕\",\n    \"&subsup;\": \"⫓\",\n    \"&succ;\": \"≻\",\n    \"&succapprox;\": \"⪸\",\n    \"&succcurlyeq;\": \"≽\",\n    \"&succeq;\": \"⪰\",\n    \"&succnapprox;\": \"⪺\",\n    \"&succneqq;\": \"⪶\",\n    \"&succnsim;\": \"⋩\",\n    \"&succsim;\": \"≿\",\n    \"&sum;\": \"∑\",\n    \"&sung;\": \"♪\",\n    \"&sup1\": \"¹\",\n    \"&sup1;\": \"¹\",\n    \"&sup2\": \"²\",\n    \"&sup2;\": \"²\",\n    \"&sup3\": \"³\",\n    \"&sup3;\": \"³\",\n    \"&sup;\": \"⊃\",\n    \"&supE;\": \"⫆\",\n    \"&supdot;\": \"⪾\",\n    \"&supdsub;\": \"⫘\",\n    \"&supe;\": \"⊇\",\n    \"&supedot;\": \"⫄\",\n    \"&suphsol;\": \"⟉\",\n    \"&suphsub;\": \"⫗\",\n    \"&suplarr;\": \"⥻\",\n    \"&supmult;\": \"⫂\",\n    \"&supnE;\": \"⫌\",\n    \"&supne;\": \"⊋\",\n    \"&supplus;\": \"⫀\",\n    \"&supset;\": \"⊃\",\n    \"&supseteq;\": \"⊇\",\n    \"&supseteqq;\": \"⫆\",\n    \"&supsetneq;\": \"⊋\",\n    \"&supsetneqq;\": \"⫌\",\n    \"&supsim;\": \"⫈\",\n    \"&supsub;\": \"⫔\",\n    \"&supsup;\": \"⫖\",\n    \"&swArr;\": \"⇙\",\n    \"&swarhk;\": \"⤦\",\n    \"&swarr;\": \"↙\",\n    \"&swarrow;\": \"↙\",\n    \"&swnwar;\": \"⤪\",\n    \"&szlig\": \"ß\",\n    \"&szlig;\": \"ß\",\n    \"&target;\": \"⌖\",\n    \"&tau;\": \"τ\",\n    \"&tbrk;\": \"⎴\",\n    \"&tcaron;\": \"ť\",\n    \"&tcedil;\": \"ţ\",\n    \"&tcy;\": \"т\",\n    \"&tdot;\": \"⃛\",\n    \"&telrec;\": \"⌕\",\n    \"&tfr;\": \"𝔱\",\n    \"&there4;\": \"∴\",\n    \"&therefore;\": \"∴\",\n    \"&theta;\": \"θ\",\n    \"&thetasym;\": \"ϑ\",\n    \"&thetav;\": \"ϑ\",\n    \"&thickapprox;\": \"≈\",\n    \"&thicksim;\": \"∼\",\n    \"&thinsp;\": \" \",\n    \"&thkap;\": \"≈\",\n    \"&thksim;\": \"∼\",\n    \"&thorn\": \"þ\",\n    \"&thorn;\": \"þ\",\n    \"&tilde;\": \"˜\",\n    \"&times\": \"×\",\n    \"&times;\": \"×\",\n    \"&timesb;\": \"⊠\",\n    \"&timesbar;\": \"⨱\",\n    \"&timesd;\": \"⨰\",\n    \"&tint;\": \"∭\",\n    \"&toea;\": \"⤨\",\n    \"&top;\": \"⊤\",\n    \"&topbot;\": \"⌶\",\n    \"&topcir;\": \"⫱\",\n    \"&topf;\": \"𝕥\",\n    \"&topfork;\": \"⫚\",\n    \"&tosa;\": \"⤩\",\n    \"&tprime;\": \"‴\",\n    \"&trade;\": \"™\",\n    \"&triangle;\": \"▵\",\n    \"&triangledown;\": \"▿\",\n    \"&triangleleft;\": \"◃\",\n    \"&trianglelefteq;\": \"⊴\",\n    \"&triangleq;\": \"≜\",\n    \"&triangleright;\": \"▹\",\n    \"&trianglerighteq;\": \"⊵\",\n    \"&tridot;\": \"◬\",\n    \"&trie;\": \"≜\",\n    \"&triminus;\": \"⨺\",\n    \"&triplus;\": \"⨹\",\n    \"&trisb;\": \"⧍\",\n    \"&tritime;\": \"⨻\",\n    \"&trpezium;\": \"⏢\",\n    \"&tscr;\": \"𝓉\",\n    \"&tscy;\": \"ц\",\n    \"&tshcy;\": \"ћ\",\n    \"&tstrok;\": \"ŧ\",\n    \"&twixt;\": \"≬\",\n    \"&twoheadleftarrow;\": \"↞\",\n    \"&twoheadrightarrow;\": \"↠\",\n    \"&uArr;\": \"⇑\",\n    \"&uHar;\": \"⥣\",\n    \"&uacute\": \"ú\",\n    \"&uacute;\": \"ú\",\n    \"&uarr;\": \"↑\",\n    \"&ubrcy;\": \"ў\",\n    \"&ubreve;\": \"ŭ\",\n    \"&ucirc\": \"û\",\n    \"&ucirc;\": \"û\",\n    \"&ucy;\": \"у\",\n    \"&udarr;\": \"⇅\",\n    \"&udblac;\": \"ű\",\n    \"&udhar;\": \"⥮\",\n    \"&ufisht;\": \"⥾\",\n    \"&ufr;\": \"𝔲\",\n    \"&ugrave\": \"ù\",\n    \"&ugrave;\": \"ù\",\n    \"&uharl;\": \"↿\",\n    \"&uharr;\": \"↾\",\n    \"&uhblk;\": \"▀\",\n    \"&ulcorn;\": \"⌜\",\n    \"&ulcorner;\": \"⌜\",\n    \"&ulcrop;\": \"⌏\",\n    \"&ultri;\": \"◸\",\n    \"&umacr;\": \"ū\",\n    \"&uml\": \"¨\",\n    \"&uml;\": \"¨\",\n    \"&uogon;\": \"ų\",\n    \"&uopf;\": \"𝕦\",\n    \"&uparrow;\": \"↑\",\n    \"&updownarrow;\": \"↕\",\n    \"&upharpoonleft;\": \"↿\",\n    \"&upharpoonright;\": \"↾\",\n    \"&uplus;\": \"⊎\",\n    \"&upsi;\": \"υ\",\n    \"&upsih;\": \"ϒ\",\n    \"&upsilon;\": \"υ\",\n    \"&upuparrows;\": \"⇈\",\n    \"&urcorn;\": \"⌝\",\n    \"&urcorner;\": \"⌝\",\n    \"&urcrop;\": \"⌎\",\n    \"&uring;\": \"ů\",\n    \"&urtri;\": \"◹\",\n    \"&uscr;\": \"𝓊\",\n    \"&utdot;\": \"⋰\",\n    \"&utilde;\": \"ũ\",\n    \"&utri;\": \"▵\",\n    \"&utrif;\": \"▴\",\n    \"&uuarr;\": \"⇈\",\n    \"&uuml\": \"ü\",\n    \"&uuml;\": \"ü\",\n    \"&uwangle;\": \"⦧\",\n    \"&vArr;\": \"⇕\",\n    \"&vBar;\": \"⫨\",\n    \"&vBarv;\": \"⫩\",\n    \"&vDash;\": \"⊨\",\n    \"&vangrt;\": \"⦜\",\n    \"&varepsilon;\": \"ϵ\",\n    \"&varkappa;\": \"ϰ\",\n    \"&varnothing;\": \"∅\",\n    \"&varphi;\": \"ϕ\",\n    \"&varpi;\": \"ϖ\",\n    \"&varpropto;\": \"∝\",\n    \"&varr;\": \"↕\",\n    \"&varrho;\": \"ϱ\",\n    \"&varsigma;\": \"ς\",\n    \"&varsubsetneq;\": \"⊊︀\",\n    \"&varsubsetneqq;\": \"⫋︀\",\n    \"&varsupsetneq;\": \"⊋︀\",\n    \"&varsupsetneqq;\": \"⫌︀\",\n    \"&vartheta;\": \"ϑ\",\n    \"&vartriangleleft;\": \"⊲\",\n    \"&vartriangleright;\": \"⊳\",\n    \"&vcy;\": \"в\",\n    \"&vdash;\": \"⊢\",\n    \"&vee;\": \"∨\",\n    \"&veebar;\": \"⊻\",\n    \"&veeeq;\": \"≚\",\n    \"&vellip;\": \"⋮\",\n    \"&verbar;\": \"|\",\n    \"&vert;\": \"|\",\n    \"&vfr;\": \"𝔳\",\n    \"&vltri;\": \"⊲\",\n    \"&vnsub;\": \"⊂⃒\",\n    \"&vnsup;\": \"⊃⃒\",\n    \"&vopf;\": \"𝕧\",\n    \"&vprop;\": \"∝\",\n    \"&vrtri;\": \"⊳\",\n    \"&vscr;\": \"𝓋\",\n    \"&vsubnE;\": \"⫋︀\",\n    \"&vsubne;\": \"⊊︀\",\n    \"&vsupnE;\": \"⫌︀\",\n    \"&vsupne;\": \"⊋︀\",\n    \"&vzigzag;\": \"⦚\",\n    \"&wcirc;\": \"ŵ\",\n    \"&wedbar;\": \"⩟\",\n    \"&wedge;\": \"∧\",\n    \"&wedgeq;\": \"≙\",\n    \"&weierp;\": \"℘\",\n    \"&wfr;\": \"𝔴\",\n    \"&wopf;\": \"𝕨\",\n    \"&wp;\": \"℘\",\n    \"&wr;\": \"≀\",\n    \"&wreath;\": \"≀\",\n    \"&wscr;\": \"𝓌\",\n    \"&xcap;\": \"⋂\",\n    \"&xcirc;\": \"◯\",\n    \"&xcup;\": \"⋃\",\n    \"&xdtri;\": \"▽\",\n    \"&xfr;\": \"𝔵\",\n    \"&xhArr;\": \"⟺\",\n    \"&xharr;\": \"⟷\",\n    \"&xi;\": \"ξ\",\n    \"&xlArr;\": \"⟸\",\n    \"&xlarr;\": \"⟵\",\n    \"&xmap;\": \"⟼\",\n    \"&xnis;\": \"⋻\",\n    \"&xodot;\": \"⨀\",\n    \"&xopf;\": \"𝕩\",\n    \"&xoplus;\": \"⨁\",\n    \"&xotime;\": \"⨂\",\n    \"&xrArr;\": \"⟹\",\n    \"&xrarr;\": \"⟶\",\n    \"&xscr;\": \"𝓍\",\n    \"&xsqcup;\": \"⨆\",\n    \"&xuplus;\": \"⨄\",\n    \"&xutri;\": \"△\",\n    \"&xvee;\": \"⋁\",\n    \"&xwedge;\": \"⋀\",\n    \"&yacute\": \"ý\",\n    \"&yacute;\": \"ý\",\n    \"&yacy;\": \"я\",\n    \"&ycirc;\": \"ŷ\",\n    \"&ycy;\": \"ы\",\n    \"&yen\": \"¥\",\n    \"&yen;\": \"¥\",\n    \"&yfr;\": \"𝔶\",\n    \"&yicy;\": \"ї\",\n    \"&yopf;\": \"𝕪\",\n    \"&yscr;\": \"𝓎\",\n    \"&yucy;\": \"ю\",\n    \"&yuml\": \"ÿ\",\n    \"&yuml;\": \"ÿ\",\n    \"&zacute;\": \"ź\",\n    \"&zcaron;\": \"ž\",\n    \"&zcy;\": \"з\",\n    \"&zdot;\": \"ż\",\n    \"&zeetrf;\": \"ℨ\",\n    \"&zeta;\": \"ζ\",\n    \"&zfr;\": \"𝔷\",\n    \"&zhcy;\": \"ж\",\n    \"&zigrarr;\": \"⇝\",\n    \"&zopf;\": \"𝕫\",\n    \"&zscr;\": \"𝓏\",\n    \"&zwj;\": \"‍\",\n    \"&zwnj;\": \"‌\"\n}\nmodule.exports.entities = entities\n"
  },
  {
    "path": "server/utils/htmlSanitizer.js",
    "content": "const sanitizeHtml = require('../libs/sanitizeHtml')\nconst { entities } = require('./htmlEntities')\n\n/**\n *\n * @param {string} html\n * @returns {string}\n */\nfunction sanitize(html) {\n  if (typeof html !== 'string') {\n    return ''\n  }\n\n  const sanitizerOptions = {\n    allowedTags: ['p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br', 'b', 'i'],\n    disallowedTagsMode: 'discard',\n    allowedAttributes: {\n      a: ['href', 'name', 'target']\n    },\n    allowedSchemes: ['http', 'https', 'mailto'],\n    allowProtocolRelative: false\n  }\n\n  return sanitizeHtml(html, sanitizerOptions)\n}\nmodule.exports.sanitize = sanitize\n\nfunction stripAllTags(html, shouldDecodeEntities = true) {\n  if (typeof html !== 'string') return ''\n\n  const sanitizerOptions = {\n    allowedTags: [],\n    disallowedTagsMode: 'discard'\n  }\n\n  let sanitized = sanitizeHtml(html, sanitizerOptions)\n  return shouldDecodeEntities ? decodeHTMLEntities(sanitized) : sanitized\n}\nmodule.exports.stripAllTags = stripAllTags\n\nfunction decodeHTMLEntities(strToDecode) {\n  return strToDecode.replace(/\\&([^;]+);?/g, function (entity) {\n    if (entity in entities) {\n      return entities[entity]\n    }\n    return entity\n  })\n}\n"
  },
  {
    "path": "server/utils/index.js",
    "content": "const Path = require('path')\nconst uuid = require('uuid')\nconst Logger = require('../Logger')\nconst { parseString } = require('xml2js')\nconst areEquivalent = require('./areEquivalent')\n\nconst levenshteinDistance = (str1, str2, caseSensitive = false) => {\n  str1 = String(str1)\n  str2 = String(str2)\n  if (!caseSensitive) {\n    str1 = str1.toLowerCase()\n    str2 = str2.toLowerCase()\n  }\n  const track = Array(str2.length + 1)\n    .fill(null)\n    .map(() => Array(str1.length + 1).fill(null))\n  for (let i = 0; i <= str1.length; i += 1) {\n    track[0][i] = i\n  }\n  for (let j = 0; j <= str2.length; j += 1) {\n    track[j][0] = j\n  }\n  for (let j = 1; j <= str2.length; j += 1) {\n    for (let i = 1; i <= str1.length; i += 1) {\n      const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1\n      track[j][i] = Math.min(\n        track[j][i - 1] + 1, // deletion\n        track[j - 1][i] + 1, // insertion\n        track[j - 1][i - 1] + indicator // substitution\n      )\n    }\n  }\n  return track[str2.length][str1.length]\n}\nmodule.exports.levenshteinDistance = levenshteinDistance\n\nconst levenshteinSimilarity = (str1, str2, caseSensitive = false) => {\n  const distance = levenshteinDistance(str1, str2, caseSensitive)\n  const maxLength = Math.max(str1.length, str2.length)\n  if (maxLength === 0) return 1\n  return 1 - distance / maxLength\n}\nmodule.exports.levenshteinSimilarity = levenshteinSimilarity\n\nmodule.exports.isObject = (val) => {\n  return val !== null && typeof val === 'object'\n}\n\nmodule.exports.comparePaths = (path1, path2) => {\n  return path1 === path2 || Path.normalize(path1) === Path.normalize(path2)\n}\n\nmodule.exports.isNullOrNaN = (num) => {\n  return num === null || isNaN(num)\n}\n\nconst xmlToJSON = (xml) => {\n  return new Promise((resolve, reject) => {\n    parseString(xml, (err, results) => {\n      if (err) {\n        Logger.error(`[xmlToJSON] Error`, err)\n        resolve(null)\n      } else {\n        resolve(results)\n      }\n    })\n  })\n}\nmodule.exports.xmlToJSON = xmlToJSON\n\nmodule.exports.getId = (prepend = '') => {\n  var _id = Math.random().toString(36).substring(2, 8) + Math.random().toString(36).substring(2, 8) + Math.random().toString(36).substring(2, 8)\n  if (prepend) return prepend + '_' + _id\n  return _id\n}\n\n/**\n *\n * @param {number} seconds\n * @returns {string}\n */\nfunction elapsedPretty(seconds) {\n  if (seconds > 0 && seconds < 1) {\n    return `${Math.floor(seconds * 1000)} ms`\n  }\n  if (seconds < 60) {\n    return `${Math.floor(seconds)} sec`\n  }\n  let minutes = Math.floor(seconds / 60)\n  if (minutes < 70) {\n    return `${minutes} min`\n  }\n  let hours = Math.floor(minutes / 60)\n  minutes -= hours * 60\n\n  let days = Math.floor(hours / 24)\n  hours -= days * 24\n\n  const timeParts = []\n  if (days) {\n    timeParts.push(`${days} d`)\n  }\n  if (hours || (days && minutes)) {\n    timeParts.push(`${hours} hr`)\n  }\n  if (minutes) {\n    timeParts.push(`${minutes} min`)\n  }\n  return timeParts.join(' ')\n}\nmodule.exports.elapsedPretty = elapsedPretty\n\nfunction secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = false) {\n  var _seconds = seconds\n  var _minutes = Math.floor(seconds / 60)\n  _seconds -= _minutes * 60\n  var _hours = Math.floor(_minutes / 60)\n  _minutes -= _hours * 60\n\n  var ms = _seconds - Math.floor(seconds)\n  _seconds = Math.floor(_seconds)\n\n  const msString = includeMs ? '.' + ms.toFixed(3).split('.')[1] : ''\n  if (alwaysIncludeHours) {\n    return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`\n  }\n  if (!_hours) {\n    return `${_minutes}:${_seconds.toString().padStart(2, '0')}${msString}`\n  }\n  return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`\n}\nmodule.exports.secondsToTimestamp = secondsToTimestamp\n\nmodule.exports.reqSupportsWebp = (req) => {\n  if (!req || !req.headers || !req.headers.accept) return false\n  return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*'\n}\n\nmodule.exports.areEquivalent = areEquivalent\n\nmodule.exports.copyValue = (val) => {\n  if (val === undefined || val === '') return null\n  else if (!val) return val\n\n  if (!this.isObject(val)) return val\n\n  if (Array.isArray(val)) {\n    return val.map(this.copyValue)\n  } else {\n    var final = {}\n    for (const key in val) {\n      final[key] = this.copyValue(val[key])\n    }\n    return final\n  }\n}\n\nmodule.exports.toNumber = (val, fallback = 0) => {\n  if (isNaN(val) || val === null) return fallback\n  return Number(val)\n}\n\nmodule.exports.cleanStringForSearch = (str) => {\n  if (!str) return ''\n  // Remove ' . ` \" ,\n  return str\n    .toLowerCase()\n    .replace(/[\\'\\.\\`\\\",]/g, '')\n    .trim()\n}\n\nconst getTitleParts = (title) => {\n  if (!title) return ['', null]\n  const prefixesToIgnore = global.ServerSettings.sortingPrefixes || []\n  for (const prefix of prefixesToIgnore) {\n    // e.g. for prefix \"the\". If title is \"The Book\" return \"Book, The\"\n    if (title.toLowerCase().startsWith(`${prefix} `)) {\n      return [title.substr(prefix.length + 1), `${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`]\n    }\n  }\n  return [title, null]\n}\n\n/**\n * Remove sortingPrefixes from title\n * @example \"The Good Book\" => \"Good Book\"\n * @param {string} title\n * @returns {string}\n */\nmodule.exports.getTitleIgnorePrefix = (title) => {\n  return getTitleParts(title)[0]\n}\n\n/**\n * Put sorting prefix at the end of title\n * @example \"The Good Book\" => \"Good Book, The\"\n * @param {string} title\n * @returns {string}\n */\nmodule.exports.getTitlePrefixAtEnd = (title) => {\n  let [sort, prefix] = getTitleParts(title)\n  return prefix ? `${sort}, ${prefix}` : title\n}\n\n/**\n * Escape string used in RegExp\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping\n *\n * @param {string} str\n * @returns {string}\n */\nmodule.exports.escapeRegExp = (str) => {\n  if (typeof str !== 'string') return ''\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Validate url string with URL class\n *\n * @param {string} rawUrl\n * @returns {string} null if invalid\n */\nmodule.exports.validateUrl = (rawUrl) => {\n  if (!rawUrl || typeof rawUrl !== 'string') return null\n  try {\n    return new URL(rawUrl).toString()\n  } catch (error) {\n    Logger.error(`Invalid URL \"${rawUrl}\"`, error)\n    return null\n  }\n}\n\n/**\n * Check if a string is a valid UUID\n *\n * @param {string} str\n * @returns {boolean}\n */\nmodule.exports.isUUID = (str) => {\n  if (!str || typeof str !== 'string') return false\n  return uuid.validate(str)\n}\n\n/**\n * Check if a string is a valid ASIN\n *\n * @param {string} str\n * @returns {boolean}\n */\nmodule.exports.isValidASIN = (str) => {\n  if (!str || typeof str !== 'string') return false\n  return /^[A-Z0-9]{10}$/.test(str)\n}\n\n/**\n * Convert timestamp to seconds\n * @example \"01:00:00\" => 3600\n * @example \"01:00\" => 60\n * @example \"01\" => 1\n *\n * @param {string} timestamp\n * @returns {number}\n */\nmodule.exports.timestampToSeconds = (timestamp) => {\n  if (typeof timestamp !== 'string') {\n    return null\n  }\n  const parts = timestamp.split(':').map(Number)\n  if (parts.some(isNaN)) {\n    return null\n  } else if (parts.length === 1) {\n    return parts[0]\n  } else if (parts.length === 2) {\n    return parts[0] * 60 + parts[1]\n  } else if (parts.length === 3) {\n    return parts[0] * 3600 + parts[1] * 60 + parts[2]\n  }\n  return null\n}\n\nclass ValidationError extends Error {\n  constructor(paramName, message, status = 400) {\n    super(`Query parameter \"${paramName}\" ${message}`)\n    this.name = 'ValidationError'\n    this.paramName = paramName\n    this.status = status\n  }\n}\nmodule.exports.ValidationError = ValidationError\n\nclass NotFoundError extends Error {\n  constructor(message, status = 404) {\n    super(message)\n    this.name = 'NotFoundError'\n    this.status = status\n  }\n}\nmodule.exports.NotFoundError = NotFoundError\n\n/**\n * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion\n * Express query parameters can be arrays if the same parameter appears multiple times\n * @example ?author=Smith => \"Smith\"\n * @example ?author=Smith&author=Jones => throws error\n *\n * @param {Object} query - Query object\n * @param {string} paramName - Parameter name\n * @param {string} defaultValue - Default value if undefined/null\n * @param {boolean} required - Whether the parameter is required\n * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks)\n * @returns {string} String value\n * @throws {ValidationError} If value is an array\n * @throws {ValidationError} If value is too long\n * @throws {ValidationError} If value is required but not provided\n */\nmodule.exports.getQueryParamAsString = (query, paramName, defaultValue = '', required = false, maxLength = 1000) => {\n  const value = query[paramName]\n  if (value === undefined || value === null) {\n    if (required) {\n      throw new ValidationError(paramName, 'is required')\n    }\n    return defaultValue\n  }\n  // Explicitly reject arrays to prevent type confusion\n  if (Array.isArray(value)) {\n    throw new ValidationError(paramName, 'is an array')\n  }\n  // Reject excessively long strings to prevent ReDoS attacks\n  if (typeof value === 'string' && value.length > maxLength) {\n    throw new ValidationError(paramName, 'is too long')\n  }\n  return String(value)\n}\n"
  },
  {
    "path": "server/utils/libraryHelpers.js",
    "content": "const { createNewSortInstance } = require('../libs/fastSort')\nconst Database = require('../Database')\nconst { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')\nconst naturalSort = createNewSortInstance({\n  comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare\n})\n\nmodule.exports = {\n  /**\n   *\n   * @param {import('../models/LibraryItem')[]} libraryItems\n   * @param {*} filterSeries\n   * @param {*} hideSingleBookSeries\n   * @returns\n   */\n  getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) {\n    const _series = {}\n    const seriesToFilterOut = {}\n    libraryItems.forEach((libraryItem) => {\n      // get all book series for item that is not already filtered out\n      const allBookSeries = (libraryItem.media.series || []).filter((se) => !seriesToFilterOut[se.id])\n      if (!allBookSeries.length) return\n\n      allBookSeries.forEach((bookSeries) => {\n        const abJson = libraryItem.toOldJSONMinified()\n        abJson.sequence = bookSeries.bookSeries.sequence\n        if (filterSeries) {\n          const series = libraryItem.media.series.find((se) => se.id === filterSeries)\n          abJson.filterSeriesSequence = series.bookSeries.sequence\n        }\n        if (!_series[bookSeries.id]) {\n          _series[bookSeries.id] = {\n            id: bookSeries.id,\n            name: bookSeries.name,\n            nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name),\n            nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name),\n            type: 'series',\n            books: [abJson],\n            totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)\n          }\n        } else {\n          _series[bookSeries.id].books.push(abJson)\n          _series[bookSeries.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)\n        }\n      })\n    })\n\n    let seriesItems = Object.values(_series)\n\n    // Library setting to hide series with only 1 book\n    if (hideSingleBookSeries) {\n      seriesItems = seriesItems.filter((se) => se.books.length > 1)\n    }\n\n    return seriesItems.map((series) => {\n      series.books = naturalSort(series.books).asc((li) => li.sequence)\n      return series\n    })\n  },\n\n  /**\n   *\n   * @param {import('../models/LibraryItem')[]} libraryItems\n   * @param {string} filterSeries - series id\n   * @param {boolean} hideSingleBookSeries\n   * @returns\n   */\n  collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {\n    // Get series from the library items. If this list is being collapsed after filtering for a series,\n    // don't collapse that series, only books that are in other series.\n    const seriesObjects = this.getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries).filter((s) => s.id != filterSeries)\n\n    const filteredLibraryItems = []\n\n    libraryItems.forEach((li) => {\n      if (li.mediaType != 'book') return\n\n      // Handle when this is the first book in a series\n      seriesObjects\n        .filter((s) => s.books[0].id == li.id)\n        .forEach((series) => {\n          // Clone the library item as we need to attach data to it, but don't\n          // want to change the global copy of the library item\n          filteredLibraryItems.push(Object.assign(Object.create(Object.getPrototypeOf(li)), li, { collapsedSeries: series }))\n        })\n\n      // Only included books not contained in series\n      if (!seriesObjects.some((s) => s.books.some((b) => b.id == li.id))) filteredLibraryItems.push(li)\n    })\n\n    return filteredLibraryItems\n  },\n\n  /**\n   *\n   * @param {*} payload\n   * @param {string} seriesId\n   * @param {import('../models/User')} user\n   * @param {import('../models/Library')} library\n   * @returns {Object[]}\n   */\n  async handleCollapseSubseries(payload, seriesId, user, library) {\n    const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {\n      include: {\n        model: Database.bookModel,\n        through: {\n          attributes: ['sequence']\n        },\n        include: [\n          {\n            model: Database.libraryItemModel\n          },\n          {\n            model: Database.authorModel,\n            through: {\n              attributes: []\n            }\n          },\n          {\n            model: Database.seriesModel,\n            through: {\n              attributes: ['sequence']\n            }\n          }\n        ]\n      }\n    })\n    if (!seriesWithBooks) {\n      payload.total = 0\n      return []\n    }\n\n    const books = seriesWithBooks.books\n    payload.total = books.length\n\n    let libraryItems = books\n      .map((book) => {\n        const libraryItem = book.libraryItem\n        delete book.libraryItem\n        libraryItem.media = book\n        return libraryItem\n      })\n      .filter((li) => {\n        return user.checkCanAccessLibraryItem(li)\n      })\n\n    const collapsedItems = this.collapseBookSeries(libraryItems, seriesId, library.settings.hideSingleBookSeries)\n    if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {\n      libraryItems = collapsedItems\n      payload.total = libraryItems.length\n    }\n\n    const sortingIgnorePrefix = Database.serverSettings.sortingIgnorePrefix\n\n    let sortArray = []\n    const direction = payload.sortDesc ? 'desc' : 'asc'\n    if (!payload.sortBy || payload.sortBy === 'sequence') {\n      sortArray = [\n        {\n          [direction]: (li) => {\n            const series = li.media.series.find((se) => se.id === seriesId)\n            return series.bookSeries.sequence\n          }\n        },\n        {\n          // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)\n          [direction]: (li) => {\n            if (sortingIgnorePrefix) {\n              return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix\n            } else {\n              return li.collapsedSeries?.name || li.media.title\n            }\n          }\n        }\n      ]\n    } else {\n      // If series are collapsed and not sorting by title or sequence,\n      // sort all collapsed series to the end in alphabetical order\n      if (payload.sortBy !== 'media.metadata.title') {\n        sortArray.push({\n          asc: (li) => {\n            if (li.collapsedSeries) {\n              return sortingIgnorePrefix ? li.collapsedSeries.nameIgnorePrefix : li.collapsedSeries.name\n            } else {\n              return ''\n            }\n          }\n        })\n      }\n      sortArray.push({\n        [direction]: (li) => {\n          if (payload.sortBy === 'media.metadata.title') {\n            if (sortingIgnorePrefix) {\n              return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix\n            } else {\n              return li.collapsedSeries?.name || li.media.title\n            }\n          } else {\n            return payload.sortBy.split('.').reduce((a, b) => a[b], li)\n          }\n        }\n      })\n    }\n\n    libraryItems = naturalSort(libraryItems).by(sortArray)\n\n    if (payload.limit) {\n      const startIndex = payload.page * payload.limit\n      libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)\n    }\n\n    return Promise.all(\n      libraryItems.map(async (li) => {\n        const filteredSeries = li.media.series.find((se) => se.id === seriesId)\n        const json = li.toOldJSONMinified()\n        json.media.metadata.series = {\n          id: filteredSeries.id,\n          name: filteredSeries.name,\n          sequence: filteredSeries.bookSeries.sequence\n        }\n\n        if (li.collapsedSeries) {\n          json.collapsedSeries = {\n            id: li.collapsedSeries.id,\n            name: li.collapsedSeries.name,\n            nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,\n            libraryItemIds: li.collapsedSeries.books.map((b) => b.id),\n            numBooks: li.collapsedSeries.books.length\n          }\n\n          // If collapsing by series and filtering by a series, generate the list of sequences the collapsed\n          // series represents in the filtered series\n          json.collapsedSeries.seriesSequenceList = naturalSort(li.collapsedSeries.books.filter((b) => b.filterSeriesSequence).map((b) => b.filterSeriesSequence))\n            .asc()\n            .reduce((ranges, currentSequence) => {\n              let lastRange = ranges.at(-1)\n              let isNumber = /^(\\d+|\\d+\\.\\d*|\\d*\\.\\d+)$/.test(currentSequence)\n              if (isNumber) currentSequence = parseFloat(currentSequence)\n\n              if (lastRange && isNumber && lastRange.isNumber && lastRange.end + 1 == currentSequence) {\n                lastRange.end = currentSequence\n              } else {\n                ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })\n              }\n\n              return ranges\n            }, [])\n            .map((r) => (r.start == r.end ? r.start : `${r.start}-${r.end}`))\n            .join(', ')\n        }\n\n        return json\n      })\n    )\n  }\n}\n"
  },
  {
    "path": "server/utils/longTimeout.js",
    "content": "/**\n * Handle timeouts greater than 32-bit signed integer\n */\nclass LongTimeout {\n  constructor() {\n    this.timeout = 0\n    this.timer = null\n  }\n\n  clear() {\n    clearTimeout(this.timer)\n  }\n\n  /**\n   *\n   * @param {Function} fn\n   * @param {number} timeout\n   */\n  set(fn, timeout) {\n    const maxValue = 2147483647\n\n    const handleTimeout = () => {\n      if (this.timeout > 0) {\n        let delay = Math.min(this.timeout, maxValue)\n        this.timeout = this.timeout - delay\n        this.timer = setTimeout(handleTimeout, delay)\n        return\n      }\n      fn()\n    }\n\n    this.timeout = timeout\n    handleTimeout()\n  }\n}\nmodule.exports = LongTimeout\n"
  },
  {
    "path": "server/utils/migrations/absMetadataMigration.js",
    "content": "const Path = require('path')\nconst Logger = require('../../Logger')\nconst fsExtra = require('../../libs/fsExtra')\nconst fileUtils = require('../fileUtils')\nconst LibraryFile = require('../../objects/files/LibraryFile')\n\n/**\n * \n * @param {import('../../models/LibraryItem')} libraryItem \n * @returns {Promise<boolean>} false if failed\n */\nasync function writeMetadataFileForItem(libraryItem) {\n  const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile\n  const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id)\n  const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json'))\n  if ((await fsExtra.pathExists(metadataFilepath))) {\n    // Metadata file already exists do nothing\n    return null\n  }\n  Logger.info(`[absMetadataMigration] metadata file not found at \"${metadataFilepath}\" - creating`)\n\n  if (!storeMetadataWithItem) {\n    // Ensure /metadata/items/<lid> dir\n    await fsExtra.ensureDir(metadataPath)\n  }\n\n  const metadataJson = libraryItem.media.getAbsMetadataJson()\n\n  // Save to file\n  const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => {\n    Logger.error(`[absMetadataMigration] failed to save metadata file at \"${metadataFilepath}\"`, error.message || error)\n    return false\n  })\n\n  if (!success) return false\n  if (!storeMetadataWithItem) return true // No need to do anything else\n\n  // Safety check to make sure library file with the same path isnt already there\n  libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath)\n\n  // Put new library file in library item\n  const newLibraryFile = new LibraryFile()\n  await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json')\n  libraryItem.libraryFiles.push(newLibraryFile.toJSON())\n\n  // Update library item timestamps and total size\n  const libraryItemDirTimestamps = await fileUtils.getFileTimestampsWithIno(libraryItem.path)\n  if (libraryItemDirTimestamps) {\n    libraryItem.mtime = libraryItemDirTimestamps.mtimeMs\n    libraryItem.ctime = libraryItemDirTimestamps.ctimeMs\n    let size = 0\n    libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))\n    libraryItem.size = size\n  }\n\n  libraryItem.changed('libraryFiles', true)\n  return libraryItem.save().then(() => true).catch((error) => {\n    Logger.error(`[absMetadataMigration] failed to save libraryItem \"${libraryItem.id}\"`, error.message || error)\n    return false\n  })\n}\n\n/**\n * \n * @param {import('../../Database')} Database \n * @param {number} [offset=0]\n * @param {number} [totalCreated=0]\n */\nasync function runMigration(Database, offset = 0, totalCreated = 0) {\n  const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, 500, { isMissing: false })\n  if (!libraryItems.length) return totalCreated\n\n  let numCreated = 0\n  for (const libraryItem of libraryItems) {\n    const success = await writeMetadataFileForItem(libraryItem)\n    if (success) numCreated++\n  }\n\n  if (libraryItems.length < 500) {\n    return totalCreated + numCreated\n  }\n  return runMigration(Database, offset + libraryItems.length, totalCreated + numCreated)\n}\n\n/**\n * \n * @param {import('../../Database')} Database \n */\nmodule.exports.migrate = async (Database) => {\n  Logger.info(`[absMetadataMigration] Starting metadata.json migration`)\n  const totalCreated = await runMigration(Database)\n  Logger.info(`[absMetadataMigration] Finished metadata.json migration (${totalCreated} files created)`)\n}"
  },
  {
    "path": "server/utils/migrations/dbMigration.js",
    "content": "const { DataTypes, QueryInterface } = require('sequelize')\nconst Path = require('path')\nconst uuidv4 = require('uuid').v4\nconst Logger = require('../../Logger')\nconst fs = require('../../libs/fsExtra')\nconst oldDbFiles = require('./oldDbFiles')\nconst parseNameString = require('../parsers/parseNameString')\n\nconst oldDbIdMap = {\n  users: {},\n  libraries: {},\n  libraryFolders: {},\n  libraryItems: {},\n  authors: {}, // key is (new) library id with another map of author ids\n  series: {}, // key is (new) library id with another map of series ids\n  collections: {},\n  podcastEpisodes: {},\n  books: {}, // key is library item id\n  podcasts: {}, // key is library item id\n  devices: {} // key is a json stringify of the old DeviceInfo data OR deviceId if it exists\n}\n\nlet prefixesToIgnore = ['the']\nfunction getTitleIgnorePrefix(title) {\n  if (!title?.trim()) return title\n  for (const prefix of prefixesToIgnore) {\n    // e.g. for prefix \"the\". If title is \"The Book\" return \"Book\"\n    if (title.toLowerCase().startsWith(`${prefix} `)) {\n      return title.substring(prefix.length).trim()\n    }\n  }\n  return title\n}\n\nfunction getDeviceInfoString(deviceInfo, UserId) {\n  if (!deviceInfo) return null\n  if (deviceInfo.deviceId) return deviceInfo.deviceId\n\n  const keys = [UserId, deviceInfo.browserName || null, deviceInfo.browserVersion || null, deviceInfo.osName || null, deviceInfo.osVersion || null, deviceInfo.clientVersion || null, deviceInfo.manufacturer || null, deviceInfo.model || null, deviceInfo.sdkVersion || null, deviceInfo.ipAddress || null].map((k) => k || '')\n  return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')\n}\n\n/**\n * Migrate oldLibraryItem.media to Book model\n * Migrate BookSeries and BookAuthor\n * @param {objects.LibraryItem} oldLibraryItem\n * @param {object} LibraryItem models.LibraryItem object\n * @returns {object} { book: object, bookSeries: [], bookAuthor: [] }\n */\nfunction migrateBook(oldLibraryItem, LibraryItem) {\n  const oldBook = oldLibraryItem.media\n\n  const _newRecords = {\n    book: null,\n    bookSeries: [],\n    bookAuthor: []\n  }\n\n  const tracks = (oldBook.audioFiles || []).filter((af) => !af.exclude && !af.invalid)\n  let duration = 0\n  for (const track of tracks) {\n    if (track.duration !== null && !isNaN(track.duration)) {\n      duration += track.duration\n    }\n  }\n\n  //\n  // Migrate Book\n  //\n  const Book = {\n    id: uuidv4(),\n    title: oldBook.metadata.title,\n    titleIgnorePrefix: getTitleIgnorePrefix(oldBook.metadata.title),\n    subtitle: oldBook.metadata.subtitle,\n    publishedYear: oldBook.metadata.publishedYear,\n    publishedDate: oldBook.metadata.publishedDate,\n    publisher: oldBook.metadata.publisher,\n    description: oldBook.metadata.description,\n    isbn: oldBook.metadata.isbn,\n    asin: oldBook.metadata.asin,\n    language: oldBook.metadata.language,\n    explicit: !!oldBook.metadata.explicit,\n    abridged: !!oldBook.metadata.abridged,\n    lastCoverSearchQuery: oldBook.lastCoverSearchQuery,\n    lastCoverSearch: oldBook.lastCoverSearch,\n    createdAt: LibraryItem.createdAt,\n    updatedAt: LibraryItem.updatedAt,\n    narrators: oldBook.metadata.narrators,\n    ebookFile: oldBook.ebookFile,\n    coverPath: oldBook.coverPath,\n    duration,\n    audioFiles: oldBook.audioFiles,\n    chapters: oldBook.chapters,\n    tags: oldBook.tags,\n    genres: oldBook.metadata.genres\n  }\n  _newRecords.book = Book\n  oldDbIdMap.books[oldLibraryItem.id] = Book.id\n\n  //\n  // Migrate BookAuthors\n  //\n  const bookAuthorsInserted = []\n  for (const oldBookAuthor of oldBook.metadata.authors) {\n    if (oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]) {\n      const authorId = oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]\n\n      if (bookAuthorsInserted.includes(authorId)) continue // Duplicate prevention\n      bookAuthorsInserted.push(authorId)\n\n      _newRecords.bookAuthor.push({\n        id: uuidv4(),\n        authorId,\n        bookId: Book.id\n      })\n    } else {\n      Logger.warn(`[dbMigration] migrateBook: Book author not found \"${oldBookAuthor.name}\"`)\n    }\n  }\n\n  //\n  // Migrate BookSeries\n  //\n  const bookSeriesInserted = []\n  for (const oldBookSeries of oldBook.metadata.series) {\n    if (oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]) {\n      const seriesId = oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]\n\n      if (bookSeriesInserted.includes(seriesId)) continue // Duplicate prevention\n      bookSeriesInserted.push(seriesId)\n\n      _newRecords.bookSeries.push({\n        id: uuidv4(),\n        sequence: oldBookSeries.sequence,\n        seriesId: oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id],\n        bookId: Book.id\n      })\n    } else {\n      Logger.warn(`[dbMigration] migrateBook: Series not found \"${oldBookSeries.name}\"`)\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate oldLibraryItem.media to Podcast model\n * Migrate PodcastEpisode\n * @param {objects.LibraryItem} oldLibraryItem\n * @param {object} LibraryItem models.LibraryItem object\n * @returns {object} { podcast: object, podcastEpisode: [] }\n */\nfunction migratePodcast(oldLibraryItem, LibraryItem) {\n  const _newRecords = {\n    podcast: null,\n    podcastEpisode: []\n  }\n\n  const oldPodcast = oldLibraryItem.media\n  const oldPodcastMetadata = oldPodcast.metadata\n\n  //\n  // Migrate Podcast\n  //\n  const Podcast = {\n    id: uuidv4(),\n    title: oldPodcastMetadata.title,\n    titleIgnorePrefix: getTitleIgnorePrefix(oldPodcastMetadata.title),\n    author: oldPodcastMetadata.author,\n    releaseDate: oldPodcastMetadata.releaseDate,\n    feedURL: oldPodcastMetadata.feedUrl,\n    imageURL: oldPodcastMetadata.imageUrl,\n    description: oldPodcastMetadata.description,\n    itunesPageURL: oldPodcastMetadata.itunesPageUrl,\n    itunesId: oldPodcastMetadata.itunesId,\n    itunesArtistId: oldPodcastMetadata.itunesArtistId,\n    language: oldPodcastMetadata.language,\n    podcastType: oldPodcastMetadata.type,\n    explicit: !!oldPodcastMetadata.explicit,\n    autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,\n    autoDownloadSchedule: oldPodcast.autoDownloadSchedule,\n    lastEpisodeCheck: oldPodcast.lastEpisodeCheck,\n    maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep || 0,\n    maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload || 3,\n    lastCoverSearchQuery: oldPodcast.lastCoverSearchQuery,\n    lastCoverSearch: oldPodcast.lastCoverSearch,\n    createdAt: LibraryItem.createdAt,\n    updatedAt: LibraryItem.updatedAt,\n    coverPath: oldPodcast.coverPath,\n    tags: oldPodcast.tags,\n    genres: oldPodcastMetadata.genres\n  }\n  _newRecords.podcast = Podcast\n  oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id\n\n  //\n  // Migrate PodcastEpisodes\n  //\n  const oldEpisodes = oldPodcast.episodes || []\n  for (const oldEpisode of oldEpisodes) {\n    oldEpisode.audioFile.index = 1\n\n    const PodcastEpisode = {\n      id: uuidv4(),\n      oldEpisodeId: oldEpisode.id,\n      index: oldEpisode.index,\n      season: oldEpisode.season || null,\n      episode: oldEpisode.episode || null,\n      episodeType: oldEpisode.episodeType || null,\n      title: oldEpisode.title,\n      subtitle: oldEpisode.subtitle || null,\n      description: oldEpisode.description || null,\n      pubDate: oldEpisode.pubDate || null,\n      enclosureURL: oldEpisode.enclosure?.url || null,\n      enclosureSize: oldEpisode.enclosure?.length || null,\n      enclosureType: oldEpisode.enclosure?.type || null,\n      publishedAt: oldEpisode.publishedAt || null,\n      createdAt: oldEpisode.addedAt,\n      updatedAt: oldEpisode.updatedAt,\n      podcastId: Podcast.id,\n      audioFile: oldEpisode.audioFile,\n      chapters: oldEpisode.chapters || []\n    }\n    _newRecords.podcastEpisode.push(PodcastEpisode)\n    oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id\n  }\n  return _newRecords\n}\n\n/**\n * Migrate libraryItems to LibraryItem, Book, Podcast models\n * @param {Array<objects.LibraryItem>} oldLibraryItems\n * @returns {object} { libraryItem: [], book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [] }\n */\nfunction migrateLibraryItems(oldLibraryItems) {\n  const _newRecords = {\n    book: [],\n    podcast: [],\n    podcastEpisode: [],\n    bookSeries: [],\n    bookAuthor: [],\n    libraryItem: []\n  }\n  for (const oldLibraryItem of oldLibraryItems) {\n    const libraryFolderId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId]\n    if (!libraryFolderId) {\n      Logger.error(`[dbMigration] migrateLibraryItems: Old library folder id not found \"${oldLibraryItem.folderId}\"`)\n      continue\n    }\n    const libraryId = oldDbIdMap.libraries[oldLibraryItem.libraryId]\n    if (!libraryId) {\n      Logger.error(`[dbMigration] migrateLibraryItems: Old library id not found \"${oldLibraryItem.libraryId}\"`)\n      continue\n    }\n    if (!['book', 'podcast'].includes(oldLibraryItem.mediaType)) {\n      Logger.error(`[dbMigration] migrateLibraryItems: Not migrating library item with mediaType=${oldLibraryItem.mediaType}`)\n      continue\n    }\n\n    let size = 0\n    for (const libraryFile of oldLibraryItem.libraryFiles) {\n      if (libraryFile.metadata?.size && !isNaN(libraryFile.metadata?.size)) {\n        size += libraryFile.metadata.size\n      }\n    }\n\n    //\n    // Migrate LibraryItem\n    //\n    const LibraryItem = {\n      id: uuidv4(),\n      oldLibraryItemId: oldLibraryItem.id,\n      ino: oldLibraryItem.ino,\n      path: oldLibraryItem.path,\n      relPath: oldLibraryItem.relPath,\n      mediaId: null, // set below\n      mediaType: oldLibraryItem.mediaType,\n      isFile: !!oldLibraryItem.isFile,\n      isMissing: !!oldLibraryItem.isMissing,\n      isInvalid: !!oldLibraryItem.isInvalid,\n      mtime: oldLibraryItem.mtimeMs,\n      ctime: oldLibraryItem.ctimeMs,\n      birthtime: oldLibraryItem.birthtimeMs,\n      size,\n      lastScan: oldLibraryItem.lastScan,\n      lastScanVersion: oldLibraryItem.scanVersion,\n      createdAt: oldLibraryItem.addedAt,\n      updatedAt: oldLibraryItem.updatedAt,\n      libraryId,\n      libraryFolderId,\n      libraryFiles: oldLibraryItem.libraryFiles.map((lf) => {\n        if (lf.isSupplementary === undefined) lf.isSupplementary = null\n        return lf\n      })\n    }\n    oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id\n    _newRecords.libraryItem.push(LibraryItem)\n\n    //\n    // Migrate Book/Podcast\n    //\n    if (oldLibraryItem.mediaType === 'book') {\n      const bookRecords = migrateBook(oldLibraryItem, LibraryItem)\n      _newRecords.book.push(bookRecords.book)\n      _newRecords.bookAuthor.push(...bookRecords.bookAuthor)\n      _newRecords.bookSeries.push(...bookRecords.bookSeries)\n\n      LibraryItem.mediaId = oldDbIdMap.books[oldLibraryItem.id]\n    } else if (oldLibraryItem.mediaType === 'podcast') {\n      const podcastRecords = migratePodcast(oldLibraryItem, LibraryItem)\n      _newRecords.podcast.push(podcastRecords.podcast)\n      _newRecords.podcastEpisode.push(...podcastRecords.podcastEpisode)\n\n      LibraryItem.mediaId = oldDbIdMap.podcasts[oldLibraryItem.id]\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate Library and LibraryFolder\n * @param {Array<objects.Library>} oldLibraries\n * @returns {object} { library: [], libraryFolder: [] }\n */\nfunction migrateLibraries(oldLibraries) {\n  const _newRecords = {\n    library: [],\n    libraryFolder: []\n  }\n  for (const oldLibrary of oldLibraries) {\n    if (!['book', 'podcast'].includes(oldLibrary.mediaType)) {\n      Logger.error(`[dbMigration] migrateLibraries: Not migrating library with mediaType=${oldLibrary.mediaType}`)\n      continue\n    }\n\n    //\n    // Migrate Library\n    //\n    const Library = {\n      id: uuidv4(),\n      oldLibraryId: oldLibrary.id,\n      name: oldLibrary.name,\n      displayOrder: oldLibrary.displayOrder,\n      icon: oldLibrary.icon || null,\n      mediaType: oldLibrary.mediaType || null,\n      provider: oldLibrary.provider,\n      settings: oldLibrary.settings || {},\n      createdAt: oldLibrary.createdAt,\n      updatedAt: oldLibrary.lastUpdate\n    }\n    oldDbIdMap.libraries[oldLibrary.id] = Library.id\n    _newRecords.library.push(Library)\n\n    //\n    // Migrate LibraryFolders\n    //\n    for (const oldFolder of oldLibrary.folders) {\n      const LibraryFolder = {\n        id: uuidv4(),\n        path: oldFolder.fullPath,\n        createdAt: oldFolder.addedAt,\n        updatedAt: oldLibrary.lastUpdate,\n        libraryId: Library.id\n      }\n      oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id\n      _newRecords.libraryFolder.push(LibraryFolder)\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate Author\n * Previously Authors were shared between libraries, this will ensure every author has one library\n * @param {Array<objects.entities.Author>} oldAuthors\n * @param {Array<objects.LibraryItem>} oldLibraryItems\n * @returns {Array<object>} Array of Author model objs\n */\nfunction migrateAuthors(oldAuthors, oldLibraryItems) {\n  const _newRecords = []\n  for (const oldAuthor of oldAuthors) {\n    // Get an array of NEW library ids that have this author\n    const librariesWithThisAuthor = [\n      ...new Set(\n        oldLibraryItems\n          .map((li) => {\n            if (!li.media.metadata.authors?.some((au) => au.id === oldAuthor.id)) return null\n            if (!oldDbIdMap.libraries[li.libraryId]) {\n              Logger.warn(`[dbMigration] Authors library id ${li.libraryId} was not migrated`)\n            }\n            return oldDbIdMap.libraries[li.libraryId]\n          })\n          .filter((lid) => lid)\n      )\n    ]\n\n    if (!librariesWithThisAuthor.length) {\n      Logger.error(`[dbMigration] Author ${oldAuthor.name} was not found in any libraries`)\n    }\n\n    for (const libraryId of librariesWithThisAuthor) {\n      const lastFirst = oldAuthor.name ? parseNameString.nameToLastFirst(oldAuthor.name) : ''\n      const Author = {\n        id: uuidv4(),\n        name: oldAuthor.name,\n        lastFirst,\n        asin: oldAuthor.asin || null,\n        description: oldAuthor.description,\n        imagePath: oldAuthor.imagePath,\n        createdAt: oldAuthor.addedAt || Date.now(),\n        updatedAt: oldAuthor.updatedAt || Date.now(),\n        libraryId\n      }\n      if (!oldDbIdMap.authors[libraryId]) oldDbIdMap.authors[libraryId] = {}\n      oldDbIdMap.authors[libraryId][oldAuthor.id] = Author.id\n      _newRecords.push(Author)\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate Series\n * Previously Series were shared between libraries, this will ensure every series has one library\n * @param {Array<objects.entities.Series>} oldSerieses\n * @param {Array<objects.LibraryItem>} oldLibraryItems\n * @returns {Array<object>} Array of Series model objs\n */\nfunction migrateSeries(oldSerieses, oldLibraryItems) {\n  const _newRecords = []\n  // Originaly series were shared between libraries if they had the same name\n  // Series will be separate between libraries\n  for (const oldSeries of oldSerieses) {\n    // Get an array of NEW library ids that have this series\n    const librariesWithThisSeries = [\n      ...new Set(\n        oldLibraryItems\n          .map((li) => {\n            if (!li.media.metadata.series?.some((se) => se.id === oldSeries.id)) return null\n            return oldDbIdMap.libraries[li.libraryId]\n          })\n          .filter((lid) => lid)\n      )\n    ]\n\n    if (!librariesWithThisSeries.length) {\n      Logger.error(`[dbMigration] Series ${oldSeries.name} was not found in any libraries`)\n    }\n\n    for (const libraryId of librariesWithThisSeries) {\n      const Series = {\n        id: uuidv4(),\n        name: oldSeries.name,\n        nameIgnorePrefix: getTitleIgnorePrefix(oldSeries.name),\n        description: oldSeries.description || null,\n        createdAt: oldSeries.addedAt || Date.now(),\n        updatedAt: oldSeries.updatedAt || Date.now(),\n        libraryId\n      }\n      if (!oldDbIdMap.series[libraryId]) oldDbIdMap.series[libraryId] = {}\n      oldDbIdMap.series[libraryId][oldSeries.id] = Series.id\n      _newRecords.push(Series)\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate users to User and MediaProgress models\n * @param {Array<objects.User>} oldUsers\n * @returns {object} { user: [], mediaProgress: [] }\n */\nfunction migrateUsers(oldUsers) {\n  const _newRecords = {\n    user: [],\n    mediaProgress: []\n  }\n  for (const oldUser of oldUsers) {\n    //\n    // Migrate User\n    //\n    // Convert old library ids to new ids\n    const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter((li) => li)\n\n    // Convert old library item ids to new ids\n    const bookmarks = (oldUser.bookmarks || [])\n      .map((bm) => {\n        bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]\n        return bm\n      })\n      .filter((bm) => bm.libraryItemId)\n\n    // Convert old series ids to new\n    const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || [])\n      .map((oldSeriesId) => {\n        // Series were split to be per library\n        // This will use the first series it finds\n        for (const libraryId in oldDbIdMap.series) {\n          if (oldDbIdMap.series[libraryId][oldSeriesId]) {\n            return oldDbIdMap.series[libraryId][oldSeriesId]\n          }\n        }\n        return null\n      })\n      .filter((se) => se)\n\n    const User = {\n      id: uuidv4(),\n      username: oldUser.username,\n      pash: oldUser.pash || null,\n      type: oldUser.type || null,\n      token: oldUser.token || null,\n      isActive: !!oldUser.isActive,\n      lastSeen: oldUser.lastSeen || null,\n      extraData: {\n        seriesHideFromContinueListening,\n        oldUserId: oldUser.id // Used to keep old tokens\n      },\n      createdAt: oldUser.createdAt || Date.now(),\n      permissions: {\n        ...oldUser.permissions,\n        librariesAccessible,\n        itemTagsSelected: oldUser.itemTagsSelected || []\n      },\n      bookmarks\n    }\n    oldDbIdMap.users[oldUser.id] = User.id\n    _newRecords.user.push(User)\n\n    //\n    // Migrate MediaProgress\n    //\n    for (const oldMediaProgress of oldUser.mediaProgress) {\n      let mediaItemType = 'book'\n      let mediaItemId = null\n      if (oldMediaProgress.episodeId) {\n        mediaItemType = 'podcastEpisode'\n        mediaItemId = oldDbIdMap.podcastEpisodes[oldMediaProgress.episodeId]\n      } else {\n        mediaItemId = oldDbIdMap.books[oldMediaProgress.libraryItemId]\n      }\n\n      if (!mediaItemId) {\n        Logger.warn(`[dbMigration] migrateUsers: Unable to find media item for media progress \"${oldMediaProgress.id}\"`)\n        continue\n      }\n\n      const MediaProgress = {\n        id: uuidv4(),\n        mediaItemId,\n        mediaItemType,\n        duration: oldMediaProgress.duration,\n        currentTime: oldMediaProgress.currentTime,\n        ebookLocation: oldMediaProgress.ebookLocation || null,\n        ebookProgress: oldMediaProgress.ebookProgress || null,\n        isFinished: !!oldMediaProgress.isFinished,\n        hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,\n        finishedAt: oldMediaProgress.finishedAt,\n        createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,\n        updatedAt: oldMediaProgress.lastUpdate,\n        userId: User.id,\n        extraData: {\n          libraryItemId: oldDbIdMap.libraryItems[oldMediaProgress.libraryItemId],\n          progress: oldMediaProgress.progress\n        }\n      }\n      _newRecords.mediaProgress.push(MediaProgress)\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate playbackSessions to PlaybackSession and Device models\n * @param {Array<objects.PlaybackSession>} oldSessions\n * @returns {object} { playbackSession: [], device: [] }\n */\nfunction migrateSessions(oldSessions) {\n  const _newRecords = {\n    device: [],\n    playbackSession: []\n  }\n\n  for (const oldSession of oldSessions) {\n    const userId = oldDbIdMap.users[oldSession.userId]\n    if (!userId) {\n      Logger.info(`[dbMigration] Not migrating playback session ${oldSession.id} because user was not found`)\n      continue\n    }\n\n    //\n    // Migrate Device\n    //\n    let deviceId = null\n    if (oldSession.deviceInfo) {\n      const oldDeviceInfo = oldSession.deviceInfo\n      const deviceDeviceId = getDeviceInfoString(oldDeviceInfo, userId)\n      deviceId = oldDbIdMap.devices[deviceDeviceId]\n      if (!deviceId) {\n        let clientName = 'Unknown'\n        let clientVersion = null\n        let deviceName = null\n        let deviceVersion = oldDeviceInfo.browserVersion || null\n        let extraData = {}\n        if (oldDeviceInfo.sdkVersion) {\n          clientName = 'Abs Android'\n          clientVersion = oldDeviceInfo.clientVersion || null\n          deviceName = `${oldDeviceInfo.manufacturer} ${oldDeviceInfo.model}`\n          deviceVersion = oldDeviceInfo.sdkVersion\n        } else if (oldDeviceInfo.model) {\n          clientName = 'Abs iOS'\n          clientVersion = oldDeviceInfo.clientVersion || null\n          deviceName = `${oldDeviceInfo.manufacturer} ${oldDeviceInfo.model}`\n        } else if (oldDeviceInfo.osName && oldDeviceInfo.browserName) {\n          clientName = 'Abs Web'\n          clientVersion = oldDeviceInfo.serverVersion || null\n          deviceName = `${oldDeviceInfo.osName} ${oldDeviceInfo.osVersion || 'N/A'} ${oldDeviceInfo.browserName}`\n        }\n\n        if (oldDeviceInfo.manufacturer) {\n          extraData.manufacturer = oldDeviceInfo.manufacturer\n        }\n        if (oldDeviceInfo.model) {\n          extraData.model = oldDeviceInfo.model\n        }\n        if (oldDeviceInfo.osName) {\n          extraData.osName = oldDeviceInfo.osName\n        }\n        if (oldDeviceInfo.osVersion) {\n          extraData.osVersion = oldDeviceInfo.osVersion\n        }\n        if (oldDeviceInfo.browserName) {\n          extraData.browserName = oldDeviceInfo.browserName\n        }\n\n        const id = uuidv4()\n        const Device = {\n          id,\n          deviceId: deviceDeviceId,\n          clientName,\n          clientVersion,\n          ipAddress: oldDeviceInfo.ipAddress,\n          deviceName, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3\n          deviceVersion,\n          userId,\n          extraData\n        }\n        deviceId = Device.id\n        _newRecords.device.push(Device)\n        oldDbIdMap.devices[deviceDeviceId] = Device.id\n      }\n    }\n\n    //\n    // Migrate PlaybackSession\n    //\n    let mediaItemId = null\n    let mediaItemType = 'book'\n    if (oldSession.mediaType === 'podcast') {\n      mediaItemId = oldDbIdMap.podcastEpisodes[oldSession.episodeId] || null\n      mediaItemType = 'podcastEpisode'\n    } else {\n      mediaItemId = oldDbIdMap.books[oldSession.libraryItemId] || null\n    }\n\n    const PlaybackSession = {\n      id: uuidv4(),\n      mediaItemId, // Can be null\n      mediaItemType,\n      libraryId: oldDbIdMap.libraries[oldSession.libraryId] || null,\n      displayTitle: oldSession.displayTitle,\n      displayAuthor: oldSession.displayAuthor,\n      duration: oldSession.duration,\n      playMethod: oldSession.playMethod,\n      mediaPlayer: oldSession.mediaPlayer,\n      startTime: oldSession.startTime,\n      currentTime: oldSession.currentTime,\n      serverVersion: oldSession.deviceInfo?.serverVersion || null,\n      createdAt: oldSession.startedAt,\n      updatedAt: oldSession.updatedAt,\n      userId,\n      deviceId,\n      timeListening: oldSession.timeListening,\n      coverPath: oldSession.coverPath,\n      mediaMetadata: oldSession.mediaMetadata,\n      date: oldSession.date,\n      dayOfWeek: oldSession.dayOfWeek,\n      extraData: {\n        libraryItemId: oldDbIdMap.libraryItems[oldSession.libraryItemId]\n      }\n    }\n    _newRecords.playbackSession.push(PlaybackSession)\n  }\n  return _newRecords\n}\n\n/**\n * Migrate collections to Collection & CollectionBook\n * @param {Array<objects.Collection>} oldCollections\n * @returns {object} { collection: [], collectionBook: [] }\n */\nfunction migrateCollections(oldCollections) {\n  const _newRecords = {\n    collection: [],\n    collectionBook: []\n  }\n  for (const oldCollection of oldCollections) {\n    const libraryId = oldDbIdMap.libraries[oldCollection.libraryId]\n    if (!libraryId) {\n      Logger.warn(`[dbMigration] migrateCollections: Library not found for collection \"${oldCollection.name}\" (id:${oldCollection.libraryId})`)\n      continue\n    }\n\n    const BookIds = oldCollection.books.map((lid) => oldDbIdMap.books[lid]).filter((bid) => bid)\n    if (!BookIds.length) {\n      Logger.warn(`[dbMigration] migrateCollections: Collection \"${oldCollection.name}\" has no books`)\n      continue\n    }\n\n    const Collection = {\n      id: uuidv4(),\n      name: oldCollection.name,\n      description: oldCollection.description,\n      createdAt: oldCollection.createdAt,\n      updatedAt: oldCollection.lastUpdate,\n      libraryId\n    }\n    oldDbIdMap.collections[oldCollection.id] = Collection.id\n    _newRecords.collection.push(Collection)\n\n    let order = 1\n    BookIds.forEach((bookId) => {\n      const CollectionBook = {\n        id: uuidv4(),\n        createdAt: Collection.createdAt,\n        bookId,\n        collectionId: Collection.id,\n        order: order++\n      }\n      _newRecords.collectionBook.push(CollectionBook)\n    })\n  }\n  return _newRecords\n}\n\n/**\n * Migrate playlists to Playlist and PlaylistMediaItem\n * @param {Array<objects.Playlist>} oldPlaylists\n * @returns {object} { playlist: [], playlistMediaItem: [] }\n */\nfunction migratePlaylists(oldPlaylists) {\n  const _newRecords = {\n    playlist: [],\n    playlistMediaItem: []\n  }\n  for (const oldPlaylist of oldPlaylists) {\n    const libraryId = oldDbIdMap.libraries[oldPlaylist.libraryId]\n    if (!libraryId) {\n      Logger.warn(`[dbMigration] migratePlaylists: Library not found for playlist \"${oldPlaylist.name}\" (id:${oldPlaylist.libraryId})`)\n      continue\n    }\n\n    const userId = oldDbIdMap.users[oldPlaylist.userId]\n    if (!userId) {\n      Logger.warn(`[dbMigration] migratePlaylists: User not found for playlist \"${oldPlaylist.name}\" (id:${oldPlaylist.userId})`)\n      continue\n    }\n\n    let mediaItemType = 'book'\n    let MediaItemIds = []\n    oldPlaylist.items.forEach((itemObj) => {\n      if (itemObj.episodeId) {\n        mediaItemType = 'podcastEpisode'\n        if (oldDbIdMap.podcastEpisodes[itemObj.episodeId]) {\n          MediaItemIds.push(oldDbIdMap.podcastEpisodes[itemObj.episodeId])\n        }\n      } else if (oldDbIdMap.books[itemObj.libraryItemId]) {\n        MediaItemIds.push(oldDbIdMap.books[itemObj.libraryItemId])\n      }\n    })\n    if (!MediaItemIds.length) {\n      Logger.warn(`[dbMigration] migratePlaylists: Playlist \"${oldPlaylist.name}\" has no items`)\n      continue\n    }\n\n    const Playlist = {\n      id: uuidv4(),\n      name: oldPlaylist.name,\n      description: oldPlaylist.description,\n      createdAt: oldPlaylist.createdAt,\n      updatedAt: oldPlaylist.lastUpdate,\n      userId,\n      libraryId\n    }\n    _newRecords.playlist.push(Playlist)\n\n    let order = 1\n    MediaItemIds.forEach((mediaItemId) => {\n      const PlaylistMediaItem = {\n        id: uuidv4(),\n        mediaItemId,\n        mediaItemType,\n        createdAt: Playlist.createdAt,\n        playlistId: Playlist.id,\n        order: order++\n      }\n      _newRecords.playlistMediaItem.push(PlaylistMediaItem)\n    })\n  }\n  return _newRecords\n}\n\n/**\n * Migrate feeds to Feed and FeedEpisode models\n * @param {Array<objects.Feed>} oldFeeds\n * @returns {object} { feed: [], feedEpisode: [] }\n */\nfunction migrateFeeds(oldFeeds) {\n  const _newRecords = {\n    feed: [],\n    feedEpisode: []\n  }\n  for (const oldFeed of oldFeeds) {\n    if (!oldFeed.episodes?.length) {\n      continue\n    }\n\n    let entityId = null\n\n    if (oldFeed.entityType === 'collection') {\n      entityId = oldDbIdMap.collections[oldFeed.entityId]\n    } else if (oldFeed.entityType === 'libraryItem') {\n      entityId = oldDbIdMap.libraryItems[oldFeed.entityId]\n    } else if (oldFeed.entityType === 'series') {\n      // Series were split to be per library\n      // This will use the first series it finds\n      for (const libraryId in oldDbIdMap.series) {\n        if (oldDbIdMap.series[libraryId][oldFeed.entityId]) {\n          entityId = oldDbIdMap.series[libraryId][oldFeed.entityId]\n          break\n        }\n      }\n    }\n\n    if (!entityId) {\n      Logger.warn(`[dbMigration] migrateFeeds: Entity not found for feed \"${oldFeed.entityType}\" (id:${oldFeed.entityId})`)\n      continue\n    }\n\n    const userId = oldDbIdMap.users[oldFeed.userId]\n    if (!userId) {\n      Logger.warn(`[dbMigration] migrateFeeds: User not found for feed (id:${oldFeed.userId})`)\n      continue\n    }\n\n    const oldFeedMeta = oldFeed.meta\n\n    const Feed = {\n      id: uuidv4(),\n      slug: oldFeed.slug,\n      entityType: oldFeed.entityType,\n      entityId,\n      entityUpdatedAt: oldFeed.entityUpdatedAt,\n      serverAddress: oldFeed.serverAddress,\n      feedURL: oldFeed.feedUrl,\n      coverPath: oldFeed.coverPath || null,\n      imageURL: oldFeedMeta.imageUrl,\n      siteURL: oldFeedMeta.link,\n      title: oldFeedMeta.title,\n      description: oldFeedMeta.description,\n      author: oldFeedMeta.author,\n      podcastType: oldFeedMeta.type || null,\n      language: oldFeedMeta.language || null,\n      ownerName: oldFeedMeta.ownerName || null,\n      ownerEmail: oldFeedMeta.ownerEmail || null,\n      explicit: !!oldFeedMeta.explicit,\n      preventIndexing: !!oldFeedMeta.preventIndexing,\n      createdAt: oldFeed.createdAt,\n      updatedAt: oldFeed.updatedAt,\n      userId\n    }\n    _newRecords.feed.push(Feed)\n\n    //\n    // Migrate FeedEpisodes\n    //\n    for (const oldFeedEpisode of oldFeed.episodes) {\n      const FeedEpisode = {\n        id: uuidv4(),\n        title: oldFeedEpisode.title,\n        author: oldFeedEpisode.author,\n        description: oldFeedEpisode.description,\n        siteURL: oldFeedEpisode.link,\n        enclosureURL: oldFeedEpisode.enclosure?.url || null,\n        enclosureType: oldFeedEpisode.enclosure?.type || null,\n        enclosureSize: oldFeedEpisode.enclosure?.size || null,\n        pubDate: oldFeedEpisode.pubDate,\n        season: oldFeedEpisode.season || null,\n        episode: oldFeedEpisode.episode || null,\n        episodeType: oldFeedEpisode.episodeType || null,\n        duration: oldFeedEpisode.duration,\n        filePath: oldFeedEpisode.fullPath,\n        explicit: !!oldFeedEpisode.explicit,\n        createdAt: oldFeed.createdAt,\n        updatedAt: oldFeed.updatedAt,\n        feedId: Feed.id\n      }\n      _newRecords.feedEpisode.push(FeedEpisode)\n    }\n  }\n  return _newRecords\n}\n\n/**\n * Migrate ServerSettings, NotificationSettings and EmailSettings to Setting model\n * @param {Array<objects.settings.*>} oldSettings\n * @returns {Array<object>} Array of Setting model objs\n */\nfunction migrateSettings(oldSettings) {\n  const _newRecords = []\n  const serverSettings = oldSettings.find((s) => s.id === 'server-settings')\n  const notificationSettings = oldSettings.find((s) => s.id === 'notification-settings')\n  const emailSettings = oldSettings.find((s) => s.id === 'email-settings')\n\n  if (serverSettings) {\n    _newRecords.push({\n      key: 'server-settings',\n      value: serverSettings\n    })\n\n    if (serverSettings.sortingPrefixes?.length) {\n      // Used for migrating titles/names\n      prefixesToIgnore = serverSettings.sortingPrefixes\n    }\n  }\n\n  if (notificationSettings) {\n    _newRecords.push({\n      key: 'notification-settings',\n      value: notificationSettings\n    })\n  }\n\n  if (emailSettings) {\n    _newRecords.push({\n      key: 'email-settings',\n      value: emailSettings\n    })\n  }\n  return _newRecords\n}\n\n/**\n * Load old libraries and bulkCreate new Library and LibraryFolder rows\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateLibraries(DatabaseModels) {\n  const oldLibraries = await oldDbFiles.loadOldData('libraries')\n  const newLibraryRecords = migrateLibraries(oldLibraries)\n  for (const model in newLibraryRecords) {\n    Logger.info(`[dbMigration] Inserting ${newLibraryRecords[model].length} ${model} rows`)\n    await DatabaseModels[model].bulkCreate(newLibraryRecords[model])\n  }\n}\n\n/**\n * Load old EmailSettings, NotificationSettings and ServerSettings and bulkCreate new Setting rows\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateSettings(DatabaseModels) {\n  const oldSettings = await oldDbFiles.loadOldData('settings')\n  const newSettings = migrateSettings(oldSettings)\n  Logger.info(`[dbMigration] Inserting ${newSettings.length} setting rows`)\n  await DatabaseModels.setting.bulkCreate(newSettings)\n}\n\n/**\n * Load old authors and bulkCreate new Author rows\n * @param {Map<string,Model>} DatabaseModels\n * @param {Array<objects.LibraryItem>} oldLibraryItems\n */\nasync function handleMigrateAuthors(DatabaseModels, oldLibraryItems) {\n  const oldAuthors = await oldDbFiles.loadOldData('authors')\n  const newAuthors = migrateAuthors(oldAuthors, oldLibraryItems)\n  Logger.info(`[dbMigration] Inserting ${newAuthors.length} author rows`)\n  await DatabaseModels.author.bulkCreate(newAuthors)\n}\n\n/**\n * Load old series and bulkCreate new Series rows\n * @param {Map<string,Model>} DatabaseModels\n * @param {Array<objects.LibraryItem>} oldLibraryItems\n */\nasync function handleMigrateSeries(DatabaseModels, oldLibraryItems) {\n  const oldSeries = await oldDbFiles.loadOldData('series')\n  const newSeries = migrateSeries(oldSeries, oldLibraryItems)\n  Logger.info(`[dbMigration] Inserting ${newSeries.length} series rows`)\n  await DatabaseModels.series.bulkCreate(newSeries)\n}\n\n/**\n * bulkCreate new LibraryItem, Book and Podcast rows\n * @param {Map<string,Model>} DatabaseModels\n * @param {Array<objects.LibraryItem>} oldLibraryItems\n */\nasync function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) {\n  const newItemsBooksPodcasts = migrateLibraryItems(oldLibraryItems)\n  for (const model in newItemsBooksPodcasts) {\n    Logger.info(`[dbMigration] Inserting ${newItemsBooksPodcasts[model].length} ${model} rows`)\n    await DatabaseModels[model].bulkCreate(newItemsBooksPodcasts[model])\n  }\n}\n\n/**\n * Migrate authors, series then library items in chunks\n * Authors and series require old library items loaded first\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) {\n  const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')\n  await handleMigrateAuthors(DatabaseModels, oldLibraryItems)\n\n  await handleMigrateSeries(DatabaseModels, oldLibraryItems)\n\n  // Migrate library items in chunks of 1000\n  const numChunks = Math.ceil(oldLibraryItems.length / 1000)\n  for (let i = 0; i < numChunks; i++) {\n    let start = i * 1000\n    await handleMigrateLibraryItems(DatabaseModels, oldLibraryItems.slice(start, start + 1000))\n  }\n}\n\n/**\n * Load old users and bulkCreate new User rows\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateUsers(DatabaseModels) {\n  const oldUsers = await oldDbFiles.loadOldData('users')\n  const newUserRecords = migrateUsers(oldUsers)\n  for (const model in newUserRecords) {\n    Logger.info(`[dbMigration] Inserting ${newUserRecords[model].length} ${model} rows`)\n    await DatabaseModels[model].bulkCreate(newUserRecords[model])\n  }\n}\n\n/**\n * Load old sessions and bulkCreate new PlaybackSession & Device rows\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateSessions(DatabaseModels) {\n  const oldSessions = await oldDbFiles.loadOldData('sessions')\n\n  let chunkSize = 1000\n  let numChunks = Math.ceil(oldSessions.length / chunkSize)\n\n  for (let i = 0; i < numChunks; i++) {\n    let start = i * chunkSize\n    const newSessionRecords = migrateSessions(oldSessions.slice(start, start + chunkSize))\n    for (const model in newSessionRecords) {\n      Logger.info(`[dbMigration] Inserting ${newSessionRecords[model].length} ${model} rows`)\n      await DatabaseModels[model].bulkCreate(newSessionRecords[model])\n    }\n  }\n}\n\n/**\n * Load old collections and bulkCreate new Collection, CollectionBook models\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateCollections(DatabaseModels) {\n  const oldCollections = await oldDbFiles.loadOldData('collections')\n  const newCollectionRecords = migrateCollections(oldCollections)\n  for (const model in newCollectionRecords) {\n    Logger.info(`[dbMigration] Inserting ${newCollectionRecords[model].length} ${model} rows`)\n    await DatabaseModels[model].bulkCreate(newCollectionRecords[model])\n  }\n}\n\n/**\n * Load old playlists and bulkCreate new Playlist, PlaylistMediaItem models\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigratePlaylists(DatabaseModels) {\n  const oldPlaylists = await oldDbFiles.loadOldData('playlists')\n  const newPlaylistRecords = migratePlaylists(oldPlaylists)\n  for (const model in newPlaylistRecords) {\n    Logger.info(`[dbMigration] Inserting ${newPlaylistRecords[model].length} ${model} rows`)\n    await DatabaseModels[model].bulkCreate(newPlaylistRecords[model])\n  }\n}\n\n/**\n * Load old feeds and bulkCreate new Feed, FeedEpisode models\n * @param {Map<string,Model>} DatabaseModels\n */\nasync function handleMigrateFeeds(DatabaseModels) {\n  const oldFeeds = await oldDbFiles.loadOldData('feeds')\n  const newFeedRecords = migrateFeeds(oldFeeds)\n  for (const model in newFeedRecords) {\n    Logger.info(`[dbMigration] Inserting ${newFeedRecords[model].length} ${model} rows`)\n    await DatabaseModels[model].bulkCreate(newFeedRecords[model])\n  }\n}\n\nmodule.exports.migrate = async (DatabaseModels) => {\n  Logger.info(`[dbMigration] Starting migration`)\n\n  const start = Date.now()\n\n  // Migrate to Library and LibraryFolder models\n  await handleMigrateLibraries(DatabaseModels)\n\n  // Migrate EmailSettings, NotificationSettings and ServerSettings to Setting model\n  await handleMigrateSettings(DatabaseModels)\n\n  // Migrate Series, Author, LibraryItem, Book, Podcast\n  await handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels)\n\n  // Migrate User, MediaProgress\n  await handleMigrateUsers(DatabaseModels)\n\n  // Migrate PlaybackSession, Device\n  await handleMigrateSessions(DatabaseModels)\n\n  // Migrate Collection, CollectionBook\n  await handleMigrateCollections(DatabaseModels)\n\n  // Migrate Playlist, PlaylistMediaItem\n  await handleMigratePlaylists(DatabaseModels)\n\n  // Migrate Feed, FeedEpisode\n  await handleMigrateFeeds(DatabaseModels)\n\n  // Purge author images and cover images from cache\n  try {\n    const CachePath = Path.join(global.MetadataPath, 'cache')\n    await fs.emptyDir(Path.join(CachePath, 'covers'))\n    await fs.emptyDir(Path.join(CachePath, 'images'))\n  } catch (error) {\n    Logger.error(`[dbMigration] Failed to purge author/cover image cache`, error)\n  }\n\n  // Put all old db folders into a zipfile oldDb.zip\n  await oldDbFiles.zipWrapOldDb()\n\n  const elapsed = Date.now() - start\n  Logger.info(`[dbMigration] Migration complete. Elapsed ${(elapsed / 1000).toFixed(2)}s`)\n}\n\n/**\n * @returns {boolean} true if old database exists\n */\nmodule.exports.checkShouldMigrate = async () => {\n  if (await oldDbFiles.checkHasOldDb()) return true\n  return oldDbFiles.checkHasOldDbZip()\n}\n\n/**\n * Migration from 2.3.0 to 2.3.1 - create extraData columns in LibraryItem and PodcastEpisode\n * @param {QueryInterface} queryInterface\n */\nasync function migrationPatchNewColumns(queryInterface) {\n  try {\n    return queryInterface.sequelize.transaction((t) => {\n      return Promise.all([\n        queryInterface.addColumn(\n          'libraryItems',\n          'extraData',\n          {\n            type: DataTypes.JSON\n          },\n          { transaction: t }\n        ),\n        queryInterface.addColumn(\n          'podcastEpisodes',\n          'extraData',\n          {\n            type: DataTypes.JSON\n          },\n          { transaction: t }\n        ),\n        queryInterface.addColumn(\n          'libraries',\n          'extraData',\n          {\n            type: DataTypes.JSON\n          },\n          { transaction: t }\n        )\n      ])\n    })\n  } catch (error) {\n    Logger.error(`[dbMigration] Migration from 2.3.0+ column creation failed`, error)\n    return false\n  }\n}\n\n/**\n * Migration from 2.3.0 to 2.3.1 - old library item ids\n * @param {/src/Database} ctx\n */\nasync function handleOldLibraryItems(ctx) {\n  const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')\n  const libraryItems = await ctx.models.libraryItem.findAllExpandedWhere()\n\n  const bulkUpdateItems = []\n  const bulkUpdateEpisodes = []\n\n  for (const libraryItem of libraryItems) {\n    // Find matching old library item by ino\n    const matchingOldLibraryItem = oldLibraryItems.find((oli) => oli.ino === libraryItem.ino)\n    if (matchingOldLibraryItem) {\n      oldDbIdMap.libraryItems[matchingOldLibraryItem.id] = libraryItem.id\n\n      bulkUpdateItems.push({\n        id: libraryItem.id,\n        extraData: {\n          oldLibraryItemId: matchingOldLibraryItem.id\n        }\n      })\n\n      if (libraryItem.media.podcastEpisodes?.length && matchingOldLibraryItem.media.episodes?.length) {\n        for (const podcastEpisode of libraryItem.media.podcastEpisodes) {\n          // Find matching old episode by audio file ino\n          const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)\n          if (matchingOldPodcastEpisode) {\n            oldDbIdMap.podcastEpisodes[matchingOldPodcastEpisode.id] = podcastEpisode.id\n\n            bulkUpdateEpisodes.push({\n              id: podcastEpisode.id,\n              extraData: {\n                oldEpisodeId: matchingOldPodcastEpisode.id\n              }\n            })\n          }\n        }\n      }\n    }\n  }\n\n  if (bulkUpdateEpisodes.length) {\n    await ctx.models.podcastEpisode.bulkCreate(bulkUpdateEpisodes, {\n      updateOnDuplicate: ['extraData']\n    })\n  }\n\n  if (bulkUpdateItems.length) {\n    await ctx.models.libraryItem.bulkCreate(bulkUpdateItems, {\n      updateOnDuplicate: ['extraData']\n    })\n  }\n\n  Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${bulkUpdateItems.length} library items & ${bulkUpdateEpisodes.length} episodes`)\n}\n\n/**\n * Migration from 2.3.0 to 2.3.1 - updating oldLibraryId\n * @param {/src/Database} ctx\n */\nasync function handleOldLibraries(ctx) {\n  const oldLibraries = await oldDbFiles.loadOldData('libraries')\n  const libraries = await ctx.models.library.getAllWithFolders()\n\n  let librariesUpdated = 0\n  for (const library of libraries) {\n    // Find matching old library using exact match on folder paths, exact match on library name\n    const matchingOldLibrary = oldLibraries.find((ol) => {\n      if (ol.name !== library.name) {\n        return false\n      }\n      const folderPaths = ol.folders?.map((f) => f.fullPath) || []\n      return folderPaths.join(',') === library.libraryFolders.map((f) => f.path).join(',')\n    })\n\n    if (matchingOldLibrary) {\n      const newExtraData = library.extraData || {}\n      newExtraData.oldLibraryId = matchingOldLibrary.id\n      library.extraData = newExtraData\n      library.changed('extraData', true)\n\n      oldDbIdMap.libraries[library.oldLibraryId] = library.id\n      await library.save()\n      librariesUpdated++\n    }\n  }\n  Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${librariesUpdated} libraries`)\n}\n\n/**\n * Migration from 2.3.0 to 2.3.1 - fixing librariesAccessible and bookmarks\n * @param {import('../../Database')} ctx\n */\nasync function handleOldUsers(ctx) {\n  const usersNew = await ctx.userModel.findAll({\n    include: ctx.models.mediaProgress\n  })\n\n  let usersUpdated = 0\n  for (const user of usersNew) {\n    let hasUpdates = false\n    if (user.bookmarks?.length) {\n      user.bookmarks = user.bookmarks\n        .map((bm) => {\n          // Only update if this is not the old id format\n          if (!bm.libraryItemId.startsWith('li_')) return bm\n\n          bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]\n          hasUpdates = true\n          return bm\n        })\n        .filter((bm) => bm.libraryItemId)\n      if (hasUpdates) {\n        user.changed('bookmarks', true)\n      }\n    }\n\n    const librariesAccessible = user.permissions?.librariesAccessible || []\n\n    // Convert old library ids to new library ids\n    if (librariesAccessible.length) {\n      user.permissions.librariesAccessible = librariesAccessible\n        .map((lid) => {\n          if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change\n          hasUpdates = true\n          return oldDbIdMap.libraries[lid]\n        })\n        .filter((lid) => lid)\n      if (hasUpdates) {\n        user.changed('permissions', true)\n      }\n    }\n\n    const seriesHideFromContinueListening = user.extraData?.seriesHideFromContinueListening || []\n\n    if (seriesHideFromContinueListening.length) {\n      user.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening\n        .map((seriesId) => {\n          if (seriesId.startsWith('se_')) {\n            hasUpdates = true\n            return null // Filter out old series ids\n          }\n          return seriesId\n        })\n        .filter((se) => se)\n      if (hasUpdates) {\n        user.changed('extraData', true)\n      }\n    }\n\n    if (hasUpdates) {\n      await user.save()\n      usersUpdated++\n    }\n  }\n  Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${usersUpdated} users`)\n}\n\n/**\n * Migration from 2.3.0 to 2.3.1\n * @param {/src/Database} ctx\n */\nmodule.exports.migrationPatch = async (ctx) => {\n  const queryInterface = ctx.sequelize.getQueryInterface()\n  const librariesTableDescription = await queryInterface.describeTable('libraries')\n\n  if (librariesTableDescription?.extraData) {\n    Logger.info(`[dbMigration] Migration patch 2.3.0+ - extraData columns already on model`)\n  } else {\n    const migrationResult = await migrationPatchNewColumns(queryInterface)\n    if (migrationResult === false) {\n      return\n    }\n  }\n\n  const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')\n  if (!(await fs.pathExists(oldDbPath))) {\n    Logger.info(`[dbMigration] Migration patch 2.3.0+ unnecessary - no oldDb.zip found`)\n    return\n  }\n\n  const migrationStart = Date.now()\n  Logger.info(`[dbMigration] Applying migration patch from 2.3.0+`)\n\n  // Extract from oldDb.zip\n  if (!(await oldDbFiles.checkExtractItemsUsersAndLibraries())) {\n    return\n  }\n\n  await handleOldLibraryItems(ctx)\n  await handleOldLibraries(ctx)\n  await handleOldUsers(ctx)\n\n  await oldDbFiles.removeOldItemsUsersAndLibrariesFolders()\n\n  const elapsed = Date.now() - migrationStart\n  Logger.info(`[dbMigration] Migration patch 2.3.0+ finished. Elapsed ${(elapsed / 1000).toFixed(2)}s`)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the size column on libraryItem\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2LibraryItems(ctx, offset = 0) {\n  const libraryItems = await ctx.models.libraryItem.findAll({\n    limit: 500,\n    offset\n  })\n  if (!libraryItems.length) return\n\n  const bulkUpdateItems = []\n  for (const libraryItem of libraryItems) {\n    if (libraryItem.libraryFiles?.length) {\n      let size = 0\n      libraryItem.libraryFiles.forEach((lf) => {\n        if (!isNaN(lf.metadata?.size)) {\n          size += Number(lf.metadata.size)\n        }\n      })\n      bulkUpdateItems.push({\n        id: libraryItem.id,\n        size\n      })\n    }\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} library items`)\n    await ctx.models.libraryItem.bulkCreate(bulkUpdateItems, {\n      updateOnDuplicate: ['size']\n    })\n  }\n\n  if (libraryItems.length < 500) {\n    return\n  }\n  return migrationPatch2LibraryItems(ctx, offset + libraryItems.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the duration & titleIgnorePrefix column on book\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2Books(ctx, offset = 0) {\n  const books = await ctx.models.book.findAll({\n    limit: 500,\n    offset\n  })\n  if (!books.length) return\n\n  const bulkUpdateItems = []\n  for (const book of books) {\n    let duration = 0\n\n    if (book.audioFiles?.length) {\n      const tracks = book.audioFiles.filter((af) => !af.exclude && !af.invalid)\n      for (const track of tracks) {\n        if (track.duration !== null && !isNaN(track.duration)) {\n          duration += track.duration\n        }\n      }\n    }\n\n    bulkUpdateItems.push({\n      id: book.id,\n      titleIgnorePrefix: getTitleIgnorePrefix(book.title),\n      duration\n    })\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} books`)\n    await ctx.models.book.bulkCreate(bulkUpdateItems, {\n      updateOnDuplicate: ['duration', 'titleIgnorePrefix']\n    })\n  }\n\n  if (books.length < 500) {\n    return\n  }\n  return migrationPatch2Books(ctx, offset + books.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the titleIgnorePrefix column on podcast\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2Podcasts(ctx, offset = 0) {\n  const podcasts = await ctx.models.podcast.findAll({\n    limit: 500,\n    offset\n  })\n  if (!podcasts.length) return\n\n  const bulkUpdateItems = []\n  for (const podcast of podcasts) {\n    bulkUpdateItems.push({\n      id: podcast.id,\n      titleIgnorePrefix: getTitleIgnorePrefix(podcast.title)\n    })\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} podcasts`)\n    await ctx.models.podcast.bulkCreate(bulkUpdateItems, {\n      updateOnDuplicate: ['titleIgnorePrefix']\n    })\n  }\n\n  if (podcasts.length < 500) {\n    return\n  }\n  return migrationPatch2Podcasts(ctx, offset + podcasts.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the nameIgnorePrefix column on series\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2Series(ctx, offset = 0) {\n  const allSeries = await ctx.models.series.findAll({\n    limit: 500,\n    offset\n  })\n  if (!allSeries.length) return\n\n  const bulkUpdateItems = []\n  for (const series of allSeries) {\n    bulkUpdateItems.push({\n      id: series.id,\n      nameIgnorePrefix: getTitleIgnorePrefix(series.name)\n    })\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} series`)\n    await ctx.models.series.bulkCreate(bulkUpdateItems, {\n      updateOnDuplicate: ['nameIgnorePrefix']\n    })\n  }\n\n  if (allSeries.length < 500) {\n    return\n  }\n  return migrationPatch2Series(ctx, offset + allSeries.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the lastFirst column on author\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2Authors(ctx, offset = 0) {\n  const authors = await ctx.models.author.findAll({\n    limit: 500,\n    offset\n  })\n  if (!authors.length) return\n\n  const bulkUpdateItems = []\n  for (const author of authors) {\n    if (author.name?.trim()) {\n      bulkUpdateItems.push({\n        id: author.id,\n        lastFirst: parseNameString.nameToLastFirst(author.name)\n      })\n    }\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} authors`)\n    await ctx.models.author.bulkCreate(bulkUpdateItems, {\n      updateOnDuplicate: ['lastFirst']\n    })\n  }\n\n  if (authors.length < 500) {\n    return\n  }\n  return migrationPatch2Authors(ctx, offset + authors.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the createdAt column on bookAuthor\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2BookAuthors(ctx, offset = 0) {\n  const bookAuthors = await ctx.models.bookAuthor.findAll({\n    include: {\n      model: ctx.models.author\n    },\n    limit: 500,\n    offset\n  })\n  if (!bookAuthors.length) return\n\n  const bulkUpdateItems = []\n  for (const bookAuthor of bookAuthors) {\n    if (bookAuthor.author?.createdAt) {\n      const dateString = bookAuthor.author.createdAt.toISOString().replace('T', ' ').replace('Z', '')\n      bulkUpdateItems.push(`(\"${bookAuthor.id}\",\"${dateString}\")`)\n    }\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} bookAuthors`)\n    await ctx.sequelize.query(`INSERT INTO bookAuthors ('id','createdAt') VALUES ${bulkUpdateItems.join(',')} ON CONFLICT(id) DO UPDATE SET 'createdAt' = EXCLUDED.createdAt;`)\n  }\n\n  if (bookAuthors.length < 500) {\n    return\n  }\n  return migrationPatch2BookAuthors(ctx, offset + bookAuthors.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Populating the createdAt column on bookSeries\n * @param {/src/Database} ctx\n * @param {number} offset\n */\nasync function migrationPatch2BookSeries(ctx, offset = 0) {\n  const allBookSeries = await ctx.models.bookSeries.findAll({\n    include: {\n      model: ctx.models.series\n    },\n    limit: 500,\n    offset\n  })\n  if (!allBookSeries.length) return\n\n  const bulkUpdateItems = []\n  for (const bookSeries of allBookSeries) {\n    if (bookSeries.series?.createdAt) {\n      const dateString = bookSeries.series.createdAt.toISOString().replace('T', ' ').replace('Z', '')\n      bulkUpdateItems.push(`(\"${bookSeries.id}\",\"${dateString}\")`)\n    }\n  }\n\n  if (bulkUpdateItems.length) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - patching ${bulkUpdateItems.length} bookSeries`)\n    await ctx.sequelize.query(`INSERT INTO bookSeries ('id','createdAt') VALUES ${bulkUpdateItems.join(',')} ON CONFLICT(id) DO UPDATE SET 'createdAt' = EXCLUDED.createdAt;`)\n  }\n\n  if (allBookSeries.length < 500) {\n    return\n  }\n  return migrationPatch2BookSeries(ctx, offset + allBookSeries.length)\n}\n\n/**\n * Migration from 2.3.3 to 2.3.4\n * Adding coverPath column to Feed model\n * @param {/src/Database} ctx\n */\nmodule.exports.migrationPatch2 = async (ctx) => {\n  const queryInterface = ctx.sequelize.getQueryInterface()\n  const feedTableDescription = await queryInterface.describeTable('feeds')\n  const authorsTableDescription = await queryInterface.describeTable('authors')\n  const bookAuthorsTableDescription = await queryInterface.describeTable('bookAuthors')\n\n  if (feedTableDescription?.coverPath && authorsTableDescription?.lastFirst && bookAuthorsTableDescription?.createdAt) {\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ - columns already on model`)\n    return false\n  }\n  Logger.info(`[dbMigration] Applying migration patch from 2.3.3+`)\n\n  try {\n    await queryInterface.sequelize.transaction((t) => {\n      const queries = []\n      if (!bookAuthorsTableDescription?.createdAt) {\n        queries.push(\n          ...[\n            queryInterface.addColumn(\n              'bookAuthors',\n              'createdAt',\n              {\n                type: DataTypes.DATE\n              },\n              { transaction: t }\n            ),\n            queryInterface.addColumn(\n              'bookSeries',\n              'createdAt',\n              {\n                type: DataTypes.DATE\n              },\n              { transaction: t }\n            )\n          ]\n        )\n      }\n      if (!authorsTableDescription?.lastFirst) {\n        queries.push(\n          ...[\n            queryInterface.addColumn(\n              'authors',\n              'lastFirst',\n              {\n                type: DataTypes.STRING\n              },\n              { transaction: t }\n            ),\n            queryInterface.addColumn(\n              'libraryItems',\n              'size',\n              {\n                type: DataTypes.BIGINT\n              },\n              { transaction: t }\n            ),\n            queryInterface.addColumn(\n              'books',\n              'duration',\n              {\n                type: DataTypes.FLOAT\n              },\n              { transaction: t }\n            ),\n            queryInterface.addColumn(\n              'books',\n              'titleIgnorePrefix',\n              {\n                type: DataTypes.STRING\n              },\n              { transaction: t }\n            ),\n            queryInterface.addColumn(\n              'podcasts',\n              'titleIgnorePrefix',\n              {\n                type: DataTypes.STRING\n              },\n              { transaction: t }\n            ),\n            queryInterface.addColumn(\n              'series',\n              'nameIgnorePrefix',\n              {\n                type: DataTypes.STRING\n              },\n              { transaction: t }\n            )\n          ]\n        )\n      }\n      if (!feedTableDescription?.coverPath) {\n        queries.push(\n          queryInterface.addColumn(\n            'feeds',\n            'coverPath',\n            {\n              type: DataTypes.STRING\n            },\n            { transaction: t }\n          )\n        )\n      }\n      return Promise.all(queries)\n    })\n\n    if (!authorsTableDescription?.lastFirst) {\n      if (global.ServerSettings.sortingPrefixes?.length) {\n        prefixesToIgnore = global.ServerSettings.sortingPrefixes\n      }\n\n      // Patch library items size column\n      await migrationPatch2LibraryItems(ctx, 0)\n\n      // Patch books duration & titleIgnorePrefix column\n      await migrationPatch2Books(ctx, 0)\n\n      // Patch podcasts titleIgnorePrefix column\n      await migrationPatch2Podcasts(ctx, 0)\n\n      // Patch authors lastFirst column\n      await migrationPatch2Authors(ctx, 0)\n\n      // Patch series nameIgnorePrefix column\n      await migrationPatch2Series(ctx, 0)\n    }\n\n    if (!bookAuthorsTableDescription?.createdAt) {\n      // Patch bookAuthors createdAt column\n      await migrationPatch2BookAuthors(ctx, 0)\n\n      // Patch bookSeries createdAt column\n      await migrationPatch2BookSeries(ctx, 0)\n    }\n\n    Logger.info(`[dbMigration] Migration patch 2.3.3+ finished`)\n    return true\n  } catch (error) {\n    Logger.error(`[dbMigration] Migration from 2.3.3+ column creation failed`, error)\n    throw new Error('Migration 2.3.3+ failed ' + error)\n  }\n}\n"
  },
  {
    "path": "server/utils/migrations/oldDbFiles.js",
    "content": "const { once } = require('events')\nconst { createInterface } = require('readline')\nconst Path = require('path')\nconst Logger = require('../../Logger')\nconst fs = require('../../libs/fsExtra')\nconst archiver = require('../../libs/archiver')\nconst StreamZip = require('../../libs/nodeStreamZip')\n\nasync function processDbFile(filepath) {\n  if (!fs.pathExistsSync(filepath)) {\n    Logger.error(`[oldDbFiles] Db file does not exist at \"${filepath}\"`)\n    return []\n  }\n\n  const entities = []\n\n  try {\n    const fileStream = fs.createReadStream(filepath)\n\n    const rl = createInterface({\n      input: fileStream,\n      crlfDelay: Infinity,\n    })\n\n    rl.on('line', (line) => {\n      if (line && line.trim()) {\n        try {\n          const entity = JSON.parse(line)\n          if (entity && Object.keys(entity).length) entities.push(entity)\n        } catch (jsonParseError) {\n          Logger.error(`[oldDbFiles] Failed to parse line \"${line}\" in db file \"${filepath}\"`, jsonParseError)\n        }\n      }\n    })\n\n    await once(rl, 'close')\n\n    console.log(`[oldDbFiles] Db file \"${filepath}\" processed`)\n\n    return entities\n  } catch (error) {\n    Logger.error(`[oldDbFiles] Failed to read db file \"${filepath}\"`, error)\n    return []\n  }\n}\n\nasync function loadDbData(dbpath) {\n  try {\n    Logger.info(`[oldDbFiles] Loading db data at \"${dbpath}\"`)\n    const files = await fs.readdir(dbpath)\n\n    const entities = []\n    for (const filename of files) {\n      if (Path.extname(filename).toLowerCase() !== '.json') {\n        Logger.warn(`[oldDbFiles] Ignoring filename \"${filename}\" in db folder \"${dbpath}\"`)\n        continue\n      }\n\n      const filepath = Path.join(dbpath, filename)\n      Logger.info(`[oldDbFiles] Loading db data file \"${filepath}\"`)\n      const someEntities = await processDbFile(filepath)\n      Logger.info(`[oldDbFiles] Processed db data file with ${someEntities.length} entities`)\n      entities.push(...someEntities)\n    }\n\n    Logger.info(`[oldDbFiles] Finished loading db data with ${entities.length} entities`)\n    return entities\n  } catch (error) {\n    Logger.error(`[oldDbFiles] Failed to load db data \"${dbpath}\"`, error)\n    return null\n  }\n}\n\nmodule.exports.loadOldData = async (dbName) => {\n  const dbPath = Path.join(global.ConfigPath, dbName, 'data')\n  const dbData = await loadDbData(dbPath) || []\n  Logger.info(`[oldDbFiles] ${dbData.length} ${dbName} loaded`)\n  return dbData\n}\n\nmodule.exports.zipWrapOldDb = async () => {\n  const dbs = {\n    libraryItems: Path.join(global.ConfigPath, 'libraryItems'),\n    users: Path.join(global.ConfigPath, 'users'),\n    sessions: Path.join(global.ConfigPath, 'sessions'),\n    libraries: Path.join(global.ConfigPath, 'libraries'),\n    settings: Path.join(global.ConfigPath, 'settings'),\n    collections: Path.join(global.ConfigPath, 'collections'),\n    playlists: Path.join(global.ConfigPath, 'playlists'),\n    authors: Path.join(global.ConfigPath, 'authors'),\n    series: Path.join(global.ConfigPath, 'series'),\n    feeds: Path.join(global.ConfigPath, 'feeds')\n  }\n\n  return new Promise((resolve) => {\n    const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')\n    const output = fs.createWriteStream(oldDbPath)\n    const archive = archiver('zip', {\n      zlib: { level: 9 } // Sets the compression level.\n    })\n\n    // listen for all archive data to be written\n    // 'close' event is fired only when a file descriptor is involved\n    output.on('close', async () => {\n      Logger.info(`[oldDbFiles] Old db files have been zipped in ${oldDbPath}. ${archive.pointer()} total bytes`)\n\n      // Remove old db folders have successful zip\n      for (const db in dbs) {\n        await fs.remove(dbs[db])\n      }\n\n      resolve(true)\n    })\n\n    // This event is fired when the data source is drained no matter what was the data source.\n    // It is not part of this library but rather from the NodeJS Stream API.\n    // @see: https://nodejs.org/api/stream.html#stream_event_end\n    output.on('end', () => {\n      Logger.debug('[oldDbFiles] Data has been drained')\n    })\n\n    // good practice to catch this error explicitly\n    archive.on('error', (err) => {\n      Logger.error(`[oldDbFiles] Failed to zip old db folders`, err)\n      resolve(false)\n    })\n\n    // pipe archive data to the file\n    archive.pipe(output)\n\n    for (const db in dbs) {\n      archive.directory(dbs[db], db)\n    }\n\n    // finalize the archive (ie we are done appending files but streams have to finish yet)\n    // 'close', 'end' or 'finish' may be fired right after calling this method so register to them beforehand\n    archive.finalize()\n  })\n}\n\nmodule.exports.checkHasOldDb = async () => {\n  const dbs = {\n    libraryItems: Path.join(global.ConfigPath, 'libraryItems'),\n    users: Path.join(global.ConfigPath, 'users'),\n    sessions: Path.join(global.ConfigPath, 'sessions'),\n    libraries: Path.join(global.ConfigPath, 'libraries'),\n    settings: Path.join(global.ConfigPath, 'settings'),\n    collections: Path.join(global.ConfigPath, 'collections'),\n    playlists: Path.join(global.ConfigPath, 'playlists'),\n    authors: Path.join(global.ConfigPath, 'authors'),\n    series: Path.join(global.ConfigPath, 'series'),\n    feeds: Path.join(global.ConfigPath, 'feeds')\n  }\n  for (const db in dbs) {\n    if (await fs.pathExists(dbs[db])) {\n      return true\n    }\n  }\n  return false\n}\n\nmodule.exports.checkHasOldDbZip = async () => {\n  const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')\n  if (!await fs.pathExists(oldDbPath)) {\n    return false\n  }\n\n  // Extract oldDb.zip\n  const zip = new StreamZip.async({ file: oldDbPath })\n  await zip.extract(null, global.ConfigPath)\n  await zip.close()\n\n  return this.checkHasOldDb()\n}\n\n/**\n * Used for migration from 2.3.0 -> 2.3.1\n * @returns {boolean} true if extracted\n */\nmodule.exports.checkExtractItemsUsersAndLibraries = async () => {\n  const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')\n\n  const zip = new StreamZip.async({ file: oldDbPath })\n  const libraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')\n  await zip.extract('libraryItems/', libraryItemsPath)\n\n  if (!await fs.pathExists(libraryItemsPath)) {\n    Logger.error(`[oldDbFiles] Failed to extract old libraryItems from oldDb.zip`)\n    return false\n  }\n\n  const usersPath = Path.join(global.ConfigPath, 'users')\n  await zip.extract('users/', usersPath)\n\n  if (!await fs.pathExists(usersPath)) {\n    Logger.error(`[oldDbFiles] Failed to extract old users from oldDb.zip`)\n    await fs.remove(libraryItemsPath) // Remove old library items folder\n    return false\n  }\n\n  const librariesPath = Path.join(global.ConfigPath, 'libraries')\n  await zip.extract('libraries/', librariesPath)\n\n  if (!await fs.pathExists(librariesPath)) {\n    Logger.error(`[oldDbFiles] Failed to extract old libraries from oldDb.zip`)\n    await fs.remove(usersPath) // Remove old users folder\n    await fs.remove(libraryItemsPath) // Remove old library items folder\n    return false\n  }\n\n  await zip.close()\n\n  return true\n}\n\n/**\n * Used for migration from 2.3.0 -> 2.3.1\n */\nmodule.exports.removeOldItemsUsersAndLibrariesFolders = async () => {\n  const libraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')\n  const usersPath = Path.join(global.ConfigPath, 'users')\n  const librariesPath = Path.join(global.ConfigPath, 'libraries')\n  await fs.remove(libraryItemsPath)\n  await fs.remove(usersPath)\n  await fs.remove(librariesPath)\n}"
  },
  {
    "path": "server/utils/notifications.js",
    "content": "const { version } = require('../../package.json')\n\nmodule.exports.notificationData = {\n  events: [\n    {\n      name: 'onPodcastEpisodeDownloaded',\n      requiresLibrary: true,\n      libraryMediaType: 'podcast',\n      description: 'Triggered when a podcast episode is auto-downloaded',\n      descriptionKey: 'NotificationOnEpisodeDownloadedDescription',\n      variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'podcastAuthor', 'podcastDescription', 'podcastGenres', 'episodeTitle', 'episodeSubtitle', 'episodeDescription', 'libraryName', 'episodeId', 'mediaTags'],\n      defaults: {\n        title: 'New {{podcastTitle}} Episode!',\n        body: '{{episodeTitle}} has been added to {{libraryName}} library.'\n      },\n      testData: {\n        libraryItemId: 'li_notification_test',\n        libraryId: 'lib_test',\n        libraryName: 'Podcasts',\n        mediaTags: 'TestTag1, TestTag2',\n        podcastTitle: 'Abs Test Podcast',\n        podcastAuthor: 'Audiobookshelf',\n        podcastDescription: 'Description of the Abs Test Podcast belongs here.',\n        podcastGenres: 'TestGenre1, TestGenre2',\n        episodeId: 'ep_notification_test',\n        episodeTitle: 'Successful Test Episode',\n        episodeSubtitle: 'Episode Subtitle',\n        episodeDescription: 'Some description of the podcast episode.'\n      }\n    },\n    {\n      name: 'onBackupCompleted',\n      requiresLibrary: false,\n      description: 'Triggered when a backup is completed',\n      descriptionKey: 'NotificationOnBackupCompletedDescription',\n      variables: ['completionTime', 'backupPath', 'backupSize', 'backupCount', 'removedOldest'],\n      defaults: {\n        title: 'Backup Completed',\n        body: 'Backup has been completed successfully.\\n\\nPath: {{backupPath}}\\nSize: {{backupSize}} bytes\\nCount: {{backupCount}}\\nRemoved Oldest: {{removedOldest}}'\n      },\n      testData: {\n        completionTime: '12:00 AM',\n        backupPath: 'path/to/backup',\n        backupSize: '1.23 MB',\n        backupCount: '1',\n        removedOldest: 'false'\n      }\n    },\n    {\n      name: 'onBackupFailed',\n      requiresLibrary: false,\n      description: 'Triggered when a backup fails',\n      descriptionKey: 'NotificationOnBackupFailedDescription',\n      variables: ['errorMsg'],\n      defaults: {\n        title: 'Backup Failed',\n        body: 'Backup failed, check ABS logs for more information.\\nError message: {{errorMsg}}'\n      },\n      testData: {\n        errorMsg: 'Example error message'\n      }\n    },\n    {\n      name: 'onRSSFeedFailed',\n      requiresLibrary: true,\n      description: 'Triggered when the RSS feed request fails for an automatic episode download',\n      descriptionKey: 'NotificationOnRSSFeedFailedDescription',\n      variables: ['feedUrl', 'numFailed', 'title'],\n      defaults: {\n        title: 'RSS Feed Request Failed',\n        body: 'Failed to request RSS feed for {{title}}.\\nFeed URL: {{feedUrl}}\\nNumber of failed attempts: {{numFailed}}'\n      },\n      testData: {\n        title: 'Test RSS Feed',\n        feedUrl: 'https://example.com/rss',\n        numFailed: 3\n      }\n    },\n    {\n      name: 'onRSSFeedDisabled',\n      requiresLibrary: true,\n      description: 'Triggered when automatic episode downloads are disabled due to too many failed attempts',\n      descriptionKey: 'NotificationOnRSSFeedDisabledDescription',\n      variables: ['feedUrl', 'numFailed', 'title'],\n      defaults: {\n        title: 'Podcast Episode Download Schedule Disabled',\n        body: 'Automatic episode downloads for {{title}} have been disabled due to too many failed RSS feed requests.\\nFeed URL: {{feedUrl}}\\nNumber of failed attempts: {{numFailed}}'\n      },\n      testData: {\n        title: 'Test RSS Feed',\n        feedUrl: 'https://example.com/rss',\n        numFailed: 5\n      }\n    },\n    {\n      name: 'onTest',\n      requiresLibrary: false,\n      description: 'Event for testing the notification system',\n      descriptionKey: 'NotificationOnTestDescription',\n      variables: ['version'],\n      defaults: {\n        title: 'Test Notification on Abs {{version}}',\n        body: 'Test notificataion body for abs {{version}}.'\n      },\n      testData: {\n        version: 'v' + version\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "server/utils/parsers/parseComicInfoMetadata.js",
    "content": "\n/**\n * TODO: Add more fields\n * @see https://anansi-project.github.io/docs/comicinfo/intro\n * \n * @param {Object} comicInfoJson \n * @returns {import('../../scanner/BookScanner').BookMetadataObject}\n */\nmodule.exports.parse = (comicInfoJson) => {\n  if (!comicInfoJson?.ComicInfo) return null\n\n  const ComicSeries = comicInfoJson.ComicInfo.Series?.[0]?.trim() || null\n  const ComicNumber = comicInfoJson.ComicInfo.Number?.[0]?.trim() || null\n  const ComicSummary = comicInfoJson.ComicInfo.Summary?.[0]?.trim() || null\n\n  let title = null\n  const series = []\n  if (ComicSeries) {\n    series.push({\n      name: ComicSeries,\n      sequence: ComicNumber\n    })\n\n    title = ComicSeries\n    if (ComicNumber) {\n      title += ` ${ComicNumber}`\n    }\n  }\n\n  return {\n    title,\n    series,\n    description: ComicSummary\n  }\n}"
  },
  {
    "path": "server/utils/parsers/parseComicMetadata.js",
    "content": "const Path = require('path')\nconst Logger = require('../../Logger')\nconst parseComicInfoMetadata = require('./parseComicInfoMetadata')\nconst globals = require('../globals')\nconst { xmlToJSON } = require('../index')\nconst { createComicBookExtractor } = require('../comicBookExtractors.js')\n\n/**\n * Extract cover image from comic return true if success\n *\n * @param {string} comicPath\n * @param {string} comicImageFilepath\n * @param {string} outputCoverPath\n * @returns {Promise<boolean>}\n */\nasync function extractCoverImage(comicPath, comicImageFilepath, outputCoverPath) {\n  let archive = null\n  try {\n    archive = createComicBookExtractor(comicPath)\n    await archive.open()\n    return await archive.extractToFile(comicImageFilepath, outputCoverPath)\n  } catch (error) {\n    Logger.error(`[parseComicMetadata] Failed to extract image \"${comicImageFilepath}\" from comicPath \"${comicPath}\" into \"${outputCoverPath}\"`, error)\n    return false\n  } finally {\n    // Ensure we free the memory\n    archive?.close()\n  }\n}\nmodule.exports.extractCoverImage = extractCoverImage\n\n/**\n * Parse metadata from comic\n *\n * @param {import('../../models/Book').EBookFileObject} ebookFile\n * @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}\n */\nasync function parse(ebookFile) {\n  const comicPath = ebookFile.metadata.path\n  Logger.debug(`[parseComicMetadata] Parsing comic metadata at \"${comicPath}\"`)\n  let archive = null\n  try {\n    archive = createComicBookExtractor(comicPath)\n    await archive.open()\n\n    const filePaths = await archive.getFilePaths().catch((error) => {\n      Logger.error(`[parseComicMetadata] Failed to get file paths from comic at \"${comicPath}\"`, error)\n    })\n\n    // Sort the file paths in a natural order to get the first image\n    filePaths.sort((a, b) => {\n      return a.localeCompare(b, undefined, {\n        numeric: true,\n        sensitivity: 'base'\n      })\n    })\n\n    let metadata = null\n    const comicInfoPath = filePaths.find((filePath) => filePath === 'ComicInfo.xml')\n    if (comicInfoPath) {\n      const comicInfoData = await archive.extractToBuffer(comicInfoPath)\n      if (comicInfoData) {\n        const comicInfoStr = new TextDecoder().decode(comicInfoData)\n        const comicInfoJson = await xmlToJSON(comicInfoStr)\n        if (comicInfoJson) {\n          metadata = parseComicInfoMetadata.parse(comicInfoJson)\n        }\n      }\n    }\n\n    const payload = {\n      path: comicPath,\n      ebookFormat: ebookFile.ebookFormat,\n      metadata\n    }\n\n    const firstImagePath = filePaths.find((filePath) => globals.SupportedImageTypes.includes(Path.extname(filePath).toLowerCase().slice(1)))\n    if (firstImagePath) {\n      payload.ebookCoverPath = firstImagePath\n    } else {\n      Logger.warn(`[parseComicMetadata] Cover image not found in comic at \"${comicPath}\"`)\n    }\n\n    return payload\n  } catch (error) {\n    Logger.error(`[parseComicMetadata] Failed to parse comic metadata at \"${comicPath}\"`, error)\n    return null\n  } finally {\n    // Ensure we free the memory\n    archive?.close()\n  }\n}\nmodule.exports.parse = parse\n"
  },
  {
    "path": "server/utils/parsers/parseEbookMetadata.js",
    "content": "const parseEpubMetadata = require('./parseEpubMetadata')\nconst parseComicMetadata = require('./parseComicMetadata')\n\n/**\n * @typedef EBookFileScanData\n * @property {string} path\n * @property {string} ebookFormat\n * @property {string} ebookCoverPath internal image path\n * @property {import('../../scanner/BookScanner').BookMetadataObject} metadata\n */\n\n/**\n * Parse metadata from ebook file\n * \n * @param {import('../../models/Book').EBookFileObject} ebookFile \n * @returns {Promise<EBookFileScanData>}\n */\nasync function parse(ebookFile) {\n  if (!ebookFile) return null\n\n  if (ebookFile.ebookFormat === 'epub') {\n    return parseEpubMetadata.parse(ebookFile)\n  } else if (['cbz', 'cbr'].includes(ebookFile.ebookFormat)) {\n    return parseComicMetadata.parse(ebookFile)\n  }\n  return null\n}\nmodule.exports.parse = parse\n\n/**\n * Extract cover from ebook file\n * \n * @param {EBookFileScanData} ebookFileScanData \n * @param {string} outputCoverPath \n * @returns {Promise<boolean>}\n */\nasync function extractCoverImage(ebookFileScanData, outputCoverPath) {\n  if (!ebookFileScanData?.ebookCoverPath) return false\n\n  if (ebookFileScanData.ebookFormat === 'epub') {\n    return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)\n  } else if (['cbz', 'cbr'].includes(ebookFileScanData.ebookFormat)) {\n    return parseComicMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)\n  }\n  return false\n}\nmodule.exports.extractCoverImage = extractCoverImage"
  },
  {
    "path": "server/utils/parsers/parseEpubMetadata.js",
    "content": "const Path = require('path')\nconst Logger = require('../../Logger')\nconst StreamZip = require('../../libs/nodeStreamZip')\nconst parseOpfMetadata = require('./parseOpfMetadata')\nconst { xmlToJSON } = require('../index')\n\n/**\n * Extract file from epub and return string content\n *\n * @param {string} epubPath\n * @param {string} filepath\n * @returns {Promise<string>}\n */\nasync function extractFileFromEpub(epubPath, filepath) {\n  const zip = new StreamZip.async({ file: epubPath })\n  const data = await zip.entryData(filepath).catch((error) => {\n    Logger.error(`[parseEpubMetadata] Failed to extract ${filepath} from epub at \"${epubPath}\"`, error)\n  })\n  const filedata = data?.toString('utf8')\n  await zip.close().catch((error) => {\n    Logger.error(`[parseEpubMetadata] Failed to close zip`, error)\n  })\n\n  return filedata\n}\n\n/**\n * Extract an XML file from epub and return JSON\n *\n * @param {string} epubPath\n * @param {string} xmlFilepath\n * @returns {Promise<Object>}\n */\nasync function extractXmlToJson(epubPath, xmlFilepath) {\n  const filedata = await extractFileFromEpub(epubPath, xmlFilepath)\n  if (!filedata) return null\n  return xmlToJSON(filedata)\n}\n\n/**\n * Extract cover image from epub return true if success\n *\n * @param {string} epubPath\n * @param {string} epubImageFilepath\n * @param {string} outputCoverPath\n * @returns {Promise<boolean>}\n */\nasync function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {\n  const zip = new StreamZip.async({ file: epubPath })\n\n  const success = await zip\n    .extract(epubImageFilepath, outputCoverPath)\n    .then(() => true)\n    .catch((error) => {\n      Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at \"${epubPath}\"`, error)\n      return false\n    })\n\n  await zip.close().catch((error) => {\n    Logger.error(`[parseEpubMetadata] Failed to close zip`, error)\n  })\n\n  return success\n}\nmodule.exports.extractCoverImage = extractCoverImage\n\n/**\n * Parse metadata from epub\n *\n * @param {import('../../models/Book').EBookFileObject} ebookFile\n * @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}\n */\nasync function parse(ebookFile) {\n  const epubPath = ebookFile.metadata.path\n  Logger.debug(`Parsing metadata from epub at \"${epubPath}\"`)\n  // Entrypoint of the epub that contains the filepath to the package document (opf file)\n  const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml')\n  if (!containerJson) {\n    return null\n  }\n\n  // Get package document opf filepath from container.xml\n  const packageDocPath = containerJson.container?.rootfiles?.[0]?.rootfile?.[0]?.$?.['full-path']\n  if (!packageDocPath) {\n    Logger.error(`Failed to get package doc path in Container.xml`, JSON.stringify(containerJson, null, 2))\n    return null\n  }\n\n  // Extract package document to JSON\n  const packageJson = await extractXmlToJson(epubPath, packageDocPath)\n  if (!packageJson) {\n    return null\n  }\n\n  // Parse metadata from package document opf file\n  const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(structuredClone(packageJson))\n  if (!opfMetadata) {\n    Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))\n    return null\n  }\n\n  const payload = {\n    path: epubPath,\n    ebookFormat: 'epub',\n    metadata: opfMetadata\n  }\n\n  // Attempt to find filepath to cover image:\n  // Metadata may include <meta name=\"cover\" content=\"id\"/> where content is the id of the cover image in the manifest\n  //  Otherwise find image in the manifest with cover-image property set\n  //  As a fallback the first image in the manifest is used as the cover image\n  let packageMetadata = packageJson.package?.metadata\n  if (Array.isArray(packageMetadata)) {\n    packageMetadata = packageMetadata[0]\n  }\n  const metaCoverId = packageMetadata?.meta?.find?.((meta) => meta.$?.name === 'cover')?.$?.content\n\n  let manifestFirstImage = null\n  if (metaCoverId) {\n    manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.id === metaCoverId)\n  }\n  if (!manifestFirstImage) {\n    manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.['properties']?.split(' ')?.includes('cover-image'))\n  }\n  if (!manifestFirstImage) {\n    manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.['media-type']?.startsWith('image/'))\n  }\n\n  let coverImagePath = manifestFirstImage?.$?.href\n  if (coverImagePath) {\n    const packageDirname = Path.dirname(packageDocPath)\n    payload.ebookCoverPath = Path.posix.join(packageDirname, coverImagePath)\n  } else {\n    Logger.warn(`Cover image not found in manifest for epub at \"${epubPath}\"`)\n  }\n\n  return payload\n}\nmodule.exports.parse = parse\n"
  },
  {
    "path": "server/utils/parsers/parseFullName.js",
    "content": "\n\n// https://github.com/RateGravity/parse-full-name/blob/master/index.js\nmodule.exports = (nameToParse, partToReturn, fixCase, stopOnError, useLongLists) => {\n\n  var i, j, k, l, m, n, part, comma, titleList, suffixList, prefixList, regex,\n    partToCheck, partFound, partsFoundCount, firstComma, remainingCommas,\n    nameParts = [], nameCommas = [null], partsFound = [],\n    conjunctionList = ['&', 'and', 'et', 'e', 'of', 'the', 'und', 'y'],\n    parsedName = {\n      title: '', first: '', middle: '', last: '', nick: '', suffix: '', error: []\n    };\n\n  // Validate inputs, or set to defaults\n  partToReturn = partToReturn && ['title', 'first', 'middle', 'last', 'nick',\n    'suffix', 'error'].indexOf(partToReturn.toLowerCase()) > -1 ?\n    partToReturn.toLowerCase() : 'all';\n  // 'all' = return object with all parts, others return single part\n  if (fixCase === false) fixCase = 0;\n  if (fixCase === true) fixCase = 1;\n  fixCase = fixCase !== 'undefined' && (fixCase === 0 || fixCase === 1) ?\n    fixCase : -1; // -1 = fix case only if input is all upper or lowercase\n  if (stopOnError === true) stopOnError = 1;\n  stopOnError = stopOnError && stopOnError === 1 ? 1 : 0;\n  // false = output warnings on parse error, but don't stop\n  if (useLongLists === true) useLongLists = 1;\n  useLongLists = useLongLists && useLongLists === 1 ? 1 : 0; // 0 = short lists\n\n  // If stopOnError = 1, throw error, otherwise return error messages in array\n  function handleError(errorMessage) {\n    if (stopOnError) {\n      throw 'Error: ' + errorMessage;\n    } else {\n      parsedName.error.push('Error: ' + errorMessage);\n    }\n  }\n\n  // If fixCase = 1, fix case of parsedName parts before returning\n  function fixParsedNameCase(fixedCaseName, fixCaseNow) {\n    var forceCaseList = ['e', 'y', 'av', 'af', 'da', 'dal', 'de', 'del', 'der', 'di',\n      'la', 'le', 'van', 'der', 'den', 'vel', 'von', 'II', 'III', 'IV', 'J.D.', 'LL.M.',\n      'M.D.', 'D.O.', 'D.C.', 'Ph.D.'];\n    var forceCaseListIndex;\n    var namePartLabels = [];\n    var namePartWords;\n    if (fixCaseNow) {\n      namePartLabels = Object.keys(parsedName)\n        .filter(function (v) { return v !== 'error'; });\n      for (i = 0, l = namePartLabels.length; i < l; i++) {\n        if (fixedCaseName[namePartLabels[i]]) {\n          namePartWords = (fixedCaseName[namePartLabels[i]] + '').split(' ');\n          for (j = 0, m = namePartWords.length; j < m; j++) {\n            forceCaseListIndex = forceCaseList\n              .map(function (v) { return v.toLowerCase(); })\n              .indexOf(namePartWords[j].toLowerCase());\n            if (forceCaseListIndex > -1) { // Set case of words in forceCaseList\n              namePartWords[j] = forceCaseList[forceCaseListIndex];\n            } else if (namePartWords[j].length === 1) { // Uppercase initials\n              namePartWords[j] = namePartWords[j].toUpperCase();\n            } else if (\n              namePartWords[j].length > 2 &&\n              namePartWords[j].slice(0, 1) ===\n              namePartWords[j].slice(0, 1).toUpperCase() &&\n              namePartWords[j].slice(1, 2) ===\n              namePartWords[j].slice(1, 2).toLowerCase() &&\n              namePartWords[j].slice(2) ===\n              namePartWords[j].slice(2).toUpperCase()\n            ) { // Detect McCASE and convert to McCase\n              namePartWords[j] = namePartWords[j].slice(0, 3) +\n                namePartWords[j].slice(3).toLowerCase();\n            } else if (\n              namePartLabels[j] === 'suffix' &&\n              namePartWords[j].slice(-1) !== '.' &&\n              !suffixList.indexOf(namePartWords[j].toLowerCase())\n            ) { // Convert suffix abbreviations to UPPER CASE\n              if (namePartWords[j] === namePartWords[j].toLowerCase()) {\n                namePartWords[j] = namePartWords[j].toUpperCase();\n              }\n            } else { // Convert to Title Case\n              namePartWords[j] = namePartWords[j].slice(0, 1).toUpperCase() +\n                namePartWords[j].slice(1).toLowerCase();\n            }\n          }\n          fixedCaseName[namePartLabels[i]] = namePartWords.join(' ');\n        }\n      }\n    }\n    return fixedCaseName;\n  }\n\n  // If no input name, or input name is not a string, abort\n  if (!nameToParse || typeof nameToParse !== 'string') {\n    handleError('No input');\n    parsedName = fixParsedNameCase(parsedName, fixCase);\n    return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n  } else {\n    nameToParse = nameToParse.trim();\n  }\n\n  // Auto-detect fixCase: fix if nameToParse is all upper or all lowercase\n  if (fixCase === -1) {\n    fixCase = (\n      nameToParse === nameToParse.toUpperCase() ||\n        nameToParse === nameToParse.toLowerCase() ? 1 : 0\n    );\n  }\n\n  // Initilize lists of prefixs, suffixs, and titles to detect\n  // Note: These list entries must be all lowercase\n  if (useLongLists) {\n    suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',\n      'v', 'clu', 'chfc', 'cfp', 'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.',\n      'p.c.', 'ph.d.'];\n    prefixList = ['a', 'ab', 'antune', 'ap', 'abu', 'al', 'alm', 'alt', 'bab', 'bäck',\n      'bar', 'bath', 'bat', 'beau', 'beck', 'ben', 'berg', 'bet', 'bin', 'bint', 'birch',\n      'björk', 'björn', 'bjur', 'da', 'dahl', 'dal', 'de', 'degli', 'dele', 'del',\n      'della', 'der', 'di', 'dos', 'du', 'e', 'ek', 'el', 'escob', 'esch', 'fleisch',\n      'fitz', 'fors', 'gott', 'griff', 'haj', 'haug', 'holm', 'ibn', 'kauf', 'kil',\n      'koop', 'kvarn', 'la', 'le', 'lind', 'lönn', 'lund', 'mac', 'mhic', 'mic', 'mir',\n      'na', 'naka', 'neder', 'nic', 'ni', 'nin', 'nord', 'norr', 'ny', 'o', 'ua', 'ui\\'',\n      'öfver', 'ost', 'över', 'öz', 'papa', 'pour', 'quarn', 'skog', 'skoog', 'sten',\n      'stor', 'ström', 'söder', 'ter', 'ter', 'tre', 'türk', 'van', 'väst', 'väster',\n      'vest', 'von'];\n    titleList = ['mr', 'mrs', 'ms', 'miss', 'dr', 'herr', 'monsieur', 'hr', 'frau',\n      'a v m', 'admiraal', 'admiral', 'air cdre', 'air commodore', 'air marshal',\n      'air vice marshal', 'alderman', 'alhaji', 'ambassador', 'baron', 'barones',\n      'brig', 'brig gen', 'brig general', 'brigadier', 'brigadier general',\n      'brother', 'canon', 'capt', 'captain', 'cardinal', 'cdr', 'chief', 'cik', 'cmdr',\n      'coach', 'col', 'col dr', 'colonel', 'commandant', 'commander', 'commissioner',\n      'commodore', 'comte', 'comtessa', 'congressman', 'conseiller', 'consul',\n      'conte', 'contessa', 'corporal', 'councillor', 'count', 'countess',\n      'crown prince', 'crown princess', 'dame', 'datin', 'dato', 'datuk',\n      'datuk seri', 'deacon', 'deaconess', 'dean', 'dhr', 'dipl ing', 'doctor',\n      'dott', 'dott sa', 'dr', 'dr ing', 'dra', 'drs', 'embajador', 'embajadora', 'en',\n      'encik', 'eng', 'eur ing', 'exma sra', 'exmo sr', 'f o', 'father',\n      'first lieutient', 'first officer', 'flt lieut', 'flying officer', 'fr',\n      'frau', 'fraulein', 'fru', 'gen', 'generaal', 'general', 'governor', 'graaf',\n      'gravin', 'group captain', 'grp capt', 'h e dr', 'h h', 'h m', 'h r h', 'hajah',\n      'haji', 'hajim', 'her highness', 'her majesty', 'herr', 'high chief',\n      'his highness', 'his holiness', 'his majesty', 'hon', 'hr', 'hra', 'ing', 'ir',\n      'jonkheer', 'judge', 'justice', 'khun ying', 'kolonel', 'lady', 'lcda', 'lic',\n      'lieut', 'lieut cdr', 'lieut col', 'lieut gen', 'lord', 'm', 'm l', 'm r',\n      'madame', 'mademoiselle', 'maj gen', 'major', 'master', 'mevrouw', 'miss',\n      'mlle', 'mme', 'monsieur', 'monsignor', 'mr', 'mrs', 'ms', 'mstr', 'nti', 'pastor',\n      'president', 'prince', 'princess', 'princesse', 'prinses', 'prof', 'prof dr',\n      'prof sir', 'professor', 'puan', 'puan sri', 'rabbi', 'rear admiral', 'rev',\n      'rev canon', 'rev dr', 'rev mother', 'reverend', 'rva', 'senator', 'sergeant',\n      'sheikh', 'sheikha', 'sig', 'sig na', 'sig ra', 'sir', 'sister', 'sqn ldr', 'sr',\n      'sr d', 'sra', 'srta', 'sultan', 'tan sri', 'tan sri dato', 'tengku', 'teuku',\n      'than puying', 'the hon dr', 'the hon justice', 'the hon miss', 'the hon mr',\n      'the hon mrs', 'the hon ms', 'the hon sir', 'the very rev', 'toh puan', 'tun',\n      'vice admiral', 'viscount', 'viscountess', 'wg cdr', 'ind', 'misc', 'mx'];\n  } else {\n    suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',\n      'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.', 'p.c.', 'ph.d.'];\n    prefixList = ['ab', 'bar', 'bin', 'da', 'dal', 'de', 'de la', 'del', 'della', 'der',\n      'di', 'du', 'ibn', 'l\\'', 'la', 'le', 'san', 'st', 'st.', 'ste', 'ter', 'van',\n      'van de', 'van der', 'van den', 'vel', 'ver', 'vere', 'von'];\n    titleList = ['dr', 'miss', 'mr', 'mrs', 'ms', 'prof', 'sir', 'frau', 'herr', 'hr',\n      'monsieur', 'captain', 'doctor', 'judge', 'officer', 'professor', 'ind', 'misc',\n      'mx'];\n  }\n\n  // Nickname: remove and store parts with surrounding punctuation as nicknames\n  regex = /\\s(?:[‘’']([^‘’']+)[‘’']|[“”\"]([^“”\"]+)[“”\"]|\\[([^\\]]+)\\]|\\(([^\\)]+)\\)),?\\s/g;\n  partFound = (' ' + nameToParse + ' ').match(regex);\n  if (partFound) partsFound = partsFound.concat(partFound);\n  partsFoundCount = partsFound.length;\n  if (partsFoundCount === 1) {\n    parsedName.nick = partsFound[0].slice(2).slice(0, -2);\n    if (parsedName.nick.slice(-1) === ',') {\n      parsedName.nick = parsedName.nick.slice(0, -1);\n    }\n    nameToParse = (' ' + nameToParse + ' ').replace(partsFound[0], ' ').trim();\n    partsFound = [];\n  } else if (partsFoundCount > 1) {\n    handleError(partsFoundCount + ' nicknames found');\n    for (i = 0; i < partsFoundCount; i++) {\n      nameToParse = (' ' + nameToParse + ' ')\n        .replace(partsFound[i], ' ').trim();\n      partsFound[i] = partsFound[i].slice(2).slice(0, -2);\n      if (partsFound[i].slice(-1) === ',') {\n        partsFound[i] = partsFound[i].slice(0, -1);\n      }\n    }\n    parsedName.nick = partsFound.join(', ');\n    partsFound = [];\n  }\n  if (!nameToParse.trim().length) {\n    parsedName = fixParsedNameCase(parsedName, fixCase);\n    return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n  }\n\n  // Split remaining nameToParse into parts, remove and store preceding commas\n  for (i = 0, n = nameToParse.split(' '), l = n.length; i < l; i++) {\n    part = n[i];\n    comma = null;\n    if (part.slice(-1) === ',') {\n      comma = ',';\n      part = part.slice(0, -1);\n    }\n    nameParts.push(part);\n    nameCommas.push(comma);\n  }\n\n  // Suffix: remove and store matching parts as suffixes\n  for (l = nameParts.length, i = l - 1; i > 0; i--) {\n    partToCheck = (nameParts[i].slice(-1) === '.' ?\n      nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());\n    if (\n      suffixList.indexOf(partToCheck) > -1 ||\n      suffixList.indexOf(partToCheck + '.') > -1\n    ) {\n      partsFound = nameParts.splice(i, 1).concat(partsFound);\n      if (nameCommas[i] === ',') { // Keep comma, either before or after\n        nameCommas.splice(i + 1, 1);\n      } else {\n        nameCommas.splice(i, 1);\n      }\n    }\n  }\n  partsFoundCount = partsFound.length;\n  if (partsFoundCount === 1) {\n    parsedName.suffix = partsFound[0];\n    partsFound = [];\n  } else if (partsFoundCount > 1) {\n    handleError(partsFoundCount + ' suffixes found');\n    parsedName.suffix = partsFound.join(', ');\n    partsFound = [];\n  }\n  if (!nameParts.length) {\n    parsedName = fixParsedNameCase(parsedName, fixCase);\n    return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n  }\n\n  // Title: remove and store matching parts as titles\n  for (l = nameParts.length, i = l - 1; i >= 0; i--) {\n    partToCheck = (nameParts[i].slice(-1) === '.' ?\n      nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());\n    if (\n      titleList.indexOf(partToCheck) > -1 ||\n      titleList.indexOf(partToCheck + '.') > -1\n    ) {\n      partsFound = nameParts.splice(i, 1).concat(partsFound);\n      if (nameCommas[i] === ',') { // Keep comma, either before or after\n        nameCommas.splice(i + 1, 1);\n      } else {\n        nameCommas.splice(i, 1);\n      }\n    }\n  }\n  partsFoundCount = partsFound.length;\n  if (partsFoundCount === 1) {\n    parsedName.title = partsFound[0];\n    partsFound = [];\n  } else if (partsFoundCount > 1) {\n    handleError(partsFoundCount + ' titles found');\n    parsedName.title = partsFound.join(', ');\n    partsFound = [];\n  }\n  if (!nameParts.length) {\n    parsedName = fixParsedNameCase(parsedName, fixCase);\n    return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n  }\n\n  // Join name prefixes to following names\n  if (nameParts.length > 1) {\n    for (i = nameParts.length - 2; i >= 0; i--) {\n      if (prefixList.indexOf(nameParts[i].toLowerCase()) > -1) {\n        nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1];\n        nameParts.splice(i + 1, 1);\n        nameCommas.splice(i + 1, 1);\n      }\n    }\n  }\n\n  // Join conjunctions to surrounding names\n  if (nameParts.length > 2) {\n    for (i = nameParts.length - 3; i >= 0; i--) {\n      if (conjunctionList.indexOf(nameParts[i + 1].toLowerCase()) > -1) {\n        nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1] + ' ' + nameParts[i + 2];\n        nameParts.splice(i + 1, 2);\n        nameCommas.splice(i + 1, 2);\n        i--;\n      }\n    }\n  }\n\n  // Suffix: remove and store items after extra commas as suffixes\n  nameCommas.pop();\n  firstComma = nameCommas.indexOf(',');\n  remainingCommas = nameCommas.filter(function (v) { return v !== null; }).length;\n  if (firstComma > 1 || remainingCommas > 1) {\n    for (i = nameParts.length - 1; i >= 2; i--) {\n      if (nameCommas[i] === ',') {\n        partsFound = nameParts.splice(i, 1).concat(partsFound);\n        nameCommas.splice(i, 1);\n        remainingCommas--;\n      } else {\n        break;\n      }\n    }\n  }\n  if (partsFound.length) {\n    if (parsedName.suffix) {\n      partsFound = [parsedName.suffix].concat(partsFound);\n    }\n    parsedName.suffix = partsFound.join(', ');\n    partsFound = [];\n  }\n\n  // Last name: remove and store last name\n  if (remainingCommas > 0) {\n    if (remainingCommas > 1) {\n      handleError((remainingCommas - 1) + ' extra commas found');\n    }\n    // Remove and store all parts before first comma as last name\n    if (nameCommas.indexOf(',')) {\n      parsedName.last = nameParts.splice(0, nameCommas.indexOf(',')).join(' ');\n      nameCommas.splice(0, nameCommas.indexOf(','));\n    }\n  } else {\n    // Remove and store last part as last name\n    parsedName.last = nameParts.pop();\n  }\n  if (!nameParts.length) {\n    parsedName = fixParsedNameCase(parsedName, fixCase);\n    return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n  }\n\n  // First name: remove and store first part as first name\n  parsedName.first = nameParts.shift();\n  if (!nameParts.length) {\n    parsedName = fixParsedNameCase(parsedName, fixCase);\n    return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n  }\n\n  // Middle name: store all remaining parts as middle name\n  if (nameParts.length > 2) {\n    handleError(nameParts.length + ' middle names');\n  }\n  parsedName.middle = nameParts.join(' ');\n\n  parsedName = fixParsedNameCase(parsedName, fixCase);\n  return partToReturn === 'all' ? parsedName : parsedName[partToReturn];\n};\n"
  },
  {
    "path": "server/utils/parsers/parseNameString.js",
    "content": "//\n// This takes a string and parsed out first and last names\n//   accepts comma separated lists e.g. \"Jon Smith, Jane Smith\" or \"Smith, Jon, Smith, Jane\"\n//   can be separated by \"&\" e.g. \"Jon Smith & Jane Smith\" or \"Smith, Jon & Smith, Jane\"\n//\nconst parseFullName = require('./parseFullName')\n\nfunction parseName(name) {\n  var parts = parseFullName(name)\n  var firstName = parts.first\n  if (firstName && parts.middle) firstName += ' ' + parts.middle\n\n  return {\n    first_name: firstName,\n    last_name: parts.last\n  }\n}\n\n// Check if this name segment is of the format \"Last, First\" or \"First Last\"\n// return true is \"Last, First\"\nfunction checkIsALastName(name) {\n  if (!name.includes(' ')) return true // No spaces must be a Last name\n\n  var parsed = parseFullName(name)\n  if (!parsed.first) return true // had spaces but not a first name i.e. \"von Mises\", must be last name only\n\n  return false\n}\n\n// Handle name already in First Last format and return Last, First\nmodule.exports.nameToLastFirst = (firstLast) => {\n  var nameObj = parseName(firstLast)\n  if (!nameObj.last_name) return nameObj.first_name\n  else if (!nameObj.first_name) return nameObj.last_name\n  return `${nameObj.last_name}, ${nameObj.first_name}`\n}\n\n/**\n * Parses a name string into an array of names\n *\n * @param {string} nameString - The name string to parse\n * @returns {{ names: string[] }} Array of names\n */\nmodule.exports.parse = (nameString) => {\n  if (!nameString) return null\n\n  let splitNames = []\n  const isCommaSeparated = nameString.includes(',')\n\n  // Example &LF: Friedman, Milton & Friedman, Rose\n  if (nameString.includes('&')) {\n    nameString.split('&').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))\n  } else if (nameString.includes(' and ')) {\n    nameString.split(' and ').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))\n  } else if (nameString.includes(';')) {\n    nameString.split(';').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))\n  } else {\n    splitNames = nameString.split(',')\n  }\n  if (splitNames.length) splitNames = splitNames.map((a) => a.trim())\n\n  // If names are in Chinese，Japanese and Korean languages, return as is.\n  if (/[\\u4e00-\\u9fff\\u3040-\\u30ff\\u31f0-\\u31ff]/.test(splitNames[0])) {\n    return {\n      names: splitNames\n    }\n  }\n\n  let names = []\n\n  // 1 name FIRST LAST\n  if (splitNames.length === 1) {\n    names.push(parseName(nameString))\n  } else {\n    // Determines whether this is formatted as last, first or first last (only if using comma separator)\n    // Example: \"Smith; James Jones\" -> [\"Smith\", \"James Jones\"]\n    let firstChunkIsALastName = !isCommaSeparated ? false : checkIsALastName(splitNames[0])\n    let isEvenNum = splitNames.length % 2 === 0\n\n    if (!isEvenNum && firstChunkIsALastName) {\n      splitNames = splitNames.slice(0, splitNames.length - 1)\n    }\n\n    if (firstChunkIsALastName) {\n      var num = splitNames.length / 2\n      for (let i = 0; i < num; i++) {\n        var last = splitNames.shift()\n        var first = splitNames.shift()\n        names.push({\n          first_name: first,\n          last_name: last\n        })\n      }\n    } else {\n      splitNames.forEach((segment) => {\n        names.push(parseName(segment))\n      })\n    }\n  }\n\n  // Filter out names that have no first and last\n  names = names.filter((n) => n.first_name || n.last_name)\n\n  // Set name strings and remove duplicates\n  const namesArray = [...new Set(names.map((a) => (a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)))]\n\n  return {\n    names: namesArray // Array of first last\n  }\n}\n"
  },
  {
    "path": "server/utils/parsers/parseNfoMetadata.js",
    "content": "function parseNfoMetadata(nfoText) {\n  if (!nfoText) return null\n  const lines = nfoText.split(/\\r?\\n/)\n  const metadata = {}\n  let insideBookDescription = false\n  lines.forEach(line => {\n    if (line.search(/^\\s*book description\\s*$/i) !== -1) {\n      insideBookDescription = true\n      return\n    }\n    if (insideBookDescription) {\n      if (line.search(/^\\s*=+\\s*$/i) !== -1) return\n      metadata.description = metadata.description || ''\n      metadata.description += line + '\\n'\n      return\n    }\n    const match = line.match(/^(.*?):(.*)$/)\n    if (match) {\n      const key = match[1].toLowerCase().trim()\n      const value = match[2].trim()\n      if (!value) return\n      switch (key) {\n        case 'title':\n          {\n            const titleMatch = value.match(/^(.*?): (.*)$/)\n            if (titleMatch) {\n              metadata.title = titleMatch[1].trim()\n              metadata.subtitle = titleMatch[2].trim()\n            } else {\n              metadata.title = value\n            }\n          }\n          break\n        case 'author':\n          metadata.authors = value.split(/\\s*,\\s*/).filter(v => v)\n          break\n        case 'narrator':\n        case 'read by':\n          metadata.narrators = value.split(/\\s*,\\s*/).filter(v => v)\n          break\n        case 'series name':\n          metadata.series = value\n          break\n        case 'genre':\n          metadata.genres = value.split(/\\s*,\\s*/).filter(v => v)\n          break\n        case 'tags':\n          metadata.tags = value.split(/\\s*,\\s*/).filter(v => v)\n          break\n        case 'copyright':\n        case 'audible.com release':\n        case 'audiobook copyright':\n        case 'book copyright':\n        case 'recording copyright':\n        case 'release date':\n        case 'date':\n          {\n            const year = extractYear(value)\n            if (year) {\n              metadata.publishedYear = year\n            }\n          }\n          break\n        case 'position in series':\n          metadata.sequence = value\n          break\n        case 'unabridged':\n          metadata.abridged = value.toLowerCase() === 'yes' ? false : true\n          break\n        case 'abridged':\n          metadata.abridged = value.toLowerCase() === 'no' ? false : true\n          break\n        case 'publisher':\n          metadata.publisher = value\n          break\n        case 'asin':\n          metadata.asin = value\n          break\n        case 'isbn':\n        case 'isbn-10':\n        case 'isbn-13':\n          metadata.isbn = value\n          break\n        case 'language':\n        case 'lang':\n          metadata.language = value\n          break\n      }\n    }\n  })\n\n  // Trim leading/trailing whitespace for description\n  if (metadata.description) {\n    metadata.description = metadata.description.trim()\n  }\n\n  return metadata\n}\nmodule.exports = { parseNfoMetadata }\n\nfunction extractYear(str) {\n  const match = str.match(/\\d{4}/g)\n  return match ? match[match.length - 1] : null\n}"
  },
  {
    "path": "server/utils/parsers/parseOPML.js",
    "content": "const h = require('htmlparser2')\nconst Logger = require('../../Logger')\n\n/**\n *\n * @param {string} opmlText\n * @returns {Array<{title: string, feedUrl: string}>\n */\nfunction parse(opmlText) {\n  var feeds = []\n  var parser = new h.Parser({\n    onopentag: (name, attribs) => {\n      if (name === 'outline' && attribs.type === 'rss') {\n        if (!attribs.xmlurl) {\n          Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute')\n        } else {\n          feeds.push({\n            title: attribs.title || attribs.text || '',\n            feedUrl: attribs.xmlurl\n          })\n        }\n      }\n    }\n  })\n  parser.write(opmlText)\n  return feeds\n}\nmodule.exports.parse = parse\n"
  },
  {
    "path": "server/utils/parsers/parseOpfMetadata.js",
    "content": "const { xmlToJSON } = require('../index')\nconst htmlSanitizer = require('../htmlSanitizer')\n\n/**\n * @typedef MetadataCreatorObject\n * @property {string} value\n * @property {string} role\n * @property {string} fileAs\n *\n * @example\n * <dc:creator xmlns:ns0=\"http://www.idpf.org/2007/opf\" ns0:role=\"aut\" ns0:file-as=\"Steinbeck, John\">John Steinbeck</dc:creator>\n * <dc:creator opf:role=\"aut\" opf:file-as=\"Orwell, George\">George Orwell</dc:creator>\n *\n * @param {Object} metadata\n * @returns {MetadataCreatorObject[]}\n */\nfunction parseCreators(metadata) {\n  if (!metadata['dc:creator']?.length) return null\n  return metadata['dc:creator'].map((c) => {\n    if (typeof c !== 'object' || !c['$'] || !c['_']) return false\n    const namespace =\n      Object.keys(c['$'])\n        .find((key) => key.startsWith('xmlns:'))\n        ?.split(':')[1] || 'opf'\n    const creator = {\n      value: c['_'],\n      role: c['$'][`${namespace}:role`] || null,\n      fileAs: c['$'][`${namespace}:file-as`] || null\n    }\n\n    const id = c['$']['id']\n    if (id && metadata.meta.refines?.some((r) => r.refines === `#${id}`)) {\n      const creatorMeta = metadata.meta.refines.filter((r) => r.refines === `#${id}`)\n      if (creatorMeta) {\n        creator.role = creatorMeta.find((r) => r.property === 'role')?.value || creator.role || null\n        creator.fileAs = creatorMeta.find((r) => r.property === 'file-as')?.value || creator.fileAs || null\n      }\n    }\n\n    return creator\n  })\n}\n\nfunction fetchCreators(creators, role) {\n  if (!creators?.length) return null\n  return [...new Set(creators.filter((c) => c.role === role && c.value).map((c) => c.value))]\n}\n\nfunction fetchTagString(metadata, tag) {\n  if (!metadata[tag] || !metadata[tag].length) return null\n  let value = metadata[tag][0]\n\n  /*\n    EXAMPLES:\n\n    \"dc:title\": [\n      {\n        \"_\": \"The Quest for Character\",\n        \"$\": {\n          \"opf:file-as\": \"Quest for Character What the Story of Socrates and Alcibiades\"\n        }\n      }\n    ]\n\n    OR\n\n    \"dc:title\": [\n      \"The Quest for Character\"\n    ]\n  */\n  if (typeof value === 'object') value = value._\n  if (typeof value !== 'string') return null\n  return value\n}\n\nfunction fetchDate(metadata) {\n  const date = fetchTagString(metadata, 'dc:date')\n  if (!date) return null\n  const dateSplit = date.split('-')\n  if (!dateSplit.length || dateSplit[0].length !== 4 || isNaN(dateSplit[0])) return null\n  return dateSplit[0]\n}\n\nfunction fetchPublisher(metadata) {\n  return fetchTagString(metadata, 'dc:publisher')\n}\n\n/**\n * @example\n * <dc:identifier xmlns:ns4=\"http://www.idpf.org/2007/opf\" ns4:scheme=\"ISBN\">9781440633904</dc:identifier>\n * <dc:identifier opf:scheme=\"ISBN\">9780141187761</dc:identifier>\n *\n * @param {Object} metadata\n * @param {string} scheme\n * @returns {string}\n */\nfunction fetchIdentifier(metadata, scheme) {\n  if (!metadata['dc:identifier']?.length) return null\n  const identifierObj = metadata['dc:identifier'].find((i) => {\n    if (!i['$']) return false\n    const namespace =\n      Object.keys(i['$'])\n        .find((key) => key.startsWith('xmlns:'))\n        ?.split(':')[1] || 'opf'\n    return i['$'][`${namespace}:scheme`] === scheme\n  })\n  return identifierObj?.['_'] || null\n}\n\nfunction fetchISBN(metadata) {\n  return fetchIdentifier(metadata, 'ISBN')\n}\n\nfunction fetchASIN(metadata) {\n  return fetchIdentifier(metadata, 'ASIN')\n}\n\nfunction fetchTitle(metadata) {\n  return fetchTagString(metadata, 'dc:title')\n}\n\nfunction fetchSubtitle(metadata) {\n  return fetchTagString(metadata, 'dc:subtitle')\n}\n\nfunction fetchDescription(metadata) {\n  let description = fetchTagString(metadata, 'dc:description')\n  if (!description) return null\n  // check if description is HTML or plain text. only plain text allowed\n  // calibre stores < and > as &lt; and &gt;\n  description = description.replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n  return htmlSanitizer.stripAllTags(description)\n}\n\nfunction fetchGenres(metadata) {\n  if (!metadata['dc:subject'] || !metadata['dc:subject'].length) return []\n  return [...new Set(metadata['dc:subject'].filter((g) => g && typeof g === 'string'))]\n}\n\nfunction fetchLanguage(metadata) {\n  return fetchTagString(metadata, 'dc:language')\n}\n\nfunction fetchSeries(metadataMeta) {\n  if (!metadataMeta) return []\n  const result = []\n  for (let i = 0; i < metadataMeta.length; i++) {\n    if (metadataMeta[i].$?.name === 'calibre:series' && metadataMeta[i].$.content?.trim()) {\n      const name = metadataMeta[i].$.content.trim()\n      let sequence = null\n      if (metadataMeta[i + 1]?.$?.name === 'calibre:series_index' && metadataMeta[i + 1].$?.content?.trim()) {\n        sequence = metadataMeta[i + 1].$.content.trim()\n      }\n      result.push({ name, sequence })\n    }\n  }\n\n  // If one series was found with no series_index then check if any series_index meta can be found\n  //   this is to support when calibre:series_index is not directly underneath calibre:series\n  if (result.length === 1 && !result[0].sequence) {\n    const seriesIndexMeta = metadataMeta.find((m) => m.$?.name === 'calibre:series_index' && m.$.content?.trim())\n    if (seriesIndexMeta) {\n      result[0].sequence = seriesIndexMeta.$.content.trim()\n    }\n  }\n\n  // Remove duplicates\n  const dedupedResult = result.filter((se, idx) => result.findIndex((s) => s.name === se.name) === idx)\n\n  return dedupedResult\n}\n\nfunction fetchNarrators(creators, metadata) {\n  const narrators = fetchCreators(creators, 'nrt')\n  if (narrators?.length) return narrators\n  try {\n    const narratorsJSON = JSON.parse(fetchTagString(metadata.meta, 'calibre:user_metadata:#narrators').replace(/&quot;/g, '\"'))\n    return narratorsJSON['#value#']\n  } catch {\n    return null\n  }\n}\n\nfunction fetchTags(metadata) {\n  if (!metadata['dc:tag'] || !metadata['dc:tag'].length) return []\n  return [...new Set(metadata['dc:tag'].filter((tag) => tag && typeof tag === 'string'))]\n}\n\nfunction stripPrefix(str) {\n  if (!str) return ''\n  return str.split(':').pop()\n}\n\nmodule.exports.parseOpfMetadataJson = (json) => {\n  // Handle <package ...> or with prefix <ns0:package ...>\n  const packageKey = Object.keys(json).find((key) => stripPrefix(key) === 'package')\n  if (!packageKey) return null\n  const prefix = packageKey.split(':').shift()\n  let metadata = prefix ? json[packageKey][`${prefix}:metadata`] || json[packageKey].metadata : json[packageKey].metadata\n  if (!metadata) return null\n  if (Array.isArray(metadata)) {\n    if (!metadata.length) return null\n    metadata = metadata[0]\n  }\n\n  const metadataMeta = prefix ? metadata[`${prefix}:meta`] || metadata.meta : metadata.meta\n\n  metadata.meta = {}\n  if (metadataMeta?.length) {\n    metadataMeta.forEach((meta) => {\n      if (meta?.['$']?.name) {\n        metadata.meta[meta['$'].name] = [meta['$'].content || '']\n      } else if (meta?.['$']?.refines) {\n        // https://www.w3.org/TR/epub-33/#sec-meta-elem\n\n        if (!metadata.meta.refines) {\n          metadata.meta.refines = []\n        }\n        metadata.meta.refines.push({\n          value: meta._,\n          refines: meta['$'].refines,\n          property: meta['$'].property\n        })\n      }\n    })\n  }\n  const creators = parseCreators(metadata)\n  const authors = (fetchCreators(creators, 'aut') || []).map((au) => au?.trim()).filter((au) => au)\n  const narrators = (fetchNarrators(creators, metadata) || []).map((nrt) => nrt?.trim()).filter((nrt) => nrt)\n  return {\n    title: fetchTitle(metadata),\n    subtitle: fetchSubtitle(metadata),\n    authors,\n    narrators,\n    publishedYear: fetchDate(metadata),\n    publisher: fetchPublisher(metadata),\n    isbn: fetchISBN(metadata),\n    asin: fetchASIN(metadata),\n    description: fetchDescription(metadata),\n    genres: fetchGenres(metadata),\n    language: fetchLanguage(metadata),\n    series: fetchSeries(metadataMeta),\n    tags: fetchTags(metadata)\n  }\n}\n\nmodule.exports.parseOpfMetadataXML = async (xml) => {\n  const json = await xmlToJSON(xml)\n  if (!json) return null\n\n  return this.parseOpfMetadataJson(json)\n}\n"
  },
  {
    "path": "server/utils/parsers/parseOverdriveMediaMarkers.js",
    "content": "const xml2js = require('xml2js')\nconst Logger = require('../../Logger')\n\n// given the array of Overdrive Media Markers from generateOverdriveMediaMarkers()\n//  parse and clean them in to something a bit more usable\nfunction cleanOverdriveMediaMarkers(overdriveMediaMarkers) {\n  Logger.debug('[parseOverdriveMediaMarkers] Cleaning up overdrive media markers')\n  /*\n  returns an array of arrays of objects. Each inner array corresponds to an audio track, with it's objects being a chapter:\n  [\n    [\n     {\n      \"Name\": \"Chapter 1\",\n      \"Time\": \"0:00.000\"\n    },\n    {\n      \"Name\": \"Chapter 2\",\n      \"Time\": \"15:51.000\"\n    },\n    { etc }\n    ]\n  ]\n  */\n\n  const parsedOverdriveMediaMarkers = []\n  overdriveMediaMarkers.forEach((item, index) => {\n    let parsed_result = null\n    // convert xml to JSON\n    xml2js.parseString(item, function (err, result) {\n      /*\n      result.Markers.Marker is the result of parsing the XML for the MediaMarker tags for the MP3 file (Part##.mp3)\n      it is shaped like this, and needs further cleaning below:\n      [\n        {\n          \"Name\": [\n              \"Chapter 1:  \"\n          ],\n          \"Time\": [\n              \"0:00.000\"\n          ]\n        },\n        {\n          ANOTHER CHAPTER\n        },\n      ]\n      */\n\n      // The values for Name and Time in results.Markers.Marker are returned as Arrays from parseString and should be strings\n      if (result?.Markers?.Marker) {\n        parsed_result = objectValuesArrayToString(result.Markers.Marker)\n      }\n    })\n\n    if (parsed_result) {\n      parsedOverdriveMediaMarkers.push(parsed_result)\n    }\n  })\n\n  return removeExtraChapters(parsedOverdriveMediaMarkers)\n}\n\n// given an array of objects, convert any values that are arrays to strings\nfunction objectValuesArrayToString(arrayOfObjects) {\n  Logger.debug('[parseOverdriveMediaMarkers] Converting Marker object values from arrays to strings')\n  arrayOfObjects.forEach((item) => {\n    Object.keys(item).forEach((key) => {\n      item[key] = item[key].toString()\n    })\n  })\n\n  return arrayOfObjects\n}\n\n// Overdrive sometimes has weird chapters and subchapters defined\n//  These aren't necessary, so lets remove them\nfunction removeExtraChapters(parsedOverdriveMediaMarkers) {\n  Logger.debug('[parseOverdriveMediaMarkers] Removing any unnecessary chapters')\n  const weirdChapterFilterRegex = /([(]\\d|[cC]ontinued)/\n  var cleaned = []\n  parsedOverdriveMediaMarkers.forEach(function (item) {\n    cleaned.push(item.filter((chapter) => !weirdChapterFilterRegex.test(chapter.Name)))\n  })\n\n  return cleaned\n}\n\n// Given a set of chapters from generateParsedChapters, add the end time to each one\nfunction addChapterEndTimes(chapters, totalAudioDuration) {\n  Logger.debug('[parseOverdriveMediaMarkers] Adding chapter end times')\n  chapters.forEach((chapter, chapter_index) => {\n    if (chapter_index < chapters.length - 1) {\n      chapter.end = chapters[chapter_index + 1].start\n    } else {\n      chapter.end = totalAudioDuration\n    }\n  })\n\n  return chapters\n}\n\n// The function that actually generates the Chapters object that we update ABS with\nfunction generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers) {\n  Logger.debug('[parseOverdriveMediaMarkers] Generating new chapters for ABS')\n  // logic ported over from benonymity's OverdriveChapterizer:\n  //    https://github.com/benonymity/OverdriveChapterizer/blob/main/chapters.py\n  var parsedChapters = []\n  var length = 0.0\n  var index = 0\n  var time = 0.0\n\n  // cleanedOverdriveMediaMarkers is an array of array of objects, where the inner array matches to the included audio files tracks\n  //     this allows us to leverage the individual track durations when calculating the start times of chapters in tracks after the first (using length)\n  // TODO: can we guarantee the inner array matches the included audio files?\n  includedAudioFiles.forEach((track, track_index) => {\n    cleanedOverdriveMediaMarkers[track_index].forEach((chapter) => {\n      let timeParts = chapter.Time.split(':')\n      // add seconds\n      time = length + parseFloat(timeParts.pop())\n      if (timeParts.length) {\n        // add minutes\n        time += parseFloat(timeParts.pop()) * 60\n      }\n      if (timeParts.length) {\n        // add hours\n        time += parseFloat(timeParts.pop()) * 3600\n      }\n      var newChapterData = {\n        id: index++,\n        start: time,\n        title: chapter.Name\n      }\n      parsedChapters.push(newChapterData)\n    })\n    length += track.duration\n  })\n\n  parsedChapters = addChapterEndTimes(parsedChapters, length) // we need all the start times sorted out before we can add the end times\n\n  return parsedChapters\n}\n\nmodule.exports.parseOverdriveMediaMarkersAsChapters = (includedAudioFiles) => {\n  const overdriveMediaMarkers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter((af) => af) || []\n  if (!overdriveMediaMarkers.length) return null\n\n  var cleanedOverdriveMediaMarkers = cleanOverdriveMediaMarkers(overdriveMediaMarkers)\n  // TODO: generateParsedChapters requires overdrive media markers and included audio files length to be the same\n  //         so if not equal then we must exit\n  if (cleanedOverdriveMediaMarkers.length !== includedAudioFiles.length) return null\n  return generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)\n}\n"
  },
  {
    "path": "server/utils/parsers/parseSeriesString.js",
    "content": "/**\n * Parse a series string into a name and sequence\n *\n * @example\n * Name #1a => { name: 'Name', sequence: '1a' }\n * Name #1 => { name: 'Name', sequence: '1' }\n *\n * @param {string} seriesString\n * @returns {{name: string, sequence: string}|null}\n */\nmodule.exports.parse = (seriesString) => {\n  if (!seriesString || typeof seriesString !== 'string') return null\n\n  let sequence = null\n  let name = seriesString\n  // Series sequence match any characters after \" #\" other than whitespace and another #\n  //  e.g. \"Name #1a\" is valid. \"Name #1#a\" or \"Name #1 a\" is not valid.\n  const matchResults = seriesString.match(/ #([^#\\s]+)$/) // Pull out sequence #\n  if (matchResults && matchResults.length && matchResults.length > 1) {\n    sequence = matchResults[1] // Group 1\n    name = seriesString.replace(matchResults[0], '')\n  }\n  return {\n    name,\n    sequence\n  }\n}\n"
  },
  {
    "path": "server/utils/podcastUtils.js",
    "content": "const axios = require('axios')\nconst ssrfFilter = require('ssrf-req-filter')\nconst Logger = require('../Logger')\nconst { xmlToJSON, timestampToSeconds } = require('./index')\nconst htmlSanitizer = require('../utils/htmlSanitizer')\nconst Fuse = require('../libs/fusejs')\n\n/**\n * @typedef RssPodcastChapter\n * @property {number} id\n * @property {string} title\n * @property {number} start\n * @property {number} end\n */\n\n/**\n * @typedef RssPodcastEpisode\n * @property {string} title\n * @property {string} subtitle\n * @property {string} description\n * @property {string} descriptionPlain\n * @property {string} pubDate\n * @property {string} episodeType\n * @property {string} season\n * @property {string} episode\n * @property {string} author\n * @property {string} duration\n * @property {number|null} durationSeconds - Parsed from duration string if duration is valid\n * @property {string} explicit\n * @property {number} publishedAt - Unix timestamp\n * @property {{ url: string, type?: string, length?: string }} enclosure\n * @property {string} guid\n * @property {string} chaptersUrl\n * @property {string} chaptersType\n * @property {RssPodcastChapter[]} chapters\n */\n\n/**\n * @typedef RssPodcastMetadata\n * @property {string} title\n * @property {string} language\n * @property {string} explicit\n * @property {string} author\n * @property {string} pubDate\n * @property {string} link\n * @property {string} image\n * @property {string[]} categories\n * @property {string} feedUrl\n * @property {string} description\n * @property {string} descriptionPlain\n * @property {string} type\n */\n\n/**\n * @typedef RssPodcast\n * @property {RssPodcastMetadata} metadata\n * @property {RssPodcastEpisode[]} episodes\n * @property {number} numEpisodes\n */\n\nfunction extractFirstArrayItem(json, key) {\n  if (!json[key]?.length) return null\n  return json[key][0]\n}\n\nfunction extractStringOrStringify(json) {\n  try {\n    if (typeof json[Object.keys(json)[0]]?.[0] === 'string') {\n      return json[Object.keys(json)[0]][0]\n    }\n    // Handles case where html was included without being wrapped in CDATA\n    return JSON.stringify(value)\n  } catch {\n    return ''\n  }\n}\n\nfunction extractFirstArrayItemString(json, key) {\n  const item = extractFirstArrayItem(json, key)\n  if (!item) return ''\n  if (typeof item === 'object') {\n    if (item?.['_'] && typeof item['_'] === 'string') return item['_']\n\n    return extractStringOrStringify(item)\n  }\n  return typeof item === 'string' ? item : ''\n}\n\nfunction extractImage(channel) {\n  if (!channel.image || !channel.image.url || !channel.image.url.length) {\n    if (!channel['itunes:image'] || !channel['itunes:image'].length || !channel['itunes:image'][0]['$']) {\n      return null\n    }\n    var itunesImage = channel['itunes:image'][0]['$']\n    return itunesImage.href || null\n  }\n  return channel.image.url[0] || null\n}\n\nfunction extractCategories(channel) {\n  if (!channel['itunes:category'] || !channel['itunes:category'].length) return []\n  var categories = channel['itunes:category']\n  var cleanedCats = []\n  categories.forEach((cat) => {\n    if (!cat['$'] || !cat['$'].text) return\n    var cattext = cat['$'].text\n    if (cat['itunes:category']) {\n      var subcats = extractCategories(cat)\n      if (subcats.length) {\n        cleanedCats = cleanedCats.concat(subcats.map((subcat) => `${cattext}:${subcat}`))\n      } else {\n        cleanedCats.push(cattext)\n      }\n    } else {\n      cleanedCats.push(cattext)\n    }\n  })\n  return cleanedCats\n}\n\nfunction extractPodcastMetadata(channel) {\n  const metadata = {\n    image: extractImage(channel),\n    categories: extractCategories(channel),\n    feedUrl: null,\n    description: null,\n    descriptionPlain: null,\n    type: null\n  }\n\n  if (channel['itunes:new-feed-url']) {\n    metadata.feedUrl = extractFirstArrayItem(channel, 'itunes:new-feed-url')\n  } else if (channel['atom:link'] && channel['atom:link'].length && channel['atom:link'][0]['$']) {\n    metadata.feedUrl = channel['atom:link'][0]['$'].href || null\n  }\n\n  if (channel['description']) {\n    const rawDescription = extractFirstArrayItemString(channel, 'description')\n    metadata.description = htmlSanitizer.sanitize(rawDescription.trim())\n    metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())\n  }\n\n  const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']\n  arrayFields.forEach((key) => {\n    const cleanKey = key.split(':').pop()\n    let value = extractFirstArrayItem(channel, key)\n    if (value?.['_']) value = value['_']\n    metadata[cleanKey] = value\n  })\n  return metadata\n}\n\nfunction extractEpisodeData(item) {\n  // Episode must have url\n  let enclosure\n\n  if (item.enclosure?.[0]?.['$']?.url) {\n    enclosure = item.enclosure[0]['$']\n  } else if (item['media:content']?.find((c) => c?.['$']?.url && (c?.['$']?.type ?? '').startsWith('audio'))) {\n    enclosure = item['media:content'].find((c) => (c['$']?.type ?? '').startsWith('audio'))['$']\n  } else {\n    Logger.error(`[podcastUtils] Invalid podcast episode data`)\n    return null\n  }\n\n  const episode = {\n    enclosure: enclosure\n  }\n\n  episode.enclosure.url = episode.enclosure.url.trim()\n\n  // Full description with html\n  if (item['content:encoded']) {\n    const rawDescription = (extractFirstArrayItemString(item, 'content:encoded') || '').trim()\n    episode.description = htmlSanitizer.sanitize(rawDescription)\n  }\n\n  // Extract chapters\n  if (item['podcast:chapters']?.[0]?.['$']?.url) {\n    episode.chaptersUrl = item['podcast:chapters'][0]['$'].url\n    episode.chaptersType = item['podcast:chapters'][0]['$'].type || 'application/json'\n  }\n\n  // Supposed to be the plaintext description but not always followed\n  if (item['description']) {\n    const rawDescription = extractFirstArrayItemString(item, 'description')\n\n    if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())\n    episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())\n  }\n\n  if (item['pubDate']) {\n    const pubDate = extractFirstArrayItem(item, 'pubDate')\n    if (typeof pubDate === 'string') {\n      episode.pubDate = pubDate\n    } else if (typeof pubDate?._ === 'string') {\n      episode.pubDate = pubDate._\n    } else {\n      Logger.error(`[podcastUtils] Invalid pubDate ${item['pubDate']} for ${episode.enclosure.url}`)\n    }\n  }\n\n  if (item['guid']) {\n    const guidItem = extractFirstArrayItem(item, 'guid')\n    if (typeof guidItem === 'string') {\n      episode.guid = guidItem\n    } else if (typeof guidItem?._ === 'string') {\n      episode.guid = guidItem._\n    } else {\n      Logger.error(`[podcastUtils] Invalid guid for ${episode.enclosure.url}`, item['guid'])\n    }\n  }\n\n  const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']\n  arrayFields.forEach((key) => {\n    const cleanKey = key.split(':').pop()\n    episode[cleanKey] = extractFirstArrayItemString(item, key)\n  })\n\n  // Extract psc:chapters if duration is set\n  episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null\n\n  if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episode.durationSeconds) {\n    // Example chapter:\n    // {\"id\":0,\"start\":0,\"end\":43.004286,\"title\":\"chapter 1\"}\n\n    const cleanedChapters = item['psc:chapters'][0]['psc:chapter'].map((chapter, index) => {\n      if (!chapter['$']?.title || !chapter['$']?.start || typeof chapter['$']?.start !== 'string' || typeof chapter['$']?.title !== 'string') {\n        return null\n      }\n\n      const start = timestampToSeconds(chapter['$'].start)\n      if (start === null) {\n        return null\n      }\n\n      return {\n        id: index,\n        title: chapter['$'].title,\n        start\n      }\n    })\n\n    if (cleanedChapters.some((chapter) => !chapter)) {\n      Logger.warn(`[podcastUtils] Invalid chapter data for ${episode.enclosure.url}`)\n    } else {\n      episode.chapters = cleanedChapters.map((chapter, index) => {\n        const nextChapter = cleanedChapters[index + 1]\n        const end = nextChapter ? nextChapter.start : episode.durationSeconds\n        return {\n          id: chapter.id,\n          title: chapter.title,\n          start: chapter.start,\n          end\n        }\n      })\n    }\n  }\n\n  return episode\n}\n\nfunction cleanEpisodeData(data) {\n  const pubJsDate = data.pubDate ? new Date(data.pubDate) : null\n  const publishedAt = pubJsDate && !isNaN(pubJsDate) ? pubJsDate.valueOf() : null\n\n  return {\n    title: data.title,\n    subtitle: data.subtitle || '',\n    description: data.description || '',\n    descriptionPlain: data.descriptionPlain || '',\n    pubDate: data.pubDate || '',\n    episodeType: data.episodeType || '',\n    season: data.season || '',\n    episode: data.episode || '',\n    author: data.author || '',\n    duration: data.duration || '',\n    durationSeconds: data.durationSeconds || null,\n    explicit: data.explicit || '',\n    publishedAt,\n    enclosure: data.enclosure,\n    guid: data.guid || null,\n    chaptersUrl: data.chaptersUrl || null,\n    chaptersType: data.chaptersType || null,\n    chapters: data.chapters || []\n  }\n}\n\nfunction extractPodcastEpisodes(items) {\n  const episodes = []\n  items.forEach((item) => {\n    const extracted = extractEpisodeData(item)\n    if (extracted) {\n      episodes.push(cleanEpisodeData(extracted))\n    }\n  })\n  return episodes\n}\n\nfunction cleanPodcastJson(rssJson, excludeEpisodeMetadata) {\n  if (!rssJson.channel?.length) {\n    Logger.error(`[podcastUtil] Invalid podcast no channel object`)\n    return null\n  }\n  const channel = rssJson.channel[0]\n  if (!channel.item?.length) {\n    Logger.error(`[podcastUtil] Invalid podcast no episodes`)\n    return null\n  }\n  const podcast = {\n    metadata: extractPodcastMetadata(channel)\n  }\n  if (!excludeEpisodeMetadata) {\n    podcast.episodes = extractPodcastEpisodes(channel.item)\n  } else {\n    podcast.numEpisodes = channel.item.length\n  }\n  return podcast\n}\n\nmodule.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = false, includeRaw = false) => {\n  if (!xml) return null\n  const json = await xmlToJSON(xml)\n  if (!json?.rss) {\n    Logger.error('[podcastUtils] Invalid XML or RSS feed')\n    return null\n  }\n\n  const podcast = cleanPodcastJson(json.rss, excludeEpisodeMetadata)\n  if (!podcast) return null\n\n  if (includeRaw) {\n    return {\n      podcast,\n      rawJson: json\n    }\n  } else {\n    return {\n      podcast\n    }\n  }\n}\n\n/**\n * Get podcast RSS feed as JSON\n * Uses SSRF filter to prevent internal URLs\n *\n * @param {string} feedUrl\n * @param {boolean} [excludeEpisodeMetadata=false]\n * @returns {Promise<RssPodcast|null>}\n */\nmodule.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {\n  Logger.debug(`[podcastUtils] getPodcastFeed for \"${feedUrl}\"`)\n\n  let userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS)'\n  // Workaround for CBC RSS feeds rejecting our user agent string\n  // See: https://github.com/advplyr/audiobookshelf/issues/3322\n  if (feedUrl.startsWith('https://www.cbc.ca')) {\n    userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS) - CBC'\n  }\n\n  return axios({\n    url: feedUrl,\n    method: 'GET',\n    timeout: global.PodcastDownloadTimeout,\n    responseType: 'arraybuffer',\n    headers: {\n      Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',\n      'Accept-Encoding': 'gzip, compress, deflate',\n      'User-Agent': userAgent\n    },\n    httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl),\n    httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl)\n  })\n    .then(async (data) => {\n      // Adding support for ios-8859-1 encoded RSS feeds.\n      //  See: https://github.com/advplyr/audiobookshelf/issues/1489\n      const contentType = data.headers?.['content-type'] || '' // e.g. text/xml; charset=iso-8859-1\n      if (contentType.toLowerCase().includes('iso-8859-1')) {\n        data.data = data.data.toString('latin1')\n      } else {\n        data.data = data.data.toString()\n      }\n\n      if (!data?.data) {\n        Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)\n        return null\n      }\n      Logger.debug(`[podcastUtils] getPodcastFeed for \"${feedUrl}\" success - parsing xml`)\n      const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)\n      if (!payload) {\n        return null\n      }\n\n      // RSS feed may be a private RSS feed\n      payload.podcast.metadata.feedUrl = feedUrl\n\n      return payload.podcast\n    })\n    .catch((error) => {\n      // Check for failures due to redirecting from http to https. If original url was http, upgrade to https and try again\n      if (error.code === 'ERR_FR_REDIRECTION_FAILURE' && error.cause.code === 'ERR_INVALID_PROTOCOL') {\n        if (feedUrl.startsWith('http://') && error.request._options.protocol === 'https:') {\n          Logger.info('Redirection from http to https detected. Upgrading Request', error.request._options.href)\n          feedUrl = feedUrl.replace('http://', 'https://')\n          return this.getPodcastFeed(feedUrl, excludeEpisodeMetadata)\n        }\n      }\n      Logger.error('[podcastUtils] getPodcastFeed Error', error)\n      return null\n    })\n}\n\n// Return array of episodes ordered by closest match using fuse.js\nmodule.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => {\n  const feed = await this.getPodcastFeed(feedUrl).catch(() => {\n    return null\n  })\n\n  return this.findMatchingEpisodesInFeed(feed, searchTitle)\n}\n\n/**\n *\n * @param {RssPodcast} feed\n * @param {string} searchTitle\n * @param {number} [threshold=0.4] - 0.0 for perfect match, 1.0 for match anything\n * @returns {Array<{ episode: RssPodcastEpisode }>}\n */\nmodule.exports.findMatchingEpisodesInFeed = (feed, searchTitle, threshold = 0.4) => {\n  if (!feed?.episodes) {\n    return null\n  }\n\n  const fuseOptions = {\n    ignoreDiacritics: true,\n    threshold,\n    keys: [\n      { name: 'title', weight: 0.7 }, // prefer match in title\n      { name: 'subtitle', weight: 0.3 }\n    ]\n  }\n  const fuse = new Fuse(feed.episodes, fuseOptions)\n\n  const matches = []\n  fuse.search(searchTitle).forEach((match) => {\n    matches.push({\n      episode: match.item\n    })\n  })\n  return matches\n}\n"
  },
  {
    "path": "server/utils/prober.js",
    "content": "const ffprobe = require('../libs/nodeFfprobe')\nconst MediaProbeData = require('../scanner/MediaProbeData')\n\nconst Logger = require('../Logger')\n\nfunction tryGrabBitRate(stream, all_streams, total_bit_rate) {\n  if (!isNaN(stream.bit_rate) && stream.bit_rate) {\n    return Number(stream.bit_rate)\n  }\n  if (!stream.tags) {\n    return null\n  }\n\n  // Attempt to get bitrate from bps tags\n  var bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']\n  if (bps && !isNaN(bps)) {\n    return Number(bps)\n  }\n\n  var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']\n  var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']\n  if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {\n    var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))\n    if (bps && !isNaN(bps)) {\n      return bps\n    }\n  }\n\n  if (total_bit_rate && stream.codec_type === 'video') {\n    var estimated_bit_rate = total_bit_rate\n    all_streams.forEach((stream) => {\n      if (stream.bit_rate && !isNaN(stream.bit_rate)) {\n        estimated_bit_rate -= Number(stream.bit_rate)\n      }\n    })\n    if (!all_streams.find((s) => s.codec_type === 'audio' && s.bit_rate && Number(s.bit_rate) > estimated_bit_rate)) {\n      return estimated_bit_rate\n    } else {\n      return total_bit_rate\n    }\n  } else if (stream.codec_type === 'audio') {\n    return 112000\n  } else {\n    return 0\n  }\n}\n\nfunction tryGrabFrameRate(stream) {\n  var avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate\n  if (!avgFrameRate) return null\n  var parts = avgFrameRate.split('/')\n  if (parts.length === 2) {\n    avgFrameRate = Number(parts[0]) / Number(parts[1])\n  } else {\n    avgFrameRate = Number(parts[0])\n  }\n  if (!isNaN(avgFrameRate)) return avgFrameRate\n  return null\n}\n\nfunction tryGrabSampleRate(stream) {\n  var sample_rate = stream.sample_rate\n  if (!isNaN(sample_rate)) return Number(sample_rate)\n  return null\n}\n\nfunction tryGrabChannelLayout(stream) {\n  var layout = stream.channel_layout\n  if (!layout) return null\n  return String(layout).split('(').shift()\n}\n\nfunction tryGrabTags(stream, ...tags) {\n  if (!stream.tags) return null\n  for (let i = 0; i < tags.length; i++) {\n    const tagKey = Object.keys(stream.tags).find((t) => t.toLowerCase() === tags[i].toLowerCase())\n    const value = stream.tags[tagKey]\n    if (value && value.trim()) return value.trim()\n  }\n  return null\n}\n\nfunction parseMediaStreamInfo(stream, all_streams, total_bit_rate) {\n  var info = {\n    index: stream.index,\n    type: stream.codec_type,\n    codec: stream.codec_name || null,\n    codec_long: stream.codec_long_name || null,\n    codec_time_base: stream.codec_time_base || null,\n    time_base: stream.time_base || null,\n    bit_rate: tryGrabBitRate(stream, all_streams, total_bit_rate),\n    language: tryGrabTags(stream, 'language'),\n    title: tryGrabTags(stream, 'title')\n  }\n  if (stream.tags) info.tags = stream.tags\n\n  if (info.type === 'audio' || info.type === 'subtitle') {\n    var disposition = stream.disposition || {}\n    info.is_default = disposition.default === 1 || disposition.default === '1'\n  }\n\n  if (info.type === 'video') {\n    info.profile = stream.profile || null\n    info.is_avc = stream.is_avc !== '0' && stream.is_avc !== 'false'\n    info.pix_fmt = stream.pix_fmt || null\n    info.frame_rate = tryGrabFrameRate(stream)\n    info.width = !isNaN(stream.width) ? Number(stream.width) : null\n    info.height = !isNaN(stream.height) ? Number(stream.height) : null\n    info.color_range = stream.color_range || null\n    info.color_space = stream.color_space || null\n    info.color_transfer = stream.color_transfer || null\n    info.color_primaries = stream.color_primaries || null\n  } else if (stream.codec_type === 'audio') {\n    info.channels = stream.channels || null\n    info.sample_rate = tryGrabSampleRate(stream)\n    info.channel_layout = tryGrabChannelLayout(stream)\n  }\n\n  return info\n}\n\nfunction isNullOrNaN(val) {\n  return val === null || isNaN(val)\n}\n\n/* Example chapter object\n * {\n      \"id\": 71,\n      \"time_base\": \"1/1000\",\n      \"start\": 80792671,\n      \"start_time\": \"80792.671000\",\n      \"end\": 81084755,\n      \"end_time\": \"81084.755000\",\n      \"tags\": {\n          \"title\": \"072\"\n      }\n * }\n */\nfunction parseChapters(_chapters) {\n  if (!_chapters) return []\n\n  return _chapters\n    .map((chap) => {\n      let title = chap['TAG:title'] || chap.title || ''\n      if (!title && chap.tags?.title) title = chap.tags.title\n      title = title.trim()\n\n      const timebase = chap.time_base?.includes('/') ? Number(chap.time_base.split('/')[1]) : 1\n      const start = !isNullOrNaN(chap.start_time) ? Number(chap.start_time) : !isNullOrNaN(chap.start) ? Number(chap.start) / timebase : 0\n      const end = !isNullOrNaN(chap.end_time) ? Number(chap.end_time) : !isNullOrNaN(chap.end) ? Number(chap.end) / timebase : 0\n      return {\n        start,\n        end,\n        title\n      }\n    })\n    .sort((a, b) => a.start - b.start)\n    .map((chap, index) => {\n      chap.id = index\n      return chap\n    })\n}\n\nfunction parseTags(format, verbose) {\n  if (!format.tags) {\n    return {}\n  }\n  if (verbose) {\n    Logger.debug('Tags', format.tags)\n  }\n\n  const tags = {\n    file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),\n    file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),\n    file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),\n    file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'),\n    file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),\n    file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),\n    file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos', 'tpa'),\n    file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),\n    file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'),\n    file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),\n    file_tag_artistsort: tryGrabTags(format, 'artist-sort', 'tsop'),\n    file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'),\n    file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),\n    file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),\n    file_tag_publisher: tryGrabTags(format, 'publisher', 'tpub', 'tpb'),\n    file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),\n    file_tag_description: tryGrabTags(format, 'description', 'desc'),\n    file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),\n    file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),\n    file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),\n    file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'),\n    file_tag_isbn: tryGrabTags(format, 'isbn'), // custom\n    file_tag_language: tryGrabTags(format, 'language', 'lang'),\n    file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom\n    file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom\n    file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom\n    file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom\n    file_tag_originalyear: tryGrabTags(format, 'originalyear'),\n    file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'),\n    file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'),\n    file_tag_releasetype: tryGrabTags(format, 'MusicBrainz Album Type', 'releasetype', 'musicbrainz_albumtype'),\n    file_tag_isrc: tryGrabTags(format, 'tsrc', 'isrc'),\n    file_tag_musicbrainz_trackid: tryGrabTags(format, 'MusicBrainz Release Track Id', 'musicbrainz_releasetrackid'),\n    file_tag_musicbrainz_albumid: tryGrabTags(format, 'MusicBrainz Album Id', 'musicbrainz_albumid'),\n    file_tag_musicbrainz_albumartistid: tryGrabTags(format, 'MusicBrainz Album Artist Id', 'musicbrainz_albumartistid'),\n    file_tag_musicbrainz_artistid: tryGrabTags(format, 'MusicBrainz Artist Id', 'musicbrainz_artistid'),\n\n    // Not sure if these are actually used yet or not\n    file_tag_creation_time: tryGrabTags(format, 'creation_time'),\n    file_tag_wwwaudiofile: tryGrabTags(format, 'wwwaudiofile', 'woaf', 'waf'),\n    file_tag_contentgroup: tryGrabTags(format, 'contentgroup', 'tit1', 'tt1'),\n    file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'),\n    file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'),\n    file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),\n    file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),\n    file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),\n    file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers')\n  }\n  for (const key in tags) {\n    if (!tags[key]) {\n      delete tags[key]\n    }\n  }\n\n  return tags\n}\n\nfunction getDefaultAudioStream(audioStreams) {\n  if (!audioStreams || !audioStreams.length) return null\n  if (audioStreams.length === 1) return audioStreams[0]\n  var defaultStream = audioStreams.find((a) => a.is_default)\n  if (!defaultStream) return audioStreams[0]\n  return defaultStream\n}\n\nfunction parseProbeData(data, verbose = false) {\n  try {\n    const { format, streams, chapters } = data\n\n    const sizeBytes = !isNaN(format.size) ? Number(format.size) : null\n    const sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null\n\n    let cleanedData = {\n      format: format.format_long_name || format.name || 'Unknown',\n      duration: !isNaN(format.duration) ? Number(format.duration) : null,\n      size: sizeBytes,\n      sizeMb,\n      bit_rate: !isNaN(format.bit_rate) ? Number(format.bit_rate) : null,\n      tags: parseTags(format, verbose)\n    }\n    if (verbose && format.tags) {\n      cleanedData.rawTags = format.tags\n    }\n\n    const cleaned_streams = streams.map((s) => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))\n    cleanedData.video_stream = cleaned_streams.find((s) => s.type === 'video')\n    const audioStreams = cleaned_streams.filter((s) => s.type === 'audio')\n    cleanedData.audio_stream = getDefaultAudioStream(audioStreams)\n\n    if (cleanedData.audio_stream && cleanedData.video_stream) {\n      const videoBitrate = cleanedData.video_stream.bit_rate\n      // If audio stream bitrate larger then video, most likely incorrect\n      if (cleanedData.audio_stream.bit_rate > videoBitrate) {\n        cleanedData.video_stream.bit_rate = cleanedData.bit_rate\n      }\n    }\n\n    // If format does not have tags, check audio stream (https://github.com/advplyr/audiobookshelf/issues/256)\n    if (!format.tags && cleanedData.audio_stream && cleanedData.audio_stream.tags) {\n      cleanedData = {\n        ...cleanedData,\n        tags: parseTags(cleanedData.audio_stream, verbose)\n      }\n    }\n\n    cleanedData.chapters = parseChapters(chapters)\n\n    return cleanedData\n  } catch (error) {\n    console.error('Parse failed', error)\n    return null\n  }\n}\n\n/**\n * Run ffprobe on audio filepath\n * @param {string} filepath\n * @param {boolean} [verbose=false]\n * @returns {import('../scanner/MediaProbeData')|{error:string}}\n */\nfunction probe(filepath, verbose = false) {\n  if (process.env.FFPROBE_PATH) {\n    ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH\n  }\n\n  return ffprobe(filepath)\n    .then((raw) => {\n      if (raw.error) {\n        return {\n          error: raw.error.string\n        }\n      }\n\n      const rawProbeData = parseProbeData(raw, verbose)\n      if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {\n        return {\n          error: rawProbeData ? 'Invalid media file: no audio or video streams found' : 'Probe Failed'\n        }\n      } else {\n        const probeData = new MediaProbeData()\n        probeData.setData(rawProbeData)\n        return probeData\n      }\n    })\n    .catch((err) => {\n      return {\n        error: err\n      }\n    })\n}\nmodule.exports.probe = probe\n\n/**\n * Ffprobe for audio file path\n *\n * @param {string} filepath\n * @returns {Object} ffprobe json output\n */\nfunction rawProbe(filepath) {\n  if (process.env.FFPROBE_PATH) {\n    ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH\n  }\n\n  return ffprobe(filepath).catch((err) => {\n    return {\n      error: err\n    }\n  })\n}\nmodule.exports.rawProbe = rawProbe\n"
  },
  {
    "path": "server/utils/profiler.js",
    "content": "const { performance, createHistogram } = require('perf_hooks')\nconst util = require('util')\nconst Logger = require('../Logger')\n\nconst histograms = new Map()\n\nfunction profile(asyncFunc, isFindQuery = true, funcName = asyncFunc.name) {\n  if (!histograms.has(funcName)) {\n    const histogram = createHistogram()\n    histogram.values = []\n    histograms.set(funcName, histogram)\n  }\n  const histogram = histograms.get(funcName)\n\n  return async (...args) => {\n    if (isFindQuery) {\n      const findOptions = args[0]\n      Logger.info(`[${funcName}] findOptions:`, util.inspect(findOptions, { depth: null }))\n      findOptions.logging = (query, time) => Logger.info(`[${funcName}] ${query} Elapsed time: ${time}ms`)\n      findOptions.benchmark = true\n    }\n    const start = performance.now()\n    try {\n      const result = await asyncFunc(...args)\n      return result\n    } catch (error) {\n      Logger.error(`[${funcName}] failed`)\n      throw error\n    } finally {\n      const end = performance.now()\n      const duration = Math.round(end - start)\n      histogram.record(duration)\n      histogram.values.push(duration)\n      Logger.info(`[${funcName}] duration: ${duration}ms`)\n      Logger.info(`[${funcName}] histogram values:`, histogram.values)\n      Logger.info(`[${funcName}] histogram:`, histogram)\n    }\n  }\n}\n\nmodule.exports = { profile }\n"
  },
  {
    "path": "server/utils/queries/adminStats.js",
    "content": "const Sequelize = require('sequelize')\nconst Database = require('../../Database')\nconst PlaybackSession = require('../../models/PlaybackSession')\nconst fsExtra = require('../../libs/fsExtra')\n\nmodule.exports = {\n  /**\n   *\n   * @param {number} year YYYY\n   * @returns {Promise<PlaybackSession[]>}\n   */\n  async getListeningSessionsForYear(year) {\n    const sessions = await Database.playbackSessionModel.findAll({\n      where: {\n        createdAt: {\n          [Sequelize.Op.gte]: `${year}-01-01`,\n          [Sequelize.Op.lt]: `${year + 1}-01-01`\n        }\n      }\n    })\n    return sessions\n  },\n\n  /**\n   *\n   * @param {number} year YYYY\n   * @returns {Promise<number>}\n   */\n  async getNumAuthorsAddedForYear(year) {\n    const count = await Database.authorModel.count({\n      where: {\n        createdAt: {\n          [Sequelize.Op.gte]: `${year}-01-01`,\n          [Sequelize.Op.lt]: `${year + 1}-01-01`\n        }\n      }\n    })\n    return count\n  },\n\n  /**\n   *\n   * @param {number} year YYYY\n   * @returns {Promise<import('../../models/Book')[]>}\n   */\n  async getBooksAddedForYear(year) {\n    const books = await Database.bookModel.findAll({\n      attributes: ['id', 'title', 'coverPath', 'duration', 'createdAt'],\n      where: {\n        createdAt: {\n          [Sequelize.Op.gte]: `${year}-01-01`,\n          [Sequelize.Op.lt]: `${year + 1}-01-01`\n        }\n      },\n      include: {\n        model: Database.libraryItemModel,\n        attributes: ['id', 'mediaId', 'mediaType', 'size'],\n        required: true\n      },\n      order: Database.sequelize.random()\n    })\n    return books\n  },\n\n  /**\n   *\n   * @param {number} year YYYY\n   */\n  async getStatsForYear(year) {\n    const booksAdded = await this.getBooksAddedForYear(year)\n\n    let totalBooksAddedSize = 0\n    let totalBooksAddedDuration = 0\n    const booksWithCovers = []\n\n    for (const book of booksAdded) {\n      // Grab first 25 that have a cover\n      if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) {\n        booksWithCovers.push(book.libraryItem.id)\n      }\n      if (book.duration && !isNaN(book.duration)) {\n        totalBooksAddedDuration += book.duration\n      }\n      if (book.libraryItem.size && !isNaN(book.libraryItem.size)) {\n        totalBooksAddedSize += book.libraryItem.size\n      }\n    }\n\n    const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)\n\n    let authorListeningMap = {}\n    let narratorListeningMap = {}\n    let genreListeningMap = {}\n\n    const listeningSessions = await this.getListeningSessionsForYear(year)\n    let totalListeningTime = 0\n    for (const ls of listeningSessions) {\n      totalListeningTime += ls.timeListening || 0\n\n      const authors = ls.mediaMetadata?.authors || []\n      authors.forEach((au) => {\n        if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0\n        authorListeningMap[au.name] += ls.timeListening || 0\n      })\n\n      const narrators = ls.mediaMetadata?.narrators || []\n      narrators.forEach((narrator) => {\n        if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0\n        narratorListeningMap[narrator] += ls.timeListening || 0\n      })\n\n      // Filter out bad genres like \"audiobook\" and \"audio book\"\n      const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))\n      genres.forEach((genre) => {\n        if (!genreListeningMap[genre]) genreListeningMap[genre] = 0\n        genreListeningMap[genre] += ls.timeListening || 0\n      })\n    }\n\n    let topAuthors = null\n    topAuthors = Object.keys(authorListeningMap)\n      .map((authorName) => ({\n        name: authorName,\n        time: Math.round(authorListeningMap[authorName])\n      }))\n      .sort((a, b) => b.time - a.time)\n      .slice(0, 3)\n\n    let topNarrators = null\n    topNarrators = Object.keys(narratorListeningMap)\n      .map((narratorName) => ({\n        name: narratorName,\n        time: Math.round(narratorListeningMap[narratorName])\n      }))\n      .sort((a, b) => b.time - a.time)\n      .slice(0, 3)\n\n    let topGenres = null\n    topGenres = Object.keys(genreListeningMap)\n      .map((genre) => ({\n        genre,\n        time: Math.round(genreListeningMap[genre])\n      }))\n      .sort((a, b) => b.time - a.time)\n      .slice(0, 3)\n\n    // Stats for total books, size and duration for everything added this year or earlier\n    const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < \":nextYear-01-01\";`, {\n      replacements: {\n        nextYear: year + 1\n      }\n    })\n    const totalStatResults = totalStatResultsRow[0]\n\n    return {\n      numListeningSessions: listeningSessions.length,\n      numBooksAdded: booksAdded.length,\n      numAuthorsAdded,\n      totalBooksAddedSize,\n      totalBooksAddedDuration: Math.round(totalBooksAddedDuration),\n      booksAddedWithCovers: booksWithCovers,\n      totalBooksSize: totalStatResults?.totalSize || 0,\n      totalBooksDuration: totalStatResults?.totalDuration || 0,\n      totalListeningTime,\n      numBooks: totalStatResults?.totalItems || 0,\n      topAuthors,\n      topNarrators,\n      topGenres\n    }\n  },\n\n  /**\n   * Get total file size and number of items for books and podcasts\n   *\n   * @typedef {Object} SizeObject\n   * @property {number} totalSize\n   * @property {number} numItems\n   *\n   * @returns {Promise<{books: SizeObject, podcasts: SizeObject, total: SizeObject}}>}\n   */\n  async getTotalSize() {\n    const [mediaTypeStats] = await Database.sequelize.query(`SELECT li.mediaType, SUM(li.size) AS totalSize, COUNT(*) AS numItems FROM libraryItems li group by li.mediaType;`)\n    const bookStats = mediaTypeStats.find((m) => m.mediaType === 'book')\n    const podcastStats = mediaTypeStats.find((m) => m.mediaType === 'podcast')\n\n    return {\n      books: {\n        totalSize: bookStats?.totalSize || 0,\n        numItems: bookStats?.numItems || 0\n      },\n      podcasts: {\n        totalSize: podcastStats?.totalSize || 0,\n        numItems: podcastStats?.numItems || 0\n      },\n      total: {\n        totalSize: (bookStats?.totalSize || 0) + (podcastStats?.totalSize || 0),\n        numItems: (bookStats?.numItems || 0) + (podcastStats?.numItems || 0)\n      }\n    }\n  },\n\n  /**\n   * Get total number of audio files for books and podcasts\n   *\n   * @returns {Promise<{numBookAudioFiles: number, numPodcastAudioFiles: number, numAudioFiles: number}>}\n   */\n  async getNumAudioFiles() {\n    const [numBookAudioFilesRow] = await Database.sequelize.query(`SELECT SUM(json_array_length(b.audioFiles)) AS numAudioFiles FROM books b;`)\n    const numBookAudioFiles = numBookAudioFilesRow[0]?.numAudioFiles || 0\n    const numPodcastAudioFiles = await Database.podcastEpisodeModel.count()\n    return {\n      numBookAudioFiles,\n      numPodcastAudioFiles,\n      numAudioFiles: numBookAudioFiles + numPodcastAudioFiles\n    }\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/authorFilters.js",
    "content": "const Sequelize = require('sequelize')\nconst Database = require('../../Database')\n\nmodule.exports = {\n  /**\n   * Get authors total count\n   *\n   * @param {string} libraryId\n   * @returns {Promise<number>} count\n   */\n  async getAuthorsTotalCount(libraryId) {\n    const authorsCount = await Database.authorModel.count({\n      where: {\n        libraryId: libraryId\n      }\n    })\n    return authorsCount\n  },\n\n  /**\n   * Get authors with count of num books\n   *\n   * @param {string} libraryId\n   * @param {number} limit\n   * @returns {Promise<{id:string, name:string, count:number}>}\n   */\n  async getAuthorsWithCount(libraryId, limit) {\n    const authors = await Database.bookAuthorModel.findAll({\n      include: [\n        {\n          model: Database.authorModel,\n          as: 'author', // Use the correct alias as defined in your associations\n          attributes: ['name'],\n          where: {\n            libraryId: libraryId\n          }\n        }\n      ],\n      attributes: ['authorId', [Sequelize.fn('COUNT', Sequelize.col('authorId')), 'count']],\n      group: ['authorId', 'author.id'], // Include 'author.id' to satisfy GROUP BY with JOIN\n      order: [[Sequelize.literal('count'), 'DESC']],\n      limit: limit\n    })\n    return authors.map((au) => {\n      return {\n        id: au.authorId,\n        name: au.author.name,\n        count: au.get('count') // Use get method to access aliased attributes\n      }\n    })\n  },\n\n  /**\n   * Search authors\n   *\n   * @param {string} libraryId\n   * @param {Database.TextQuery} query\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {Promise<Object[]>} oldAuthor with numBooks\n   */\n  async search(libraryId, query, limit, offset) {\n    const matchAuthor = query.matchExpression('name')\n    const authors = await Database.authorModel.findAll({\n      where: {\n        [Sequelize.Op.and]: [Sequelize.literal(matchAuthor), { libraryId }]\n      },\n      attributes: {\n        include: [[Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks']]\n      },\n      limit,\n      offset\n    })\n    const authorMatches = []\n    for (const author of authors) {\n      const oldAuthor = author.toOldJSONExpanded(author.dataValues.numBooks)\n      authorMatches.push(oldAuthor)\n    }\n    return authorMatches\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/libraryFilters.js",
    "content": "const Sequelize = require('sequelize')\nconst Logger = require('../../Logger')\nconst Database = require('../../Database')\nconst libraryItemsBookFilters = require('./libraryItemsBookFilters')\nconst libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')\nconst { createNewSortInstance } = require('../../libs/fastSort')\nconst { profile } = require('../../utils/profiler')\nconst naturalSort = createNewSortInstance({\n  comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare\n})\n\nmodule.exports = {\n  decode(text) {\n    return Buffer.from(decodeURIComponent(text), 'base64').toString()\n  },\n\n  /**\n   * Get library items using filter and sort\n   * @param {string} libraryId\n   * @param {import('../../models/User')} user\n   * @param {object} options\n   * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}\n   */\n  async getFilteredLibraryItems(libraryId, user, options) {\n    const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options\n\n    let filterValue = null\n    let filterGroup = null\n    if (filterBy) {\n      const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'publishedDecades', 'missing', 'languages', 'tracks', 'ebooks']\n      const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.'))\n      filterGroup = group || filterBy\n      filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null\n    }\n\n    if (mediaType === 'book') {\n      return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset)\n    } else {\n      return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)\n    }\n  },\n\n  /**\n   * Get library items for continue listening & continue reading shelves\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>}\n   */\n  async getMediaItemsInProgress(library, user, include, limit) {\n    if (library.isBook) {\n      const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true)\n      return {\n        items: libraryItems.map((li) => {\n          const oldLibraryItem = li.toOldJSONMinified()\n          if (li.rssFeed) {\n            oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n          }\n          if (li.mediaItemShare) {\n            oldLibraryItem.mediaItemShare = li.mediaItemShare\n          }\n          return oldLibraryItem\n        }),\n        count\n      }\n    } else {\n      const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'progress', 'in-progress', 'progress', true, limit, 0, true)\n      return {\n        count,\n        items: libraryItems.map((li) => {\n          const oldLibraryItem = li.toOldJSONMinified()\n          oldLibraryItem.recentEpisode = li.recentEpisode\n          return oldLibraryItem\n        })\n      }\n    }\n  },\n\n  /**\n   * Get library items for most recently added shelf\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {object} { libraryItems:LibraryItem[], count:number }\n   */\n  async getLibraryItemsMostRecentlyAdded(library, user, include, limit) {\n    if (library.isBook) {\n      const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0)\n      return {\n        libraryItems: libraryItems.map((li) => {\n          const oldLibraryItem = li.toOldJSONMinified()\n          if (li.rssFeed) {\n            oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n          }\n          if (li.size && !oldLibraryItem.media.size) {\n            oldLibraryItem.media.size = li.size\n          }\n          if (li.mediaItemShare) {\n            oldLibraryItem.mediaItemShare = li.mediaItemShare\n          }\n          return oldLibraryItem\n        }),\n        count\n      }\n    } else {\n      const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0)\n      return {\n        libraryItems: libraryItems.map((li) => {\n          const oldLibraryItem = li.toOldJSONMinified()\n          if (li.rssFeed) {\n            oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n          }\n          if (li.size && !oldLibraryItem.media.size) {\n            oldLibraryItem.media.size = li.size\n          }\n          if (li.numEpisodesIncomplete) {\n            oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete\n          }\n          return oldLibraryItem\n        }),\n        count\n      }\n    }\n  },\n\n  /**\n   * Get library items for continue series shelf\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {object} { libraryItems:LibraryItem[], count:number }\n   */\n  async getLibraryItemsContinueSeries(library, user, include, limit) {\n    const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library, user, include, limit, 0)\n    return {\n      libraryItems: libraryItems.map((li) => {\n        const oldLibraryItem = li.toOldJSONMinified()\n        if (li.rssFeed) {\n          oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n        }\n        if (li.series) {\n          oldLibraryItem.media.metadata.series = li.series\n        }\n        if (li.mediaItemShare) {\n          oldLibraryItem.mediaItemShare = li.mediaItemShare\n        }\n        return oldLibraryItem\n      }),\n      count\n    }\n  },\n\n  /**\n   * Get library items or podcast episodes for the \"Listen Again\" and \"Read Again\" shelf\n   *\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {Promise<{ items:oldLibraryItem[], count:number }>}\n   */\n  async getMediaFinished(library, user, include, limit) {\n    if (library.isBook) {\n      const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0)\n      return {\n        items: libraryItems.map((li) => {\n          const oldLibraryItem = li.toOldJSONMinified()\n          if (li.rssFeed) {\n            oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n          }\n          if (li.mediaItemShare) {\n            oldLibraryItem.mediaItemShare = li.mediaItemShare\n          }\n          return oldLibraryItem\n        }),\n        count\n      }\n    } else {\n      const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'progress', 'finished', 'progress', true, limit, 0)\n      return {\n        count,\n        items: libraryItems.map((li) => {\n          const oldLibraryItem = li.toOldJSONMinified()\n          oldLibraryItem.recentEpisode = li.recentEpisode\n          return oldLibraryItem\n        })\n      }\n    }\n  },\n\n  /**\n   * Get series for recent series shelf\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {{ series:any[], count:number}}\n   */\n  async getSeriesMostRecentlyAdded(library, user, include, limit) {\n    if (!library.isBook) return { series: [], count: 0 }\n\n    const seriesIncludes = []\n    if (include.includes('rssfeed')) {\n      seriesIncludes.push({\n        model: Database.feedModel\n      })\n    }\n\n    const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)\n\n    const seriesWhere = [\n      {\n        libraryId: library.id,\n        createdAt: {\n          [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago\n        }\n      }\n    ]\n\n    // Handle library setting to hide single book series\n    // TODO: Merge with existing query\n    if (library.settings.hideSingleBookSeries) {\n      seriesWhere.push(\n        Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {\n          [Sequelize.Op.gt]: 1\n        })\n      )\n    }\n\n    // Handle user permissions to only include series with at least 1 book\n    // TODO: Simplify to a single query\n    if (userPermissionBookWhere.bookWhere.length) {\n      let attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'\n      if (!user.canAccessExplicitContent) {\n        attrQuery += ' AND b.explicit = 0'\n      }\n      if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {\n        if (user.permissions.selectedTagsNotAccessible) {\n          attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'\n        } else {\n          attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'\n        }\n      }\n      seriesWhere.push(\n        Sequelize.where(Sequelize.literal(`(${attrQuery})`), {\n          [Sequelize.Op.gt]: 0\n        })\n      )\n    }\n\n    const { rows: series, count } = await Database.seriesModel.findAndCountAll({\n      where: seriesWhere,\n      limit,\n      offset: 0,\n      distinct: true,\n      subQuery: false,\n      replacements: userPermissionBookWhere.replacements,\n      include: [\n        {\n          model: Database.bookSeriesModel,\n          include: {\n            model: Database.bookModel,\n            where: userPermissionBookWhere.bookWhere,\n            include: {\n              model: Database.libraryItemModel\n            }\n          },\n          separate: true\n        },\n        ...seriesIncludes\n      ],\n      order: [['createdAt', 'DESC']]\n    })\n\n    const allOldSeries = []\n    for (const s of series) {\n      const oldSeries = s.toOldJSON()\n\n      if (s.feeds?.length) {\n        oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()\n      }\n\n      // TODO: Sort books by sequence in query\n      s.bookSeries.sort((a, b) => {\n        if (!a.sequence) return 1\n        if (!b.sequence) return -1\n        return a.sequence.localeCompare(b.sequence, undefined, {\n          numeric: true,\n          sensitivity: 'base'\n        })\n      })\n      oldSeries.books = s.bookSeries\n        .map((bs) => {\n          const libraryItem = bs.book.libraryItem\n          if (!libraryItem) {\n            Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series)\n            return null\n          }\n\n          delete bs.book.libraryItem\n          bs.book.authors = [] // Not needed\n          bs.book.series = [] // Not needed\n          libraryItem.media = bs.book\n          const oldLibraryItem = libraryItem.toOldJSONMinified()\n          return oldLibraryItem\n        })\n        .filter((b) => b)\n      allOldSeries.push(oldSeries)\n    }\n\n    return {\n      series: allOldSeries,\n      count\n    }\n  },\n\n  /**\n   * Get most recently created authors for \"Newest Authors\" shelf\n   * Author must be linked to at least 1 book\n   *\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {number} limit\n   * @returns {Promise<{ authors:oldAuthor[], count:number }>}\n   */\n  async getNewestAuthors(library, user, limit) {\n    if (library.mediaType !== 'book') return { authors: [], count: 0 }\n\n    const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)\n\n    const { rows: authors, count } = await Database.authorModel.findAndCountAll({\n      where: {\n        libraryId: library.id,\n        createdAt: {\n          [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago\n        }\n      },\n      replacements,\n      include: {\n        model: Database.bookModel,\n        attributes: ['id', 'tags', 'explicit'],\n        where: bookWhere,\n        required: true, // Must belong to a book\n        through: {\n          attributes: []\n        }\n      },\n      limit,\n      distinct: true,\n      order: [['createdAt', 'DESC']]\n    })\n\n    return {\n      authors: authors.map((au) => {\n        const numBooks = au.books.length || 0\n        return au.toOldJSONExpanded(numBooks)\n      }),\n      count\n    }\n  },\n\n  /**\n   * Get book library items for the \"Discover\" shelf\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>}\n   */\n  async getLibraryItemsToDiscover(library, user, include, limit) {\n    if (library.mediaType !== 'book') return { libraryItems: [], count: 0 }\n\n    const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit)\n    return {\n      libraryItems: libraryItems.map((li) => {\n        const oldLibraryItem = li.toOldJSONMinified()\n        if (li.rssFeed) {\n          oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()\n        }\n        if (li.mediaItemShare) {\n          oldLibraryItem.mediaItemShare = li.mediaItemShare\n        }\n        return oldLibraryItem\n      }),\n      count\n    }\n  },\n\n  /**\n   * Get podcast episodes most recently added\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {number} limit\n   * @returns {Promise<{libraryItems:oldLibraryItem[], count:number}>}\n   */\n  async getNewestPodcastEpisodes(library, user, limit) {\n    if (library.mediaType !== 'podcast') return { libraryItems: [], count: 0 }\n\n    const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'recent', null, 'createdAt', true, limit, 0)\n    return {\n      count,\n      libraryItems: libraryItems.map((li) => {\n        const oldLibraryItem = li.toOldJSONMinified()\n        oldLibraryItem.recentEpisode = li.recentEpisode\n        return oldLibraryItem\n      })\n    }\n  },\n\n  /**\n   * Get library items for an author, optional use user permissions\n   * @param {import('../../models/Author')} author\n   * @param {import('../../models/User')} user\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}\n   */\n  async getLibraryItemsForAuthor(author, user, limit, offset) {\n    const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset)\n    return {\n      count,\n      libraryItems\n    }\n  },\n\n  /**\n   * Get book library items in a collection\n   * @param {oldCollection} collection\n   * @returns {Promise<import('../../models/LibraryItem')[]>}\n   */\n  getLibraryItemsForCollection(collection) {\n    return libraryItemsBookFilters.getLibraryItemsForCollection(collection)\n  },\n\n  /**\n   * Get filter data used in filter menus\n   * @param {string} mediaType\n   * @param {string} libraryId\n   * @returns {Promise<object>}\n   */\n  async getFilterData(mediaType, libraryId) {\n    const cachedFilterData = Database.libraryFilterData[libraryId]\n    if (cachedFilterData) {\n      const cacheElapsed = Date.now() - cachedFilterData.loadedAt\n      // Cache library filters for 30 mins\n      // TODO: Keep cached filter data up-to-date on updates\n      if (cacheElapsed < 1000 * 60 * 30) {\n        return cachedFilterData\n      }\n    }\n    const start = Date.now() // Temp for checking load times\n\n    const data = {\n      authors: [],\n      genres: new Set(),\n      tags: new Set(),\n      series: [],\n      narrators: new Set(),\n      languages: new Set(),\n      publishers: new Set(),\n      publishedDecades: new Set(),\n      bookCount: 0, // How many books returned from database query\n      authorCount: 0, // How many authors returned from database query\n      seriesCount: 0, // How many series returned from database query\n      podcastCount: 0, // How many podcasts returned from database query\n      numIssues: 0\n    }\n\n    const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0\n\n    if (mediaType === 'podcast') {\n      // Check how many podcasts are in library to determine if we need to load all of the data\n      // This is done to handle the edge case of podcasts having been deleted and not having\n      // an updatedAt timestamp to trigger a reload of the filter data\n      const podcastModelCount = process.env.QUERY_PROFILING ? profile(Database.podcastModel.count.bind(Database.podcastModel)) : Database.podcastModel.count.bind(Database.podcastModel)\n      const podcastCountFromDatabase = await podcastModelCount({\n        include: {\n          model: Database.libraryItemModel,\n          attributes: [],\n          where: {\n            libraryId: libraryId\n          }\n        }\n      })\n\n      // To reduce the cold-start load time, first check if any podcasts\n      // have an \"updatedAt\" timestamp since the last time the filter\n      // data was loaded. If so, we can skip loading all of the data.\n      // Because many items could change, just check the count of items instead\n      // of actually loading the data twice\n      const changedPodcasts = await podcastModelCount({\n        include: {\n          model: Database.libraryItemModel,\n          attributes: [],\n          where: {\n            libraryId: libraryId,\n            updatedAt: {\n              [Sequelize.Op.gt]: new Date(lastLoadedAt)\n            }\n          }\n        },\n        where: {\n          updatedAt: {\n            [Sequelize.Op.gt]: new Date(lastLoadedAt)\n          }\n        },\n        limit: 1\n      })\n\n      if (changedPodcasts === 0) {\n        // If nothing has changed, check if the number of podcasts in\n        // library is still the same as prior check before updating cache creation time\n\n        if (podcastCountFromDatabase === Database.libraryFilterData[libraryId]?.podcastCount) {\n          Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`)\n          Database.libraryFilterData[libraryId].loadedAt = Date.now()\n          return cachedFilterData\n        }\n      }\n\n      // Something has changed in the podcasts table, so reload all of the filter data for library\n      const findAll = process.env.QUERY_PROFILING ? profile(Database.podcastModel.findAll.bind(Database.podcastModel)) : Database.podcastModel.findAll.bind(Database.podcastModel)\n      const podcasts = await findAll({\n        include: {\n          model: Database.libraryItemModel,\n          attributes: [],\n          where: {\n            libraryId: libraryId\n          }\n        },\n        attributes: ['tags', 'genres', 'language']\n      })\n      for (const podcast of podcasts) {\n        if (podcast.tags?.length) {\n          podcast.tags.forEach((tag) => data.tags.add(tag))\n        }\n        if (podcast.genres?.length) {\n          podcast.genres.forEach((genre) => data.genres.add(genre))\n        }\n        if (podcast.language) {\n          data.languages.add(podcast.language)\n        }\n      }\n\n      // Set podcast count for later comparison\n      data.podcastCount = podcastCountFromDatabase\n    } else {\n      const bookCountFromDatabase = await Database.bookModel.count({\n        include: {\n          model: Database.libraryItemModel,\n          attributes: [],\n          where: {\n            libraryId: libraryId\n          }\n        }\n      })\n\n      const seriesCountFromDatabase = await Database.seriesModel.count({\n        where: {\n          libraryId: libraryId\n        }\n      })\n\n      const authorCountFromDatabase = await Database.authorModel.count({\n        where: {\n          libraryId: libraryId\n        }\n      })\n\n      // To reduce the cold-start load time, first check if any library items, series,\n      // or authors have an \"updatedAt\" timestamp since the last time the filter\n      // data was loaded. If so, we can skip loading all of the data.\n      // Because many items could change, just check the count of items instead\n      // of actually loading the data twice\n\n      const changedBooks = await Database.bookModel.count({\n        include: {\n          model: Database.libraryItemModel,\n          attributes: [],\n          where: {\n            libraryId: libraryId,\n            updatedAt: {\n              [Sequelize.Op.gt]: new Date(lastLoadedAt)\n            }\n          }\n        },\n        where: {\n          updatedAt: {\n            [Sequelize.Op.gt]: new Date(lastLoadedAt)\n          }\n        },\n        limit: 1\n      })\n\n      const changedSeries = await Database.seriesModel.count({\n        where: {\n          libraryId: libraryId,\n          updatedAt: {\n            [Sequelize.Op.gt]: new Date(lastLoadedAt)\n          }\n        },\n        limit: 1\n      })\n\n      const changedAuthors = await Database.authorModel.count({\n        where: {\n          libraryId: libraryId,\n          updatedAt: {\n            [Sequelize.Op.gt]: new Date(lastLoadedAt)\n          }\n        },\n        limit: 1\n      })\n\n      if (changedBooks + changedSeries + changedAuthors === 0) {\n        // If nothing has changed, check if the number of authors, series, and books\n        // matches the prior check before updating cache creation time\n        if (bookCountFromDatabase === Database.libraryFilterData[libraryId]?.bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId]?.seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) {\n          Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`)\n          Database.libraryFilterData[libraryId].loadedAt = Date.now()\n          return cachedFilterData\n        }\n      }\n\n      // Store the counts for later comparison\n      data.bookCount = bookCountFromDatabase\n      data.seriesCount = seriesCountFromDatabase\n      data.authorCount = authorCountFromDatabase\n\n      // Something has changed in one of the tables, so reload all of the filter data for library\n      const books = await Database.bookModel.findAll({\n        include: {\n          model: Database.libraryItemModel,\n          attributes: ['isMissing', 'isInvalid'],\n          where: {\n            libraryId: libraryId\n          }\n        },\n        attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']\n      })\n      for (const book of books) {\n        if (book.libraryItem.isMissing || book.libraryItem.isInvalid) data.numIssues++\n        if (book.tags?.length) {\n          book.tags.forEach((tag) => data.tags.add(tag))\n        }\n        if (book.genres?.length) {\n          book.genres.forEach((genre) => data.genres.add(genre))\n        }\n        if (book.narrators?.length) {\n          book.narrators.forEach((narrator) => data.narrators.add(narrator))\n        }\n        if (book.publisher) data.publishers.add(book.publisher)\n        // Check if published year exists and is valid\n        if (book.publishedYear && !isNaN(book.publishedYear) && book.publishedYear > 0 && book.publishedYear < 3000) {\n          const decade = (Math.floor(book.publishedYear / 10) * 10).toString()\n          data.publishedDecades.add(decade)\n        }\n        if (book.language) data.languages.add(book.language)\n      }\n\n      const series = await Database.seriesModel.findAll({\n        where: {\n          libraryId: libraryId\n        },\n        attributes: ['id', 'name']\n      })\n      series.forEach((s) => data.series.push({ id: s.id, name: s.name || 'No Title' }))\n\n      const authors = await Database.authorModel.findAll({\n        where: {\n          libraryId: libraryId\n        },\n        attributes: ['id', 'name']\n      })\n      authors.forEach((a) => data.authors.push({ id: a.id, name: a.name }))\n    }\n\n    data.authors = naturalSort(data.authors).asc((au) => au.name)\n    data.genres = naturalSort([...data.genres]).asc()\n    data.tags = naturalSort([...data.tags]).asc()\n    data.series = naturalSort(data.series).asc((se) => se.name)\n    data.narrators = naturalSort([...data.narrators]).asc()\n    data.publishers = naturalSort([...data.publishers]).asc()\n    data.publishedDecades = naturalSort([...data.publishedDecades]).asc()\n    data.languages = naturalSort([...data.languages]).asc()\n    data.loadedAt = Date.now()\n    Database.libraryFilterData[libraryId] = data\n\n    Logger.debug(`Loaded filterdata in ${((Date.now() - start) / 1000).toFixed(2)}s`)\n    return data\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/libraryItemFilters.js",
    "content": "const Sequelize = require('sequelize')\nconst Database = require('../../Database')\nconst libraryItemsBookFilters = require('./libraryItemsBookFilters')\nconst libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')\n\nmodule.exports = {\n  /**\n   * Get all library items that have tags\n   * @param {string[]} tags\n   * @returns {Promise<import('../../models/LibraryItem')[]>}\n   */\n  async getAllLibraryItemsWithTags(tags) {\n    const libraryItems = []\n    const booksWithTag = await Database.bookModel.findAll({\n      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {\n        [Sequelize.Op.gte]: 1\n      }),\n      replacements: {\n        tags\n      },\n      include: [\n        {\n          model: Database.libraryItemModel\n        },\n        {\n          model: Database.authorModel,\n          through: {\n            attributes: []\n          }\n        },\n        {\n          model: Database.seriesModel,\n          through: {\n            attributes: ['sequence']\n          }\n        }\n      ],\n      order: [\n        [Database.authorModel, Database.bookAuthorModel, 'createdAt', 'ASC'],\n        [Database.seriesModel, 'bookSeries', 'createdAt', 'ASC']\n      ]\n    })\n    for (const book of booksWithTag) {\n      const libraryItem = book.libraryItem\n      libraryItem.media = book\n      libraryItems.push(libraryItem)\n    }\n    const podcastsWithTag = await Database.podcastModel.findAll({\n      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:tags))`), {\n        [Sequelize.Op.gte]: 1\n      }),\n      replacements: {\n        tags\n      },\n      include: [\n        {\n          model: Database.libraryItemModel\n        },\n        {\n          model: Database.podcastEpisodeModel\n        }\n      ]\n    })\n    for (const podcast of podcastsWithTag) {\n      const libraryItem = podcast.libraryItem\n      libraryItem.media = podcast\n      libraryItems.push(libraryItem)\n    }\n    return libraryItems\n  },\n\n  /**\n   * Get all library items that have genres\n   * @param {string[]} genres\n   * @returns {Promise<import('../../models/LibraryItem')[]>}\n   */\n  async getAllLibraryItemsWithGenres(genres) {\n    const libraryItems = []\n    const booksWithGenre = await Database.bookModel.findAll({\n      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {\n        [Sequelize.Op.gte]: 1\n      }),\n      replacements: {\n        genres\n      },\n      include: [\n        {\n          model: Database.libraryItemModel\n        },\n        {\n          model: Database.authorModel,\n          through: {\n            attributes: []\n          }\n        },\n        {\n          model: Database.seriesModel,\n          through: {\n            attributes: ['sequence']\n          }\n        }\n      ]\n    })\n    for (const book of booksWithGenre) {\n      const libraryItem = book.libraryItem\n      libraryItem.media = book\n      libraryItems.push(libraryItem)\n    }\n    const podcastsWithGenre = await Database.podcastModel.findAll({\n      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(genres) WHERE json_valid(genres) AND json_each.value IN (:genres))`), {\n        [Sequelize.Op.gte]: 1\n      }),\n      replacements: {\n        genres\n      },\n      include: [\n        {\n          model: Database.libraryItemModel\n        },\n        {\n          model: Database.podcastEpisodeModel\n        }\n      ]\n    })\n    for (const podcast of podcastsWithGenre) {\n      const libraryItem = podcast.libraryItem\n      libraryItem.media = podcast\n      libraryItems.push(libraryItem)\n    }\n    return libraryItems\n  },\n\n  /**\n   * Get all library items that have narrators\n   * @param {string[]} narrators\n   * @returns {Promise<import('../../models/LibraryItem')[]>}\n   */\n  async getAllLibraryItemsWithNarrators(narrators) {\n    const libraryItems = []\n    const booksWithGenre = await Database.bookModel.findAll({\n      where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {\n        [Sequelize.Op.gte]: 1\n      }),\n      replacements: {\n        narrators\n      },\n      include: [\n        {\n          model: Database.libraryItemModel\n        },\n        {\n          model: Database.authorModel,\n          through: {\n            attributes: []\n          }\n        },\n        {\n          model: Database.seriesModel,\n          through: {\n            attributes: ['sequence']\n          }\n        }\n      ]\n    })\n    for (const book of booksWithGenre) {\n      const libraryItem = book.libraryItem\n      libraryItem.media = book\n      libraryItems.push(libraryItem)\n    }\n    return libraryItems\n  },\n\n  /**\n   * Search library items\n   * @param {import('../../models/User')} user\n   * @param {import('../../models/Library')} library\n   * @param {string} query\n   * @param {number} limit\n   * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}}\n   */\n  search(user, library, query, limit) {\n    if (library.isBook) {\n      return libraryItemsBookFilters.search(user, library, query, limit, 0)\n    } else {\n      return libraryItemsPodcastFilters.search(user, library, query, limit, 0)\n    }\n  },\n\n  /**\n   * Get largest items in library\n   * @param {string} libraryId\n   * @param {number} limit\n   * @returns {Promise<{ id:string, title:string, size:number }[]>}\n   */\n  async getLargestItems(libraryId, limit) {\n    const libraryItems = await Database.libraryItemModel.findAll({\n      attributes: ['id', 'mediaId', 'mediaType', 'size'],\n      where: {\n        libraryId\n      },\n      include: [\n        {\n          model: Database.bookModel,\n          attributes: ['id', 'title']\n        },\n        {\n          model: Database.podcastModel,\n          attributes: ['id', 'title']\n        }\n      ],\n      order: [['size', 'DESC']],\n      limit\n    })\n    return libraryItems.map((libraryItem) => {\n      return {\n        id: libraryItem.id,\n        title: libraryItem.media.title,\n        size: libraryItem.size\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/libraryItemsBookFilters.js",
    "content": "const Sequelize = require('sequelize')\nconst Database = require('../../Database')\nconst Logger = require('../../Logger')\nconst authorFilters = require('./authorFilters')\n\nconst ShareManager = require('../../managers/ShareManager')\nconst { profile } = require('../profiler')\nconst stringifySequelizeQuery = require('../stringifySequelizeQuery')\nconst countCache = new Map()\n\nmodule.exports = {\n  /**\n   * User permissions to restrict books for explicit content & tags\n   * @param {import('../../models/User')} user\n   * @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }}\n   */\n  getUserPermissionBookWhereQuery(user) {\n    const bookWhere = []\n    const replacements = {}\n    if (!user) return { bookWhere, replacements }\n\n    if (!user.canAccessExplicitContent) {\n      bookWhere.push({\n        explicit: false\n      })\n    }\n    if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {\n      replacements['userTagsSelected'] = user.permissions.itemTagsSelected\n      if (user.permissions.selectedTagsNotAccessible) {\n        bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))\n      } else {\n        bookWhere.push(\n          Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {\n            [Sequelize.Op.gte]: 1\n          })\n        )\n      }\n    }\n    return {\n      bookWhere,\n      replacements\n    }\n  },\n\n  /**\n   * When collapsing series and filtering by progress\n   * different where options are required\n   *\n   * @param {string} value\n   * @returns {Sequelize.WhereOptions}\n   */\n  getCollapseSeriesMediaProgressFilter(value) {\n    const mediaWhere = {}\n    if (value === 'not-finished') {\n      mediaWhere['$books.mediaProgresses.isFinished$'] = {\n        [Sequelize.Op.or]: [null, false]\n      }\n    } else if (value === 'not-started') {\n      mediaWhere[Sequelize.Op.and] = [\n        {\n          '$books.mediaProgresses.currentTime$': {\n            [Sequelize.Op.or]: [null, 0]\n          }\n        },\n        {\n          '$books.mediaProgresses.isFinished$': {\n            [Sequelize.Op.or]: [null, false]\n          }\n        }\n      ]\n    } else if (value === 'finished') {\n      mediaWhere['$books.mediaProgresses.isFinished$'] = true\n    } else if (value === 'in-progress') {\n      mediaWhere[Sequelize.Op.and] = [\n        {\n          [Sequelize.Op.or]: [\n            {\n              '$books.mediaProgresses.currentTime$': {\n                [Sequelize.Op.gt]: 0\n              }\n            },\n            {\n              '$books.mediaProgresses.ebookProgress$': {\n                [Sequelize.Op.gt]: 0\n              }\n            }\n          ]\n        },\n        {\n          '$books.mediaProgresses.isFinished$': false\n        }\n      ]\n    }\n    return mediaWhere\n  },\n\n  /**\n   * Get where options for Book model\n   * @param {string} group\n   * @param {[string]} value\n   * @returns {object} { Sequelize.WhereOptions, string[] }\n   */\n  getMediaGroupQuery(group, value) {\n    if (!group) return { mediaWhere: {}, replacements: {} }\n\n    let mediaWhere = {}\n    const replacements = {}\n\n    if (group === 'progress') {\n      if (value === 'not-finished') {\n        mediaWhere['$mediaProgresses.isFinished$'] = {\n          [Sequelize.Op.or]: [null, false]\n        }\n      } else if (value === 'not-started') {\n        mediaWhere[Sequelize.Op.and] = [\n          {\n            '$mediaProgresses.currentTime$': {\n              [Sequelize.Op.or]: [null, 0]\n            }\n          },\n          {\n            '$mediaProgresses.isFinished$': {\n              [Sequelize.Op.or]: [null, false]\n            }\n          }\n        ]\n      } else if (value === 'finished') {\n        mediaWhere['$mediaProgresses.isFinished$'] = true\n      } else if (value === 'in-progress') {\n        mediaWhere[Sequelize.Op.and] = [\n          {\n            [Sequelize.Op.or]: [\n              {\n                '$mediaProgresses.currentTime$': {\n                  [Sequelize.Op.gt]: 0\n                }\n              },\n              {\n                '$mediaProgresses.ebookProgress$': {\n                  [Sequelize.Op.gt]: 0\n                }\n              }\n            ]\n          },\n          {\n            '$mediaProgresses.isFinished$': false\n          }\n        ]\n      } else if (value === 'audio-in-progress') {\n        mediaWhere[Sequelize.Op.and] = [\n          {\n            '$mediaProgresses.currentTime$': {\n              [Sequelize.Op.gt]: 0\n            }\n          },\n          {\n            '$mediaProgresses.isFinished$': false\n          }\n        ]\n      } else if (value === 'ebook-in-progress') {\n        // Filters for ebook only\n        mediaWhere = [\n          Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0),\n          {\n            '$mediaProgresses.ebookProgress$': {\n              [Sequelize.Op.gt]: 0\n            }\n          },\n          {\n            '$mediaProgresses.isFinished$': false\n          }\n        ]\n      } else if (value === 'ebook-finished') {\n        // Filters for ebook only\n        mediaWhere = [\n          Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0),\n          {\n            '$mediaProgresses.isFinished$': true,\n            ebookFile: {\n              [Sequelize.Op.not]: null\n            }\n          }\n        ]\n      }\n    } else if (group === 'series' && value === 'no-series') {\n      mediaWhere['$series.id$'] = null\n    } else if (group === 'abridged') {\n      mediaWhere['abridged'] = true\n    } else if (group === 'explicit') {\n      mediaWhere['explicit'] = true\n    } else if (['genres', 'tags', 'narrators'].includes(group)) {\n      mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {\n        [Sequelize.Op.gte]: 1\n      })\n      replacements.filterValue = value\n    } else if (group === 'publishers') {\n      mediaWhere['publisher'] = value\n    } else if (group === 'languages') {\n      mediaWhere['language'] = value\n    } else if (group === 'tracks') {\n      if (value === 'none') {\n        mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0)\n      } else if (value === 'multi') {\n        mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), {\n          [Sequelize.Op.gt]: 1\n        })\n      } else {\n        mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 1)\n      }\n    } else if (group === 'ebooks') {\n      if (value === 'ebook') {\n        mediaWhere['ebookFile'] = {\n          [Sequelize.Op.not]: null\n        }\n      } else if (value == 'no-ebook') {\n        mediaWhere['ebookFile'] = {\n          [Sequelize.Op.eq]: null\n        }\n      }\n    } else if (group === 'missing') {\n      if (['asin', 'isbn', 'subtitle', 'publishedYear', 'description', 'publisher', 'language', 'cover'].includes(value)) {\n        let key = value\n        if (value === 'cover') key = 'coverPath'\n        mediaWhere[key] = {\n          [Sequelize.Op.or]: [null, '']\n        }\n      } else if (['genres', 'tags', 'narrators', 'chapters'].includes(value)) {\n        mediaWhere[value] = {\n          [Sequelize.Op.or]: [null, Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col(value)), 0)]\n        }\n      } else if (value === 'authors') {\n        mediaWhere['$authors.id$'] = null\n      } else if (value === 'series') {\n        mediaWhere['$series.id$'] = null\n      }\n    } else if (group === 'publishedDecades') {\n      const startYear = parseInt(value)\n      const endYear = parseInt(value, 10) + 9\n      mediaWhere = Sequelize.where(Sequelize.literal('CAST(publishedYear AS INTEGER)'), {\n        [Sequelize.Op.between]: [startYear, endYear]\n      })\n    }\n\n    return { mediaWhere, replacements }\n  },\n\n  /**\n   * Get sequelize order\n   * @param {string} sortBy\n   * @param {boolean} sortDesc\n   * @param {boolean} collapseseries\n   * @returns {Sequelize.order}\n   */\n  getOrder(sortBy, sortDesc, collapseseries) {\n    const dir = sortDesc ? 'DESC' : 'ASC'\n\n    const getTitleOrder = () => {\n      if (global.ServerSettings.sortingIgnorePrefix) {\n        return [Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]\n      } else {\n        return [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]\n      }\n    }\n\n    if (sortBy === 'addedAt') {\n      return [[Sequelize.literal('libraryItem.createdAt'), dir]]\n    } else if (sortBy === 'size') {\n      return [[Sequelize.literal('libraryItem.size'), dir]]\n    } else if (sortBy === 'birthtimeMs') {\n      return [[Sequelize.literal('libraryItem.birthtime'), dir]]\n    } else if (sortBy === 'mtimeMs') {\n      return [[Sequelize.literal('libraryItem.mtime'), dir]]\n    } else if (sortBy === 'media.duration') {\n      return [['duration', dir]]\n    } else if (sortBy === 'media.metadata.publishedYear') {\n      return [[Sequelize.literal(`CAST(\\`book\\`.\\`publishedYear\\` AS INTEGER)`), dir]]\n    } else if (sortBy === 'media.metadata.authorNameLF') {\n      // Sort by author name last first, secondary sort by title\n      return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()]\n    } else if (sortBy === 'media.metadata.authorName') {\n      // Sort by author name first last, secondary sort by title\n      return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()]\n    } else if (sortBy === 'media.metadata.title') {\n      if (collapseseries) {\n        return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]\n      }\n      return [getTitleOrder()]\n    } else if (sortBy === 'sequence') {\n      const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'\n      return [[Sequelize.literal(`CAST(\\`series.bookSeries.sequence\\` AS FLOAT) ${nullDir}`)]]\n    } else if (sortBy === 'progress') {\n      return [[Sequelize.literal(`mediaProgresses.updatedAt ${dir} NULLS LAST`)]]\n    } else if (sortBy === 'progress.createdAt') {\n      return [[Sequelize.literal(`mediaProgresses.createdAt ${dir} NULLS LAST`)]]\n    } else if (sortBy === 'progress.finishedAt') {\n      return [[Sequelize.literal(`mediaProgresses.finishedAt ${dir} NULLS LAST`)]]\n    } else if (sortBy === 'random') {\n      return [Database.sequelize.random()]\n    }\n    return []\n  },\n\n  /**\n   * When collapsing series get first book in each series\n   * to know which books to exclude from primary query.\n   * Additionally use this query to get the number of books in each series\n   *\n   * @param {Sequelize.ModelStatic} bookFindOptions\n   * @param {Sequelize.WhereOptions} seriesWhere\n   * @returns {object} { booksToExclude, bookSeriesToInclude }\n   */\n  async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {\n    const allSeries = await Database.seriesModel.findAll({\n      attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']],\n      distinct: true,\n      subQuery: false,\n      where: seriesWhere,\n      include: [\n        {\n          model: Database.bookModel,\n          attributes: ['id', 'title'],\n          through: {\n            attributes: ['id', 'seriesId', 'bookId', 'sequence']\n          },\n          ...bookFindOptions,\n          required: true\n        }\n      ],\n      order: [Sequelize.literal('CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]\n    })\n    const bookSeriesToInclude = []\n    const booksToInclude = []\n    let booksToExclude = []\n    allSeries.forEach((s) => {\n      let found = false\n      for (let book of s.books) {\n        if (!found && !booksToInclude.includes(book.id)) {\n          booksToInclude.push(book.id)\n          bookSeriesToInclude.push({\n            id: book.bookSeries.id,\n            numBooks: s.dataValues.numBooks,\n            libraryItemIds: s.books?.map((b) => b.libraryItem.id) || []\n          })\n          booksToExclude = booksToExclude.filter((bid) => bid !== book.id)\n          found = true\n        } else if (!booksToExclude.includes(book.id) && !booksToInclude.includes(book.id)) {\n          booksToExclude.push(book.id)\n        }\n      }\n    })\n    return { booksToExclude, bookSeriesToInclude }\n  },\n\n  clearCountCache(hook) {\n    Logger.debug(`[LibraryItemsBookFilters] book.${hook}: Clearing count cache`)\n    countCache.clear()\n  },\n\n  async findAndCountAll(findOptions, limit, offset, useCountCache) {\n    const model = Database.bookModel\n    if (useCountCache) {\n      const countCacheKey = stringifySequelizeQuery(findOptions)\n      Logger.debug(`[LibraryItemsBookFilters] countCacheKey: ${countCacheKey}`)\n      if (!countCache.has(countCacheKey)) {\n        const count = await model.count(findOptions)\n        countCache.set(countCacheKey, count)\n      }\n\n      findOptions.limit = limit || null\n      findOptions.offset = offset\n\n      const rows = await model.findAll(findOptions)\n\n      return { rows, count: countCache.get(countCacheKey) }\n    }\n\n    findOptions.limit = limit || null\n    findOptions.offset = offset\n\n    return await model.findAndCountAll(findOptions)\n  },\n\n  /**\n   * Get library items for book media type using filter and sort\n   * @param {string} libraryId\n   * @param {import('../../models/User')} user\n   * @param {string|null} filterGroup\n   * @param {string|null} filterValue\n   * @param {string} sortBy\n   * @param {string} sortDesc\n   * @param {boolean} collapseseries\n   * @param {string[]} include\n   * @param {number} limit\n   * @param {number} offset\n   * @param {boolean} isHomePage for home page shelves\n   * @returns {{ libraryItems: import('../../models/LibraryItem')[], count: number }}\n   */\n  async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) {\n    // TODO: Handle collapse sub-series\n    if (filterGroup === 'series' && collapseseries) {\n      collapseseries = false\n    }\n    if (filterGroup !== 'series' && sortBy === 'sequence') {\n      sortBy = 'media.metadata.title'\n    }\n    const includeRSSFeed = include.includes('rssfeed')\n    const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share')\n\n    let bookAttributes = null\n\n    const libraryItemWhere = {\n      libraryId\n    }\n\n    let seriesInclude = {\n      model: Database.bookSeriesModel,\n      attributes: ['id', 'seriesId', 'sequence', 'createdAt'],\n      include: {\n        model: Database.seriesModel,\n        attributes: ['id', 'name', 'nameIgnorePrefix']\n      },\n      order: [['createdAt', 'ASC']],\n      separate: true\n    }\n\n    let authorInclude = {\n      model: Database.bookAuthorModel,\n      attributes: ['authorId', 'createdAt'],\n      include: {\n        model: Database.authorModel,\n        attributes: ['id', 'name']\n      },\n      order: [['createdAt', 'ASC']],\n      separate: true\n    }\n\n    const sortOrder = this.getOrder(sortBy, sortDesc, collapseseries)\n\n    const libraryItemIncludes = []\n    const bookIncludes = []\n\n    if (filterGroup === 'feed-open' || includeRSSFeed) {\n      const rssFeedRequired = filterGroup === 'feed-open'\n      libraryItemIncludes.push({\n        model: Database.feedModel,\n        required: rssFeedRequired,\n        separate: !rssFeedRequired\n      })\n    }\n\n    if (filterGroup === 'share-open') {\n      bookIncludes.push({\n        model: Database.mediaItemShareModel,\n        required: true\n      })\n    } else if (filterGroup === 'ebooks' && filterValue === 'supplementary') {\n      // TODO: Temp workaround for filtering supplementary ebook\n      libraryItemWhere['libraryFiles'] = {\n        [Sequelize.Op.substring]: `\"isSupplementary\":true`\n      }\n    } else if (filterGroup === 'ebooks' && filterValue === 'no-supplementary') {\n      libraryItemWhere['libraryFiles'] = {\n        [Sequelize.Op.notLike]: Sequelize.literal(`\\'%\"isSupplementary\":true%\\'`)\n      }\n    } else if (filterGroup === 'missing' && filterValue === 'authors') {\n      authorInclude = {\n        model: Database.authorModel,\n        attributes: ['id'],\n        through: {\n          attributes: []\n        }\n      }\n    } else if ((filterGroup === 'series' && filterValue === 'no-series') || (filterGroup === 'missing' && filterValue === 'series')) {\n      seriesInclude = {\n        model: Database.seriesModel,\n        attributes: ['id'],\n        through: {\n          attributes: []\n        }\n      }\n    } else if (filterGroup === 'authors') {\n      bookIncludes.push({\n        model: Database.authorModel,\n        attributes: ['id', 'name'],\n        where: {\n          id: filterValue\n        },\n        through: {\n          attributes: []\n        }\n      })\n    } else if (filterGroup === 'series') {\n      bookIncludes.push({\n        model: Database.seriesModel,\n        attributes: ['id', 'name'],\n        where: {\n          id: filterValue\n        },\n        through: {\n          attributes: ['sequence']\n        }\n      })\n      if (sortBy !== 'sequence') {\n        // Secondary sort by sequence\n        sortOrder.push([Sequelize.literal('CAST(`series.bookSeries.sequence` AS FLOAT) ASC NULLS LAST')])\n      }\n    } else if (filterGroup === 'issues') {\n      libraryItemWhere[Sequelize.Op.or] = [\n        {\n          isMissing: true\n        },\n        {\n          isInvalid: true\n        }\n      ]\n    } else if (filterGroup === 'progress' && user) {\n      const mediaProgressWhere = {\n        userId: user.id\n      }\n      // Respect hide from continue listening for home page shelf\n      if (isHomePage) {\n        mediaProgressWhere.hideFromContinueListening = false\n      }\n      bookIncludes.push({\n        model: Database.mediaProgressModel,\n        attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt', 'createdAt', 'finishedAt'],\n        where: mediaProgressWhere,\n        required: false\n      })\n    } else if (filterGroup === 'recent') {\n      libraryItemWhere['createdAt'] = {\n        [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago\n      }\n    }\n\n    // When sorting by progress but not filtering by progress, include media progresses\n    if (filterGroup !== 'progress' && ['progress.createdAt', 'progress.finishedAt', 'progress'].includes(sortBy)) {\n      bookIncludes.push({\n        model: Database.mediaProgressModel,\n        attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt', 'createdAt', 'finishedAt'],\n        where: {\n          userId: user.id\n        },\n        required: false\n      })\n    }\n\n    let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)\n    let bookWhere = Array.isArray(mediaWhere) ? mediaWhere : [mediaWhere]\n\n    // User permissions\n    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)\n    replacements = { ...replacements, ...userPermissionBookWhere.replacements }\n    bookWhere.push(...userPermissionBookWhere.bookWhere)\n\n    // Handle collapsed series\n    let collapseSeriesBookSeries = []\n    if (collapseseries) {\n      let seriesBookWhere = null\n      let seriesWhere = null\n      if (filterGroup === 'progress') {\n        seriesWhere = this.getCollapseSeriesMediaProgressFilter(filterValue)\n      } else if (filterGroup === 'missing' && filterValue === 'authors') {\n        seriesWhere = {\n          ['$books.authors.id$']: null\n        }\n      } else {\n        seriesBookWhere = bookWhere\n      }\n\n      const bookFindOptions = {\n        where: seriesBookWhere,\n        include: [\n          {\n            model: Database.libraryItemModel,\n            required: true,\n            where: libraryItemWhere,\n            include: libraryItemIncludes\n          },\n          authorInclude,\n          ...bookIncludes\n        ]\n      }\n      const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere)\n      if (booksToExclude.length) {\n        bookWhere.push({\n          id: {\n            [Sequelize.Op.notIn]: booksToExclude\n          }\n        })\n      }\n      collapseSeriesBookSeries = bookSeriesToInclude\n      if (!bookAttributes?.include) bookAttributes = { include: [] }\n\n      // When collapsing series and sorting by title then use the series name instead of the book title\n      //  for this set an attribute \"display_title\" to use in sorting\n      if (global.ServerSettings.sortingIgnorePrefix) {\n        bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `\"${v.id}\"`).join(', ')})), \\`libraryItem\\`.\\`titleIgnorePrefix\\`)`), 'display_title'])\n      } else {\n        bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `\"${v.id}\"`).join(', ')})), \\`libraryItem\\`.\\`title\\`)`), 'display_title'])\n      }\n    }\n\n    const findOptions = {\n      where: bookWhere,\n      distinct: true,\n      attributes: bookAttributes,\n      replacements,\n      include: [\n        {\n          model: Database.libraryItemModel,\n          required: true,\n          where: libraryItemWhere,\n          include: libraryItemIncludes\n        },\n        seriesInclude,\n        authorInclude,\n        ...bookIncludes\n      ],\n      order: sortOrder,\n      subQuery: false\n    }\n\n    const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll\n    const { rows: books, count } = await findAndCountAll(findOptions, limit, offset, !filterGroup && !userPermissionBookWhere.bookWhere.length)\n\n    const libraryItems = books.map((bookExpanded) => {\n      const libraryItem = bookExpanded.libraryItem\n      const book = bookExpanded\n\n      if (filterGroup === 'series' && book.series?.length) {\n        // For showing sequence on book cover when filtering for series\n        libraryItem.series = {\n          id: book.series[0].id,\n          name: book.series[0].name,\n          sequence: book.series[0].bookSeries?.sequence || null\n        }\n      }\n\n      delete book.libraryItem\n\n      book.series =\n        book.bookSeries?.map((bs) => {\n          const series = bs.series\n          delete bs.series\n          series.bookSeries = bs\n          return series\n        }) || []\n      delete book.bookSeries\n\n      book.authors = book.bookAuthors?.map((ba) => ba.author) || []\n      delete book.bookAuthors\n\n      // For showing details of collapsed series\n      if (collapseseries && book.series?.length) {\n        const collapsedSeries = book.series.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.bookSeries.id))\n        if (collapsedSeries) {\n          const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.bookSeries.id)\n          libraryItem.collapsedSeries = {\n            id: collapsedSeries.id,\n            name: collapsedSeries.name,\n            nameIgnorePrefix: collapsedSeries.nameIgnorePrefix,\n            sequence: collapsedSeries.bookSeries.sequence,\n            numBooks: collapseSeriesObj?.numBooks || 0,\n            libraryItemIds: collapseSeriesObj?.libraryItemIds || []\n          }\n        }\n      }\n\n      if (libraryItem.feeds?.length) {\n        libraryItem.rssFeed = libraryItem.feeds[0]\n      }\n\n      if (includeMediaItemShare) {\n        libraryItem.mediaItemShare = ShareManager.findByMediaItemId(libraryItem.mediaId)\n      }\n\n      libraryItem.media = book\n\n      return libraryItem\n    })\n\n    return {\n      libraryItems,\n      count\n    }\n  },\n\n  /**\n   * Get library items for continue series shelf\n   * A series is included on the shelf if it meets the following:\n   * 1. Has at least 1 finished book\n   * 2. Has no books in progress\n   * 3. Has at least 1 unfinished book\n   * TODO: Reduce queries\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}\n   */\n  async getContinueSeriesLibraryItems(library, user, include, limit, offset) {\n    const libraryId = library.id\n    const libraryItemIncludes = []\n    if (include.includes('rssfeed')) {\n      libraryItemIncludes.push({\n        model: Database.feedModel\n      })\n    }\n\n    const bookWhere = []\n    // TODO: Permissions should also be applied to subqueries\n    // User permissions\n    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)\n    bookWhere.push(...userPermissionBookWhere.bookWhere)\n\n    let includeAttributes = [[Sequelize.literal('(SELECT max(mp.updatedAt) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id)'), 'recent_progress']]\n    let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL)`\n\n    if (library.settings.onlyShowLaterBooksInContinueSeries) {\n      const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as FLOAT) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = 1 AND mp.userId = :userId AND bs.seriesId = series.id)`\n      includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence'])\n\n      booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as FLOAT) > ${maxSequenceQuery}`\n    }\n\n    const { rows: series, count } = await Database.seriesModel.findAndCountAll({\n      where: [\n        {\n          id: {\n            [Sequelize.Op.notIn]: user.extraData?.seriesHideFromContinueListening || []\n          },\n          libraryId\n        },\n        // TODO: Simplify queries\n        // Has at least 1 book finished\n        Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE bs.seriesId = series.id AND mp.mediaItemId = bs.bookId AND mp.userId = :userId AND mp.isFinished = 1)`), {\n          [Sequelize.Op.gte]: 1\n        }),\n        // Has at least 1 book not finished (that has a sequence number higher than the highest already read, if library config is toggled)\n        Sequelize.where(Sequelize.literal(`(${booksNotFinishedQuery})`), {\n          [Sequelize.Op.gte]: 1\n        }),\n        // Has no books in progress\n        Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id AND mp.isFinished = 0 AND mp.currentTime > 0)`), 0)\n      ],\n      attributes: {\n        include: includeAttributes\n      },\n      replacements: {\n        userId: user.id,\n        ...userPermissionBookWhere.replacements\n      },\n      include: {\n        model: Database.bookSeriesModel,\n        attributes: ['bookId', 'sequence'],\n        separate: true,\n        subQuery: false,\n        order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]],\n        where: {\n          '$book.mediaProgresses.isFinished$': {\n            [Sequelize.Op.or]: [null, 0]\n          }\n        },\n        include: {\n          model: Database.bookModel,\n          where: bookWhere,\n          include: [\n            {\n              model: Database.libraryItemModel,\n              include: libraryItemIncludes\n            },\n            {\n              model: Database.authorModel,\n              through: {\n                attributes: []\n              }\n            },\n            {\n              model: Database.mediaProgressModel,\n              where: {\n                userId: user.id\n              },\n              required: false\n            }\n          ]\n        }\n      },\n      order: [[Sequelize.literal('recent_progress DESC')]],\n      distinct: true,\n      subQuery: false,\n      limit,\n      offset\n    })\n\n    const libraryItems = series\n      .map((s) => {\n        if (!s.bookSeries.length) return null // this is only possible if user has restricted books in series\n\n        let bookIndex = 0\n        // if the library setting is toggled, only show later entries in series, otherwise skip\n        if (library.settings.onlyShowLaterBooksInContinueSeries) {\n          bookIndex = s.bookSeries.findIndex(function (b) {\n            return parseFloat(b.dataValues.sequence) > s.dataValues.maxSequence\n          })\n          if (bookIndex === -1) {\n            // no later books than maxSequence\n            return null\n          }\n        }\n\n        const libraryItem = s.bookSeries[bookIndex].book.libraryItem\n        const book = s.bookSeries[bookIndex].book\n        delete book.libraryItem\n\n        book.series = []\n\n        libraryItem.series = {\n          id: s.id,\n          name: s.name,\n          sequence: s.bookSeries[bookIndex].sequence\n        }\n        if (libraryItem.feeds?.length) {\n          libraryItem.rssFeed = libraryItem.feeds[0]\n        }\n        libraryItem.media = book\n        return libraryItem\n      })\n      .filter((s) => s)\n\n    return {\n      libraryItems,\n      count\n    }\n  },\n\n  /**\n   * Get book library items for the \"Discover\" shelf\n   * Random selection of books that are not started\n   *  - only includes the first book of a not-started series\n   * @param {string} libraryId\n   * @param {import('../../models/User')} user\n   * @param {string[]} include\n   * @param {number} limit\n   * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}\n   */\n  async getDiscoverLibraryItems(libraryId, user, include, limit) {\n    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)\n\n    // Step 1: Get the first book of every series that hasnt been started yet\n    const seriesNotStarted = await Database.seriesModel.findAll({\n      where: [\n        {\n          libraryId\n        },\n        Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId WHERE bs.seriesId = series.id AND mp.userId = :userId AND (mp.isFinished = 1 OR mp.currentTime > 0))`), 0)\n      ],\n      replacements: {\n        userId: user.id,\n        ...userPermissionBookWhere.replacements\n      },\n      attributes: ['id'],\n      include: {\n        model: Database.bookSeriesModel,\n        attributes: ['bookId', 'sequence'],\n        separate: true,\n        required: true,\n        include: {\n          model: Database.bookModel,\n          where: userPermissionBookWhere.bookWhere\n        },\n        order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]],\n        limit: 1\n      },\n      subQuery: false,\n      limit,\n      order: Database.sequelize.random()\n    })\n\n    const booksFromSeriesToInclude = seriesNotStarted.map((se) => se.bookSeries?.[0]?.bookId).filter((bid) => bid)\n\n    // optional include rssFeed\n    const libraryItemIncludes = []\n    if (include.includes('rssfeed')) {\n      libraryItemIncludes.push({\n        model: Database.feedModel\n      })\n    }\n\n    const discoverWhere = [\n      {\n        '$mediaProgresses.isFinished$': {\n          [Sequelize.Op.or]: [null, 0]\n        },\n        '$mediaProgresses.currentTime$': {\n          [Sequelize.Op.or]: [null, 0]\n        },\n        [Sequelize.Op.or]: [\n          Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0),\n          {\n            id: {\n              [Sequelize.Op.in]: booksFromSeriesToInclude\n            }\n          }\n        ]\n      },\n      ...userPermissionBookWhere.bookWhere\n    ]\n\n    const baseDiscoverInclude = [\n      {\n        model: Database.libraryItemModel,\n        where: {\n          libraryId\n        }\n      },\n      {\n        model: Database.mediaProgressModel,\n        where: {\n          userId: user.id\n        },\n        required: false\n      }\n    ]\n\n    // Step 2a: Count with lightweight includes only\n    const count = await Database.bookModel.count({\n      where: discoverWhere,\n      replacements: userPermissionBookWhere.replacements,\n      include: baseDiscoverInclude,\n      distinct: true,\n      col: 'id',\n      subQuery: false\n    })\n\n    // Step 2b: Select random IDs with lightweight includes only\n    const randomSelection = await Database.bookModel.findAll({\n      attributes: ['id'],\n      where: discoverWhere,\n      replacements: userPermissionBookWhere.replacements,\n      include: baseDiscoverInclude,\n      subQuery: false,\n      distinct: true,\n      limit,\n      order: Database.sequelize.random()\n    })\n\n    const selectedIds = randomSelection.map((b) => b.id).filter(Boolean)\n    if (!selectedIds.length) {\n      return {\n        libraryItems: [],\n        count\n      }\n    }\n\n    // Step 2c: Hydrate selected IDs with full metadata for API response\n    const books = await Database.bookModel.findAll({\n      where: {\n        id: {\n          [Sequelize.Op.in]: selectedIds\n        }\n      },\n      include: [\n        {\n          model: Database.libraryItemModel,\n          where: {\n            libraryId\n          },\n          include: libraryItemIncludes\n        },\n        {\n          model: Database.bookAuthorModel,\n          attributes: ['authorId'],\n          include: {\n            model: Database.authorModel\n          },\n          separate: true\n        },\n        {\n          model: Database.bookSeriesModel,\n          attributes: ['seriesId', 'sequence'],\n          include: {\n            model: Database.seriesModel\n          },\n          separate: true\n        }\n      ],\n      subQuery: false\n    })\n\n    const booksById = new Map(books.map((b) => [b.id, b]))\n    const orderedBooks = selectedIds.map((id) => booksById.get(id)).filter(Boolean)\n\n    // Step 3: Map books to library items\n    const libraryItems = orderedBooks.map((bookExpanded) => {\n      const libraryItem = bookExpanded.libraryItem\n      const book = bookExpanded\n      delete book.libraryItem\n\n      book.series =\n        book.bookSeries?.map((bs) => {\n          const series = bs.series\n          delete bs.series\n          series.bookSeries = bs\n          return series\n        }) || []\n      delete book.bookSeries\n\n      book.authors = book.bookAuthors?.map((ba) => ba.author) || []\n      delete book.bookAuthors\n\n      libraryItem.media = book\n\n      if (libraryItem.feeds?.length) {\n        libraryItem.rssFeed = libraryItem.feeds[0]\n      }\n\n      return libraryItem\n    })\n\n    return {\n      libraryItems,\n      count\n    }\n  },\n\n  /**\n   * Get book library items in a collection\n   * @param {oldCollection} collection\n   * @returns {Promise<import('../../models/LibraryItem')[]>}\n   */\n  async getLibraryItemsForCollection(collection) {\n    if (!collection?.books?.length) {\n      Logger.error(`[libraryItemsBookFilters] Invalid collection`, collection)\n      return []\n    }\n\n    const books = await Database.bookModel.findAll({\n      include: [\n        {\n          model: Database.libraryItemModel,\n          where: {\n            id: {\n              [Sequelize.Op.in]: collection.books\n            }\n          }\n        },\n        {\n          model: Database.authorModel,\n          through: {\n            attributes: []\n          }\n        },\n        {\n          model: Database.seriesModel,\n          through: {\n            attributes: ['sequence']\n          }\n        }\n      ]\n    })\n\n    return books.map((book) => {\n      const libraryItem = book.libraryItem\n      delete book.libraryItem\n      libraryItem.media = book\n      return libraryItem\n    })\n  },\n\n  /**\n   * Get library items for series\n   * @param {import('../../models/Series')} series\n   * @param {import('../../models/User')} [user]\n   * @returns {Promise<import('../../models/LibraryItem')[]>}\n   */\n  async getLibraryItemsForSeries(series, user) {\n    const { libraryItems } = await this.getFilteredLibraryItems(series.libraryId, user, 'series', series.id, null, null, false, [], null, null)\n    return libraryItems\n  },\n\n  /**\n   * Search books, authors, series\n   * @param {import('../../models/User')} user\n   * @param {import('../../models/Library')} library\n   * @param {string} query\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}}\n   */\n  async search(user, library, query, limit, offset) {\n    const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)\n\n    const textSearchQuery = await Database.createTextSearchQuery(query)\n\n    const matchTitle = textSearchQuery.matchExpression('book.title')\n    const matchSubtitle = textSearchQuery.matchExpression('book.subtitle')\n\n    // Search title, subtitle, asin, isbn\n    const books = await Database.bookModel.findAll({\n      where: [\n        {\n          [Sequelize.Op.or]: [\n            Sequelize.literal(matchTitle),\n            Sequelize.literal(matchSubtitle),\n            {\n              asin: {\n                [Sequelize.Op.substring]: query\n              }\n            },\n            {\n              isbn: {\n                [Sequelize.Op.substring]: query\n              }\n            }\n          ]\n        },\n        ...userPermissionBookWhere.bookWhere\n      ],\n      replacements: userPermissionBookWhere.replacements,\n      include: [\n        {\n          model: Database.libraryItemModel,\n          where: {\n            libraryId: library.id\n          }\n        },\n        {\n          model: Database.bookSeriesModel,\n          include: {\n            model: Database.seriesModel\n          },\n          separate: true\n        },\n        {\n          model: Database.bookAuthorModel,\n          include: {\n            model: Database.authorModel\n          },\n          separate: true\n        }\n      ],\n      subQuery: false,\n      distinct: true,\n      limit,\n      offset\n    })\n\n    const itemMatches = []\n\n    for (const book of books) {\n      const libraryItem = book.libraryItem\n      delete book.libraryItem\n\n      book.series = book.bookSeries.map((bs) => {\n        const series = bs.series\n        delete bs.series\n        series.bookSeries = bs\n        return series\n      })\n      delete book.bookSeries\n\n      book.authors = book.bookAuthors.map((ba) => ba.author)\n      delete book.bookAuthors\n\n      libraryItem.media = book\n      itemMatches.push({\n        libraryItem: libraryItem.toOldJSONExpanded()\n      })\n    }\n\n    const matchJsonValue = textSearchQuery.matchExpression('json_each.value')\n\n    // Search narrators\n    const narratorMatches = []\n    const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {\n      replacements: {\n        libraryId: library.id,\n        limit,\n        offset\n      },\n      raw: true\n    })\n    for (const row of narratorResults) {\n      narratorMatches.push({\n        name: row.value,\n        numBooks: row.numBooks\n      })\n    }\n\n    // Search tags\n    const tagMatches = []\n    const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {\n      replacements: {\n        libraryId: library.id,\n        limit,\n        offset\n      },\n      raw: true\n    })\n    for (const row of tagResults) {\n      tagMatches.push({\n        name: row.value,\n        numItems: row.numItems\n      })\n    }\n\n    // Search genres\n    const genreMatches = []\n    const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {\n      replacements: {\n        libraryId: library.id,\n        limit,\n        offset\n      },\n      raw: true\n    })\n    for (const row of genreResults) {\n      genreMatches.push({\n        name: row.value,\n        numItems: row.numItems\n      })\n    }\n\n    // Search series\n    const matchName = textSearchQuery.matchExpression('name')\n    const allSeries = await Database.seriesModel.findAll({\n      where: {\n        [Sequelize.Op.and]: [\n          Sequelize.literal(matchName),\n          {\n            libraryId: library.id\n          }\n        ]\n      },\n      replacements: userPermissionBookWhere.replacements,\n      include: {\n        separate: true,\n        model: Database.bookSeriesModel,\n        include: {\n          model: Database.bookModel,\n          where: userPermissionBookWhere.bookWhere,\n          include: {\n            model: Database.libraryItemModel\n          }\n        }\n      },\n      subQuery: false,\n      distinct: true,\n      limit,\n      offset\n    })\n    const seriesMatches = []\n    for (const series of allSeries) {\n      const books = series.bookSeries.map((bs) => {\n        const libraryItem = bs.book.libraryItem\n        libraryItem.media = bs.book\n        libraryItem.media.authors = []\n        libraryItem.media.series = []\n        return libraryItem.toOldJSON()\n      })\n      if (books.length) {\n        seriesMatches.push({\n          series: series.toOldJSON(),\n          books\n        })\n      }\n    }\n\n    // Search authors\n    const authorMatches = await authorFilters.search(library.id, textSearchQuery, limit, offset)\n\n    return {\n      book: itemMatches,\n      narrators: narratorMatches,\n      tags: tagMatches,\n      genres: genreMatches,\n      series: seriesMatches,\n      authors: authorMatches\n    }\n  },\n\n  /**\n   * Genres with num books\n   * @param {string} libraryId\n   * @returns {{genre:string, count:number}[]}\n   */\n  async getGenresWithCount(libraryId) {\n    const genres = []\n    const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {\n      replacements: {\n        libraryId\n      },\n      raw: true\n    })\n    for (const row of genreResults) {\n      genres.push({\n        genre: row.value,\n        count: row.numItems\n      })\n    }\n    return genres\n  },\n\n  /**\n   * Get stats for book library\n   * @param {string} libraryId\n   * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}\n   */\n  async getBookLibraryStats(libraryId) {\n    const [statResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, SUM(json_array_length(b.audioFiles)) AS numAudioFiles, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.libraryId = :libraryId;`, {\n      replacements: {\n        libraryId\n      }\n    })\n    return {\n      totalSize: statResults?.[0]?.totalSize || 0,\n      totalDuration: statResults?.[0]?.totalDuration || 0,\n      numAudioFiles: statResults?.[0]?.numAudioFiles || 0,\n      totalItems: statResults?.[0]?.totalItems || 0\n    }\n  },\n\n  /**\n   * Get longest books in library\n   * @param {string} libraryId\n   * @param {number} limit\n   * @returns {Promise<{ id:string, title:string, duration:number }[]>}\n   */\n  async getLongestBooks(libraryId, limit) {\n    const books = await Database.bookModel.findAll({\n      attributes: ['id', 'title', 'duration'],\n      include: {\n        model: Database.libraryItemModel,\n        attributes: ['id', 'libraryId'],\n        where: {\n          libraryId\n        }\n      },\n      order: [['duration', 'DESC']],\n      limit\n    })\n    return books.map((book) => {\n      return {\n        id: book.libraryItem.id,\n        title: book.title,\n        duration: book.duration\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/libraryItemsPodcastFilters.js",
    "content": "const Sequelize = require('sequelize')\nconst Database = require('../../Database')\nconst Logger = require('../../Logger')\nconst { profile } = require('../../utils/profiler')\nconst stringifySequelizeQuery = require('../stringifySequelizeQuery')\n\nconst countCache = new Map()\n\nmodule.exports = {\n  /**\n   * User permissions to restrict podcasts for explicit content & tags\n   * @param {import('../../models/User')} user\n   * @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }}\n   */\n  getUserPermissionPodcastWhereQuery(user) {\n    const podcastWhere = []\n    const replacements = {}\n    if (!user.canAccessExplicitContent) {\n      podcastWhere.push({\n        explicit: false\n      })\n    }\n\n    if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {\n      replacements['userTagsSelected'] = user.permissions.itemTagsSelected\n      if (user.permissions.selectedTagsNotAccessible) {\n        podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))\n      } else {\n        podcastWhere.push(\n          Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {\n            [Sequelize.Op.gte]: 1\n          })\n        )\n      }\n    }\n\n    return {\n      podcastWhere,\n      replacements\n    }\n  },\n\n  /**\n   * Get where options for Podcast model\n   * @param {string} group\n   * @param {[string]} value\n   * @returns {object} { Sequelize.WhereOptions, string[] }\n   */\n  getMediaGroupQuery(group, value) {\n    if (!group) return { mediaWhere: {}, replacements: {} }\n\n    let mediaWhere = {}\n    const replacements = {}\n\n    if (['genres', 'tags'].includes(group)) {\n      mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {\n        [Sequelize.Op.gte]: 1\n      })\n      replacements.filterValue = value\n    } else if (group === 'languages') {\n      mediaWhere['language'] = value\n    } else if (group === 'explicit') {\n      mediaWhere['explicit'] = true\n    }\n\n    return {\n      mediaWhere,\n      replacements\n    }\n  },\n\n  /**\n   * Get sequelize order\n   * @param {string} sortBy\n   * @param {boolean} sortDesc\n   * @returns {Sequelize.order}\n   */\n  getOrder(sortBy, sortDesc) {\n    const dir = sortDesc ? 'DESC' : 'ASC'\n    if (sortBy === 'addedAt') {\n      return [[Sequelize.literal('libraryItem.createdAt'), dir]]\n    } else if (sortBy === 'size') {\n      return [[Sequelize.literal('libraryItem.size'), dir]]\n    } else if (sortBy === 'birthtimeMs') {\n      return [[Sequelize.literal('libraryItem.birthtime'), dir]]\n    } else if (sortBy === 'mtimeMs') {\n      return [[Sequelize.literal('libraryItem.mtime'), dir]]\n    } else if (sortBy === 'media.metadata.author') {\n      const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'\n      return [[Sequelize.literal(`\\`podcast\\`.\\`author\\` COLLATE NOCASE ${nullDir}`)]]\n    } else if (sortBy === 'media.metadata.title') {\n      if (global.ServerSettings.sortingIgnorePrefix) {\n        return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]\n      } else {\n        return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]\n      }\n    } else if (sortBy === 'media.numTracks') {\n      return [['numEpisodes', dir]]\n    } else if (sortBy === 'random') {\n      return [Database.sequelize.random()]\n    }\n    return []\n  },\n\n  clearCountCache(model, hook) {\n    Logger.debug(`[LibraryItemsPodcastFilters] ${model}.${hook}: Clearing count cache`)\n    countCache.clear()\n  },\n\n  async findAndCountAll(findOptions, model, limit, offset, useCountCache) {\n    if (useCountCache) {\n      const countCacheKey = stringifySequelizeQuery(findOptions)\n      Logger.debug(`[LibraryItemsPodcastFilters] countCacheKey: ${countCacheKey}`)\n      if (!countCache.has(countCacheKey)) {\n        const count = await model.count(findOptions)\n        countCache.set(countCacheKey, count)\n      }\n\n      findOptions.limit = limit || null\n      findOptions.offset = offset\n\n      const rows = await model.findAll(findOptions)\n\n      return { rows, count: countCache.get(countCacheKey) }\n    }\n\n    findOptions.limit = limit || null\n    findOptions.offset = offset\n\n    return await model.findAndCountAll(findOptions)\n  },\n\n  /**\n   * Get library items for podcast media type using filter and sort\n   * @param {string} libraryId\n   * @param {import('../../models/User')} user\n   * @param {[string]} filterGroup\n   * @param {[string]} filterValue\n   * @param {string} sortBy\n   * @param {string} sortDesc\n   * @param {string[]} include\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}\n   */\n  async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {\n    const includeRSSFeed = include.includes('rssfeed')\n    const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete')\n\n    const libraryItemWhere = {\n      libraryId\n    }\n    const libraryItemIncludes = []\n    if (filterGroup === 'feed-open' || includeRSSFeed) {\n      const rssFeedRequired = filterGroup === 'feed-open'\n      libraryItemIncludes.push({\n        model: Database.feedModel,\n        required: rssFeedRequired,\n        separate: !rssFeedRequired\n      })\n    }\n    if (filterGroup === 'issues') {\n      libraryItemWhere[Sequelize.Op.or] = [\n        {\n          isMissing: true\n        },\n        {\n          isInvalid: true\n        }\n      ]\n    } else if (filterGroup === 'recent') {\n      libraryItemWhere['createdAt'] = {\n        [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago\n      }\n    }\n\n    const podcastIncludes = []\n\n    let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)\n    replacements.userId = user.id\n\n    const podcastWhere = []\n    if (Object.keys(mediaWhere).length) podcastWhere.push(mediaWhere)\n\n    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)\n    replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }\n    podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)\n\n    const findOptions = {\n      where: podcastWhere,\n      replacements,\n      distinct: true,\n      attributes: {\n        include: [...podcastIncludes]\n      },\n      include: [\n        {\n          model: Database.libraryItemModel,\n          required: true,\n          where: libraryItemWhere,\n          include: libraryItemIncludes\n        }\n      ],\n      order: this.getOrder(sortBy, sortDesc),\n      subQuery: false\n    }\n\n    const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll\n\n    const { rows: podcasts, count } = await findAndCountAll(findOptions, Database.podcastModel, limit, offset, !filterGroup && !userPermissionPodcastWhere.podcastWhere.length)\n\n    const libraryItems = podcasts.map((podcastExpanded) => {\n      const libraryItem = podcastExpanded.libraryItem\n      const podcast = podcastExpanded\n\n      delete podcast.libraryItem\n\n      if (libraryItem.feeds?.length) {\n        libraryItem.rssFeed = libraryItem.feeds[0]\n      }\n\n      if (includeNumEpisodesIncomplete) {\n        const numEpisodesComplete = user.mediaProgresses.reduce((acc, mp) => {\n          if (mp.podcastId === podcast.id && mp.isFinished) {\n            acc += 1\n          }\n          return acc\n        }, 0)\n        libraryItem.numEpisodesIncomplete = podcast.numEpisodes - numEpisodesComplete\n      }\n\n      libraryItem.media = podcast\n\n      return libraryItem\n    })\n\n    return {\n      libraryItems,\n      count\n    }\n  },\n\n  /**\n   * Get podcast episodes filtered and sorted\n   * @param {string} libraryId\n   * @param {import('../../models/User')} user\n   * @param {[string]} filterGroup\n   * @param {[string]} filterValue\n   * @param {string} sortBy\n   * @param {string} sortDesc\n   * @param {number} limit\n   * @param {number} offset\n   * @param {boolean} isHomePage for home page shelves\n   * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>}\n   */\n  async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset, isHomePage = false) {\n    if (sortBy === 'progress' && filterGroup !== 'progress') {\n      Logger.warn('Cannot sort podcast episodes by progress without filtering by progress')\n      sortBy = 'createdAt'\n    }\n\n    const podcastEpisodeIncludes = []\n    let podcastEpisodeWhere = {}\n    let libraryItemWhere = {\n      libraryId\n    }\n    if (filterGroup === 'progress') {\n      const mediaProgressWhere = {\n        userId: user.id\n      }\n      // Respect hide from continue listening for home page shelf\n      if (isHomePage) {\n        mediaProgressWhere.hideFromContinueListening = false\n      }\n      podcastEpisodeIncludes.push({\n        model: Database.mediaProgressModel,\n        where: mediaProgressWhere,\n        attributes: ['id', 'isFinished', 'currentTime', 'updatedAt']\n      })\n\n      if (filterValue === 'in-progress') {\n        podcastEpisodeWhere = [\n          {\n            '$mediaProgresses.isFinished$': false\n          },\n          {\n            '$mediaProgresses.currentTime$': {\n              [Sequelize.Op.gt]: 0\n            }\n          }\n        ]\n      } else if (filterValue === 'finished') {\n        podcastEpisodeWhere['$mediaProgresses.isFinished$'] = true\n      }\n    } else if (filterGroup === 'recent') {\n      podcastEpisodeWhere['createdAt'] = {\n        [Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago\n      }\n    }\n\n    const podcastEpisodeOrder = []\n    if (sortBy === 'createdAt') {\n      podcastEpisodeOrder.push(['createdAt', sortDesc ? 'DESC' : 'ASC'])\n    } else if (sortBy === 'progress') {\n      podcastEpisodeOrder.push([Sequelize.literal('mediaProgresses.updatedAt'), sortDesc ? 'DESC' : 'ASC'])\n    }\n\n    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)\n\n    const findOptions = {\n      where: podcastEpisodeWhere,\n      replacements: userPermissionPodcastWhere.replacements,\n      include: [\n        {\n          model: Database.podcastModel,\n          required: true,\n          where: userPermissionPodcastWhere.podcastWhere,\n          include: [\n            {\n              model: Database.libraryItemModel,\n              required: true,\n              where: libraryItemWhere\n            }\n          ]\n        },\n        ...podcastEpisodeIncludes\n      ],\n      subQuery: false,\n      order: podcastEpisodeOrder\n    }\n\n    const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll\n\n    const { rows: podcastEpisodes, count } = await findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset, !filterGroup)\n\n    const libraryItems = podcastEpisodes.map((ep) => {\n      const libraryItem = ep.podcast.libraryItem\n      const podcast = ep.podcast\n      delete podcast.libraryItem\n      libraryItem.media = podcast\n\n      libraryItem.recentEpisode = ep.toOldJSON(libraryItem.id)\n      return libraryItem\n    })\n\n    return {\n      libraryItems,\n      count\n    }\n  },\n\n  /**\n   * Search podcasts\n   * @param {import('../../models/User')} user\n   * @param {import('../../models/Library')} library\n   * @param {string} query\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {{podcast:object[], tags:object[]}}\n   */\n  async search(user, library, query, limit, offset) {\n    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)\n\n    const textSearchQuery = await Database.createTextSearchQuery(query)\n\n    const matchTitle = textSearchQuery.matchExpression('podcast.title')\n    const matchAuthor = textSearchQuery.matchExpression('podcast.author')\n\n    // Search title, author, itunesId, itunesArtistId\n    const podcasts = await Database.podcastModel.findAll({\n      where: [\n        {\n          [Sequelize.Op.or]: [\n            Sequelize.literal(matchTitle),\n            Sequelize.literal(matchAuthor),\n            {\n              itunesId: {\n                [Sequelize.Op.substring]: query\n              }\n            },\n            {\n              itunesArtistId: {\n                [Sequelize.Op.substring]: query\n              }\n            }\n          ]\n        },\n        ...userPermissionPodcastWhere.podcastWhere\n      ],\n      replacements: userPermissionPodcastWhere.replacements,\n      include: [\n        {\n          model: Database.libraryItemModel,\n          where: {\n            libraryId: library.id\n          }\n        }\n      ],\n      subQuery: false,\n      distinct: true,\n      limit,\n      offset\n    })\n\n    const itemMatches = []\n\n    for (const podcast of podcasts) {\n      const libraryItem = podcast.libraryItem\n      delete podcast.libraryItem\n      libraryItem.media = podcast\n      libraryItem.media.podcastEpisodes = []\n      itemMatches.push({\n        libraryItem: libraryItem.toOldJSONExpanded()\n      })\n    }\n\n    // Search podcast episode title\n    const podcastEpisodes = await Database.podcastEpisodeModel.findAll({\n      where: [\n        Sequelize.literal(textSearchQuery.matchExpression('podcastEpisode.title')),\n        {\n          '$podcast.libraryItem.libraryId$': library.id\n        }\n      ],\n      replacements: userPermissionPodcastWhere.replacements,\n      include: [\n        {\n          model: Database.podcastModel,\n          where: [...userPermissionPodcastWhere.podcastWhere],\n          include: [\n            {\n              model: Database.libraryItemModel\n            }\n          ]\n        }\n      ],\n      distinct: true,\n      offset,\n      limit\n    })\n    const episodeMatches = []\n    for (const episode of podcastEpisodes) {\n      const libraryItem = episode.podcast.libraryItem\n      libraryItem.media = episode.podcast\n      libraryItem.media.podcastEpisodes = []\n      const oldPodcastEpisodeJson = episode.toOldJSONExpanded(libraryItem.id)\n      const libraryItemJson = libraryItem.toOldJSONExpanded()\n      libraryItemJson.recentEpisode = oldPodcastEpisodeJson\n      episodeMatches.push({\n        libraryItem: libraryItemJson\n      })\n    }\n\n    const matchJsonValue = textSearchQuery.matchExpression('json_each.value')\n\n    // Search tags\n    const tagMatches = []\n    const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {\n      replacements: {\n        libraryId: library.id,\n        limit,\n        offset\n      },\n      raw: true\n    })\n    for (const row of tagResults) {\n      tagMatches.push({\n        name: row.value,\n        numItems: row.numItems\n      })\n    }\n\n    // Search genres\n    const genreMatches = []\n    const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {\n      replacements: {\n        libraryId: library.id,\n        limit,\n        offset\n      },\n      raw: true\n    })\n    for (const row of genreResults) {\n      genreMatches.push({\n        name: row.value,\n        numItems: row.numItems\n      })\n    }\n\n    return {\n      podcast: itemMatches,\n      tags: tagMatches,\n      genres: genreMatches,\n      episodes: episodeMatches\n    }\n  },\n\n  /**\n   * Most recent podcast episodes not finished\n   * @param {import('../../models/User')} user\n   * @param {import('../../models/Library')} library\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {Promise<object[]>}\n   */\n  async getRecentEpisodes(user, library, limit, offset) {\n    const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)\n\n    const findOptions = {\n      where: {\n        '$mediaProgresses.isFinished$': {\n          [Sequelize.Op.or]: [null, false]\n        }\n      },\n      replacements: userPermissionPodcastWhere.replacements,\n      include: [\n        {\n          model: Database.podcastModel,\n          where: userPermissionPodcastWhere.podcastWhere,\n          required: true,\n          include: {\n            model: Database.libraryItemModel,\n            where: {\n              libraryId: library.id\n            }\n          }\n        },\n        {\n          model: Database.mediaProgressModel,\n          where: {\n            userId: user.id\n          },\n          required: false\n        }\n      ],\n      order: [['publishedAt', 'DESC']],\n      subQuery: false,\n      limit,\n      offset\n    }\n\n    const findtAll = process.env.QUERY_PROFILING ? profile(Database.podcastEpisodeModel.findAll.bind(Database.podcastEpisodeModel)) : Database.podcastEpisodeModel.findAll.bind(Database.podcastEpisodeModel)\n\n    const episodes = await findtAll(findOptions)\n\n    const episodeResults = episodes.map((ep) => {\n      ep.podcast.podcastEpisodes = [] // Not needed\n      const oldPodcastJson = ep.podcast.toOldJSON(ep.podcast.libraryItem.id)\n\n      const oldPodcastEpisodeJson = ep.toOldJSONExpanded(ep.podcast.libraryItem.id)\n\n      oldPodcastEpisodeJson.podcast = oldPodcastJson\n      oldPodcastEpisodeJson.libraryId = ep.podcast.libraryItem.libraryId\n      return oldPodcastEpisodeJson\n    })\n\n    return episodeResults\n  },\n\n  /**\n   * Get stats for podcast library\n   * @param {string} libraryId\n   * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}\n   */\n  async getPodcastLibraryStats(libraryId) {\n    const [sizeResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize FROM libraryItems li WHERE li.mediaType = \"podcast\" AND li.libraryId = :libraryId;`, {\n      replacements: {\n        libraryId\n      }\n    })\n    const [statResults] = await Database.sequelize.query(`SELECT SUM(json_extract(pe.audioFile, '$.duration')) AS totalDuration, COUNT(DISTINCT(li.id)) AS totalItems, COUNT(pe.id) AS numAudioFiles FROM libraryItems li, podcasts p LEFT OUTER JOIN podcastEpisodes pe ON pe.podcastId = p.id WHERE p.id = li.mediaId AND li.libraryId = :libraryId;`, {\n      replacements: {\n        libraryId\n      }\n    })\n    return {\n      totalDuration: statResults?.[0]?.totalDuration || 0,\n      numAudioFiles: statResults?.[0]?.numAudioFiles || 0,\n      totalItems: statResults?.[0]?.totalItems || 0,\n      totalSize: sizeResults?.[0]?.totalSize || 0\n    }\n  },\n\n  /**\n   * Genres with num podcasts\n   * @param {string} libraryId\n   * @returns {{genre:string, count:number}[]}\n   */\n  async getGenresWithCount(libraryId) {\n    const genres = []\n    const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, {\n      replacements: {\n        libraryId\n      },\n      raw: true\n    })\n    for (const row of genreResults) {\n      genres.push({\n        genre: row.value,\n        count: row.numItems\n      })\n    }\n    return genres\n  },\n\n  /**\n   * Get longest podcasts in library\n   * @param {string} libraryId\n   * @param {number} limit\n   * @returns {Promise<{ id:string, title:string, duration:number }[]>}\n   */\n  async getLongestPodcasts(libraryId, limit) {\n    const podcasts = await Database.podcastModel.findAll({\n      attributes: ['id', 'title', [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']],\n      include: {\n        model: Database.libraryItemModel,\n        attributes: ['id', 'libraryId'],\n        where: {\n          libraryId\n        }\n      },\n      order: [['duration', 'DESC']],\n      limit\n    })\n    return podcasts.map((podcast) => {\n      return {\n        id: podcast.libraryItem.id,\n        title: podcast.title,\n        duration: podcast.dataValues.duration\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/seriesFilters.js",
    "content": "const Sequelize = require('sequelize')\nconst Logger = require('../../Logger')\nconst Database = require('../../Database')\nconst libraryItemsBookFilters = require('./libraryItemsBookFilters')\n\nmodule.exports = {\n  decode(text) {\n    return Buffer.from(decodeURIComponent(text), 'base64').toString()\n  },\n\n  /**\n   * Get series filtered and sorted\n   *\n   * @param {import('../../models/Library')} library\n   * @param {import('../../models/User')} user\n   * @param {string} filterBy\n   * @param {string} sortBy\n   * @param {boolean} sortDesc\n   * @param {string[]} include\n   * @param {number} limit\n   * @param {number} offset\n   * @returns {Promise<{ series:object[], count:number }>}\n   */\n  async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) {\n    let filterValue = null\n    let filterGroup = null\n    if (filterBy) {\n      const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']\n      const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.'))\n      filterGroup = group || filterBy\n      filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null\n    }\n\n    const seriesIncludes = []\n    if (include.includes('rssfeed')) {\n      seriesIncludes.push({\n        model: Database.feedModel\n      })\n    }\n\n    const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)\n\n    const seriesWhere = [\n      {\n        libraryId: library.id\n      }\n    ]\n\n    // Handle library setting to hide single book series\n    // TODO: Merge with existing query\n    if (library.settings.hideSingleBookSeries) {\n      seriesWhere.push(\n        Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {\n          [Sequelize.Op.gt]: 1\n        })\n      )\n    }\n\n    // Handle filters\n    // TODO: Simplify and break-out\n    let attrQuery = null\n    if (['genres', 'tags', 'narrators'].includes(filterGroup)) {\n      attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0`\n      userPermissionBookWhere.replacements.filterValue = filterValue\n    } else if (filterGroup === 'authors') {\n      attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue'\n      userPermissionBookWhere.replacements.filterValue = filterValue\n    } else if (filterGroup === 'publishers') {\n      attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.publisher = :filterValue'\n      userPermissionBookWhere.replacements.filterValue = filterValue\n    } else if (filterGroup === 'languages') {\n      attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.language = :filterValue'\n      userPermissionBookWhere.replacements.filterValue = filterValue\n    } else if (filterGroup === 'progress') {\n      if (filterValue === 'not-finished') {\n        attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'\n        userPermissionBookWhere.replacements.userId = user.id\n      } else if (filterValue === 'finished') {\n        const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'\n        seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))\n        userPermissionBookWhere.replacements.userId = user.id\n      } else if (filterValue === 'not-started') {\n        const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'\n        seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))\n        userPermissionBookWhere.replacements.userId = user.id\n      } else if (filterValue === 'in-progress') {\n        attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'\n        userPermissionBookWhere.replacements.userId = user.id\n      }\n    }\n\n    // Handle user permissions to only include series with at least 1 book\n    // TODO: Simplify to a single query\n    if (userPermissionBookWhere.bookWhere.length) {\n      if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'\n\n      if (!user.canAccessExplicitContent) {\n        attrQuery += ' AND b.explicit = 0'\n      }\n      if (!user.permissions?.accessAllTags && user.permissions?.itemTagsSelected?.length) {\n        if (user.permissions.selectedTagsNotAccessible) {\n          attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'\n        } else {\n          attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'\n        }\n      }\n    }\n\n    if (attrQuery) {\n      seriesWhere.push(\n        Sequelize.where(Sequelize.literal(`(${attrQuery})`), {\n          [Sequelize.Op.gt]: 0\n        })\n      )\n    }\n\n    const order = []\n    let seriesAttributes = {\n      include: []\n    }\n\n    // Handle sort order\n    const dir = sortDesc ? 'DESC' : 'ASC'\n    if (sortBy === 'numBooks') {\n      seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks'])\n      order.push(['numBooks', dir])\n    } else if (sortBy === 'addedAt') {\n      order.push(['createdAt', dir])\n    } else if (sortBy === 'name') {\n      if (global.ServerSettings.sortingIgnorePrefix) {\n        order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir])\n      } else {\n        order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir])\n      }\n    } else if (sortBy === 'totalDuration') {\n      seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration'])\n      order.push(['totalDuration', dir])\n    } else if (sortBy === 'lastBookAdded') {\n      seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.createdAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookAdded'])\n      order.push(['mostRecentBookAdded', dir])\n    } else if (sortBy === 'lastBookUpdated') {\n      seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated'])\n      order.push(['mostRecentBookUpdated', dir])\n    } else if (sortBy === 'random') {\n      order.push(Database.sequelize.random())\n    }\n\n    const { rows: series, count } = await Database.seriesModel.findAndCountAll({\n      where: seriesWhere,\n      limit,\n      offset,\n      distinct: true,\n      subQuery: false,\n      attributes: seriesAttributes,\n      replacements: userPermissionBookWhere.replacements,\n      include: [\n        {\n          model: Database.bookSeriesModel,\n          include: {\n            model: Database.bookModel,\n            where: userPermissionBookWhere.bookWhere,\n            include: [\n              {\n                model: Database.libraryItemModel\n              },\n              {\n                model: Database.authorModel\n              },\n              {\n                model: Database.seriesModel\n              }\n            ]\n          },\n          separate: true\n        },\n        ...seriesIncludes\n      ],\n      order\n    })\n\n    // Map series to old series\n    const allOldSeries = []\n    for (const s of series) {\n      const oldSeries = s.toOldJSON()\n\n      if (s.dataValues.totalDuration) {\n        oldSeries.totalDuration = s.dataValues.totalDuration\n      }\n\n      if (s.feeds?.length) {\n        oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()\n      }\n\n      // TODO: Sort books by sequence in query\n      s.bookSeries.sort((a, b) => {\n        if (!a.sequence) return 1\n        if (!b.sequence) return -1\n        return a.sequence.localeCompare(b.sequence, undefined, {\n          numeric: true,\n          sensitivity: 'base'\n        })\n      })\n      oldSeries.books = s.bookSeries.map((bs) => {\n        const libraryItem = bs.book.libraryItem\n        delete bs.book.libraryItem\n        libraryItem.media = bs.book\n        const oldLibraryItem = libraryItem.toOldJSONMinified()\n        return oldLibraryItem\n      })\n      allOldSeries.push(oldSeries)\n    }\n\n    return {\n      series: allOldSeries,\n      count\n    }\n  }\n}\n"
  },
  {
    "path": "server/utils/queries/userStats.js",
    "content": "const Sequelize = require('sequelize')\nconst Database = require('../../Database')\nconst PlaybackSession = require('../../models/PlaybackSession')\nconst MediaProgress = require('../../models/MediaProgress')\nconst fsExtra = require('../../libs/fsExtra')\n\nmodule.exports = {\n  /**\n   *\n   * @param {string} userId\n   * @param {number} year YYYY\n   * @returns {Promise<PlaybackSession[]>}\n   */\n  async getUserListeningSessionsForYear(userId, year) {\n    const sessions = await Database.playbackSessionModel.findAll({\n      where: {\n        userId,\n        createdAt: {\n          [Sequelize.Op.gte]: `${year}-01-01`,\n          [Sequelize.Op.lt]: `${year + 1}-01-01`\n        }\n      },\n      include: {\n        model: Database.bookModel,\n        attributes: ['id', 'coverPath'],\n        include: {\n          model: Database.libraryItemModel,\n          attributes: ['id', 'mediaId', 'mediaType']\n        },\n        required: false\n      },\n      order: Database.sequelize.random()\n    })\n    return sessions\n  },\n\n  /**\n   *\n   * @param {string} userId\n   * @param {number} year YYYY\n   * @returns {Promise<MediaProgress[]>}\n   */\n  async getBookMediaProgressFinishedForYear(userId, year) {\n    const progresses = await Database.mediaProgressModel.findAll({\n      where: {\n        userId,\n        mediaItemType: 'book',\n        finishedAt: {\n          [Sequelize.Op.gte]: `${year}-01-01`,\n          [Sequelize.Op.lt]: `${year + 1}-01-01`\n        }\n      },\n      include: {\n        model: Database.bookModel,\n        attributes: ['id', 'title', 'coverPath'],\n        include: {\n          model: Database.libraryItemModel,\n          attributes: ['id', 'mediaId', 'mediaType']\n        },\n        required: true\n      },\n      order: Database.sequelize.random()\n    })\n    return progresses\n  },\n\n  /**\n   * @param {string} userId\n   * @param {number} year YYYY\n   */\n  async getStatsForYear(userId, year) {\n    const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)\n    const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)\n\n    let totalBookListeningTime = 0\n    let totalPodcastListeningTime = 0\n    let totalListeningTime = 0\n\n    let authorListeningMap = {}\n    let genreListeningMap = {}\n    let narratorListeningMap = {}\n    let monthListeningMap = {}\n    let bookListeningMap = {}\n\n    const booksWithCovers = []\n    const finishedBooksWithCovers = []\n\n    // Get finished book stats\n    const numBooksFinished = bookProgressesFinished.length\n    let longestAudiobookFinished = null\n    for (const mediaProgress of bookProgressesFinished) {\n      // Grab first 5 that have a cover\n      if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && (await fsExtra.pathExists(mediaProgress.mediaItem.coverPath))) {\n        finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id)\n      }\n\n      if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {\n        longestAudiobookFinished = {\n          id: mediaProgress.mediaItem.id,\n          title: mediaProgress.mediaItem.title,\n          duration: Math.round(mediaProgress.duration),\n          finishedAt: mediaProgress.finishedAt\n        }\n      }\n    }\n\n    // Get listening session stats\n    for (const ls of listeningSessions) {\n      // Grab first 25 that have a cover\n      if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(ls.mediaItem.coverPath))) {\n        booksWithCovers.push(ls.mediaItem.libraryItem.id)\n      }\n\n      const listeningSessionListeningTime = ls.timeListening || 0\n\n      const lsMonth = ls.createdAt.getMonth()\n      if (!monthListeningMap[lsMonth]) monthListeningMap[lsMonth] = 0\n      monthListeningMap[lsMonth] += listeningSessionListeningTime\n\n      totalListeningTime += listeningSessionListeningTime\n      if (ls.mediaItemType === 'book') {\n        totalBookListeningTime += listeningSessionListeningTime\n\n        if (ls.displayTitle && !bookListeningMap[ls.displayTitle]) {\n          bookListeningMap[ls.displayTitle] = listeningSessionListeningTime\n        } else if (ls.displayTitle) {\n          bookListeningMap[ls.displayTitle] += listeningSessionListeningTime\n        }\n\n        const authors = ls.mediaMetadata?.authors || []\n        authors.forEach((au) => {\n          if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0\n          authorListeningMap[au.name] += listeningSessionListeningTime\n        })\n\n        const narrators = ls.mediaMetadata?.narrators || []\n        narrators.forEach((narrator) => {\n          if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0\n          narratorListeningMap[narrator] += listeningSessionListeningTime\n        })\n\n        // Filter out bad genres like \"audiobook\" and \"audio book\"\n        const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))\n        genres.forEach((genre) => {\n          if (!genreListeningMap[genre]) genreListeningMap[genre] = 0\n          genreListeningMap[genre] += listeningSessionListeningTime\n        })\n      } else {\n        totalPodcastListeningTime += listeningSessionListeningTime\n      }\n    }\n\n    totalListeningTime = Math.round(totalListeningTime)\n    totalBookListeningTime = Math.round(totalBookListeningTime)\n    totalPodcastListeningTime = Math.round(totalPodcastListeningTime)\n\n    let topAuthors = null\n    topAuthors = Object.keys(authorListeningMap)\n      .map((authorName) => ({\n        name: authorName,\n        time: Math.round(authorListeningMap[authorName])\n      }))\n      .sort((a, b) => b.time - a.time)\n      .slice(0, 3)\n\n    let mostListenedNarrator = null\n    for (const narrator in narratorListeningMap) {\n      if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) {\n        mostListenedNarrator = {\n          time: Math.round(narratorListeningMap[narrator]),\n          name: narrator\n        }\n      }\n    }\n\n    let topGenres = null\n    topGenres = Object.keys(genreListeningMap)\n      .map((genre) => ({\n        genre,\n        time: Math.round(genreListeningMap[genre])\n      }))\n      .sort((a, b) => b.time - a.time)\n      .slice(0, 3)\n\n    let mostListenedMonth = null\n    for (const month in monthListeningMap) {\n      if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) {\n        mostListenedMonth = {\n          month: Number(month),\n          time: Math.round(monthListeningMap[month])\n        }\n      }\n    }\n\n    return {\n      totalListeningSessions: listeningSessions.length,\n      totalListeningTime,\n      totalBookListeningTime,\n      totalPodcastListeningTime,\n      topAuthors,\n      topGenres,\n      mostListenedNarrator,\n      mostListenedMonth,\n      numBooksFinished,\n      numBooksListened: Object.keys(bookListeningMap).length,\n      longestAudiobookFinished,\n      booksWithCovers,\n      finishedBooksWithCovers\n    }\n  }\n}\n"
  },
  {
    "path": "server/utils/rateLimiterFactory.js",
    "content": "const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit')\nconst Logger = require('../Logger')\nconst requestIp = require('../libs/requestIp')\n\n/**\n * Factory for creating authentication rate limiters\n */\nclass RateLimiterFactory {\n  static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes\n  static DEFAULT_MAX = 40 // 40 attempts\n\n  constructor() {\n    this.authRateLimiter = null\n  }\n\n  /**\n   * Get the authentication rate limiter\n   * @returns {RateLimitRequestHandler}\n   */\n  getAuthRateLimiter() {\n    if (this.authRateLimiter) {\n      return this.authRateLimiter\n    }\n\n    // Disable by setting max to 0\n    if (process.env.RATE_LIMIT_AUTH_MAX === '0') {\n      this.authRateLimiter = (req, res, next) => next()\n      Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`)\n      return this.authRateLimiter\n    }\n\n    let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS\n    if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) {\n      windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW)\n      if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) {\n        Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`)\n      }\n    }\n\n    let max = RateLimiterFactory.DEFAULT_MAX\n    if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) {\n      max = parseInt(process.env.RATE_LIMIT_AUTH_MAX)\n      if (max !== RateLimiterFactory.DEFAULT_MAX) {\n        Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`)\n      }\n    }\n\n    let message = 'Too many authentication requests'\n    if (process.env.RATE_LIMIT_AUTH_MESSAGE) {\n      message = process.env.RATE_LIMIT_AUTH_MESSAGE\n    }\n\n    this.authRateLimiter = rateLimit({\n      windowMs,\n      max,\n      standardHeaders: true,\n      legacyHeaders: false,\n      keyGenerator: (req) => {\n        // Override keyGenerator to handle proxy IPs\n        return requestIp.getClientIp(req) || req.ip\n      },\n      handler: (req, res) => {\n        const userAgent = req.get('User-Agent') || 'Unknown'\n        const endpoint = req.path\n        const method = req.method\n        const ip = requestIp.getClientIp(req) || req.ip\n\n        Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`)\n\n        res.status(429).json({\n          error: message\n        })\n      }\n    })\n\n    Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`)\n\n    return this.authRateLimiter\n  }\n}\n\nmodule.exports = new RateLimiterFactory()\n"
  },
  {
    "path": "server/utils/scandir.js",
    "content": "const Path = require('path')\nconst { filePathToPOSIX } = require('./fileUtils')\nconst globals = require('./globals')\nconst LibraryFile = require('../objects/files/LibraryFile')\nconst parseNameString = require('./parsers/parseNameString')\n\n/**\n * @typedef LibraryItemFilenameMetadata\n * @property {string} title\n * @property {string} subtitle Book mediaType only\n * @property {string} asin Book mediaType only\n * @property {string[]} authors Book mediaType only\n * @property {string[]} narrators Book mediaType only\n * @property {string} seriesName Book mediaType only\n * @property {string} seriesSequence Book mediaType only\n * @property {string} publishedYear Book mediaType only\n */\n\nfunction isMediaFile(mediaType, ext, audiobooksOnly = false) {\n  if (!ext) return false\n  const extclean = ext.slice(1).toLowerCase()\n  if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean)\n  else if (audiobooksOnly) return globals.SupportedAudioTypes.includes(extclean)\n  return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)\n}\n\nfunction isScannableNonMediaFile(ext) {\n  if (!ext) return false\n  const extclean = ext.slice(1).toLowerCase()\n  return globals.TextFileTypes.includes(extclean) || globals.MetadataFileTypes.includes(extclean) || globals.SupportedImageTypes.includes(extclean)\n}\n\nfunction checkFilepathIsAudioFile(filepath) {\n  const ext = Path.extname(filepath)\n  if (!ext) return false\n  const extclean = ext.slice(1).toLowerCase()\n  return globals.SupportedAudioTypes.includes(extclean)\n}\nmodule.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile\n\n/**\n * @param {string} mediaType\n * @param {import('./fileUtils').FilePathItem[]} fileItems\n * @param {boolean} audiobooksOnly\n * @param {boolean} [includeNonMediaFiles=false] - Used by the watcher to re-scan when covers/metadata files are added/removed\n * @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs\n */\nfunction groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly, includeNonMediaFiles = false) {\n  // Step 1: Filter out non-book-media files in root dir (with depth of 0)\n  const itemsFiltered = fileItems.filter((i) => {\n    return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly))\n  })\n\n  // Step 2: Separate media files and other files\n  //     - Directories without a media file will not be included (unless includeNonMediaFiles is true)\n  /** @type {import('./fileUtils').FilePathItem[]} */\n  const mediaFileItems = []\n  /** @type {import('./fileUtils').FilePathItem[]} */\n  const otherFileItems = []\n  itemsFiltered.forEach((item) => {\n    if (isMediaFile(mediaType, item.extension, audiobooksOnly) || (includeNonMediaFiles && isScannableNonMediaFile(item.extension))) {\n      mediaFileItems.push(item)\n    } else {\n      otherFileItems.push(item)\n    }\n  })\n\n  // Step 3: Group media files (or non-media files if includeNonMediaFiles is true) in library items\n  const libraryItemGroup = {}\n  mediaFileItems.forEach((item) => {\n    const dirparts = item.reldirpath.split('/').filter((p) => !!p)\n    const numparts = dirparts.length\n    let _path = ''\n\n    if (!dirparts.length) {\n      // Media file in root\n      libraryItemGroup[item.name] = item.name\n    } else {\n      // Iterate over directories in path\n      for (let i = 0; i < numparts; i++) {\n        const dirpart = dirparts.shift()\n        _path = Path.posix.join(_path, dirpart)\n\n        if (libraryItemGroup[_path]) {\n          // Directory already has files, add file\n          const relpath = Path.posix.join(dirparts.join('/'), item.name)\n          libraryItemGroup[_path].push(relpath)\n          return\n        } else if (!dirparts.length) {\n          // This is the last directory, create group\n          libraryItemGroup[_path] = [item.name]\n          return\n        } else if (dirparts.length === 1 && /^(cd|dis[ck])\\s*\\d{1,3}$/i.test(dirparts[0])) {\n          // Next directory is the last and is a CD dir, create group\n          libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]\n          return\n        }\n      }\n    }\n  })\n\n  // Step 4: Add other files into library item groups\n  otherFileItems.forEach((item) => {\n    const dirparts = item.reldirpath.split('/')\n    const numparts = dirparts.length\n    let _path = ''\n\n    // Iterate over directories in path\n    for (let i = 0; i < numparts; i++) {\n      const dirpart = dirparts.shift()\n      _path = Path.posix.join(_path, dirpart)\n      if (libraryItemGroup[_path]) {\n        // Directory is audiobook group\n        const relpath = Path.posix.join(dirparts.join('/'), item.name)\n        libraryItemGroup[_path].push(relpath)\n        return\n      }\n    }\n  })\n  return libraryItemGroup\n}\nmodule.exports.groupFileItemsIntoLibraryItemDirs = groupFileItemsIntoLibraryItemDirs\n\n/**\n * Get LibraryFile from filepath\n * @param {string} libraryItemPath\n * @param {string[]} files\n * @returns {import('../objects/files/LibraryFile')}\n */\nfunction buildLibraryFile(libraryItemPath, files) {\n  return Promise.all(\n    files.map(async (file) => {\n      const filePath = Path.posix.join(libraryItemPath, file)\n      const newLibraryFile = new LibraryFile()\n      await newLibraryFile.setDataFromPath(filePath, file)\n      return newLibraryFile\n    })\n  )\n}\nmodule.exports.buildLibraryFile = buildLibraryFile\n\n/**\n * Get details parsed from filenames\n *\n * @param {string} relPath\n * @param {boolean} parseSubtitle\n * @returns {LibraryItemFilenameMetadata}\n */\nfunction getBookDataFromDir(relPath, parseSubtitle = false) {\n  const splitDir = relPath.split('/')\n\n  var folder = splitDir.pop() // Audio files will always be in the directory named for the title\n  series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series\n  author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/\n\n  // The  may contain various other pieces of metadata, these functions extract it.\n  var [folder, asin] = getASIN(folder)\n  var [folder, narrators] = getNarrator(folder)\n  var [folder, sequence] = series ? getSequence(folder) : [folder, null]\n  var [folder, publishedYear] = getPublishedYear(folder)\n  var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]\n\n  return {\n    title,\n    subtitle,\n    asin,\n    authors: parseNameString.parse(author)?.names || [],\n    narrators: parseNameString.parse(narrators)?.names || [],\n    seriesName: series,\n    seriesSequence: sequence,\n    publishedYear\n  }\n}\nmodule.exports.getBookDataFromDir = getBookDataFromDir\n\n/**\n * Extract narrator from folder name\n *\n * @param {string} folder\n * @returns {[string, string]} [folder, narrator]\n */\nfunction getNarrator(folder) {\n  let pattern = /^(?<title>.*) \\{(?<narrators>.*)\\}$/\n  let match = folder.match(pattern)\n  return match ? [match.groups.title, match.groups.narrators] : [folder, null]\n}\n\n/**\n * Extract series sequence from folder name\n *\n * @example\n * 'Book 2 - Title - Subtitle'\n * 'Title - Subtitle - Vol 12'\n * 'Title - volume 9 - Subtitle'\n * 'Vol. 3 Title Here - Subtitle'\n * '1980 - Book 2 - Title'\n * 'Volume 12. Title - Subtitle'\n * '100 - Book Title'\n * '6. Title'\n * '0.5 - Book Title'\n *\n * @param {string} folder\n * @returns {[string, string]} [folder, sequence]\n */\nfunction getSequence(folder) {\n  // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.\n  let pattern = /^(?<volumeLabel>vol\\.? |volume |book )?(?<sequence>\\d{0,3}(?:\\.\\d{1,2})?)(?<trailingDot>\\.?)(?: (?<suffix>.*))?$/i\n\n  let volumeNumber = null\n  let parts = folder.split(' - ')\n  for (let i = 0; i < parts.length; i++) {\n    let match = parts[i].match(pattern)\n    // This excludes '101 Dalmations' but includes '101. Dalmations'\n    if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {\n      volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()\n      parts[i] = match.groups.suffix\n      if (!parts[i]) {\n        parts.splice(i, 1)\n      }\n      break\n    }\n  }\n\n  folder = parts.join(' - ')\n  return [folder, volumeNumber]\n}\n\n/**\n * Extract published year from folder name\n *\n * @param {string} folder\n * @returns {[string, string]} [folder, publishedYear]\n */\nfunction getPublishedYear(folder) {\n  var publishedYear = null\n\n  pattern = /^ *\\(?([0-9]{4})\\)? * - *(.+)/ //Matches #### - title or (####) - title\n  var match = folder.match(pattern)\n  if (match) {\n    publishedYear = match[1]\n    folder = match[2]\n  }\n\n  return [folder, publishedYear]\n}\n\n/**\n * Extract subtitle from folder name\n *\n * @param {string} folder\n * @returns {[string, string]} [folder, subtitle]\n */\nfunction getSubtitle(folder) {\n  // Subtitle is everything after \" - \"\n  var splitTitle = folder.split(' - ')\n  return [splitTitle.shift(), splitTitle.join(' - ')]\n}\n\n/**\n * Extract asin from folder name\n *\n * @param {string} folder\n * @returns {[string, string]} [folder, asin]\n */\nfunction getASIN(folder) {\n  let asin = null\n\n  let pattern = /(?: |^)\\[([A-Z0-9]{10})](?= |$)/ // Matches \"[B0015T963C]\"\n  const match = folder.match(pattern)\n  if (match) {\n    asin = match[1]\n    folder = folder.replace(match[0], '')\n  }\n  return [folder.trim(), asin]\n}\n\n/**\n *\n * @param {string} relPath\n * @returns {LibraryItemFilenameMetadata}\n */\nfunction getPodcastDataFromDir(relPath) {\n  const splitDir = relPath.split('/')\n\n  // Audio files will always be in the directory named for the title\n  const title = splitDir.pop()\n  return {\n    title\n  }\n}\n\n/**\n *\n * @param {string} libraryMediaType\n * @param {string} folderPath\n * @param {string} relPath\n * @returns {{ mediaMetadata: LibraryItemFilenameMetadata, relPath: string, path: string}}\n */\nfunction getDataFromMediaDir(libraryMediaType, folderPath, relPath) {\n  relPath = filePathToPOSIX(relPath)\n  let fullPath = Path.posix.join(folderPath, relPath)\n  let mediaMetadata = null\n\n  if (libraryMediaType === 'podcast') {\n    mediaMetadata = getPodcastDataFromDir(relPath)\n  } else {\n    // book\n    mediaMetadata = getBookDataFromDir(relPath, !!global.ServerSettings.scannerParseSubtitle)\n  }\n\n  return {\n    mediaMetadata,\n    relPath,\n    path: fullPath\n  }\n}\nmodule.exports.getDataFromMediaDir = getDataFromMediaDir\n"
  },
  {
    "path": "server/utils/stringifySequelizeQuery.js",
    "content": "function stringifySequelizeQuery(findOptions) {\n  function isClass(func) {\n    return typeof func === 'function' && /^class\\s/.test(func.toString())\n  }\n\n  function replacer(key, value) {\n    if (typeof value === 'object' && value !== null) {\n      const symbols = Object.getOwnPropertySymbols(value).reduce((acc, sym) => {\n        acc[sym.toString()] = value[sym]\n        return acc\n      }, {})\n\n      return { ...value, ...symbols }\n    }\n\n    if (isClass(value)) {\n      return `${value.name}`\n    }\n\n    return value\n  }\n\n  return JSON.stringify(findOptions, replacer)\n}\nmodule.exports = stringifySequelizeQuery\n"
  },
  {
    "path": "server/utils/zipHelpers.js",
    "content": "const Path = require('path')\nconst { Response } = require('express')\nconst Logger = require('../Logger')\nconst archiver = require('../libs/archiver')\n\nmodule.exports.zipDirectoryPipe = (path, filename, res) => {\n  return new Promise((resolve, reject) => {\n    // create a file to stream archive data to\n    res.attachment(filename)\n\n    const archive = archiver('zip', {\n      zlib: { level: 0 } // Sets the compression level.\n    })\n\n    // listen for all archive data to be written\n    // 'close' event is fired only when a file descriptor is involved\n    res.on('close', () => {\n      Logger.info(archive.pointer() + ' total bytes')\n      Logger.debug('archiver has been finalized and the output file descriptor has closed.')\n      resolve()\n    })\n\n    // This event is fired when the data source is drained no matter what was the data source.\n    // It is not part of this library but rather from the NodeJS Stream API.\n    // @see: https://nodejs.org/api/stream.html#stream_event_end\n    res.on('end', () => {\n      Logger.debug('Data has been drained')\n    })\n\n    // good practice to catch warnings (ie stat failures and other non-blocking errors)\n    archive.on('warning', function (err) {\n      if (err.code === 'ENOENT') {\n        // log warning\n        Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)\n      } else {\n        // throw error\n        Logger.error(`[DownloadManager] Archiver error: ${err.message}`)\n        // throw err\n        reject(err)\n      }\n    })\n    archive.on('error', function (err) {\n      Logger.error(`[DownloadManager] Archiver error: ${err.message}`)\n      reject(err)\n    })\n\n    // pipe archive data to the file\n    archive.pipe(res)\n\n    archive.directory(path, false)\n\n    archive.finalize()\n  })\n}\n\n/**\n * Creates a zip archive containing multiple directories and streams it to the response.\n *\n * @param {{ path: string, isFile: boolean }[]} pathObjects\n * @param {string} filename - Name of the zip file to be sent as attachment.\n * @param {Response} res - Response object to pipe the archive data to.\n * @returns {Promise<void>} - Promise that resolves when the zip operation completes.\n */\nmodule.exports.zipDirectoriesPipe = (pathObjects, filename, res) => {\n  return new Promise((resolve, reject) => {\n    // create a file to stream archive data to\n    res.attachment(filename)\n\n    const archive = archiver('zip', {\n      zlib: { level: 0 } // Sets the compression level.\n    })\n\n    // listen for all archive data to be written\n    // 'close' event is fired only when a file descriptor is involved\n    res.on('close', () => {\n      Logger.info(archive.pointer() + ' total bytes')\n      Logger.debug('archiver has been finalized and the output file descriptor has closed.')\n      resolve()\n    })\n\n    // This event is fired when the data source is drained no matter what was the data source.\n    // It is not part of this library but rather from the NodeJS Stream API.\n    // @see: https://nodejs.org/api/stream.html#stream_event_end\n    res.on('end', () => {\n      Logger.debug('Data has been drained')\n    })\n\n    // good practice to catch warnings (ie stat failures and other non-blocking errors)\n    archive.on('warning', function (err) {\n      if (err.code === 'ENOENT') {\n        // log warning\n        Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)\n      } else {\n        // throw error\n        Logger.error(`[DownloadManager] Archiver error: ${err.message}`)\n        // throw err\n        reject(err)\n      }\n    })\n    archive.on('error', function (err) {\n      Logger.error(`[DownloadManager] Archiver error: ${err.message}`)\n      reject(err)\n    })\n\n    // pipe archive data to the file\n    archive.pipe(res)\n\n    // Add each path as a directory in the zip\n    pathObjects.forEach((pathObject) => {\n      if (!pathObject.isFile) {\n        // Add the directory to the archive with its name as the root folder\n        archive.directory(pathObject.path, Path.basename(pathObject.path))\n      } else {\n        archive.file(pathObject.path, { name: Path.basename(pathObject.path) })\n      }\n    })\n\n    archive.finalize()\n  })\n}\n\n/**\n * Handles errors that occur during the download process.\n *\n * @param {*} error\n * @param {Response} res\n * @returns {*}\n */\nmodule.exports.handleDownloadError = (error, res) => {\n  if (!res.headersSent) {\n    if (error.code === 'ENOENT') {\n      return res.status(404).send('File not found')\n    } else {\n      return res.status(500).send('Download failed')\n    }\n  }\n}\n"
  },
  {
    "path": "test/server/Logger.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst Logger = require('../../server/Logger') // Adjust the path as needed\nconst { LogLevel } = require('../../server/utils/constants')\nconst date = require('../../server/libs/dateAndTime')\nconst util = require('util')\n\ndescribe('Logger', function () {\n  let consoleTraceStub\n  let consoleDebugStub\n  let consoleInfoStub\n  let consoleWarnStub\n  let consoleErrorStub\n  let consoleLogStub\n\n  beforeEach(function () {\n    // Stub the date format function to return a consistent timestamp\n    sinon.stub(date, 'format').returns('2024-09-10 12:34:56.789')\n    // Stub the source getter to return a consistent source\n    sinon.stub(Logger, 'source').get(() => 'some/source.js')\n    // Stub the console methods used in Logger\n    consoleTraceStub = sinon.stub(console, 'trace')\n    consoleDebugStub = sinon.stub(console, 'debug')\n    consoleInfoStub = sinon.stub(console, 'info')\n    consoleWarnStub = sinon.stub(console, 'warn')\n    consoleErrorStub = sinon.stub(console, 'error')\n    consoleLogStub = sinon.stub(console, 'log')\n    // Initialize the Logger's logManager as a mock object\n    Logger.logManager = {\n      logToFile: sinon.stub().resolves()\n    }\n  })\n\n  afterEach(function () {\n    sinon.restore()\n  })\n\n  describe('logging methods', function () {\n    it('should have a method for each log level defined in the static block', function () {\n      const loggerMethods = Object.keys(LogLevel).map((key) => key.toLowerCase())\n\n      loggerMethods.forEach((method) => {\n        expect(Logger).to.have.property(method).that.is.a('function')\n      })\n    })\n\n    it('should call console.trace for trace logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.trace('Test message')\n\n      // Assert\n      expect(consoleTraceStub.calledOnce).to.be.true\n    })\n\n    it('should call console.debug for debug logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.debug('Test message')\n\n      // Assert\n      expect(consoleDebugStub.calledOnce).to.be.true\n    })\n\n    it('should call console.info for info logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.info('Test message')\n\n      // Assert\n      expect(consoleInfoStub.calledOnce).to.be.true\n    })\n\n    it('should call console.warn for warn logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.warn('Test message')\n\n      // Assert\n      expect(consoleWarnStub.calledOnce).to.be.true\n    })\n\n    it('should call console.error for error logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.error('Test message')\n\n      // Assert\n      expect(consoleErrorStub.calledOnce).to.be.true\n    })\n\n    it('should call console.error for fatal logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.fatal('Test message')\n\n      // Assert\n      expect(consoleErrorStub.calledOnce).to.be.true\n    })\n\n    it('should call console.log for note logging', function () {\n      // Arrange\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.note('Test message')\n\n      // Assert\n      expect(consoleLogStub.calledOnce).to.be.true\n    })\n  })\n\n  describe('#log', function () {\n    it('should log to console and file if level is high enough', async function () {\n      // Arrange\n      const logArgs = ['Test message']\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      Logger.debug(...logArgs)\n\n      expect(consoleDebugStub.calledOnce).to.be.true\n      expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', ...logArgs)).to.be.true\n      expect(Logger.logManager.logToFile.calledOnce).to.be.true\n      expect(\n        Logger.logManager.logToFile.calledWithExactly({\n          timestamp: '2024-09-10 12:34:56.789',\n          source: 'some/source.js',\n          message: 'Test message',\n          levelName: 'DEBUG',\n          level: LogLevel.DEBUG\n        })\n      ).to.be.true\n    })\n\n    it('should not log if log level is too low', function () {\n      // Arrange\n      const logArgs = ['This log should not appear']\n      // Set log level to ERROR, so DEBUG log should be ignored\n      Logger.logLevel = LogLevel.ERROR\n\n      // Act\n      Logger.debug(...logArgs)\n\n      // Verify console.debug is not called\n      expect(consoleDebugStub.called).to.be.false\n      expect(Logger.logManager.logToFile.called).to.be.false\n    })\n\n    it('should emit log to all connected sockets with appropriate log level', async function () {\n      // Arrange\n      const socket1 = { id: '1', emit: sinon.spy() }\n      const socket2 = { id: '2', emit: sinon.spy() }\n      Logger.addSocketListener(socket1, LogLevel.DEBUG)\n      Logger.addSocketListener(socket2, LogLevel.ERROR)\n      const logArgs = ['Socket test']\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      await Logger.debug(...logArgs)\n\n      // socket1 should receive the log, but not socket2\n      expect(socket1.emit.calledOnce).to.be.true\n      expect(\n        socket1.emit.calledWithExactly('log', {\n          timestamp: '2024-09-10 12:34:56.789',\n          source: 'some/source.js',\n          message: 'Socket test',\n          levelName: 'DEBUG',\n          level: LogLevel.DEBUG\n        })\n      ).to.be.true\n\n      expect(socket2.emit.called).to.be.false\n    })\n\n    it('should log fatal messages to console and file regardless of log level', async function () {\n      // Arrange\n      const logArgs = ['Fatal error']\n      // Set log level to NOTE + 1, so nothing should be logged\n      Logger.logLevel = LogLevel.NOTE + 1\n\n      // Act\n      await Logger.fatal(...logArgs)\n\n      // Assert\n      expect(consoleErrorStub.calledOnce).to.be.true\n      expect(consoleErrorStub.calledWithExactly('[2024-09-10 12:34:56.789] FATAL:', ...logArgs)).to.be.true\n      expect(Logger.logManager.logToFile.calledOnce).to.be.true\n      expect(\n        Logger.logManager.logToFile.calledWithExactly({\n          timestamp: '2024-09-10 12:34:56.789',\n          source: 'some/source.js',\n          message: 'Fatal error',\n          levelName: 'FATAL',\n          level: LogLevel.FATAL\n        })\n      ).to.be.true\n    })\n\n    it('should log note messages to console and file regardless of log level', async function () {\n      // Arrange\n      const logArgs = ['Note message']\n      // Set log level to NOTE + 1, so nothing should be logged\n      Logger.logLevel = LogLevel.NOTE + 1\n\n      // Act\n      await Logger.note(...logArgs)\n\n      // Assert\n      expect(consoleLogStub.calledOnce).to.be.true\n      expect(consoleLogStub.calledWithExactly('[2024-09-10 12:34:56.789] NOTE:', ...logArgs)).to.be.true\n      expect(Logger.logManager.logToFile.calledOnce).to.be.true\n      expect(\n        Logger.logManager.logToFile.calledWithExactly({\n          timestamp: '2024-09-10 12:34:56.789',\n          source: 'some/source.js',\n          message: 'Note message',\n          levelName: 'NOTE',\n          level: LogLevel.NOTE\n        })\n      ).to.be.true\n    })\n\n    it('should log util.inspect(arg) for non-string objects', async function () {\n      // Arrange\n      const obj = { key: 'value' }\n      const logArgs = ['Logging object:', obj]\n      Logger.logLevel = LogLevel.TRACE\n\n      // Act\n      await Logger.debug(...logArgs)\n\n      // Assert\n      expect(consoleDebugStub.calledOnce).to.be.true\n      expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', 'Logging object:', obj)).to.be.true\n      expect(Logger.logManager.logToFile.calledOnce).to.be.true\n      expect(Logger.logManager.logToFile.firstCall.args[0].message).to.equal('Logging object: ' + util.inspect(obj))\n    })\n  })\n\n  describe('socket listeners', function () {\n    it('should add and remove socket listeners', function () {\n      // Arrange\n      const socket1 = { id: '1', emit: sinon.spy() }\n      const socket2 = { id: '2', emit: sinon.spy() }\n\n      // Act\n      Logger.addSocketListener(socket1, LogLevel.DEBUG)\n      Logger.addSocketListener(socket2, LogLevel.ERROR)\n      Logger.removeSocketListener('1')\n\n      // Assert\n      expect(Logger.socketListeners).to.have.lengthOf(1)\n      expect(Logger.socketListeners[0].id).to.equal('2')\n    })\n  })\n\n  describe('setLogLevel', function () {\n    it('should change the log level and log the new level', function () {\n      // Arrange\n      const debugSpy = sinon.spy(Logger, 'debug')\n\n      // Act\n      Logger.setLogLevel(LogLevel.WARN)\n\n      // Assert\n      expect(Logger.logLevel).to.equal(LogLevel.WARN)\n      expect(debugSpy.calledOnce).to.be.true\n      expect(debugSpy.calledWithExactly('Set Log Level to WARN')).to.be.true\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/controllers/LibraryItemController.test.js",
    "content": "const { expect } = require('chai')\nconst { Sequelize } = require('sequelize')\nconst sinon = require('sinon')\n\nconst Database = require('../../../server/Database')\nconst ApiRouter = require('../../../server/routers/ApiRouter')\nconst LibraryItemController = require('../../../server/controllers/LibraryItemController')\nconst ApiCacheManager = require('../../../server/managers/ApiCacheManager')\nconst Auth = require('../../../server/Auth')\nconst Logger = require('../../../server/Logger')\n\ndescribe('LibraryItemController', () => {\n  /** @type {ApiRouter} */\n  let apiRouter\n\n  beforeEach(async () => {\n    global.ServerSettings = {}\n    Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')\n    await Database.buildModels()\n\n    apiRouter = new ApiRouter({\n      auth: new Auth(),\n      apiCacheManager: new ApiCacheManager()\n    })\n\n    sinon.stub(Logger, 'info')\n  })\n\n  afterEach(async () => {\n    sinon.restore()\n\n    // Clear all tables\n    await Database.sequelize.sync({ force: true })\n  })\n\n  describe('checkRemoveAuthorsAndSeries', () => {\n    let libraryItem1Id\n    let libraryItem2Id\n    let author1Id\n    let author2Id\n    let author3Id\n    let series1Id\n    let series2Id\n\n    beforeEach(async () => {\n      const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })\n      const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })\n\n      const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n      const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })\n      libraryItem1Id = newLibraryItem.id\n\n      const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n      const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })\n      libraryItem2Id = newLibraryItem2.id\n\n      const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id })\n      author1Id = newAuthor.id\n      const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id })\n      author2Id = newAuthor2.id\n      const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id })\n      author3Id = newAuthor3.id\n\n      // Book 1 has Author 1, Author 2 and Author 3\n      await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id })\n      await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id })\n      await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id })\n\n      // Book 2 has Author 2\n      await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id })\n\n      const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id })\n      series1Id = newSeries.id\n      const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id })\n      series2Id = newSeries2.id\n\n      // Book 1 is in Series 1 and Series 2\n      await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id })\n      await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id })\n\n      // Book 2 is in Series 2\n      await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id })\n    })\n\n    it('should remove authors and series with no books on library item delete', async () => {\n      const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)\n\n      const fakeReq = {\n        query: {},\n        libraryItem\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy()\n      }\n      await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(200)).to.be.true\n\n      // Author 1 should be removed because it has no books\n      const author1Exists = await Database.authorModel.checkExistsById(author1Id)\n      expect(author1Exists).to.be.false\n\n      // Author 2 should not be removed because it still has Book 2\n      const author2Exists = await Database.authorModel.checkExistsById(author2Id)\n      expect(author2Exists).to.be.true\n\n      // Author 3 should not be removed because it has an image\n      const author3Exists = await Database.authorModel.checkExistsById(author3Id)\n      expect(author3Exists).to.be.true\n\n      // Series 1 should be removed because it has no books\n      const series1Exists = await Database.seriesModel.checkExistsById(series1Id)\n      expect(series1Exists).to.be.false\n\n      // Series 2 should not be removed because it still has Book 2\n      const series2Exists = await Database.seriesModel.checkExistsById(series2Id)\n      expect(series2Exists).to.be.true\n    })\n\n    it('should remove authors and series with no books on library item batch delete', async () => {\n      // Batch delete library item 1\n      const fakeReq = {\n        query: {},\n        user: {\n          canDelete: true\n        },\n        body: {\n          libraryItemIds: [libraryItem1Id]\n        }\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy()\n      }\n      await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(200)).to.be.true\n\n      // Author 1 should be removed because it has no books\n      const author1Exists = await Database.authorModel.checkExistsById(author1Id)\n      expect(author1Exists).to.be.false\n\n      // Author 2 should not be removed because it still has Book 2\n      const author2Exists = await Database.authorModel.checkExistsById(author2Id)\n      expect(author2Exists).to.be.true\n\n      // Author 3 should not be removed because it has an image\n      const author3Exists = await Database.authorModel.checkExistsById(author3Id)\n      expect(author3Exists).to.be.true\n\n      // Series 1 should be removed because it has no books\n      const series1Exists = await Database.seriesModel.checkExistsById(series1Id)\n      expect(series1Exists).to.be.false\n\n      // Series 2 should not be removed because it still has Book 2\n      const series2Exists = await Database.seriesModel.checkExistsById(series2Id)\n      expect(series2Exists).to.be.true\n    })\n\n    it('should remove authors and series with no books on library item update media', async () => {\n      const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)\n      libraryItem.saveMetadataFile = sinon.stub()\n      // Update library item 1 remove all authors and series\n      const fakeReq = {\n        query: {},\n        body: {\n          metadata: {\n            authors: [],\n            series: []\n          }\n        },\n        libraryItem\n      }\n      const fakeRes = {\n        json: sinon.spy()\n      }\n      await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)\n\n      expect(fakeRes.json.calledOnce).to.be.true\n\n      // Author 1 should be removed because it has no books\n      const author1Exists = await Database.authorModel.checkExistsById(author1Id)\n      expect(author1Exists).to.be.false\n\n      // Author 2 should not be removed because it still has Book 2\n      const author2Exists = await Database.authorModel.checkExistsById(author2Id)\n      expect(author2Exists).to.be.true\n\n      // Author 3 should not be removed because it has an image\n      const author3Exists = await Database.authorModel.checkExistsById(author3Id)\n      expect(author3Exists).to.be.true\n\n      // Series 1 should be removed because it has no books\n      const series1Exists = await Database.seriesModel.checkExistsById(series1Id)\n      expect(series1Exists).to.be.false\n\n      // Series 2 should not be removed because it still has Book 2\n      const series2Exists = await Database.seriesModel.checkExistsById(series2Id)\n      expect(series2Exists).to.be.true\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/controllers/MeController.test.js",
    "content": "const { expect } = require('chai')\nconst { Sequelize } = require('sequelize')\nconst sinon = require('sinon')\n\nconst Database = require('../../../server/Database')\nconst ApiRouter = require('../../../server/routers/ApiRouter')\nconst MeController = require('../../../server/controllers/MeController')\nconst Auth = require('../../../server/Auth')\nconst Logger = require('../../../server/Logger')\nconst SocketAuthority = require('../../../server/SocketAuthority')\n\ndescribe('MeController - IDOR Security Tests', () => {\n  /** @type {ApiRouter} */\n  let apiRouter\n\n  beforeEach(async () => {\n    global.ServerSettings = {}\n    Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')\n    await Database.buildModels()\n\n    // Create mock server object with required dependencies\n    const mockServer = {\n      auth: new Auth(),\n      playbackSessionManager: { sessions: [] },\n      abMergeManager: {},\n      backupManager: {},\n      podcastManager: {},\n      audioMetadataManager: {},\n      cronManager: {},\n      emailManager: {},\n      apiCacheManager: { middleware: (req, res, next) => next() }\n    }\n\n    apiRouter = new ApiRouter(mockServer)\n\n    sinon.stub(Logger, 'info')\n    sinon.stub(Logger, 'error')\n    sinon.stub(SocketAuthority, 'clientEmitter')\n  })\n\n  afterEach(async () => {\n    sinon.restore()\n\n    // Clear all tables\n    await Database.sequelize.sync({ force: true })\n  })\n\n  describe('removeMediaProgress - IDOR Protection', () => {\n    let user1, user2\n    let mediaProgress1, mediaProgress2\n\n    beforeEach(async () => {\n      // Create two users\n      user1 = await Database.userModel.create({\n        username: 'user1',\n        pash: 'hashed_password_1',\n        type: 'user',\n        isActive: true\n      })\n\n      user2 = await Database.userModel.create({\n        username: 'user2',\n        pash: 'hashed_password_2',\n        type: 'user',\n        isActive: true\n      })\n\n      // Create library and book\n      const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })\n      const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })\n      const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n      const libraryItem = await Database.libraryItemModel.create({\n        libraryFiles: [],\n        mediaId: book.id,\n        mediaType: 'book',\n        libraryId: library.id,\n        libraryFolderId: libraryFolder.id\n      })\n\n      // Create media progress for each user\n      mediaProgress1 = await Database.mediaProgressModel.create({\n        userId: user1.id,\n        mediaItemId: book.id,\n        mediaItemType: 'book',\n        duration: 1000,\n        currentTime: 500,\n        isFinished: false\n      })\n\n      mediaProgress2 = await Database.mediaProgressModel.create({\n        userId: user2.id,\n        mediaItemId: book.id,\n        mediaItemType: 'book',\n        duration: 1000,\n        currentTime: 300,\n        isFinished: false\n      })\n\n      // Load media progresses into users\n      user1.mediaProgresses = await user1.getMediaProgresses()\n      user2.mediaProgresses = await user2.getMediaProgresses()\n    })\n\n    it('should allow user to delete their own media progress', async () => {\n      const fakeReq = {\n        user: user1,\n        params: { id: mediaProgress1.id }\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy(),\n        status: sinon.stub().returnsThis(),\n        send: sinon.spy()\n      }\n\n      await MeController.removeMediaProgress(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(200)).to.be.true\n\n      // Verify media progress was deleted\n      const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id)\n      expect(deletedProgress).to.be.null\n    })\n\n    it('should prevent user from deleting another users media progress (IDOR)', async () => {\n      const fakeReq = {\n        user: user1,\n        params: { id: mediaProgress2.id } // Trying to delete user2's progress\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy(),\n        status: sinon.stub().returnsThis(),\n        send: sinon.spy()\n      }\n\n      await MeController.removeMediaProgress(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(404)).to.be.true\n\n      // Verify media progress was NOT deleted\n      const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id)\n      expect(existingProgress).to.not.be.null\n      expect(existingProgress.userId).to.equal(user2.id)\n    })\n\n    it('should return 404 for non-existent media progress', async () => {\n      const fakeReq = {\n        user: user1,\n        params: { id: 'non-existent-id' }\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy(),\n        status: sinon.stub().returnsThis(),\n        send: sinon.spy()\n      }\n\n      await MeController.removeMediaProgress(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(404)).to.be.true\n    })\n  })\n\n  describe('Bookmark Operations - Authorization Checks', () => {\n    let user1, user2\n    let library1, library2\n    let libraryItem1, libraryItem2\n\n    beforeEach(async () => {\n      // Create two users with different library access\n      user1 = await Database.userModel.create({\n        username: 'user1',\n        pash: 'hashed_password_1',\n        type: 'user',\n        isActive: true,\n        librariesAccessible: null // Access to all libraries\n      })\n\n      user2 = await Database.userModel.create({\n        username: 'user2',\n        pash: 'hashed_password_2',\n        type: 'user',\n        isActive: true,\n        librariesAccessible: [] // Will be set to specific library\n      })\n\n      // Create two libraries\n      library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })\n      library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })\n\n      // User2 only has access to library1\n      user2.librariesAccessible = [library1.id]\n      await user2.save()\n\n      const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })\n      const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })\n\n      const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n      const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n\n      libraryItem1 = await Database.libraryItemModel.create({\n        libraryFiles: [],\n        mediaId: book1.id,\n        mediaType: 'book',\n        libraryId: library1.id,\n        libraryFolderId: libraryFolder1.id\n      })\n\n      libraryItem2 = await Database.libraryItemModel.create({\n        libraryFiles: [],\n        mediaId: book2.id,\n        mediaType: 'book',\n        libraryId: library2.id,\n        libraryFolderId: libraryFolder2.id\n      })\n\n      // Initialize bookmarks\n      user1.bookmarks = []\n      user2.bookmarks = []\n    })\n\n    describe('createBookmark', () => {\n      it('should allow user to create bookmark for accessible library item', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)\n\n        const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() }\n\n        const fakeReq = {\n          user: {\n            ...user2.toJSON(),\n            id: user2.id,\n            username: user2.username,\n            checkCanAccessLibraryItem: () => true,\n            createBookmark: sinon.stub().resolves(bookmark),\n            toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })\n          },\n          params: { id: libraryItem1.id },\n          body: { time: 100, title: 'Test Bookmark' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy(),\n          json: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.createBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.json.calledOnce).to.be.true\n        expect(fakeRes.json.calledWith(bookmark)).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n\n      it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)\n\n        const fakeReq = {\n          user: user2, // user2 doesn't have access to library2\n          params: { id: libraryItem2.id },\n          body: { time: 100, title: 'Test Bookmark' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy(),\n          json: sinon.spy()\n        }\n\n        // Mock getExpandedById\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.createBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.sendStatus.calledWith(403)).to.be.true\n        expect(fakeRes.json.called).to.be.false\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n\n      it('should return 404 for non-existent library item', async () => {\n        const fakeReq = {\n          user: user1,\n          params: { id: 'non-existent-id' },\n          body: { time: 100, title: 'Test Bookmark' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy(),\n          json: sinon.spy()\n        }\n\n        // Mock getExpandedById to return null\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)\n\n        await MeController.createBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.sendStatus.calledWith(404)).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n\n      it('should validate bookmark time parameter', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)\n\n        const fakeReq = {\n          user: {\n            ...user1.toJSON(),\n            id: user1.id,\n            username: user1.username,\n            checkCanAccessLibraryItem: () => true\n          },\n          params: { id: libraryItem1.id },\n          body: { time: null, title: 'Test Bookmark' } // null time is invalid\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy(),\n          json: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.createBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.status.calledWith(400)).to.be.true\n        expect(fakeRes.send.calledWith('Invalid time')).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n    })\n\n    describe('updateBookmark', () => {\n      beforeEach(async () => {\n        // Add existing bookmark to user1\n        user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }]\n        await user1.save()\n      })\n\n      it('should allow user to update bookmark for accessible library item', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)\n\n        const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' }\n\n        const fakeReq = {\n          user: {\n            ...user1.toJSON(),\n            id: user1.id,\n            username: user1.username,\n            checkCanAccessLibraryItem: () => true,\n            updateBookmark: sinon.stub().resolves(bookmark),\n            toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })\n          },\n          params: { id: libraryItem1.id },\n          body: { time: 100, title: 'Updated Title' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy(),\n          json: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.updateBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.json.calledOnce).to.be.true\n        expect(fakeRes.json.calledWith(bookmark)).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n\n      it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)\n\n        const fakeReq = {\n          user: user2, // user2 doesn't have access to library2\n          params: { id: libraryItem2.id },\n          body: { time: 100, title: 'Updated Title' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy(),\n          json: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.updateBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.sendStatus.calledWith(403)).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n    })\n\n    describe('removeBookmark', () => {\n      beforeEach(async () => {\n        // Add existing bookmark to user1\n        user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }]\n        await user1.save()\n      })\n\n      it('should allow user to remove bookmark for accessible library item', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)\n\n        const fakeReq = {\n          user: {\n            ...user1.toJSON(),\n            id: user1.id,\n            username: user1.username,\n            checkCanAccessLibraryItem: () => true,\n            findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }),\n            removeBookmark: sinon.stub().resolves(true),\n            toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })\n          },\n          params: { id: libraryItem1.id, time: '100' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.removeBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.sendStatus.calledWith(200)).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n\n      it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)\n\n        const fakeReq = {\n          user: user2, // user2 doesn't have access to library2\n          params: { id: libraryItem2.id, time: '100' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.removeBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.sendStatus.calledWith(403)).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n\n      it('should validate time parameter is a number', async () => {\n        const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)\n\n        const fakeReq = {\n          user: {\n            ...user1.toJSON(),\n            id: user1.id,\n            username: user1.username,\n            checkCanAccessLibraryItem: () => true\n          },\n          params: { id: libraryItem1.id, time: 'not-a-number' }\n        }\n        const fakeRes = {\n          sendStatus: sinon.spy(),\n          status: sinon.stub().returnsThis(),\n          send: sinon.spy()\n        }\n\n        sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n\n        await MeController.removeBookmark(fakeReq, fakeRes)\n\n        expect(fakeRes.status.calledWith(400)).to.be.true\n        expect(fakeRes.send.calledWith('Invalid time')).to.be.true\n\n        Database.libraryItemModel.getExpandedById.restore()\n      })\n    })\n  })\n\n  describe('getItemListeningSessions - Authorization Check', () => {\n    let user1, user2\n    let library1, library2\n    let libraryItem1, libraryItem2\n\n    beforeEach(async () => {\n      // Create two users with different library access\n      user1 = await Database.userModel.create({\n        username: 'user1',\n        pash: 'hashed_password_1',\n        type: 'user',\n        isActive: true,\n        librariesAccessible: null // Access to all libraries\n      })\n\n      user2 = await Database.userModel.create({\n        username: 'user2',\n        pash: 'hashed_password_2',\n        type: 'user',\n        isActive: true,\n        librariesAccessible: [] // Will be set to specific library\n      })\n\n      // Create two libraries\n      library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })\n      library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })\n\n      // User2 only has access to library1\n      user2.librariesAccessible = [library1.id]\n      await user2.save()\n\n      const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })\n      const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })\n\n      const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n      const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })\n\n      libraryItem1 = await Database.libraryItemModel.create({\n        libraryFiles: [],\n        mediaId: book1.id,\n        mediaType: 'book',\n        libraryId: library1.id,\n        libraryFolderId: libraryFolder1.id\n      })\n\n      libraryItem2 = await Database.libraryItemModel.create({\n        libraryFiles: [],\n        mediaId: book2.id,\n        mediaType: 'book',\n        libraryId: library2.id,\n        libraryFolderId: libraryFolder2.id\n      })\n    })\n\n    it('should allow user to view listening sessions for accessible library item', async () => {\n      const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)\n\n      // Create mock context with getUserItemListeningSessionsHelper\n      const mockContext = {\n        getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }])\n      }\n\n      const fakeReq = {\n        user: {\n          ...user1.toJSON(),\n          id: user1.id,\n          username: user1.username,\n          checkCanAccessLibraryItem: () => true\n        },\n        params: { libraryItemId: libraryItem1.id },\n        query: {}\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy(),\n        status: sinon.stub().returnsThis(),\n        send: sinon.spy(),\n        json: sinon.spy()\n      }\n\n      sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n      sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)\n\n      await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes)\n\n      expect(fakeRes.json.calledOnce).to.be.true\n      expect(fakeRes.sendStatus.called).to.be.false\n\n      // Verify the payload structure\n      const payload = fakeRes.json.firstCall.args[0]\n      expect(payload).to.have.property('total')\n      expect(payload).to.have.property('sessions')\n\n      Database.libraryItemModel.getExpandedById.restore()\n      Database.podcastEpisodeModel.findByPk.restore()\n    })\n\n    it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => {\n      const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)\n\n      const fakeReq = {\n        user: user2, // user2 doesn't have access to library2\n        params: { libraryItemId: libraryItem2.id },\n        query: {}\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy(),\n        status: sinon.stub().returnsThis(),\n        send: sinon.spy(),\n        json: sinon.spy()\n      }\n\n      sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)\n      sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)\n\n      await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(403)).to.be.true\n      expect(fakeRes.json.called).to.be.false\n\n      Database.libraryItemModel.getExpandedById.restore()\n      Database.podcastEpisodeModel.findByPk.restore()\n    })\n\n    it('should return 404 for non-existent library item', async () => {\n      const fakeReq = {\n        user: user1,\n        params: { libraryItemId: 'non-existent-id' },\n        query: {}\n      }\n      const fakeRes = {\n        sendStatus: sinon.spy(),\n        status: sinon.stub().returnsThis(),\n        send: sinon.spy(),\n        json: sinon.spy()\n      }\n\n      sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)\n\n      await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)\n\n      expect(fakeRes.sendStatus.calledWith(404)).to.be.true\n\n      Database.libraryItemModel.getExpandedById.restore()\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/finders/BookFinder.test.js",
    "content": "const sinon = require('sinon')\nconst chai = require('chai')\nconst expect = chai.expect\nconst bookFinder = require('../../../server/finders/BookFinder')\nconst { LogLevel } = require('../../../server/utils/constants')\nconst Logger = require('../../../server/Logger')\nLogger.setLogLevel(LogLevel.INFO)\nconst { levenshteinDistance } = require('../../../server/utils/index')\n\n// levenshteinDistance is needed for manual calculation of expected scores in tests.\n// Assuming it's accessible for testing purposes or we mock/replicate its basic behavior if needed.\n// For now, we'll assume bookFinder.search uses it internally correctly.\n// const { levenshteinDistance } = require('../../../server/utils/index') // Not used directly in test logic, but for reasoning.\n\ndescribe('TitleCandidates', () => {\n  describe('cleanAuthor non-empty', () => {\n    let titleCandidates\n    const cleanAuthor = 'leo tolstoy'\n\n    beforeEach(() => {\n      titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)\n    })\n\n    describe('no adds', () => {\n      it('returns no candidates', () => {\n        expect(titleCandidates.getCandidates()).to.deep.equal([])\n      })\n    })\n\n    describe('single add', () => {\n      ;[\n        ['adds candidate', 'anna karenina', ['anna karenina']],\n        ['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']],\n        ['adds candidate, removing redundant spaces', 'anna    karenina', ['anna karenina']],\n        ['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']],\n        ['does not add empty candidate after removing author', cleanAuthor, []],\n        ['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']],\n        ['adds candidate, not stripping subtitle for bare colon in title', '10:04', ['10:04']],\n        ['adds candidate, not stripping subtitle for colon between words without space', 'making the mission:impossible movies', ['making the mission:impossible movies']],\n        ['adds candidate + variant, removing \"by ...\"', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']],\n        ['adds candidate + variant, removing \"by ...\" when title has bare colon', '10:04 by ben lerner', ['10:04', '10:04 by ben lerner']],\n        ['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']],\n        ['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']],\n        ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']],\n        ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']],\n        ['adds candidate + variant, removing \"a novel\"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']],\n        ['adds candidate + variant, removing \"abridged\"', 'abridged anna karenina', ['anna karenina', 'abridged anna karenina']],\n        ['adds candidate + variant, removing \"unabridged\"', 'anna karenina unabridged', ['anna karenina', 'anna karenina unabridged']],\n        ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],\n        ['does not add empty candidate', '', []],\n        ['does not add spaces-only candidate', '   ', []],\n        ['does not add empty variant', '1984', ['1984']]\n      ].forEach(([name, title, expected]) =>\n        it(name, () => {\n          titleCandidates.add(title)\n          expect(titleCandidates.getCandidates()).to.deep.equal(expected)\n        })\n      )\n    })\n\n    describe('multiple adds', () => {\n      ;[\n        ['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']],\n        ['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']],\n        ['orders by position', ['title2', 'title1'], ['title2', 'title1']],\n        ['dedupes candidates', ['title1', 'title1'], ['title1']]\n      ].forEach(([name, titles, expected]) =>\n        it(name, () => {\n          for (const title of titles) titleCandidates.add(title)\n          expect(titleCandidates.getCandidates()).to.deep.equal(expected)\n        })\n      )\n    })\n  })\n\n  describe('cleanAuthor empty', () => {\n    let titleCandidates\n    let cleanAuthor = ''\n\n    beforeEach(() => {\n      titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)\n    })\n\n    describe('single add', () => {\n      ;[['adds a candidate', 'leo tolstoy', ['leo tolstoy']]].forEach(([name, title, expected]) =>\n        it(name, () => {\n          titleCandidates.add(title)\n          expect(titleCandidates.getCandidates()).to.deep.equal(expected)\n        })\n      )\n    })\n  })\n})\n\ndescribe('AuthorCandidates', () => {\n  let authorCandidates\n  const audnexus = {\n    authorASINsRequest: sinon.stub().resolves([{ name: 'Leo Tolstoy' }, { name: 'Nikolai Gogol' }, { name: 'J. K. Rowling' }])\n  }\n\n  describe('cleanAuthor is null', () => {\n    beforeEach(() => {\n      authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus)\n    })\n\n    describe('no adds', () => {\n      ;[['returns empty author candidate', []]].forEach(([name, expected]) =>\n        it(name, async () => {\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n\n    describe('single add', () => {\n      ;[\n        ['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']],\n        ['does not add unrecognized candidate', 'fyodor dostoevsky', []],\n        ['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']],\n        ['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']],\n        ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']],\n        ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []],\n        ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai    gogol', ['nikolai gogol']],\n        ['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']],\n        ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']]\n      ].forEach(([name, author, expected]) =>\n        it(name, async () => {\n          authorCandidates.add(author)\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n\n    describe('multi add', () => {\n      ;[\n        ['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']],\n        ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']]\n      ].forEach(([name, authors, expected]) =>\n        it(name, async () => {\n          for (const author of authors) authorCandidates.add(author)\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n  })\n\n  describe('cleanAuthor is a recognized author', () => {\n    const cleanAuthor = 'leo tolstoy'\n\n    beforeEach(() => {\n      authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)\n    })\n\n    describe('no adds', () => {\n      ;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) =>\n        it(name, async () => {\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n\n    describe('single add', () => {\n      ;[\n        ['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']],\n        ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]]\n      ].forEach(([name, author, expected]) =>\n        it(name, async () => {\n          authorCandidates.add(author)\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n  })\n\n  describe('cleanAuthor is an unrecognized author', () => {\n    const cleanAuthor = 'Fyodor Dostoevsky'\n\n    beforeEach(() => {\n      authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)\n    })\n\n    describe('no adds', () => {\n      ;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) =>\n        it(name, async () => {\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n\n    describe('single add', () => {\n      ;[\n        ['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']],\n        ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]]\n      ].forEach(([name, author, expected]) =>\n        it(name, async () => {\n          authorCandidates.add(author)\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n  })\n\n  describe('cleanAuthor is unrecognized and dirty', () => {\n    describe('no adds', () => {\n      ;[\n        ['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']],\n        ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']]\n      ].forEach(([name, cleanAuthor, expected]) =>\n        it(name, async () => {\n          authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n\n    describe('single add', () => {\n      ;[['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']]].forEach(([name, cleanAuthor, author, expected]) =>\n        it(name, async () => {\n          authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)\n          authorCandidates.add(author)\n          expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])\n        })\n      )\n    })\n  })\n})\n\ndescribe('search', () => {\n  const t = 'title'\n  const a = 'author'\n  const u = 'unrecognized'\n  const r = ['book']\n\n  let runSearchStub\n  let audnexusStub\n\n  beforeEach(() => {\n    runSearchStub = sinon.stub(bookFinder, 'runSearch')\n    runSearchStub.resolves([])\n    runSearchStub.withArgs(t, a).resolves(r)\n    runSearchStub.withArgs(t, u).resolves(r)\n\n    audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')\n    audnexusStub.resolves([{ name: a }])\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('search title is empty', () => {\n    it('returns empty result', async () => {\n      expect(await bookFinder.search(null, '', '', a)).to.deep.equal([])\n      sinon.assert.callCount(bookFinder.runSearch, 0)\n    })\n  })\n\n  describe('search title is a recognized title and search author is a recognized author', () => {\n    it('returns non-empty result (no fuzzy searches)', async () => {\n      expect(await bookFinder.search(null, '', t, a)).to.deep.equal(r)\n      sinon.assert.callCount(bookFinder.runSearch, 1)\n    })\n  })\n\n  describe('search title contains recognized title and search author is a recognized author', () => {\n    ;[[`${t} -`], [`${t} - ${a}`], [`${a} - ${t}`], [`${t}- ${a}`], [`${t} -${a}`], [`${t} ${a}`], [`${a} - ${t} (unabridged)`], [`${a} - ${t} (subtitle) - mp3`], [`${t} {narrator} - series-01 64kbps 10:00:00`], [`${a} - ${t} (2006) narrated by narrator [unabridged]`], [`${t} - ${a} 2022 mp3`], [`01 ${t}`], [`2022_${t}_HQ`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)\n        sinon.assert.callCount(bookFinder.runSearch, 2)\n      })\n    })\n    ;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)\n        sinon.assert.callCount(bookFinder.runSearch, 3)\n      })\n    })\n    ;[[`${t}-${a}`], [`${t} junk`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([])\n      })\n    })\n\n    describe('maxFuzzySearches = 0', () => {\n      ;[[`${t} - ${a}`]].forEach(([searchTitle]) => {\n        it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {\n          expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])\n          sinon.assert.callCount(bookFinder.runSearch, 1)\n        })\n      })\n    })\n\n    describe('maxFuzzySearches = 1', () => {\n      ;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => {\n        it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {\n          expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])\n          sinon.assert.callCount(bookFinder.runSearch, 2)\n        })\n      })\n    })\n  })\n\n  describe('search title contains recognized title and search author is empty', () => {\n    ;[[`${t} - ${a}`], [`${a} - ${t}`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r)\n        sinon.assert.callCount(bookFinder.runSearch, 2)\n      })\n    })\n    ;[[`${t}`], [`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '') returns an empty result`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([])\n      })\n    })\n  })\n\n  describe('search title contains recognized title and search author is an unrecognized author', () => {\n    ;[[`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)\n        sinon.assert.callCount(bookFinder.runSearch, 2)\n      })\n    })\n    ;[[`${t}`]].forEach(([searchTitle]) => {\n      it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {\n        expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)\n        sinon.assert.callCount(bookFinder.runSearch, 1)\n      })\n    })\n  })\n\n  describe('search provider results have duration', () => {\n    const libraryItem = { media: { duration: 60 * 1000 } }\n    const provider = 'audible'\n    const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]\n    const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]\n\n    beforeEach(() => {\n      runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))\n    })\n\n    afterEach(() => {\n      sinon.restore()\n    })\n\n    it('returns results sorted by library item duration diff', async () => {\n      const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))\n      expect(result).to.deep.equal(sorted)\n    })\n\n    it('returns unsorted results if library item is null', async () => {\n      const result = (await bookFinder.search(null, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))\n      expect(result).to.deep.equal(unsorted)\n    })\n\n    it('returns unsorted results if library item duration is undefined', async () => {\n      const result = (await bookFinder.search({ media: {} }, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))\n      expect(result).to.deep.equal(unsorted)\n    })\n\n    it('returns unsorted results if library item media is undefined', async () => {\n      const result = (await bookFinder.search({}, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))\n      expect(result).to.deep.equal(unsorted)\n    })\n\n    it('should return a result last if it has no duration', async () => {\n      const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]\n      const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]\n      runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))\n      const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))\n      expect(result).to.deep.equal(sorted)\n    })\n  })\n\n  describe('matchConfidence score', () => {\n    const W_DURATION = 0.7\n    const W_TITLE = 0.2\n    const W_AUTHOR = 0.1\n    const DEFAULT_DURATION_SCORE_MISSING_INFO = 0.1\n\n    const libraryItemPerfectDuration = { media: { duration: 600 } } // 10 minutes\n\n    // Helper to calculate expected title/author score based on Levenshtein\n    // Assumes queryPart and bookPart are already \"cleaned\" for length calculation consistency with BookFinder.js\n    const calculateStringMatchScore = (cleanedQueryPart, cleanedBookPart) => {\n      if (!cleanedQueryPart) return cleanedBookPart ? 0 : 1 // query empty: 1 if book empty, else 0\n      if (!cleanedBookPart) return 0 // query non-empty, book empty: 0\n\n      // Use the imported levenshteinDistance. It defaults to case-insensitive, which is what we want.\n      const distance = levenshteinDistance(cleanedQueryPart, cleanedBookPart)\n      return Math.max(0, 1 - distance / Math.max(cleanedQueryPart.length, cleanedBookPart.length))\n    }\n\n    beforeEach(() => {\n      runSearchStub.resolves([])\n    })\n\n    afterEach(() => {\n      sinon.restore()\n    })\n\n    describe('for audible provider', () => {\n      const provider = 'audible'\n\n      it('should be 1.0 for perfect duration, title, and author match', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // durationScore = 1.0 (diff 0 <= 1 min)\n        // titleScore = 1.0 (exact match)\n        // authorScore = 1.0 (exact match)\n        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should correctly score a large duration mismatch', async () => {\n        const bookResults = [{ duration: 21, title: 'The Great Novel', author: 'John Doe' }] // 21 min, diff = 11 min\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // durationScore = 0.0\n        // titleScore = 1.0\n        // authorScore = 1.0\n        const expectedConfidence = W_DURATION * 0.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should correctly score a medium duration mismatch', async () => {\n        const bookResults = [{ duration: 16, title: 'The Great Novel', author: 'John Doe' }] // 16 min, diff = 6 min\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // durationScore = 1.2 - 6 * 0.12 = 0.48\n        // titleScore = 1.0\n        // authorScore = 1.0\n        const expectedConfidence = W_DURATION * 0.48 + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should correctly score a minor duration mismatch', async () => {\n        const bookResults = [{ duration: 14, title: 'The Great Novel', author: 'John Doe' }] // 14 min, diff = 4 min\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // durationScore = 1.1 - 4 * 0.1 = 0.7\n        // titleScore = 1.0\n        // authorScore = 1.0\n        const expectedConfidence = W_DURATION * 0.7 + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should correctly score a tiny duration mismatch', async () => {\n        const bookResults = [{ duration: 11, title: 'The Great Novel', author: 'John Doe' }] // 11 min, diff = 1 min\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // durationScore = 1.0\n        // titleScore = 1.0\n        // authorScore = 1.0\n        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should use default duration score if libraryItem duration is missing', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search({ media: {} }, provider, 'The Great Novel', 'John Doe')\n        // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)\n        const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should use default duration score if book duration is missing', async () => {\n        const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] // No duration in book\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)\n        const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should correctly score a partial title match', async () => {\n        const bookResults = [{ duration: 10, title: 'Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        // Query: 'Novel Ex', Book: 'Novel'\n        // cleanTitleForCompares('Novel Ex') -> 'novel ex' (length 8)\n        // cleanTitleForCompares('Novel')    -> 'novel' (length 5)\n        // levenshteinDistance('novel ex', 'novel') = 3\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'Novel Ex', 'John Doe')\n        const expectedTitleScore = calculateStringMatchScore('novel ex', 'novel') // 1 - (3/8) = 0.625\n        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * expectedTitleScore + W_AUTHOR * 1.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should correctly score a partial author match (comma-separated)', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'Jane Smith, Jon Doee' }]\n        runSearchStub.resolves(bookResults)\n        // Query: 'Jon Doe', Book part: 'Jon Doee'\n        // cleanAuthorForCompares('Jon Doe') -> 'jon doe' (length 7)\n        // book author part (already lowercased) -> 'jon doee' (length 8)\n        // levenshteinDistance('jon doe', 'jon doee') = 1\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'Jon Doe')\n        // For the author part 'jon doee':\n        const expectedAuthorPartScore = calculateStringMatchScore('jon doe', 'jon doee') // 1 - (1/7)\n        // Assuming 'jane smith' gives a lower or 0 score, max score will be from 'jon doee'\n        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * expectedAuthorPartScore\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should give authorScore 0 if query has author but book does not', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: null }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // authorScore = 0.0\n        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should give authorScore 1.0 if query has no author', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', '') // Empty author\n        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n      })\n\n      it('handles book author string that is only commas correctly (score 0)', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: ',, ,, ,' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        // cleanedQueryAuthorForScore = \"john doe\"\n        // book.author leads to validBookAuthorParts being empty.\n        // authorScore = 0.0\n        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0\n        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)\n      })\n\n      it('should return 1.0 for ASIN results', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'B000F28ZJ4', null)\n        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n      })\n\n      it('should return 1.0 when author matches one of the book authors', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n      })\n\n      it('should return 1.0 when author query and multiple book authors are the same', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')\n        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n      })\n\n      it('should correctly score against a book with a subtitle when the query has a subtitle', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel: A Novel', 'John Doe')\n        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n      })\n\n      it('should correctly score against a book with a subtitle when the query does not have a subtitle', async () => {\n        const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n      })\n\n      describe('after fuzzy searches', () => {\n        it('should return 1.0 for a title candidate match', async () => {\n          const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]\n          runSearchStub.resolves([])\n          runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)\n          const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel - A Novel', 'John Doe')\n          expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n        })\n\n        it('should return 1.0 for an author candidate match', async () => {\n          const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]\n          runSearchStub.resolves([])\n          runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)\n          const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')\n          expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)\n        })\n      })\n    })\n\n    describe('for non-audible provider (e.g., google)', () => {\n      const provider = 'google'\n      it('should have not have matchConfidence', async () => {\n        const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }]\n        runSearchStub.resolves(bookResults)\n        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')\n        expect(results[0]).to.not.have.property('matchConfidence')\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/managers/ApiCacheManager.test.js",
    "content": "// Import dependencies and modules for testing\nconst { expect } = require('chai')\nconst sinon = require('sinon')\nconst ApiCacheManager = require('../../../server/managers/ApiCacheManager')\n\ndescribe('ApiCacheManager', () => {\n  let cache\n  let req\n  let res\n  let next\n  let manager\n\n  beforeEach(() => {\n    cache = { get: sinon.stub(), set: sinon.spy() }\n    req = { user: { username: 'testUser' }, url: '/test-url', query: {} }\n    res = { send: sinon.spy(), getHeaders: sinon.stub(), statusCode: 200, status: sinon.spy(), set: sinon.spy() }\n    next = sinon.spy()\n  })\n\n  describe('middleware', () => {\n    it('should send cached data if available', () => {\n      // Arrange\n      const cachedData = { body: 'cached data', headers: { 'content-type': 'application/json' }, statusCode: 200 }\n      cache.get.returns(cachedData)\n      const key = JSON.stringify({ user: req.user.username, url: req.url })\n      manager = new ApiCacheManager(cache)\n\n      // Act\n      manager.middleware(req, res, next)\n\n      // Assert\n      expect(cache.get.calledOnce).to.be.true\n      expect(cache.get.calledWith(key)).to.be.true\n      expect(res.set.calledOnce).to.be.true\n      expect(res.set.calledWith(cachedData.headers)).to.be.true\n      expect(res.status.calledOnce).to.be.true\n      expect(res.status.calledWith(cachedData.statusCode)).to.be.true\n      expect(res.send.calledOnce).to.be.true\n      expect(res.send.calledWith(cachedData.body)).to.be.true\n      expect(res.originalSend).to.be.undefined\n      expect(next.called).to.be.false\n      expect(cache.set.called).to.be.false\n    })\n\n    it('should cache and send response if data is not cached', () => {\n      // Arrange\n      cache.get.returns(null)\n      const headers = { 'content-type': 'application/json' }\n      res.getHeaders.returns(headers)\n      const body = 'response data'\n      const statusCode = 200\n      const responseData = { body, headers, statusCode }\n      const key = JSON.stringify({ user: req.user.username, url: req.url })\n      manager = new ApiCacheManager(cache)\n\n      // Act\n      manager.middleware(req, res, next)\n      res.send(body)\n\n      // Assert\n      expect(cache.get.calledOnce).to.be.true\n      expect(cache.get.calledWith(key)).to.be.true\n      expect(next.calledOnce).to.be.true\n      expect(cache.set.calledOnce).to.be.true\n      expect(cache.set.calledWith(key, responseData)).to.be.true\n      expect(res.originalSend.calledOnce).to.be.true\n      expect(res.originalSend.calledWith(body)).to.be.true\n    })\n\n    it('should cache personalized response with 30 minutes TTL', () => {\n      // Arrange\n      cache.get.returns(null)\n      const headers = { 'content-type': 'application/json' }\n      res.getHeaders.returns(headers)\n      const body = 'personalized data'\n      const statusCode = 200\n      const responseData = { body, headers, statusCode }\n      req.url = '/libraries/id/personalized'\n      const key = JSON.stringify({ user: req.user.username, url: req.url })\n      const ttlOptions = { ttl: 30 * 60 * 1000 }\n      manager = new ApiCacheManager(cache, ttlOptions)\n\n      // Act\n      manager.middleware(req, res, next)\n      res.send(body)\n\n      // Assert\n      expect(cache.get.calledOnce).to.be.true\n      expect(cache.get.calledWith(key)).to.be.true\n      expect(next.calledOnce).to.be.true\n      expect(cache.set.calledOnce).to.be.true\n      expect(cache.set.calledWith(key, responseData, ttlOptions)).to.be.true\n      expect(res.originalSend.calledOnce).to.be.true\n      expect(res.originalSend.calledWith(body)).to.be.true\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/managers/BinaryManager.test.js",
    "content": "const chai = require('chai')\nconst sinon = require('sinon')\nconst fs = require('../../../server/libs/fsExtra')\nconst fileUtils = require('../../../server/utils/fileUtils')\nconst which = require('../../../server/libs/which')\nconst path = require('path')\nconst BinaryManager = require('../../../server/managers/BinaryManager')\nconst { Binary, ffbinaries } = require('../../../server/managers/BinaryManager')\n\nconst expect = chai.expect\n\ndescribe('BinaryManager', () => {\n  let binaryManager\n\n  describe('init', () => {\n    let findStub\n    let installStub\n    let removeOldBinariesStub\n    let errorStub\n    let exitStub\n\n    beforeEach(() => {\n      binaryManager = new BinaryManager()\n      findStub = sinon.stub(binaryManager, 'findRequiredBinaries')\n      installStub = sinon.stub(binaryManager, 'install')\n      removeOldBinariesStub = sinon.stub(binaryManager, 'removeOldBinaries')\n      errorStub = sinon.stub(console, 'error')\n      exitStub = sinon.stub(process, 'exit')\n    })\n\n    afterEach(() => {\n      findStub.restore()\n      installStub.restore()\n      removeOldBinariesStub.restore()\n      errorStub.restore()\n      exitStub.restore()\n    })\n\n    it('should not install binaries if they are already found', async () => {\n      findStub.resolves([])\n\n      await binaryManager.init()\n\n      expect(installStub.called).to.be.false\n      expect(removeOldBinariesStub.called).to.be.false\n      expect(findStub.calledOnce).to.be.true\n      expect(errorStub.called).to.be.false\n      expect(exitStub.called).to.be.false\n    })\n\n    it('should install missing binaries', async () => {\n      const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries)\n      const requiredBinaries = [ffmpegBinary, ffprobeBinary]\n      const missingBinaries = [ffprobeBinary]\n      const missingBinariesAfterInstall = []\n      findStub.onFirstCall().resolves(missingBinaries)\n      findStub.onSecondCall().resolves(missingBinariesAfterInstall)\n      binaryManager.requiredBinaries = requiredBinaries\n\n      await binaryManager.init()\n\n      expect(findStub.calledTwice).to.be.true\n      expect(installStub.calledOnce).to.be.true\n      expect(removeOldBinariesStub.calledOnce).to.be.true\n      expect(errorStub.called).to.be.false\n      expect(exitStub.called).to.be.false\n    })\n\n    it('exit if binaries are not found after installation', async () => {\n      const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries)\n      const requiredBinaries = [ffmpegBinary, ffprobeBinary]\n      const missingBinaries = [ffprobeBinary]\n      const missingBinariesAfterInstall = [ffprobeBinary]\n      findStub.onFirstCall().resolves(missingBinaries)\n      findStub.onSecondCall().resolves(missingBinariesAfterInstall)\n\n      await binaryManager.init()\n\n      expect(findStub.calledTwice).to.be.true\n      expect(installStub.calledOnce).to.be.true\n      expect(removeOldBinariesStub.calledOnce).to.be.true\n      expect(errorStub.calledOnce).to.be.true\n      expect(exitStub.calledOnce).to.be.true\n      expect(exitStub.calledWith(1)).to.be.true\n    })\n\n    it('should not exit if binaries are not found but not required', async () => {\n      const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries, false)\n      const requiredBinaries = [ffmpegBinary]\n      const missingBinaries = [ffprobeBinary]\n      const missingBinariesAfterInstall = [ffprobeBinary]\n      findStub.onFirstCall().resolves(missingBinaries)\n      findStub.onSecondCall().resolves(missingBinariesAfterInstall)\n      binaryManager.requiredBinaries = requiredBinaries\n\n      await binaryManager.init()\n\n      expect(findStub.calledTwice).to.be.true\n      expect(installStub.calledOnce).to.be.true\n      expect(removeOldBinariesStub.calledOnce).to.be.true\n      expect(errorStub.called).to.be.false\n      expect(exitStub.called).to.be.false\n    })\n  })\n\n  describe('findRequiredBinaries', () => {\n    let findBinaryStub\n    let ffmpegBinary\n\n    beforeEach(() => {\n      ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      const requiredBinaries = [ffmpegBinary]\n      binaryManager = new BinaryManager(requiredBinaries)\n      findBinaryStub = sinon.stub(ffmpegBinary, 'find')\n    })\n\n    afterEach(() => {\n      findBinaryStub.restore()\n    })\n\n    it('should put found paths in the correct environment variables', async () => {\n      const pathToFFmpeg = '/path/to/ffmpeg'\n      const missingBinaries = []\n      delete process.env.FFMPEG_PATH\n      findBinaryStub.resolves(pathToFFmpeg)\n\n      const result = await binaryManager.findRequiredBinaries()\n\n      expect(result).to.deep.equal(missingBinaries)\n      expect(findBinaryStub.calledOnce).to.be.true\n      expect(process.env.FFMPEG_PATH).to.equal(pathToFFmpeg)\n    })\n\n    it('should add missing binaries to result', async () => {\n      const missingBinaries = [ffmpegBinary]\n      delete process.env.FFMPEG_PATH\n      findBinaryStub.resolves(null)\n\n      const result = await binaryManager.findRequiredBinaries()\n\n      expect(result).to.deep.equal(missingBinaries)\n      expect(findBinaryStub.calledOnce).to.be.true\n      expect(process.env.FFMPEG_PATH).to.be.undefined\n    })\n  })\n\n  describe('install', () => {\n    let isWritableStub\n    let downloadBinaryStub\n    let ffmpegBinary\n\n    beforeEach(() => {\n      ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      const requiredBinaries = [ffmpegBinary]\n      binaryManager = new BinaryManager(requiredBinaries)\n      isWritableStub = sinon.stub(fileUtils, 'isWritable')\n      downloadBinaryStub = sinon.stub(ffmpegBinary, 'download')\n      binaryManager.mainInstallDir = '/path/to/main/install'\n      binaryManager.altInstallDir = '/path/to/alt/install'\n    })\n\n    afterEach(() => {\n      isWritableStub.restore()\n      downloadBinaryStub.restore()\n    })\n\n    it('should not install binaries if no binaries are passed', async () => {\n      const binaries = []\n\n      await binaryManager.install(binaries)\n\n      expect(isWritableStub.called).to.be.false\n      expect(downloadBinaryStub.called).to.be.false\n    })\n\n    it('should install binaries in main install path if has access', async () => {\n      const binaries = [ffmpegBinary]\n      const destination = binaryManager.mainInstallDir\n      isWritableStub.withArgs(destination).resolves(true)\n      downloadBinaryStub.resolves()\n\n      await binaryManager.install(binaries)\n\n      expect(isWritableStub.calledOnce).to.be.true\n      expect(downloadBinaryStub.calledOnce).to.be.true\n      expect(downloadBinaryStub.calledWith(destination)).to.be.true\n    })\n\n    it('should install binaries in alt install path if has no access to main', async () => {\n      const binaries = [ffmpegBinary]\n      const mainDestination = binaryManager.mainInstallDir\n      const destination = binaryManager.altInstallDir\n      isWritableStub.withArgs(mainDestination).resolves(false)\n      downloadBinaryStub.resolves()\n\n      await binaryManager.install(binaries)\n\n      expect(isWritableStub.calledOnce).to.be.true\n      expect(downloadBinaryStub.calledOnce).to.be.true\n      expect(downloadBinaryStub.calledWith(destination)).to.be.true\n    })\n  })\n})\n\ndescribe('Binary', () => {\n  describe('find', () => {\n    let binary\n    let isGoodStub\n    let whichSyncStub\n    let mainInstallPath\n    let altInstallPath\n\n    const name = 'ffmpeg'\n    const envVariable = 'FFMPEG_PATH'\n    const defaultPath = '/path/to/ffmpeg'\n    const executable = name + (process.platform == 'win32' ? '.exe' : '')\n    const whichPath = '/usr/bin/ffmpeg'\n\n    beforeEach(() => {\n      binary = new Binary(name, 'executable', envVariable, ['5.1'], ffbinaries)\n      isGoodStub = sinon.stub(binary, 'isGood')\n      whichSyncStub = sinon.stub(which, 'sync')\n      binary.mainInstallDir = '/path/to/main/install'\n      mainInstallPath = path.join(binary.mainInstallDir, executable)\n      binary.altInstallDir = '/path/to/alt/install'\n      altInstallPath = path.join(binary.altInstallDir, executable)\n    })\n\n    afterEach(() => {\n      isGoodStub.restore()\n      whichSyncStub.restore()\n    })\n\n    it('should return the defaultPath if it exists and is a good binary', async () => {\n      process.env[envVariable] = defaultPath\n      isGoodStub.withArgs(defaultPath).resolves(true)\n\n      const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)\n\n      expect(result).to.equal(defaultPath)\n      expect(isGoodStub.calledOnce).to.be.true\n      expect(isGoodStub.calledWith(defaultPath)).to.be.true\n    })\n\n    it('should return the whichPath if it exists and is a good binary', async () => {\n      delete process.env[envVariable]\n      isGoodStub.withArgs(undefined).resolves(false)\n      whichSyncStub.returns(whichPath)\n      isGoodStub.withArgs(whichPath).resolves(true)\n\n      const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)\n\n      expect(result).to.equal(whichPath)\n      expect(isGoodStub.calledTwice).to.be.true\n      expect(isGoodStub.calledWith(undefined)).to.be.true\n      expect(isGoodStub.calledWith(whichPath)).to.be.true\n    })\n\n    it('should return the mainInstallPath if it exists and is a good binary', async () => {\n      delete process.env[envVariable]\n      isGoodStub.withArgs(undefined).resolves(false)\n      whichSyncStub.returns(null)\n      isGoodStub.withArgs(null).resolves(false)\n      isGoodStub.withArgs(mainInstallPath).resolves(true)\n\n      const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)\n\n      expect(result).to.equal(mainInstallPath)\n      expect(isGoodStub.callCount).to.be.equal(3)\n      expect(isGoodStub.calledWith(undefined)).to.be.true\n      expect(isGoodStub.calledWith(null)).to.be.true\n      expect(isGoodStub.calledWith(mainInstallPath)).to.be.true\n    })\n\n    it('should return the altInstallPath if it exists and is a good binary', async () => {\n      delete process.env[envVariable]\n      isGoodStub.withArgs(undefined).resolves(false)\n      whichSyncStub.returns(null)\n      isGoodStub.withArgs(null).resolves(false)\n      isGoodStub.withArgs(mainInstallPath).resolves(false)\n      isGoodStub.withArgs(altInstallPath).resolves(true)\n\n      const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)\n\n      expect(result).to.equal(altInstallPath)\n      expect(isGoodStub.callCount).to.be.equal(4)\n      expect(isGoodStub.calledWith(undefined)).to.be.true\n      expect(isGoodStub.calledWith(null)).to.be.true\n      expect(isGoodStub.calledWith(mainInstallPath)).to.be.true\n      expect(isGoodStub.calledWith(altInstallPath)).to.be.true\n    })\n\n    it('should return null if no good binary is found', async () => {\n      delete process.env[envVariable]\n      isGoodStub.withArgs(undefined).resolves(false)\n      whichSyncStub.returns(null)\n      isGoodStub.withArgs(null).resolves(false)\n      isGoodStub.withArgs(mainInstallPath).resolves(false)\n      isGoodStub.withArgs(altInstallPath).resolves(false)\n\n      const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)\n\n      expect(result).to.be.null\n      expect(isGoodStub.callCount).to.be.equal(4)\n      expect(isGoodStub.calledWith(undefined)).to.be.true\n      expect(isGoodStub.calledWith(null)).to.be.true\n      expect(isGoodStub.calledWith(mainInstallPath)).to.be.true\n      expect(isGoodStub.calledWith(altInstallPath)).to.be.true\n    })\n  })\n\n  describe('isGood', () => {\n    let binary\n    let fsPathExistsStub\n    let fsReadFileStub\n    let execStub\n\n    const binaryPath = '/path/to/binary'\n    const execCommand = '\"' + binaryPath + '\"' + ' -version'\n    const goodVersions = ['5.1', '6']\n\n    beforeEach(() => {\n      binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', goodVersions, ffbinaries)\n      fsPathExistsStub = sinon.stub(fs, 'pathExists')\n      fsReadFileStub = sinon.stub(fs, 'readFile')\n      execStub = sinon.stub(binary, 'exec')\n    })\n\n    afterEach(() => {\n      fsPathExistsStub.restore()\n      fsReadFileStub.restore()\n      execStub.restore()\n    })\n\n    it('should return false if binaryPath is falsy', async () => {\n      fsPathExistsStub.resolves(true)\n\n      const result = await binary.isGood(null)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.called).to.be.false\n      expect(execStub.called).to.be.false\n    })\n\n    it('should return false if binaryPath does not exist', async () => {\n      fsPathExistsStub.resolves(false)\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.calledOnce).to.be.true\n      expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true\n      expect(execStub.called).to.be.false\n    })\n\n    it('should return false if failed to check version of binary', async () => {\n      fsPathExistsStub.resolves(true)\n      execStub.rejects(new Error('Failed to execute command'))\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.calledOnce).to.be.true\n      expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true\n      expect(execStub.calledOnce).to.be.true\n      expect(execStub.calledWith(execCommand)).to.be.true\n    })\n\n    it('should return false if version is not found', async () => {\n      const stdout = 'Some output without version'\n      fsPathExistsStub.resolves(true)\n      execStub.resolves({ stdout })\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.calledOnce).to.be.true\n      expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true\n      expect(execStub.calledOnce).to.be.true\n      expect(execStub.calledWith(execCommand)).to.be.true\n    })\n\n    it('should return false if version is found but does not match a good version', async () => {\n      const stdout = 'version 1.2.3'\n      fsPathExistsStub.resolves(true)\n      execStub.resolves({ stdout })\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.calledOnce).to.be.true\n      expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true\n      expect(execStub.calledOnce).to.be.true\n      expect(execStub.calledWith(execCommand)).to.be.true\n    })\n\n    it('should return true if version is found and matches a good version', async () => {\n      const stdout = 'version 6.1.2'\n      fsPathExistsStub.resolves(true)\n      execStub.resolves({ stdout })\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.true\n      expect(fsPathExistsStub.calledOnce).to.be.true\n      expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true\n      expect(execStub.calledOnce).to.be.true\n      expect(execStub.calledWith(execCommand)).to.be.true\n    })\n\n    it('should check library version file', async () => {\n      const binary = new Binary('libavcodec', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      fsReadFileStub.resolves('5.1.2 ')\n      fsPathExistsStub.onFirstCall().resolves(true)\n      fsPathExistsStub.onSecondCall().resolves(true)\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.true\n      expect(fsPathExistsStub.calledTwice).to.be.true\n      expect(fsPathExistsStub.firstCall.args[0]).to.be.equal(binaryPath)\n      expect(fsPathExistsStub.secondCall.args[0]).to.be.equal(binaryPath + '.ver')\n      expect(fsReadFileStub.calledOnce).to.be.true\n      expect(fsReadFileStub.calledWith(binaryPath + '.ver'), 'utf8').to.be.true\n    })\n\n    it('should return false if library version file does not exist', async () => {\n      const binary = new Binary('libavcodec', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      fsReadFileStub.resolves('5.1.2 ')\n      fsPathExistsStub.onFirstCall().resolves(true)\n      fsPathExistsStub.onSecondCall().resolves(false)\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.calledTwice).to.be.true\n      expect(fsPathExistsStub.firstCall.args[0]).to.be.equal(binaryPath)\n      expect(fsPathExistsStub.secondCall.args[0]).to.be.equal(binaryPath + '.ver')\n      expect(fsReadFileStub.called).to.be.false\n    })\n\n    it('should return false if library version does not match a valid version', async () => {\n      const binary = new Binary('libavcodec', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      fsReadFileStub.resolves('5.2.1 ')\n      fsPathExistsStub.onFirstCall().resolves(true)\n      fsPathExistsStub.onSecondCall().resolves(true)\n\n      const result = await binary.isGood(binaryPath)\n\n      expect(result).to.be.false\n      expect(fsPathExistsStub.calledTwice).to.be.true\n      expect(fsPathExistsStub.firstCall.args[0]).to.be.equal(binaryPath)\n      expect(fsPathExistsStub.secondCall.args[0]).to.be.equal(binaryPath + '.ver')\n      expect(fsReadFileStub.calledOnce).to.be.true\n      expect(fsReadFileStub.calledWith(binaryPath + '.ver'), 'utf8').to.be.true\n    })\n  })\n\n  describe('getFileName', () => {\n    let originalPlatform\n\n    const mockPlatform = (platform) => {\n      Object.defineProperty(process, 'platform', { value: platform })\n    }\n\n    beforeEach(() => {\n      // Save the original process.platform descriptor\n      originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')\n    })\n\n    afterEach(() => {\n      // Restore the original process.platform descriptor\n      Object.defineProperty(process, 'platform', originalPlatform)\n    })\n\n    it('should return the executable file name with .exe extension on Windows', () => {\n      const binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      mockPlatform('win32')\n\n      const result = binary.getFileName()\n\n      expect(result).to.equal('ffmpeg.exe')\n    })\n\n    it('should return the executable file name without extension on linux', () => {\n      const binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      mockPlatform('linux')\n\n      const result = binary.getFileName()\n\n      expect(result).to.equal('ffmpeg')\n    })\n\n    it('should return the library file name with .dll extension on Windows', () => {\n      const binary = new Binary('ffmpeg', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      mockPlatform('win32')\n\n      const result = binary.getFileName()\n\n      expect(result).to.equal('ffmpeg.dll')\n    })\n\n    it('should return the library file name with .so extension on linux', () => {\n      const binary = new Binary('ffmpeg', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      mockPlatform('linux')\n\n      const result = binary.getFileName()\n\n      expect(result).to.equal('ffmpeg.so')\n    })\n\n    it('should return the file name without extension for other types', () => {\n      const binary = new Binary('ffmpeg', 'other', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      mockPlatform('win32')\n\n      const result = binary.getFileName()\n\n      expect(result).to.equal('ffmpeg')\n    })\n  })\n\n  describe('download', () => {\n    let binary\n    let downloadBinaryStub\n    let fsWriteFileStub\n\n    beforeEach(() => {\n      binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      downloadBinaryStub = sinon.stub(binary.source, 'downloadBinary')\n      fsWriteFileStub = sinon.stub(fs, 'writeFile')\n    })\n\n    afterEach(() => {\n      downloadBinaryStub.restore()\n      fsWriteFileStub.restore()\n    })\n\n    it('should call downloadBinary with the correct parameters', async () => {\n      const destination = '/path/to/destination'\n\n      await binary.download(destination)\n\n      expect(downloadBinaryStub.calledOnce).to.be.true\n      expect(downloadBinaryStub.calledWith('ffmpeg', '5.1', destination)).to.be.true\n    })\n\n    it('should write a version file for libraries', async () => {\n      const binary = new Binary('libavcodec', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)\n      const destination = '/path/to/destination'\n      const versionFilePath = path.join(destination, binary.fileName) + '.ver'\n\n      await binary.download(destination)\n\n      expect(downloadBinaryStub.calledOnce).to.be.true\n      expect(downloadBinaryStub.calledWith('libavcodec', '5.1', destination)).to.be.true\n      expect(fsWriteFileStub.calledOnce).to.be.true\n      expect(fsWriteFileStub.calledWith(versionFilePath, '5.1')).to.be.true\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/managers/MigrationManager.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst { Sequelize } = require('sequelize')\nconst fs = require('../../../server/libs/fsExtra')\nconst Logger = require('../../../server/Logger')\nconst MigrationManager = require('../../../server/managers/MigrationManager')\nconst path = require('path')\nconst { Umzug, memoryStorage } = require('../../../server/libs/umzug')\n\ndescribe('MigrationManager', () => {\n  let sequelizeStub\n  let umzugStub\n  let migrationManager\n  let loggerInfoStub\n  let loggerErrorStub\n  let fsCopyStub\n  let fsMoveStub\n  let fsRemoveStub\n  let fsEnsureDirStub\n  let processExitStub\n  let configPath = '/path/to/config'\n\n  const serverVersion = '1.2.0'\n\n  beforeEach(() => {\n    sequelizeStub = sinon.createStubInstance(Sequelize)\n    umzugStub = {\n      migrations: sinon.stub(),\n      executed: sinon.stub(),\n      up: sinon.stub(),\n      down: sinon.stub()\n    }\n    sequelizeStub.getQueryInterface.returns({})\n    migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n    migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves()\n    migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves()\n    migrationManager.updateMaxVersion = sinon.stub().resolves()\n    migrationManager.initUmzug = sinon.stub()\n    migrationManager.umzug = umzugStub\n    loggerInfoStub = sinon.stub(Logger, 'info')\n    loggerErrorStub = sinon.stub(Logger, 'error')\n    fsCopyStub = sinon.stub(fs, 'copy').resolves()\n    fsMoveStub = sinon.stub(fs, 'move').resolves()\n    fsRemoveStub = sinon.stub(fs, 'remove').resolves()\n    fsEnsureDirStub = sinon.stub(fs, 'ensureDir').resolves()\n    fsPathExistsStub = sinon.stub(fs, 'pathExists').resolves(true)\n    processExitStub = sinon.stub(process, 'exit')\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('init', () => {\n    it('should initialize the MigrationManager', async () => {\n      // arrange\n      migrationManager.databaseVersion = '1.1.0'\n      migrationManager.maxVersion = '1.1.0'\n      migrationManager.umzug = null\n      migrationManager.configPath = __dirname\n\n      // Act\n      await migrationManager.init(serverVersion)\n\n      // Assert\n      expect(fsEnsureDirStub.calledOnce).to.be.true\n      expect(fsEnsureDirStub.calledWith(migrationManager.migrationsDir)).to.be.true\n      expect(migrationManager.serverVersion).to.equal(serverVersion)\n      expect(migrationManager.sequelize).to.equal(sequelizeStub)\n      expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations'))\n      expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true\n      expect(migrationManager.updateMaxVersion.calledOnce).to.be.true\n      expect(migrationManager.initialized).to.be.true\n    })\n\n    it('should throw error if serverVersion is not provided', async () => {\n      // Act\n      try {\n        const result = await migrationManager.init()\n        expect.fail('Expected init to throw an error, but it did not.')\n      } catch (error) {\n        expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.')\n      }\n    })\n  })\n\n  describe('runMigrations', () => {\n    it('should run up migrations successfully', async () => {\n      // Arrange\n      migrationManager.databaseVersion = '1.1.0'\n      migrationManager.maxVersion = '1.1.0'\n      migrationManager.serverVersion = '1.2.0'\n      migrationManager.initialized = true\n\n      umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])\n      umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])\n\n      // Act\n      await migrationManager.runMigrations()\n\n      // Assert\n      expect(migrationManager.initUmzug.calledOnce).to.be.true\n      expect(umzugStub.up.calledOnce).to.be.true\n      expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'], rerun: 'ALLOW' })).to.be.true\n      expect(fsCopyStub.calledOnce).to.be.true\n      expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true\n      expect(fsRemoveStub.calledOnce).to.be.true\n      expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true\n      expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true\n    })\n\n    it('should run down migrations successfully', async () => {\n      // Arrange\n      migrationManager.databaseVersion = '1.2.0'\n      migrationManager.maxVersion = '1.2.0'\n      migrationManager.serverVersion = '1.1.0'\n      migrationManager.initialized = true\n\n      umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])\n      umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])\n\n      // Act\n      await migrationManager.runMigrations()\n\n      // Assert\n      expect(migrationManager.initUmzug.calledOnce).to.be.true\n      expect(umzugStub.down.calledOnce).to.be.true\n      expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'], rerun: 'ALLOW' })).to.be.true\n      expect(fsCopyStub.calledOnce).to.be.true\n      expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true\n      expect(fsRemoveStub.calledOnce).to.be.true\n      expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true\n      expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true\n    })\n\n    it('should log that migrations will be skipped if database is new', async () => {\n      // Arrange\n      migrationManager.isDatabaseNew = true\n      migrationManager.initialized = true\n\n      // Act\n      await migrationManager.runMigrations()\n\n      // Assert\n      expect(loggerInfoStub.calledWith(sinon.match('Database is new. Skipping migrations.'))).to.be.true\n      expect(migrationManager.initUmzug.called).to.be.false\n      expect(umzugStub.up.called).to.be.false\n      expect(umzugStub.down.called).to.be.false\n    })\n\n    it('should log that no migrations are needed if serverVersion equals databaseVersion', async () => {\n      // Arrange\n      migrationManager.serverVersion = '1.2.0'\n      migrationManager.databaseVersion = '1.2.0'\n      migrationManager.maxVersion = '1.2.0'\n      migrationManager.initialized = true\n\n      // Act\n      await migrationManager.runMigrations()\n\n      // Assert\n      expect(umzugStub.up.called).to.be.false\n      expect(loggerInfoStub.calledWith(sinon.match('Database is already up to date.'))).to.be.true\n    })\n\n    it('should handle migration failure and restore the original database', async () => {\n      // Arrange\n      migrationManager.serverVersion = '1.2.0'\n      migrationManager.databaseVersion = '1.1.0'\n      migrationManager.maxVersion = '1.1.0'\n      migrationManager.initialized = true\n\n      umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }])\n      umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])\n      umzugStub.up.rejects(new Error('Migration failed'))\n\n      const originalDbPath = path.join(configPath, 'absdatabase.sqlite')\n      const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite')\n\n      // Act\n      await migrationManager.runMigrations()\n\n      // Assert\n      expect(migrationManager.initUmzug.calledOnce).to.be.true\n      expect(umzugStub.up.calledOnce).to.be.true\n      expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true\n      expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true\n      expect(fsMoveStub.calledWith(backupDbPath, originalDbPath, { overwrite: true })).to.be.true\n      expect(loggerInfoStub.calledWith(sinon.match('Restored the original database'))).to.be.true\n      expect(processExitStub.calledOnce).to.be.true\n    })\n  })\n\n  describe('fetchVersionsFromDatabase', () => {\n    it('should fetch versions from the migrationsMeta table', async () => {\n      // Arrange\n      const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n      // Create a migrationsMeta table and populate it with version and maxVersion\n      await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')\n      await sequelize.query(\"INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')\")\n      const migrationManager = new MigrationManager(sequelize, false, configPath)\n      migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()\n\n      // Act\n      await migrationManager.fetchVersionsFromDatabase()\n\n      // Assert\n      expect(migrationManager.maxVersion).to.equal('1.1.0')\n      expect(migrationManager.databaseVersion).to.equal('1.1.0')\n    })\n\n    it('should create the migrationsMeta table if it does not exist and fetch versions from it', async () => {\n      // Arrange\n      const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n      const migrationManager = new MigrationManager(sequelize, false, configPath)\n      migrationManager.serverVersion = serverVersion\n\n      // Act\n      await migrationManager.fetchVersionsFromDatabase()\n\n      // Assert\n      const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta')\n      expect(tableDescription).to.deep.equal({\n        key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false },\n        value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }\n      })\n      expect(migrationManager.maxVersion).to.equal('0.0.0')\n      expect(migrationManager.databaseVersion).to.equal('0.0.0')\n    })\n\n    it('should create the migrationsMeta with databaseVersion=serverVersion if database is new', async () => {\n      // Arrange\n      const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n      const migrationManager = new MigrationManager(sequelize, true, configPath)\n      migrationManager.serverVersion = serverVersion\n\n      // Act\n      await migrationManager.fetchVersionsFromDatabase()\n\n      // Assert\n      const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta')\n      expect(tableDescription).to.deep.equal({\n        key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false },\n        value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }\n      })\n      expect(migrationManager.maxVersion).to.equal('0.0.0')\n      expect(migrationManager.databaseVersion).to.equal(serverVersion)\n    })\n\n    it('should re-create the migrationsMeta table if it existed and database is new (Database force=true)', async () => {\n      // Arrange\n      const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n      // Create a migrationsMeta table and populate it with version and maxVersion\n      await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')\n      await sequelize.query(\"INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')\")\n      const migrationManager = new MigrationManager(sequelize, true, configPath)\n      migrationManager.serverVersion = serverVersion\n\n      // Act\n      await migrationManager.fetchVersionsFromDatabase()\n\n      // Assert\n      expect(migrationManager.maxVersion).to.equal('0.0.0')\n      expect(migrationManager.databaseVersion).to.equal(serverVersion)\n    })\n\n    it('should throw an error if the database query fails', async () => {\n      // Arrange\n      const sequelizeStub = sinon.createStubInstance(Sequelize)\n      sequelizeStub.query.rejects(new Error('Database query failed'))\n      const migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n      migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()\n\n      // Act\n      try {\n        await migrationManager.fetchVersionsFromDatabase()\n        expect.fail('Expected fetchVersionsFromDatabase to throw an error, but it did not.')\n      } catch (error) {\n        // Assert\n        expect(error.message).to.equal('Database query failed')\n      }\n    })\n  })\n\n  describe('updateMaxVersion', () => {\n    it('should update the maxVersion in the database', async () => {\n      // Arrange\n      const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n      // Create a migrationsMeta table and populate it with version and maxVersion\n      await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')\n      await sequelize.query(\"INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')\")\n      const migrationManager = new MigrationManager(sequelize, false, configPath)\n      migrationManager.serverVersion = '1.2.0'\n\n      // Act\n      await migrationManager.updateMaxVersion()\n\n      // Assert\n      const [{ maxVersion }] = await sequelize.query(\"SELECT value AS maxVersion FROM migrationsMeta WHERE key = 'maxVersion'\", {\n        type: Sequelize.QueryTypes.SELECT\n      })\n      expect(maxVersion).to.equal('1.2.0')\n    })\n  })\n\n  describe('extractVersionFromTag', () => {\n    it('should return null if tag is not provided', () => {\n      // Arrange\n      const migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n\n      // Act\n      const result = migrationManager.extractVersionFromTag()\n\n      // Assert\n      expect(result).to.be.null\n    })\n\n    it('should return null if tag does not match the version format', () => {\n      // Arrange\n      const migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n      const tag = 'invalid-tag'\n\n      // Act\n      const result = migrationManager.extractVersionFromTag(tag)\n\n      // Assert\n      expect(result).to.be.null\n    })\n\n    it('should extract the version from the tag', () => {\n      // Arrange\n      const migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n      const tag = 'v1.2.3'\n\n      // Act\n      const result = migrationManager.extractVersionFromTag(tag)\n\n      // Assert\n      expect(result).to.equal('1.2.3')\n    })\n  })\n\n  describe('copyMigrationsToConfigDir', () => {\n    it('should copy migrations to the config directory', async () => {\n      // Arrange\n      const migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n      migrationManager.migrationsDir = path.join(configPath, 'migrations')\n      const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')\n      const targetDir = migrationManager.migrationsDir\n      const files = ['migration1.js', 'migration2.js', 'readme.md']\n\n      const readdirStub = sinon.stub(fs, 'readdir').resolves(files)\n\n      // Act\n      await migrationManager.copyMigrationsToConfigDir()\n\n      // Assert\n      expect(readdirStub.calledOnce).to.be.true\n      expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true\n      expect(fsCopyStub.calledTwice).to.be.true\n      expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true\n      expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true\n    })\n\n    it('should throw an error if copying the migrations fails', async () => {\n      // Arrange\n      const migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n      migrationManager.migrationsDir = path.join(configPath, 'migrations')\n      const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')\n      const targetDir = migrationManager.migrationsDir\n      const files = ['migration1.js', 'migration2.js', 'readme.md']\n\n      const readdirStub = sinon.stub(fs, 'readdir').resolves(files)\n      fsCopyStub.restore()\n      fsCopyStub = sinon.stub(fs, 'copy').rejects()\n\n      // Act\n      try {\n        // Act\n        await migrationManager.copyMigrationsToConfigDir()\n        expect.fail('Expected copyMigrationsToConfigDir to throw an error, but it did not.')\n      } catch (error) {}\n\n      // Assert\n      expect(readdirStub.calledOnce).to.be.true\n      expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true\n      expect(fsCopyStub.calledTwice).to.be.true\n      expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true\n      expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true\n    })\n  })\n\n  describe('findMigrationsToRun', () => {\n    it('should return migrations to run when direction is \"up\"', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.0.0-migration.js']\n      migrationManager.databaseVersion = '1.0.0'\n      migrationManager.serverVersion = '1.2.0'\n      const direction = 'up'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal(['v1.1.0-migration.js', 'v1.2.0-migration.js'])\n    })\n\n    it('should return migrations to run when direction is \"down\"', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.2.0-migration.js', 'v1.3.0-migration.js']\n      migrationManager.databaseVersion = '1.3.0'\n      migrationManager.serverVersion = '1.2.0'\n      const direction = 'down'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal(['v1.3.0-migration.js'])\n    })\n\n    it('should return empty array when no migrations to run up', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']\n      migrationManager.databaseVersion = '1.3.0'\n      migrationManager.serverVersion = '1.4.0'\n      const direction = 'up'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal([])\n    })\n\n    it('should return empty array when no migrations to run down', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = []\n      migrationManager.databaseVersion = '1.4.0'\n      migrationManager.serverVersion = '1.3.0'\n      const direction = 'down'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal([])\n    })\n\n    it('should return down migrations to run when direction is \"down\" and up migration was not executed', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = []\n      migrationManager.databaseVersion = '1.3.0'\n      migrationManager.serverVersion = '1.0.0'\n      const direction = 'down'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal(['v1.3.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])\n    })\n\n    it('should return empty array when direction is \"down\" and server version is higher than database version', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']\n      migrationManager.databaseVersion = '1.0.0'\n      migrationManager.serverVersion = '1.3.0'\n      const direction = 'down'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal([])\n    })\n\n    it('should return empty array when direction is \"up\" and server version is lower than database version', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']\n      migrationManager.databaseVersion = '1.3.0'\n      migrationManager.serverVersion = '1.0.0'\n      const direction = 'up'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal([])\n    })\n\n    it('should return up migrations to run when server version is between migrations', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js']\n      migrationManager.databaseVersion = '1.1.0'\n      migrationManager.serverVersion = '1.2.3'\n      const direction = 'up'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal(['v1.2.0-migration.js'])\n    })\n\n    it('should return down migrations to run when server version is between migrations', () => {\n      // Arrange\n      const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]\n      const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js']\n      migrationManager.databaseVersion = '1.2.0'\n      migrationManager.serverVersion = '1.1.3'\n      const direction = 'down'\n\n      // Act\n      const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)\n\n      // Assert\n      expect(result).to.deep.equal(['v1.2.0-migration.js'])\n    })\n  })\n\n  describe('initUmzug', () => {\n    it('should initialize the umzug instance with migrations in the proper order', async () => {\n      // Arrange\n      const readdirStub = sinon.stub(fs, 'readdir').resolves(['v1.0.0-migration.js', 'v1.10.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])\n      const readFileSyncStub = sinon.stub(fs, 'readFileSync').returns('module.exports = { up: () => {}, down: () => {} }')\n      const umzugStorage = memoryStorage()\n      migrationManager = new MigrationManager(sequelizeStub, false, configPath)\n      migrationManager.migrationsDir = path.join(configPath, 'migrations')\n      const resolvedMigrationNames = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']\n      const resolvedMigrationPaths = resolvedMigrationNames.map((name) => path.resolve(path.join(migrationManager.migrationsDir, name)))\n\n      // Act\n      await migrationManager.initUmzug(umzugStorage)\n\n      // Assert\n      expect(readdirStub.calledOnce).to.be.true\n      expect(migrationManager.umzug).to.be.an.instanceOf(Umzug)\n      const migrations = await migrationManager.umzug.migrations()\n      expect(migrations.map((m) => m.name)).to.deep.equal(resolvedMigrationNames)\n      expect(migrations.map((m) => m.path)).to.deep.equal(resolvedMigrationPaths)\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/managers/migrations/v1.0.0-migration.js",
    "content": "async function up() {\n  console.log('v1.0.0 up')\n}\n\nasync function down() {\n  console.log('v1.0.0 down')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "test/server/managers/migrations/v1.1.0-migration.js",
    "content": "async function up() {\n  console.log('v1.1.0 up')\n}\n\nasync function down() {\n  console.log('v1.1.0 down')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "test/server/managers/migrations/v1.10.0-migration.js",
    "content": "async function up() {\n  console.log('v1.10.0 up')\n}\n\nasync function down() {\n  console.log('v1.10.0 down')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "test/server/managers/migrations/v1.2.0-migration.js",
    "content": "async function up() {\n  console.log('v1.2.0 up')\n}\n\nasync function down() {\n  console.log('v1.2.0 down')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "test/server/migrations/v0.0.1-migration_example.js",
    "content": "const { DataTypes } = require('sequelize')\n\n/**\n * @typedef MigrationContext\n * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.\n * @property {import('../Logger')} logger - a Logger object.\n *\n * @typedef MigrationOptions\n * @property {MigrationContext} context - an object containing the migration context.\n */\n\n/**\n * This is an example of an upward migration script.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function up({ context: { queryInterface, logger } }) {\n  logger.info('Running migration_example up...')\n  logger.info('Creating example_table...')\n  await queryInterface.createTable('example_table', {\n    id: {\n      type: DataTypes.INTEGER,\n      primaryKey: true,\n      autoIncrement: true\n    },\n    name: {\n      type: DataTypes.STRING,\n      allowNull: false\n    }\n  })\n  logger.info('example_table created.')\n  logger.info('migration_example up complete.')\n}\n\n/**\n * This is an example of a downward migration script.\n *\n * @param {MigrationOptions} options - an object containing the migration context.\n * @returns {Promise<void>} - A promise that resolves when the migration is complete.\n */\nasync function down({ context: { queryInterface, logger } }) {\n  logger.info('Running migration_example down...')\n  logger.info('Dropping example_table...')\n  await queryInterface.dropTable('example_table')\n  logger.info('example_table dropped.')\n  logger.info('migration_example down complete.')\n}\n\nmodule.exports = { up, down }\n"
  },
  {
    "path": "test/server/migrations/v0.0.1-migration_example.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst { up, down } = require('./v0.0.1-migration_example')\nconst { Sequelize } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\ndescribe('migration_example', () => {\n  let sequelize\n  let queryInterface\n  let loggerInfoStub\n\n  beforeEach(() => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    loggerInfoStub = sinon.stub(Logger, 'info')\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('up', () => {\n    it('should create example_table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(4)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('Creating example_table...'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('example_table created.'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('migration_example up complete.'))).to.be.true\n      expect(await queryInterface.showAllTables()).to.include('example_table')\n      const tableDescription = await queryInterface.describeTable('example_table')\n      expect(tableDescription).to.deep.equal({\n        id: { type: 'INTEGER', allowNull: true, defaultValue: undefined, primaryKey: true, unique: false },\n        name: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }\n      })\n    })\n  })\n\n  describe('down', () => {\n    it('should drop example_table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(8)\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Dropping example_table...'))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('example_table dropped.'))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('migration_example down complete.'))).to.be.true\n      expect(await queryInterface.showAllTables()).not.to.include('example_table')\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.15.0-series-column-unique.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst { up, down } = require('../../../server/migrations/v2.15.0-series-column-unique')\nconst { Sequelize } = require('sequelize')\nconst Logger = require('../../../server/Logger')\nconst { query } = require('express')\nconst { logger } = require('sequelize/lib/utils/logger')\nconst e = require('express')\n\ndescribe('migration-v2.15.0-series-column-unique', () => {\n  let sequelize\n  let queryInterface\n  let loggerInfoStub\n  let series1Id\n  let series2Id\n  let series3Id\n  let series1Id_dup\n  let series3Id_dup\n  let series1Id_dup2\n  let book1Id\n  let book2Id\n  let book3Id\n  let book4Id\n  let book5Id\n  let book6Id\n  let library1Id\n  let library2Id\n  let bookSeries1Id\n  let bookSeries2Id\n  let bookSeries3Id\n  let bookSeries1Id_dup\n  let bookSeries3Id_dup\n  let bookSeries1Id_dup2\n\n  beforeEach(() => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    loggerInfoStub = sinon.stub(Logger, 'info')\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('up', () => {\n    beforeEach(async () => {\n      await queryInterface.createTable('Series', {\n        id: { type: Sequelize.UUID, primaryKey: true },\n        name: { type: Sequelize.STRING, allowNull: false },\n        libraryId: { type: Sequelize.UUID, allowNull: false },\n        createdAt: { type: Sequelize.DATE, allowNull: false },\n        updatedAt: { type: Sequelize.DATE, allowNull: false }\n      })\n      // Create a table for BookSeries, with a unique constraint of bookId and seriesId\n      await queryInterface.createTable(\n        'BookSeries',\n        {\n          id: { type: Sequelize.UUID, primaryKey: true },\n          sequence: { type: Sequelize.STRING, allowNull: true },\n          bookId: { type: Sequelize.UUID, allowNull: false },\n          seriesId: { type: Sequelize.UUID, allowNull: false }\n        },\n        { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } }\n      )\n      // Set UUIDs for the tests\n      series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b'\n      series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd'\n      series3Id = '01cac008-142b-4e15-b0ff-cf7cc2c5b64e'\n      series1Id_dup = 'ad0b3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      series3Id_dup = '4b3b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      series1Id_dup2 = '0123456a-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      book1Id = '4a38b6e5-0ae4-4de4-b119-4e33891bd63f'\n      book2Id = '8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f'\n      book3Id = 'ec9bbaaf-1e55-457f-b59c-bd2bd955a404'\n      book4Id = '876f3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      book5Id = '4e5b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      book6Id = 'abcda123-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      library1Id = '3a5a1c7c-a914-472e-88b0-b871ceae63e7'\n      library2Id = 'fd6c324a-4f3a-4bb0-99d6-7a330e765e7e'\n      bookSeries1Id = 'eca24687-2241-4ffa-a9b3-02a0ba03c763'\n      bookSeries2Id = '56f56105-813b-4395-9689-fd04198e7d5d'\n      bookSeries3Id = '404a1761-c710-4d86-9d78-68d9a9c0fb6b'\n      bookSeries1Id_dup = '8bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      bookSeries3Id_dup = '89656a3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n      bookSeries1Id_dup2 = '9bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b'\n    })\n    afterEach(async () => {\n      await queryInterface.dropTable('Series')\n      await queryInterface.dropTable('BookSeries')\n    })\n    it('upgrade with no duplicate series', async () => {\n      // Add some entries to the Series table using the UUID for the ids\n      await queryInterface.bulkInsert('Series', [\n        { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },\n        { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() },\n        { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }\n      ])\n      // Add some entries to the BookSeries table\n      await queryInterface.bulkInsert('BookSeries', [\n        { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id },\n        { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id },\n        { id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id }\n      ])\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(6)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // Validate rows in tables\n      const series = await queryInterface.sequelize.query('SELECT \"id\", \"name\", \"libraryId\" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(series).to.have.length(3)\n      expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })\n      expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id })\n      expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id })\n      const bookSeries = await queryInterface.sequelize.query('SELECT \"id\", \"sequence\", \"bookId\", \"seriesId\" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(bookSeries).to.have.length(3)\n      expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book2Id, seriesId: series2Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id })\n    })\n    it('upgrade with duplicate series and no sequence', async () => {\n      // Add some entries to the Series table using the UUID for the ids\n      await queryInterface.bulkInsert('Series', [\n        { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(7), updatedAt: new Date(7) },\n        { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(7), updatedAt: new Date(8) },\n        { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(7), updatedAt: new Date(9) },\n        { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },\n        { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },\n        { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) }\n      ])\n      // Add some entries to the BookSeries table\n      await queryInterface.bulkInsert('BookSeries', [\n        { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id },\n        { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id },\n        { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id },\n        { id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id_dup },\n        { id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id_dup },\n        { id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id_dup2 }\n      ])\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(8)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 2 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating series \"Series 1\" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Deduplicating series \"Series 3\" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // Validate rows\n      const series = await queryInterface.sequelize.query('SELECT \"id\", \"name\", \"libraryId\" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(series).to.have.length(3)\n      expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })\n      expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id })\n      expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id })\n      const bookSeries = await queryInterface.sequelize.query('SELECT \"id\", \"bookId\", \"seriesId\" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(bookSeries).to.have.length(6)\n      expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id })\n    })\n    it('upgrade with same series name in different libraries', async () => {\n      // Add some entries to the Series table using the UUID for the ids\n      await queryInterface.bulkInsert('Series', [\n        { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },\n        { id: series2Id, name: 'Series 1', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }\n      ])\n      // Add some entries to the BookSeries table\n      await queryInterface.bulkInsert('BookSeries', [\n        { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id },\n        { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }\n      ])\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(6)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // Validate rows\n      const series = await queryInterface.sequelize.query('SELECT \"id\", \"name\", \"libraryId\" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(series).to.have.length(2)\n      expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })\n      expect(series).to.deep.include({ id: series2Id, name: 'Series 1', libraryId: library2Id })\n      const bookSeries = await queryInterface.sequelize.query('SELECT \"id\", \"bookId\", \"seriesId\" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(bookSeries).to.have.length(2)\n      expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id })\n      expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id })\n    })\n    it('upgrade with one book in two of the same series, both sequence are null', async () => {\n      // Create two different series with the same name in the same library\n      await queryInterface.bulkInsert('Series', [\n        { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(8), updatedAt: new Date(20) },\n        { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(10) }\n      ])\n      // Create a book that is in both series\n      await queryInterface.bulkInsert('BookSeries', [\n        { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id },\n        { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id }\n      ])\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(9)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating series \"Series 1\" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series \"Series 1\" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series \"Series 1\" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // validate rows\n      const series = await queryInterface.sequelize.query('SELECT \"id\", \"name\", \"libraryId\" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(series).to.have.length(1)\n      expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })\n      const bookSeries = await queryInterface.sequelize.query('SELECT \"id\", \"sequence\", \"bookId\", \"seriesId\" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(bookSeries).to.have.length(1)\n      // Keep BookSeries 2 because it was edited last from cleaning up duplicate books\n      expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book1Id, seriesId: series1Id })\n    })\n    it('upgrade with one book in two of the same series, one sequence is null', async () => {\n      // Create two different series with the same name in the same library\n      await queryInterface.bulkInsert('Series', [\n        { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(9) },\n        { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(7) }\n      ])\n      // Create a book that is in both series\n      await queryInterface.bulkInsert('BookSeries', [\n        { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id },\n        { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id }\n      ])\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(9)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating series \"Series 1\" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series \"Series 1\" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series \"Series 1\" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // validate rows\n      const series = await queryInterface.sequelize.query('SELECT \"id\", \"name\", \"libraryId\" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(series).to.have.length(1)\n      expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })\n      const bookSeries = await queryInterface.sequelize.query('SELECT \"id\", \"sequence\", \"bookId\", \"seriesId\" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(bookSeries).to.have.length(1)\n      expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id })\n    })\n    it('upgrade with one book in two of the same series, both sequence are not null', async () => {\n      // Create two different series with the same name in the same library\n      await queryInterface.bulkInsert('Series', [\n        { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(1), updatedAt: new Date(3) },\n        { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(2), updatedAt: new Date(2) }\n      ])\n      // Create a book that is in both series\n      await queryInterface.bulkInsert('BookSeries', [\n        { id: bookSeries1Id, sequence: '3', bookId: book1Id, seriesId: series1Id },\n        { id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id }\n      ])\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(9)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 1 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplicating series \"Series 1\" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series \"Series 1\" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series \"Series 1\" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // validate rows\n      const series = await queryInterface.sequelize.query('SELECT \"id\", \"name\", \"libraryId\" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(series).to.have.length(1)\n      expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id })\n      const bookSeries = await queryInterface.sequelize.query('SELECT \"id\", \"sequence\", \"bookId\", \"seriesId\" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT })\n      expect(bookSeries).to.have.length(1)\n      // Keep BookSeries 2 because it is the lower sequence number\n      expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series1Id })\n    })\n  })\n\n  describe('down', () => {\n    beforeEach(async () => {\n      await queryInterface.createTable('Series', {\n        id: { type: Sequelize.UUID, primaryKey: true },\n        name: { type: Sequelize.STRING, allowNull: false },\n        libraryId: { type: Sequelize.UUID, allowNull: false },\n        createdAt: { type: Sequelize.DATE, allowNull: false },\n        updatedAt: { type: Sequelize.DATE, allowNull: false }\n      })\n      // Create a table for BookSeries, with a unique constraint of bookId and seriesId\n      await queryInterface.createTable(\n        'BookSeries',\n        {\n          id: { type: Sequelize.UUID, primaryKey: true },\n          bookId: { type: Sequelize.UUID, allowNull: false },\n          seriesId: { type: Sequelize.UUID, allowNull: false }\n        },\n        { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } }\n      )\n    })\n    it('should not have unique constraint on series name and libraryId', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(9)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.15.0 migration] Found 0 duplicate series'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.15.0 migration] Deduplication complete'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.15.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique '))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId'))).to.be.true\n      expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique '))).to.be.true\n      // Ensure index does not exist\n      const indexes = await queryInterface.showIndex('Series')\n      expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' })\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.17.3-fk-constraints.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst { up } = require('../../../server/migrations/v2.17.3-fk-constraints')\nconst { Sequelize, QueryInterface } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\ndescribe('migration-v2.17.3-fk-constraints', () => {\n  let sequelize\n  /** @type {QueryInterface} */\n  let queryInterface\n  let loggerInfoStub\n\n  beforeEach(() => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    loggerInfoStub = sinon.stub(Logger, 'info')\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('up', () => {\n    beforeEach(async () => {\n      // Create associated tables: Users, libraries, libraryFolders, playlists, devices\n      await queryInterface.sequelize.query('CREATE TABLE `users` (`id` UUID PRIMARY KEY);')\n      await queryInterface.sequelize.query('CREATE TABLE `libraries` (`id` UUID PRIMARY KEY);')\n      await queryInterface.sequelize.query('CREATE TABLE `libraryFolders` (`id` UUID PRIMARY KEY);')\n      await queryInterface.sequelize.query('CREATE TABLE `playlists` (`id` UUID PRIMARY KEY);')\n      await queryInterface.sequelize.query('CREATE TABLE `devices` (`id` UUID PRIMARY KEY);')\n    })\n\n    afterEach(async () => {\n      await queryInterface.dropAllTables()\n    })\n\n    it('should fix table foreign key constraints', async () => {\n      // Create tables with missing foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses\n      await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID UNIQUE PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`), `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`));')\n      await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')\n      await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')\n      await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`), `deviceId` UUID REFERENCES `devices` (`id`), `libraryId` UUID REFERENCES `libraries` (`id`));')\n      await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID UNIQUE PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`));')\n      await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')\n\n      //\n      // Validate that foreign key constraints are missing\n      //\n      let libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`)\n      expect(libraryItemsForeignKeys).to.have.deep.members([\n        { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },\n        { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }\n      ])\n\n      let feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`)\n      expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])\n\n      let mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`)\n      expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])\n\n      let playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`)\n      expect(playbackSessionForeignKeys).to.deep.equal([\n        { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },\n        { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },\n        { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }\n      ])\n\n      let playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`)\n      expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])\n\n      let mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`)\n      expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])\n\n      //\n      // Insert test data into tables\n      //\n      await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n      await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }])\n\n      await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n      await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n      await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n\n      //\n      // Query data before migration\n      //\n      const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')\n      const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;')\n      const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')\n      const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')\n      const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')\n      const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')\n\n      //\n      // Run migration\n      //\n      await up({ context: { queryInterface, logger: Logger } })\n\n      //\n      // Validate that foreign key constraints are updated\n      //\n      libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`)\n      expect(libraryItemsForeignKeys).to.have.deep.members([\n        { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },\n        { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }\n      ])\n\n      feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`)\n      expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }])\n\n      mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`)\n      expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }])\n\n      playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`)\n      expect(playbackSessionForeignKeys).to.deep.equal([\n        { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },\n        { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },\n        { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }\n      ])\n\n      playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`)\n      expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }])\n\n      mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`)\n      expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }])\n\n      //\n      // Validate that data is not changed\n      //\n      const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')\n      expect(libraryItemsAfter).to.deep.equal(libraryItems)\n\n      const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;')\n      expect(feedsAfter).to.deep.equal(feeds)\n\n      const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')\n      expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares)\n\n      const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')\n      expect(playbackSessionsAfter).to.deep.equal(playbackSessions)\n\n      const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')\n      expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems)\n\n      const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')\n      expect(mediaProgressesAfter).to.deep.equal(mediaProgresses)\n    })\n\n    it('should keep correct table foreign key constraints', async () => {\n      // Create tables with correct foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses\n      await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')\n      await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')\n      await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')\n      await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `deviceId` UUID REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')\n      await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);')\n      await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);')\n\n      //\n      // Insert test data into tables\n      //\n      await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n      await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }])\n\n      await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n      await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n      await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])\n      await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])\n\n      //\n      // Query data before migration\n      //\n      const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')\n      const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;')\n      const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')\n      const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')\n      const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')\n      const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')\n\n      await up({ context: { queryInterface, logger: Logger } })\n\n      expect(loggerInfoStub.callCount).to.equal(14)\n      expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.17.3 migration] Updating libraryItems constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.17.3 migration] No changes needed for libraryItems constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.17.3 migration] Updating feeds constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.17.3 migration] No changes needed for feeds constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.17.3 migration] Updating mediaItemShares constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaItemShares constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.17.3 migration] Updating playbackSessions constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.17.3 migration] No changes needed for playbackSessions constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(9).calledWith(sinon.match('[2.17.3 migration] Updating playlistMediaItems constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(10).calledWith(sinon.match('[2.17.3 migration] No changes needed for playlistMediaItems constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(11).calledWith(sinon.match('[2.17.3 migration] Updating mediaProgresses constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(12).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaProgresses constraints'))).to.be.true\n      expect(loggerInfoStub.getCall(13).calledWith(sinon.match('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints'))).to.be.true\n\n      //\n      // Validate that data is not changed\n      //\n      const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')\n      expect(libraryItemsAfter).to.deep.equal(libraryItems)\n\n      const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;')\n      expect(feedsAfter).to.deep.equal(feeds)\n\n      const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')\n      expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares)\n\n      const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')\n      expect(playbackSessionsAfter).to.deep.equal(playbackSessions)\n\n      const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')\n      expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems)\n\n      const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')\n      expect(mediaProgressesAfter).to.deep.equal(mediaProgresses)\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst { up, down } = require('../../../server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris')\nconst { Sequelize } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\ndescribe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => {\n  let queryInterface, logger, context\n\n  beforeEach(() => {\n    queryInterface = {\n      sequelize: {\n        query: sinon.stub()\n      }\n    }\n    logger = {\n      info: sinon.stub(),\n      error: sinon.stub()\n    }\n    context = { queryInterface, logger }\n  })\n\n  describe('up', () => {\n    it('should add authOpenIDSubfolderForRedirectURLs if OIDC is enabled', async () => {\n      queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: ['openid'] }) }]])\n      queryInterface.sequelize.query.onSecondCall().resolves()\n\n      await up({ context })\n\n      expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true\n      expect(queryInterface.sequelize.query.calledTwice).to.be.true\n      expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = \"server-settings\";')).to.be.true\n      expect(\n        queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = \"server-settings\";', {\n          replacements: {\n            value: JSON.stringify({ authActiveAuthMethods: ['openid'], authOpenIDSubfolderForRedirectURLs: '' })\n          }\n        })\n      ).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true\n    })\n\n    it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => {\n      queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: [] }) }]])\n\n      await up({ context })\n\n      expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] OIDC is not enabled, no action required')).to.be.true\n      expect(queryInterface.sequelize.query.calledOnce).to.be.true\n      expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = \"server-settings\";')).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true\n    })\n\n    it('should throw an error if server settings cannot be parsed', async () => {\n      queryInterface.sequelize.query.onFirstCall().resolves([[{ value: 'invalid json' }]])\n\n      try {\n        await up({ context })\n      } catch (error) {\n        expect(queryInterface.sequelize.query.calledOnce).to.be.true\n        expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = \"server-settings\";')).to.be.true\n        expect(logger.error.calledWith('[2.17.4 migration] Error parsing server settings:')).to.be.true\n        expect(error).to.be.instanceOf(Error)\n      }\n    })\n\n    it('should throw an error if server settings are not found', async () => {\n      queryInterface.sequelize.query.onFirstCall().resolves([[]])\n\n      try {\n        await up({ context })\n      } catch (error) {\n        expect(queryInterface.sequelize.query.calledOnce).to.be.true\n        expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = \"server-settings\";')).to.be.true\n        expect(logger.error.calledWith('[2.17.4 migration] Server settings not found')).to.be.true\n        expect(error).to.be.instanceOf(Error)\n      }\n    })\n  })\n\n  describe('down', () => {\n    it('should remove authOpenIDSubfolderForRedirectURLs if it exists', async () => {\n      queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authOpenIDSubfolderForRedirectURLs: '' }) }]])\n      queryInterface.sequelize.query.onSecondCall().resolves()\n\n      await down({ context })\n\n      expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true\n      expect(queryInterface.sequelize.query.calledTwice).to.be.true\n      expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = \"server-settings\";')).to.be.true\n      expect(\n        queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = \"server-settings\";', {\n          replacements: {\n            value: JSON.stringify({})\n          }\n        })\n      ).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true\n    })\n\n    it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => {\n      queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({}) }]])\n\n      await down({ context })\n\n      expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true\n      expect(queryInterface.sequelize.query.calledOnce).to.be.true\n      expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = \"server-settings\";')).to.be.true\n      expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst { up, down } = require('../../../server/migrations/v2.17.5-remove-host-from-feed-urls')\nconst { Sequelize, DataTypes } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\nconst defineModels = (sequelize) => {\n  const Feeds = sequelize.define('Feeds', {\n    id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },\n    feedUrl: { type: DataTypes.STRING },\n    imageUrl: { type: DataTypes.STRING },\n    siteUrl: { type: DataTypes.STRING },\n    serverAddress: { type: DataTypes.STRING }\n  })\n\n  const FeedEpisodes = sequelize.define('FeedEpisodes', {\n    id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },\n    feedId: { type: DataTypes.UUID },\n    siteUrl: { type: DataTypes.STRING },\n    enclosureUrl: { type: DataTypes.STRING }\n  })\n\n  return { Feeds, FeedEpisodes }\n}\n\ndescribe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => {\n  let queryInterface, logger, context\n  let sequelize\n  let Feeds, FeedEpisodes\n  const feed1Id = '00000000-0000-4000-a000-000000000001'\n  const feed2Id = '00000000-0000-4000-a000-000000000002'\n  const feedEpisode1Id = '00000000-4000-a000-0000-000000000011'\n  const feedEpisode2Id = '00000000-4000-a000-0000-000000000012'\n  const feedEpisode3Id = '00000000-4000-a000-0000-000000000021'\n\n  before(async () => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    ;({ Feeds, FeedEpisodes } = defineModels(sequelize))\n    await sequelize.sync()\n  })\n\n  after(async () => {\n    await sequelize.close()\n  })\n\n  beforeEach(async () => {\n    // Reset tables before each test\n    await Feeds.destroy({ where: {}, truncate: true })\n    await FeedEpisodes.destroy({ where: {}, truncate: true })\n\n    logger = {\n      info: sinon.stub(),\n      error: sinon.stub()\n    }\n    context = { queryInterface, logger }\n  })\n\n  describe('up', () => {\n    it('should remove serverAddress from URLs in Feeds and FeedEpisodes tables', async () => {\n      await Feeds.bulkCreate([\n        { id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' },\n        { id: feed2Id, feedUrl: 'http://server2.com/feed2', imageUrl: 'http://server2.com/img2', siteUrl: 'http://server2.com/site2', serverAddress: 'http://server2.com' }\n      ])\n\n      await FeedEpisodes.bulkCreate([\n        { id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' },\n        { id: feedEpisode2Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode12', enclosureUrl: 'http://server1.com/enclosure12' },\n        { id: feedEpisode3Id, feedId: feed2Id, siteUrl: 'http://server2.com/episode21', enclosureUrl: 'http://server2.com/enclosure21' }\n      ])\n\n      await up({ context })\n      const feeds = await Feeds.findAll({ raw: true })\n      const feedEpisodes = await FeedEpisodes.findAll({ raw: true })\n\n      expect(logger.info.calledWith('[2.17.5 migration] UPGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true\n      expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from Feeds table URLs')).to.be.true\n\n      expect(feeds[0].feedUrl).to.equal('/feed1')\n      expect(feeds[0].imageUrl).to.equal('/img1')\n      expect(feeds[0].siteUrl).to.equal('/site1')\n      expect(feeds[1].feedUrl).to.equal('/feed2')\n      expect(feeds[1].imageUrl).to.equal('/img2')\n      expect(feeds[1].siteUrl).to.equal('/site2')\n\n      expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from Feeds table URLs')).to.be.true\n      expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from FeedEpisodes table URLs')).to.be.true\n\n      expect(feedEpisodes[0].siteUrl).to.equal('/episode11')\n      expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11')\n      expect(feedEpisodes[1].siteUrl).to.equal('/episode12')\n      expect(feedEpisodes[1].enclosureUrl).to.equal('/enclosure12')\n      expect(feedEpisodes[2].siteUrl).to.equal('/episode21')\n      expect(feedEpisodes[2].enclosureUrl).to.equal('/enclosure21')\n\n      expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from FeedEpisodes table URLs')).to.be.true\n      expect(logger.info.calledWith('[2.17.5 migration] UPGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true\n    })\n\n    it('should handle null URLs in Feeds and FeedEpisodes tables', async () => {\n      await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: null, siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }])\n\n      await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: 'http://server1.com/enclosure11' }])\n\n      await up({ context })\n      const feeds = await Feeds.findAll({ raw: true })\n      const feedEpisodes = await FeedEpisodes.findAll({ raw: true })\n\n      expect(feeds[0].feedUrl).to.equal('/feed1')\n      expect(feeds[0].imageUrl).to.be.null\n      expect(feeds[0].siteUrl).to.equal('/site1')\n      expect(feedEpisodes[0].siteUrl).to.be.null\n      expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11')\n    })\n\n    it('should handle null serverAddress in Feeds table', async () => {\n      await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: null }])\n      await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }])\n\n      await up({ context })\n      const feeds = await Feeds.findAll({ raw: true })\n      const feedEpisodes = await FeedEpisodes.findAll({ raw: true })\n\n      expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1')\n      expect(feeds[0].imageUrl).to.equal('http://server1.com/img1')\n      expect(feeds[0].siteUrl).to.equal('http://server1.com/site1')\n      expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11')\n      expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11')\n    })\n  })\n\n  describe('down', () => {\n    it('should add serverAddress back to URLs in Feeds and FeedEpisodes tables', async () => {\n      await Feeds.bulkCreate([\n        { id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: 'http://server1.com' },\n        { id: feed2Id, feedUrl: '/feed2', imageUrl: '/img2', siteUrl: '/site2', serverAddress: 'http://server2.com' }\n      ])\n\n      await FeedEpisodes.bulkCreate([\n        { id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' },\n        { id: feedEpisode2Id, feedId: feed1Id, siteUrl: '/episode12', enclosureUrl: '/enclosure12' },\n        { id: feedEpisode3Id, feedId: feed2Id, siteUrl: '/episode21', enclosureUrl: '/enclosure21' }\n      ])\n\n      await down({ context })\n      const feeds = await Feeds.findAll({ raw: true })\n      const feedEpisodes = await FeedEpisodes.findAll({ raw: true })\n\n      expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true\n      expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to Feeds table URLs')).to.be.true\n\n      expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1')\n      expect(feeds[0].imageUrl).to.equal('http://server1.com/img1')\n      expect(feeds[0].siteUrl).to.equal('http://server1.com/site1')\n      expect(feeds[1].feedUrl).to.equal('http://server2.com/feed2')\n      expect(feeds[1].imageUrl).to.equal('http://server2.com/img2')\n      expect(feeds[1].siteUrl).to.equal('http://server2.com/site2')\n\n      expect(logger.info.calledWith('[2.17.5 migration] Added serverAddress back to Feeds table URLs')).to.be.true\n      expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to FeedEpisodes table URLs')).to.be.true\n\n      expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11')\n      expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11')\n      expect(feedEpisodes[1].siteUrl).to.equal('http://server1.com/episode12')\n      expect(feedEpisodes[1].enclosureUrl).to.equal('http://server1.com/enclosure12')\n      expect(feedEpisodes[2].siteUrl).to.equal('http://server2.com/episode21')\n      expect(feedEpisodes[2].enclosureUrl).to.equal('http://server2.com/enclosure21')\n\n      expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true\n    })\n\n    it('should handle null URLs in Feeds and FeedEpisodes tables', async () => {\n      await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: null, siteUrl: '/site1', serverAddress: 'http://server1.com' }])\n      await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: '/enclosure11' }])\n\n      await down({ context })\n      const feeds = await Feeds.findAll({ raw: true })\n      const feedEpisodes = await FeedEpisodes.findAll({ raw: true })\n\n      expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1')\n      expect(feeds[0].imageUrl).to.be.null\n      expect(feeds[0].siteUrl).to.equal('http://server1.com/site1')\n      expect(feedEpisodes[0].siteUrl).to.be.null\n      expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11')\n    })\n\n    it('should handle null serverAddress in Feeds table', async () => {\n      await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: null }])\n      await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }])\n\n      await down({ context })\n      const feeds = await Feeds.findAll({ raw: true })\n      const feedEpisodes = await FeedEpisodes.findAll({ raw: true })\n\n      expect(feeds[0].feedUrl).to.equal('/feed1')\n      expect(feeds[0].imageUrl).to.equal('/img1')\n      expect(feeds[0].siteUrl).to.equal('/site1')\n      expect(feedEpisodes[0].siteUrl).to.equal('/episode11')\n      expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11')\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.17.6-share-add-isdownloadable.test.js",
    "content": "const chai = require('chai')\nconst sinon = require('sinon')\nconst { expect } = chai\n\nconst { DataTypes } = require('sequelize')\n\nconst { up, down } = require('../../../server/migrations/v2.17.6-share-add-isdownloadable')\n\ndescribe('Migration v2.17.6-share-add-isDownloadable', () => {\n  let queryInterface, logger\n\n  beforeEach(() => {\n    queryInterface = {\n      addColumn: sinon.stub().resolves(),\n      removeColumn: sinon.stub().resolves(),\n      tableExists: sinon.stub().resolves(true),\n      describeTable: sinon.stub().resolves({ isDownloadable: undefined }),\n      sequelize: {\n        Sequelize: {\n          DataTypes: {\n            BOOLEAN: DataTypes.BOOLEAN\n          }\n        }\n      }\n    }\n\n    logger = {\n      info: sinon.stub(),\n      error: sinon.stub()\n    }\n  })\n\n  describe('up', () => {\n    it('should add the isDownloadable column to mediaItemShares table', async () => {\n      await up({ context: { queryInterface, logger } })\n\n      expect(queryInterface.addColumn.calledOnce).to.be.true\n      expect(\n        queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', {\n          type: DataTypes.BOOLEAN,\n          defaultValue: false,\n          allowNull: false\n        })\n      ).to.be.true\n\n      expect(logger.info.calledWith('[2.17.6 migration] UPGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true\n      expect(logger.info.calledWith('[2.17.6 migration] Adding isDownloadable column to mediaItemShares table')).to.be.true\n      expect(logger.info.calledWith('[2.17.6 migration] Added isDownloadable column to mediaItemShares table')).to.be.true\n      expect(logger.info.calledWith('[2.17.6 migration] UPGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true\n    })\n  })\n\n  describe('down', () => {\n    it('should remove the isDownloadable column from mediaItemShares table', async () => {\n      queryInterface.describeTable.resolves({ isDownloadable: true })\n\n      await down({ context: { queryInterface, logger } })\n\n      expect(queryInterface.removeColumn.calledOnce).to.be.true\n      expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true\n\n      expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true\n      expect(logger.info.calledWith('[2.17.6 migration] Removing isDownloadable column from mediaItemShares table')).to.be.true\n      expect(logger.info.calledWith('[2.17.6 migration] Removed isDownloadable column from mediaItemShares table')).to.be.true\n      expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.19.1-copy-title-to-library-items.test.js",
    "content": "const chai = require('chai')\nconst sinon = require('sinon')\nconst { expect } = chai\n\nconst { DataTypes, Sequelize } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\nconst { up, down } = require('../../../server/migrations/v2.19.1-copy-title-to-library-items')\n\ndescribe('Migration v2.19.1-copy-title-to-library-items', () => {\n  let sequelize\n  let queryInterface\n  let loggerInfoStub\n\n  beforeEach(async () => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    loggerInfoStub = sinon.stub(Logger, 'info')\n\n    await queryInterface.createTable('books', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      title: { type: DataTypes.STRING, allowNull: true },\n      titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true }\n    })\n\n    await queryInterface.createTable('libraryItems', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      libraryId: { type: DataTypes.INTEGER, allowNull: false },\n      mediaType: { type: DataTypes.STRING, allowNull: false },\n      mediaId: { type: DataTypes.INTEGER, allowNull: false },\n      createdAt: { type: DataTypes.DATE, allowNull: false }\n    })\n\n    await queryInterface.bulkInsert('books', [\n      { id: 1, title: 'The Book 1', titleIgnorePrefix: 'Book 1, The' },\n      { id: 2, title: 'Book 2', titleIgnorePrefix: 'Book 2' }\n    ])\n\n    await queryInterface.bulkInsert('libraryItems', [\n      { id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },\n      { id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' }\n    ])\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('up', () => {\n    it('should copy title and titleIgnorePrefix to libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')\n      expect(libraryItems).to.deep.equal([\n        { id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, title: 'The Book 1', titleIgnorePrefix: 'Book 1, The', createdAt: '2025-01-01 00:00:00.000 +00:00' },\n        { id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, title: 'Book 2', titleIgnorePrefix: 'Book 2', createdAt: '2025-01-02 00:00:00.000 +00:00' }\n      ])\n    })\n\n    it('should add index on title to libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`)\n      expect(count).to.equal(1)\n    })\n\n    it('should add trigger to books.title to update libraryItems.title', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title'`)\n      expect(count).to.equal(1)\n    })\n\n    it('should add index on titleIgnorePrefix to libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`)\n      expect(count).to.equal(1)\n    })\n\n    it('should add trigger to books.titleIgnorePrefix to update libraryItems.titleIgnorePrefix', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix'`)\n      expect(count).to.equal(1)\n    })\n\n    it('should add index on createdAt to libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_created_at'`)\n      expect(count).to.equal(1)\n    })\n  })\n\n  describe('down', () => {\n    it('should remove title and titleIgnorePrefix from libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')\n      expect(libraryItems).to.deep.equal([\n        { id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },\n        { id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' }\n      ])\n    })\n\n    it('should remove title trigger from books', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should remove titleIgnorePrefix trigger from books', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should remove index on titleIgnorePrefix from libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should remove index on title from libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should remove index on createdAt from libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_created_at'`)\n      expect(count).to.equal(0)\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.19.4-improve-podcast-queries.test.js",
    "content": "const chai = require('chai')\nconst sinon = require('sinon')\nconst { expect } = chai\n\nconst { DataTypes, Sequelize } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\nconst { up, down } = require('../../../server/migrations/v2.19.4-improve-podcast-queries')\n\ndescribe('Migration v2.19.4-improve-podcast-queries', () => {\n  let sequelize\n  let queryInterface\n  let loggerInfoStub\n\n  beforeEach(async () => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    loggerInfoStub = sinon.stub(Logger, 'info')\n\n    await queryInterface.createTable('libraryItems', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      mediaId: { type: DataTypes.INTEGER, allowNull: false },\n      title: { type: DataTypes.STRING, allowNull: true },\n      titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true }\n    })\n    await queryInterface.createTable('podcasts', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      title: { type: DataTypes.STRING, allowNull: false },\n      titleIgnorePrefix: { type: DataTypes.STRING, allowNull: false }\n    })\n\n    await queryInterface.createTable('podcastEpisodes', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      podcastId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'podcasts', key: 'id', onDelete: 'CASCADE' } }\n    })\n\n    await queryInterface.createTable('mediaProgresses', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      userId: { type: DataTypes.INTEGER, allowNull: false },\n      mediaItemId: { type: DataTypes.INTEGER, allowNull: false },\n      mediaItemType: { type: DataTypes.STRING, allowNull: false },\n      isFinished: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }\n    })\n\n    await queryInterface.bulkInsert('libraryItems', [\n      { id: 1, mediaId: 1, title: null, titleIgnorePrefix: null },\n      { id: 2, mediaId: 2, title: null, titleIgnorePrefix: null }\n    ])\n\n    await queryInterface.bulkInsert('podcasts', [\n      { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n      { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n    ])\n\n    await queryInterface.bulkInsert('podcastEpisodes', [\n      { id: 1, podcastId: 1 },\n      { id: 2, podcastId: 1 },\n      { id: 3, podcastId: 2 }\n    ])\n\n    await queryInterface.bulkInsert('mediaProgresses', [\n      { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },\n      { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },\n      { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },\n      { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },\n      { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },\n      { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },\n      { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },\n      { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }\n    ])\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('up', () => {\n    it('should add numEpisodes column to podcasts', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')\n      expect(podcasts).to.deep.equal([\n        { id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n\n      // Make sure podcastEpisodes are not affected due to ON DELETE CASCADE\n      const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes')\n      expect(podcastEpisodes).to.deep.equal([\n        { id: 1, podcastId: 1 },\n        { id: 2, podcastId: 1 },\n        { id: 3, podcastId: 2 }\n      ])\n    })\n\n    it('should add podcastId column to mediaProgresses', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')\n      expect(mediaProgresses).to.deep.equal([\n        { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },\n        { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },\n        { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 },\n        { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },\n        { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },\n        { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 },\n        { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 },\n        { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 }\n      ])\n    })\n\n    it('should copy title and titleIgnorePrefix from podcasts to libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n    })\n\n    it('should add trigger to update title in libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)\n      expect(count).to.equal(1)\n    })\n\n    it('should add trigger to update titleIgnorePrefix in libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)\n      expect(count).to.equal(1)\n    })\n\n    it('should be idempotent', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')\n      expect(podcasts).to.deep.equal([\n        { id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n\n      const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')\n      expect(mediaProgresses).to.deep.equal([\n        { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },\n        { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },\n        { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 },\n        { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },\n        { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },\n        { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 },\n        { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 },\n        { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 }\n      ])\n\n      const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n\n      const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)\n      expect(count1).to.equal(1)\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)\n      expect(count2).to.equal(1)\n    })\n  })\n\n  describe('down', () => {\n    it('should remove numEpisodes column from podcasts', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      try {\n        await down({ context: { queryInterface, logger: Logger } })\n      } catch (error) {\n        console.log(error)\n      }\n\n      const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')\n      expect(podcasts).to.deep.equal([\n        { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n\n      // Make sure podcastEpisodes are not affected due to ON DELETE CASCADE\n      const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes')\n      expect(podcastEpisodes).to.deep.equal([\n        { id: 1, podcastId: 1 },\n        { id: 2, podcastId: 1 },\n        { id: 3, podcastId: 2 }\n      ])\n    })\n\n    it('should remove podcastId column from mediaProgresses', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')\n      expect(mediaProgresses).to.deep.equal([\n        { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },\n        { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },\n        { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },\n        { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },\n        { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },\n        { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },\n        { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },\n        { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }\n      ])\n    })\n\n    it('should remove trigger to update title in libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should remove trigger to update titleIgnorePrefix in libraryItems', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should be idempotent', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')\n      expect(podcasts).to.deep.equal([\n        { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n\n      const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')\n      expect(mediaProgresses).to.deep.equal([\n        { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },\n        { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },\n        { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },\n        { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },\n        { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },\n        { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },\n        { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },\n        { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }\n      ])\n\n      const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },\n        { id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }\n      ])\n\n      const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)\n      expect(count1).to.equal(0)\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)\n      expect(count2).to.equal(0)\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/migrations/v2.20.0-improve-author-sort-queries.test.js",
    "content": "const chai = require('chai')\nconst sinon = require('sinon')\nconst { expect } = chai\n\nconst { DataTypes, Sequelize } = require('sequelize')\nconst Logger = require('../../../server/Logger')\n\nconst { up, down } = require('../../../server/migrations/v2.20.0-improve-author-sort-queries')\n\nconst normalizeWhitespaceAndBackticks = (str) => str.replace(/\\s+/g, ' ').trim().replace(/`/g, '')\n\ndescribe('Migration v2.20.0-improve-author-sort-queries', () => {\n  let sequelize\n  let queryInterface\n  let loggerInfoStub\n\n  beforeEach(async () => {\n    sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })\n    queryInterface = sequelize.getQueryInterface()\n    loggerInfoStub = sinon.stub(Logger, 'info')\n\n    await queryInterface.createTable('libraryItems', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      mediaId: { type: DataTypes.INTEGER, allowNull: false },\n      mediaType: { type: DataTypes.STRING, allowNull: false },\n      libraryId: { type: DataTypes.INTEGER, allowNull: false }\n    })\n\n    await queryInterface.createTable('authors', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      name: { type: DataTypes.STRING, allowNull: false },\n      lastFirst: { type: DataTypes.STRING, allowNull: false }\n    })\n\n    await queryInterface.createTable('bookAuthors', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      bookId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'libraryItems', key: 'id', onDelete: 'CASCADE' } },\n      authorId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'authors', key: 'id', onDelete: 'CASCADE' } },\n      createdAt: { type: DataTypes.DATE, allowNull: false }\n    })\n\n    await queryInterface.createTable('podcastEpisodes', {\n      id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },\n      publishedAt: { type: DataTypes.DATE, allowNull: true }\n    })\n\n    await queryInterface.bulkInsert('libraryItems', [\n      { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 },\n      { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 }\n    ])\n\n    await queryInterface.bulkInsert('authors', [\n      { id: 1, name: 'John Doe', lastFirst: 'Doe, John' },\n      { id: 2, name: 'Jane Smith', lastFirst: 'Smith, Jane' },\n      { id: 3, name: 'John Smith', lastFirst: 'Smith, John' }\n    ])\n\n    await queryInterface.bulkInsert('bookAuthors', [\n      { id: 1, bookId: 1, authorId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' },\n      { id: 2, bookId: 2, authorId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' },\n      { id: 3, bookId: 1, authorId: 3, createdAt: '2024-12-31 00:00:00.000 +00:00' }\n    ])\n\n    await queryInterface.bulkInsert('podcastEpisodes', [\n      { id: 1, publishedAt: '2025-01-01 00:00:00.000 +00:00' },\n      { id: 2, publishedAt: '2025-01-02 00:00:00.000 +00:00' },\n      { id: 3, publishedAt: '2025-01-03 00:00:00.000 +00:00' }\n    ])\n  })\n\n  afterEach(() => {\n    sinon.restore()\n  })\n\n  describe('up', () => {\n    it('should add the authorNamesFirstLast and authorNamesLastFirst columns to the libraryItems table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const libraryItems = await queryInterface.describeTable('libraryItems')\n      expect(libraryItems.authorNamesFirstLast).to.exist\n      expect(libraryItems.authorNamesLastFirst).to.exist\n    })\n\n    it('should populate the authorNamesFirstLast and authorNamesLastFirst columns with the author names for each libraryItem', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe', authorNamesLastFirst: 'Smith, John, Doe, John' },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }\n      ])\n    })\n\n    it('should create triggers to update the authorNamesFirstLast and authorNamesLastFirst columns when the corresponding bookAuthors and authors records are updated', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)\n      expect(count).to.equal(1)\n\n      const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)\n      expect(normalizeWhitespaceAndBackticks(sql)).to.equal(\n        normalizeWhitespaceAndBackticks(`\n          CREATE TRIGGER update_library_items_author_names_on_book_authors_insert\n            AFTER insert ON bookAuthors\n            FOR EACH ROW\n            BEGIN\n              UPDATE libraryItems\n                SET (authorNamesFirstLast, authorNamesLastFirst) = (\n                  SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC)\n                  FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId\n                  WHERE bookAuthors.bookId = NEW.bookId\n                )\n              WHERE mediaId = NEW.bookId;\n            END\n        `)\n      )\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)\n      expect(count2).to.equal(1)\n\n      const [[{ sql: sql2 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)\n      expect(normalizeWhitespaceAndBackticks(sql2)).to.equal(\n        normalizeWhitespaceAndBackticks(`\n          CREATE TRIGGER update_library_items_author_names_on_book_authors_delete\n            AFTER delete ON bookAuthors\n            FOR EACH ROW\n            BEGIN\n              UPDATE libraryItems\n                SET (authorNamesFirstLast, authorNamesLastFirst) = (\n                  SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC)\n                  FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId\n                  WHERE bookAuthors.bookId = OLD.bookId\n                )\n              WHERE mediaId = OLD.bookId;\n            END\n        `)\n      )\n\n      const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)\n      expect(count3).to.equal(1)\n\n      const [[{ sql: sql3 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)\n      expect(normalizeWhitespaceAndBackticks(sql3)).to.equal(\n        normalizeWhitespaceAndBackticks(`\n          CREATE TRIGGER update_library_items_author_names_on_authors_update\n            AFTER UPDATE OF name ON authors\n            FOR EACH ROW\n            BEGIN\n              UPDATE libraryItems\n                SET (authorNamesFirstLast, authorNamesLastFirst) = (\n                  SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC)\n                  FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId\n                  WHERE bookAuthors.bookId = libraryItems.mediaId\n                )\n              WHERE mediaId IN (SELECT bookId FROM bookAuthors WHERE authorId = NEW.id);\n            END\n        `)\n      )\n    })\n\n    it('should create indexes on the authorNamesFirstLast and authorNamesLastFirst columns', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)\n      expect(count).to.equal(1)\n\n      const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)\n      expect(normalizeWhitespaceAndBackticks(sql)).to.equal(\n        normalizeWhitespaceAndBackticks(`\n          CREATE INDEX library_items_library_id_media_type_author_names_first_last ON libraryItems (libraryId, mediaType, authorNamesFirstLast COLLATE NOCASE)\n        `)\n      )\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)\n      expect(count2).to.equal(1)\n\n      const [[{ sql: sql2 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)\n      expect(normalizeWhitespaceAndBackticks(sql2)).to.equal(\n        normalizeWhitespaceAndBackticks(`\n          CREATE INDEX library_items_library_id_media_type_author_names_last_first ON libraryItems (libraryId, mediaType, authorNamesLastFirst COLLATE NOCASE)\n        `)\n      )\n    })\n\n    it('should trigger after update on authors', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      // update author name\n      await queryInterface.sequelize.query(`UPDATE authors SET (name, lastFirst) = ('John Wayne', 'Wayne, John') WHERE id = 1`)\n\n      // check that the libraryItems table was updated\n      const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Wayne', authorNamesLastFirst: 'Smith, John, Wayne, John' },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }\n      ])\n    })\n\n    it('should trigger after insert on bookAuthors', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      // insert a new author\n      await queryInterface.sequelize.query(`INSERT INTO authors (id, name, lastFirst) VALUES (4, 'John Wayne', 'Wayne, John')`)\n\n      // insert a new bookAuthor\n      await queryInterface.sequelize.query(`INSERT INTO bookAuthors (id, bookId, authorId, createdAt) VALUES (4, 1, 4, '2025-01-04 00:00:00.000 +00:00')`)\n\n      // check that the libraryItems table was updated\n      const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe, John Wayne', authorNamesLastFirst: 'Smith, John, Doe, John, Wayne, John' },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }\n      ])\n    })\n\n    it('should trigger after delete on bookAuthors', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      // delete a bookAuthor\n      await queryInterface.sequelize.query(`DELETE FROM bookAuthors WHERE id = 1`)\n\n      // check that the libraryItems table was updated\n      const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith', authorNamesLastFirst: 'Smith, John' },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }\n      ])\n    })\n\n    it('should add an index on publishedAt to the podcastEpisodes table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)\n      expect(count).to.equal(1)\n\n      const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)\n      expect(normalizeWhitespaceAndBackticks(sql)).to.equal(\n        normalizeWhitespaceAndBackticks(`\n          CREATE INDEX podcast_episodes_published_at ON podcastEpisodes (publishedAt)\n        `)\n      )\n    })\n\n    it('should be idempotent', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await up({ context: { queryInterface, logger: Logger } })\n\n      const libraryItemsTable = await queryInterface.describeTable('libraryItems')\n      expect(libraryItemsTable.authorNamesFirstLast).to.exist\n      expect(libraryItemsTable.authorNamesLastFirst).to.exist\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)\n      expect(count).to.equal(1)\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)\n      expect(count2).to.equal(1)\n\n      const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)\n      expect(count3).to.equal(1)\n\n      const [[{ count: count4 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)\n      expect(count4).to.equal(1)\n\n      const [[{ count: count5 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)\n      expect(count5).to.equal(1)\n\n      const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)\n      expect(count6).to.equal(1)\n\n      const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe', authorNamesLastFirst: 'Smith, John, Doe, John' },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' }\n      ])\n    })\n  })\n\n  describe('down', () => {\n    it('should remove the authorNamesFirstLast and authorNamesLastFirst columns from the libraryItems table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const libraryItemsTable = await queryInterface.describeTable('libraryItems')\n      expect(libraryItemsTable.authorNamesFirstLast).to.not.exist\n      expect(libraryItemsTable.authorNamesLastFirst).to.not.exist\n\n      const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 }\n      ])\n    })\n\n    it('should remove the triggers from the libraryItems table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)\n      expect(count).to.equal(0)\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)\n      expect(count2).to.equal(0)\n\n      const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)\n      expect(count3).to.equal(0)\n    })\n\n    it('should remove the indexes from the libraryItems table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)\n      expect(count).to.equal(0)\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)\n      expect(count2).to.equal(0)\n    })\n\n    it('should remove the index on publishedAt from the podcastEpisodes table', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)\n      expect(count).to.equal(0)\n    })\n\n    it('should be idempotent', async () => {\n      await up({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n      await down({ context: { queryInterface, logger: Logger } })\n\n      const libraryItemsTable = await queryInterface.describeTable('libraryItems')\n      expect(libraryItemsTable.authorNamesFirstLast).to.not.exist\n      expect(libraryItemsTable.authorNamesLastFirst).to.not.exist\n\n      const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`)\n      expect(libraryItems).to.deep.equal([\n        { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 },\n        { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 }\n      ])\n\n      const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`)\n      expect(count).to.equal(0)\n\n      const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`)\n      expect(count2).to.equal(0)\n\n      const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`)\n      expect(count3).to.equal(0)\n\n      const [[{ count: count4 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`)\n      expect(count4).to.equal(0)\n\n      const [[{ count: count5 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`)\n      expect(count5).to.equal(0)\n\n      const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`)\n      expect(count6).to.equal(0)\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/objects/TrackProgressMonitor.test.js",
    "content": "const chai = require('chai')\nconst sinon = require('sinon')\nconst TrackProgressMonitor = require('../../../server/objects/TrackProgressMonitor')\n\nconst expect = chai.expect\n\ndescribe('TrackProgressMonitor', () => {\n  let trackDurations\n  let trackStartedCallback\n  let progressCallback\n  let trackFinishedCallback\n  let monitor\n\n  beforeEach(() => {\n    trackDurations = [10, 40, 50]\n    trackStartedCallback = sinon.spy()\n    progressCallback = sinon.spy()\n    trackFinishedCallback = sinon.spy()\n  })\n\n  it('should initialize correctly', () => {\n    monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)\n\n    expect(monitor.trackDurations).to.deep.equal(trackDurations)\n    expect(monitor.totalDuration).to.equal(100)\n    expect(monitor.trackStartedCallback).to.equal(trackStartedCallback)\n    expect(monitor.progressCallback).to.equal(progressCallback)\n    expect(monitor.trackFinishedCallback).to.equal(trackFinishedCallback)\n    expect(monitor.currentTrackIndex).to.equal(0)\n    expect(monitor.cummulativeProgress).to.equal(0)\n    expect(monitor.currentTrackPercentage).to.equal(10)\n    expect(monitor.numTracks).to.equal(trackDurations.length)\n    expect(monitor.allTracksFinished).to.be.false\n  })\n\n  it('should update the progress', () => {\n    monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)\n    monitor.update(5)\n\n    expect(monitor.currentTrackIndex).to.equal(0)\n    expect(monitor.cummulativeProgress).to.equal(0)\n    expect(monitor.currentTrackPercentage).to.equal(10)\n    expect(trackStartedCallback.calledOnceWithExactly(0)).to.be.true\n    expect(progressCallback.calledOnceWithExactly(0, 50, 5)).to.be.true\n    expect(trackFinishedCallback.notCalled).to.be.true\n  })\n\n  it('should update the progress multiple times on the same track', () => {\n    monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)\n    monitor.update(5)\n    monitor.update(7)\n\n    expect(monitor.currentTrackIndex).to.equal(0)\n    expect(monitor.cummulativeProgress).to.equal(0)\n    expect(monitor.currentTrackPercentage).to.equal(10)\n    expect(trackStartedCallback.calledOnceWithExactly(0)).to.be.true\n    expect(progressCallback.calledTwice).to.be.true\n    expect(progressCallback.calledWithExactly(0, 50, 5)).to.be.true\n    expect(progressCallback.calledWithExactly(0, 70, 7)).to.be.true\n    expect(trackFinishedCallback.notCalled).to.be.true\n  })\n\n  it('should update the progress multiple times on different tracks', () => {\n    monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)\n    monitor.update(5)\n    monitor.update(20)\n\n    expect(monitor.currentTrackIndex).to.equal(1)\n    expect(monitor.cummulativeProgress).to.equal(10)\n    expect(monitor.currentTrackPercentage).to.equal(40)\n    expect(trackStartedCallback.calledTwice).to.be.true\n    expect(trackStartedCallback.calledWithExactly(0)).to.be.true\n    expect(trackStartedCallback.calledWithExactly(1)).to.be.true\n    expect(progressCallback.calledTwice).to.be.true\n    expect(progressCallback.calledWithExactly(0, 50, 5)).to.be.true\n    expect(progressCallback.calledWithExactly(1, 25, 20)).to.be.true\n    expect(trackFinishedCallback.calledOnceWithExactly(0)).to.be.true\n  })\n\n  it('should finish all tracks', () => {\n    monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)\n    monitor.finish()\n\n    expect(monitor.allTracksFinished).to.be.true\n    expect(trackStartedCallback.calledThrice).to.be.true\n    expect(trackFinishedCallback.calledThrice).to.be.true\n    expect(progressCallback.notCalled).to.be.true\n    expect(trackStartedCallback.calledWithExactly(0)).to.be.true\n    expect(trackFinishedCallback.calledWithExactly(0)).to.be.true\n    expect(trackStartedCallback.calledWithExactly(1)).to.be.true\n    expect(trackFinishedCallback.calledWithExactly(1)).to.be.true\n    expect(trackStartedCallback.calledWithExactly(2)).to.be.true\n    expect(trackFinishedCallback.calledWithExactly(2)).to.be.true\n  })\n})\n"
  },
  {
    "path": "test/server/providers/Audible.test.js",
    "content": "const Audible = require('../../../server/providers/Audible')\nconst { expect } = require('chai')\nconst sinon = require('sinon')\n\ndescribe('Audible', () => {\n  let audible;\n\n  beforeEach(() => {\n    audible = new Audible();\n  });\n\n  describe('cleanSeriesSequence', () => {\n    it('should return an empty string if sequence is falsy', () => {\n      const result = audible.cleanSeriesSequence('Series Name', null)\n      expect(result).to.equal('')\n    })\n\n    it('should return the sequence as is if it does not contain a number', () => {\n      const result = audible.cleanSeriesSequence('Series Name', 'part a')\n      expect(result).to.equal('part a')\n    })\n\n    it('should return the sequence as is if contains just a number', () => {\n      const result = audible.cleanSeriesSequence('Series Name', '2')\n      expect(result).to.equal('2')\n    })\n\n    it('should return the sequence as is if contains just a number with decimals', () => {\n      const result = audible.cleanSeriesSequence('Series Name', '2.3')\n      expect(result).to.equal('2.3')\n    })\n\n    it('should extract and return the first number from the sequence', () => {\n      const result = audible.cleanSeriesSequence('Series Name', 'Book 1')\n      expect(result).to.equal('1')\n    })\n\n    it('should extract and return the number with decimals from the sequence', () => {\n      const result = audible.cleanSeriesSequence('Series Name', 'Book 1.5')\n      expect(result).to.equal('1.5')\n    })\n    \n    it('should extract and return the number even if it has no leading zero', () => {\n      const result = audible.cleanSeriesSequence('Series Name', 'Book .5')\n      expect(result).to.equal('.5')\n    })\n  })\n})"
  },
  {
    "path": "test/server/utils/ffmpegHelpers.test.js",
    "content": "const { expect } = require('chai')\nconst sinon = require('sinon')\nconst fileUtils = require('../../../server/utils/fileUtils')\nconst fs = require('../../../server/libs/fsExtra')\nconst EventEmitter = require('events')\n\nconst { generateFFMetadata, addCoverAndMetadataToFile } = require('../../../server/utils/ffmpegHelpers')\n\nglobal.isWin = process.platform === 'win32'\n\ndescribe('generateFFMetadata', () => {\n  function createTestSetup() {\n    const metadata = {\n      title: 'My Audiobook',\n      artist: 'John Doe',\n      album: 'Best Audiobooks'\n    }\n\n    const chapters = [\n      { start: 0, end: 1000, title: 'Chapter 1' },\n      { start: 1000, end: 2000, title: 'Chapter 2' }\n    ]\n\n    return { metadata, chapters }\n  }\n\n  let metadata = null\n  let chapters = null\n  beforeEach(() => {\n    const input = createTestSetup()\n    metadata = input.metadata\n    chapters = input.chapters\n  })\n\n  it('should generate ffmetadata content with chapters', () => {\n    const result = generateFFMetadata(metadata, chapters)\n\n    expect(result).to.equal(';FFMETADATA1\\ntitle=My Audiobook\\nartist=John Doe\\nalbum=Best Audiobooks\\n\\n[CHAPTER]\\nTIMEBASE=1/1000\\nSTART=0\\nEND=1000000\\ntitle=Chapter 1\\n\\n[CHAPTER]\\nTIMEBASE=1/1000\\nSTART=1000000\\nEND=2000000\\ntitle=Chapter 2\\n')\n  })\n\n  it('should generate ffmetadata content without chapters', () => {\n    chapters = null\n\n    const result = generateFFMetadata(metadata, chapters)\n\n    expect(result).to.equal(';FFMETADATA1\\ntitle=My Audiobook\\nartist=John Doe\\nalbum=Best Audiobooks\\n')\n  })\n\n  it('should handle chapters with no title', () => {\n    chapters = [\n      { start: 0, end: 1000 },\n      { start: 1000, end: 2000 }\n    ]\n\n    const result = generateFFMetadata(metadata, chapters)\n\n    expect(result).to.equal(';FFMETADATA1\\ntitle=My Audiobook\\nartist=John Doe\\nalbum=Best Audiobooks\\n\\n[CHAPTER]\\nTIMEBASE=1/1000\\nSTART=0\\nEND=1000000\\n\\n[CHAPTER]\\nTIMEBASE=1/1000\\nSTART=1000000\\nEND=2000000\\n')\n  })\n\n  it('should handle metadata escaping special characters (=, ;, #,  and a newline)', () => {\n    metadata.title = 'My Audiobook; with = special # characters\\n'\n    chapters[0].title = 'Chapter #1'\n\n    const result = generateFFMetadata(metadata, chapters)\n\n    expect(result).to.equal(';FFMETADATA1\\ntitle=My Audiobook\\\\; with \\\\= special \\\\# characters\\\\\\n\\nartist=John Doe\\nalbum=Best Audiobooks\\n\\n[CHAPTER]\\nTIMEBASE=1/1000\\nSTART=0\\nEND=1000000\\ntitle=Chapter \\\\#1\\n\\n[CHAPTER]\\nTIMEBASE=1/1000\\nSTART=1000000\\nEND=2000000\\ntitle=Chapter 2\\n')\n  })\n})\n\ndescribe('addCoverAndMetadataToFile', () => {\n  function createTestSetup() {\n    const audioFilePath = '/path/to/audio/file.mp3'\n    const coverFilePath = '/path/to/cover/image.jpg'\n    const metadataFilePath = '/path/to/metadata/file.txt'\n    const track = 1\n    const mimeType = 'audio/mpeg'\n\n    const ffmpegStub = new EventEmitter()\n    ffmpegStub.input = sinon.stub().returnsThis()\n    ffmpegStub.outputOptions = sinon.stub().returnsThis()\n    ffmpegStub.output = sinon.stub().returnsThis()\n    ffmpegStub.input = sinon.stub().returnsThis()\n    ffmpegStub.run = sinon.stub().callsFake(() => {\n      ffmpegStub.emit('end')\n    })\n    const copyStub = sinon.stub().resolves()\n    const fsRemoveStub = sinon.stub(fs, 'remove').resolves()\n\n    return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, copyStub, fsRemoveStub }\n  }\n\n  let audioFilePath = null\n  let coverFilePath = null\n  let metadataFilePath = null\n  let track = null\n  let mimeType = null\n  let ffmpegStub = null\n  let copyStub = null\n  let fsRemoveStub = null\n  beforeEach(() => {\n    const input = createTestSetup()\n    audioFilePath = input.audioFilePath\n    coverFilePath = input.coverFilePath\n    metadataFilePath = input.metadataFilePath\n    track = input.track\n    mimeType = input.mimeType\n    ffmpegStub = input.ffmpegStub\n    copyStub = input.copyStub\n    fsRemoveStub = input.fsRemoveStub\n  })\n\n  it('should add cover image and metadata to audio file', async () => {\n    // Act\n    await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)\n\n    // Assert\n    expect(ffmpegStub.input.calledThrice).to.be.true\n    expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)\n    expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)\n    expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)\n\n    expect(ffmpegStub.outputOptions.callCount).to.equal(4)\n    expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])\n    expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])\n    expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])\n    expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])\n\n    expect(ffmpegStub.output.calledOnce).to.be.true\n    expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n\n    expect(ffmpegStub.run.calledOnce).to.be.true\n\n    expect(copyStub.calledOnce).to.be.true\n    expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n    expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')\n    expect(fsRemoveStub.calledOnce).to.be.true\n    expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n\n    // Restore the stub\n    sinon.restore()\n  })\n\n  it('should handle missing cover image', async () => {\n    // Arrange\n    coverFilePath = null\n\n    // Act\n    await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)\n\n    // Assert\n    expect(ffmpegStub.input.calledTwice).to.be.true\n    expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)\n    expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)\n\n    expect(ffmpegStub.outputOptions.callCount).to.equal(4)\n    expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])\n    expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])\n    expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])\n    expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 0:v?'])\n\n    expect(ffmpegStub.output.calledOnce).to.be.true\n    expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n\n    expect(ffmpegStub.run.calledOnce).to.be.true\n\n    expect(copyStub.callCount).to.equal(1)\n    expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n    expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')\n    expect(fsRemoveStub.calledOnce).to.be.true\n    expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n\n    // Restore the stub\n    sinon.restore()\n  })\n\n  it('should handle error during ffmpeg execution', async () => {\n    // Arrange\n    ffmpegStub.run = sinon.stub().callsFake(() => {\n      ffmpegStub.emit('error', new Error('FFmpeg error'))\n    })\n\n    // Act\n    try {\n      await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)\n      expect.fail('Expected an error to be thrown')\n    } catch (error) {\n      // Assert\n      expect(error.message).to.equal('FFmpeg error')\n    }\n\n    // Assert\n    expect(ffmpegStub.input.calledThrice).to.be.true\n    expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)\n    expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)\n    expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)\n\n    expect(ffmpegStub.outputOptions.callCount).to.equal(4)\n    expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])\n    expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])\n    expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])\n    expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])\n\n    expect(ffmpegStub.output.calledOnce).to.be.true\n    expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')\n\n    expect(ffmpegStub.run.calledOnce).to.be.true\n\n    expect(copyStub.called).to.be.false\n    expect(fsRemoveStub.called).to.be.false\n\n    // Restore the stub\n    sinon.restore()\n  })\n\n  it('should handle m4b embedding', async () => {\n    // Arrange\n    mimeType = 'audio/mp4'\n    audioFilePath = '/path/to/audio/file.m4b'\n\n    // Act\n    await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)\n\n    // Assert\n    expect(ffmpegStub.input.calledThrice).to.be.true\n    expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)\n    expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)\n    expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)\n\n    expect(ffmpegStub.outputOptions.callCount).to.equal(4)\n    expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])\n    expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])\n    expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4'])\n    expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])\n\n    expect(ffmpegStub.output.calledOnce).to.be.true\n    expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')\n\n    expect(ffmpegStub.run.calledOnce).to.be.true\n\n    expect(copyStub.calledOnce).to.be.true\n    expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')\n    expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')\n    expect(fsRemoveStub.calledOnce).to.be.true\n    expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')\n\n    // Restore the stub\n    sinon.restore()\n  })\n})\n"
  },
  {
    "path": "test/server/utils/fileUtils.test.js",
    "content": "const chai = require('chai')\nconst expect = chai.expect\nconst sinon = require('sinon')\nconst fileUtils = require('../../../server/utils/fileUtils')\nconst fs = require('fs')\nconst Logger = require('../../../server/Logger')\n\ndescribe('fileUtils', () => {\n  it('shouldIgnoreFile', () => {\n    global.isWin = process.platform === 'win32'\n\n    const testCases = [\n      { path: 'test.txt', expected: null },\n      { path: 'folder/test.mp3', expected: null },\n      { path: 'normal/path/file.m4b', expected: null },\n      { path: 'test.txt.part', expected: '.part file' },\n      { path: 'test.txt.tmp', expected: '.tmp file' },\n      { path: 'test.txt.crdownload', expected: '.crdownload file' },\n      { path: 'test.txt.download', expected: '.download file' },\n      { path: 'test.txt.bak', expected: '.bak file' },\n      { path: 'test.txt.old', expected: '.old file' },\n      { path: 'test.txt.temp', expected: '.temp file' },\n      { path: 'test.txt.tempfile', expected: '.tempfile file' },\n      { path: 'test.txt.tempfile~', expected: '.tempfile~ file' },\n      { path: '.gitignore', expected: 'dotfile' },\n      { path: 'folder/.hidden', expected: 'dotfile' },\n      { path: '.git/config', expected: 'dotpath' },\n      { path: 'path/.hidden/file.txt', expected: 'dotpath' },\n      { path: '@eaDir', expected: '@eaDir directory' },\n      { path: 'folder/@eaDir', expected: '@eaDir directory' },\n      { path: 'path/@eaDir/file.txt', expected: '@eaDir directory' },\n      { path: '.hidden/test.tmp', expected: 'dotpath' },\n      { path: '@eaDir/test.part', expected: '@eaDir directory' }\n    ]\n\n    testCases.forEach(({ path, expected }) => {\n      const result = fileUtils.shouldIgnoreFile(path)\n      expect(result).to.equal(expected)\n    })\n  })\n\n  describe('recurseFiles', () => {\n    let readdirStub, realpathStub, statStub\n\n    beforeEach(() => {\n      global.isWin = process.platform === 'win32'\n\n      // Mock file structure with normalized paths\n      const mockDirContents = new Map([\n        ['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'ignoremenot.mp3', 'temp.mp3.tmp']],\n        ['/test/subfolder', ['file2.m4b']],\n        ['/test/ignoreme', ['.ignore', 'ignored.mp3']]\n      ])\n\n      const mockStats = new Map([\n        ['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1' }],\n        ['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }],\n        ['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],\n        ['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],\n        ['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }],\n        ['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }],\n        ['/test/ignoremenot.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }],\n        ['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '8' }]\n      ])\n\n      // Stub fs.readdir\n      readdirStub = sinon.stub(fs, 'readdir')\n      readdirStub.callsFake((path, callback) => {\n        const contents = mockDirContents.get(path)\n        if (contents) {\n          callback(null, contents)\n        } else {\n          callback(new Error(`ENOENT: no such file or directory, scandir '${path}'`))\n        }\n      })\n\n      // Stub fs.realpath\n      realpathStub = sinon.stub(fs, 'realpath')\n      realpathStub.callsFake((path, callback) => {\n        // Return normalized path\n        callback(null, fileUtils.filePathToPOSIX(path).replace(/\\/$/, ''))\n      })\n\n      // Stub fs.stat\n      statStub = sinon.stub(fs, 'stat')\n      statStub.callsFake((path, callback) => {\n        const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\\/$/, '')\n        const stats = mockStats.get(normalizedPath)\n        if (stats) {\n          callback(null, stats)\n        } else {\n          callback(new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`))\n        }\n      })\n\n      // Stub Logger\n      sinon.stub(Logger, 'debug')\n    })\n\n    afterEach(() => {\n      sinon.restore()\n    })\n\n    it('should return filtered file list', async () => {\n      const files = await fileUtils.recurseFiles('/test')\n      expect(files).to.be.an('array')\n      expect(files).to.have.lengthOf(3)\n\n      expect(files[0]).to.deep.equal({\n        name: 'file1.mp3',\n        path: 'file1.mp3',\n        reldirpath: '',\n        fullpath: '/test/file1.mp3',\n        extension: '.mp3',\n        deep: 0\n      })\n\n      expect(files[1]).to.deep.equal({\n        name: 'ignoremenot.mp3',\n        path: 'ignoremenot.mp3',\n        reldirpath: '',\n        fullpath: '/test/ignoremenot.mp3',\n        extension: '.mp3',\n        deep: 0\n      })\n\n      expect(files[2]).to.deep.equal({\n        name: 'file2.m4b',\n        path: 'subfolder/file2.m4b',\n        reldirpath: 'subfolder',\n        fullpath: '/test/subfolder/file2.m4b',\n        extension: '.m4b',\n        deep: 1\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/utils/parsers/parseNameString.test.js",
    "content": "const chai = require('chai')\nconst expect = chai.expect\nconst { parse, nameToLastFirst } = require('../../../../server/utils/parsers/parseNameString')\n\ndescribe('parseNameString', () => {\n  describe('parse', () => {\n    it('returns null if nameString is empty', () => {\n      const result = parse('')\n      expect(result).to.be.null\n    })\n\n    it('parses single name in First Last format', () => {\n      const result = parse('John Smith')\n      expect(result.names).to.deep.equal(['John Smith'])\n    })\n\n    it('parses single name in Last, First format', () => {\n      const result = parse('Smith, John')\n      expect(result.names).to.deep.equal(['John Smith'])\n    })\n\n    it('parses multiple names separated by &', () => {\n      const result = parse('John Smith & Jane Doe')\n      expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])\n    })\n\n    it('parses multiple names separated by \"and\"', () => {\n      const result = parse('John Smith and Jane Doe')\n      expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])\n    })\n\n    it('parses multiple names separated by comma and \"and\"', () => {\n      const result = parse('John Smith, Jane Doe and John Doe')\n      expect(result.names).to.deep.equal(['John Smith', 'Jane Doe', 'John Doe'])\n    })\n\n    it('parses multiple names separated by semicolon', () => {\n      const result = parse('John Smith; Jane Doe')\n      expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])\n    })\n\n    it('parses multiple names in Last, First format', () => {\n      const result = parse('Smith, John, Doe, Jane')\n      expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])\n    })\n\n    it('parses multiple names with single word name', () => {\n      const result = parse('John Smith, Jones, James Doe, Ludwig von Mises')\n      expect(result.names).to.deep.equal(['John Smith', 'Jones', 'James Doe', 'Ludwig von Mises'])\n    })\n\n    it('parses multiple names with single word name listed first (semicolon separator)', () => {\n      const result = parse('Jones; John Smith; James Doe; Ludwig von Mises')\n      expect(result.names).to.deep.equal(['Jones', 'John Smith', 'James Doe', 'Ludwig von Mises'])\n    })\n\n    it('handles names with suffixes', () => {\n      const result = parse('Smith, John Jr.')\n      expect(result.names).to.deep.equal(['John Jr. Smith'])\n    })\n\n    it('handles compound last names', () => {\n      const result = parse('von Mises, Ludwig')\n      expect(result.names).to.deep.equal(['Ludwig von Mises'])\n    })\n\n    it('handles Chinese/Japanese/Korean names', () => {\n      const result = parse('张三, 李四')\n      expect(result.names).to.deep.equal(['张三', '李四'])\n    })\n\n    it('removes duplicate names', () => {\n      const result = parse('John Smith & John Smith')\n      expect(result.names).to.deep.equal(['John Smith'])\n    })\n\n    it('filters out empty names', () => {\n      const result = parse('John Smith,')\n      expect(result.names).to.deep.equal(['John Smith'])\n    })\n  })\n\n  describe('nameToLastFirst', () => {\n    it('converts First Last to Last, First format', () => {\n      const result = nameToLastFirst('John Smith')\n      expect(result).to.equal('Smith, John')\n    })\n\n    it('returns last name only when no first name', () => {\n      const result = nameToLastFirst('Smith')\n      expect(result).to.equal('Smith')\n    })\n\n    it('handles names with middle names', () => {\n      const result = nameToLastFirst('John Middle Smith')\n      expect(result).to.equal('Smith, John Middle')\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/utils/parsers/parseNfoMetadata.test.js",
    "content": "const chai = require('chai')\nconst expect = chai.expect\nconst { parseNfoMetadata } = require('../../../../server/utils/parsers/parseNfoMetadata')\n\ndescribe('parseNfoMetadata', () => {\n  it('returns null if nfoText is empty', () => {\n    const result = parseNfoMetadata('')\n    expect(result).to.be.null\n  })\n\n  it('parses title', () => {\n    const nfoText = 'Title: The Great Gatsby'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.title).to.equal('The Great Gatsby')\n  })\n\n  it('parses title with subtitle', () => {\n    const nfoText = 'Title: The Great Gatsby: A Novel'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.title).to.equal('The Great Gatsby')\n    expect(result.subtitle).to.equal('A Novel')\n  })\n\n  it('does not split title on bare colon without space', () => {\n    const nfoText = 'Title: 10:04'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.title).to.equal('10:04')\n    expect(result.subtitle).to.be.undefined\n  })\n\n  it('does not split title on colon between words without space', () => {\n    const nfoText = 'Title: Making the Mission:Impossible Movies'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.title).to.equal('Making the Mission:Impossible Movies')\n    expect(result.subtitle).to.be.undefined\n  })\n\n  it('parses authors', () => {\n    const nfoText = 'Author: F. Scott Fitzgerald'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.authors).to.deep.equal(['F. Scott Fitzgerald'])\n  })\n\n  it('parses multiple authors', () => {\n    const nfoText = 'Author: John Steinbeck, Ernest Hemingway'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.authors).to.deep.equal(['John Steinbeck', 'Ernest Hemingway'])\n  })\n\n  it('parses narrators', () => {\n    const nfoText = 'Read by: Jake Gyllenhaal'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.narrators).to.deep.equal(['Jake Gyllenhaal'])\n  })\n\n  it('parses multiple narrators', () => {\n    const nfoText = 'Read by: Jake Gyllenhaal, Kate Winslet'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.narrators).to.deep.equal(['Jake Gyllenhaal', 'Kate Winslet'])\n  })\n\n  it('parses series name', () => {\n    const nfoText = 'Series Name: Harry Potter'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.series).to.equal('Harry Potter')\n  })\n\n  it('parses genre', () => {\n    const nfoText = 'Genre: Fiction'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.genres).to.deep.equal(['Fiction'])\n  })\n\n  it('parses multiple genres', () => {\n    const nfoText = 'Genre: Fiction, Historical'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.genres).to.deep.equal(['Fiction', 'Historical'])\n  })\n\n  it('parses tags', () => {\n    const nfoText = 'Tags: mystery, thriller'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.tags).to.deep.equal(['mystery', 'thriller'])\n  })\n\n  it('parses year from various date fields', () => {\n    const nfoText = 'Release Date: 2021-05-01\\nBook Copyright: 2021\\nRecording Copyright: 2021'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.publishedYear).to.equal('2021')\n  })\n\n  it('parses position in series', () => {\n    const nfoText = 'Position in Series: 2'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.sequence).to.equal('2')\n  })\n\n  it('parses abridged flag', () => {\n    const nfoText = 'Abridged: No'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.abridged).to.be.false\n\n    const nfoText2 = 'Unabridged: Yes'\n    const result2 = parseNfoMetadata(nfoText2)\n    expect(result2.abridged).to.be.false\n  })\n\n  it('parses publisher', () => {\n    const nfoText = 'Publisher: Penguin Random House'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.publisher).to.equal('Penguin Random House')\n  })\n\n  it('parses ASIN', () => {\n    const nfoText = 'ASIN: B08X5JZJLH'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.asin).to.equal('B08X5JZJLH')\n  })\n\n  it('parses language', () => {\n    const nfoText = 'Language: eng'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.language).to.equal('eng')\n\n    const nfoText2 = 'lang: deu'\n    const result2 = parseNfoMetadata(nfoText2)\n    expect(result2.language).to.equal('deu')\n  })\n\n  it('parses description', () => {\n    const nfoText = 'Book Description\\n=========\\nThis is a book.\\n It\\'s good'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.description).to.equal('This is a book.\\n It\\'s good')\n  })\n\n  it('no value', () => {\n    const nfoText = 'Title:'\n    const result = parseNfoMetadata(nfoText)\n    expect(result.title).to.be.undefined\n  })\n\n  it('no year value', () => {\n    const nfoText = \"Date:0\"\n    const result = parseNfoMetadata(nfoText)\n    expect(result.publishedYear).to.be.undefined\n  })\n})"
  },
  {
    "path": "test/server/utils/parsers/parseOpfMetadata.test.js",
    "content": "const chai = require('chai')\nconst expect = chai.expect\nconst { parseOpfMetadataXML } = require('../../../../server/utils/parsers/parseOpfMetadata')\n\ndescribe('parseOpfMetadata - test series', async () => {\n  it('test one series', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <metadata>\n                  <meta name=\"calibre:series\" content=\"Serie\"/>\n                  <meta name=\"calibre:series_index\" content=\"1\"/>\n              </metadata>\n            </package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([{ name: 'Serie', sequence: '1' }])\n  })\n\n  it('test more then 1 series - in correct order', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <metadata>\n                  <meta name=\"calibre:series\" content=\"Serie 1\"/>\n                  <meta name=\"calibre:series_index\" content=\"1\"/>\n                  <meta name=\"calibre:series\" content=\"Serie 2\"/>\n                  <meta name=\"calibre:series_index\" content=\"2\"/>\n                  <meta name=\"calibre:series\" content=\"Serie 3\"/>\n                  <meta name=\"calibre:series_index\" content=\"3\"/>\n              </metadata>\n            </package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([\n      { name: 'Serie 1', sequence: '1' },\n      { name: 'Serie 2', sequence: '2' },\n      { name: 'Serie 3', sequence: '3' }\n    ])\n  })\n\n  it('test messed order of series content and index', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <metadata>\n                  <meta name=\"calibre:series\" content=\"Serie 1\"/>\n                  <meta name=\"calibre:series_index\" content=\"1\"/>\n                  <meta name=\"calibre:series_index\" content=\"2\"/>\n                  <meta name=\"calibre:series_index\" content=\"3\"/>\n                  <meta name=\"calibre:series\" content=\"Serie 3\"/>\n              </metadata>\n            </package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([\n      { name: 'Serie 1', sequence: '1' },\n      { name: 'Serie 3', sequence: null }\n    ])\n  })\n\n  it('test different values of series content and index', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <metadata>\n                  <meta name=\"calibre:series\" content=\"Serie 1\"/>\n                  <meta name=\"calibre:series_index\"/>\n                  <meta name=\"calibre:series\" content=\"Serie 2\"/>\n                  <meta name=\"calibre:series_index\" content=\"abc\"/>\n                  <meta name=\"calibre:series\" content=\"Serie 3\"/>\n                  <meta name=\"calibre:series_index\" content=\"\"/>\n              </metadata>\n            </package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([\n      { name: 'Serie 1', sequence: null },\n      { name: 'Serie 2', sequence: 'abc' },\n      { name: 'Serie 3', sequence: null }\n    ])\n  })\n\n  it('test empty series content', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <metadata>\n                  <meta name=\"calibre:series\" content=\"\"/>\n                  <meta name=\"calibre:series_index\" content=\"\"/>\n              </metadata>\n            </package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([])\n  })\n\n  it('test series and index using an xml namespace', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <ns0:package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <ns0:metadata>\n                  <ns0:meta name=\"calibre:series\" content=\"Serie 1\"/>\n                  <ns0:meta name=\"calibre:series_index\" content=\"\"/>\n              </ns0:metadata>\n            </ns0:package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([{ name: 'Serie 1', sequence: null }])\n  })\n\n  it('test series and series index not directly underneath', async () => {\n    const opf = `\n            <?xml version='1.0' encoding='UTF-8'?>\n            <package xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xml:lang=\"en\" version=\"3.0\" unique-identifier=\"bookid\">\n              <metadata>\n                  <meta name=\"calibre:series\" content=\"Serie 1\"/>\n                  <meta name=\"calibre:title_sort\" content=\"Test Title\"/>\n                  <meta name=\"calibre:series_index\" content=\"1\"/>\n              </metadata>\n            </package>\n        `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.series).to.deep.equal([{ name: 'Serie 1', sequence: '1' }])\n  })\n\n  it('test author is parsed from refines meta', async () => {\n    const opf = `\n        <package version=\"3.0\" unique-identifier=\"uuid_id\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\" xmlns=\"http://www.idpf.org/2007/opf\">\n          <metadata>\n            <dc:creator id=\"create1\">Nevil Shute</dc:creator>\n            <meta refines=\"#create1\" property=\"role\" scheme=\"marc:relators\">aut</meta>\n            <meta refines=\"#create1\" property=\"file-as\">Shute, Nevil</meta>\n          </metadata>\n        </package>\n      `\n    const parsedOpf = await parseOpfMetadataXML(opf)\n    expect(parsedOpf.authors).to.deep.equal(['Nevil Shute'])\n  })\n})\n"
  },
  {
    "path": "test/server/utils/scandir.test.js",
    "content": "const Path = require('path')\nconst chai = require('chai')\nconst expect = chai.expect\nconst scanUtils = require('../../../server/utils/scandir')\n\ndescribe('scanUtils', async () => {\n  it('should properly group files into potential book library items', async () => {\n    global.isWin = process.platform === 'win32'\n    global.ServerSettings = {\n      scannerParseSubtitle: true\n    }\n\n    const filePaths = [\n      'randomfile.txt', // Should be ignored because it's not a book media file\n      'Book1.m4b', // Root single file audiobook\n      'Book2/audiofile.m4b',\n      'Book2/disk 001/audiofile.m4b',\n      'Book2/disk 002/audiofile.m4b',\n      'Author/Book3/audiofile.mp3',\n      'Author/Book3/Disc 1/audiofile.mp3',\n      'Author/Book3/Disc 2/audiofile.mp3',\n      'Author/Series/Book4/cover.jpg',\n      'Author/Series/Book4/CD1/audiofile.mp3',\n      'Author/Series/Book4/CD2/audiofile.mp3',\n      'Author/Series2/Book5/deeply/nested/cd 01/audiofile.mp3',\n      'Author/Series2/Book5/deeply/nested/cd 02/audiofile.mp3',\n      'Author/Series2/Book5/randomfile.js' // Should be ignored because it's not a book media file\n    ]\n\n    // Create fileItems to match the format of fileUtils.recurseFiles\n    const fileItems = []\n    for (const filePath of filePaths) {\n      const dirname = Path.dirname(filePath)\n      fileItems.push({\n        name: Path.basename(filePath),\n        reldirpath: dirname === '.' ? '' : dirname,\n        extension: Path.extname(filePath),\n        deep: filePath.split('/').length - 1\n      })\n    }\n\n    const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false)\n\n    expect(libraryItemGrouping).to.deep.equal({\n      'Book1.m4b': 'Book1.m4b',\n      Book2: ['audiofile.m4b', 'disk 001/audiofile.m4b', 'disk 002/audiofile.m4b'],\n      'Author/Book3': ['audiofile.mp3', 'Disc 1/audiofile.mp3', 'Disc 2/audiofile.mp3'],\n      'Author/Series/Book4': ['CD1/audiofile.mp3', 'CD2/audiofile.mp3', 'cover.jpg'],\n      'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3']\n    })\n  })\n})\n"
  },
  {
    "path": "test/server/utils/stringifySequeslizeQuery.test.js",
    "content": "const { expect } = require('chai')\nconst stringifySequelizeQuery = require('../../../server/utils/stringifySequelizeQuery')\nconst Sequelize = require('sequelize')\n\nclass DummyClass {}\n\ndescribe('stringifySequelizeQuery', () => {\n  it('should stringify a sequelize query containing an op', () => {\n    const query = {\n      where: {\n        name: 'John',\n        age: {\n          [Sequelize.Op.gt]: 20\n        }\n      }\n    }\n\n    const result = stringifySequelizeQuery(query)\n    expect(result).to.equal('{\"where\":{\"name\":\"John\",\"age\":{\"Symbol(gt)\":20}}}')\n  })\n\n  it('should stringify a sequelize query containing a literal', () => {\n    const query = {\n      order: [[Sequelize.literal('libraryItem.title'), 'ASC']]\n    }\n\n    const result = stringifySequelizeQuery(query)\n    expect(result).to.equal('{\"order\":{\"0\":{\"0\":{\"val\":\"libraryItem.title\"},\"1\":\"ASC\"}}}')\n  })\n\n  it('should stringify a sequelize query containing a class', () => {\n    const query = {\n      include: [\n        {\n          model: DummyClass\n        }\n      ]\n    }\n\n    const result = stringifySequelizeQuery(query)\n    expect(result).to.equal('{\"include\":{\"0\":{\"model\":\"DummyClass\"}}}')\n  })\n\n  it('should ignore non-class functions', () => {\n    const query = {\n      logging: (query) => console.log(query)\n    }\n\n    const result = stringifySequelizeQuery(query)\n    expect(result).to.equal('{}')\n  })\n})\n"
  }
]